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

@ -0,0 +1,3 @@
bump: minor
type: feat
Cursor capture (#89) — `Input.cursor_mode(mode)` hides / locks / confines the OS mouse for a windowed game: `0` normal (visible, free), `1` hidden (hide the OS cursor while focused so a game draws its own reticle), `2` locked (hidden + dissociated — the mouse feeds *relative* motion through `Input.mouse_dx/dy`, and `Input.mouse_x/y` becomes a clamped virtual cursor, the FPS / twin-stick aim mode), `3` confined (dissociated but visible; the mouse cannot leave the window). The platform auto-releases (shows + reconnects the cursor) while the window is not key (Cmd-Tab) and on close, so the cursor is never left captured. Adds the native macOS implementation in `cocoa.ll` (`[NSCursor hide]/[unhide]`, ref-counted and toggled only on change; `CGAssociateMouseAndMouseCursorPosition`; `CGGetLastMouseDelta` for the relative virtual cursor) behind a new `win_cursor_mode` intrinsic; headless / non-windowed it is a no-op (DCE'd). Example: `examples/library/cursor_capture.ludic`.

View file

@ -0,0 +1,34 @@
---
id: input-cursor_mode
name: Input.cursor_mode
category: input
kind: namespace-method
tokens: Input.cursor_mode
sig: Input.cursor_mode(mode)
tip: Hide / lock / confine the OS mouse cursor to the window (windowed).
order: 36
ns: Input
member: cursor_mode
---
Sets how a windowed game captures the OS mouse cursor:
- <code>0</code> normal (default) — the cursor is visible and free.
- <code>1</code> hidden — the cursor is hidden while the window is focused, so a game can draw its own reticle.
- <code>2</code> locked — hidden and dissociated from the mouse; the mouse feeds <em>relative</em> motion through <a href="input-mouse_dx.html"><code>Input.mouse_dx</code></a> / <code>mouse_dy</code>, and <a href="input-mouse_x.html"><code>Input.mouse_x</code></a> / <code>mouse_y</code> is a clamped virtual cursor — the FPS / twin-stick aim mode.
- <code>3</code> confined — dissociated but visible; the mouse cannot leave the window.
The platform **auto-releases** (shows the cursor and reconnects it) while the window is not the key window (Cmd-Tab), and restores it on window close, so the cursor is never left captured. On a headless / non-windowed build it is a no-op.
```ludic
program Aim {
property Tag { n: int = 0 }
model P { Tag }
@OnStart handler Boot { Input.cursor_mode(2) } # lock + hide for aiming
handler Draw phase Render {
Screen.clear(0x101018)
Screen.fill_rectangle(Input.mouse_x() - 2, Input.mouse_y() - 2, 4, 4, 0xffcc00)
Screen.show()
}
}
```

View file

@ -0,0 +1,24 @@
# cursor_capture.ludic — cursor capture (#89): a windowed game hides / locks the
# OS mouse and draws its own reticle from the mouse position. Input.cursor_mode is
# a no-op headless (it only touches the platform cursor on a windowed build), so
# this compiles and links both ways; the capture behaviour is windowed-only.
#
# Run windowed: bin/ludicc examples/library/cursor_capture.ludic -o /tmp/aim && /tmp/aim
program CursorCapture {
property Tag { n: int = 0 }
model P { Tag }
@OnStart handler Boot {
Input.cursor_mode(2) # 2 = locked: hide the OS cursor, feed relative motion
}
handler Draw phase Render {
Screen.clear(0x101018)
# a reticle drawn at the captured mouse position (a virtual cursor while locked)
let mx = Input.mouse_x()
let my = Input.mouse_y()
Screen.fill_rectangle(mx - 3, my - 1, 7, 2, 0xffcc00)
Screen.fill_rectangle(mx - 1, my - 3, 2, 7, 0xffcc00)
Screen.show()
}
}

View file

@ -41,6 +41,10 @@ declare void @CGImageRelease(ptr)
declare void @CGContextRelease(ptr)
declare void @CGColorSpaceRelease(ptr)
declare i32 @usleep(i32)
; #89 — cursor capture: NSCursor hide/unhide (ref-counted) + the CoreGraphics
; mouse-association / relative-delta calls.
declare i32 @CGAssociateMouseAndMouseCursorPosition(i32)
declare void @CGGetLastMouseDelta(ptr, ptr)
@.c_app = private unnamed_addr constant [14 x i8] c"NSApplication\00"
@.c_win = private unnamed_addr constant [9 x i8] c"NSWindow\00"
@ -49,6 +53,10 @@ declare i32 @usleep(i32)
@.c_view = private unnamed_addr constant [7 x i8] c"NSView\00"
@.c_gctx = private unnamed_addr constant [18 x i8] c"NSGraphicsContext\00"
@.c_ludic = private unnamed_addr constant [10 x i8] c"LudicView\00"
@.c_cursor = private unnamed_addr constant [9 x i8] c"NSCursor\00"
@.s_hide = private unnamed_addr constant [5 x i8] c"hide\00"
@.s_unhide = private unnamed_addr constant [7 x i8] c"unhide\00"
@.s_iskey = private unnamed_addr constant [12 x i8] c"isKeyWindow\00"
@.s_shared = private unnamed_addr constant [18 x i8] c"sharedApplication\00"
@.s_policy = private unnamed_addr constant [21 x i8] c"setActivationPolicy:\00"
@ -143,6 +151,14 @@ declare i32 @usleep(i32)
; #51 — up to 8 trackpad touch points, [active, x, y] each (framebuffer pixels),
; refreshed by the view's touchesBegan/Moved/Ended handlers.
@W_touch = internal global [24 x i32] zeroinitializer
; #89 — cursor capture. @W_cursor_mode is the desired mode (0 normal, 1 hidden,
; 2 locked+relative, 3 confined). @W_cursor_hidden / @W_cursor_assoc track the
; currently-applied NSCursor hide (ref-counted, so we only toggle on a change) and
; the CGAssociateMouseAndMouseCursorPosition state, so focus loss auto-releases and
; refocus re-acquires without unbalancing the hide counter.
@W_cursor_mode = internal global i32 0
@W_cursor_hidden = internal global i32 0
@W_cursor_assoc = internal global i32 1
; -drawRect: — blit the framebuffer into the view.
; The NSRect argument is ignored, so it never appears in this signature.
@ -477,6 +493,7 @@ dosend:
%fwd = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %app, ptr %sel_send, ptr %ev)
br label %pump
finish:
call void @win_cursor_maintain() ; #89 — keep the cursor state (auto-release on focus loss)
; a closed window ends the run
%win = load ptr, ptr @W_win
%hw = icmp ne ptr %win, null
@ -519,6 +536,9 @@ entry:
define void @win_close() {
entry:
; #89 — restore the cursor so it is never left hidden / dissociated after quit.
store i32 0, ptr @W_cursor_mode
call void @cursor_apply(i32 0, i32 1)
store i32 0, ptr @W_running
ret void
}
@ -550,6 +570,41 @@ entry:
; #51 — read the live cursor position from the window (points, origin
; bottom-left) and convert to framebuffer pixels, y-flipped so row 0 is the
; top, matching the framebuffer the game draws into.
; #89 — in a relative mode (locked / confined) the OS cursor is dissociated, so
; mouseLocation is frozen; accumulate CGGetLastMouseDelta into a virtual cursor
; (clamped to the framebuffer) so Input.mouse_dx/dy report the motion and
; Input.mouse_x/y give a reticle position.
%cmode = load i32, ptr @W_cursor_mode
%isrel = icmp sge i32 %cmode, 2
br i1 %isrel, label %reldelta, label %abspos
reldelta:
%dxp = alloca i32
%dyp = alloca i32
call void @CGGetLastMouseDelta(ptr %dxp, ptr %dyp)
%ddx = load i32, ptr %dxp
%ddy = load i32, ptr %dyp
%omx = load i32, ptr @W_mx
%omy = load i32, ptr @W_my
%nmx = add i32 %omx, %ddx
%nmy = add i32 %omy, %ddy
; clamp x to [0, fbw-1]
%fbwv = load i32, ptr @W_fbw
%fbwm = sub i32 %fbwv, 1
%xlo = icmp slt i32 %nmx, 0
%nmx1 = select i1 %xlo, i32 0, i32 %nmx
%xhi = icmp sgt i32 %nmx1, %fbwm
%nmx2 = select i1 %xhi, i32 %fbwm, i32 %nmx1
; clamp y to [0, fbh-1]
%fbhv = load i32, ptr @W_fbh
%fbhm = sub i32 %fbhv, 1
%ylo = icmp slt i32 %nmy, 0
%nmy1 = select i1 %ylo, i32 0, i32 %nmy
%yhi = icmp sgt i32 %nmy1, %fbhm
%nmy2 = select i1 %yhi, i32 %fbhm, i32 %nmy1
store i32 %nmx2, ptr @W_mx
store i32 %nmy2, ptr @W_my
br label %emit
abspos:
%win = load ptr, ptr @W_win
%nowin = icmp eq ptr %win, null
br i1 %nowin, label %emit, label %qpos
@ -587,6 +642,84 @@ emit:
ret void
}
; ============================================================================
; #89 — cursor capture. Apply the currently-wanted hidden / associated state,
; toggling only on a change so the ref-counted [NSCursor hide]/[unhide] stays
; balanced across frames and focus changes.
define void @cursor_apply(i32 %wh, i32 %wa) {
entry:
%ch = load i32, ptr @W_cursor_hidden
%hdiff = icmp ne i32 %ch, %wh
br i1 %hdiff, label %doh, label %skiph
doh:
%curscls = call ptr @objc_getClass(ptr @.c_cursor)
%wanth = icmp ne i32 %wh, 0
br i1 %wanth, label %hide, label %unhide
hide:
%selhide = call ptr @sel_registerName(ptr @.s_hide)
%r1 = call ptr (ptr, ptr) @objc_msgSend(ptr %curscls, ptr %selhide)
br label %storeh
unhide:
%selunh = call ptr @sel_registerName(ptr @.s_unhide)
%r2 = call ptr (ptr, ptr) @objc_msgSend(ptr %curscls, ptr %selunh)
br label %storeh
storeh:
store i32 %wh, ptr @W_cursor_hidden
br label %skiph
skiph:
%ca = load i32, ptr @W_cursor_assoc
%adiff = icmp ne i32 %ca, %wa
br i1 %adiff, label %doa, label %skipa
doa:
%rc = call i32 @CGAssociateMouseAndMouseCursorPosition(i32 %wa)
store i32 %wa, ptr @W_cursor_assoc
br label %skipa
skipa:
ret void
}
; Re-derive the wanted state from the mode + window key status, then apply it.
; Called on a mode change and once per frame from win_poll, so losing focus
; (Cmd-Tab) auto-releases the cursor and refocusing re-acquires it.
define void @win_cursor_maintain() {
entry:
%mode = load i32, ptr @W_cursor_mode
%win = load ptr, ptr @W_win
%nowin = icmp eq ptr %win, null
br i1 %nowin, label %release, label %askkey
askkey:
%selk = call ptr @sel_registerName(ptr @.s_iskey)
%k8 = call i8 (ptr, ptr) @objc_msgSend(ptr %win, ptr %selk)
%iskey = icmp ne i8 %k8, 0
br i1 %iskey, label %keyed, label %release
release:
; not focused (or no window): show the cursor and reconnect it
call void @cursor_apply(i32 0, i32 1)
ret void
keyed:
; want_hidden when mode is 1 (hidden) or 2 (locked)
%h1 = icmp eq i32 %mode, 1
%h2 = icmp eq i32 %mode, 2
%wh = or i1 %h1, %h2
%whi = zext i1 %wh to i32
; dissociate (relative) when mode is 2 (locked) or 3 (confined)
%a2 = icmp eq i32 %mode, 2
%a3 = icmp eq i32 %mode, 3
%dis = or i1 %a2, %a3
%wa = xor i1 %dis, true
%wai = zext i1 %wa to i32
call void @cursor_apply(i32 %whi, i32 %wai)
ret void
}
; the Ludic entry point: set the desired cursor mode and apply it now.
define void @win_cursor_mode(i32 %mode) {
entry:
store i32 %mode, ptr @W_cursor_mode
call void @win_cursor_maintain()
ret void
}
; ============================================================================
; #51 — gamepad, via GameController.framework.
;

