a73x

docs/superpowers/plans/2026-04-09-hidpi-support-implementation.md

Ref:   Size: 38.3 KiB

# HiDPI Support Implementation Plan

> **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.

**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.

**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.

**Tech Stack:** Zig 0.15, `zig-wayland` (wl_compositor v6, wl_output v4), FreeType, Vulkan, existing `renderer.zig` / `font.zig` / `wayland.zig`.

---

## File Structure

- Create: `src/scale_tracker.zig`
  - 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.
- Modify: `src/wayland.zig`
  - Bind `wl_output` in `registryListener`; attach output listeners that push scale/done into the tracker.
  - Add an `Output` struct (handle + proxy listener context).
  - Extend `Window` with a pointer to the connection's `ScaleTracker` plus a generation counter incremented when `enter`/`leave` changes the effective scale.
  - Add a `Window.bufferScale()` helper.
- Modify: `src/font.zig`
  - Add `Atlas.reset()` that clears the cache + zeroes pixels + resets cursors + marks dirty.
  - Add `Face.reinit()` that deinits freetype state and re-opens at a new `px_size`.
- Modify: `src/main.zig`
  - 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.
  - Add a `rebuildForScale()` helper shared by `runTerminal` and `runTextCoverageCompare`.
  - Integrate scale changes into both loops' resize-handling blocks.
- Modify: `src/renderer.zig`
  - No structural changes; `recreateSwapchain(width, height)` continues to take buffer‑pixel dimensions. (`drawCells` already receives `cell_size` via push constants.)
- Test: `src/scale_tracker.zig` — unit tests for the tracker.
- Test: `src/wayland.zig` — wiring smoke test that the `Output` add/remove path feeds the tracker when exercised manually (fake events).
- Test: `src/font.zig` — test `Atlas.reset()` and `Face.reinit()` behavior.
- Test: `src/main.zig` — test that `buildTextCoverageCompareScene` still produces the same grid after the refactor (no regression).

### Commit cadence

One commit per task unless explicitly noted. Never squash visual‑verification work into the code commit.

### Manual verification plan

Manual verification happens at the end of the plan on the real dual-monitor setup:
- Run `./zig-out/bin/waystty --text-compare` on DP‑5 (scale 1.0): text should be crisp (baseline regression check).
- Drag the same window to DP‑4 (Studio Display, scale 2.0): text should remain crisp (the feature).
- Run `./zig-out/bin/waystty` on DP‑4: terminal should be crisp.
- Drag between outputs while typing: no glyph corruption; brief re-layout is OK.

---

### Task 1: Pure ScaleTracker data struct + tests

**Files:**
- Create: `src/scale_tracker.zig`
- Test: `src/scale_tracker.zig` (inline tests)

- [ ] **Step 1: Write the failing tests**

Create `src/scale_tracker.zig` with the tests first (no implementation yet):

```zig
const std = @import("std");

pub const ScaleTracker = struct {
    // implementation added in Step 3
};

test "new tracker reports default scale of 1" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "entered output scale is reflected in bufferScale" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(1);
    t.setOutputScale(1, 2);
    try t.enterOutput(1);
    try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
}

test "not-yet-entered output does not change bufferScale" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(7);
    t.setOutputScale(7, 3);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "bufferScale is max across entered outputs" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(1);
    try t.addOutput(2);
    t.setOutputScale(1, 1);
    t.setOutputScale(2, 2);

    try t.enterOutput(1);
    try t.enterOutput(2);
    try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
}

test "leaving an output drops its contribution" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(1);
    try t.addOutput(2);
    t.setOutputScale(1, 2);
    t.setOutputScale(2, 3);
    try t.enterOutput(1);
    try t.enterOutput(2);
    try std.testing.expectEqual(@as(i32, 3), t.bufferScale());

    t.leaveOutput(2);
    try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
}

test "removing an unknown output is a no-op" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();
    t.removeOutput(999);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "removeOutput also removes it from entered set" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(5);
    t.setOutputScale(5, 4);
    try t.enterOutput(5);
    try std.testing.expectEqual(@as(i32, 4), t.bufferScale());

    t.removeOutput(5);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "setOutputScale on unknown id is a no-op" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();
    t.setOutputScale(999, 5);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}
```

- [ ] **Step 2: Register the module and run tests to verify they fail**

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):

