feat(input): #89 cursor capture — Input.cursor_mode (hide/lock/confine)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 22s
ci / build-and-test (push) Successful in 2m24s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 29s

A windowed action game can hide the OS cursor and lock/confine the mouse to the
window. Input.cursor_mode(mode): 0 normal, 1 hidden (draw your own reticle),
2 locked (hidden + dissociated — the mouse feeds relative motion via
Input.mouse_dx/dy and Input.mouse_x/y is a clamped virtual cursor, FPS/twin-stick
aim), 3 confined (dissociated but visible; the mouse can't leave the window).
The platform auto-releases (shows + reconnects) while the window is not key
(Cmd-Tab) and on close, so the cursor is never left captured. Headless it is a
no-op (DCE'd).

Native macOS impl in cocoa.ll: [NSCursor hide]/[unhide] (ref-counted, toggled
only on change so the count stays balanced across focus changes),
CGAssociateMouseAndMouseCursorPosition, and CGGetLastMouseDelta for the relative
virtual cursor, behind a new win_cursor_mode intrinsic. Windowed-only behaviour
(not in the headless golden suite); example compiles headless and links
windowed. Full suite 115/0, fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-02 08:06:34 +03:00
parent f3f1336882
commit 3eb5447f74
11 changed files with 26543 additions and 26240 deletions

View file

@ -263,6 +263,7 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "touch_x") { bare = "input_touch_x"; push(labels, "index") }
if (meth == "touch_y") { bare = "input_touch_y"; push(labels, "index") }
if (meth == "set_touch") { bare = "input_set_touch"; push(labels, "index"); push(labels, "x"); push(labels, "y"); push(labels, "active") }
if (meth == "cursor_mode") { bare = "input_cursor_mode"; push(labels, "mode") } # #89 cursor capture
}
# Audio.* (#22) — sfx/music playback over the platform audio backend
# (runtime/native/audio.ludic + audio.ll). Playback is out-of-band; the

View file

@ -68,6 +68,7 @@ function emit_header() -> void {
emith("declare void @win_mouse(ptr)\n")
emith("declare void @win_pad(ptr)\n")
emith("declare void @win_touch(ptr)\n")
emith("declare void @win_cursor_mode(i32)\n")
emith("declare ptr @snd_load(ptr)\n") # #22 audio backend (audio.ll)
emith("declare void @snd_play(ptr, i32, i32, i32)\n")
emith("declare void @snd_stop(ptr)\n")

View file

@ -13,6 +13,7 @@ function is_intrinsic2(name: pointer) -> bool {
if (name == "win_running") or (name == "win_close") { return true }
if (name == "win_held") or (name == "win_mouse") { return true } # #50 device layer
if (name == "win_pad") or (name == "win_touch") { return true } # #51 gamepad / touch
if (name == "win_cursor_mode") { return true } # #89 cursor capture
if (name == "snd_load") or (name == "snd_play") or (name == "snd_stop") { return true } # #22 audio
if (name == "snd_playing") or (name == "snd_set_volume") or (name == "snd_set_rate") { return true }
if (name == "hs_req_new") or (name == "hs_req_header") or (name == "hs_req_body") { return true } # #6 HTTP
@ -65,6 +66,8 @@ function emit_intrinsic2(name: pointer, e: Node) -> Val {
# words/point [active, x, y]. Both windowed-only, DCE'd headless.
if (name == "win_pad") { let a = arg_code(e, 0); emit(" call void @win_pad(ptr "); emit(a); emit(")\n"); return val("0", "void") }
if (name == "win_touch") { let a = arg_code(e, 0); emit(" call void @win_touch(ptr "); emit(a); emit(")\n"); return val("0", "void") }
# #89 — set the OS cursor capture mode (windowed only; DCE'd headless).
if (name == "win_cursor_mode") { let a = arg_code(e, 0); emit(" call void @win_cursor_mode(i32 "); emit(a); emit(")\n"); return val("0", "void") }
# #22 audio backend — AVAudioPlayer via audio.ll; windowed-only, DCE'd headless.
if (name == "snd_load") { let a = arg_code(e, 0); return val(emit_bind(`call ptr @snd_load(ptr {a})`), "pointer") }
if (name == "snd_play") {

View file

@ -196,7 +196,7 @@ function p_postfix() -> Node {
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "bind" or e.s == "rebind" or e.s == "poll" or e.s == "down" or e.s == "pressed" or e.s == "record" or e.s == "replay" or e.s == "action" or e.s == "bind_pad" or e.s == "active" or e.s == "just_pressed" or e.s == "just_released") { g_uses_input = true }
# #50 device layer — any of the multi-key / analog / mouse / gamepad / touch
# methods also splices input.ludic (Input.key stays bare, no runtime).
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "axis_i" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch") { g_uses_input = true }
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "axis_i" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch" or e.s == "cursor_mode") { g_uses_input = true }
# Anim.play/clip/on_frame/fired + Motion.to (#48): the ergonomic writes over
# the SpriteAnim/Motion components live in systems.ludic and use the world
# table, so splice it and force the reflection ABI even if the game leaves

File diff suppressed because it is too large Load diff