dec56ed3
Introduce FrameLoop module and DisplayOps trait
a73x 2026-04-16 10:59
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -37,6 +37,14 @@ pub fn build(b: *std.Build) void { | |||
| 37 | .optimize = optimize, | 37 | .optimize = optimize, |
| 38 | }); | 38 | }); |
| 39 | 39 | ||
| 40 | const frame_loop_mod = b.createModule(.{ | ||
| 41 | .root_source_file = b.path("src/frame_loop.zig"), | ||
| 42 | .target = target, | ||
| 43 | .optimize = optimize, | ||
| 44 | }); | ||
| 45 | frame_loop_mod.addImport("wayland", wayland_generated_mod); | ||
| 46 | frame_loop_mod.addImport("scale_tracker", scale_tracker_mod); | ||
| 47 | |||
| 40 | const wayland_mod = b.createModule(.{ | 48 | const wayland_mod = b.createModule(.{ |
| 41 | .root_source_file = b.path("src/wayland.zig"), | 49 | .root_source_file = b.path("src/wayland.zig"), |
| 42 | .target = target, | 50 | .target = target, |
| @@ -45,6 +53,7 @@ pub fn build(b: *std.Build) void { | |||
| 45 | }); | 53 | }); |
| 46 | wayland_mod.addImport("wayland", wayland_generated_mod); | 54 | wayland_mod.addImport("wayland", wayland_generated_mod); |
| 47 | wayland_mod.addImport("scale_tracker", scale_tracker_mod); | 55 | wayland_mod.addImport("scale_tracker", scale_tracker_mod); |
| 56 | wayland_mod.addImport("frame_loop", frame_loop_mod); | ||
| 48 | wayland_mod.linkSystemLibrary("wayland-client", .{}); | 57 | wayland_mod.linkSystemLibrary("wayland-client", .{}); |
| 49 | wayland_mod.linkSystemLibrary("xkbcommon", .{}); | 58 | wayland_mod.linkSystemLibrary("xkbcommon", .{}); |
| 50 | _ = wayland_dep; // referenced via Scanner | 59 | _ = wayland_dep; // referenced via Scanner |
| @@ -69,6 +78,7 @@ pub fn build(b: *std.Build) void { | |||
| 69 | exe_mod.addImport("vt", vt_mod); | 78 | exe_mod.addImport("vt", vt_mod); |
| 70 | exe_mod.addImport("wayland-client", wayland_mod); | 79 | exe_mod.addImport("wayland-client", wayland_mod); |
| 71 | exe_mod.addImport("config", config_mod); | 80 | exe_mod.addImport("config", config_mod); |
| 81 | exe_mod.addImport("frame_loop", frame_loop_mod); | ||
| 72 | 82 | ||
| 73 | const exe = b.addExecutable(.{ | 83 | const exe = b.addExecutable(.{ |
| 74 | .name = "waystty", | 84 | .name = "waystty", |
| @@ -120,6 +130,19 @@ pub fn build(b: *std.Build) void { | |||
| 120 | }); | 130 | }); |
| 121 | test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step); | 131 | test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step); |
| 122 | 132 | ||
| 133 | // Test frame_loop.zig | ||
| 134 | const frame_loop_test_mod = b.createModule(.{ | ||
| 135 | .root_source_file = b.path("src/frame_loop.zig"), | ||
| 136 | .target = target, | ||
| 137 | .optimize = optimize, | ||
| 138 | }); | ||
| 139 | frame_loop_test_mod.addImport("wayland", wayland_generated_mod); | ||
| 140 | frame_loop_test_mod.addImport("scale_tracker", scale_tracker_mod); | ||
| 141 | const frame_loop_tests = b.addTest(.{ | ||
| 142 | .root_module = frame_loop_test_mod, | ||
| 143 | }); | ||
| 144 | test_step.dependOn(&b.addRunArtifact(frame_loop_tests).step); | ||
| 145 | |||
| 123 | // Test wayland.zig | 146 | // Test wayland.zig |
| 124 | const wayland_test_mod = b.createModule(.{ | 147 | const wayland_test_mod = b.createModule(.{ |
| 125 | .root_source_file = b.path("src/wayland.zig"), | 148 | .root_source_file = b.path("src/wayland.zig"), |
src/frame_loop.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,126 @@ | |||
| 1 | const std = @import("std"); | ||
| 2 | const wl = @import("wayland").client.wl; | ||
| 3 | const scale_tracker = @import("scale_tracker"); | ||
| 4 | |||
| 5 | // Mirror of wayland.SurfaceState. Using a structural duplicate here instead of | ||
| 6 | // importing wayland.zig avoids a circular module dependency (wayland depends on | ||
| 7 | // frame_loop). The real wayland.SurfaceState embeds one of these by reference. | ||
| 8 | pub const SurfaceStateView = struct { | ||
| 9 | configured_ptr: *const bool, | ||
| 10 | suspended_ptr: *const bool, | ||
| 11 | tracker: *const scale_tracker.ScaleTracker, | ||
| 12 | |||
| 13 | pub fn visible(self: SurfaceStateView) bool { | ||
| 14 | return self.configured_ptr.* | ||
| 15 | and !self.suspended_ptr.* | ||
| 16 | and self.tracker.enteredCount() > 0; | ||
| 17 | } | ||
| 18 | }; | ||
| 19 | |||
| 20 | // Opaque handle to a frame callback. Real path holds a *wl.Callback cast here; | ||
| 21 | // mock holds a usize cast here. Identity is pointer equality. | ||
| 22 | pub const CallbackToken = *const anyopaque; | ||
| 23 | |||
| 24 | pub const DisplayOps = struct { | ||
| 25 | ctx: *anyopaque, | ||
| 26 | |||
| 27 | // All fn pointers receive the same ctx so the caller can carry whatever | ||
| 28 | // concrete objects it needs (wl.Display, wl.Surface, test mock, etc). | ||
| 29 | flushFn: *const fn (*anyopaque) void, | ||
| 30 | prepareReadFn: *const fn (*anyopaque) bool, | ||
| 31 | readEventsFn: *const fn (*anyopaque) void, | ||
| 32 | dispatchPendingFn: *const fn (*anyopaque) void, | ||
| 33 | getFdFn: *const fn (*anyopaque) std.posix.fd_t, | ||
| 34 | |||
| 35 | // Requests a wl_surface.frame() and sets its done listener. Returns the | ||
| 36 | // token identifying the new callback. FrameLoop compares the token | ||
| 37 | // delivered by onFrameCallbackDone against pending_token. | ||
| 38 | requestFrameFn: *const fn (*anyopaque, *FrameLoop) anyerror!CallbackToken, | ||
| 39 | |||
| 40 | // Destroys a previously issued frame callback (for hide-path cleanup). | ||
| 41 | // Must tolerate being called on a token the compositor has already | ||
| 42 | // consumed — real path: wl.Callback.destroy is idempotent on the client. | ||
| 43 | destroyCallbackFn: *const fn (*anyopaque, CallbackToken) void, | ||
| 44 | }; | ||
| 45 | |||
| 46 | pub const FrameLoop = struct { | ||
| 47 | ops: DisplayOps, | ||
| 48 | state: SurfaceStateView, | ||
| 49 | |||
| 50 | pending_token: ?CallbackToken = null, | ||
| 51 | armed: bool = true, | ||
| 52 | |||
| 53 | pub fn init(ops: DisplayOps, state: SurfaceStateView) FrameLoop { | ||
| 54 | return .{ .ops = ops, .state = state }; | ||
| 55 | } | ||
| 56 | |||
| 57 | pub fn deinit(self: *FrameLoop) void { | ||
| 58 | if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t); | ||
| 59 | self.pending_token = null; | ||
| 60 | } | ||
| 61 | |||
| 62 | pub fn canRender(self: *const FrameLoop) bool { | ||
| 63 | return self.armed and self.state.visible(); | ||
| 64 | } | ||
| 65 | |||
| 66 | pub fn commitRender(self: *FrameLoop) !void { | ||
| 67 | std.debug.assert(self.canRender()); | ||
| 68 | if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t); | ||
| 69 | self.pending_token = try self.ops.requestFrameFn(self.ops.ctx, self); | ||
| 70 | self.armed = false; | ||
| 71 | } | ||
| 72 | |||
| 73 | pub fn onFrameCallbackDone(self: *FrameLoop, token: CallbackToken) void { | ||
| 74 | if (self.pending_token == null or self.pending_token.? != token) return; | ||
| 75 | self.ops.destroyCallbackFn(self.ops.ctx, token); | ||
| 76 | self.pending_token = null; | ||
| 77 | self.armed = true; | ||
| 78 | } | ||
| 79 | |||
| 80 | pub fn onSurfaceHidden(self: *FrameLoop) void { | ||
| 81 | if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t); | ||
| 82 | self.pending_token = null; | ||
| 83 | // armed unchanged — canRender() is false while hidden regardless. | ||
| 84 | } | ||
| 85 | |||
| 86 | pub fn onSurfaceShown(self: *FrameLoop) void { | ||
| 87 | self.armed = true; | ||
| 88 | } | ||
| 89 | |||
| 90 | pub fn forceArm(self: *FrameLoop) void { | ||
| 91 | if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t); | ||
| 92 | self.pending_token = null; | ||
| 93 | self.armed = true; | ||
| 94 | } | ||
| 95 | |||
| 96 | /// Blocks on wl_fd + extra pollfds with `timeout_ms`, then reads + dispatches | ||
| 97 | /// any pending Wayland events. Safe to call in any state. | ||
| 98 | pub fn waitForWork( | ||
| 99 | self: *FrameLoop, | ||
| 100 | extra: []std.posix.pollfd, | ||
| 101 | timeout_ms: i32, | ||
| 102 | ) !void { | ||
| 103 | self.ops.flushFn(self.ops.ctx); | ||
| 104 | |||
| 105 | const wl_fd = self.ops.getFdFn(self.ops.ctx); | ||
| 106 | // Build a small on-stack pollfd array: wl_fd + extras. | ||
| 107 | // Cap extras at 8 — waystty never polls more than pty+wl. | ||
| 108 | var all: [9]std.posix.pollfd = undefined; | ||
| 109 | all[0] = .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 110 | std.debug.assert(extra.len <= all.len - 1); | ||
| 111 | for (extra, 0..) |fd, i| all[i + 1] = fd; | ||
| 112 | const total = 1 + extra.len; | ||
| 113 | |||
| 114 | _ = std.posix.poll(all[0..total], timeout_ms) catch {}; | ||
| 115 | |||
| 116 | // Propagate revents back into caller's extra slice. | ||
| 117 | for (extra, 0..) |*fd, i| fd.* = all[i + 1]; | ||
| 118 | |||
| 119 | if (all[0].revents & std.posix.POLL.IN != 0) { | ||
| 120 | if (self.ops.prepareReadFn(self.ops.ctx)) { | ||
| 121 | self.ops.readEventsFn(self.ops.ctx); | ||
| 122 | } | ||
| 123 | } | ||
| 124 | self.ops.dispatchPendingFn(self.ops.ctx); | ||
| 125 | } | ||
| 126 | }; | ||