```zig
    const scale_tracker_mod = b.createModule(.{
        .root_source_file = b.path("src/scale_tracker.zig"),
        .target = target,
        .optimize = optimize,
    });
```

Then add a dedicated test module and register it with `test_step`. Insert after the `pty_tests` block (after line 102):

```zig
    // Test scale_tracker.zig
    const scale_tracker_test_mod = b.createModule(.{
        .root_source_file = b.path("src/scale_tracker.zig"),
        .target = target,
        .optimize = optimize,
    });
    const scale_tracker_tests = b.addTest(.{
        .root_module = scale_tracker_test_mod,
    });
    test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step);
```

Finally, wire the module into `wayland_mod` so Task 2 can `@import("scale_tracker")`. Add after `wayland_mod.linkSystemLibrary("xkbcommon", .{});` (around line 41):

```zig
    wayland_mod.addImport("scale_tracker", scale_tracker_mod);
```

Then run:

```bash
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
```

Expected: FAIL — tests reference `ScaleTracker.init`, `bufferScale`, `addOutput`, `setOutputScale`, `enterOutput`, `leaveOutput`, `removeOutput` which are undefined.

- [ ] **Step 3: Implement ScaleTracker**

Replace the stub in `src/scale_tracker.zig` with:

```zig
const std = @import("std");

pub const OutputId = u32;

pub const ScaleTracker = struct {
    alloc: std.mem.Allocator,
    scales: std.AutoHashMapUnmanaged(OutputId, i32),
    entered: std.AutoHashMapUnmanaged(OutputId, void),

    pub fn init(alloc: std.mem.Allocator) ScaleTracker {
        return .{
            .alloc = alloc,
            .scales = .empty,
            .entered = .empty,
        };
    }

    pub fn deinit(self: *ScaleTracker) void {
        self.scales.deinit(self.alloc);
        self.entered.deinit(self.alloc);
    }

    pub fn addOutput(self: *ScaleTracker, id: OutputId) !void {
        try self.scales.put(self.alloc, id, 1);
    }

    pub fn setOutputScale(self: *ScaleTracker, id: OutputId, scale: i32) void {
        if (self.scales.getPtr(id)) |slot| slot.* = scale;
    }

    pub fn removeOutput(self: *ScaleTracker, id: OutputId) void {
        _ = self.scales.remove(id);
        _ = self.entered.remove(id);
    }

    pub fn enterOutput(self: *ScaleTracker, id: OutputId) !void {
        try self.entered.put(self.alloc, id, {});
    }

    pub fn leaveOutput(self: *ScaleTracker, id: OutputId) void {
        _ = self.entered.remove(id);
    }

    pub fn bufferScale(self: *const ScaleTracker) i32 {
        var max_scale: i32 = 1;
        var it = self.entered.iterator();
        while (it.next()) |entry| {
            const id = entry.key_ptr.*;
            if (self.scales.get(id)) |s| {
                if (s > max_scale) max_scale = s;
            }
        }
        return max_scale;
    }
};
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: all 8 `ScaleTracker` tests PASS, plus all pre-existing tests remain passing.

- [ ] **Step 5: Commit**

```bash
git add src/scale_tracker.zig build.zig
git commit -m "Add pure ScaleTracker for wl_output scale tracking"
```

### Task 2: Bind wl_output in the registry and feed ScaleTracker

**Files:**
- Modify: `src/wayland.zig`

- [ ] **Step 1: Import the tracker and add an Output proxy struct**

Add near the top of `src/wayland.zig`, after the existing imports:

```zig
const ScaleTracker = @import("scale_tracker").ScaleTracker;
```

Add a new pub struct above `Globals`:

```zig
pub const Output = struct {
    wl_output: *wl.Output,
    name: u32,
    tracker: *ScaleTracker,
    pending_scale: i32 = 1,
};
```

- [ ] **Step 2: Extend Globals + Connection to own a ScaleTracker and an outputs list**

Update `Globals`:

```zig
pub const Globals = struct {
    compositor: ?*wl.Compositor = null,
    wm_base: ?*xdg.WmBase = null,
    seat: ?*wl.Seat = null,
    data_device_manager: ?*wl.DataDeviceManager = null,
};
```

Leave `Globals` alone; the outputs list and the tracker live on `Connection` because they need an allocator. Change `Connection`:

```zig
pub const Connection = struct {
    display: *wl.Display,
    registry: *wl.Registry,
    globals: Globals,
    alloc: std.mem.Allocator,
    scale_tracker: ScaleTracker,
    outputs: std.ArrayListUnmanaged(*Output),

    pub fn init(alloc: std.mem.Allocator) !Connection {
        const display = try wl.Display.connect(null);
        errdefer display.disconnect();

        const registry = try display.getRegistry();
        errdefer registry.destroy();

        var conn = Connection{
            .display = display,
            .registry = registry,
            .globals = Globals{},
            .alloc = alloc,
            .scale_tracker = ScaleTracker.init(alloc),
            .outputs = .empty,
        };

        registry.setListener(*Connection, registryListener, &conn);

        if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
        // Second roundtrip so each wl_output's initial scale/done events are received.
        if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;

        if (conn.globals.compositor == null) return error.NoCompositor;
        if (conn.globals.wm_base == null) return error.NoXdgWmBase;
        if (conn.globals.seat == null) return error.NoSeat;

        return conn;
    }

    pub fn deinit(self: *Connection) void {
        for (self.outputs.items) |out| {
            out.wl_output.release();
            self.alloc.destroy(out);
        }
        self.outputs.deinit(self.alloc);
        self.scale_tracker.deinit();
        self.display.disconnect();
    }

    // createWindow stays; see Task 3.
};
```

Note: `Connection.init` now takes an allocator. This breaks every call site — the next step fixes them.

- [ ] **Step 3: Rewrite registryListener to handle wl_output**

Replace `registryListener` with:

```zig
fn registryListener(
    registry: *wl.Registry,
    event: wl.Registry.Event,
    conn: *Connection,
) void {
    switch (event) {
        .global => |g| {
            const iface = std.mem.span(g.interface);
            if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) {
                conn.globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(wl.DataDeviceManager.interface.name))) {
                conn.globals.data_device_manager = registry.bind(g.name, wl.DataDeviceManager, 3) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
                conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(wl.Seat.interface.name))) {
                conn.globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(wl.Output.interface.name))) {
                const wl_out = registry.bind(g.name, wl.Output, 4) catch return;
                const out = conn.alloc.create(Output) catch {
                    wl_out.release();
                    return;
                };
                out.* = .{
                    .wl_output = wl_out,
                    .name = g.name,
                    .tracker = &conn.scale_tracker,
                };
                conn.outputs.append(conn.alloc, out) catch {
                    wl_out.release();
                    conn.alloc.destroy(out);
                    return;
                };
                conn.scale_tracker.addOutput(g.name) catch {};
                wl_out.setListener(*Output, outputListener, out);
            }
        },
        .global_remove => |g| {
            var i: usize = 0;
            while (i < conn.outputs.items.len) : (i += 1) {
                const out = conn.outputs.items[i];
                if (out.name == g.name) {
                    conn.scale_tracker.removeOutput(out.name);
                    out.wl_output.release();
                    conn.alloc.destroy(out);
                    _ = conn.outputs.swapRemove(i);
                    return;
                }
            }
        },
    }
}

