a73x

dec56ed3

Introduce FrameLoop module and DisplayOps trait

a73x   2026-04-16 10:59

Commit message
Introduce FrameLoop module and DisplayOps trait

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.

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