Skip to content

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).

MeasurementValue
Lock assembly3 calls (dissociate + hide + read deltas); all synchronous, none can be denied
CGAssociateMouse…(false) read-backnone — no public getter; the app must track lock state itself
mouseMoved: deliveryopt-in: acceptsMouseMovedEvents default 0, must be set
Plain NSEvent ctor deltasconstructor has no delta parameters; wrapped deltaX/Y read back 0.00
CG-delta survival through the wrapkCGMouseEventDeltaX/Y = 12/−7 → view sees dx=12.00 dy=−7.00 (lossless)
CGWarpMouseCursorPosition, locked sessionerr=0, CG pointer read-back moves (947.3,412.2)→(360.0,837.0)not a no-op
Events generated by a warp0 (arrivals_since_warp=0)
Warp suppression intervaldefault 0.250 s per event source; settable to 0, reads back 0.000
Unlock position restorewarp to saved (947.3,412.2) reads back (947.0,412.0)fraction truncated
Confine APInone public; warp-back workaround: 2 of 2 excursions delivered out-of-bounds before the warp
Exitclean 0 (loop_exit steps=16 arrivals=7 warps=2)

The lock trio: dissociate + hide + deltas A[ssh]

Entering mouselook (verbatim):

text
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_getter
  • CGAssociateMouseAndMouseCursorPosition(false) returns CGError 0 and divorces cursor motion from mouse motion: the cursor freezes, but mouse movement still generates mouseMoved: events carrying deltas. It is synchronous and unconditional — unlike Wayland's lock_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-up NSEvent.mouseLocation:
text
248552 APPKIT_F10 baseline nsevent_mouseLocation=(947.3,704.8) cgevent_location=(947.3,412.2) accepts_mouse_moved=1

Same 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's acceptsMouseMovedEvents defaults to 0 (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:

text
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:

text
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=sendEvent

The 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:

text
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.50

CGEventCreateMouseEvent + 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]

text
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_source
  • CGWarpMouseCursorPosition returns err=0 and 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 sCGEventSourceGetLocalEventsSuppressionInterval reads 0.250, and setting it to 0.0 reads back 0.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]

text
1447340 APPKIT_F10 cursor op=CGDisplayHideCursor err=0 display=1 scope=display
1447402 APPKIT_F10 cursor op=CGDisplayShowCursor err=0 display=1 scope=display

NSCursor.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:

text
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=2

The 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:

  1. one out-of-rect event per excursion reaches the app (and, worse, whatever other window the cursor crossed into);
  2. the user sees the cursor jump back (Tier C);
  3. each warp triggers the 0.25 s suppression interval unless zeroed (above);
  4. 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):

  1. Build per the scaffold (binary staged at /tmp/wsi-m6/f10-pointer-capture/demo), run ./demo with no env vars; a dark window with a red crosshair appears.
  2. Click the view: mouselook engages (lock state=on, cursor hides and freezes). Move the mouse — confirm pointer rel dx/dy flow with the cursor pinned, the crosshair tracks, and pointer abs stays constant. Note whether small/slow vs fast motions show acceleration curvature in the deltas (they are post-ballistics).
  3. Click again: lock exits — confirm the cursor reappears where it was locked (integer-truncated restore) and exactly once (hide/unhide balanced).
  4. While unlocked, observe the confine phase if re-run with WSI_AUTO_EXIT=1 from 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.
  5. 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: with acceptsMouseMovedEvents opted 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 NSEvent constructor can't carry them (always 0); kCGMouseEventDeltaX/Y survive eventWithCGEvent: 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: CGWarpMouseCursorPosition generates 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-global CGDisplayHideCursor (capture-style apps).

Sources