a73x

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