fn outputListener(
    _: *wl.Output,
    event: wl.Output.Event,
    out: *Output,
) void {
    switch (event) {
        .scale => |s| {
            out.pending_scale = s.factor;
        },
        .done => {
            out.tracker.setOutputScale(out.name, out.pending_scale);
        },
        .geometry, .mode, .name, .description => {},
    }
}
```

- [ ] **Step 4: Fix call sites**

Update every call to `Connection.init()` in the codebase to pass the allocator. Run:

```bash
grep -rn "wayland_client.Connection.init" src/
```

Expected matches: `runTerminal`, `runTextCoverageCompare`, plus any smoke test functions (`runWaylandSmokeTest`, etc). For each, change `wayland_client.Connection.init()` to `wayland_client.Connection.init(alloc)`.

- [ ] **Step 5: Build and run full test suite**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: PASS. No new tests yet — this step just verifies we didn't break the build or existing tests.

- [ ] **Step 6: Manual smoke test — verify outputs get tracked**

Add a temporary debug print at the end of `Connection.init` (after the second roundtrip) that prints scales for each bound output:

```zig
for (conn.outputs.items) |out| {
    std.debug.print("wl_output name={d} scale={d}\n", .{ out.name, conn.scale_tracker.scales.get(out.name) orelse 0 });
}
```

Build and run:

```bash
zig build
./zig-out/bin/waystty --text-compare
```

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).

**Remove the debug print before committing.**

- [ ] **Step 7: Commit**

```bash
git add src/wayland.zig src/main.zig
git commit -m "Bind wl_output globals into ScaleTracker"
```

### Task 3: Track surface enter/leave and expose bufferScale on Window

**Files:**
- Modify: `src/wayland.zig`
- Test: `src/wayland.zig` (inline test for tracker reaction to simulated enter/leave)

- [ ] **Step 1: Write a failing test for enter/leave plumbing**

Add this test at the bottom of `src/wayland.zig`:

```zig
test "Window.bufferScale reflects ScaleTracker entered outputs" {
    var tracker = ScaleTracker.init(std.testing.allocator);
    defer tracker.deinit();

    try tracker.addOutput(1);
    try tracker.addOutput(2);
    tracker.setOutputScale(1, 1);
    tracker.setOutputScale(2, 2);

    // Simulate the bits Window.bufferScale delegates to.
    try tracker.enterOutput(2);
    try std.testing.expectEqual(@as(i32, 2), tracker.bufferScale());

    tracker.leaveOutput(2);
    try std.testing.expectEqual(@as(i32, 1), tracker.bufferScale());
}
```

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.

- [ ] **Step 2: Extend Window to hold tracker pointer and generation counter**

Update `Window`:

```zig
pub const Window = struct {
    alloc: std.mem.Allocator,
    surface: *wl.Surface,
    xdg_surface: *xdg.Surface,
    xdg_toplevel: *xdg.Toplevel,
    tracker: *ScaleTracker,
    scale_generation: u64 = 0,
    applied_buffer_scale: i32 = 1,
    configured: bool = false,
    should_close: bool = false,
    width: u32 = 800,
    height: u32 = 600,

    pub fn deinit(self: *Window) void {
        self.xdg_toplevel.destroy();
        self.xdg_surface.destroy();
        self.surface.destroy();
        self.alloc.destroy(self);
    }

    pub fn setTitle(self: *Window, title: ?[:0]const u8) void {
        self.xdg_toplevel.setTitle((title orelse "waystty"));
    }

    pub fn bufferScale(self: *const Window) i32 {
        return self.tracker.bufferScale();
    }
};
```

- [ ] **Step 3: Wire the wl_surface enter/leave listener in createWindow**

Replace `createWindow` with:

```zig
pub fn createWindow(self: *Connection, alloc: std.mem.Allocator, title: [*:0]const u8) !*Window {
    const compositor = self.globals.compositor orelse return error.NoCompositor;
    const wm_base = self.globals.wm_base orelse return error.NoXdgWmBase;

    const window = try alloc.create(Window);
    errdefer alloc.destroy(window);

    window.* = .{
        .alloc = alloc,
        .surface = try compositor.createSurface(),
        .xdg_surface = undefined,
        .xdg_toplevel = undefined,
        .tracker = &self.scale_tracker,
    };
    errdefer window.surface.destroy();

    window.surface.setListener(*Window, surfaceListener, window);

    window.xdg_surface = try wm_base.getXdgSurface(window.surface);
    errdefer window.xdg_surface.destroy();

    window.xdg_toplevel = try window.xdg_surface.getToplevel();

    window.xdg_toplevel.setTitle(title);
    window.xdg_toplevel.setAppId("waystty");

    window.xdg_surface.setListener(*Window, xdgSurfaceListener, window);
    window.xdg_toplevel.setListener(*Window, xdgToplevelListener, window);
    wm_base.setListener(*xdg.WmBase, wmBaseListener, wm_base);

    window.surface.commit();
    _ = self.display.roundtrip();

    return window;
}
```

Add the listener function next to the existing `xdgSurfaceListener`:

```zig
fn surfaceListener(
    _: *wl.Surface,
    event: wl.Surface.Event,
    window: *Window,
) void {
    switch (event) {
        .enter => |e| {
            window.handleSurfaceEnter(e.output);
            window.scale_generation += 1;
        },
        .leave => |e| {
            window.handleSurfaceLeave(e.output);
            window.scale_generation += 1;
        },
        .preferred_buffer_scale => {},
        .preferred_buffer_transform => {},
    }
}
```

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`:

