Skip to content

fix(tao): match AWT trackpad scrolling on macOS and surface Pan events - #656

Merged
kdroidFilter merged 7 commits into
mainfrom
fix/652-654-macos-trackpad-scroll
Sep 7, 2026
Merged

fix(tao): match AWT trackpad scrolling on macOS and surface Pan events#656
kdroidFilter merged 7 commits into
mainfrom
fix/652-654-macos-trackpad-scroll

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #652, closes #653, closes #654.

Root causes

Changes

  • tao patch 0007 (+ README row): drop the X flip; PixelDelta carries AppKit's logical points (no backing-scale round trip, no per-event lock in the loop); WindowEvent::MouseWheel carries scroll_phase: ScrollPhase — the full AppKit phase / momentumPhase (None for a wheel and on Windows / Linux, which TouchPhase cannot express), derived by testing the NS_OPTIONS bits.
  • Loop: phased steps go to a new EventCallback.onScrollGesture (GraalVM metadata added). The phase decides the route for the whole gesture; a step that arrives in lines is scaled to its point equivalent rather than leaking as a stray wheel Scroll.
  • TaoSceneScrollRouter (new): the single front door for wheel / trackpad input, used by the window host and both NSPanel popup hosts (popup_panel.m forwards the phase as a 6th onScroll arg, kept whatever hasPreciseScrollingDeltas says) so a two-finger swipe over a popup list behaves like the window behind it. Wheel notches and phase-less precise scrolls → AWT-shaped Scroll; trackpad gesture steps → TaoTrackpadPanRouter → Compose PanStart / PanMove / PanEnd, panOffset = AWT delta × 10 dp (the MacOSCocoaConfig wheel factor, so a pan and a notch move content equally; popups use their layer's own density, the one their content and MacOSCocoaConfig measure with).
    • An open pan is always bounded by one deadline-driven timer: the finger Ended pulls the deadline in to 150 ms (-Dnucleus.tao.trackpadMomentumGraceMillis) so AppKit's momentum tail continues the same pan instead of stacking on Compose's own fling; every other step pushes it out to a 1 s stall window without re-scheduling anything; MomentumEnded / Cancelled, MayBegin (fingers resting on the glass), a click and a wheel notch close it at once. Momentum steps only ever continue an open pan; a tail that arrives after the grace already closed it is delivered as the AWT-shaped Scroll it would have been under AWT (zero-delta ends skipped, as AWT does) — neither stacked nor dropped. A finger step still carrying a delta pans even when no gesture is open.
    • Pan position / modifiers are those of the gesture steps, deliberately: Compose hit-tests Pan events and the deferred PanEnd must reach the node that got the PanMoves; MayBegin (which belongs to the next gesture) does not move it.
    • The deferred PanEnd runs on a lazily created scope with TaoNonFatalCoroutineExceptionHandler: a broken PanEnd costs one gesture, not the app (same as the synchronous popup path, where popup_panel.m clears the JNI exception).
    • Behaviour change / opt-out: code that only listens to PointerEventType.Scroll no longer sees trackpad input on this backend (AWT has no Pan events). -Dnucleus.tao.trackpadPanEvents=false restores the AWT-style behaviour where every gesture step is a Scroll; documented in README and CLAUDE.md, and the first routed gesture logs one CONFIG line naming the property. A "re-dispatch unconsumed pans as Scroll" fallback is not possible from outside compose-ui (PointerEventResult.anyChangeConsumed is internal). The demo ScrollTestScreen logs Pan steps and finalises a gesture inline on PanEnd / the next PanStart. Default is on — flipping it to opt-in for the 2.5 line is a one-liner if preferred.
  • TaoScrollGesturePhase is an enum with its wire code, distinct from the public TaoTrackpadPhase (magnify / rotate); unknown wire codes degrade to a plain precise scroll.
  • NativeView forwards the whole pan — PanStart / PanMove / PanEnd, offsets in scene px that the host converts back with its own scale — and consumes it. native_view.m replays a fresh AppKit event whole when its phase class matches, otherwise synthesises a phased scroll carrying a per-child sub-point residue; an event's delta is marked spent only once it has actually travelled, so neither a deferred PanEnd nor the second Compose step derived from one event applies it twice, and per-child gesture state keeps a PanEnd from sending a second terminal phase. An embedded WKWebView / NSScrollView thus sees the gesture begin and end (rubber-band snap-back, scroller fade). Windows / Linux are untouched (dispatchScrollToNative keeps its signature; dispatchPanToNative is a no-op there).
  • Popups: appKitWheelToAwtScrollDelta flips both axes and no longer applies the scale; TaoStandalonePopupHostMac drops the native callback before cancelling the router, runs finishPan() inside its frame pump, and its dispose() is idempotent and releases the render executor on the invalid-host path too.
  • TaoWindow: one preciseScrollEvent() shaping helper; AWT_PIXEL_TO_ROTATION / MACOS_AWT_SCROLL_AMOUNT stay on TaoWindow as aliases of the shared constants because they are public statics in the validated ABI (apiCheck unchanged vs main).

Tests

Headful e2e (MacOsTrackpadScrollHeadfulCases, macOS only) written first — 4/4 red before the fix, 4/4 green after. They inject real scrollWheel: NSEvents into the tao NSView via NativeMetalBridge.nativeDiagInjectScrollWheel (same idea as nativeLinuxInjectGdkScroll: no WindowServer, no Accessibility grant). The injector is inert unless NUCLEUS_TAO_INPUT_INJECTION=1 (set by the taoHeadfulTest task), main-thread only, and whole-point only — the CGEvent delta fields are integers and NSEvent(cgEvent:) derives scrollingDelta* from them (verified; CGEventSetDoubleValueField on the point / fixed-point fields only changes the legacy deltaX/Y).

  • #652 two-finger swipe left scrolls a horizontal row forward
  • #653 precise deltas match AWT preciseWheelRotation (flips the display to its HiDPI twin for the case, restored in finally)
  • #654 a gesture arrives as PanStart / PanMove / PanEnd (single PanEnd, after the momentum tail, no Scroll, no zero-offset PanMove), a wheel notch stays Scroll
  • #654 the pan drives a verticalScroll column through foundation's TrackpadScrollingLogic

Unit: TaoTrackpadPanRouterTest (grace, stall deadline, one timer per gesture, MayBegin, orphaned momentum handed back, finishNow, -0.0), TaoSceneTrackpadPanTest (routed gesture path, trackpadPanEvents=false path, orphaned tail falling back to Scroll — all through a real TaoSceneScrollRouter on a manual clock), TaoSceneScrollTest pins MacOSCocoaConfig's 10 dp per wheel unit on macOS, updated TaoWindowScrollTest / MacOsWheelDeltaTest, TaoScrollWireDriftTest (Rust SCROLL_GESTURE_*, ObjC NucleusScrollGesture* / kNv* and Kotlin wire codes, the hand-written JNI descriptors in popup_panel.m, and the 10-units-per-wheel factor in events.rs and native_view.m); registered in TaoSceneTestBattery + drift test. :decorated-window-tao:test detekt ktlintCheck apiCheck green on macOS (220 tests).

Manual test rig

NUCLEUS_DEMO_TAB="Trackpad Lab" ./gradlew :examples:nucleus-demo:run opens the flagship demo on a new Trackpad Lab tab: a root inspector (every Scroll / PanStart / PanMove / PanEnd with gaps, counters, one summary per gesture — how long after the last move the PanEnd came tells momentum-closed from grace-closed), vertical / horizontal strips for sign and magnitude, a map canvas that pans on Pan and zooms on Scroll, a scrollable DropdownMenu (inline, or an NSPanel through "Open with native popup layers"), and an embedded WKWebView whose page draws its own scrollY / wheel-event HUD. Verified here with synthetic phased gestures: 7 steps → (0, 5.7) wheel units, one PanEnd 19 ms after the last (momentum) move, the web view at scrollY 57 with exactly 7 wheel events, the map panned by 57 px and zoomed only by the wheel notch.

Not changed on purpose

delay() on TaoMainDispatcher goes through kotlinx's default delay executor (now one timer per gesture, not per step); teaching the dispatcher Delay needs a native timer wake and is out of scope here.

Not verified here

Windows / Linux only gain scroll_phase: ScrollPhase::None in their tao constructors; no toolchain locally, relying on build-natives. Port to nucleus-2.6 applies with --3way (only the tao-patches/README.md table row conflicts).

#652, #653, #654)

Horizontal trackpad scrolling was reversed and precise deltas were scaled
by the display factor, both relative to the AWT backend; trackpad gestures
were indistinguishable from wheel notches.

- vendored tao (patch 0007): stop negating scrollingDeltaX in the macOS
  scroll_wheel (AppKit already follows the MouseScrollDelta convention, as
  winit does) and carry the full AppKit phase / momentumPhase on
  WindowEvent::MouseWheel as `scroll_phase: ScrollPhase`
- loop: hand SCROLL_PIXEL over in logical points (AWT never applies the
  display scale to preciseWheelRotation) and route phased steps to a new
  EventCallback.onScrollGesture
- host: TaoTrackpadPanRouter turns the gesture stream into Compose
  PanStart / PanMove / PanEnd with panOffset = AWT delta x 10 dp, keeping
  the pan open across AppKit's momentum tail (deferred PanEnd) so Compose
  does not stack its own fling; wheel notches and phase-less precise
  scrolls stay Scroll events; NativeView forwards Pan to the native view
- popups: appKitWheelToAwtScrollDelta flips both axes and drops the scale
- headful e2e: MacOsTrackpadScrollHeadfulCases inject real scrollWheel:
  NSEvents into the tao content view (nativeDiagInjectScrollWheel), the
  #653 case flipping the display to its HiDPI twin; plus router, scene and
  wire unit tests
… step (review)

Review follow-ups on #652 / #653 / #654:

- TaoSceneScrollRouter: single front door for wheel / trackpad input shared
  by the window host and both NSPanel popup hosts; popup_panel.m now forwards
  the AppKit phase so a two-finger swipe over a popup list behaves like the
  window behind it. `-Dnucleus.tao.trackpadPanEvents=false` restores
  AWT-style Scroll events for handlers that only know PointerEventType.Scroll
- tao patch 0007: PixelDelta carries AppKit's logical points, so the loop no
  longer locks WINDOWS per event nor round-trips through two scale caches
- TaoTrackpadPanRouter: terminal steps with a delta still pan when no gesture
  is open; momentum grace 150 ms, tunable via
  -Dnucleus.tao.trackpadMomentumGraceMillis; isPanning removed
- NativeView forwards only non-zero PanMove (PanStart / PanEnd replayed
  NSApp.currentEvent, stale for the deferred end)
- host detach cancels the router's timer scope and ignores scrolls after
  the scene is gone
- TaoWindow: one preciseScrollEvent() shaping helper, shared AWT constants
  (apiDump: the two leaked private-companion fields are gone)
- headful cases: recorder reset per run, display-mode restore even when the
  window never reports the new scale; JNI descriptor drift guard for
  popup_panel.m; demo ScrollTestScreen logs Pan steps too
…tep (review 2)

- TaoTrackpadPanRouter: an open pan always has an end timer (150 ms grace
  after the finger Ended, 1 s stall watchdog otherwise); MayBegin during the
  momentum tail closes the pan at once; finishNow() for clicks / wheel
  notches; TaoScrollGesturePhase is now an enum (wire codes attached),
  distinct from the public TaoTrackpadPhase, unknown codes degrade to a
  plain precise scroll
- loop: the phase decides the route for the whole gesture — a step that
  arrives in lines is scaled to its point equivalent instead of being
  dropped as a stray wheel Scroll; dispatch_scroll_gesture loses its bogus
  dead_code allow; view.rs drops the dead ViewState binding
- TaoSceneScrollRouter: pan position / modifiers come from gesture steps
  only (PanEnd must hit the node that got the PanMoves), a click or a wheel
  notch closes the pan, cancel() makes late events no-ops, the timer scope
  carries TaoFatalCoroutineExceptionHandler; TaoPopupSceneLayer reads the
  live layer density; TaoStandalonePopupHostMac drops the native callback
  before cancelling the router; first Pan routing logs one CONFIG line
- NativeView consumes PanStart / PanEnd over an embedded view so the
  ancestor scrollable never opens a session of its own
- popup_panel.m: NucleusScrollGesture enum instead of literals;
  TaoScrollWireDriftTest compares Rust, ObjC and Kotlin wire codes plus the
  JNI descriptors
- nativeDiagInjectScrollWheel is inert unless NUCLEUS_TAO_INPUT_INJECTION=1
  (set by taoHeadfulTest) and main-thread only
- README / CLAUDE.md document the Pan behaviour and
  -Dnucleus.tao.trackpadPanEvents=false
…m tail (review 3)

- NativeView forwards PanStart / PanMove / PanEnd with a phase; native_view.m
  replays the live AppKit event only when its phase class matches and
  otherwise synthesises a phased scroll, so an embedded scroll view sees the
  gesture begin and end (rubber-band, scroller fade) — Windows / Linux ignore
  the phase
- popups keep the gesture phase whatever hasPreciseScrollingDeltas says,
  like the Rust window path
- TaoTrackpadPanRouter: momentum steps only continue an open pan; a tail
  arriving after the grace already closed it is dropped instead of stacked on
  Compose's fling
- TaoSceneScrollRouter: lazily created timer scope with
  TaoNonFatalCoroutineExceptionHandler (a broken deferred PanEnd costs one
  gesture, not the app); TaoScrollGesturePhase.fromWire is a map lookup
- TaoStandalonePopupHostMac: finishPan() runs inside the frame pump; the
  invalid-host dispose path also shuts the render executor down
- nativeDiagInjectScrollWheel targets the NSView it is handed, is nil-safe on
  the primary screen, inlined; documented as whole-point only (CGEvent delta
  fields are integers — verified) and the headful momentum tail uses whole
  points
- demo ScrollTestScreen closes a gesture on PanEnd / next PanStart and names
  the 10 dp factor; harness closes the pan after the cursor-move guard like
  the host; TaoScrollWireDriftTest resolves its sources per test with a
  readable failure; stray @Suppress dropped
…ry an orphaned tail (review 4)

- native_view.m remembers the AppKit event it last handed to a child
  (NSApp.currentEvent is not cleared while the app idles, and one event can
  yield two Compose steps), so a deferred PanEnd or a PanStart+PanMove pair
  never applies a delta twice; the synthesised fallback keeps a per-view
  sub-point residue instead of rounding slow drags to zero
- popup_panel.m and the vendored view.rs test NSEventPhase bits instead of
  switching on the NS_OPTIONS mask
- TaoSceneScrollRouter leaves the pan position alone for MayBegin (its
  PanEnd belongs to the previous gesture); an orphaned momentum tail is
  delivered as AWT-shaped Scroll instead of dropped; once-only CAS behind a
  plain read; scrollRouter declared with the rest of the host state
- TaoStandalonePopupHostMac.dispose() is idempotent on the invalid path too
- TaoWindow keeps AWT_PIXEL_TO_ROTATION / MACOS_AWT_SCROLL_AMOUNT as
  aliases of the shared constants (public statics in the validated ABI)
- demo ScrollTestScreen finalises a gesture inline on PanEnd / PanStart
- TaoScrollWireDriftTest also guards the kNv* native-view wire and the
  10-units-per-wheel factor in events.rs, native_view.m and the demo;
  TaoSceneScrollTest pins MacOSCocoaConfig's 10 dp per unit on macOS;
  harness routers get one timer slot each
…(review 5)

- native_view.m: an AppKit event is marked spent only once its delta has
  actually travelled (replayed, or synthesised by the step that carries it),
  so an Ended-with-delta arriving with no pan open (PanStart + PanMove from
  one event) no longer loses the finger movement; per-child gesture state,
  keyed on the child handle rather than a raw NSView*, keeps a deferred
  PanEnd from sending a second terminal phase and resets the sub-point
  residue at gesture boundaries
- NativeView hands pan offsets over in scene px; the macOS host converts
  them back with its own scale, so an app-level LocalDensity override no
  longer skews the embedded view's distance. dispatchScrollToNative is back
  to its original signature (Windows / Linux untouched)
- TaoTrackpadPanRouter: the pan end is a deadline, one timer in flight;
  finger and momentum steps only move the deadline, the grace re-schedules
  once (earlier), the timer re-arms for the remainder — no coroutine, wake
  and cancel per 120 Hz step
- TaoSceneScrollRouter: a zero-delta orphaned tail end is not turned into a
  Scroll (AWT drops zero deltas); once-only announce on a plain volatile;
  the redundant sceneBundle guard in onPointerScroll is gone (the router's
  cancelled flag is the contract)
- TaoPopupSceneLayer documents why the pan uses the layer density while the
  surface uses host.scale; TaoWindow uses import aliases for the ABI alias
  constants; the injector checks the main thread before its static init
- headful: shared ScrollableColumn / ScrollableRow fixtures and a harness
  awaitUntilOrTimeout replace the copies; the wheel-notch baseline is taken
  right before the injection; the library drift test no longer reads the
  demo source
@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

@sargunv can you check if it's ok for you ? use publishDevToMavenLocal

One screen in nucleus-demo to test the macOS trackpad work by hand:
a root inspector (every Scroll / PanStart / PanMove / PanEnd with gaps,
counters and one summary per gesture incl. how long after the last move
the PanEnd came), vertical and horizontal strips for sign and magnitude,
a map canvas that pans on Pan and zooms on Scroll, a scrollable
DropdownMenu (inline, or NSPanel via "Open with native popup layers"),
and an embedded WKWebView whose page draws its own scrollY / wheel HUD.
The header shows density, px per wheel unit and the trackpadPanEvents
flag. NUCLEUS_DEMO_TAB=<tab> opens the demo straight on a tab.

Adds the composewebview dependency to nucleus-demo (in-tree Nucleus
modules excluded, as in tao-demo).
@kdroidFilter
kdroidFilter marked this pull request as ready for review September 6, 2026 13:39
@sargunv

sargunv commented Sep 6, 2026

Copy link
Copy Markdown

Works great with maplibre/maplibre-compose#1290; except trackpad panning ended up inverted. I'm still investigating the intended convention in Compose (from Android) and whether that's my bug or yours

@sargunv

sargunv commented Sep 6, 2026

Copy link
Copy Markdown

Okay, confirmed the convention here matches Android's Compose implementation, so this lgtm. Works great once I flipped the sign on my end

@kdroidFilter
kdroidFilter merged commit 6b7a109 into main Sep 7, 2026
65 of 69 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants