a73x

e4c70529

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

a73x   2026-04-09 13:17

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

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.

build.zig
Old New
@@ -9,6 +9,12 @@ pub fn build(b: *std.Build) void {
9 .optimize = optimize, 9 .optimize = optimize,
10 }); 10 });
11 11
12 const scale_tracker_mod = b.createModule(.{
13 .root_source_file = b.path("src/scale_tracker.zig"),
14 .target = target,
15 .optimize = optimize,
16 });
17
12 // Lazy-fetch the ghostty dependency. On the first invocation this 18 // Lazy-fetch the ghostty dependency. On the first invocation this
13 // materializes the package; subsequent builds use the local cache. 19 // materializes the package; subsequent builds use the local cache.
14 const ghostty_dep = b.lazyDependency("ghostty", .{}); 20 const ghostty_dep = b.lazyDependency("ghostty", .{});
@@ -21,6 +27,7 @@ pub fn build(b: *std.Build) void {
21 scanner.generate("wl_compositor", 6); 27 scanner.generate("wl_compositor", 6);
22 scanner.generate("wl_seat", 9); 28 scanner.generate("wl_seat", 9);
23 scanner.generate("wl_data_device_manager", 3); 29 scanner.generate("wl_data_device_manager", 3);
30 scanner.generate("wl_output", 4);
24 scanner.generate("xdg_wm_base", 6); 31 scanner.generate("xdg_wm_base", 6);
25 32
26 // wayland module — generated bindings + our Connection wrapper 33 // wayland module — generated bindings + our Connection wrapper
@@ -37,6 +44,7 @@ pub fn build(b: *std.Build) void {
37 .link_libc = true, 44 .link_libc = true,
38 }); 45 });
39 wayland_mod.addImport("wayland", wayland_generated_mod); 46 wayland_mod.addImport("wayland", wayland_generated_mod);
47 wayland_mod.addImport("scale_tracker", scale_tracker_mod);
40 wayland_mod.linkSystemLibrary("wayland-client", .{}); 48 wayland_mod.linkSystemLibrary("wayland-client", .{});
41 wayland_mod.linkSystemLibrary("xkbcommon", .{}); 49 wayland_mod.linkSystemLibrary("xkbcommon", .{});
42 _ = wayland_dep; // referenced via Scanner 50 _ = wayland_dep; // referenced via Scanner
@@ -101,6 +109,33 @@ pub fn build(b: *std.Build) void {
101 }); 109 });
102 test_step.dependOn(&b.addRunArtifact(pty_tests).step); 110 test_step.dependOn(&b.addRunArtifact(pty_tests).step);
103 111
112 // Test scale_tracker.zig
113 const scale_tracker_test_mod = b.createModule(.{
114 .root_source_file = b.path("src/scale_tracker.zig"),
115 .target = target,
116 .optimize = optimize,
117 });
118 const scale_tracker_tests = b.addTest(.{
119 .root_module = scale_tracker_test_mod,
120 });
121 test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step);
122
123 // Test wayland.zig
124 const wayland_test_mod = b.createModule(.{
125 .root_source_file = b.path("src/wayland.zig"),
126 .target = target,
127 .optimize = optimize,
128 .link_libc = true,
129 });
130 wayland_test_mod.addImport("wayland", wayland_generated_mod);
131 wayland_test_mod.addImport("scale_tracker", scale_tracker_mod);
132 wayland_test_mod.linkSystemLibrary("wayland-client", .{});
133 wayland_test_mod.linkSystemLibrary("xkbcommon", .{});
134 const wayland_tests = b.addTest(.{
135 .root_module = wayland_test_mod,
136 });
137 test_step.dependOn(&b.addRunArtifact(wayland_tests).step);
138
104 // Test main.zig (and transitively vt.zig via its import) 139 // Test main.zig (and transitively vt.zig via its import)
105 const main_test_mod = b.createModule(.{ 140 const main_test_mod = b.createModule(.{
106 .root_source_file = b.path("src/main.zig"), 141 .root_source_file = b.path("src/main.zig"),
src/font.zig
Old New
@@ -85,6 +85,25 @@ pub const Face = struct {
85 _ = c.FT_Done_FreeType(self.library); 85 _ = c.FT_Done_FreeType(self.library);
86 } 86 }
87 87
88 pub fn reinit(
89 self: *Face,
90 path: [:0]const u8,
91 index: c_int,
92 px_size: u32,
93 ) !void {
94 _ = c.FT_Done_Face(self.face);
95 self.face = null;
96
97 var new_face: c.FT_Face = null;
98 if (c.FT_New_Face(self.library, path.ptr, index, &new_face) != 0) return error.FtNewFaceFailed;
99 errdefer _ = c.FT_Done_Face(new_face);
100
101 if (c.FT_Set_Pixel_Sizes(new_face, 0, px_size) != 0) return error.FtSetPixelSizesFailed;
102
103 self.face = new_face;
104 self.px_size = px_size;
105 }
106
88 pub fn rasterize(self: *Face, codepoint: u21) !Glyph { 107 pub fn rasterize(self: *Face, codepoint: u21) !Glyph {
89 const glyph_index = c.FT_Get_Char_Index(self.face, codepoint); 108 const glyph_index = c.FT_Get_Char_Index(self.face, codepoint);
90 if (c.FT_Load_Glyph(self.face, glyph_index, c.FT_LOAD_RENDER) != 0) { 109 if (c.FT_Load_Glyph(self.face, glyph_index, c.FT_LOAD_RENDER) != 0) {
@@ -191,6 +210,16 @@ pub const Atlas = struct {
191 self.cache.deinit(); 210 self.cache.deinit();
192 } 211 }
193 212
213 pub fn reset(self: *Atlas) void {
214 @memset(self.pixels, 0);
215 self.pixels[0] = 255;
216 self.cursor_x = 1;
217 self.cursor_y = 0;
218 self.row_height = 1;
219 self.cache.clearRetainingCapacity();
220 self.dirty = true;
221 }
222
194 pub fn cursorUV(self: *const Atlas) GlyphUV { 223 pub fn cursorUV(self: *const Atlas) GlyphUV {
195 return .{ 224 return .{
196 .u0 = 0, 225 .u0 = 0,
@@ -301,3 +330,39 @@ test "Atlas reserves a white pixel for cursor rendering" {
301 try std.testing.expectEqual(@as(f32, 0), uv.u0); 330 try std.testing.expectEqual(@as(f32, 0), uv.u0);
302 try std.testing.expectEqual(@as(f32, 0), uv.v0); 331 try std.testing.expectEqual(@as(f32, 0), uv.v0);
303 } 332 }
333
334 test "Atlas.reset clears cache and starts fresh" {
335 var lookup = try lookupConfiguredFont(std.testing.allocator);
336 defer lookup.deinit(std.testing.allocator);
337
338 var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
339 defer face.deinit();
340
341 var atlas = try Atlas.init(std.testing.allocator, 256, 256);
342 defer atlas.deinit();
343
344 _ = try atlas.getOrInsert(&face, 'A');
345 try std.testing.expect(atlas.cache.count() > 0);
346
347 atlas.reset();
348 try std.testing.expectEqual(@as(u32, 0), atlas.cache.count());
349 try std.testing.expectEqual(@as(u8, 255), atlas.pixels[0]);
350 try std.testing.expect(atlas.dirty);
351
352 // Re-inserting the same glyph should succeed after reset.
353 _ = try atlas.getOrInsert(&face, 'A');
354 }
355
356 test "Face.reinit switches px_size and produces different cell metrics" {
357 var lookup = try lookupConfiguredFont(std.testing.allocator);
358 defer lookup.deinit(std.testing.allocator);
359
360 var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
361 defer face.deinit();
362 const small_cell = face.cellWidth();
363
364 try face.reinit(lookup.path, lookup.index, 28);
365 const large_cell = face.cellWidth();
366
367 try std.testing.expect(large_cell > small_cell);
368 }
src/main.zig
Old New
@@ -16,6 +16,35 @@ const GridSize = struct {
16 rows: u16, 16 rows: u16,
17 }; 17 };
18 18
19 const ScaledGeometry = struct {
20 buffer_scale: i32,
21 px_size: u32,
22 cell_w_px: u32, // buffer pixels
23 cell_h_px: u32, // buffer pixels
24 baseline_px: u32,
25 };
26
27 fn rebuildFaceForScale(
28 face: *font.Face,
29 atlas: *font.Atlas,
30 font_path: [:0]const u8,
31 font_index: c_int,
32 base_px_size: u32,
33 buffer_scale: i32,
34 ) !ScaledGeometry {
35 const scale: u32 = @intCast(@max(@as(i32, 1), buffer_scale));
36 const new_px = base_px_size * scale;
37 try face.reinit(font_path, font_index, new_px);
38 atlas.reset();
39 return .{
40 .buffer_scale = @intCast(scale),
41 .px_size = new_px,
42 .cell_w_px = face.cellWidth(),
43 .cell_h_px = face.cellHeight(),
44 .baseline_px = face.baseline(),
45 };
46 }
47
19 fn writePtyFromTerminal(_: *vt.Terminal, ctx: ?*anyopaque, data: []const u8) void { 48 fn writePtyFromTerminal(_: *vt.Terminal, ctx: ?*anyopaque, data: []const u8) void {
20 const p: *pty.Pty = @ptrCast(@alignCast(ctx orelse return)); 49 const p: *pty.Pty = @ptrCast(@alignCast(ctx orelse return));
21 _ = p.write(data) catch |err| { 50 _ = p.write(data) catch |err| {
@@ -85,7 +114,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
85 const initial_h: u32 = @as(u32, rows) * cell_h; 114 const initial_h: u32 = @as(u32, rows) * cell_h;
86 115
87 // === wayland === 116 // === wayland ===
88 var conn = try wayland_client.Connection.init(); 117 const conn = try wayland_client.Connection.init(alloc);
89 defer conn.deinit(); 118 defer conn.deinit();
90 119
91 const window = try conn.createWindow(alloc, "waystty"); 120 const window = try conn.createWindow(alloc, "waystty");
@@ -1757,7 +1786,7 @@ fn makeTestInstances(
1757 } 1786 }
1758 1787
1759 fn runTextCoverageCompare(alloc: std.mem.Allocator) !void { 1788 fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
1760 var conn = try wayland_client.Connection.init(); 1789 const conn = try wayland_client.Connection.init(alloc);
1761 defer conn.deinit(); 1790 defer conn.deinit();
1762 1791
1763 const window = try conn.createWindow(alloc, "waystty-text-compare"); 1792 const window = try conn.createWindow(alloc, "waystty-text-compare");
@@ -1771,23 +1800,35 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
1771 var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px); 1800 var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
1772 defer face.deinit(); 1801 defer face.deinit();
1773 1802
1774 var atlas = try font.Atlas.init(alloc, 1024, 1024); 1803 var atlas = try font.Atlas.init(alloc, 2048, 2048);
1775 defer atlas.deinit(); 1804 defer atlas.deinit();
1776 1805
1806 var geom: ScaledGeometry = .{
1807 .buffer_scale = 1,
1808 .px_size = config.font_size_px,
1809 .cell_w_px = face.cellWidth(),
1810 .cell_h_px = face.cellHeight(),
1811 .baseline_px = face.baseline(),
1812 };
1813
1777 var scene = try buildTextCoverageCompareScene(alloc, &face, &atlas); 1814 var scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
1778 defer scene.deinit(alloc); 1815 defer scene.deinit(alloc);
1779 1816
1780 const cell_w = face.cellWidth(); 1817 // Initial surface-coordinate window size. Prefer whatever the compositor
1781 const cell_h = face.cellHeight(); 1818 // configured us at (already stored on window by xdgToplevelListener). If
1782 window.width = scene.window_cols * cell_w; 1819 // the initial configure was (0, 0) — meaning "client chooses" — use a
1783 window.height = scene.window_rows * cell_h; 1820 // size that fits the scene exactly at scale=1.
1821 if (window.width == 800 and window.height == 600) {
1822 window.width = scene.window_cols * geom.cell_w_px;
1823 window.height = scene.window_rows * geom.cell_h_px;
1824 }
1784 1825
1785 var ctx = try renderer.Context.init( 1826 var ctx = try renderer.Context.init(
1786 alloc, 1827 alloc,
1787 @ptrCast(conn.display), 1828 @ptrCast(conn.display),
1788 @ptrCast(window.surface), 1829 @ptrCast(window.surface),
1789 window.width, 1830 window.width * @as(u32, @intCast(geom.buffer_scale)),
1790 window.height, 1831 window.height * @as(u32, @intCast(geom.buffer_scale)),
1791 ); 1832 );
1792 defer ctx.deinit(); 1833 defer ctx.deinit();
1793 1834
@@ -1801,6 +1842,7 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
1801 }; 1842 };
1802 var last_window_w = window.width; 1843 var last_window_w = window.width;
1803 var last_window_h = window.height; 1844 var last_window_h = window.height;
1845 var last_scale: i32 = geom.buffer_scale;
1804 1846
1805 while (!window.should_close) { 1847 while (!window.should_close) {
1806 _ = conn.display.flush(); 1848 _ = conn.display.flush();
@@ -1815,9 +1857,42 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
1815 } 1857 }
1816 _ = conn.display.dispatchPending(); 1858 _ = conn.display.dispatchPending();
1817 1859
1818 if (window.width != last_window_w or window.height != last_window_h) { 1860 const current_scale = window.bufferScale();
1861 const scale_changed = current_scale != last_scale;
1862 const size_changed = window.width != last_window_w or window.height != last_window_h;
1863
1864 if (scale_changed or size_changed) {
1819 _ = try ctx.vkd.deviceWaitIdle(ctx.device); 1865 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1820 try ctx.recreateSwapchain(window.width, window.height); 1866
1867 if (scale_changed) {
1868 geom = try rebuildFaceForScale(
1869 &face,
1870 &atlas,
1871 font_lookup.path,
1872 font_lookup.index,
1873 config.font_size_px,
1874 current_scale,
1875 );
1876 // Rebuild the scene against the fresh atlas.
1877 scene.deinit(alloc);
1878 scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
1879
1880 // Do NOT touch window.width/window.height here — those reflect the
1881 // compositor's configured surface size (from xdg_toplevel.configure).
1882 // Overwriting them forced sway to non-integer-scale our buffer to fit
1883 // its tile, which was the actual cause of the residual fuzz.
1884
1885 window.surface.setBufferScale(geom.buffer_scale);
1886 try ctx.uploadAtlas(atlas.pixels);
1887 atlas.dirty = false;
1888 try ctx.uploadInstances(scene.instances.items);
1889 last_scale = current_scale;
1890 }
1891
1892 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
1893 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
1894 try ctx.recreateSwapchain(buf_w, buf_h);
1895
1821 last_window_w = window.width; 1896 last_window_w = window.width;
1822 last_window_h = window.height; 1897 last_window_h = window.height;
1823 } 1898 }
@@ -1825,13 +1900,15 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
1825 drawTextCoverageCompareFrame( 1900 drawTextCoverageCompareFrame(
1826 &ctx, 1901 &ctx,
1827 &scene, 1902 &scene,
1828 cell_w, 1903 geom.cell_w_px,
1829 cell_h, 1904 geom.cell_h_px,
1830 .{ 0.0, 0.0, 0.0, 1.0 }, 1905 .{ 0.0, 0.0, 0.0, 1.0 },
1831 ) catch |err| switch (err) { 1906 ) catch |err| switch (err) {
1832 error.OutOfDateKHR => { 1907 error.OutOfDateKHR => {
1833 _ = try ctx.vkd.deviceWaitIdle(ctx.device); 1908 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1834 try ctx.recreateSwapchain(window.width, window.height); 1909 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
1910 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
1911 try ctx.recreateSwapchain(buf_w, buf_h);
1835 last_window_w = window.width; 1912 last_window_w = window.width;
1836 last_window_h = window.height; 1913 last_window_h = window.height;
1837 continue; 1914 continue;
@@ -1847,7 +1924,7 @@ fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
1847 } 1924 }
1848 1925
1849 fn runDrawSmokeTest(alloc: std.mem.Allocator) !void { 1926 fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
1850 var conn = try wayland_client.Connection.init(); 1927 const conn = try wayland_client.Connection.init(alloc);
1851 defer conn.deinit(); 1928 defer conn.deinit();
1852 std.debug.print("wayland connected\n", .{}); 1929 std.debug.print("wayland connected\n", .{});
1853 1930
@@ -2179,7 +2256,7 @@ test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
2179 } 2256 }
2180 2257
2181 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void { 2258 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
2182 var conn = try wayland_client.Connection.init(); 2259 const conn = try wayland_client.Connection.init(alloc);
2183 defer conn.deinit(); 2260 defer conn.deinit();
2184 std.debug.print("wayland connected\n", .{}); 2261 std.debug.print("wayland connected\n", .{});
2185 2262
@@ -2210,7 +2287,7 @@ fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
2210 } 2287 }
2211 2288
2212 fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void { 2289 fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
2213 var conn = try wayland_client.Connection.init(); 2290 const conn = try wayland_client.Connection.init(alloc);
2214 defer conn.deinit(); 2291 defer conn.deinit();
2215 std.debug.print("wayland connected\n", .{}); 2292 std.debug.print("wayland connected\n", .{});
2216 2293
@@ -2237,7 +2314,7 @@ fn runVulkanSmokeTest(alloc: std.mem.Allocator) !void {
2237 } 2314 }
2238 2315
2239 fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void { 2316 fn runWaylandSmokeTest(alloc: std.mem.Allocator) !void {
2240 var conn = try wayland_client.Connection.init(); 2317 const conn = try wayland_client.Connection.init(alloc);
2241 defer conn.deinit(); 2318 defer conn.deinit();
2242 std.debug.print("connected\n", .{}); 2319 std.debug.print("connected\n", .{});
2243 2320
src/scale_tracker.zig
Old New
@@ -0,0 +1,137 @@
1 const std = @import("std");
2
3 pub const OutputId = u32;
4
5 pub const ScaleTracker = struct {
6 alloc: std.mem.Allocator,
7 scales: std.AutoHashMapUnmanaged(OutputId, i32),
8 entered: std.AutoHashMapUnmanaged(OutputId, void),
9
10 pub fn init(alloc: std.mem.Allocator) ScaleTracker {
11 return .{
12 .alloc = alloc,
13 .scales = .empty,
14 .entered = .empty,
15 };
16 }
17
18 pub fn deinit(self: *ScaleTracker) void {
19 self.scales.deinit(self.alloc);
20 self.entered.deinit(self.alloc);
21 }
22
23 pub fn addOutput(self: *ScaleTracker, id: OutputId) !void {
24 try self.scales.put(self.alloc, id, 1);
25 }
26
27 pub fn setOutputScale(self: *ScaleTracker, id: OutputId, scale: i32) void {
28 if (self.scales.getPtr(id)) |slot| slot.* = scale;
29 }
30
31 pub fn removeOutput(self: *ScaleTracker, id: OutputId) void {
32 _ = self.scales.remove(id);
33 _ = self.entered.remove(id);
34 }
35
36 pub fn enterOutput(self: *ScaleTracker, id: OutputId) !void {
37 try self.entered.put(self.alloc, id, {});
38 }
39
40 pub fn leaveOutput(self: *ScaleTracker, id: OutputId) void {
41 _ = self.entered.remove(id);
42 }
43
44 pub fn bufferScale(self: *const ScaleTracker) i32 {
45 var max_scale: i32 = 1;
46 var it = self.entered.iterator();
47 while (it.next()) |entry| {
48 const id = entry.key_ptr.*;
49 if (self.scales.get(id)) |s| {
50 if (s > max_scale) max_scale = s;
51 }
52 }
53 return max_scale;
54 }
55 };
56
57 test "new tracker reports default scale of 1" {
58 var t = ScaleTracker.init(std.testing.allocator);
59 defer t.deinit();
60 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
61 }
62
63 test "entered output scale is reflected in bufferScale" {
64 var t = ScaleTracker.init(std.testing.allocator);
65 defer t.deinit();
66
67 try t.addOutput(1);
68 t.setOutputScale(1, 2);
69 try t.enterOutput(1);
70 try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
71 }
72
73 test "not-yet-entered output does not change bufferScale" {
74 var t = ScaleTracker.init(std.testing.allocator);
75 defer t.deinit();
76
77 try t.addOutput(7);
78 t.setOutputScale(7, 3);
79 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
80 }
81
82 test "bufferScale is max across entered outputs" {
83 var t = ScaleTracker.init(std.testing.allocator);
84 defer t.deinit();
85
86 try t.addOutput(1);
87 try t.addOutput(2);
88 t.setOutputScale(1, 1);
89 t.setOutputScale(2, 2);
90
91 try t.enterOutput(1);
92 try t.enterOutput(2);
93 try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
94 }
95
96 test "leaving an output drops its contribution" {
97 var t = ScaleTracker.init(std.testing.allocator);
98 defer t.deinit();
99
100 try t.addOutput(1);
101 try t.addOutput(2);
102 t.setOutputScale(1, 2);
103 t.setOutputScale(2, 3);
104 try t.enterOutput(1);
105 try t.enterOutput(2);
106 try std.testing.expectEqual(@as(i32, 3), t.bufferScale());
107
108 t.leaveOutput(2);
109 try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
110 }
111
112 test "removing an unknown output is a no-op" {
113 var t = ScaleTracker.init(std.testing.allocator);
114 defer t.deinit();
115 t.removeOutput(999);
116 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
117 }
118
119 test "removeOutput also removes it from entered set" {
120 var t = ScaleTracker.init(std.testing.allocator);
121 defer t.deinit();
122
123 try t.addOutput(5);
124 t.setOutputScale(5, 4);
125 try t.enterOutput(5);
126 try std.testing.expectEqual(@as(i32, 4), t.bufferScale());
127
128 t.removeOutput(5);
129 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
130 }
131
132 test "setOutputScale on unknown id is a no-op" {
133 var t = ScaleTracker.init(std.testing.allocator);
134 defer t.deinit();
135 t.setOutputScale(999, 5);
136 try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
137 }
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,
@@ -233,6 +241,10 @@ pub const Window = struct {
233 surface: *wl.Surface, 241 surface: *wl.Surface,
234 xdg_surface: *xdg.Surface, 242 xdg_surface: *xdg.Surface,
235 xdg_toplevel: *xdg.Toplevel, 243 xdg_toplevel: *xdg.Toplevel,
244 tracker: *ScaleTracker,
245 outputs: *std.ArrayListUnmanaged(*Output),
246 scale_generation: u64 = 0,
247 applied_buffer_scale: i32 = 1,
236 configured: bool = false, 248 configured: bool = false,
237 should_close: bool = false, 249 should_close: bool = false,
238 width: u32 = 800, 250 width: u32 = 800,
@@ -248,38 +260,93 @@ pub const Window = struct {
248 pub fn setTitle(self: *Window, title: ?[:0]const u8) void { 260 pub fn setTitle(self: *Window, title: ?[:0]const u8) void {
249 self.xdg_toplevel.setTitle((title orelse "waystty")); 261 self.xdg_toplevel.setTitle((title orelse "waystty"));
250 } 262 }
263
264 pub fn bufferScale(self: *const Window) i32 {
265 return self.tracker.bufferScale();
266 }
267
268 pub fn handleSurfaceEnter(self: *Window, wl_out: *wl.Output) void {
269 for (self.outputs.items) |out| {
270 if (out.wl_output == wl_out) {
271 self.tracker.enterOutput(out.name) catch {};
272 return;
273 }
274 }
275 }
276
277 pub fn handleSurfaceLeave(self: *Window, wl_out: *wl.Output) void {
278 for (self.outputs.items) |out| {
279 if (out.wl_output == wl_out) {
280 self.tracker.leaveOutput(out.name);
281 return;
282 }
283 }
284 }
251 }; 285 };
252 286
253 pub const Connection = struct { 287 pub const Connection = struct {
254 display: *wl.Display, 288 display: *wl.Display,
255 registry: *wl.Registry, 289 registry: *wl.Registry,
256 globals: Globals, 290 globals: Globals,
257 291 alloc: std.mem.Allocator,
258 pub fn init() !Connection { 292 scale_tracker: ScaleTracker,
293 outputs: std.ArrayListUnmanaged(*Output),
294
295 // Heap-allocated so the pointer passed to `registry.setListener` remains stable
296 // across the init call boundary. With wl_output hotplug support the listener
297 // can fire after init returns, so we need a stable address — a stack-local
298 // Connection would dangle.
299 pub fn init(alloc: std.mem.Allocator) !*Connection {
259 const display = try wl.Display.connect(null); 300 const display = try wl.Display.connect(null);
260 errdefer display.disconnect(); 301 errdefer display.disconnect();
261 302
262 const registry = try display.getRegistry(); 303 const registry = try display.getRegistry();
263 errdefer registry.destroy(); 304 errdefer registry.destroy();
264 305
265 var globals = Globals{}; 306 const conn = try alloc.create(Connection);
266 registry.setListener(*Globals, registryListener, &globals); 307 errdefer alloc.destroy(conn);
267 308
268 if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; 309 conn.* = .{
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
274 return .{
275 .display = display, 310 .display = display,
276 .registry = registry, 311 .registry = registry,
277 .globals = globals, 312 .globals = Globals{},
313 .alloc = alloc,
314 .scale_tracker = ScaleTracker.init(alloc),
315 .outputs = .empty,
278 }; 316 };
317 errdefer {
318 for (conn.outputs.items) |out| {
319 out.wl_output.release();
320 alloc.destroy(out);
321 }
322 conn.outputs.deinit(alloc);
323 conn.scale_tracker.deinit();
324 }
325
326 registry.setListener(*Connection, registryListener, conn);
327
328 if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
329 // Second roundtrip so each wl_output's initial scale/done events are
330 // received after the output's own listener is attached in the first pass.
331 if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
332
333 if (conn.globals.compositor == null) return error.NoCompositor;
334 if (conn.globals.wm_base == null) return error.NoXdgWmBase;
335 if (conn.globals.seat == null) return error.NoSeat;
336
337 return conn;
279 } 338 }
280 339
281 pub fn deinit(self: *Connection) void { 340 pub fn deinit(self: *Connection) void {
341 const alloc = self.alloc;
342 for (self.outputs.items) |out| {
343 out.wl_output.release();
344 alloc.destroy(out);
345 }
346 self.outputs.deinit(alloc);
347 self.scale_tracker.deinit();
282 self.display.disconnect(); 348 self.display.disconnect();
349 alloc.destroy(self);
283 } 350 }
284 351
285 pub fn createWindow(self: *Connection, alloc: std.mem.Allocator, title: [*:0]const u8) !*Window { 352 pub fn createWindow(self: *Connection, alloc: std.mem.Allocator, title: [*:0]const u8) !*Window {
@@ -294,9 +361,13 @@ pub const Connection = struct {
294 .surface = try compositor.createSurface(), 361 .surface = try compositor.createSurface(),
295 .xdg_surface = undefined, 362 .xdg_surface = undefined,
296 .xdg_toplevel = undefined, 363 .xdg_toplevel = undefined,
364 .tracker = &self.scale_tracker,
365 .outputs = &self.outputs,
297 }; 366 };
298 errdefer window.surface.destroy(); 367 errdefer window.surface.destroy();
299 368
369 window.surface.setListener(*Window, surfaceListener, window);
370
300 window.xdg_surface = try wm_base.getXdgSurface(window.surface); 371 window.xdg_surface = try wm_base.getXdgSurface(window.surface);
301 errdefer window.xdg_surface.destroy(); 372 errdefer window.xdg_surface.destroy();
302 373
@@ -478,6 +549,23 @@ fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *
478 } 549 }
479 } 550 }
480 551
552 fn surfaceListener(_: *wl.Surface, event: wl.Surface.Event, window: *Window) void {
553 switch (event) {
554 .enter => |e| {
555 const wl_out = e.output orelse return;
556 window.handleSurfaceEnter(wl_out);
557 window.scale_generation += 1;
558 },
559 .leave => |e| {
560 const wl_out = e.output orelse return;
561 window.handleSurfaceLeave(wl_out);
562 window.scale_generation += 1;
563 },
564 .preferred_buffer_scale => {},
565 .preferred_buffer_transform => {},
566 }
567 }
568
481 fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void { 569 fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
482 switch (event) { 570 switch (event) {
483 .configure => |cfg| { 571 .configure => |cfg| {
@@ -493,22 +581,68 @@ fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Win
493 fn registryListener( 581 fn registryListener(
494 registry: *wl.Registry, 582 registry: *wl.Registry,
495 event: wl.Registry.Event, 583 event: wl.Registry.Event,
496 globals: *Globals, 584 conn: *Connection,
497 ) void { 585 ) void {
498 switch (event) { 586 switch (event) {
499 .global => |g| { 587 .global => |g| {
500 const iface = std.mem.span(g.interface); 588 const iface = std.mem.span(g.interface);
501 if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) { 589 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; 590 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))) { 591 } 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; 592 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))) { 593 } 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; 594 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))) { 595 } 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; 596 conn.globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
597 } else if (std.mem.eql(u8, iface, std.mem.span(wl.Output.interface.name))) {
598 const wl_out = registry.bind(g.name, wl.Output, 4) catch return;
599 const out = conn.alloc.create(Output) catch {
600 wl_out.release();
601 return;
602 };
603 out.* = .{
604 .wl_output = wl_out,
605 .name = g.name,
606 .tracker = &conn.scale_tracker,
607 };
608 conn.outputs.append(conn.alloc, out) catch {
609 wl_out.release();
610 conn.alloc.destroy(out);
611 return;
612 };
613 conn.scale_tracker.addOutput(g.name) catch {};
614 wl_out.setListener(*Output, outputListener, out);
615 }
616 },
617 .global_remove => |g| {
618 var i: usize = 0;
619 while (i < conn.outputs.items.len) : (i += 1) {
620 const out = conn.outputs.items[i];
621 if (out.name == g.name) {
622 conn.scale_tracker.removeOutput(out.name);
623 out.wl_output.release();
624 conn.alloc.destroy(out);
625 _ = conn.outputs.swapRemove(i);
626 return;
627 }
509 } 628 }
510 }, 629 },
511 .global_remove => {}, 630 }
631 }
632
633 fn outputListener(
634 _: *wl.Output,
635 event: wl.Output.Event,
636 out: *Output,
637 ) void {
638 switch (event) {
639 .scale => |s| {
640 out.pending_scale = s.factor;
641 },
642 .done => {
643 out.tracker.setOutputScale(out.name, out.pending_scale);
644 },
645 .geometry, .mode, .name, .description => {},
512 } 646 }
513 } 647 }
514 648
@@ -568,3 +702,20 @@ test "drainSelectionPipeThenRoundtrip drains large payload before roundtrip" {
568 try std.testing.expect(roundtrip_called); 702 try std.testing.expect(roundtrip_called);
569 try std.testing.expectEqualStrings(payload, text); 703 try std.testing.expectEqualStrings(payload, text);
570 } 704 }
705
706 test "Window.bufferScale reflects ScaleTracker entered outputs" {
707 var tracker = ScaleTracker.init(std.testing.allocator);
708 defer tracker.deinit();
709
710 try tracker.addOutput(1);
711 try tracker.addOutput(2);
712 tracker.setOutputScale(1, 1);
713 tracker.setOutputScale(2, 2);
714
715 // Simulate the bits Window.bufferScale delegates to.
716 try tracker.enterOutput(2);
717 try std.testing.expectEqual(@as(i32, 2), tracker.bufferScale());
718
719 tracker.leaveOutput(2);
720 try std.testing.expectEqual(@as(i32, 1), tracker.bufferScale());
721 }