```zig
    tracker: *ScaleTracker,
    outputs: *std.ArrayListUnmanaged(*Output),
```

Populate it in `createWindow`:

```zig
    window.* = .{
        .alloc = alloc,
        .surface = try compositor.createSurface(),
        .xdg_surface = undefined,
        .xdg_toplevel = undefined,
        .tracker = &self.scale_tracker,
        .outputs = &self.outputs,
    };
```

And implement the lookup helpers on `Window`:

```zig
    pub fn handleSurfaceEnter(self: *Window, wl_out: *wl.Output) void {
        for (self.outputs.items) |out| {
            if (out.wl_output == wl_out) {
                self.tracker.enterOutput(out.name) catch {};
                return;
            }
        }
    }

    pub fn handleSurfaceLeave(self: *Window, wl_out: *wl.Output) void {
        for (self.outputs.items) |out| {
            if (out.wl_output == wl_out) {
                self.tracker.leaveOutput(out.name);
                return;
            }
        }
    }
```

- [ ] **Step 4: Run tests and build**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: all tests PASS including the new `Window.bufferScale reflects ScaleTracker entered outputs` test.

- [ ] **Step 5: Commit**

```bash
git add src/wayland.zig
git commit -m "Track wl_surface enter/leave on Window"
```

### Task 4: Atlas reset + Face reinit so rasterization can follow the scale

