AppKit F10 — Pointer capture: lock, confine, relative motion
Per the F10 feature spec, mouselook on macOS is not one API but three orthogonal ones assembled by hand: stop the cursor (CGAssociateMouseAndMouseCursorPosition(false)), hide it (NSCursor.hide — app-scoped counter — vs CGDisplayHideCursor — per-display), and read relative motion (NSEvent.deltaX/deltaY on mouseMoved:, which keeps flowing while the cursor is pinned). The fourth piece — confine-to-rect — does not exist publicly on macOS: this demo measures the warp-back workaround and its artifacts. The program is ./examples/f10-pointer-capture/app.d (with the shared instrument.d logger), built on the scaffold recipe.
Last reviewed: June 11, 2026
All run findings are A[ssh]: built and executed on mac-bsn (aarch64-darwin, macOS 26.3.1, LDC 1.41.0) over SSH with the console session locked. As established in F12, a locked session delivers zero real pointer events — so motion is driven by synthetic NSEvents dispatched in-process via +[NSEvent eventWithCGEvent:] + [window sendEvent:] (the F07 route-1 workhorse). What is genuinely measured anyway: the CG calls' return values, the CG-side pointer position read-back (which does respond to warps even locked), the delta plumbing through the event-wrap route, and every piece of app-side state. The feel of the lock — and acceleration — is Tier C (script).
| Measurement | Value |
|---|---|
| Lock assembly | 3 calls (dissociate + hide + read deltas); all synchronous, none can be denied |
CGAssociateMouse…(false) read-back | none — no public getter; the app must track lock state itself |
mouseMoved: delivery | opt-in: acceptsMouseMovedEvents default 0, must be set |
Plain NSEvent ctor deltas | constructor has no delta parameters; wrapped deltaX/Y read back 0.00 |
| CG-delta survival through the wrap | kCGMouseEventDeltaX/Y = 12/−7 → view sees dx=12.00 dy=−7.00 (lossless) |
CGWarpMouseCursorPosition, locked session | err=0, CG pointer read-back moves (947.3,412.2)→(360.0,837.0) — not a no-op |
| Events generated by a warp | 0 (arrivals_since_warp=0) |
| Warp suppression interval | default 0.250 s per event source; settable to 0, reads back 0.000 |
| Unlock position restore | warp to saved (947.3,412.2) reads back (947.0,412.0) — fraction truncated |
| Confine API | none public; warp-back workaround: 2 of 2 excursions delivered out-of-bounds before the warp |
| Exit | clean 0 (loop_exit steps=16 arrivals=7 warps=2) |
The lock trio: dissociate + hide + deltas A[ssh]
Entering mouselook (verbatim):
398539 APPKIT_F10 lock_save cg_pos=(947.3,412.2)
398641 APPKIT_F10 cursor op=NSCursor_hide scope=app note=balanced_counter
398710 APPKIT_F10 lock state=on assoc_err=0 readback=no_public_getterCGAssociateMouseAndMouseCursorPosition(false)returnsCGError0 and divorces cursor motion from mouse motion: the cursor freezes, but mouse movement still generatesmouseMoved:events carrying deltas. It is synchronous and unconditional — unlike Wayland'slock_pointer(a request the compositor may refuse; see the Wayland F10 findings), there is no grant/deny protocol, and no focus requirement is enforced by the API itself.- There is no getter. The association state is write-only global session state; a framework must shadow it (and re-assert it on focus changes, since the system resets it when the app deactivates).
- The saved position comes from
CGEventCreate(null)+CGEventGetLocation— the CG-side (top-left-origin y-down) pointer, contrasted at baseline with the AppKit-side y-upNSEvent.mouseLocation:
248552 APPKIT_F10 baseline nsevent_mouseLocation=(947.3,704.8) cgevent_location=(947.3,412.2) accepts_mouse_moved=1Same point, two coordinate systems (704.8 + 412.2 = 1117 = display height in points): every lock/confine implementation on macOS juggles both, because the reading APIs are AppKit-side and the warping APIs are CG-side.
- One prerequisite bites first:
mouseMoved:is opt-in. The window'sacceptsMouseMovedEventsdefaults to0(window_created … accepts_mouse_moved_default=0 set=1) — without setting it, a lock implementation reads deltas only during drags.
Exit restores the cursor where the lock began:
1300777 APPKIT_F10 cursor op=NSCursor_unhide scope=app
1300834 APPKIT_F10 lock state=off assoc_err=0 restore_warp_err=0 target=(947.3,412.2) readback=(947.0,412.0)Re-associate, warp back to the saved spot, unhide — and the read-back shows the warp truncates the fractional part (947.3 → 947.0): position restoration on macOS is exact only to integer points. (Restoration is the app's job; nothing restores automatically, unlike Wayland's optional cursor-position hint.)
Relative motion: what a delta-bearing event takes A[ssh]
With no real mouse, the demo probes both ways to build a synthetic mouseMoved: with deltas — and the result is asymmetric:
548593 APPKIT_F10 inject route=nsevent label=plain_move_center loc=(240,160) ctor_deltas=none wrapped_dx=0.00 wrapped_dy=0.00
548796 APPKIT_F10 pointer rel dx=0.00 dy=0.00 raw=0 route=sendEventThe AppKit-level constructor mouseEventWithType:location:… simply has no delta parameters — a plain synthetic move always arrives with deltaX/Y == 0. Deltas live one layer down:
698561 APPKIT_F10 inject route=cgevent_wrap label=cg_move_delta_12_-7 cg_dx=12 cg_dy=-7
698652 APPKIT_F10 wrap label=cg_move_delta_12_-7 type=5 wrapped_dx=12.00 wrapped_dy=-7.00 win=0
698725 APPKIT_F10 pointer rel dx=12.00 dy=-7.00 raw=0 route=sendEvent
698745 APPKIT_F10 mouselook yaw=6.00 pitch=-3.50CGEventCreateMouseEvent + CGEventSetIntegerValueField(kCGMouseEventDeltaX/Y) wrapped via +[NSEvent eventWithCGEvent:] survives lossless through every hop: CG read-back 12/−7, wrapped-NSEvent deltaX/Y 12.00/−7.00, and the view's mouseMoved: the same — driving the crosshair (yaw/pitch) integration. Two routing details worth recording: the wrapped event reports windowNumber=0 (no window association) yet [window sendEvent:] still delivers it to the first responder, and its locationInWindow is already in screen coordinates.
On real hardware the deltas in mouseMoved: are post-acceleration (WindowServer pointer ballistics applied) — raw=0 in the log is honest labeling, not a probe result. Raw counts on macOS require dropping below the window system entirely (IOHIDManager), which is out of scope for this column; the Tier-C session can at least confirm that deltas keep flowing (and the cursor stays pinned) while locked.
Warp, and the suppression interval A[ssh]
996508 APPKIT_F10 warp target=(360.0,837.0) err=0 before=(947.3,412.2) after=(360.0,837.0)
1150364 APPKIT_F10 warp_events arrivals_since_warp=0 note=warp_generates_no_events_documented
1150446 APPKIT_F10 suppression_interval default_s=0.250 set_s=0.000 readback_s=0.000 scope=per_event_sourceCGWarpMouseCursorPositionreturnserr=0and the CG-side read-back moves even with the session locked — the WindowServer's pointer state is live; only delivery to apps is severed. (The expected locked-session no-op did not materialize; the warp is real.)- The warp generated zero motion events (
arrivals_since_warp=0) — exactly as documented ("does not generate or post any events"). A warp-to-center fallback (the pre-CGAssociate…X11-style technique) therefore doesn't pollute the delta stream, but also can't be observed via events. - The classic gotcha is quantified: after a programmatic warp, the event source suppresses hardware events for a default of 0.250 s —
CGEventSourceGetLocalEventsSuppressionIntervalreads0.250, and setting it to0.0reads back0.000. Any warp-using technique (including the confine workaround below) must zero this or motion hiccups a quarter-second after every warp.
Hide: two scopes, two APIs A[ssh]
1447340 APPKIT_F10 cursor op=CGDisplayHideCursor err=0 display=1 scope=display
1447402 APPKIT_F10 cursor op=CGDisplayShowCursor err=0 display=1 scope=displayNSCursor.hide is an app-scoped balanced counter (each hide needs an unhide, as probed in the F12 cursors findings); the lock path uses it so an aborted lock can't leave another app's cursor invisible. CGDisplayHideCursor/CGDisplayShowCursor take a CGDirectDisplayID and also return 0 headless — but despite the per-display signature the docs note the cursor is hidden globally, and the same balanced-counting caveat applies connection-wide. For a lock implementation the NSCursor pair is the right scope; the CG pair exists for display-capture-style apps.
The missing confine API, and the warp-back workaround A[ssh]
macOS has no public confine-to-rect API — nothing like Win32's ClipCursor, Wayland's confine_pointer, or X11 pointer barriers (see the Win32 / Wayland / X11 F10 findings; the F10 spec calls out the absence). The closest public tool is reactive: watch motion, and warp back when it leaves the rect. The demo confines to the center half of the content view and scripts two escapes:
1598545 APPKIT_F10 confine rect_view=(120,80 240x160) rect_screen=(240,200 240x160) api=none_public mechanism=warp_back
1898632 APPKIT_F10 pointer abs x=590.0 y=280.0 screen=(590.0,280.0) kind=mouseMoved win=0
1898838 APPKIT_F10 confine warp from=(590.0,280.0) to=(480.0,280.0) err=0 note=event_already_delivered_outside
2048715 APPKIT_F10 pointer abs x=125.0 y=435.0 screen=(125.0,435.0) kind=mouseMoved win=0
2048849 APPKIT_F10 confine warp from=(125.0,435.0) to=(240.0,360.0) err=0 note=event_already_delivered_outside
2348545 APPKIT_F10 confine state=off out_of_bounds_deliveries=2 warps=2The artifact is structural, not a timing accident: every escape is delivered out-of-bounds first (2 of 2 here — the pointer abs x=590 line precedes its corrective warp), because the workaround only runs after the event arrives. The costs, enumerated:
- one out-of-rect event per excursion reaches the app (and, worse, whatever other window the cursor crossed into);
- the user sees the cursor jump back (Tier C);
- each warp triggers the 0.25 s suppression interval unless zeroed (above);
- it only works while the app receives the motion — drag the cursor fast enough into another app's window and the events stop coming.
A portability layer should expose confine as unsupported on macOS (or emulate with exactly these documented artifacts) rather than pretend warp-back is equivalent.
Tier C script: mouselook session
Run on mac-bsn in an unlocked GUI session (results → this doc):
- Build per the scaffold (binary staged at
/tmp/wsi-m6/f10-pointer-capture/demo), run./demowith no env vars; a dark window with a red crosshair appears. - Click the view: mouselook engages (
lock state=on, cursor hides and freezes). Move the mouse — confirmpointer rel dx/dyflow with the cursor pinned, the crosshair tracks, andpointer absstays constant. Note whether small/slow vs fast motions show acceleration curvature in the deltas (they are post-ballistics). - Click again: lock exits — confirm the cursor reappears where it was locked (integer-truncated restore) and exactly once (
hide/unhidebalanced). - While unlocked, observe the confine phase if re-run with
WSI_AUTO_EXIT=1from the GUI session: the cursor should visibly snap back at the rect edge (the warp-back jump), and out-of-bounds deliveries should match warps. - Alt-Tab away while locked: record whether the association resets on deactivate (cursor moves again without an explicit unlock) — the known re-assert-on-focus requirement.
Findings summary (for event-sequences.md)
- Lock is assembled, not atomic: dissociate (
CGAssociate…(false)) + hide (NSCursor.hide) + deltas (mouseMoved:withacceptsMouseMovedEventsopted in). All synchronous, cannot be denied (vs Wayland's async grant), and with no read-back — the framework must shadow lock state and restore the position itself (integer-truncated). - Deltas live at the CG layer: the
NSEventconstructor can't carry them (always 0);kCGMouseEventDeltaX/YsurviveeventWithCGEvent:wrapping and arrive at the view lossless. Real deltas are post-acceleration; raw counts need IOHIDManager (below the window system). - Warp is silent and quantized:
CGWarpMouseCursorPositiongenerates no events, truncates fractions, works even in a locked session — and arms a 0.25 s hardware-event suppression interval per event source that lock/confine code must zero (CGEventSourceSetLocalEventsSuppressionInterval). - Confine does not exist publicly. The warp-back emulation leaks one out-of-bounds event per excursion (measured 2/2), shows a visible cursor jump, and interacts with the suppression interval — expose it as an unsupported capability, not an equivalent.
- Two hide scopes: app-scoped balanced
NSCursor.hide/unhide(right for mouselook) vs connection-globalCGDisplayHideCursor(capture-style apps).
Sources
- This demo —
./examples/f10-pointer-capture/app.d,./examples/f10-pointer-capture/instrument.d; the AppKit scaffold findings (recipe, locked-session evidence), the F12 cursors findings (hide/unhide counter, locked-session event delivery), the F07 text-input findings (the in-processsendEvent:route), and the AppKit survey. - Feature spec — F10 pointer capture; sibling columns: Wayland, Win32, X11; the Tier-C entry in the manual-run-queue.
- Apple Developer documentation (Wayback-pinned where a verified snapshot exists; this host is bot-hostile):
CGAssociateMouseAndMouseCursorPosition,CGWarpMouseCursorPosition,CGDisplayHideCursor,CGDisplayShowCursor,NSEvent.deltaX/deltaY,NSEvent.mouseLocation,CGEventGetLocation,NSWindow.acceptsMouseMovedEvents,NSEvent init(cgEvent:),mouseEvent(with:location:…),mouseMoved(with:),CGEventSourceSetLocalEventsSuppressionInterval,NSCursor.hide.