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