a73x

e4c70529

Merge branch 'feature/hidpi-support' into implement-v1

a73x   2026-04-09 13:17

HiDPI support for the text-compare renderer. Adds wl_output binding
with a ScaleTracker, wl_surface enter/leave tracking on Window,
font rasterization at scale * px_size via Face.reinit + Atlas.reset,
and honors xdg_toplevel.configure dimensions to avoid non-integer
compositor rescaling.

Scope notes:
- runTextCoverageCompare is HiDPI-aware (fuzz fix for --text-compare).
- runTerminal (the main terminal loop) was not yet wired up — it
  still uses fixed scale=1. Follow-up task tracked in the plan.

diff --git a/build.zig b/build.zig
index e74c775..cfab411 100644
--- a/build.zig
+++ b/build.zig
@@ -9,6 +9,12 @@ pub fn build(b: *std.Build) void {
        .optimize = optimize,
    });

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

    // Lazy-fetch the ghostty dependency. On the first invocation this
    // materializes the package; subsequent builds use the local cache.
    const ghostty_dep = b.lazyDependency("ghostty", .{});
@@ -21,6 +27,7 @@ pub fn build(b: *std.Build) void {
    scanner.generate("wl_compositor", 6);
    scanner.generate("wl_seat", 9);
    scanner.generate("wl_data_device_manager", 3);
    scanner.generate("wl_output", 4);
    scanner.generate("xdg_wm_base", 6);

    // wayland module — generated bindings + our Connection wrapper
@@ -37,6 +44,7 @@ pub fn build(b: *std.Build) void {
        .link_libc = true,
    });
    wayland_mod.addImport("wayland", wayland_generated_mod);
    wayland_mod.addImport("scale_tracker", scale_tracker_mod);
    wayland_mod.linkSystemLibrary("wayland-client", .{});
    wayland_mod.linkSystemLibrary("xkbcommon", .{});
    _ = wayland_dep; // referenced via Scanner
@@ -101,6 +109,33 @@ pub fn build(b: *std.Build) void {
    });
    test_step.dependOn(&b.addRunArtifact(pty_tests).step);

    // 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);

    // Test wayland.zig
    const wayland_test_mod = b.createModule(.{
        .root_source_file = b.path("src/wayland.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });
    wayland_test_mod.addImport("wayland", wayland_generated_mod);
    wayland_test_mod.addImport("scale_tracker", scale_tracker_mod);
    wayland_test_mod.linkSystemLibrary("wayland-client", .{});
    wayland_test_mod.linkSystemLibrary("xkbcommon", .{});
    const wayland_tests = b.addTest(.{
        .root_module = wayland_test_mod,
    });
    test_step.dependOn(&b.addRunArtifact(wayland_tests).step);

    // Test main.zig (and transitively vt.zig via its import)
    const main_test_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
diff --git a/src/font.zig b/src/font.zig
index fe1cde6..89f3001 100644
--- a/src/font.zig
+++ b/src/font.zig
@@ -85,6 +85,25 @@ pub const Face = struct {
        _ = c.FT_Done_FreeType(self.library);
    }

    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;
    }

    pub fn rasterize(self: *Face, codepoint: u21) !Glyph {
        const glyph_index = c.FT_Get_Char_Index(self.face, codepoint);
        if (c.FT_Load_Glyph(self.face, glyph_index, c.FT_LOAD_RENDER) != 0) {
@@ -191,6 +210,16 @@ pub const Atlas = struct {
        self.cache.deinit();
    }

    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;
    }

    pub fn cursorUV(self: *const Atlas) GlyphUV {
        return .{
            .u0 = 0,
@@ -301,3 +330,39 @@ test "Atlas reserves a white pixel for cursor rendering" {
    try std.testing.expectEqual(@as(f32, 0), uv.u0);
    try std.testing.expectEqual(@as(f32, 0), uv.v0);
}

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, 0), atlas.cache.count());
    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);
}
diff --git a/src/main.zig b/src/main.zig
index d7e6927..cc30caa 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -16,6 +16,35 @@ const GridSize = struct {
    rows: u16,
};

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(),
    };
}

fn writePtyFromTerminal(_: *vt.Terminal, ctx: ?*anyopaque, data: []const u8) void {
    const p: *pty.Pty = @ptrCast(@alignCast(ctx orelse return));
    _ = p.write(data) catch |err| {
@@ -85,7 +114,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
    const initial_h: u32 = @as(u32, rows) * cell_h;

    // === wayland ===
    var conn = try wayland_client.Connection.init();
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();

    const window = try conn.createWindow(alloc, "waystty");
@@ -1757,7 +1786,7 @@ fn makeTestInstances(
}

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

    const window = try conn.createWindow(alloc, "waystty-text-compare");
@@ -1771,23 +1800,35 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
    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, 1024, 1024);
    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);

    const cell_w = face.cellWidth();
    const cell_h = face.cellHeight();
    window.width = scene.window_cols * cell_w;
    window.height = scene.window_rows * cell_h;
    // Initial surface-coordinate window size. Prefer whatever the compositor
    // configured us at (already stored on window by xdgToplevelListener). If
    // the initial configure was (0, 0) — meaning "client chooses" — use a
    // size that fits the scene exactly at scale=1.
    if (window.width == 800 and window.height == 600) {
        window.width = scene.window_cols * geom.cell_w_px;
        window.height = scene.window_rows * geom.cell_h_px;
    }

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

@@ -1801,6 +1842,7 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
    };
    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();
