dec56ed3
Introduce FrameLoop module and DisplayOps trait
a73x 2026-04-16 10:59
Pure readiness primitive for the wl_surface.frame-callback pacing pattern. Not yet used by any loop — next commits add a mock DisplayOps for tests, then migrate the real loops one at a time.
diff --git a/build.zig b/build.zig index cfab411..7c8c808 100644 --- a/build.zig +++ b/build.zig @@ -37,6 +37,14 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); const frame_loop_mod = b.createModule(.{ .root_source_file = b.path("src/frame_loop.zig"), .target = target, .optimize = optimize, }); frame_loop_mod.addImport("wayland", wayland_generated_mod); frame_loop_mod.addImport("scale_tracker", scale_tracker_mod); const wayland_mod = b.createModule(.{ .root_source_file = b.path("src/wayland.zig"), .target = target, @@ -45,6 +53,7 @@ pub fn build(b: *std.Build) void { }); wayland_mod.addImport("wayland", wayland_generated_mod); wayland_mod.addImport("scale_tracker", scale_tracker_mod); wayland_mod.addImport("frame_loop", frame_loop_mod); wayland_mod.linkSystemLibrary("wayland-client", .{}); wayland_mod.linkSystemLibrary("xkbcommon", .{}); _ = wayland_dep; // referenced via Scanner @@ -69,6 +78,7 @@ pub fn build(b: *std.Build) void { exe_mod.addImport("vt", vt_mod); exe_mod.addImport("wayland-client", wayland_mod); exe_mod.addImport("config", config_mod); exe_mod.addImport("frame_loop", frame_loop_mod); const exe = b.addExecutable(.{ .name = "waystty", @@ -120,6 +130,19 @@ pub fn build(b: *std.Build) void { }); test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step); // Test frame_loop.zig const frame_loop_test_mod = b.createModule(.{ .root_source_file = b.path("src/frame_loop.zig"), .target = target, .optimize = optimize, }); frame_loop_test_mod.addImport("wayland", wayland_generated_mod); frame_loop_test_mod.addImport("scale_tracker", scale_tracker_mod); const frame_loop_tests = b.addTest(.{ .root_module = frame_loop_test_mod, }); test_step.dependOn(&b.addRunArtifact(frame_loop_tests).step); // Test wayland.zig const wayland_test_mod = b.createModule(.{ .root_source_file = b.path("src/wayland.zig"), diff --git a/src/frame_loop.zig b/src/frame_loop.zig new file mode 100644 index 0000000..d3e84c3 --- /dev/null +++ b/src/frame_loop.zig @@ -0,0 +1,126 @@ const std = @import("std"); const wl = @import("wayland").client.wl; const scale_tracker = @import("scale_tracker"); // Mirror of wayland.SurfaceState. Using a structural duplicate here instead of // importing wayland.zig avoids a circular module dependency (wayland depends on // frame_loop). The real wayland.SurfaceState embeds one of these by reference. pub const SurfaceStateView = struct { configured_ptr: *const bool, suspended_ptr: *const bool, tracker: *const scale_tracker.ScaleTracker, pub fn visible(self: SurfaceStateView) bool { return self.configured_ptr.* and !self.suspended_ptr.* and self.tracker.enteredCount() > 0; } }; // Opaque handle to a frame callback. Real path holds a *wl.Callback cast here; // mock holds a usize cast here. Identity is pointer equality. pub const CallbackToken = *const anyopaque; pub const DisplayOps = struct { ctx: *anyopaque, // All fn pointers receive the same ctx so the caller can carry whatever // concrete objects it needs (wl.Display, wl.Surface, test mock, etc). flushFn: *const fn (*anyopaque) void, prepareReadFn: *const fn (*anyopaque) bool, readEventsFn: *const fn (*anyopaque) void, dispatchPendingFn: *const fn (*anyopaque) void, getFdFn: *const fn (*anyopaque) std.posix.fd_t, // Requests a wl_surface.frame() and sets its done listener. Returns the // token identifying the new callback. FrameLoop compares the token // delivered by onFrameCallbackDone against pending_token. requestFrameFn: *const fn (*anyopaque, *FrameLoop) anyerror!CallbackToken, // Destroys a previously issued frame callback (for hide-path cleanup). // Must tolerate being called on a token the compositor has already // consumed — real path: wl.Callback.destroy is idempotent on the client. destroyCallbackFn: *const fn (*anyopaque, CallbackToken) void, }; pub const FrameLoop = struct { ops: DisplayOps, state: SurfaceStateView, pending_token: ?CallbackToken = null, armed: bool = true, pub fn init(ops: DisplayOps, state: SurfaceStateView) FrameLoop { return .{ .ops = ops, .state = state }; } pub fn deinit(self: *FrameLoop) void { if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t); self.pending_token = null; } pub fn canRender(self: *const FrameLoop) bool { return self.armed and self.state.visible(); } pub fn commitRender(self: *FrameLoop) !void { std.debug.assert(self.canRender()); if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t); self.pending_token = try self.ops.requestFrameFn(self.ops.ctx, self); self.armed = false; } pub fn onFrameCallbackDone(self: *FrameLoop, token: CallbackToken) void { if (self.pending_token == null or self.pending_token.? != token) return; self.ops.destroyCallbackFn(self.ops.ctx, token); self.pending_token = null; self.armed = true; } pub fn onSurfaceHidden(self: *FrameLoop) void { if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t); self.pending_token = null; // armed unchanged — canRender() is false while hidden regardless. } pub fn onSurfaceShown(self: *FrameLoop) void { self.armed = true; } pub fn forceArm(self: *FrameLoop) void { if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t); self.pending_token = null; self.armed = true; } /// Blocks on wl_fd + extra pollfds with `timeout_ms`, then reads + dispatches /// any pending Wayland events. Safe to call in any state. pub fn waitForWork( self: *FrameLoop, extra: []std.posix.pollfd, timeout_ms: i32, ) !void { self.ops.flushFn(self.ops.ctx); const wl_fd = self.ops.getFdFn(self.ops.ctx); // Build a small on-stack pollfd array: wl_fd + extras. // Cap extras at 8 — waystty never polls more than pty+wl. var all: [9]std.posix.pollfd = undefined; all[0] = .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 }; std.debug.assert(extra.len <= all.len - 1); for (extra, 0..) |fd, i| all[i + 1] = fd; const total = 1 + extra.len; _ = std.posix.poll(all[0..total], timeout_ms) catch {}; // Propagate revents back into caller's extra slice. for (extra, 0..) |*fd, i| fd.* = all[i + 1]; if (all[0].revents & std.posix.POLL.IN != 0) { if (self.ops.prepareReadFn(self.ops.ctx)) { self.ops.readEventsFn(self.ops.ctx); } } self.ops.dispatchPendingFn(self.ops.ctx); } };