a73x

16676784

Add implementation plans for dirty row rendering, font config, HiDPI, text coverage, and visible selection

a73x   2026-04-10 08:09

Commit message
Add implementation plans for dirty row rendering, font config, HiDPI, text coverage, and visible selection

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

docs/superpowers/plans/2026-04-08-dirty-row-rendering-implementation.md
Old New
@@ -0,0 +1,674 @@
1 # Dirty-Row Rendering 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:** Make terminal redraw cost proportional to changed rows by caching per-row instances and supporting partial instance-buffer uploads.
6
7 **Architecture:** Keep the existing single contiguous instance buffer and single draw call. Add CPU-side row and cursor caches in `src/main.zig`, use `ghostty-vt` dirty flags to decide what to rebuild, and extend `src/renderer.zig` with partial-range instance uploads plus full-upload fallback when buffer growth invalidates prior GPU contents.
8
9 **Tech Stack:** Zig 0.15, ghostty-vt render state dirty flags, Vulkan host-visible buffers, existing `renderer.Instance` pipeline.
10
11 ---
12
13 ## File Structure
14
15 - Modify: `src/main.zig`
16 - Add row-cache data structures, rebuild planning helpers, cursor-cache tracking, and the new redraw/update flow.
17 - Modify: `src/renderer.zig`
18 - Add partial instance-range upload support and tests for the buffer-growth fallback behavior.
19 - Test: `src/main.zig`
20 - Add tests for rebuild planning, packing offsets, cursor-only invalidation, and dirty-flag lifecycle.
21 - Test: `src/renderer.zig`
22 - Add tests for range calculations and fallback decisions without requiring a live Vulkan device.
23
24 ### Task 1: Add failing tests for dirty-row planning helpers
25
26 **Files:**
27 - Modify: `src/main.zig`
28 - Test: `src/main.zig`
29
30 - [ ] **Step 1: Write the failing tests**
31
32 ```zig
33 test "planRowRefresh requests full rebuild for full dirty state" {
34 const plan = planRowRefresh(.full, &.{ false, true, false }, .{
35 .cursor_changed = false,
36 .old_cursor_row = null,
37 .new_cursor_row = null,
38 });
39
40 try std.testing.expect(plan.full_rebuild);
41 try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
42 }
43
44 test "planRowRefresh selects only dirty rows for partial state" {
45 const plan = planRowRefresh(.partial, &.{ false, true, false, true }, .{
46 .cursor_changed = false,
47 .old_cursor_row = null,
48 .new_cursor_row = null,
49 });
50
51 try std.testing.expect(!plan.full_rebuild);
52 try std.testing.expect(plan.rows_to_rebuild.isSet(1));
53 try std.testing.expect(plan.rows_to_rebuild.isSet(3));
54 try std.testing.expect(!plan.rows_to_rebuild.isSet(0));
55 }
56
57 test "planRowRefresh handles cursor-only updates without unrelated rows" {
58 const plan = planRowRefresh(.partial, &.{ false, false, false }, .{
59 .cursor_changed = true,
60 .old_cursor_row = 1,
61 .new_cursor_row = 2,
62 });
63
64 try std.testing.expect(!plan.full_rebuild);
65 try std.testing.expect(plan.cursor_rebuild);
66 try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
67 }
68 ```
69
70 - [ ] **Step 2: Run test to verify it fails**
71
72 Run: `zig test src/main.zig`
73 Expected: FAIL with missing identifiers such as `planRowRefresh`.
74
75 - [ ] **Step 3: Write minimal helper types and planning implementation**
76
77 ```zig
78 const RowRefreshContext = struct {
79 cursor_changed: bool,
80 old_cursor_row: ?usize,
81 new_cursor_row: ?usize,
82 };
83
84 const RowRefreshPlan = struct {
85 full_rebuild: bool,
86 cursor_rebuild: bool,
87 rows_to_rebuild: std.StaticBitSet(256),
88 };
89
90 fn planRowRefresh(
91 dirty: vt.RenderDirty,
92 row_dirty: []const bool,
93 ctx: RowRefreshContext,
94 ) RowRefreshPlan {
95 var rows = std.StaticBitSet(256).initEmpty();
96 if (dirty == .full) {
97 return .{
98 .full_rebuild = true,
99 .cursor_rebuild = true,
100 .rows_to_rebuild = rows,
101 };
102 }
103
104 for (row_dirty, 0..) |is_dirty, i| {
105 if (is_dirty) rows.set(i);
106 }
107
108 return .{
109 .full_rebuild = false,
110 .cursor_rebuild = ctx.cursor_changed,
111 .rows_to_rebuild = rows,
112 };
113 }
114 ```
115
116 - [ ] **Step 4: Run test to verify it passes**
117
118 Run: `zig test src/main.zig`
119 Expected: PASS for the new planning tests.
120
121 - [ ] **Step 5: Commit**
122
123 ```bash
124 git add src/main.zig
125 git commit -m "Add dirty-row refresh planning helpers"
126 ```
127
128 ### Task 2: Add failing tests for row packing and layout invalidation
129
130 **Files:**
131 - Modify: `src/main.zig`
132 - Test: `src/main.zig`
133
134 - [ ] **Step 1: Write the failing tests**
135
136 ```zig
137 test "repackRowCaches assigns contiguous offsets" {
138 var rows = [_]RowInstanceCache{
139 .{ .instances = try makeTestInstances(std.testing.allocator, 2), .gpu_offset_instances = 99, .gpu_len_instances = 0 },
140 .{ .instances = try makeTestInstances(std.testing.allocator, 3), .gpu_offset_instances = 99, .gpu_len_instances = 0 },
141 };
142 defer for (&rows) |*row| row.instances.deinit(std.testing.allocator);
143
144 var packed: std.ArrayListUnmanaged(renderer.Instance) = .empty;
145 defer packed.deinit(std.testing.allocator);
146
147 const total = try repackRowCaches(std.testing.allocator, &packed, &rows, &.{});
148
149 try std.testing.expectEqual(@as(u32, 0), rows[0].gpu_offset_instances);
150 try std.testing.expectEqual(@as(u32, 2), rows[1].gpu_offset_instances);
151 try std.testing.expectEqual(@as(u32, 5), total);
152 }
153
154 test "updateLayoutDirty becomes true when row instance count changes" {
155 try std.testing.expect(markLayoutDirtyOnLenChange(2, 3));
156 try std.testing.expect(!markLayoutDirtyOnLenChange(3, 3));
157 }
158 ```
159
160 - [ ] **Step 2: Run test to verify it fails**
161
162 Run: `zig test src/main.zig`
163 Expected: FAIL with missing identifiers such as `repackRowCaches`.
164
165 - [ ] **Step 3: Write minimal packing helpers**
166
167 ```zig
168 fn markLayoutDirtyOnLenChange(old_len: usize, new_len: usize) bool {
169 return old_len != new_len;
170 }
171
172 fn repackRowCaches(
173 alloc: std.mem.Allocator,
174 packed: *std.ArrayListUnmanaged(renderer.Instance),
175 rows: []RowInstanceCache,
176 cursor_instances: []const renderer.Instance,
177 ) !u32 {
178 packed.clearRetainingCapacity();
179
180 var offset: u32 = 0;
181 for (rows) |*row| {
182 row.gpu_offset_instances = offset;
183 row.gpu_len_instances = @intCast(row.instances.items.len);
184 try packed.appendSlice(alloc, row.instances.items);
185 offset += @intCast(row.instances.items.len);
186 }
187
188 _ = cursor_instances;
189 return offset;
190 }
191 ```
192
193 - [ ] **Step 4: Run test to verify it passes**
194
195 Run: `zig test src/main.zig`
196 Expected: PASS for the new packing tests.
197
198 - [ ] **Step 5: Commit**
199
200 ```bash
201 git add src/main.zig
202 git commit -m "Add dirty-row packing helpers"
203 ```
204
205 ### Task 3: Add failing tests for renderer partial-upload fallback decisions
206
207 **Files:**
208 - Modify: `src/renderer.zig`
209 - Test: `src/renderer.zig`
210
211 - [ ] **Step 1: Write the failing tests**
212
213 ```zig
214 test "range upload falls back to full upload when capacity must grow" {
215 const decision = planInstanceUpload(.{
216 .current_capacity = 8,
217 .offset_instances = 6,
218 .write_len = 4,
219 });
220
221 try std.testing.expect(decision.needs_growth);
222 try std.testing.expect(decision.force_full_upload);
223 }
224
225 test "range upload stays partial when capacity is sufficient" {
226 const decision = planInstanceUpload(.{
227 .current_capacity = 16,
228 .offset_instances = 4,
229 .write_len = 3,
230 });
231
232 try std.testing.expect(!decision.needs_growth);
233 try std.testing.expect(!decision.force_full_upload);
234 }
235 ```
236
237 - [ ] **Step 2: Run test to verify it fails**
238
239 Run: `zig test src/renderer.zig`
240 Expected: FAIL with `planInstanceUpload` undefined.
241
242 - [ ] **Step 3: Write minimal planning implementation in renderer**
243
244 ```zig
245 const InstanceUploadRequest = struct {
246 current_capacity: u32,
247 offset_instances: u32,
248 write_len: u32,
249 };
250
251 const InstanceUploadDecision = struct {
252 needed_capacity: u32,
253 needs_growth: bool,
254 force_full_upload: bool,
255 };
256
257 fn planInstanceUpload(req: InstanceUploadRequest) InstanceUploadDecision {
258 const needed = req.offset_instances + req.write_len;
259 const needs_growth = needed > req.current_capacity;
260 return .{
261 .needed_capacity = needed,
262 .needs_growth = needs_growth,
263 .force_full_upload = needs_growth,
264 };
265 }
266 ```
267
268 - [ ] **Step 4: Run test to verify it passes**
269
270 Run: `zig test src/renderer.zig`
271 Expected: PASS for the new renderer planning tests.
272
273 - [ ] **Step 5: Commit**
274
275 ```bash
276 git add src/renderer.zig
277 git commit -m "Add instance upload planning helpers"
278 ```
279
280 ### Task 4: Implement row and cursor cache data structures in main
281
282 **Files:**
283 - Modify: `src/main.zig`
284 - Test: `src/main.zig`
285
286 - [ ] **Step 1: Write the failing tests**
287
288 ```zig
289 test "RenderCache resizeRows preserves existing row allocations" {
290 var cache = RenderCache.empty;
291 defer cache.deinit(std.testing.allocator);
292
293 try cache.resizeRows(std.testing.allocator, 3);
294 try std.testing.expectEqual(@as(usize, 3), cache.rows.len);
295
296 try cache.resizeRows(std.testing.allocator, 2);
297 try std.testing.expectEqual(@as(usize, 2), cache.rows.len);
298 }
299 ```
300
301 - [ ] **Step 2: Run test to verify it fails**
302
303 Run: `zig test src/main.zig`
304 Expected: FAIL with `RenderCache` undefined.
305
306 - [ ] **Step 3: Implement cache structs and lifecycle**
307
308 ```zig
309 const RowInstanceCache = struct {
310 instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
311 gpu_offset_instances: u32 = 0,
312 gpu_len_instances: u32 = 0,
313
314 fn deinit(self: *RowInstanceCache, alloc: std.mem.Allocator) void {
315 self.instances.deinit(alloc);
316 }
317 };
318
319 const RenderCache = struct {
320 rows: []RowInstanceCache = &.{},
321 cursor_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
322 packed_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
323 total_instance_count: u32 = 0,
324 layout_dirty: bool = true,
325
326 const empty: RenderCache = .{};
327
328 fn resizeRows(self: *RenderCache, alloc: std.mem.Allocator, row_count: usize) !void { ... }
329 fn deinit(self: *RenderCache, alloc: std.mem.Allocator) void { ... }
330 };
331 ```
332
333 - [ ] **Step 4: Run test to verify it passes**
334
335 Run: `zig test src/main.zig`
336 Expected: PASS for `RenderCache` lifecycle tests and prior helper tests.
337
338 - [ ] **Step 5: Commit**
339
340 ```bash
341 git add src/main.zig
342 git commit -m "Add render cache data structures"
343 ```
344
345 ### Task 5: Extract row rebuild logic from the current full-frame path
346
347 **Files:**
348 - Modify: `src/main.zig`
349 - Test: `src/main.zig`
350
351 - [ ] **Step 1: Write the failing test**
352
353 ```zig
354 test "rebuildRowInstances emits expected instances for a colored glyph row" {
355 // Reuse existing appendCellInstances expectations, but route through
356 // rebuildRowInstances into a RowInstanceCache.
357 }
358 ```
359
360 - [ ] **Step 2: Run test to verify it fails**
361
362 Run: `zig test src/main.zig`
363 Expected: FAIL because `rebuildRowInstances` does not exist.
364
365 - [ ] **Step 3: Implement minimal row rebuild helper**
366
367 ```zig
368 fn rebuildRowInstances(
369 alloc: std.mem.Allocator,
370 cache: *RowInstanceCache,
371 row_idx: u32,
372 row_cells: anytype,
373 face: *font.Face,
374 atlas: *font.Atlas,
375 baseline: u32,
376 default_bg: [4]f32,
377 ) !bool {
378 const old_len = cache.instances.items.len;
379 cache.instances.clearRetainingCapacity();
380 // Move the current per-row cell loop from runTerminal here.
381 return markLayoutDirtyOnLenChange(old_len, cache.instances.items.len);
382 }
383 ```
384
385 - [ ] **Step 4: Run test to verify it passes**
386
387 Run: `zig test src/main.zig`
388 Expected: PASS for the new row rebuild test and existing append-order tests.
389
390 - [ ] **Step 5: Commit**
391
392 ```bash
393 git add src/main.zig
394 git commit -m "Extract row instance rebuild logic"
395 ```
396
397 ### Task 6: Add cursor cache handling and tests
398
399 **Files:**
400 - Modify: `src/main.zig`
401 - Test: `src/main.zig`
402
403 - [ ] **Step 1: Write the failing tests**
404
405 ```zig
406 test "rebuildCursorInstances produces one cursor quad when visible" {
407 // Build a minimal cursor state and assert one instance is emitted.
408 }
409
410 test "cursor cache marks layout dirty when visibility changes instance count" {
411 try std.testing.expect(markLayoutDirtyOnLenChange(1, 0));
412 }
413 ```
414
415 - [ ] **Step 2: Run test to verify it fails**
416
417 Run: `zig test src/main.zig`
418 Expected: FAIL because `rebuildCursorInstances` is missing.
419
420 - [ ] **Step 3: Implement minimal cursor rebuild helper**
421
422 ```zig
423 fn rebuildCursorInstances(
424 alloc: std.mem.Allocator,
425 cursor_instances: *std.ArrayListUnmanaged(renderer.Instance),
426 cursor: vt.Terminal.RenderCursor,
427 cell_w: u32,
428 cell_h: u32,
429 cursor_uv: font.GlyphUV,
430 ) !bool { ... }
431 ```
432
433 - [ ] **Step 4: Run test to verify it passes**
434
435 Run: `zig test src/main.zig`
436 Expected: PASS for the new cursor-cache tests.
437
438 - [ ] **Step 5: Commit**
439
440 ```bash
441 git add src/main.zig
442 git commit -m "Add cursor cache rebuild logic"
443 ```
444
445 ### Task 7: Implement renderer partial-range uploads
446
447 **Files:**
448 - Modify: `src/renderer.zig`
449 - Test: `src/renderer.zig`
450
451 - [ ] **Step 1: Write the failing test**
452
453 ```zig
454 test "uploadInstanceRangeWrite computes byte offset from instance offset" {
455 const write = planInstanceRangeWrite(3, 2);
456 try std.testing.expectEqual(@as(vk.DeviceSize, 3 * @sizeOf(Instance)), write.byte_offset);
457 try std.testing.expectEqual(@as(vk.DeviceSize, 2 * @sizeOf(Instance)), write.byte_len);
458 }
459 ```
460
461 - [ ] **Step 2: Run test to verify it fails**
462
463 Run: `zig test src/renderer.zig`
464 Expected: FAIL because `planInstanceRangeWrite` is undefined.
465
466 - [ ] **Step 3: Implement partial-range upload support**
467
468 ```zig
469 const InstanceRangeWrite = struct {
470 byte_offset: vk.DeviceSize,
471 byte_len: vk.DeviceSize,
472 };
473
474 fn planInstanceRangeWrite(offset_instances: u32, len_instances: u32) InstanceRangeWrite {
475 return .{
476 .byte_offset = @as(vk.DeviceSize, offset_instances) * @sizeOf(Instance),
477 .byte_len = @as(vk.DeviceSize, len_instances) * @sizeOf(Instance),
478 };
479 }
480
481 pub fn uploadInstanceRange(
482 self: *Context,
483 offset_instances: u32,
484 instances: []const Instance,
485 ) !bool {
486 const decision = planInstanceUpload(.{
487 .current_capacity = self.instance_capacity,
488 .offset_instances = offset_instances,
489 .write_len = @intCast(instances.len),
490 });
491 if (decision.force_full_upload) return true;
492
493 const range = planInstanceRangeWrite(offset_instances, @intCast(instances.len));
494 const mapped = try self.vkd.mapMemory(self.device, self.instance_memory, range.byte_offset, range.byte_len, .{});
495 @memcpy(@as([*]Instance, @ptrCast(@alignCast(mapped)))[0..instances.len], instances);
496 self.vkd.unmapMemory(self.device, self.instance_memory);
497 return false;
498 }
499 ```
500
501 - [ ] **Step 4: Run test to verify it passes**
502
503 Run: `zig test src/renderer.zig`
504 Expected: PASS for the new range-write and upload-planning tests.
505
506 - [ ] **Step 5: Commit**
507
508 ```bash
509 git add src/renderer.zig
510 git commit -m "Add partial instance buffer uploads"
511 ```
512
513 ### Task 8: Integrate dirty-row cache flow into `runTerminal`
514
515 **Files:**
516 - Modify: `src/main.zig`
517 - Test: `src/main.zig`
518
519 - [ ] **Step 1: Write the failing test**
520
521 ```zig
522 test "applyRenderPlan requests full upload when layout changes" {
523 var cache = RenderCache.empty;
524 defer cache.deinit(std.testing.allocator);
525
526 const result = applyRenderPlan(.{
527 .layout_dirty = true,
528 .rows_rebuilt = 1,
529 .cursor_rebuilt = false,
530 });
531
532 try std.testing.expect(result.full_upload);
533 }
534 ```
535
536 - [ ] **Step 2: Run test to verify it fails**
537
538 Run: `zig test src/main.zig`
539 Expected: FAIL because `applyRenderPlan` is undefined.
540
541 - [ ] **Step 3: Implement the main-loop integration**
542
543 ```zig
544 // In runTerminal:
545 // 1. initialize RenderCache after grid creation
546 // 2. after term.snapshot(), compute RowRefreshPlan
547 // 3. rebuild only required rows/cursor
548 // 4. if layout dirty, repack + full upload
549 // 5. otherwise call uploadInstanceRange for changed rows/cursor
550 // 6. if uploadInstanceRange requests fallback, repack + full upload
551 // 7. clear dirty flags only after cache refresh succeeds
552 // 8. call drawCells with cache.total_instance_count
553 ```
554
555 - [ ] **Step 4: Run test to verify it passes**
556
557 Run: `zig test src/main.zig`
558 Expected: PASS for the new integration helper tests.
559
560 - [ ] **Step 5: Commit**
561
562 ```bash
563 git add src/main.zig src/renderer.zig
564 git commit -m "Use dirty-row render cache in terminal loop"
565 ```
566
567 ### Task 9: Verify dirty-flag lifecycle and resize behavior
568
569 **Files:**
570 - Modify: `src/main.zig`
571 - Test: `src/main.zig`
572
573 - [ ] **Step 1: Write the failing tests**
574
575 ```zig
576 test "clearConsumedDirtyFlags clears flags only after successful refresh" {
577 var rows = [_]bool{ true, false, true };
578 clearConsumedDirtyFlags(.partial, &rows, true);
579 try std.testing.expect(!rows[0]);
580 try std.testing.expect(!rows[2]);
581 }
582
583 test "resize invalidates cache layout for full repack" {
584 var cache = RenderCache.empty;
585 defer cache.deinit(std.testing.allocator);
586 cache.layout_dirty = false;
587 invalidateCacheForResize(&cache);
588 try std.testing.expect(cache.layout_dirty);
589 }
590 ```
591
592 - [ ] **Step 2: Run test to verify it fails**
593
594 Run: `zig test src/main.zig`
595 Expected: FAIL because helpers are undefined.
596
597 - [ ] **Step 3: Implement the helpers and wire resize invalidation**
598
599 ```zig
600 fn clearConsumedDirtyFlags(dirty: vt.RenderDirty, row_dirty: []bool, success: bool) void { ... }
601 fn invalidateCacheForResize(cache: *RenderCache) void {
602 cache.layout_dirty = true;
603 }
604 ```
605
606 - [ ] **Step 4: Run test to verify it passes**
607
608 Run: `zig test src/main.zig`
609 Expected: PASS for the dirty-flag lifecycle and resize invalidation tests.
610
611 - [ ] **Step 5: Commit**
612
613 ```bash
614 git add src/main.zig
615 git commit -m "Handle dirty flag clearing and resize invalidation"
616 ```
617
618 ### Task 10: Full verification
619
620 **Files:**
621 - Modify: none
622 - Test: `src/main.zig`, `src/renderer.zig`
623
624 - [ ] **Step 1: Run the full test suite**
625
626 Run: `zig build test`
627 Expected: PASS
628
629 - [ ] **Step 2: Run a manual responsiveness smoke test**
630
631 Run: `zig build run`
632 Expected:
633 - Terminal opens normally.
634 - Typing a single character does not trigger visible sluggishness.
635 - Cursor movement still renders correctly.
636 - Resize still redraws correctly.
637
638 - [ ] **Step 3: Run a scrolling/full-clear smoke test**
639
640 Run inside the terminal:
641
642 ```sh
643 yes "row" | head -n 200
644 clear
645 printf 'done\n'
646 ```
647
648 Expected:
649 - Scrolling remains correct.
650 - `clear` fully redraws the screen.
651 - Prompt remains usable afterward.
652
653 - [ ] **Step 4: Commit**
654
655 ```bash
656 git add src/main.zig src/renderer.zig
657 git commit -m "Verify dirty-row rendering implementation"
658 ```
659
660 ## Self-Review
661
662 - Spec coverage:
663 - Row caches: Tasks 4, 5, 8
664 - Partial redraw planning: Tasks 1, 8
665 - Full repack and offset packing: Tasks 2, 8
666 - Partial uploads and fallback on growth: Tasks 3, 7, 8
667 - Cursor-only handling: Task 6
668 - Dirty-flag lifecycle: Task 9
669 - Verification and smoke tests: Task 10
670 - Placeholder scan:
671 - The exact integration steps are listed explicitly in Task 8.
672 - No `TODO`/`TBD` markers remain.
673 - Type consistency:
674 - `RowInstanceCache`, `RenderCache`, `RowRefreshPlan`, `planInstanceUpload`, and `uploadInstanceRange` are defined before later tasks rely on them.
docs/superpowers/plans/2026-04-09-font-config-implementation.md
Old New
@@ -0,0 +1,242 @@
1 # Font Config 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:** Move terminal font family and font size into an `st`-style config module and use `Monaspace Argon` as the explicit configured font.
6
7 **Architecture:** Add a small `src/config.zig` module with compile-time constants, then thread that configuration into the existing Fontconfig lookup path and the main terminal startup path. Keep runtime behavior unchanged apart from explicit font selection and size sourcing, with no fallback logic.
8
9 **Tech Stack:** Zig 0.15, Fontconfig, FreeType, existing `font.zig` and `main.zig` startup path
10
11 ---
12
13 ## File Structure
14
15 - Create: `src/config.zig`
16 - Holds user-editable font defaults in one place.
17 - Modify: `src/font.zig`
18 - Reads the configured family and resolves it through Fontconfig without fallback.
19 - Modify: `src/main.zig`
20 - Uses configured font size in the normal terminal path and helper/demo paths that currently hardcode `16`.
21 - Test: `src/font.zig`
22 - Keeps lookup tests aligned with explicit configured-family behavior.
23 - Test: `src/main.zig`
24 - Keeps any font-size-dependent helper tests aligned with config-backed size use if needed.
25
26 ### Task 1: Add the ST-style config module
27
28 **Files:**
29 - Create: `src/config.zig`
30
31 - [ ] **Step 1: Add the config module**
32
33 ```zig
34 pub const font_family = "Monaspace Argon";
35 pub const font_size_px: u32 = 16;
36 ```
37
38 - [ ] **Step 2: Verify the file contents**
39
40 Run: `sed -n '1,40p' src/config.zig`
41 Expected:
42 - Shows only the two config constants.
43
44 - [ ] **Step 3: Commit**
45
46 ```bash
47 git add src/config.zig
48 git commit -m "Add terminal font config module"
49 ```
50
51 ### Task 2: Wire configured font family into Fontconfig lookup
52
53 **Files:**
54 - Modify: `src/font.zig`
55 - Test: `src/font.zig`
56
57 - [ ] **Step 1: Add the failing test update**
58
59 Replace the old generic lookup test with a configured-family test:
60
61 ```zig
62 test "lookupConfiguredFont returns a valid configured font path" {
63 var lookup = try lookupConfiguredFont(std.testing.allocator);
64 defer lookup.deinit(std.testing.allocator);
65
66 try std.testing.expect(lookup.path.len > 0);
67 }
68 ```
69
70 - [ ] **Step 2: Run test to verify it fails**
71
72 Run: `rm -rf /tmp/zig-global-cache-font-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-font-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-font-plan zig build test --summary all`
73 Expected:
74 - FAIL because `lookupConfiguredFont` is undefined or call sites still reference `lookupMonospace`.
75
76 - [ ] **Step 3: Implement the configured-family lookup**
77
78 Update imports and lookup code in `src/font.zig`:
79
80 ```zig
81 const config = @import("config");
82 ```
83
84 ```zig
85 pub fn lookupConfiguredFont(alloc: std.mem.Allocator) !FontLookup {
86 if (c.FcInit() == c.FcFalse) return error.FcInitFailed;
87
88 const pattern = c.FcPatternCreate() orelse return error.FcPatternCreate;
89 defer c.FcPatternDestroy(pattern);
90
91 _ = c.FcPatternAddString(pattern, c.FC_FAMILY, @ptrCast(config.font_family));
92 _ = c.FcPatternAddInteger(pattern, c.FC_WEIGHT, c.FC_WEIGHT_REGULAR);
93 _ = c.FcPatternAddInteger(pattern, c.FC_SLANT, c.FC_SLANT_ROMAN);
94
95 _ = c.FcConfigSubstitute(null, pattern, c.FcMatchPattern);
96 c.FcDefaultSubstitute(pattern);
97
98 var result: c.FcResult = undefined;
99 const matched = c.FcFontMatch(null, pattern, &result) orelse return error.FcFontMatchFailed;
100 defer c.FcPatternDestroy(matched);
101
102 var file_cstr: [*c]c.FcChar8 = null;
103 if (c.FcPatternGetString(matched, c.FC_FILE, 0, &file_cstr) != c.FcResultMatch) {
104 return error.FcGetFileFailed;
105 }
106
107 var index: c_int = 0;
108 _ = c.FcPatternGetInteger(matched, c.FC_INDEX, 0, &index);
109
110 const slice = std.mem.span(@as([*:0]const u8, @ptrCast(file_cstr)));
111 const dup = try alloc.dupeZ(u8, slice);
112 return .{ .path = dup, .index = index };
113 }
114 ```
115
116 Update existing tests and helpers that call `lookupMonospace` to call `lookupConfiguredFont` instead.
117
118 - [ ] **Step 4: Run test to verify it passes**
119
120 Run: `rm -rf /tmp/zig-global-cache-font-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-font-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-font-plan zig build test --summary all`
121 Expected:
122 - PASS for the font module tests and any dependent tests.
123
124 - [ ] **Step 5: Commit**
125
126 ```bash
127 git add src/font.zig src/main.zig build.zig
128 git commit -m "Resolve configured terminal font family"
129 ```
130
131 ### Task 3: Move font size to config-backed startup
132
133 **Files:**
134 - Modify: `src/main.zig`
135 - Modify: `src/font.zig`
136
137 - [ ] **Step 1: Add the failing test update**
138
139 Replace hardcoded `16` setup in the normal terminal path and demo helper path with config-backed size references:
140
141 ```zig
142 const config = @import("config");
143 ```
144
145 ```zig
146 const font_size: u32 = config.font_size_px;
147 ```
148
149 ```zig
150 var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
151 ```
152
153 - [ ] **Step 2: Run test to verify it fails**
154
155 Run: `rm -rf /tmp/zig-global-cache-size-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-size-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-size-plan zig build test --summary all`
156 Expected:
157 - FAIL until all hardcoded call sites are updated and the config module is imported where needed.
158
159 - [ ] **Step 3: Implement config-backed font size usage**
160
161 Update `src/main.zig`:
162
163 ```zig
164 const config = @import("config");
165 ```
166
167 Replace the startup hardcode:
168
169 ```zig
170 const font_size: u32 = config.font_size_px;
171 ```
172
173 Replace helper/demo face creation that currently uses `16`:
174
175 ```zig
176 var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
177 ```
178
179 Use `lookupConfiguredFont` in the same locations:
180
181 ```zig
182 var font_lookup = try font.lookupConfiguredFont(alloc);
183 ```
184
185 - [ ] **Step 4: Run test to verify it passes**
186
187 Run: `rm -rf /tmp/zig-global-cache-size-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-size-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-size-plan zig build test --summary all`
188 Expected:
189 - PASS with all tests green.
190
191 - [ ] **Step 5: Commit**
192
193 ```bash
194 git add src/main.zig src/font.zig src/config.zig
195 git commit -m "Read terminal font size from config"
196 ```
197
198 ### Task 4: Full verification
199
200 **Files:**
201 - Modify: none
202 - Test: `src/font.zig`, `src/main.zig`
203
204 - [ ] **Step 1: Run the full test suite**
205
206 Run: `rm -rf /tmp/zig-global-cache-font-verify && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-font-verify && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-font-verify zig build test --summary all`
207 Expected:
208 - PASS
209
210 - [ ] **Step 2: Run a build verification**
211
212 Run: `rm -rf /tmp/zig-global-cache-font-build && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-font-build && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-font-build zig build`
213 Expected:
214 - PASS
215
216 - [ ] **Step 3: Run a manual launch verification**
217
218 Run: `zig build run`
219 Expected:
220 - Terminal launches with `Monaspace Argon`.
221 - Terminal starts at configured font size.
222 - Startup fails loudly if the configured family is not available.
223
224 - [ ] **Step 4: Commit**
225
226 ```bash
227 git add src/config.zig src/font.zig src/main.zig
228 git commit -m "Verify configured terminal font defaults"
229 ```
230
231 ## Self-Review
232
233 - Spec coverage:
234 - ST-style config module: Task 1
235 - Explicit `Monaspace Argon` family: Task 2
236 - No fallback behavior: Task 2 and Task 4
237 - Config-backed font size: Task 3
238 - Validation with tests and manual launch: Task 4
239 - Placeholder scan:
240 - No `TODO`, `TBD`, or deferred implementation markers remain.
241 - Type consistency:
242 - The plan consistently uses `lookupConfiguredFont`, `config.font_family`, and `config.font_size_px`.
docs/superpowers/plans/2026-04-09-hidpi-support-implementation.md
Old New
@@ -0,0 +1,1148 @@
1 # HiDPI Support 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:** Make waystty render crisply on HiDPI Wayland outputs by binding `wl_output`, tracking per-surface buffer scale via `wl_surface.enter`/`leave`, rasterizing the font at the correct pixel size, and sizing the Vulkan swapchain in buffer pixels.
6
7 **Architecture:** A pure `ScaleTracker` struct owns the mapping from bound `wl_output`s to their advertised scales and the set of outputs the current surface has entered. `Connection` binds `wl_output` globals at registry time and drives the tracker on scale/done events. `Window` listens for `wl_surface.enter`/`leave` and exposes the tracker's computed `bufferScale()`. The main loop and the text‑compare loop both react to scale changes by (a) rebuilding `font.Face` at `px_size * scale`, (b) resetting the glyph atlas and render cache, (c) recreating the Vulkan swapchain at `surface_size * scale`, and (d) calling `wl_surface.set_buffer_scale(scale)`. Window dimensions are always surface‑coordinate; only the Vulkan extent, font rasterization size, and push‑constant `cell_size` switch to buffer pixels.
8
9 **Tech Stack:** Zig 0.15, `zig-wayland` (wl_compositor v6, wl_output v4), FreeType, Vulkan, existing `renderer.zig` / `font.zig` / `wayland.zig`.
10
11 ---
12
13 ## File Structure
14
15 - Create: `src/scale_tracker.zig`
16 - Pure data struct: adds/removes outputs, updates per-output scale, tracks entered outputs for one surface, and computes `max(scale)` across entered outputs (default 1). Fully unit-testable with no Wayland calls.
17 - Modify: `src/wayland.zig`
18 - Bind `wl_output` in `registryListener`; attach output listeners that push scale/done into the tracker.
19 - Add an `Output` struct (handle + proxy listener context).
20 - Extend `Window` with a pointer to the connection's `ScaleTracker` plus a generation counter incremented when `enter`/`leave` changes the effective scale.
21 - Add a `Window.bufferScale()` helper.
22 - Modify: `src/font.zig`
23 - Add `Atlas.reset()` that clears the cache + zeroes pixels + resets cursors + marks dirty.
24 - Add `Face.reinit()` that deinits freetype state and re-opens at a new `px_size`.
25 - Modify: `src/main.zig`
26 - Extract a `DisplayGeometry` helper (`{surface_w, surface_h, buffer_w, buffer_h, cell_w_px, cell_h_px, px_size}`) recomputed from the current scale.
27 - Add a `rebuildForScale()` helper shared by `runTerminal` and `runTextCoverageCompare`.
28 - Integrate scale changes into both loops' resize-handling blocks.
29 - Modify: `src/renderer.zig`
30 - No structural changes; `recreateSwapchain(width, height)` continues to take buffer‑pixel dimensions. (`drawCells` already receives `cell_size` via push constants.)
31 - Test: `src/scale_tracker.zig` — unit tests for the tracker.
32 - Test: `src/wayland.zig` — wiring smoke test that the `Output` add/remove path feeds the tracker when exercised manually (fake events).
33 - Test: `src/font.zig` — test `Atlas.reset()` and `Face.reinit()` behavior.
34 - Test: `src/main.zig` — test that `buildTextCoverageCompareScene` still produces the same grid after the refactor (no regression).
35
36 ### Commit cadence
37
38 One commit per task unless explicitly noted. Never squash visual‑verification work into the code commit.
39
40 ### Manual verification plan
41
42 Manual verification happens at the end of the plan on the real dual-monitor setup:
43 - Run `./zig-out/bin/waystty --text-compare` on DP‑5 (scale 1.0): text should be crisp (baseline regression check).
44 - Drag the same window to DP‑4 (Studio Display, scale 2.0): text should remain crisp (the feature).
45 - Run `./zig-out/bin/waystty` on DP‑4: terminal should be crisp.
46 - Drag between outputs while typing: no glyph corruption; brief re-layout is OK.
47
48 ---
49
50 ### Task 1: Pure ScaleTracker data struct + tests
51
52 **Files:**
53 - Create: `src/scale_tracker.zig`
54 - Test: `src/scale_tracker.zig` (inline tests)
55
56 - [ ] **Step 1: Write the failing tests**
57
58 Create `src/scale_tracker.zig` with the tests first (no implementation yet):
59
60 ```zig
61 const std = @import("std");
62
63 pub const ScaleTracker = struct {
64 // implementation added in Step 3
65 };
66
67 test "new tracker reports default scale of 1" {
68 var t = ScaleTracker.init(std.testing.allocator);
69 defer t.deinit();
70 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
71 }
72
73 test "entered output scale is reflected in bufferScale" {
74 var t = ScaleTracker.init(std.testing.allocator);
75 defer t.deinit();
76
77 try t.addOutput(1);
78 t.setOutputScale(1, 2);
79 try t.enterOutput(1);
80 try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
81 }
82
83 test "not-yet-entered output does not change bufferScale" {
84 var t = ScaleTracker.init(std.testing.allocator);
85 defer t.deinit();
86
87 try t.addOutput(7);
88 t.setOutputScale(7, 3);
89 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
90 }
91
92 test "bufferScale is max across entered outputs" {
93 var t = ScaleTracker.init(std.testing.allocator);
94 defer t.deinit();
95
96 try t.addOutput(1);
97 try t.addOutput(2);
98 t.setOutputScale(1, 1);
99 t.setOutputScale(2, 2);
100
101 try t.enterOutput(1);
102 try t.enterOutput(2);
103 try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
104 }
105
106 test "leaving an output drops its contribution" {
107 var t = ScaleTracker.init(std.testing.allocator);
108 defer t.deinit();
109
110 try t.addOutput(1);
111 try t.addOutput(2);
112 t.setOutputScale(1, 2);
113 t.setOutputScale(2, 3);
114 try t.enterOutput(1);
115 try t.enterOutput(2);
116 try std.testing.expectEqual(@as(i32, 3), t.bufferScale());
117
118 t.leaveOutput(2);
119 try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
120 }
121
122 test "removing an unknown output is a no-op" {
123 var t = ScaleTracker.init(std.testing.allocator);
124 defer t.deinit();
125 t.removeOutput(999);
126 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
127 }
128
129 test "removeOutput also removes it from entered set" {
130 var t = ScaleTracker.init(std.testing.allocator);
131 defer t.deinit();
132
133 try t.addOutput(5);
134 t.setOutputScale(5, 4);
135 try t.enterOutput(5);
136 try std.testing.expectEqual(@as(i32, 4), t.bufferScale());
137
138 t.removeOutput(5);
139 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
140 }
141
142 test "setOutputScale on unknown id is a no-op" {
143 var t = ScaleTracker.init(std.testing.allocator);
144 defer t.deinit();
145 t.setOutputScale(999, 5);
146 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
147 }
148 ```
149
150 - [ ] **Step 2: Register the module and run tests to verify they fail**
151
152 Add to `build.zig` so the test step picks up the new file. First, register a module (it'll also be imported by `wayland_mod` in Task 2). Insert after the `config_mod` block (around line 10):
153
154 ```zig
155 const scale_tracker_mod = b.createModule(.{
156 .root_source_file = b.path("src/scale_tracker.zig"),
157 .target = target,
158 .optimize = optimize,
159 });
160 ```
161
162 Then add a dedicated test module and register it with `test_step`. Insert after the `pty_tests` block (after line 102):
163
164 ```zig
165 // Test scale_tracker.zig
166 const scale_tracker_test_mod = b.createModule(.{
167 .root_source_file = b.path("src/scale_tracker.zig"),
168 .target = target,
169 .optimize = optimize,
170 });
171 const scale_tracker_tests = b.addTest(.{
172 .root_module = scale_tracker_test_mod,
173 });
174 test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step);
175 ```
176
177 Finally, wire the module into `wayland_mod` so Task 2 can `@import("scale_tracker")`. Add after `wayland_mod.linkSystemLibrary("xkbcommon", .{});` (around line 41):
178
179 ```zig
180 wayland_mod.addImport("scale_tracker", scale_tracker_mod);
181 ```
182
183 Then run:
184
185 ```bash
186 rm -rf /tmp/zig-global-cache-hidpi && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-hidpi && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
187 ```
188
189 Expected: FAIL — tests reference `ScaleTracker.init`, `bufferScale`, `addOutput`, `setOutputScale`, `enterOutput`, `leaveOutput`, `removeOutput` which are undefined.
190
191 - [ ] **Step 3: Implement ScaleTracker**
192
193 Replace the stub in `src/scale_tracker.zig` with:
194
195 ```zig
196 const std = @import("std");
197
198 pub const OutputId = u32;
199
200 pub const ScaleTracker = struct {
201 alloc: std.mem.Allocator,
202 scales: std.AutoHashMapUnmanaged(OutputId, i32),
203 entered: std.AutoHashMapUnmanaged(OutputId, void),
204
205 pub fn init(alloc: std.mem.Allocator) ScaleTracker {
206 return .{
207 .alloc = alloc,
208 .scales = .empty,
209 .entered = .empty,
210 };
211 }
212
213 pub fn deinit(self: *ScaleTracker) void {
214 self.scales.deinit(self.alloc);
215 self.entered.deinit(self.alloc);
216 }
217
218 pub fn addOutput(self: *ScaleTracker, id: OutputId) !void {
219 try self.scales.put(self.alloc, id, 1);
220 }
221
222 pub fn setOutputScale(self: *ScaleTracker, id: OutputId, scale: i32) void {
223 if (self.scales.getPtr(id)) |slot| slot.* = scale;
224 }
225
226 pub fn removeOutput(self: *ScaleTracker, id: OutputId) void {
227 _ = self.scales.remove(id);
228 _ = self.entered.remove(id);
229 }
230
231 pub fn enterOutput(self: *ScaleTracker, id: OutputId) !void {
232 try self.entered.put(self.alloc, id, {});
233 }
234
235 pub fn leaveOutput(self: *ScaleTracker, id: OutputId) void {
236 _ = self.entered.remove(id);
237 }
238
239 pub fn bufferScale(self: *const ScaleTracker) i32 {
240 var max_scale: i32 = 1;
241 var it = self.entered.iterator();
242 while (it.next()) |entry| {
243 const id = entry.key_ptr.*;
244 if (self.scales.get(id)) |s| {
245 if (s > max_scale) max_scale = s;
246 }
247 }
248 return max_scale;
249 }
250 };
251 ```
252
253 - [ ] **Step 4: Run tests to verify they pass**
254
255 ```bash
256 ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
257 ```
258
259 Expected: all 8 `ScaleTracker` tests PASS, plus all pre-existing tests remain passing.
260
261 - [ ] **Step 5: Commit**
262
263 ```bash
264 git add src/scale_tracker.zig build.zig
265 git commit -m "Add pure ScaleTracker for wl_output scale tracking"
266 ```
267
268 ### Task 2: Bind wl_output in the registry and feed ScaleTracker
269
270 **Files:**
271 - Modify: `src/wayland.zig`
272
273 - [ ] **Step 1: Import the tracker and add an Output proxy struct**
274
275 Add near the top of `src/wayland.zig`, after the existing imports:
276
277 ```zig
278 const ScaleTracker = @import("scale_tracker").ScaleTracker;
279 ```
280
281 Add a new pub struct above `Globals`:
282
283 ```zig
284 pub const Output = struct {
285 wl_output: *wl.Output,
286 name: u32,
287 tracker: *ScaleTracker,
288 pending_scale: i32 = 1,
289 };
290 ```
291
292 - [ ] **Step 2: Extend Globals + Connection to own a ScaleTracker and an outputs list**
293
294 Update `Globals`:
295
296 ```zig
297 pub const Globals = struct {
298 compositor: ?*wl.Compositor = null,
299 wm_base: ?*xdg.WmBase = null,
300 seat: ?*wl.Seat = null,
301 data_device_manager: ?*wl.DataDeviceManager = null,
302 };
303 ```
304
305 Leave `Globals` alone; the outputs list and the tracker live on `Connection` because they need an allocator. Change `Connection`:
306
307 ```zig
308 pub const Connection = struct {
309 display: *wl.Display,
310 registry: *wl.Registry,
311 globals: Globals,
312 alloc: std.mem.Allocator,
313 scale_tracker: ScaleTracker,
314 outputs: std.ArrayListUnmanaged(*Output),
315
316 pub fn init(alloc: std.mem.Allocator) !Connection {
317 const display = try wl.Display.connect(null);
318 errdefer display.disconnect();
319
320 const registry = try display.getRegistry();
321 errdefer registry.destroy();
322
323 var conn = Connection{
324 .display = display,
325 .registry = registry,
326 .globals = Globals{},
327 .alloc = alloc,
328 .scale_tracker = ScaleTracker.init(alloc),
329 .outputs = .empty,
330 };
331
332 registry.setListener(*Connection, registryListener, &conn);
333
334 if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
335 // Second roundtrip so each wl_output's initial scale/done events are received.
336 if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
337
338 if (conn.globals.compositor == null) return error.NoCompositor;
339 if (conn.globals.wm_base == null) return error.NoXdgWmBase;
340 if (conn.globals.seat == null) return error.NoSeat;
341
342 return conn;
343 }
344
345 pub fn deinit(self: *Connection) void {
346 for (self.outputs.items) |out| {
347 out.wl_output.release();
348 self.alloc.destroy(out);
349 }
350 self.outputs.deinit(self.alloc);
351 self.scale_tracker.deinit();
352 self.display.disconnect();
353 }
354
355 // createWindow stays; see Task 3.
356 };
357 ```
358
359 Note: `Connection.init` now takes an allocator. This breaks every call site — the next step fixes them.
360
361 - [ ] **Step 3: Rewrite registryListener to handle wl_output**
362
363 Replace `registryListener` with:
364
365 ```zig
366 fn registryListener(
367 registry: *wl.Registry,
368 event: wl.Registry.Event,
369 conn: *Connection,
370 ) void {
371 switch (event) {
372 .global => |g| {
373 const iface = std.mem.span(g.interface);
374 if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) {
375 conn.globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return;
376 } else if (std.mem.eql(u8, iface, std.mem.span(wl.DataDeviceManager.interface.name))) {
377 conn.globals.data_device_manager = registry.bind(g.name, wl.DataDeviceManager, 3) catch return;
378 } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
379 conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return;
380 } else if (std.mem.eql(u8, iface, std.mem.span(wl.Seat.interface.name))) {
381 conn.globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
382 } else if (std.mem.eql(u8, iface, std.mem.span(wl.Output.interface.name))) {
383 const wl_out = registry.bind(g.name, wl.Output, 4) catch return;
384 const out = conn.alloc.create(Output) catch {
385 wl_out.release();
386 return;
387 };
388 out.* = .{
389 .wl_output = wl_out,
390 .name = g.name,
391 .tracker = &conn.scale_tracker,
392 };
393 conn.outputs.append(conn.alloc, out) catch {
394 wl_out.release();
395 conn.alloc.destroy(out);
396 return;
397 };
398 conn.scale_tracker.addOutput(g.name) catch {};
399 wl_out.setListener(*Output, outputListener, out);
400 }
401 },
402 .global_remove => |g| {
403 var i: usize = 0;
404 while (i < conn.outputs.items.len) : (i += 1) {
405 const out = conn.outputs.items[i];
406 if (out.name == g.name) {
407 conn.scale_tracker.removeOutput(out.name);
408 out.wl_output.release();
409 conn.alloc.destroy(out);
410 _ = conn.outputs.swapRemove(i);
411 return;
412 }
413 }
414 },
415 }
416 }
417
418 fn outputListener(
419 _: *wl.Output,
420 event: wl.Output.Event,
421 out: *Output,
422 ) void {
423 switch (event) {
424 .scale => |s| {
425 out.pending_scale = s.factor;
426 },
427 .done => {
428 out.tracker.setOutputScale(out.name, out.pending_scale);
429 },
430 .geometry, .mode, .name, .description => {},
431 }
432 }
433 ```
434
435 - [ ] **Step 4: Fix call sites**
436
437 Update every call to `Connection.init()` in the codebase to pass the allocator. Run:
438
439 ```bash
440 grep -rn "wayland_client.Connection.init" src/
441 ```
442
443 Expected matches: `runTerminal`, `runTextCoverageCompare`, plus any smoke test functions (`runWaylandSmokeTest`, etc). For each, change `wayland_client.Connection.init()` to `wayland_client.Connection.init(alloc)`.
444
445 - [ ] **Step 5: Build and run full test suite**
446
447 ```bash
448 ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
449 ```
450
451 Expected: PASS. No new tests yet — this step just verifies we didn't break the build or existing tests.
452
453 - [ ] **Step 6: Manual smoke test — verify outputs get tracked**
454
455 Add a temporary debug print at the end of `Connection.init` (after the second roundtrip) that prints scales for each bound output:
456
457 ```zig
458 for (conn.outputs.items) |out| {
459 std.debug.print("wl_output name={d} scale={d}\n", .{ out.name, conn.scale_tracker.scales.get(out.name) orelse 0 });
460 }
461 ```
462
463 Build and run:
464
465 ```bash
466 zig build
467 ./zig-out/bin/waystty --text-compare
468 ```
469
470 Expected: two `wl_output name=... scale=...` lines printed before the window appears, one showing `scale=2` (DP‑4) and one showing `scale=1` (DP‑5).
471
472 **Remove the debug print before committing.**
473
474 - [ ] **Step 7: Commit**
475
476 ```bash
477 git add src/wayland.zig src/main.zig
478 git commit -m "Bind wl_output globals into ScaleTracker"
479 ```
480
481 ### Task 3: Track surface enter/leave and expose bufferScale on Window
482
483 **Files:**
484 - Modify: `src/wayland.zig`
485 - Test: `src/wayland.zig` (inline test for tracker reaction to simulated enter/leave)
486
487 - [ ] **Step 1: Write a failing test for enter/leave plumbing**
488
489 Add this test at the bottom of `src/wayland.zig`:
490
491 ```zig
492 test "Window.bufferScale reflects ScaleTracker entered outputs" {
493 var tracker = ScaleTracker.init(std.testing.allocator);
494 defer tracker.deinit();
495
496 try tracker.addOutput(1);
497 try tracker.addOutput(2);
498 tracker.setOutputScale(1, 1);
499 tracker.setOutputScale(2, 2);
500
501 // Simulate the bits Window.bufferScale delegates to.
502 try tracker.enterOutput(2);
503 try std.testing.expectEqual(@as(i32, 2), tracker.bufferScale());
504
505 tracker.leaveOutput(2);
506 try std.testing.expectEqual(@as(i32, 1), tracker.bufferScale());
507 }
508 ```
509
510 This test documents the contract for `Window.bufferScale()` and will pass trivially once the window wiring lands. It exists so the behavior has test coverage; the real integration test is the manual run at the end.
511
512 - [ ] **Step 2: Extend Window to hold tracker pointer and generation counter**
513
514 Update `Window`:
515
516 ```zig
517 pub const Window = struct {
518 alloc: std.mem.Allocator,
519 surface: *wl.Surface,
520 xdg_surface: *xdg.Surface,
521 xdg_toplevel: *xdg.Toplevel,
522 tracker: *ScaleTracker,
523 scale_generation: u64 = 0,
524 applied_buffer_scale: i32 = 1,
525 configured: bool = false,
526 should_close: bool = false,
527 width: u32 = 800,
528 height: u32 = 600,
529
530 pub fn deinit(self: *Window) void {
531 self.xdg_toplevel.destroy();
532 self.xdg_surface.destroy();
533 self.surface.destroy();
534 self.alloc.destroy(self);
535 }
536
537 pub fn setTitle(self: *Window, title: ?[:0]const u8) void {
538 self.xdg_toplevel.setTitle((title orelse "waystty"));
539 }
540
541 pub fn bufferScale(self: *const Window) i32 {
542 return self.tracker.bufferScale();
543 }
544 };
545 ```
546
547 - [ ] **Step 3: Wire the wl_surface enter/leave listener in createWindow**
548
549 Replace `createWindow` with:
550
551 ```zig
552 pub fn createWindow(self: *Connection, alloc: std.mem.Allocator, title: [*:0]const u8) !*Window {
553 const compositor = self.globals.compositor orelse return error.NoCompositor;
554 const wm_base = self.globals.wm_base orelse return error.NoXdgWmBase;
555
556 const window = try alloc.create(Window);
557 errdefer alloc.destroy(window);
558
559 window.* = .{
560 .alloc = alloc,
561 .surface = try compositor.createSurface(),
562 .xdg_surface = undefined,
563 .xdg_toplevel = undefined,
564 .tracker = &self.scale_tracker,
565 };
566 errdefer window.surface.destroy();
567
568 window.surface.setListener(*Window, surfaceListener, window);
569
570 window.xdg_surface = try wm_base.getXdgSurface(window.surface);
571 errdefer window.xdg_surface.destroy();
572
573 window.xdg_toplevel = try window.xdg_surface.getToplevel();
574
575 window.xdg_toplevel.setTitle(title);
576 window.xdg_toplevel.setAppId("waystty");
577
578 window.xdg_surface.setListener(*Window, xdgSurfaceListener, window);
579 window.xdg_toplevel.setListener(*Window, xdgToplevelListener, window);
580 wm_base.setListener(*xdg.WmBase, wmBaseListener, wm_base);
581
582 window.surface.commit();
583 _ = self.display.roundtrip();
584
585 return window;
586 }
587 ```
588
589 Add the listener function next to the existing `xdgSurfaceListener`:
590
591 ```zig
592 fn surfaceListener(
593 _: *wl.Surface,
594 event: wl.Surface.Event,
595 window: *Window,
596 ) void {
597 switch (event) {
598 .enter => |e| {
599 window.handleSurfaceEnter(e.output);
600 window.scale_generation += 1;
601 },
602 .leave => |e| {
603 window.handleSurfaceLeave(e.output);
604 window.scale_generation += 1;
605 },
606 .preferred_buffer_scale => {},
607 .preferred_buffer_transform => {},
608 }
609 }
610 ```
611
612 The callback needs access to the `Connection.outputs` list to map from `*wl.Output` to a stable id. Add a pointer to the outputs list onto `Window`:
613
614 ```zig
615 tracker: *ScaleTracker,
616 outputs: *std.ArrayListUnmanaged(*Output),
617 ```
618
619 Populate it in `createWindow`:
620
621 ```zig
622 window.* = .{
623 .alloc = alloc,
624 .surface = try compositor.createSurface(),
625 .xdg_surface = undefined,
626 .xdg_toplevel = undefined,
627 .tracker = &self.scale_tracker,
628 .outputs = &self.outputs,
629 };
630 ```
631
632 And implement the lookup helpers on `Window`:
633
634 ```zig
635 pub fn handleSurfaceEnter(self: *Window, wl_out: *wl.Output) void {
636 for (self.outputs.items) |out| {
637 if (out.wl_output == wl_out) {
638 self.tracker.enterOutput(out.name) catch {};
639 return;
640 }
641 }
642 }
643
644 pub fn handleSurfaceLeave(self: *Window, wl_out: *wl.Output) void {
645 for (self.outputs.items) |out| {
646 if (out.wl_output == wl_out) {
647 self.tracker.leaveOutput(out.name);
648 return;
649 }
650 }
651 }
652 ```
653
654 - [ ] **Step 4: Run tests and build**
655
656 ```bash
657 ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
658 ```
659
660 Expected: all tests PASS including the new `Window.bufferScale reflects ScaleTracker entered outputs` test.
661
662 - [ ] **Step 5: Commit**
663
664 ```bash
665 git add src/wayland.zig
666 git commit -m "Track wl_surface enter/leave on Window"
667 ```
668
669 ### Task 4: Atlas reset + Face reinit so rasterization can follow the scale
670
671 **Files:**
672 - Modify: `src/font.zig`
673 - Test: `src/font.zig`
674
675 - [ ] **Step 1: Write failing tests**
676
677 Append to `src/font.zig`:
678
679 ```zig
680 test "Atlas.reset clears cache and starts fresh" {
681 var lookup = try lookupConfiguredFont(std.testing.allocator);
682 defer lookup.deinit(std.testing.allocator);
683
684 var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
685 defer face.deinit();
686
687 var atlas = try Atlas.init(std.testing.allocator, 256, 256);
688 defer atlas.deinit();
689
690 _ = try atlas.getOrInsert(&face, 'A');
691 try std.testing.expect(atlas.cache.count() > 0);
692
693 atlas.reset();
694 try std.testing.expectEqual(@as(u32, 1), @as(u32, @intCast(atlas.cache.count() + 1))); // cache empty
695 try std.testing.expectEqual(@as(u8, 255), atlas.pixels[0]);
696 try std.testing.expect(atlas.dirty);
697
698 // Re-inserting the same glyph should succeed after reset.
699 _ = try atlas.getOrInsert(&face, 'A');
700 }
701
702 test "Face.reinit switches px_size and produces different cell metrics" {
703 var lookup = try lookupConfiguredFont(std.testing.allocator);
704 defer lookup.deinit(std.testing.allocator);
705
706 var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
707 defer face.deinit();
708 const small_cell = face.cellWidth();
709
710 try face.reinit(lookup.path, lookup.index, 28);
711 const large_cell = face.cellWidth();
712
713 try std.testing.expect(large_cell > small_cell);
714 }
715 ```
716
717 - [ ] **Step 2: Run tests to verify they fail**
718
719 ```bash
720 ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
721 ```
722
723 Expected: FAIL — `Atlas.reset` and `Face.reinit` are undefined.
724
725 - [ ] **Step 3: Implement Atlas.reset and Face.reinit**
726
727 Add to `Atlas` (next to `deinit`):
728
729 ```zig
730 pub fn reset(self: *Atlas) void {
731 @memset(self.pixels, 0);
732 self.pixels[0] = 255;
733 self.cursor_x = 1;
734 self.cursor_y = 0;
735 self.row_height = 1;
736 self.cache.clearRetainingCapacity();
737 self.dirty = true;
738 }
739 ```
740
741 Add to `Face` (next to `deinit`):
742
743 ```zig
744 pub fn reinit(
745 self: *Face,
746 path: [:0]const u8,
747 index: c_int,
748 px_size: u32,
749 ) !void {
750 _ = c.FT_Done_Face(self.face);
751 self.face = null;
752
753 var new_face: c.FT_Face = null;
754 if (c.FT_New_Face(self.library, path.ptr, index, &new_face) != 0) return error.FtNewFaceFailed;
755 errdefer _ = c.FT_Done_Face(new_face);
756
757 if (c.FT_Set_Pixel_Sizes(new_face, 0, px_size) != 0) return error.FtSetPixelSizesFailed;
758
759 self.face = new_face;
760 self.px_size = px_size;
761 }
762 ```
763
764 - [ ] **Step 4: Run tests to verify they pass**
765
766 ```bash
767 ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
768 ```
769
770 Expected: all tests PASS.
771
772 - [ ] **Step 5: Commit**
773
774 ```bash
775 git add src/font.zig
776 git commit -m "Add Atlas.reset and Face.reinit for scale changes"
777 ```
778
779 ### Task 5: Wire dynamic scale into runTextCoverageCompare
780
781 This task integrates scale into the simpler of the two render loops first. It's the loop the user originally reported the bug in, so we verify the fix here before extending it to the full terminal.
782
783 **Files:**
784 - Modify: `src/main.zig`
785
786 - [ ] **Step 1: Extract a rebuild helper**
787
788 Near the top of `src/main.zig` (after `GridSize`), add:
789
790 ```zig
791 const ScaledGeometry = struct {
792 buffer_scale: i32,
793 px_size: u32,
794 cell_w_px: u32, // buffer pixels
795 cell_h_px: u32, // buffer pixels
796 baseline_px: u32,
797 };
798
799 fn rebuildFaceForScale(
800 face: *font.Face,
801 atlas: *font.Atlas,
802 font_path: [:0]const u8,
803 font_index: c_int,
804 base_px_size: u32,
805 buffer_scale: i32,
806 ) !ScaledGeometry {
807 const scale: u32 = @intCast(@max(@as(i32, 1), buffer_scale));
808 const new_px = base_px_size * scale;
809 try face.reinit(font_path, font_index, new_px);
810 atlas.reset();
811 return .{
812 .buffer_scale = @intCast(scale),
813 .px_size = new_px,
814 .cell_w_px = face.cellWidth(),
815 .cell_h_px = face.cellHeight(),
816 .baseline_px = face.baseline(),
817 };
818 }
819 ```
820
821 - [ ] **Step 2: Update runTextCoverageCompare to respect scale**
822
823 Replace the body of `runTextCoverageCompare` with a version that:
824
825 1. Calls `rebuildFaceForScale` once up front at scale=1 to establish the base geometry.
826 2. Sets the surface size to `(panel_cols * variants) × rows` in surface coordinates.
827 3. After the first frame is rendered, re-reads `window.bufferScale()`; if it changed, calls `rebuildFaceForScale` again at the new scale, rebuilds the scene, calls `window.surface.setBufferScale(scale)`, commits, recreates the Vulkan swapchain at buffer pixels, re-uploads atlas and instances, and continues.
828
829 Concretely, replace lines 1759–1847 with:
830
831 ```zig
832 fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
833 var conn = try wayland_client.Connection.init(alloc);
834 defer conn.deinit();
835
836 const window = try conn.createWindow(alloc, "waystty-text-compare");
837 defer window.deinit();
838
839 _ = conn.display.roundtrip();
840
841 var font_lookup = try font.lookupConfiguredFont(alloc);
842 defer font_lookup.deinit(alloc);
843
844 var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
845 defer face.deinit();
846
847 var atlas = try font.Atlas.init(alloc, 2048, 2048);
848 defer atlas.deinit();
849
850 var geom: ScaledGeometry = .{
851 .buffer_scale = 1,
852 .px_size = config.font_size_px,
853 .cell_w_px = face.cellWidth(),
854 .cell_h_px = face.cellHeight(),
855 .baseline_px = face.baseline(),
856 };
857
858 var scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
859 defer scene.deinit(alloc);
860
861 // Surface-coordinate window size (logical pixels).
862 const surface_cell_w: u32 = geom.cell_w_px; // scale=1 initially
863 const surface_cell_h: u32 = geom.cell_h_px;
864 window.width = scene.window_cols * surface_cell_w;
865 window.height = scene.window_rows * surface_cell_h;
866
867 var ctx = try renderer.Context.init(
868 alloc,
869 @ptrCast(conn.display),
870 @ptrCast(window.surface),
871 window.width * @as(u32, @intCast(geom.buffer_scale)),
872 window.height * @as(u32, @intCast(geom.buffer_scale)),
873 );
874 defer ctx.deinit();
875
876 try ctx.uploadAtlas(atlas.pixels);
877 atlas.dirty = false;
878 try ctx.uploadInstances(scene.instances.items);
879
880 const wl_fd = conn.display.getFd();
881 var pollfds = [_]std.posix.pollfd{
882 .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 },
883 };
884 var last_window_w = window.width;
885 var last_window_h = window.height;
886 var last_scale: i32 = geom.buffer_scale;
887
888 while (!window.should_close) {
889 _ = conn.display.flush();
890 if (conn.display.prepareRead()) {
891 pollfds[0].revents = 0;
892 _ = std.posix.poll(&pollfds, 16) catch {};
893 if (pollfds[0].revents & std.posix.POLL.IN != 0) {
894 _ = conn.display.readEvents();
895 } else {
896 conn.display.cancelRead();
897 }
898 }
899 _ = conn.display.dispatchPending();
900
901 const current_scale = window.bufferScale();
902 const scale_changed = current_scale != last_scale;
903 const size_changed = window.width != last_window_w or window.height != last_window_h;
904
905 if (scale_changed or size_changed) {
906 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
907
908 if (scale_changed) {
909 geom = try rebuildFaceForScale(
910 &face,
911 &atlas,
912 font_lookup.path,
913 font_lookup.index,
914 config.font_size_px,
915 current_scale,
916 );
917 // Rebuild the scene against the fresh atlas.
918 scene.deinit(alloc);
919 scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
920
921 // The surface size is cells × (cell_px / scale). Since we raster at px_size*scale,
922 // the surface_cell values are stable across scale changes.
923 const new_surface_cell_w: u32 = geom.cell_w_px / @as(u32, @intCast(geom.buffer_scale));
924 const new_surface_cell_h: u32 = geom.cell_h_px / @as(u32, @intCast(geom.buffer_scale));
925 window.width = scene.window_cols * new_surface_cell_w;
926 window.height = scene.window_rows * new_surface_cell_h;
927
928 window.surface.setBufferScale(geom.buffer_scale);
929 try ctx.uploadAtlas(atlas.pixels);
930 atlas.dirty = false;
931 try ctx.uploadInstances(scene.instances.items);
932 last_scale = current_scale;
933 }
934
935 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
936 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
937 try ctx.recreateSwapchain(buf_w, buf_h);
938
939 last_window_w = window.width;
940 last_window_h = window.height;
941 }
942
943 drawTextCoverageCompareFrame(
944 &ctx,
945 &scene,
946 geom.cell_w_px,
947 geom.cell_h_px,
948 .{ 0.0, 0.0, 0.0, 1.0 },
949 ) catch |err| switch (err) {
950 error.OutOfDateKHR => {
951 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
952 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
953 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
954 try ctx.recreateSwapchain(buf_w, buf_h);
955 last_window_w = window.width;
956 last_window_h = window.height;
957 continue;
958 },
959 else => return err,
960 };
961
962 _ = conn.display.flush();
963 std.Thread.sleep(16 * std.time.ns_per_ms);
964 }
965
966 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
967 }
968 ```
969
970 Note: the atlas was bumped from 1024×1024 to 2048×2048 because at 2× scale a single panel of text may exceed the smaller atlas's vertical budget. If the build complains about uniform sizes, this is the first place to look.
971
972 - [ ] **Step 3: Run the test suite to confirm nothing regressed**
973
974 ```bash
975 ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
976 ```
977
978 Expected: PASS, including `buildTextCoverageCompareScene repeats the same specimen in four panels`.
979
980 - [ ] **Step 4: Manual verification on the 2× monitor**
981
982 ```bash
983 zig build
984 ./zig-out/bin/waystty --text-compare
985 ```
986
987 - Move the window to DP‑4 (Apple Studio Display, scale 2.0). Expected: glyphs are crisp (no compositor upscale blur). The window's logical size should look the same as before — only the buffer density changes.
988 - Move the window to DP‑5 (Dell AW3225QF, scale 1.0). Expected: glyphs stay crisp. No visible re-layout flicker beyond the resize frame.
989
990 If the text still looks fuzzy on DP‑4 at this stage, the `window.surface.setBufferScale` call isn't taking effect before the first frame — check that `commit()` runs before the Vulkan draw, and that the swapchain was recreated at the doubled extent.
991
992 - [ ] **Step 5: Commit**
993
994 ```bash
995 git add src/main.zig
996 git commit -m "Honor wl_output buffer scale in text-compare mode"
997 ```
998
999 ### Task 6: Wire dynamic scale into runTerminal
1000
1001 **Files:**
1002 - Modify: `src/main.zig`
1003
1004 - [ ] **Step 1: Add scale handling to the terminal loop**
1005
1006 In `runTerminal`, after the existing `last_window_w` / `last_window_h` / `render_pending` variables are declared, add:
1007
1008 ```zig
1009 var geom: ScaledGeometry = .{
1010 .buffer_scale = 1,
1011 .px_size = config.font_size_px,
1012 .cell_w_px = cell_w,
1013 .cell_h_px = cell_h,
1014 .baseline_px = baseline,
1015 };
1016 var last_scale: i32 = geom.buffer_scale;
1017 _ = &last_scale; // touched inside the loop
1018 ```
1019
1020 Rename the outer immutable `cell_w` / `cell_h` / `baseline` to `cell_w_px`, `cell_h_px`, `baseline_px` and pull them from `geom` so that the scale‑rebuild path can mutate them. Leave the initial values wired through as before — this is a pure rename in all existing references.
1021
1022 - [ ] **Step 2: Detect scale changes inside the loop**
1023
1024 In `runTerminal`'s main loop, just before the existing size-change block (`if (window.width != last_window_w or window.height != last_window_h)`), add:
1025
1026 ```zig
1027 const current_scale = window.bufferScale();
1028 if (current_scale != last_scale) {
1029 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1030
1031 geom = try rebuildFaceForScale(
1032 &face,
1033 &atlas,
1034 font_lookup.path,
1035 font_lookup.index,
1036 config.font_size_px,
1037 current_scale,
1038 );
1039
1040 // Invalidate cached instances so glyphs get re-inserted into the fresh atlas.
1041 // `invalidateAfterResize()` already zeroes per-row GPU offsets, clears cursor/packed,
1042 // and marks layout_dirty. Marking the terminal full-dirty forces rebuildRowInstances()
1043 // to run for every row next frame.
1044 render_cache.invalidateAfterResize();
1045 term.render_state.dirty = .full;
1046
1047 window.surface.setBufferScale(geom.buffer_scale);
1048
1049 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
1050 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
1051 try ctx.recreateSwapchain(buf_w, buf_h);
1052
1053 last_scale = current_scale;
1054 render_pending = true;
1055
1056 cell_w_px = geom.cell_w_px;
1057 cell_h_px = geom.cell_h_px;
1058 baseline_px = geom.baseline_px;
1059 }
1060 ```
1061
1062 - [ ] **Step 3: Update the existing resize block to use buffer-pixel extents**
1063
1064 Where the existing code calls `ctx.recreateSwapchain(window.width, window.height)` inside `runTerminal`, change both call sites to:
1065
1066 ```zig
1067 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
1068 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
1069 try ctx.recreateSwapchain(buf_w, buf_h);
1070 ```
1071
1072 Also update the grid-size computation. Because `cell_w_px` / `cell_h_px` are in buffer pixels and `window.width` / `window.height` are surface coordinates, `gridSizeForWindow` must divide by the *surface-pixel* cell size, which is `cell_w_px / buffer_scale`. Update the call:
1073
1074 ```zig
1075 const surf_cell_w = cell_w_px / @as(u32, @intCast(geom.buffer_scale));
1076 const surf_cell_h = cell_h_px / @as(u32, @intCast(geom.buffer_scale));
1077 const new_grid = gridSizeForWindow(window.width, window.height, surf_cell_w, surf_cell_h);
1078 ```
1079
1080 - [ ] **Step 4: Run the test suite**
1081
1082 ```bash
1083 ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
1084 ```
1085
1086 Expected: PASS.
1087
1088 - [ ] **Step 5: Manual verification**
1089
1090 ```bash
1091 zig build
1092 ./zig-out/bin/waystty
1093 ```
1094
1095 - Launch with the cursor focused on DP‑4. Expected: crisp terminal at startup.
1096 - Drag to DP‑5 and back. Expected: brief re-layout frame, then crisp text. Grid size (cols × rows) should stay stable because we compute grid from surface coordinates, not buffer pixels.
1097 - Type during the drag. Expected: no crashes, no corrupted glyphs after the rebuild.
1098
1099 - [ ] **Step 6: Commit**
1100
1101 ```bash
1102 git add src/main.zig
1103 git commit -m "Honor wl_output buffer scale in terminal render loop"
1104 ```
1105
1106 ### Task 7: Final verification and cleanup
1107
1108 **Files:**
1109 - Verify: all modified files
1110
1111 - [ ] **Step 1: Full test suite**
1112
1113 ```bash
1114 ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
1115 ```
1116
1117 Expected: PASS.
1118
1119 - [ ] **Step 2: Lint for stale debug prints and TODOs**
1120
1121 ```bash
1122 grep -n "std.debug.print" src/wayland.zig src/main.zig
1123 grep -n "TODO" src/scale_tracker.zig src/wayland.zig src/font.zig src/main.zig
1124 ```
1125
1126 Expected: no `std.debug.print` lines that weren't in the baseline; no new TODOs introduced by this plan.
1127
1128 - [ ] **Step 3: Four-point manual verification matrix**
1129
1130 | Binary | Start output | Drag target | Expected |
1131 |---|---|---|---|
1132 | `waystty --text-compare` | DP‑5 (1×) | — | Crisp at startup |
1133 | `waystty --text-compare` | DP‑4 (2×) | — | Crisp at startup |
1134 | `waystty --text-compare` | DP‑5 → DP‑4 | drag | Crisp after drag |
1135 | `waystty` | DP‑4 (2×) | — | Crisp terminal, input works |
1136
1137 - [ ] **Step 4: Update the dev-display memory note**
1138
1139 Edit `/home/xanderle/.claude/projects/-home-xanderle-code-rad-waystty/memory/dev_display_setup.md` and flip the status from "waystty has no HiDPI handling yet" to "waystty binds wl_output and reacts to wl_surface.enter/leave; rebuilds font+atlas+swapchain on buffer scale changes." Keep the display layout facts as-is since the dual-monitor setup is still relevant for future rendering debugging.
1140
1141 - [ ] **Step 5: Final commit if any cleanup happened**
1142
1143 Only create this commit if the previous steps surfaced anything to clean up.
1144
1145 ```bash
1146 git add <touched files>
1147 git commit -m "HiDPI support cleanup"
1148 ```
docs/superpowers/plans/2026-04-09-text-coverage-comparison-implementation.md
Old New
@@ -0,0 +1,403 @@
1 # Text Coverage Comparison 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:** Add a `--text-compare` mode that renders one specimen across four side-by-side coverage variants so text sharpness can be evaluated visually without changing the default terminal path.
6
7 **Architecture:** Reuse the existing Vulkan/font/atlas smoke-test path in `src/main.zig`, extend the renderer with a small fragment-side coverage control, and render four panels using the same atlas and instance format. Keep the default terminal rendering path unchanged; the comparison mode is an isolated inspection tool.
8
9 **Tech Stack:** Zig 0.15, Vulkan renderer in `src/renderer.zig`, GLSL fragment shader in `shaders/cell.frag`, Wayland window path in `src/main.zig`, configured font lookup in `src/font.zig`
10
11 ---
12
13 ## File Structure
14
15 - Modify: `src/main.zig`
16 - Add the `--text-compare` CLI path, specimen layout helpers, and comparison-mode render loop.
17 - Modify: `src/renderer.zig`
18 - Add a small configurable coverage parameter path that the fragment shader can read.
19 - Modify: `shaders/cell.frag`
20 - Apply variant coverage shaping while preserving the current baseline behavior.
21 - Test: `src/main.zig`
22 - Add tests for specimen layout and panel placement helpers.
23 - Test: `src/renderer.zig`
24 - Add tests for the coverage-parameter packing/planning helpers that do not require a live Vulkan device.
25
26 ### Task 1: Add coverage-variant planning helpers in the renderer
27
28 **Files:**
29 - Modify: `src/renderer.zig`
30 - Test: `src/renderer.zig`
31
32 - [ ] **Step 1: Write the failing tests**
33
34 Add tests for a small coverage parameter helper:
35
36 ```zig
37 test "coverageVariantParams returns identity values for baseline" {
38 const params = coverageVariantParams(.baseline);
39 try std.testing.expectEqualDeep([2]f32{ 1.0, 0.0 }, params);
40 }
41
42 test "coverageVariantParams steepens non-baseline variants" {
43 const mild = coverageVariantParams(.mild);
44 const medium = coverageVariantParams(.medium);
45 const crisp = coverageVariantParams(.crisp);
46
47 try std.testing.expect(mild[0] > 1.0);
48 try std.testing.expect(medium[0] > mild[0]);
49 try std.testing.expect(crisp[0] > medium[0]);
50 }
51 ```
52
53 - [ ] **Step 2: Run test to verify it fails**
54
55 Run: `rm -rf /tmp/zig-global-cache-coverage-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-coverage-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-coverage-plan zig build test --summary all`
56 Expected:
57 - FAIL because `coverageVariantParams` and the enum are undefined.
58
59 - [ ] **Step 3: Add the minimal helper types and implementation**
60
61 Add a small enum and helper in `src/renderer.zig`:
62
63 ```zig
64 pub const CoverageVariant = enum(u32) {
65 baseline,
66 mild,
67 medium,
68 crisp,
69 };
70
71 fn coverageVariantParams(variant: CoverageVariant) [2]f32 {
72 return switch (variant) {
73 .baseline => .{ 1.0, 0.0 },
74 .mild => .{ 1.15, 0.0 },
75 .medium => .{ 1.3, 0.0 },
76 .crisp => .{ 1.55, -0.08 },
77 };
78 }
79 ```
80
81 - [ ] **Step 4: Run test to verify it passes**
82
83 Run: `rm -rf /tmp/zig-global-cache-coverage-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-coverage-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-coverage-plan zig build test --summary all`
84 Expected:
85 - PASS for the new renderer helper tests.
86
87 - [ ] **Step 5: Commit**
88
89 ```bash
90 git add src/renderer.zig
91 git commit -m "Add text coverage variant helpers"
92 ```
93
94 ### Task 2: Plumb coverage controls through push constants and fragment shader
95
96 **Files:**
97 - Modify: `src/renderer.zig`
98 - Modify: `shaders/cell.frag`
99 - Test: `src/renderer.zig`
100
101 - [ ] **Step 1: Write the failing test**
102
103 Add a test that checks the push-constant default remains baseline-safe:
104
105 ```zig
106 test "PushConstants defaults preserve baseline coverage shaping" {
107 const pc = PushConstants{
108 .surface_size = .{ 800.0, 600.0 },
109 .cell_size = .{ 8.0, 16.0 },
110 .clear_color = .{ 0.0, 0.0, 0.0, 1.0 },
111 .coverage_params = .{ 1.0, 0.0 },
112 };
113
114 try std.testing.expectEqualDeep([2]f32{ 1.0, 0.0 }, pc.coverage_params);
115 }
116 ```
117
118 - [ ] **Step 2: Run test to verify it fails**
119
120 Run: `rm -rf /tmp/zig-global-cache-push-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-push-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-push-plan zig build test --summary all`
121 Expected:
122 - FAIL because `PushConstants` does not yet contain `coverage_params`.
123
124 - [ ] **Step 3: Add push-constant and shader plumbing**
125
126 Extend `PushConstants` in `src/renderer.zig`:
127
128 ```zig
129 pub const PushConstants = extern struct {
130 surface_size: [2]f32,
131 cell_size: [2]f32,
132 clear_color: [4]f32,
133 coverage_params: [2]f32,
134 };
135 ```
136
137 Update `drawCells` to accept coverage params:
138
139 ```zig
140 pub fn drawCells(
141 self: *Context,
142 instance_count: u32,
143 cell_size: [2]f32,
144 clear_color: [4]f32,
145 coverage_params: [2]f32,
146 ) !void
147 ```
148
149 Set the push constants with that field populated.
150
151 Update `shaders/cell.frag`:
152
153 ```glsl
154 layout(push_constant) uniform PushConstants {
155 vec2 surface_size;
156 vec2 cell_size;
157 vec4 clear_color;
158 vec2 coverage_params;
159 } pc;
160
161 float shape_coverage(float alpha) {
162 float curved = pow(alpha, pc.coverage_params.x);
163 return clamp(curved + pc.coverage_params.y, 0.0, 1.0);
164 }
165
166 void main() {
167 float alpha = texture(glyph_atlas, in_uv).r;
168 alpha = shape_coverage(alpha);
169 out_color = mix(in_bg, in_fg, alpha);
170 }
171 ```
172
173 Keep the baseline identity behavior by passing `{1.0, 0.0}` for normal paths.
174
175 - [ ] **Step 4: Run test to verify it passes**
176
177 Run: `rm -rf /tmp/zig-global-cache-push-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-push-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-push-plan zig build test --summary all`
178 Expected:
179 - PASS with shader compilation and the new push-constant test green.
180
181 - [ ] **Step 5: Commit**
182
183 ```bash
184 git add src/renderer.zig shaders/cell.frag
185 git commit -m "Plumb text coverage controls through renderer"
186 ```
187
188 ### Task 3: Keep the default draw path on baseline coverage
189
190 **Files:**
191 - Modify: `src/main.zig`
192
193 - [ ] **Step 1: Add the failing compile-path update**
194
195 Update existing `drawCells` call sites to pass baseline coverage:
196
197 ```zig
198 ctx.drawCells(instance_count, cell_size, clear_color, .{ 1.0, 0.0 })
199 ```
200
201 - [ ] **Step 2: Run test to verify it fails**
202
203 Run: `rm -rf /tmp/zig-global-cache-baseline-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-baseline-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-baseline-plan zig build test --summary all`
204 Expected:
205 - FAIL until all `drawCells` call sites are updated.
206
207 - [ ] **Step 3: Update existing draw paths**
208
209 Update all current `ctx.drawCells(...)` calls in `src/main.zig` to use baseline params:
210
211 ```zig
212 const baseline_coverage = .{ 1.0, 0.0 };
213 ```
214
215 Pass `baseline_coverage` in:
216 - the normal terminal loop,
217 - `runDrawSmokeTest`,
218 - any other smoke/helper render loop.
219
220 - [ ] **Step 4: Run test to verify it passes**
221
222 Run: `rm -rf /tmp/zig-global-cache-baseline-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-baseline-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-baseline-plan zig build test --summary all`
223 Expected:
224 - PASS with the default rendering path behavior unchanged.
225
226 - [ ] **Step 5: Commit**
227
228 ```bash
229 git add src/main.zig
230 git commit -m "Keep default rendering on baseline coverage"
231 ```
232
233 ### Task 4: Add specimen layout helpers for comparison mode
234
235 **Files:**
236 - Modify: `src/main.zig`
237 - Test: `src/main.zig`
238
239 - [ ] **Step 1: Write the failing tests**
240
241 Add tests for panel offsets and specimen instance generation planning:
242
243 ```zig
244 test "comparisonPanelOrigins splits four panels left to right" {
245 const origins = comparisonPanelOrigins(4, 80, 24);
246 try std.testing.expectEqual(@as(f32, 0), origins[0][0]);
247 try std.testing.expect(origins[1][0] > origins[0][0]);
248 try std.testing.expect(origins[2][0] > origins[1][0]);
249 try std.testing.expect(origins[3][0] > origins[2][0]);
250 }
251
252 test "specimenLines remains fixed and non-empty" {
253 const lines = comparisonSpecimenLines();
254 try std.testing.expect(lines.len >= 5);
255 try std.testing.expect(lines[0].len > 0);
256 }
257 ```
258
259 - [ ] **Step 2: Run test to verify it fails**
260
261 Run: `rm -rf /tmp/zig-global-cache-layout-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-layout-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-layout-plan zig build test --summary all`
262 Expected:
263 - FAIL because the comparison helpers are undefined.
264
265 - [ ] **Step 3: Add the layout helpers**
266
267 Add focused helpers in `src/main.zig`:
268
269 ```zig
270 const ComparisonVariant = struct {
271 label: []const u8,
272 coverage: [2]f32,
273 };
274
275 fn comparisonVariants() [4]ComparisonVariant {
276 return .{
277 .{ .label = "baseline", .coverage = renderer.coverageVariantParams(.baseline) },
278 .{ .label = "mild", .coverage = renderer.coverageVariantParams(.mild) },
279 .{ .label = "medium", .coverage = renderer.coverageVariantParams(.medium) },
280 .{ .label = "crisp", .coverage = renderer.coverageVariantParams(.crisp) },
281 };
282 }
283
284 fn comparisonSpecimenLines() []const []const u8 { ... }
285 fn comparisonPanelOrigins(panel_count: usize, panel_cols: u32, top_margin_rows: u32) [4][2]f32 { ... }
286 ```
287
288 Keep the specimen text fixed to the approved five lines.
289
290 - [ ] **Step 4: Run test to verify it passes**
291
292 Run: `rm -rf /tmp/zig-global-cache-layout-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-layout-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-layout-plan zig build test --summary all`
293 Expected:
294 - PASS for the new comparison-layout tests.
295
296 - [ ] **Step 5: Commit**
297
298 ```bash
299 git add src/main.zig
300 git commit -m "Add text comparison layout helpers"
301 ```
302
303 ### Task 5: Implement the `--text-compare` render mode
304
305 **Files:**
306 - Modify: `src/main.zig`
307
308 - [ ] **Step 1: Add the failing compile-path update**
309
310 Add a CLI branch:
311
312 ```zig
313 if (args.len >= 2 and std.mem.eql(u8, args[1], "--text-compare")) {
314 return runTextCoverageCompare(alloc);
315 }
316 ```
317
318 - [ ] **Step 2: Run test to verify it fails**
319
320 Run: `rm -rf /tmp/zig-global-cache-compare-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-compare-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-compare-plan zig build test --summary all`
321 Expected:
322 - FAIL because `runTextCoverageCompare` is undefined.
323
324 - [ ] **Step 3: Implement the comparison mode**
325
326 Add `runTextCoverageCompare(alloc)` in `src/main.zig` by reusing the draw-smoke structure:
327
328 ```zig
329 fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
330 // 1. Create Wayland connection, window, and Vulkan context.
331 // 2. Load configured font and atlas.
332 // 3. Build one instance list for the fixed specimen in each panel.
333 // 4. Upload atlas and packed instances once.
334 // 5. Render in a loop, selecting one panel coverage variant per draw.
335 }
336 ```
337
338 Implementation notes:
339 - Use four separate draws per frame, one per panel.
340 - Re-upload only once; reuse the same instance buffer data.
341 - Keep the specimen identical in each panel and only vary `coverage_params`.
342 - Reserve enough horizontal spacing so panel text does not overlap.
343
344 - [ ] **Step 4: Run test to verify it passes**
345
346 Run: `rm -rf /tmp/zig-global-cache-compare-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-compare-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-compare-plan zig build test --summary all`
347 Expected:
348 - PASS
349
350 - [ ] **Step 5: Commit**
351
352 ```bash
353 git add src/main.zig
354 git commit -m "Add text coverage comparison mode"
355 ```
356
357 ### Task 6: Full verification
358
359 **Files:**
360 - Modify: none
361 - Test: `src/main.zig`, `src/renderer.zig`, `shaders/cell.frag`
362
363 - [ ] **Step 1: Run the full test suite**
364
365 Run: `rm -rf /tmp/zig-global-cache-compare-final && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-compare-final && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-compare-final zig build test --summary all`
366 Expected:
367 - PASS
368
369 - [ ] **Step 2: Run a build verification**
370
371 Run: `rm -rf /tmp/zig-global-cache-compare-build && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-compare-build && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-compare-build zig build`
372 Expected:
373 - PASS
374
375 - [ ] **Step 3: Run the comparison mode manually**
376
377 Run: `zig build run -- --text-compare`
378 Expected:
379 - One window opens.
380 - Four panels are visible.
381 - The specimen text matches across all panels.
382 - Baseline, mild, medium, and crisp variants are visually distinguishable.
383
384 - [ ] **Step 4: Commit**
385
386 ```bash
387 git add src/main.zig src/renderer.zig shaders/cell.frag
388 git commit -m "Verify text coverage comparison mode"
389 ```
390
391 ## Self-Review
392
393 - Spec coverage:
394 - New CLI comparison mode: Task 5
395 - Reuse existing Vulkan/font/atlas path: Task 5
396 - Four side-by-side panels with baseline + three variants: Tasks 4 and 5
397 - Configured font family and size reuse: Tasks 3 and 5
398 - Shader-only first pass: Tasks 1 and 2
399 - Validation with build and manual comparison: Task 6
400 - Placeholder scan:
401 - No `TODO`, `TBD`, or deferred “figure this out later” markers remain.
402 - Type consistency:
403 - `CoverageVariant`, `coverageVariantParams`, `coverage_params`, `comparisonVariants`, and `runTextCoverageCompare` are named consistently across tasks.
docs/superpowers/plans/2026-04-09-visible-selection-implementation.md
Old New
@@ -0,0 +1,631 @@
1 # Visible Selection 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:** Add XTerm-style visible-grid text selection so users can drag-highlight text with the left mouse button and copy it with `Ctrl+Shift+C`.
6
7 **Architecture:** Keep selection as UI state in `src/main.zig`, sourced from visible-grid coordinates derived from Wayland pointer events. Extend `src/wayland.zig` to expose pointer events and clipboard ownership, then thread the normalized selection span into existing row rebuild and copy paths without moving selection logic into the VT wrapper.
8
9 **Tech Stack:** Zig 0.15, Wayland client bindings, `ghostty-vt`, existing `src/main.zig` render cache path, existing `src/wayland.zig` clipboard receive path
10
11 ---
12
13 ## File Structure
14
15 - Modify: `src/wayland.zig`
16 - Add pointer setup/event queue and clipboard-source support for serving copied UTF-8 text.
17 - Modify: `src/main.zig`
18 - Add visible selection state, pointer-to-grid mapping, selection-aware highlighting, copy extraction, and `Ctrl+Shift+C` copy handling.
19 - Modify: `src/vt.zig`
20 - Only if needed to expose a tiny helper for extracting printable text from visible render cells.
21 - Test: `src/wayland.zig`
22 - Add coverage for pointer queue primitives or clipboard-source helper logic that can be tested without a compositor.
23 - Test: `src/main.zig`
24 - Add coverage for selection normalization, inclusion checks, visible-text extraction, copy shortcut detection, and resize clamping behavior.
25
26 ### Task 1: Add selection primitives and tests in `main.zig`
27
28 **Files:**
29 - Modify: `src/main.zig`
30 - Test: `src/main.zig`
31
32 - [ ] **Step 1: Write the failing selection-helper tests**
33
34 Add these tests near the existing pure-function tests in `src/main.zig`:
35
36 ```zig
37 test "SelectionSpan.normalized orders endpoints in reading order" {
38 const span = SelectionSpan{
39 .start = .{ .col = 7, .row = 4 },
40 .end = .{ .col = 2, .row = 1 },
41 }.normalized();
42
43 try std.testing.expectEqual(@as(u32, 2), span.start.col);
44 try std.testing.expectEqual(@as(u32, 1), span.start.row);
45 try std.testing.expectEqual(@as(u32, 7), span.end.col);
46 try std.testing.expectEqual(@as(u32, 4), span.end.row);
47 }
48
49 test "SelectionSpan.containsCell includes the normalized endpoints" {
50 const span = SelectionSpan{
51 .start = .{ .col = 3, .row = 2 },
52 .end = .{ .col = 1, .row = 1 },
53 };
54
55 try std.testing.expect(span.containsCell(1, 1));
56 try std.testing.expect(span.containsCell(2, 1));
57 try std.testing.expect(span.containsCell(0, 2));
58 try std.testing.expect(span.containsCell(3, 2));
59 try std.testing.expect(!span.containsCell(0, 0));
60 try std.testing.expect(!span.containsCell(4, 2));
61 }
62
63 test "clampSelectionSpan clears fully offscreen spans and trims resized spans" {
64 try std.testing.expect(clampSelectionSpan(.{
65 .start = .{ .col = 5, .row = 30 },
66 .end = .{ .col = 10, .row = 40 },
67 }, 80, 24) == null);
68
69 const clamped = clampSelectionSpan(.{
70 .start = .{ .col = 78, .row = 22 },
71 .end = .{ .col = 99, .row = 30 },
72 }, 80, 24).?;
73
74 try std.testing.expectEqual(@as(u32, 78), clamped.start.col);
75 try std.testing.expectEqual(@as(u32, 22), clamped.start.row);
76 try std.testing.expectEqual(@as(u32, 79), clamped.end.col);
77 try std.testing.expectEqual(@as(u32, 23), clamped.end.row);
78 }
79
80 test "clampSelectionSpan preserves a larger span that collapses to one visible cell" {
81 const clamped = clampSelectionSpan(.{
82 .start = .{ .col = 0, .row = 0 },
83 .end = .{ .col = 120, .row = 80 },
84 }, 1, 1).?;
85
86 try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
87 try std.testing.expectEqual(@as(u32, 0), clamped.start.row);
88 try std.testing.expectEqual(@as(u32, 0), clamped.end.col);
89 try std.testing.expectEqual(@as(u32, 0), clamped.end.row);
90 try std.testing.expect(clamped.containsCell(0, 0));
91 }
92 ```
93
94 - [ ] **Step 2: Run test to verify it fails**
95
96 Run: `zig build test --summary all`
97 Expected:
98 - FAIL because `SelectionSpan` and `clampSelectionSpan` do not exist yet.
99
100 - [ ] **Step 3: Implement the minimal selection primitives**
101
102 Add these helpers in `src/main.zig` near the other render-loop helper structs/functions:
103
104 ```zig
105 const GridPoint = struct {
106 col: u32,
107 row: u32,
108 };
109
110 const SelectionSpan = struct {
111 start: GridPoint,
112 end: GridPoint,
113
114 fn normalized(self: SelectionSpan) SelectionSpan {
115 if (self.start.row < self.end.row) return self;
116 if (self.start.row == self.end.row and self.start.col <= self.end.col) return self;
117 return .{ .start = self.end, .end = self.start };
118 }
119
120 fn containsCell(self: SelectionSpan, col: u32, row: u32) bool {
121 const span = self.normalized();
122 if (row < span.start.row or row > span.end.row) return false;
123 if (span.start.row == span.end.row) {
124 return row == span.start.row and col >= span.start.col and col <= span.end.col;
125 }
126 if (row == span.start.row) return col >= span.start.col;
127 if (row == span.end.row) return col <= span.end.col;
128 return true;
129 }
130 };
131
132 fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan {
133 if (cols == 0 or rows == 0) return null;
134 const max_col = @as(u32, cols) - 1;
135 const max_row = @as(u32, rows) - 1;
136 const normalized = span.normalized();
137 if (normalized.start.row > max_row) return null;
138 if (normalized.start.row == normalized.end.row and normalized.start.col > max_col) return null;
139 return SelectionSpan{
140 .start = .{
141 .col = @min(normalized.start.col, max_col),
142 .row = @min(normalized.start.row, max_row),
143 },
144 .end = .{
145 .col = @min(normalized.end.col, max_col),
146 .row = @min(normalized.end.row, max_row),
147 },
148 };
149 }
150 ```
151
152 - [ ] **Step 4: Run test to verify it passes**
153
154 Run: `zig build test --summary all`
155 Expected:
156 - PASS for the new selection-helper tests.
157
158 Note:
159 - `SelectionSpan` is an inclusive visible span and may legitimately collapse to one visible cell after resize.
160 - Click-without-drag emptiness will be handled later by `SelectionState` in Task 4 rather than by the span primitive itself.
161
162 - [ ] **Step 5: Commit**
163
164 ```bash
165 git add src/main.zig
166 git commit -m "Add visible selection helpers"
167 ```
168
169 ### Task 2: Add visible-text extraction and copy shortcut coverage
170
171 **Files:**
172 - Modify: `src/main.zig`
173 - Modify: `src/vt.zig`
174 - Test: `src/main.zig`
175
176 - [ ] **Step 1: Write the failing extraction and copy tests**
177
178 Add the shortcut test next to `isClipboardPasteEvent`, and add focused extraction tests:
179
180 ```zig
181 test "isClipboardCopyEvent matches Ctrl-Shift-C press" {
182 try std.testing.expect(isClipboardCopyEvent(.{
183 .keysym = c.XKB_KEY_C,
184 .modifiers = .{ .ctrl = true, .shift = true },
185 .action = .press,
186 .utf8 = [_]u8{0} ** 8,
187 .utf8_len = 0,
188 }));
189 try std.testing.expect(!isClipboardCopyEvent(.{
190 .keysym = c.XKB_KEY_C,
191 .modifiers = .{ .ctrl = true, .shift = true },
192 .action = .repeat,
193 .utf8 = [_]u8{0} ** 8,
194 .utf8_len = 0,
195 }));
196 }
197 ```
198
199 ```zig
200 test "extractSelectedText trims trailing blanks on each visible row" {
201 var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 6, .rows = 2 });
202 defer term.deinit();
203
204 term.write("abc \r\ndef ");
205 try term.snapshot();
206
207 const text = try extractSelectedText(
208 std.testing.allocator,
209 &term.render_state.row_data,
210 SelectionSpan{
211 .start = .{ .col = 0, .row = 0 },
212 .end = .{ .col = 5, .row = 1 },
213 },
214 );
215 defer std.testing.allocator.free(text);
216
217 try std.testing.expectEqualStrings("abc\ndef", text);
218 }
219
220 test "extractSelectedText respects partial first and last rows" {
221 var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 6, .rows = 2 });
222 defer term.deinit();
223
224 term.write("abcdef\r\nuvwxyz");
225 try term.snapshot();
226
227 const text = try extractSelectedText(
228 std.testing.allocator,
229 &term.render_state.row_data,
230 SelectionSpan{
231 .start = .{ .col = 2, .row = 0 },
232 .end = .{ .col = 3, .row = 1 },
233 },
234 );
235 defer std.testing.allocator.free(text);
236
237 try std.testing.expectEqualStrings("cdef\nuvwx", text);
238 }
239 ```
240
241 - [ ] **Step 2: Run test to verify it fails**
242
243 Run: `zig build test --summary all`
244 Expected:
245 - FAIL because `isClipboardCopyEvent` and `extractSelectedText` do not exist yet, or because cell text extraction is not wired.
246
247 - [ ] **Step 3: Implement minimal visible-text extraction**
248
249 In `src/main.zig`, add:
250
251 ```zig
252 fn isClipboardCopyEvent(ev: wayland_client.KeyboardEvent) bool {
253 return ev.action == .press and
254 ev.modifiers.ctrl and
255 ev.modifiers.shift and
256 ev.keysym == c.XKB_KEY_C;
257 }
258 ```
259
260 Add a row/cell extraction path that walks `term.render_state.row_data` in reading order:
261
262 ```zig
263 fn extractSelectedText(
264 alloc: std.mem.Allocator,
265 row_data: anytype,
266 span: SelectionSpan,
267 ) ![]u8 {
268 const normalized = span.normalized();
269 var out = std.ArrayList(u8).empty;
270 defer out.deinit(alloc);
271
272 const rows = row_data.items(.cells);
273 var row_idx = normalized.start.row;
274 while (row_idx <= normalized.end.row and row_idx < rows.len) : (row_idx += 1) {
275 const row = rows[row_idx];
276 const first_col: u32 = if (row_idx == normalized.start.row) normalized.start.col else 0;
277 const last_col: u32 = if (row_idx == normalized.end.row) normalized.end.col else @intCast(row.items(.raw).len - 1);
278 try appendSelectedRowText(alloc, &out, row, first_col, last_col);
279 if (row_idx != normalized.end.row) try out.append(alloc, '\n');
280 }
281
282 return try out.toOwnedSlice(alloc);
283 }
284 ```
285
286 If `src/vt.zig` needs a helper to expose printable text for a render-state cell, add only that helper, for example:
287
288 ```zig
289 pub fn cellCodepoint(cell: ghostty_vt.RenderState.Cell) u21 {
290 return cell.raw.codepoint();
291 }
292 ```
293
294 - [ ] **Step 4: Run test to verify it passes**
295
296 Run: `zig build test --summary all`
297 Expected:
298 - PASS for the new copy-shortcut and selected-text extraction tests.
299
300 - [ ] **Step 5: Commit**
301
302 ```bash
303 git add src/main.zig src/vt.zig
304 git commit -m "Add visible selection copy extraction"
305 ```
306
307 ### Task 3: Add Wayland pointer events and clipboard ownership plumbing
308
309 **Files:**
310 - Modify: `src/wayland.zig`
311 - Test: `src/wayland.zig`
312
313 - [ ] **Step 1: Write the failing Wayland helper tests**
314
315 Add focused tests for logic that can run without a compositor:
316
317 ```zig
318 test "clampSurfacePointToGrid caps pointer coordinates to visible cells" {
319 const point = clampSurfacePointToGrid(9999.0, 9999.0, 8, 16, 80, 24).?;
320 try std.testing.expectEqual(@as(u32, 79), point.col);
321 try std.testing.expectEqual(@as(u32, 23), point.row);
322 }
323
324 test "ClipboardSelection.init stores offered UTF-8 bytes" {
325 const selection = ClipboardSelection.init("hello");
326 try std.testing.expectEqualStrings("hello", selection.text);
327 }
328 ```
329
330 - [ ] **Step 2: Run test to verify it fails**
331
332 Run: `zig build test --summary all`
333 Expected:
334 - FAIL because pointer/grid helpers and clipboard-source state do not exist yet.
335
336 - [ ] **Step 3: Implement pointer queue and clipboard source support**
337
338 In `src/wayland.zig`, add pointer types alongside `KeyboardEvent`:
339
340 ```zig
341 pub const PointerEvent = union(enum) {
342 enter: struct { surface_x: f64, surface_y: f64 },
343 leave: void,
344 motion: struct { surface_x: f64, surface_y: f64 },
345 button_press: struct { button: u32 },
346 button_release: struct { button: u32 },
347 };
348 ```
349
350 Add a `Pointer` wrapper parallel to `Keyboard`:
351
352 ```zig
353 pub const Pointer = struct {
354 alloc: std.mem.Allocator,
355 wl_pointer: *wl.Pointer,
356 event_queue: std.ArrayList(PointerEvent),
357
358 pub fn init(alloc: std.mem.Allocator, seat: *wl.Seat) !*Pointer { ... }
359 pub fn deinit(self: *Pointer) void { ... }
360 };
361 ```
362
363 Register the listener with `seat.getPointer()` and append `.enter`, `.leave`, `.motion`, `.button_press`, and `.button_release` events in `pointerListener`.
364
365 Extend `Clipboard` to hold source-side state and export text:
366
367 ```zig
368 const ClipboardSelection = struct {
369 text: []const u8,
370
371 fn init(text: []const u8) ClipboardSelection {
372 return .{ .text = text };
373 }
374 };
375 ```
376
377 Add a setter on `Clipboard`:
378
379 ```zig
380 pub fn setSelectionText(self: *Clipboard, text: []const u8) !void { ... }
381 ```
382
383 That method should:
384 - duplicate the copied text into owned storage
385 - create a `wl_data_source`
386 - offer `text/plain;charset=utf-8` and `text/plain`
387 - install a listener that writes the stored bytes to the requester fd on `.send`
388 - call `data_device.setSelection(...)`
389 - clean up the previous source on replacement or cancellation
390
391 - [ ] **Step 4: Run test to verify it passes**
392
393 Run: `zig build test --summary all`
394 Expected:
395 - PASS for the new pure/helper tests in `src/wayland.zig`.
396
397 - [ ] **Step 5: Commit**
398
399 ```bash
400 git add src/wayland.zig
401 git commit -m "Add Wayland pointer and clipboard source support"
402 ```
403
404 ### Task 4: Wire pointer-driven visible selection into the main loop and rendering
405
406 **Files:**
407 - Modify: `src/main.zig`
408 - Modify: `src/wayland.zig`
409 - Test: `src/main.zig`
410
411 - [ ] **Step 1: Write the failing interaction/render tests**
412
413 Add tests for the state machine and selected color override:
414
415 ```zig
416 test "SelectionState starts drag on left-button press and commits on release" {
417 var state = SelectionState{};
418 handlePointerSelectionEvent(&state, .{ .motion = .{ .surface_x = 24.0, .surface_y = 16.0 } }, 8, 16, 80, 24);
419 handlePointerSelectionEvent(&state, .{ .button_press = .{ .button = BTN_LEFT } }, 8, 16, 80, 24);
420 handlePointerSelectionEvent(&state, .{ .motion = .{ .surface_x = 56.0, .surface_y = 16.0 } }, 8, 16, 80, 24);
421 handlePointerSelectionEvent(&state, .{ .button_release = .{ .button = BTN_LEFT } }, 8, 16, 80, 24);
422
423 try std.testing.expect(state.active == null);
424 try std.testing.expect(state.committed != null);
425 }
426
427 test "selectionColors overrides terminal colors for selected cells" {
428 const selected = selectionColors(.{
429 .fg = .{ 1.0, 1.0, 1.0, 1.0 },
430 .bg = .{ 0.0, 0.0, 0.0, 1.0 },
431 }, true);
432 try std.testing.expectEqualDeep([4]f32{ 0.08, 0.08, 0.08, 1.0 }, selected.fg);
433 try std.testing.expectEqualDeep([4]f32{ 0.78, 0.82, 0.88, 1.0 }, selected.bg);
434 }
435 ```
436
437 - [ ] **Step 2: Run test to verify it fails**
438
439 Run: `zig build test --summary all`
440 Expected:
441 - FAIL because `SelectionState`, `handlePointerSelectionEvent`, and `selectionColors` do not exist yet.
442
443 - [ ] **Step 3: Implement pointer-driven selection and selection-aware rendering**
444
445 In `src/main.zig`, add UI state:
446
447 ```zig
448 const SelectionState = struct {
449 hover: ?GridPoint = null,
450 anchor: ?GridPoint = null,
451 active: ?SelectionSpan = null,
452 committed: ?SelectionSpan = null,
453 };
454 ```
455
456 Create a small helper that maps pointer coordinates to cells using surface-space cell dimensions:
457
458 ```zig
459 fn surfacePointToGrid(
460 surface_x: f64,
461 surface_y: f64,
462 cell_w: u32,
463 cell_h: u32,
464 cols: u16,
465 rows: u16,
466 ) ?GridPoint { ... }
467 ```
468
469 Drain the pointer queue in the main loop after `dispatchPending()` and before rendering:
470
471 ```zig
472 for (pointer.event_queue.items) |ev| {
473 handlePointerSelectionEvent(&selection, ev, surf_cell_w, surf_cell_h, cols, rows);
474 }
475 pointer.event_queue.clearRetainingCapacity();
476 ```
477
478 Thread the current committed-or-active span into row rebuilds:
479
480 ```zig
481 const current_selection = activeSelectionSpan(selection, cols, rows);
482 ```
483
484 Update `rebuildRowInstances` to accept `selection: ?SelectionSpan` and use `selectionColors(...)` when `selection.containsCell(col_idx, row_idx)` is true before calling `appendCellInstances`.
485
486 On grid resize:
487
488 ```zig
489 selection.committed = if (selection.committed) |span| clampSelectionSpan(span, cols, rows) else null;
490 selection.active = if (selection.active) |span| clampSelectionSpan(span, cols, rows) else null;
491 selection.anchor = if (selection.anchor) |point| clampGridPoint(point, cols, rows) else null;
492 selection.hover = if (selection.hover) |point| clampGridPoint(point, cols, rows) else null;
493 ```
494
495 - [ ] **Step 4: Run test to verify it passes**
496
497 Run: `zig build test --summary all`
498 Expected:
499 - PASS for the selection interaction and render-color tests.
500
501 - [ ] **Step 5: Commit**
502
503 ```bash
504 git add src/main.zig src/wayland.zig
505 git commit -m "Highlight visible text selection"
506 ```
507
508 ### Task 5: Connect `Ctrl+Shift+C` to Wayland clipboard export
509
510 **Files:**
511 - Modify: `src/main.zig`
512 - Modify: `src/wayland.zig`
513 - Test: `src/main.zig`
514
515 - [ ] **Step 1: Write the failing copy integration test**
516
517 Add a small testable helper around the copy path:
518
519 ```zig
520 test "copySelection returns false for empty visible selection" {
521 var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 4, .rows = 1 });
522 defer term.deinit();
523
524 try std.testing.expect(!try copySelectionText(
525 std.testing.allocator,
526 null,
527 &term,
528 null,
529 ));
530 }
531 ```
532
533 - [ ] **Step 2: Run test to verify it fails**
534
535 Run: `zig build test --summary all`
536 Expected:
537 - FAIL because the copy helper does not exist yet.
538
539 - [ ] **Step 3: Implement the explicit copy path**
540
541 In `src/main.zig`, factor the copy path into a helper:
542
543 ```zig
544 fn copySelectionText(
545 alloc: std.mem.Allocator,
546 clipboard: ?*wayland_client.Clipboard,
547 term: *vt.Terminal,
548 selection: ?SelectionSpan,
549 ) !bool {
550 const cb = clipboard orelse return false;
551 const span = selection orelse return false;
552 const text = try extractSelectedText(alloc, &term.render_state.row_data, span);
553 defer alloc.free(text);
554 if (text.len == 0) return false;
555 try cb.setSelectionText(text);
556 return true;
557 }
558 ```
559
560 Wire it into keyboard handling after `term.snapshot()` has produced current visible rows:
561
562 ```zig
563 if (isClipboardCopyEvent(ev)) {
564 _ = try copySelectionText(alloc, clipboard, term, activeSelectionSpan(selection, cols, rows));
565 continue;
566 }
567 ```
568
569 Keep the existing `Ctrl+Shift+V` paste path unchanged.
570
571 - [ ] **Step 4: Run test to verify it passes**
572
573 Run: `zig build test --summary all`
574 Expected:
575 - PASS for the new empty-selection copy helper test and all prior tests.
576
577 - [ ] **Step 5: Commit**
578
579 ```bash
580 git add src/main.zig src/wayland.zig
581 git commit -m "Copy visible selection to clipboard"
582 ```
583
584 ### Task 6: Full verification
585
586 **Files:**
587 - Modify: none
588 - Test: `src/main.zig`, `src/wayland.zig`, `src/vt.zig`
589
590 - [ ] **Step 1: Run the full test suite**
591
592 Run: `rm -rf /tmp/zig-global-cache-selection-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-selection-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-selection-plan zig build test --summary all`
593 Expected:
594 - PASS
595
596 - [ ] **Step 2: Run a build verification**
597
598 Run: `rm -rf /tmp/zig-global-cache-selection-build && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-selection-build && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-selection-build zig build`
599 Expected:
600 - PASS
601
602 - [ ] **Step 3: Run a manual Wayland verification**
603
604 Run: `zig build run`
605 Expected:
606 - Left-button drag highlights visible text.
607 - Right-to-left drag normalizes correctly.
608 - `Ctrl+Shift+C` copies the highlighted text.
609 - `Ctrl+Shift+V` paste still works.
610 - Resize does not crash or leave out-of-bounds selection state.
611
612 - [ ] **Step 4: Commit**
613
614 ```bash
615 git add src/main.zig src/wayland.zig src/vt.zig
616 git commit -m "Verify visible text selection flow"
617 ```
618
619 ## Self-Review
620
621 - Spec coverage:
622 - Visible-grid drag selection: Task 4
623 - Persistent highlight after release: Task 4
624 - `Ctrl+Shift+C` clipboard export: Task 5
625 - Wayland clipboard ownership: Task 3
626 - Resize-safe behavior: Task 1 and Task 4
627 - Tests and manual verification: Tasks 1, 2, 3, 4, 5, and 6
628 - Placeholder scan:
629 - No `TODO`, `TBD`, or deferred “figure it out later” steps remain.
630 - Type consistency:
631 - The plan consistently uses `GridPoint`, `SelectionSpan`, `SelectionState`, `extractSelectedText`, `isClipboardCopyEvent`, and `setSelectionText`.