a73x

12b4f1c8

Bind wl_output globals into ScaleTracker

a73x   2026-04-09 10:15

Binds wl_output v4 in the registry listener and attaches a per-output
listener that routes scale/done events into the Connection's ScaleTracker.
Also handles global_remove for hotplug cleanup.

Connection is now 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 a
stack-local Connection would dangle.

Verified manually on dual-monitor sway: two outputs bound, Apple Studio
Display at scale=2 and Dell AW3225QF at scale=1.

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

diff --git a/build.zig b/build.zig
index 42d5c65..8e236e9 100644
--- a/build.zig
+++ b/build.zig
@@ -27,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
diff --git a/src/main.zig b/src/main.zig
index d7e6927..959be9f 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -85,7 +85,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 +1757,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");
@@ -1847,7 +1847,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 +2179,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 +2210,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 +2237,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/wayland.zig b/src/wayland.zig
index 5443aba..f19aa7a 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,
@@ -254,32 +262,65 @@ 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);

        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;
        const conn = try alloc.create(Connection);
        errdefer alloc.destroy(conn);

        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 {
@@ -493,22 +534,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 => {},
    }
}