From 739e0967432655bb1b86cbabef00531ccdcea43f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Mon, 12 Oct 2026 21:33:56 +0000 Subject: [PATCH] feat(codegen): bind shared cursor controls before script setup --- build.zig | 3 ++ src/codegen/lifecycle/callback.zig | 2 + src/codegen/lifecycle/loop.zig | 8 ++++ test/cursor_binding_tests.zig | 37 +++++++++++++++++++ test/goldens/acme_callback_main.zig | 1 + .../goldens/bgfx_wasm_editor_preview_main.zig | 1 + test/goldens/sokol_ios_main.zig | 1 + test/main_zig_tests.zig | 22 +++++++++++ 8 files changed, 75 insertions(+) create mode 100644 test/cursor_binding_tests.zig diff --git a/build.zig b/build.zig index 563be8b7..93335480 100644 --- a/build.zig +++ b/build.zig @@ -457,6 +457,7 @@ pub fn build(b: *std.Build) void { // index-aligned, and a bad declaration is a hard error. "test/hook_ordering_tests.zig", "test/animation_assets_tests.zig", + "test/cursor_binding_tests.zig", // The HOOK ROUTE INSPECTOR (labelle-assembler#724, // docs/design/hook-route-inspection.md). The load-bearing test // generates a `main.zig` through the real emitter and compares the @@ -477,6 +478,7 @@ pub fn build(b: *std.Build) void { "test/i18n_system_locale_tests.zig", }; + const cursor_test_step = b.step("test-cursor", "Test generated cursor binding and startup ordering"); for (test_files) |test_file| { const t = b.addTest(.{ .root_module = b.createModule(.{ @@ -492,6 +494,7 @@ pub fn build(b: *std.Build) void { }), }); const run_test = b.addRunArtifact(t); + if (std.mem.eql(u8, test_file, "test/cursor_binding_tests.zig") or std.mem.eql(u8, test_file, "test/main_zig_tests.zig")) cursor_test_step.dependOn(&run_test.step); test_step.dependOn(&run_test.step); if (std.mem.eql(u8, test_file, "test/build_zig_tests.zig") or std.mem.eql(u8, test_file, "test/build_zig_zon_tests.zig")) material_test_step.dependOn(&run_test.step); if (std.mem.eql(u8, test_file, "test/apk_assets_tests.zig")) { diff --git a/src/codegen/lifecycle/callback.zig b/src/codegen/lifecycle/callback.zig index 84dd9735..2bc378fc 100644 --- a/src/codegen/lifecycle/callback.zig +++ b/src/codegen/lifecycle/callback.zig @@ -74,6 +74,8 @@ pub fn Mixin(comptime Self: type) type { try @import("../blocks/animation_assets.zig").emit(w, self.animation_jsonc_names, .catch_panic_style); + try @import("loop.zig").emitCursorBinding(w); + // Device language (RFC-I18N section 8) — same hand-off and // placement as the loop-path setup (`loop.emitSystemLocale`). if (self.i18n) try @import("loop.zig").emitSystemLocale(w); diff --git a/src/codegen/lifecycle/loop.zig b/src/codegen/lifecycle/loop.zig index 2d9d63e3..afcb77b7 100644 --- a/src/codegen/lifecycle/loop.zig +++ b/src/codegen/lifecycle/loop.zig @@ -73,6 +73,12 @@ pub fn emitSystemLocale(w: anytype) !void { try w.writeAll(" }\n\n"); } +/// Bind the optional shared cursor seam before any script setup runs. Older +/// engines fold this away; newer engines adapt older window backends safely. +pub fn emitCursorBinding(w: anytype) !void { + try w.writeAll(" if (comptime @hasDecl(AssembledGame, \"bindCursorWindow\")) g.bindCursorWindow(window);\n"); +} + pub fn Mixin(comptime Self: type) type { return struct { pub fn buildSetupCode(self: *Self) ![]const u8 { @@ -87,6 +93,8 @@ pub fn Mixin(comptime Self: type) type { try @import("../blocks/animation_assets.zig").emit(w, self.animation_jsonc_names, .try_style); + try emitCursorBinding(w); + // Window icon hand-off (labelle-cli#359). Desktop only: the // loop templates call `window.initWindow` before `{{setup_code}}`, // so the window exists here. Gated on the backend DECL, so raylib/ diff --git a/test/cursor_binding_tests.zig b/test/cursor_binding_tests.zig new file mode 100644 index 00000000..a7f9c561 --- /dev/null +++ b/test/cursor_binding_tests.zig @@ -0,0 +1,37 @@ +//! Compile and run the emitted binding with both old and new engine surfaces. +const std = @import("std"); +const h = @import("helpers.zig"); + +test "generated cursor binding runs on a new engine and compiles away on an old one" { + const allocator = std.testing.allocator; + var ctx = h.emptyCodegen(allocator); + const setup = try ctx.buildCallbackInitCode(); + defer allocator.free(setup); + const start = std.mem.indexOf(u8, setup, " if (comptime @hasDecl(AssembledGame, \"bindCursorWindow\"))").?; + const end = start + std.mem.indexOfScalar(u8, setup[start..], '\n').?; + for ([_]bool{ false, true }) |modern| { + const header = if (modern) + "const AssembledGame = struct { calls: u32 = 0, pub fn bindCursorWindow(self: *@This(), comptime W: type) void { self.calls += 1; _ = W; } };\n" + else + "const AssembledGame = struct { calls: u32 = 0 };\n"; + const source = try std.fmt.allocPrint(allocator, "{s}const window = struct {{}};\nvar g = AssembledGame{{}};\ntest \"binding\" {{\n{s}\ntry @import(\"std\").testing.expectEqual(@as(u32, {d}), g.calls);\n}}\n", .{ header, setup[start..end], @intFromBool(modern) }); + defer allocator.free(source); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var file = try tmp.dir.createFile(std.testing.io, "binding.zig", .{}); + try file.writeStreamingAll(std.testing.io, source); + file.close(std.testing.io); + const root = try tmp.dir.realPathFileAlloc(std.testing.io, ".", allocator); + defer allocator.free(root); + const result = try std.process.run(allocator, std.testing.io, .{ + .argv = &.{ @import("test_options").zig_exe, "test", "binding.zig" }, + .cwd = .{ .path = root }, + }); + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + if (result.term != .exited or result.term.exited != 0) { + std.debug.print("cursor binding failed:\n{s}\n", .{result.stderr}); + return error.CursorBindingFailed; + } + } +} diff --git a/test/goldens/acme_callback_main.zig b/test/goldens/acme_callback_main.zig index 387ac766..e07cde27 100644 --- a/test/goldens/acme_callback_main.zig +++ b/test/goldens/acme_callback_main.zig @@ -172,6 +172,7 @@ export fn init() callconv(.c) void { g = AssembledGame.init(allocator); g.setHooks(&hooks); g.setScreenHeight(@as(f32, @floatFromInt(screen_h))); + if (comptime @hasDecl(AssembledGame, "bindCursorWindow")) g.bindCursorWindow(window); // ── Image asset backend wiring (Asset Streaming RFC, #53) ── // `engine.ImageLoader.setBackend` installs function-pointer adapters // that marshal between `BackendGfx`'s `DecodedImage`/`Texture` and diff --git a/test/goldens/bgfx_wasm_editor_preview_main.zig b/test/goldens/bgfx_wasm_editor_preview_main.zig index 63d4868a..984145e9 100644 --- a/test/goldens/bgfx_wasm_editor_preview_main.zig +++ b/test/goldens/bgfx_wasm_editor_preview_main.zig @@ -225,6 +225,7 @@ pub fn main() !void { g.setScreenHeight(@as(f32, @floatFromInt(screen_h))); + if (comptime @hasDecl(AssembledGame, "bindCursorWindow")) g.bindCursorWindow(window); // ── Image asset backend wiring (Asset Streaming RFC, #53) ── // `engine.ImageLoader.setBackend` installs function-pointer adapters // that marshal between `BackendGfx`'s `DecodedImage`/`Texture` and diff --git a/test/goldens/sokol_ios_main.zig b/test/goldens/sokol_ios_main.zig index 4e4e59da..bb0bd34e 100644 --- a/test/goldens/sokol_ios_main.zig +++ b/test/goldens/sokol_ios_main.zig @@ -438,6 +438,7 @@ export fn init() callconv(.c) void { const allocator = gpa.allocator(); g = AssembledGame.init(allocator); g.setHooks(&hooks); + if (comptime @hasDecl(AssembledGame, "bindCursorWindow")) g.bindCursorWindow(window); // ── Image asset backend wiring (Asset Streaming RFC, #53) ── // `engine.ImageLoader.setBackend` installs function-pointer adapters // that marshal between `BackendGfx`'s `DecodedImage`/`Texture` and diff --git a/test/main_zig_tests.zig b/test/main_zig_tests.zig index 98c4a8a9..618fb89e 100644 --- a/test/main_zig_tests.zig +++ b/test/main_zig_tests.zig @@ -1801,3 +1801,25 @@ pub const WINDOW_ICON = struct { } } }; + + +pub const CURSOR_BINDING = struct { + fn verify(source: []const u8) !void { + const bind = std.mem.indexOf(u8, source, "if (comptime @hasDecl(AssembledGame, \"bindCursorWindow\")) g.bindCursorWindow(window);") orelse return error.MissingCursorBinding; + const init = std.mem.indexOf(u8, source, "g = AssembledGame.init(") orelse return error.MissingInit; + const setup = std.mem.indexOf(u8, source, "runner.setup(&g);") orelse return error.MissingSetup; + try std.testing.expect(init < bind and bind < setup); + } + test "loop startup binds cursor after game init and before scripts" { + const source = try SYSTEM_LOCALE.generateMain(.desktop, raylib_lifecycle); + defer std.testing.allocator.free(source); + try verify(source); + } + test "callback startup binds cursor after game init and before scripts" { + h.setBgfxAndroidLifecycle(); + defer h.clearLifecycleOverrides(); + const source = try SYSTEM_LOCALE.generateMain(.android, bgfx_android_lifecycle); + defer std.testing.allocator.free(source); + try verify(source); + } +};