@@ -1815,9 +1857,42 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
        }
        _ = conn.display.dispatchPending();

        if (window.width != last_window_w or window.height != last_window_h) {
        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);
            try ctx.recreateSwapchain(window.width, window.height);

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

                // Do NOT touch window.width/window.height here — those reflect the
                // compositor's configured surface size (from xdg_toplevel.configure).
                // Overwriting them forced sway to non-integer-scale our buffer to fit
                // its tile, which was the actual cause of the residual fuzz.

                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;
        }
@@ -1825,13 +1900,15 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
        drawTextCoverageCompareFrame(
            &ctx,
            &scene,
            cell_w,
            cell_h,
            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);
                try ctx.recreateSwapchain(window.width, window.height);
                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;
@@ -1847,7 +1924,7 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
}

fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init();
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();
    std.debug.print("wayland connected\n", .{});

@@ -2179,7 +2256,7 @@ test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
}

fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init();
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();
    std.debug.print("wayland connected\n", .{});

@@ -2210,7 +2287,7 @@ fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
}

fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init();
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();
    std.debug.print("wayland connected\n", .{});

@@ -2237,7 +2314,7 @@ fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
}

fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init();
    const conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();
    std.debug.print("connected\n", .{});

diff --git a/src/scale_tracker.zig b/src/scale_tracker.zig
new file mode 100644
index 0000000..87c2e45
--- /dev/null
+++ b/src/scale_tracker.zig
@@ -0,0 +1,137 @@
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;
    }
};

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());
}
diff --git a/src/wayland.zig b/src/wayland.zig
index 5443aba..476ec45 100644
--- a/src/wayland.zig
+++ b/src/wayland.zig
@@ -3,6 +3,7 @@ const posix = std.posix;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const xdg = wayland.client.xdg;
const ScaleTracker = @import("scale_tracker").ScaleTracker;

const c = @cImport({
    @cInclude("xkbcommon/xkbcommon.h");
@@ -10,6 +11,13 @@ const c = @cImport({
    @cInclude("unistd.h");
});

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

pub const KeyboardEvent = struct {
    keysym: u32,
    modifiers: Modifiers,
@@ -233,6 +241,10 @@ pub const Window = struct {
    surface: *wl.Surface,
    xdg_surface: *xdg.Surface,
    xdg_toplevel: *xdg.Toplevel,
    tracker: *ScaleTracker,
    outputs: *std.ArrayListUnmanaged(*Output),
    scale_generation: u64 = 0,
    applied_buffer_scale: i32 = 1,
    configured: bool = false,
    should_close: bool = false,
    width: u32 = 800,
@@ -248,38 +260,93 @@ pub const Window = struct {
    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();
    }

    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;
            }
        }
    }
};

pub const Connection = struct {
    display: *wl.Display,
    registry: *wl.Registry,
    globals: Globals,

    pub fn init() !Connection {
    alloc: std.mem.Allocator,
    scale_tracker: ScaleTracker,
    outputs: std.ArrayListUnmanaged(*Output),

    // Heap-allocated so the pointer passed to `registry.setListener` remains stable
    // across the init call boundary. With wl_output hotplug support the listener
    // can fire after init returns, so we need a stable address — a stack-local
    // Connection would dangle.
    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 globals = Globals{};
        registry.setListener(*Globals, registryListener, &globals);
        const conn = try alloc.create(Connection);
        errdefer alloc.destroy(conn);

        if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;

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

        return .{
        conn.* = .{
            .display = display,
            .registry = registry,
            .globals = globals,
            .globals = Globals{},
            .alloc = alloc,
            .scale_tracker = ScaleTracker.init(alloc),
            .outputs = .empty,
        };
        errdefer {
            for (conn.outputs.items) |out| {
                out.wl_output.release();
                alloc.destroy(out);
            }
            conn.outputs.deinit(alloc);
            conn.scale_tracker.deinit();
        }

        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 after the output's own listener is attached in the first pass.
        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 {
        const alloc = self.alloc;
        for (self.outputs.items) |out| {
            out.wl_output.release();
            alloc.destroy(out);
        }
        self.outputs.deinit(alloc);
        self.scale_tracker.deinit();
        self.display.disconnect();
        alloc.destroy(self);
    }

    pub fn createWindow(self: *Connection, alloc: std.mem.Allocator, title: [*:0]const u8) !*Window {
@@ -294,9 +361,13 @@ pub const Connection = struct {
            .surface = try compositor.createSurface(),
            .xdg_surface = undefined,
            .xdg_toplevel = undefined,
            .tracker = &self.scale_tracker,
            .outputs = &self.outputs,
        };
        errdefer window.surface.destroy();

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

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

@@ -478,6 +549,23 @@ fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *
    }
}

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

fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
    switch (event) {
        .configure => |cfg| {
@@ -493,22 +581,68 @@ fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Win
fn registryListener(
    registry: *wl.Registry,
    event: wl.Registry.Event,
    globals: *Globals,
    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))) {
                globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return;
                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))) {
                globals.data_device_manager = registry.bind(g.name, wl.DataDeviceManager, 3) catch return;
                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))) {
                globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return;
                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))) {
                globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
                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;
                }
            }
        },
        .global_remove => {},
    }
}

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 => {},
    }
}

@@ -568,3 +702,20 @@ test "drainSelectionPipeThenRoundtrip drains large payload before roundtrip" {
    try std.testing.expect(roundtrip_called);
    try std.testing.expectEqualStrings(payload, text);
}

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());
}