**Files:**
- Modify: `src/font.zig`
- Test: `src/font.zig`

- [ ] **Step 1: Write failing tests**

Append to `src/font.zig`:

```zig
test "Atlas.reset clears cache and starts fresh" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();

    var atlas = try Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    _ = try atlas.getOrInsert(&face, 'A');
    try std.testing.expect(atlas.cache.count() > 0);

    atlas.reset();
    try std.testing.expectEqual(@as(u32, 1), @as(u32, @intCast(atlas.cache.count() + 1))); // cache empty
    try std.testing.expectEqual(@as(u8, 255), atlas.pixels[0]);
    try std.testing.expect(atlas.dirty);

    // Re-inserting the same glyph should succeed after reset.
    _ = try atlas.getOrInsert(&face, 'A');
}

test "Face.reinit switches px_size and produces different cell metrics" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();
    const small_cell = face.cellWidth();

    try face.reinit(lookup.path, lookup.index, 28);
    const large_cell = face.cellWidth();

    try std.testing.expect(large_cell > small_cell);
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: FAIL — `Atlas.reset` and `Face.reinit` are undefined.

- [ ] **Step 3: Implement Atlas.reset and Face.reinit**

Add to `Atlas` (next to `deinit`):

```zig
    pub fn reset(self: *Atlas) void {
        @memset(self.pixels, 0);
        self.pixels[0] = 255;
        self.cursor_x = 1;
        self.cursor_y = 0;
        self.row_height = 1;
        self.cache.clearRetainingCapacity();
        self.dirty = true;
    }
```

Add to `Face` (next to `deinit`):

```zig
    pub fn reinit(
        self: *Face,
        path: [:0]const u8,
        index: c_int,
        px_size: u32,
    ) !void {
        _ = c.FT_Done_Face(self.face);
        self.face = null;

        var new_face: c.FT_Face = null;
        if (c.FT_New_Face(self.library, path.ptr, index, &new_face) != 0) return error.FtNewFaceFailed;
        errdefer _ = c.FT_Done_Face(new_face);

        if (c.FT_Set_Pixel_Sizes(new_face, 0, px_size) != 0) return error.FtSetPixelSizesFailed;

        self.face = new_face;
        self.px_size = px_size;
    }
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: all tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/font.zig
git commit -m "Add Atlas.reset and Face.reinit for scale changes"
```

### Task 5: Wire dynamic scale into runTextCoverageCompare

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.

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Extract a rebuild helper**

Near the top of `src/main.zig` (after `GridSize`), add:

```zig
const ScaledGeometry = struct {
    buffer_scale: i32,
    px_size: u32,
    cell_w_px: u32, // buffer pixels
    cell_h_px: u32, // buffer pixels
    baseline_px: u32,
};

