AppKit F11 — Scroll fidelity
Per the F11 feature spec, one physical scroll reaches a macOS app as one scrollWheel: event carrying three representations: the modern scrollingDeltaX/Y (pixel-precise when hasPreciseScrollingDeltas says so), the legacy deltaX/deltaY line-ish units, and the phase/momentumPhase pair that turns an event stream into trackpad gestures. This demo logs every field of every event, drives the stream from both CGEventCreateScrollWheelEvent unit modes, probes the CG→NSEventPhase mapping, and keys per-gesture summaries on the phase transitions over a scrollable ruler. The program is ./examples/f11-scroll/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 — no real wheel or trackpad, so events are built with CGEventCreateScrollWheelEvent2 (line- and pixel-unit) and delivered in-process via +[NSEvent eventWithCGEvent:] + [window sendEvent:] (the F07 route-1 workhorse). The synthetic stream exercises the full anatomy and the phase machinery; a real trackpad fling's exact choreography is Tier C (script).
| Measurement | Value |
|---|---|
| Line-unit notch at the CG layer | 1 notch → DeltaAxis1=1, FixedPtDeltaAxis1=1.000, PointDeltaAxis1=10, IsContinuous=0 |
| The line↔pixel ratio CG bakes in | 1 line = 10 px (point1 = 10 × lines, both unit modes) |
| Line-unit event at the NSEvent layer | precise=0, scrollingDeltaY == deltaY == 1.0 (line units, not ×10) |
| Pixel-unit event at the NSEvent layer | precise=1, scrollingDeltaY = px (10 → 10.00), deltaY = px/10 (10 → 1.000) |
| Smallest representable step | fractional via 16.16 FixedPtDeltaAxis1 (0.4 → sdy=0.40, legacy_dy=0.400) |
| Device-class signal | kCGScrollWheelEventIsContinuous ↔ hasPreciseScrollingDeltas, 1:1 both ways |
CG phase → NSEventPhase | re-encoded, not passed through: 1→0x1, 2→0x4, 4→0x8, 8→0x10 (began/changed/ended/cancelled) |
CG momentum → momentumPhase | 1→0x1(began), 2→0x4(changed), 3→0x8(ended) — sequential in, bitmask out |
sendEvent: vs momentum events | dropped (momentumPhase≠0 never reached the view; direct call fallback used) |
isDirectionInvertedFromDevice | 0 on every synthetic event (natural-scroll flip is a real-device property; Tier C) |
| Exit | clean 0 (loop_exit steps=18 arrivals=16; totals … gestures=2 final_offset_y=-217.4) |
The unit duality: one CG constructor, two meanings A[ssh]
CGEventCreateScrollWheelEvent takes a CGScrollEventUnit — and whichever unit you pick, Quartz synthesizes all three delta fields (CGEventField read-backs), converting at a fixed 10 px per line:
244902 APPKIT_F11 inject label=line_1_notch units=line wheel1=1 cg_phase=-1 cg_momentum=-1
244936 APPKIT_F11 cg_fields label=line_1_notch delta1=1 fixedpt1=1.000 point1=10 continuous=0 phase=0 momentum=0
245042 APPKIT_F11 scroll sdx=0.00 sdy=1.00 legacy_dx=0.000 legacy_dy=1.000 precise=0 phase=0x0(none) momentum=0x0(none) inverted=0
694428 APPKIT_F11 inject label=pixel_10 units=pixel wheel1=10 cg_phase=-1 cg_momentum=-1
694537 APPKIT_F11 cg_fields label=pixel_10 delta1=1 fixedpt1=1.000 point1=10 continuous=1 phase=0 momentum=0
694706 APPKIT_F11 scroll sdx=0.00 sdy=10.00 legacy_dx=0.000 legacy_dy=1.000 precise=1 phase=0x0(none) momentum=0x0(none) inverted=0Two events with identical CG delta fields (delta1=1 fixedpt1=1.000 point1=10) — the only difference is IsContinuous, set by the constructor's unit — and the wrapped NSEvent reads completely differently:
- Line unit (
IsContinuous=0) →hasPreciseScrollingDeltas= NO, andscrollingDeltaYreports the line count (1.00), equal to legacydeltaY. The "1 notch =scrollingDeltaY~10" folklore is not what the event carries: for imprecise events the documented contract is thatscrollingDeltaX/Yis line-based and the app multiplies by its own line height. (CG'sPointDeltaAxis1=10is where a default ~10 px line lives, but AppKit does not pre-multiply it into the event.) - Pixel unit (
IsContinuous=1) →precise=1,scrollingDeltaYis the pixel value (10.00, and120 → 120.00), and legacydeltaYbecomes px/10 (1.000,12.000) — the same ×10 constant, applied in the other direction.
So device class is a 1:1 wire signal (IsContinuous ↔ hasPreciseScrollingDeltas), and the two delta families are unit-coherent only through the 10 px/line constant. Multi-tick and negative values scale linearly (line_3_notches → sdy=3.00, line_minus2 → sdy=-2.00).
The smallest representable step is far below one notch: forcing the 16.16 fixed-point field (FixedPtDeltaAxis1 = 0.4) yields sdy=0.40 legacy_dy=0.400 precise=0 — fractional line deltas exist in the event model (this is where sub-notch high-resolution wheels live), independent of the pixel-precise flag.
998693 APPKIT_F11 cg_fields label=line_fractional_0.4 delta1=0 fixedpt1=0.400 point1=0 continuous=0 phase=0 momentum=0
998795 APPKIT_F11 scroll sdx=0.00 sdy=0.40 legacy_dx=0.000 legacy_dy=0.400 precise=0 phase=0x0(none) momentum=0x0(none) inverted=0One-line contrasts: Win32 quantizes the wire to WHEEL_DELTA 120ths the app must accumulate (Win32 F11); Wayland sends value + v120 + source explicitly (Wayland F11); X11 sends the same scroll twice in two encodings (X11 F11). AppKit is the only one that ships both resolutions plus the device class in every event — pre-converted, no accumulation contract.
The phase probe: CG values are re-encoded into NSEventPhase A[ssh]
Does a phase set on the CGEvent (kCGScrollWheelEventScrollPhase) surface in the wrapped NSEvent? Yes — translated, not passed through:
1145134 APPKIT_F11 cg_fields label=phase_began_probe ... phase=1 momentum=0
1145251 APPKIT_F11 scroll ... phase=0x1(began) momentum=0x0(none) ...
1295495 APPKIT_F11 cg_fields label=fling_changed_30 ... phase=2 ...
1295642 APPKIT_F11 scroll ... phase=0x4(changed) ...
1598663 APPKIT_F11 cg_fields label=fling_ended ... phase=4 ...
1598757 APPKIT_F11 scroll ... phase=0x8(ended) ...
2498697 APPKIT_F11 cg_fields label=cancel_cancelled ... phase=8 ...
2498811 APPKIT_F11 scroll ... phase=0x10(cancelled) ...CG ScrollPhase in | NSEventPhase out | CG MomentumPhase in | momentumPhase out |
|---|---|---|---|
kCGScrollPhaseBegan = 1 | Began = 0x1 | Begin = 1 | Began = 0x1 |
kCGScrollPhaseChanged = 2 | Changed = 0x4 | Continue = 2 | Changed = 0x4 |
kCGScrollPhaseEnded = 4 | Ended = 0x8 | End = 3 | Ended = 0x8 |
kCGScrollPhaseCancelled = 8 | Cancelled = 0x10 | — | — |
The momentum mapping is the telltale: CG's CGMomentumScrollPhase is a sequential enum (0,1,2,3) while NSEventPhase is a bitmask — 2 → 0x4 and 3 → 0x8 cannot be a bit-copy. eventWithCGEvent: performs a semantic re-encode, and both gesture dimensions ride one event type (a momentum event is just a scroll with phase=none, momentumPhase≠none).
One routing asymmetry fell out of the same probe: [window sendEvent:] delivered every phase-bearing drag event to the view, but dropped every momentum-phase event (route … sendEvent=not_delivered fallback=direct, 4 of 4). AppKit routes momentum scrolls to the view that owned the gesture's begin — state that a wrapped, window-less synthetic event never established. Real momentum events (delivered by the WindowServer through NSApplication) carry that association; synthetic ones need the direct-call fallback. A reminder that momentum is delivered as events on macOS, not app-synthesized — but their routing is stateful.
Gestures over the ruler: summaries keyed on transitions A[ssh]
The scripted fling — began → changed ×2 → ended, then momentum began → changed ×2 → ended, then a began → cancelled pair — drives the per-gesture accumulators:
1145405 APPKIT_F11 gesture n=1 state=begin
1598838 APPKIT_F11 gesture_summary n=1 kind=drag end=ended events=4 total_sdy=55.00 total_legacy_dy=5.500
1748923 APPKIT_F11 gesture n=1 state=momentum_begin
2198935 APPKIT_F11 gesture_summary n=1 kind=momentum events=4 total_sdy=26.00
2348827 APPKIT_F11 gesture n=2 state=begin
2498953 APPKIT_F11 gesture_summary n=2 kind=drag end=cancelled events=2 total_sdy=4.00 total_legacy_dy=0.400
2648634 APPKIT_F11 totals arrivals=16 gestures=2 final_offset_y=-217.4- The drag and momentum halves of one fling arrive as two phase streams on one event type;
ended/cancelledevents carry zero deltas (terminators, not motion). - The 10:1 ratio holds across a whole gesture (
total_sdy=55.00vstotal_legacy_dy=5.500). - The cancelled shape is exactly a gesture summary with
end=cancelledand whatever deltas arrived before the cancel (4.00here) — the documented cue for scroll views to snap back rather than commit; per-eventgesture_summary kind=discretelines cover phase-less wheel events (everyphase=none, momentum=noneevent is its own "gesture"). - The ruler offset (
final_offset_y=-217.4) integratesscrollingDeltaYacross all 16 events — mixed line- and pixel-unit streams integrate without special casing only because the demo treats them uniformly; a real scroll view must branch onhasPreciseScrollingDeltas(multiply lines by line height) or under-scroll wheel input by ~10×.
isDirectionInvertedFromDevice read 0 on all 16 events: the natural-scrolling flip is recorded at event creation from a real device's preference context, not synthesized by the wrap — whether it reads 1 with "natural scrolling" enabled is part of the Tier-C pass.
Tier C script: real wheel and trackpad fling
Run on mac-bsn in an unlocked GUI session (results → this doc):
- Build per the scaffold (binary staged at
/tmp/wsi-m6/f11-scroll/demo), run./demowith no env vars; the window shows a horizontal-tick ruler. - Wheel (external mouse): one slow notch at a time. Expect
precise=0 phase=0x0 momentum=0x0, integer-ishsdy— record whether macOS reports plain1.0lines per notch or accelerated multi-line values on faster spins, and each notch as akind=discretesummary. - Trackpad two-finger drag (no fling): expect
precise=1, aphase=mayBegin (0x20)event first (the resting-fingers probe — the one phase the synthetic run cannot produce), thenbegan → changed ×Nwith fractional pixel deltas, andendedwith zero delta on lift. ConfirmisDirectionInvertedFromDevice=1with natural scrolling on, and the legacydeltaY ≈ sdy/10relationship on real events. - Fling: flick and lift. Expect the drag trio, then immediately
momentum=0x1(began)followed bychangedevents with decaying deltas at a steady event rate (events, not app timers), closing withmomentum=0x8(ended); record the decay duration and per-gesture totals. - Interrupted fling: flick, then tap the trackpad mid-momentum. Record the exact terminator (
momentum endedvscancelled) and whether a newmayBeginopens immediately — the cancelled-shape ground truth. - Ruler feel: confirm over/under-scroll is visible (wheel notches should move the ruler noticeably less than a comparable trackpad drag unless the app multiplies lines by line height — the deliberate bug this demo leaves in).
Findings summary (for event-sequences.md)
- One event, three representations: pixel-precise
scrollingDeltaX/Y(trackpad), line-basedscrollingDeltaX/Y(wheel — same property, different unit!), and legacydeltaX/Yalways in lines.hasPreciseScrollingDeltasis the unit switch and the device-class signal, wired 1:1 from CG'sIsContinuous. Quartz's internal line↔pixel constant is 10 px/line. - A lossless framework scroll event on macOS must carry: both delta values, the precise flag, both phase fields, and the inversion flag — drop any one and a device class or gesture boundary becomes unrecoverable. Fractional line deltas (16.16 fixed-point) are representable and must not be truncated.
- Phases are a re-encoded layer: CG's sequential/bit values map onto the
NSEventPhasebitmask (changed=0x4,ended=0x8) viaeventWithCGEvent:; momentum is delivered as events taggedmomentumPhase, routed statefully to the gesture's origin view (which is whysendEvent:drops synthetic momentum events lacking that association). - Gesture boundaries are explicit:
began/ended/cancelled(+mayBeginon real trackpads) — terminators carry zero delta;cancelledmeans snap back. Phase-less events are self-contained discrete scrolls. - The portability trap is the unit fork: integrating
scrollingDeltaYuniformly under-scrolls wheels by ~10× (or over-scrolls trackpads); every consumer must branch on the precise flag — the macOS analog of Win32's accumulate-120ths contract.
Sources
- This demo —
./examples/f11-scroll/app.d,./examples/f11-scroll/instrument.d; the AppKit scaffold findings (recipe, locked-session evidence), the F07 text-input findings (the in-processsendEvent:route), and the AppKit survey. - Feature spec — F11 scroll fidelity; 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):
scrollWheel(with:),scrollingDeltaX/scrollingDeltaY(the line-vs-pixel unit contract quoted above lives here),hasPreciseScrollingDeltas,phase,momentumPhase,NSEvent.Phase,isDirectionInvertedFromDevice,NSEvent init(cgEvent:),CGEventCreateScrollWheelEvent,CGScrollEventUnit,CGEventField,scrollWheelEventScrollPhase.