e4c70529
Merge branch 'feature/hidpi-support' into implement-v1
a73x 2026-04-09 13:17
Commit message
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 | } | ||