fn rebuildFaceForScale(
    face: *font.Face,
    atlas: *font.Atlas,
    font_path: [:0]const u8,
    font_index: c_int,
    base_px_size: u32,
    buffer_scale: i32,
) !ScaledGeometry {
    const scale: u32 = @intCast(@max(@as(i32, 1), buffer_scale));
    const new_px = base_px_size * scale;
    try face.reinit(font_path, font_index, new_px);
    atlas.reset();
    return .{
        .buffer_scale = @intCast(scale),
        .px_size = new_px,
        .cell_w_px = face.cellWidth(),
        .cell_h_px = face.cellHeight(),
        .baseline_px = face.baseline(),
    };
}
```

- [ ] **Step 2: Update runTextCoverageCompare to respect scale**

Replace the body of `runTextCoverageCompare` with a version that:

1. Calls `rebuildFaceForScale` once up front at scale=1 to establish the base geometry.
2. Sets the surface size to `(panel_cols * variants) × rows` in surface coordinates.
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.

Concretely, replace lines 1759–1847 with:

```zig
fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();

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

    _ = conn.display.roundtrip();

    var font_lookup = try font.lookupConfiguredFont(alloc);
    defer font_lookup.deinit(alloc);

    var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
    defer face.deinit();

    var atlas = try font.Atlas.init(alloc, 2048, 2048);
    defer atlas.deinit();

    var geom: ScaledGeometry = .{
        .buffer_scale = 1,
        .px_size = config.font_size_px,
        .cell_w_px = face.cellWidth(),
        .cell_h_px = face.cellHeight(),
        .baseline_px = face.baseline(),
    };

    var scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
    defer scene.deinit(alloc);

    // Surface-coordinate window size (logical pixels).
    const surface_cell_w: u32 = geom.cell_w_px; // scale=1 initially
    const surface_cell_h: u32 = geom.cell_h_px;
    window.width = scene.window_cols * surface_cell_w;
    window.height = scene.window_rows * surface_cell_h;

    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width * @as(u32, @intCast(geom.buffer_scale)),
        window.height * @as(u32, @intCast(geom.buffer_scale)),
    );
    defer ctx.deinit();

    try ctx.uploadAtlas(atlas.pixels);
    atlas.dirty = false;
    try ctx.uploadInstances(scene.instances.items);

    const wl_fd = conn.display.getFd();
    var pollfds = [_]std.posix.pollfd{
        .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 },
    };
    var last_window_w = window.width;
    var last_window_h = window.height;
    var last_scale: i32 = geom.buffer_scale;

    while (!window.should_close) {
        _ = conn.display.flush();
        if (conn.display.prepareRead()) {
            pollfds[0].revents = 0;
            _ = std.posix.poll(&pollfds, 16) catch {};
            if (pollfds[0].revents & std.posix.POLL.IN != 0) {
                _ = conn.display.readEvents();
            } else {
                conn.display.cancelRead();
            }
        }
        _ = conn.display.dispatchPending();

        const current_scale = window.bufferScale();
        const scale_changed = current_scale != last_scale;
        const size_changed = window.width != last_window_w or window.height != last_window_h;

        if (scale_changed or size_changed) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);

            if (scale_changed) {
                geom = try rebuildFaceForScale(
                    &face,
                    &atlas,
                    font_lookup.path,
                    font_lookup.index,
                    config.font_size_px,
                    current_scale,
                );
                // Rebuild the scene against the fresh atlas.
                scene.deinit(alloc);
                scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);

                // The surface size is cells × (cell_px / scale). Since we raster at px_size*scale,
                // the surface_cell values are stable across scale changes.
                const new_surface_cell_w: u32 = geom.cell_w_px / @as(u32, @intCast(geom.buffer_scale));
                const new_surface_cell_h: u32 = geom.cell_h_px / @as(u32, @intCast(geom.buffer_scale));
                window.width = scene.window_cols * new_surface_cell_w;
                window.height = scene.window_rows * new_surface_cell_h;

                window.surface.setBufferScale(geom.buffer_scale);
                try ctx.uploadAtlas(atlas.pixels);
                atlas.dirty = false;
                try ctx.uploadInstances(scene.instances.items);
                last_scale = current_scale;
            }

            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);

            last_window_w = window.width;
            last_window_h = window.height;
        }

        drawTextCoverageCompareFrame(
            &ctx,
            &scene,
            geom.cell_w_px,
            geom.cell_h_px,
            .{ 0.0, 0.0, 0.0, 1.0 },
        ) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
                const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
                try ctx.recreateSwapchain(buf_w, buf_h);
                last_window_w = window.width;
                last_window_h = window.height;
                continue;
            },
            else => return err,
        };

        _ = conn.display.flush();
        std.Thread.sleep(16 * std.time.ns_per_ms);
    }

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

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.

