a73x

12b4f1c8

Bind wl_output globals into ScaleTracker

a73x   2026-04-09 10:15

Commit message
Bind wl_output globals into ScaleTracker

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>

build.zig
Old New
@@ -27,6 +27,7 @@ pub fn build(b: *std.Build) void {
27 scanner.generate("wl_compositor", 6); 27 scanner.generate("wl_compositor", 6);
28 scanner.generate("wl_seat", 9); 28 scanner.generate("wl_seat", 9);
29 scanner.generate("wl_data_device_manager", 3); 29 scanner.generate("wl_data_device_manager", 3);
30 scanner.generate("wl_output", 4);
30 scanner.generate("xdg_wm_base", 6); 31 scanner.generate("xdg_wm_base", 6);
31 32
32 // wayland module — generated bindings + our Connection wrapper 33 // wayland module — generated bindings + our Connection wrapper
src/main.zig
Old New
@@ -85,7 +85,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
85 const initial_h: u32 = @as(u32, rows) * cell_h; 85 const initial_h: u32 = @as(u32, rows) * cell_h;
86 86
87 // === wayland === 87 // === wayland ===
88 var conn = try wayland_client.Connection.init(); 88 const conn = try wayland_client.Connection.init(alloc);
89 defer conn.deinit(); 89 defer conn.deinit();
90 90
91 const window = try conn.createWindow(alloc, "waystty"); 91 const window = try conn.createWindow(alloc, "waystty");
@@ -1757,7 +1757,7 @@ fn makeTestInstances(
1757 } 1757 }
1758 1758
1759 fn runTextCoverageCompare(alloc: std.mem.Allocator) !void { 1759 fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
1760 var conn = try wayland_client.Connection.init(); 1760 const conn = try wayland_client.Connection.init(alloc);
1761 defer conn.deinit(); 1761 defer conn.deinit();
1762 1762
1763 const window = try conn.createWindow(alloc, "waystty-text-compare"); 1763 const window = try conn.createWindow(alloc, "waystty-text-compare");
@@ -1847,7 +1847,7 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
1847 } 1847 }
1848 1848
1849 fn runDrawSmokeTest(alloc: std.mem.Allocator) !void { 1849 fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
1850 var conn = try wayland_client.Connection.init(); 1850 const conn = try wayland_client.Connection.init(alloc);
1851 defer conn.deinit(); 1851 defer conn.deinit();
1852 std.debug.print("wayland connected\n", .{}); 1852 std.debug.print("wayland connected\n", .{});
1853 1853
@@ -2179,7 +2179,7 @@ test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
2179 } 2179 }
2180 2180
2181 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void { 2181 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
2182 var conn = try wayland_client.Connection.init(); 2182 const conn = try wayland_client.Connection.init(alloc);
2183 defer conn.deinit(); 2183 defer conn.deinit();
2184 std.debug.print("wayland connected\n", .{}); 2184 std.debug.print("wayland connected\n", .{});
2185 2185
@@ -2210,7 +2210,7 @@ fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
2210 } 2210 }
2211 2211
2212 fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void { 2212 fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
2213 var conn = try wayland_client.Connection.init(); 2213 const conn = try wayland_client.Connection.init(alloc);
2214 defer conn.deinit(); 2214 defer conn.deinit();
2215 std.debug.print("wayland connected\n", .{}); 2215 std.debug.print("wayland connected\n", .{});
2216 2216
@@ -2237,7 +2237,7 @@ fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
2237 } 2237 }
2238 2238
2239 fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void { 2239 fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void {
2240 var conn = try wayland_client.Connection.init(); 2240 const conn = try wayland_client.Connection.init(alloc);
2241 defer conn.deinit(); 2241 defer conn.deinit();
2242 std.debug.print("connected\n", .{}); 2242 std.debug.print("connected\n", .{});
2243 2243
src/wayland.zig
Old New
@@ -3,6 +3,7 @@ const posix = std.posix;
3 const wayland = @import("wayland"); 3 const wayland = @import("wayland");
4 const wl = wayland.client.wl; 4 const wl = wayland.client.wl;
5 const xdg = wayland.client.xdg; 5 const xdg = wayland.client.xdg;
6 const ScaleTracker = @import("scale_tracker").ScaleTracker;
6 7
7 const c = @cImport({ 8 const c = @cImport({
8 @cInclude("xkbcommon/xkbcommon.h"); 9 @cInclude("xkbcommon/xkbcommon.h");
@@ -10,6 +11,13 @@ const c = @cImport({
10 @cInclude("unistd.h"); 11 @cInclude("unistd.h");
11 }); 12 });
12 13
14 pub const Output = struct {
15 wl_output: *wl.Output,
16 name: u32,
17 tracker: *ScaleTracker,
18 pending_scale: i32 = 1,
19 };
20
13 pub const KeyboardEvent = struct { 21 pub const KeyboardEvent = struct {
14 keysym: u32, 22 keysym: u32,
15 modifiers: Modifiers, 23 modifiers: Modifiers,
@@ -254,32 +262,65 @@ pub const Connection = struct {
254 display: *wl.Display, 262 display: *wl.Display,
255 registry: *wl.Registry, 263 registry: *wl.Registry,
256 globals: Globals, 264 globals: Globals,
257 265 alloc: std.mem.Allocator,
258 pub fn init() !Connection { 266 scale_tracker: ScaleTracker,
267 outputs: std.ArrayListUnmanaged(*Output),
268
269 // Heap-allocated so the pointer passed to `registry.setListener` remains stable
270 // across the init call boundary. With wl_output hotplug support the listener
271 // can fire after init returns, so we need a stable address — a stack-local
272 // Connection would dangle.
273 pub fn init(alloc: std.mem.Allocator) !*Connection {
259 const display = try wl.Display.connect(null); 274 const display = try wl.Display.connect(null);
260 errdefer display.disconnect(); 275 errdefer display.disconnect();
261 276
262 const registry = try display.getRegistry(); 277 const registry = try display.getRegistry();
263 errdefer registry.destroy(); 278 errdefer registry.destroy();
264 279
265 var globals = Globals{}; 280 const conn = try alloc.create(Connection);
266 registry.setListener(*Globals, registryListener, &globals); 281 errdefer alloc.destroy(conn);
267
268 if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
269
270 if (globals.compositor == null) return error.NoCompositor;
271 if (globals.wm_base == null) return error.NoXdgWmBase;
272 if (globals.seat == null) return error.NoSeat;
273 282
274 return .{ 283 conn.* = .{
275 .display = display, 284 .display = display,
276 .registry = registry, 285 .registry = registry,
277 .globals = globals, 286 .globals = Globals{},
287 .alloc = alloc,
288 .scale_tracker = ScaleTracker.init(alloc),
289 .outputs = .empty,
278 }; 290 };
291 errdefer {
292 for (conn.outputs.items) |out| {
293 out.wl_output.release();
294 alloc.destroy(out);
295 }
296 conn.outputs.deinit(alloc);
297 conn.scale_tracker.deinit();
298 }
299
300 registry.setListener(*Connection, registryListener, conn);
301
302 if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
303 // Second roundtrip so each wl_output's initial scale/done events are
304 // received after the output's own listener is attached in the first pass.
305 if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
306
307 if (conn.globals.compositor == null) return error.NoCompositor;
308 if (conn.globals.wm_base == null) return error.NoXdgWmBase;
309 if (conn.globals.seat == null) return error.NoSeat;
310
311 return conn;
279 } 312 }
280 313
281 pub fn deinit(self: *Connection) void { 314 pub fn deinit(self: *Connection) void {
315 const alloc = self.alloc;
316 for (self.outputs.items) |out| {
317 out.wl_output.release();
318 alloc.destroy(out);
319 }
320 self.outputs.deinit(alloc);
321 self.scale_tracker.deinit();
282 self.display.disconnect(); 322 self.display.disconnect();
323 alloc.destroy(self);
283 } 324 }
284 325
285 pub fn createWindow(self: *Connection, alloc: std.mem.Allocator, title: [*:0]const u8) !*Window { 326 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
493 fn registryListener( 534 fn registryListener(
494 registry: *wl.Registry, 535 registry: *wl.Registry,
495 event: wl.Registry.Event, 536 event: wl.Registry.Event,
496 globals: *Globals, 537 conn: *Connection,
497 ) void { 538 ) void {
498 switch (event) { 539 switch (event) {
499 .global => |g| { 540 .global => |g| {
500 const iface = std.mem.span(g.interface); 541 const iface = std.mem.span(g.interface);
501 if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) { 542 if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) {
502 globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return; 543 conn.globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return;
503 } else if (std.mem.eql(u8, iface, std.mem.span(wl.DataDeviceManager.interface.name))) { 544 } else if (std.mem.eql(u8, iface, std.mem.span(wl.DataDeviceManager.interface.name))) {
504 globals.data_device_manager = registry.bind(g.name, wl.DataDeviceManager, 3) catch return; 545 conn.globals.data_device_manager = registry.bind(g.name, wl.DataDeviceManager, 3) catch return;
505 } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) { 546 } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
506 globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return; 547 conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return;
507 } else if (std.mem.eql(u8, iface, std.mem.span(wl.Seat.interface.name))) { 548 } else if (std.mem.eql(u8, iface, std.mem.span(wl.Seat.interface.name))) {
508 globals.seat = registry.bind(g.name, wl.Seat, 9) catch return; 549 conn.globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
550 } else if (std.mem.eql(u8, iface, std.mem.span(wl.Output.interface.name))) {
551 const wl_out = registry.bind(g.name, wl.Output, 4) catch return;
552 const out = conn.alloc.create(Output) catch {
553 wl_out.release();
554 return;
555 };
556 out.* = .{
557 .wl_output = wl_out,
558 .name = g.name,
559 .tracker = &conn.scale_tracker,
560 };
561 conn.outputs.append(conn.alloc, out) catch {
562 wl_out.release();
563 conn.alloc.destroy(out);
564 return;
565 };
566 conn.scale_tracker.addOutput(g.name) catch {};
567 wl_out.setListener(*Output, outputListener, out);
568 }
569 },
570 .global_remove => |g| {
571 var i: usize = 0;
572 while (i < conn.outputs.items.len) : (i += 1) {
573 const out = conn.outputs.items[i];
574 if (out.name == g.name) {
575 conn.scale_tracker.removeOutput(out.name);
576 out.wl_output.release();
577 conn.alloc.destroy(out);
578 _ = conn.outputs.swapRemove(i);
579 return;
580 }
509 } 581 }
510 }, 582 },
511 .global_remove => {}, 583 }
584 }
585
586 fn outputListener(
587 _: *wl.Output,
588 event: wl.Output.Event,
589 out: *Output,
590 ) void {
591 switch (event) {
592 .scale => |s| {
593 out.pending_scale = s.factor;
594 },
595 .done => {
596 out.tracker.setOutputScale(out.name, out.pending_scale);
597 },
598 .geometry, .mode, .name, .description => {},
512 } 599 }
513 } 600 }
514 601