View file

@ -402,6 +402,20 @@ function input_strength(name: pointer) -> fixed {
return fixed(0)
}
# ---- cursor capture (#89) --------------------------------------------------
# Set the OS cursor mode for a windowed game:
# 0 normal — cursor visible and free (default).
# 1 hidden — cursor hidden while the window is focused (draw your own reticle).
# 2 locked — hidden + dissociated; the mouse feeds relative motion through
# Input.mouse_dx/dy and Input.mouse_x/y is a clamped virtual cursor
# (the FPS / twin-stick capture mode).
# 3 confined — dissociated but visible; the mouse cannot leave the window.
# The platform auto-releases (shows + reconnects) while the window is not key
# (Cmd-Tab) and on close. Headless / non-windowed: a no-op.
function input_cursor_mode(mode: int) -> void {
if is_windowed() { win_cursor_mode(mode) }
}
# ---- mouse -----------------------------------------------------------------
function input_mouse_x() -> int { in_init(); return in_mx }
function input_mouse_y() -> int { in_init(); return in_my }

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

View file

@ -355,6 +355,7 @@ function cmd_test() -> int {
smoke("ecs/hello")
smoke("events/events")
smoke("networking/net_rt")
smoke("library/cursor_capture") # #89 Input.cursor_mode compiles (no-op headless; windowed links cocoa.ll)
print("== self-hosted front-end binaries (ludicc / ludic) ==")
# the two commands are one multi-call native binary built from the seed with