- [ ] **Step 3: Run the test suite to confirm nothing regressed**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: PASS, including `buildTextCoverageCompareScene repeats the same specimen in four panels`.

- [ ] **Step 4: Manual verification on the 2× monitor**

```bash
zig build
./zig-out/bin/waystty --text-compare
```

- 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.
- Move the window to DP‑5 (Dell AW3225QF, scale 1.0). Expected: glyphs stay crisp. No visible re-layout flicker beyond the resize frame.

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.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Honor wl_output buffer scale in text-compare mode"
```

### Task 6: Wire dynamic scale into runTerminal

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Add scale handling to the terminal loop**

In `runTerminal`, after the existing `last_window_w` / `last_window_h` / `render_pending` variables are declared, add:

```zig
    var geom: ScaledGeometry = .{
        .buffer_scale = 1,
        .px_size = config.font_size_px,
        .cell_w_px = cell_w,
        .cell_h_px = cell_h,
        .baseline_px = baseline,
    };
    var last_scale: i32 = geom.buffer_scale;
    _ = &last_scale; // touched inside the loop
```

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.

- [ ] **Step 2: Detect scale changes inside the loop**

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:

```zig
        const current_scale = window.bufferScale();
        if (current_scale != last_scale) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);

            geom = try rebuildFaceForScale(
                &face,
                &atlas,
                font_lookup.path,
                font_lookup.index,
                config.font_size_px,
                current_scale,
            );

            // Invalidate cached instances so glyphs get re-inserted into the fresh atlas.
            // `invalidateAfterResize()` already zeroes per-row GPU offsets, clears cursor/packed,
            // and marks layout_dirty. Marking the terminal full-dirty forces rebuildRowInstances()
            // to run for every row next frame.
            render_cache.invalidateAfterResize();
            term.render_state.dirty = .full;

            window.surface.setBufferScale(geom.buffer_scale);

            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);

            last_scale = current_scale;
            render_pending = true;

            cell_w_px = geom.cell_w_px;
            cell_h_px = geom.cell_h_px;
            baseline_px = geom.baseline_px;
        }
```

- [ ] **Step 3: Update the existing resize block to use buffer-pixel extents**

Where the existing code calls `ctx.recreateSwapchain(window.width, window.height)` inside `runTerminal`, change both call sites to:

```zig
            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);
```

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:

```zig
            const surf_cell_w = cell_w_px / @as(u32, @intCast(geom.buffer_scale));
            const surf_cell_h = cell_h_px / @as(u32, @intCast(geom.buffer_scale));
            const new_grid = gridSizeForWindow(window.width, window.height, surf_cell_w, surf_cell_h);
```

- [ ] **Step 4: Run the test suite**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: PASS.

- [ ] **Step 5: Manual verification**

```bash
zig build
./zig-out/bin/waystty
```

- Launch with the cursor focused on DP‑4. Expected: crisp terminal at startup.
- 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.
- Type during the drag. Expected: no crashes, no corrupted glyphs after the rebuild.

- [ ] **Step 6: Commit**

```bash
git add src/main.zig
git commit -m "Honor wl_output buffer scale in terminal render loop"
```

### Task 7: Final verification and cleanup

**Files:**
- Verify: all modified files

- [ ] **Step 1: Full test suite**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: PASS.

- [ ] **Step 2: Lint for stale debug prints and TODOs**

```bash
grep -n "std.debug.print" src/wayland.zig src/main.zig
grep -n "TODO" src/scale_tracker.zig src/wayland.zig src/font.zig src/main.zig
```

Expected: no `std.debug.print` lines that weren't in the baseline; no new TODOs introduced by this plan.

- [ ] **Step 3: Four-point manual verification matrix**

| Binary | Start output | Drag target | Expected |
|---|---|---|---|
| `waystty --text-compare` | DP‑5 (1×) | — | Crisp at startup |
| `waystty --text-compare` | DP‑4 (2×) | — | Crisp at startup |
| `waystty --text-compare` | DP‑5 → DP‑4 | drag | Crisp after drag |
| `waystty` | DP‑4 (2×) | — | Crisp terminal, input works |

- [ ] **Step 4: Update the dev-display memory note**

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.

- [ ] **Step 5: Final commit if any cleanup happened**

Only create this commit if the previous steps surfaced anything to clean up.

```bash
git add <touched files>
git commit -m "HiDPI support cleanup"
```