From 5b04066e7c9027ac3599919a41764e5e14a8d8cd Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 10:04:04 +0300 Subject: [PATCH 1/7] fix(tao): match AWT trackpad scrolling on macOS and surface Pan events (#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 --- .../nucleusframework/window/tao/NativeView.kt | 20 + .../window/tao/TaoApplication.kt | 9 + .../window/tao/TaoEventConstants.kt | 23 + .../nucleusframework/window/tao/TaoWindow.kt | 40 +- .../window/tao/event/MacOsWheelDelta.kt | 26 +- .../tao/event/TaoSyntheticMouseWheelEvent.kt | 25 + .../window/tao/ffi/NativeMetalBridge.kt | 24 + .../window/tao/ffi/NativeTaoBridge.kt | 19 + .../window/tao/popup/TaoPopupSceneLayer.kt | 2 +- .../tao/popup/TaoStandalonePopupHostMac.kt | 2 +- .../window/tao/scene/TaoComposeSceneHost.kt | 44 ++ .../window/tao/scene/TaoTrackpadPanRouter.kt | 116 +++++ .../src/main/native/macos/NucleusTaoMetal.m | 60 +++ .../src/main/native/macos/native_view.m | 10 +- .../src/main/native/src/event_loop.rs | 85 +++- .../src/main/native/src/events.rs | 62 ++- ...cos-scroll-phase-and-horizontal-sign.patch | 164 ++++++ .../main/native/vendor/tao-patches/README.md | 1 + .../src/main/native/vendor/tao/src/event.rs | 28 ++ .../tao/src/platform_impl/linux/event_loop.rs | 1 + .../tao/src/platform_impl/macos/view.rs | 30 +- .../src/platform_impl/windows/event_loop.rs | 2 + .../reachability-metadata.json | 18 + .../window/tao/TaoSceneTestBattery.kt | 47 +- .../tao/TaoSceneTestBatteryDriftTest.kt | 4 + .../window/tao/TaoWindowScrollTest.kt | 17 + .../window/tao/event/MacOsWheelDeltaTest.kt | 47 +- .../MacOsTrackpadScrollHeadfulCases.kt | 468 ++++++++++++++++++ .../window/tao/headful/MacScrollWheelProbe.kt | 72 +++ .../tao/headful/TaoHeadfulTestSuiteMain.kt | 1 + .../window/tao/scene/TaoSceneTestHarness.kt | 20 + .../tao/scene/TaoSceneTrackpadPanTest.kt | 118 +++++ .../tao/scene/TaoTrackpadPanRouterTest.kt | 157 ++++++ 33 files changed, 1681 insertions(+), 81 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt create mode 100644 decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacScrollWheelProbe.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt index edce3bc57..445ea5779 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION import kotlin.math.min import kotlin.math.roundToInt @@ -274,6 +275,25 @@ private fun Modifier.nativeViewPointerInterop( ) true } + PointerEventType.PanStart, + PointerEventType.PanMove, + PointerEventType.PanEnd, + -> { + // Trackpad pan (#654), handed over in AWT wheel + // units: panOffset is 10 dp per unit (see the + // scene host), so the native view keeps scrolling + // under a two-finger swipe exactly like under a + // wheel. + val unitPx = AWT_PIXEL_TO_ROTATION * density + host.dispatchScrollToNative( + handle, + xPx, + yPx, + change.panOffset.x / unitPx, + change.panOffset.y / unitPx, + ) + true + } else -> false } if (dispatched) event.changes.forEach { it.consume() } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt index 875d36c69..a48167fbc 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt @@ -269,6 +269,15 @@ public object TaoApplication { guarded { lookup(handle)?.dispatchTrackpadGesture(kind, phase, xFixed, yFixed, valueFixed) } } + override fun onScrollGesture( + handle: Long, + phase: Int, + dxFixed: Int, + dyFixed: Int, + ) { + guarded { lookup(handle)?.dispatchScrollGesture(phase, dxFixed, dyFixed) } + } + override fun onTouchInput( handle: Long, phase: Int, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt index a480f8f03..86c41e302 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt @@ -98,6 +98,29 @@ public object TaoTrackpadPhase { public const val CANCELLED: Int = 3 } +/** + * Phase of a macOS trackpad scroll gesture as delivered by + * `EventCallback.onScrollGesture` (mirrors the Rust `SCROLL_GESTURE_*` codes, + * #654). AppKit reports the fingers-on-glass part in `NSEvent.phase` and the + * inertial tail that follows in `momentumPhase`, never both at once. [NONE] is + * the JVM-side marker for a scroll that belongs to no gesture (mouse wheel, + * phase-less device); it never travels over the wire. + */ +@Suppress("MagicNumber") +internal object TaoScrollGesturePhase { + const val NONE: Int = -1 + const val BEGAN: Int = 0 + const val CHANGED: Int = 1 + const val ENDED: Int = 2 + const val CANCELLED: Int = 3 + const val MOMENTUM_BEGAN: Int = 4 + const val MOMENTUM_CHANGED: Int = 5 + const val MOMENTUM_ENDED: Int = 6 + + /** Fingers touched the trackpad, no scroll yet (`NSEventPhaseMayBegin`). */ + const val MAY_BEGIN: Int = 7 +} + /** Modifier-state bitmask that mirrors the Rust side. */ @Suppress("MagicNumber") public object TaoModifierMask { diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index da8d39e27..41a41bcd0 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -1085,6 +1085,28 @@ public class TaoWindow internal constructor( trackpadGestureListener?.onGesture(kind, phase, xFixed, yFixed, valueFixed) } + /** + * macOS trackpad scroll gesture (#654) — see + * [NativeTaoBridge.EventCallback.onScrollGesture]. Shaped exactly like + * [TaoEventCode.SCROLL_PIXEL] (AWT `preciseWheelRotation`, so one unit is + * `10.dp` of pan for Compose) with the gesture [phase] attached; the + * scene host turns the stream into Compose Pan events. + */ + internal fun dispatchScrollGesture( + phase: Int, + dxFixed: Int, + dyFixed: Int, + ) { + pointerScrollListener?.invoke( + TaoPointerScrollEvent( + dxAwt = -(dxFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION, + dyAwt = -(dyFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION, + scrollAmount = MACOS_AWT_SCROLL_AMOUNT, + gesturePhase = phase, + ), + ) + } + internal fun dispatchKey( type: Int, vkCode: Int, @@ -1202,6 +1224,9 @@ public class TaoWindow internal constructor( TaoEventCode.SHOWN -> shownListener?.invoke() TaoEventCode.SIZE_MOVE -> sizeMoveListener?.invoke(a != 0) TaoEventCode.SCROLL_LINE -> { + // tao (and AppKit) count positive as "content moves down / + // right"; AWT counts positive as "scroll down / right", hence + // the negation on both axes. // AWT sends the wheel rotation as scrollDelta and leaves the // platform line-count policy in MouseWheelEvent.scrollAmount. // The Windows backend emits the raw notch count (1.0 per notch, @@ -1222,9 +1247,11 @@ public class TaoWindow internal constructor( ) } TaoEventCode.SCROLL_PIXEL -> { + // The loop hands over LOGICAL points (AppKit scrollingDelta*, + // never physical pixels — AWT applies no display scale, #653). // AWT's macOS NSEvent → MouseWheelEvent conversion divides - // scrollingDelta by ~10 to obtain preciseWheelRotation; we mirror it. - // Negate as above for the AWT sign convention. + // scrollingDelta by 10 to obtain preciseWheelRotation; we mirror + // it. Negate as above for the AWT sign convention (#652). val dx = -(a / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION val dy = -(b / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION pointerScrollListener?.invoke( @@ -1260,10 +1287,19 @@ public class TaoWindow internal constructor( } } +/** + * One wheel / trackpad scroll step, shaped like AWT's `MouseWheelEvent`: + * [dxAwt] / [dyAwt] are `preciseWheelRotation` (positive = scroll down / + * right), [scrollAmount] the platform line-count policy Compose Desktop reads. + * [gesturePhase] is the [TaoScrollGesturePhase] of a macOS trackpad gesture + * step, or [TaoScrollGesturePhase.NONE] for a wheel notch / phase-less device + * — gesture steps become Compose Pan events, the rest ordinary Scroll events. + */ internal data class TaoPointerScrollEvent( val dxAwt: Float, val dyAwt: Float, val scrollAmount: Int, + val gesturePhase: Int = TaoScrollGesturePhase.NONE, ) private data class WindowsTitleBarTouchDrag( diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt index 9c5aea5be..566f5ed71 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt @@ -10,34 +10,32 @@ internal const val AWT_PIXEL_TO_ROTATION: Float = 10f internal const val MACOS_AWT_SCROLL_AMOUNT: Int = 1 /** - * Maps raw AppKit `scrollingDelta*` onto AWT `preciseWheelRotation`. + * Maps raw AppKit `scrollingDelta*` onto AWT `preciseWheelRotation` the way + * OpenJDK's `AWTView.m` + `CPlatformResponder` do: `-[event deltaX/Y]`, where + * a precise (trackpad) event's legacy delta is `scrollingDelta × 0.1` in + * points. AppKit's sign is "positive = content moves down / right", AWT's is + * "positive = scroll down / right" — both axes flip (#652) — and the display + * scale never enters (#653). * - * Matches [dev.nucleusframework.window.tao.TaoWindow] `SCROLL_LINE` / - * `SCROLL_PIXEL`: tao already flips X then Kotlin negates both axes, so - * the net sign from raw AppKit is `Offset(dx, -dy)`. Precise (trackpad) - * deltas are converted to physical pixels then divided by 10, same as - * AWT's NSEvent → `preciseWheelRotation` conversion. - * - * Popup NSPanel content views skip tao and must go through this before - * Compose. + * Same net result as [dev.nucleusframework.window.tao.TaoWindow] `SCROLL_LINE` + * / `SCROLL_PIXEL`. Popup NSPanel content views skip tao and must go through + * this before Compose. */ internal fun appKitWheelToAwtScrollDelta( dx: Float, dy: Float, precise: Boolean, - scale: Float, ): Offset { - val awtSign = Offset(dx, -dy) - return if (precise) awtSign * (scale / AWT_PIXEL_TO_ROTATION) else awtSign + val awtSign = Offset(-dx, -dy) + return if (precise) awtSign / AWT_PIXEL_TO_ROTATION else awtSign } internal fun appKitWheelToAwtScrollEvent( dx: Float, dy: Float, precise: Boolean, - scale: Float, ): TaoPointerScrollEvent { - val delta = appKitWheelToAwtScrollDelta(dx, dy, precise, scale) + val delta = appKitWheelToAwtScrollDelta(dx, dy, precise) return TaoPointerScrollEvent( dxAwt = delta.x, dyAwt = delta.y, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoSyntheticMouseWheelEvent.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoSyntheticMouseWheelEvent.kt index 8843a3ca7..bf00a5e92 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoSyntheticMouseWheelEvent.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoSyntheticMouseWheelEvent.kt @@ -58,6 +58,31 @@ internal object TaoSyntheticMouseWheelEvent { (if (isMetaPressed) InputEvent.META_DOWN_MASK else 0) } +/** + * Feeds one step of a trackpad pan into the scene as Compose's `PanStart` / + * `PanMove` / `PanEnd` (#654). [panOffset] is in pixels with Compose's sign + * (positive = content scrolls down / right, like `scrollDelta`); foundation's + * `TrackpadScrollingLogic` consumes it directly, so unlike wheel scrolls no + * AWT-shaped native event is attached — `ScrollConfig` is only consulted for + * `Scroll` events. + */ +@OptIn(InternalComposeUiApi::class) +internal fun ComposeScene.dispatchTrackpadPan( + x: Float, + y: Float, + type: PointerEventType, + panOffset: Offset, + keyboardModifiers: PointerKeyboardModifiers = PointerKeyboardModifiers(), +) { + sendPointerEvent( + eventType = type, + position = Offset(x, y), + type = PointerType.Mouse, + keyboardModifiers = keyboardModifiers, + panGestureOffset = panOffset, + ) +} + /** * Feeds an already AWT-shaped [TaoPointerScrollEvent] into the scene, including * the synthetic `MouseWheelEvent` Compose Desktop's scroll config reads for diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeMetalBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeMetalBridge.kt index ac9b5fc54..f5fa6468f 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeMetalBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeMetalBridge.kt @@ -522,6 +522,30 @@ internal object NativeMetalBridge { @JvmStatic external fun nativeDiagViewTopLeftPx(nsViewPtr: Long): Long + /** + * Headful e2e only (#652 / #653 / #654): hands a synthetic `scrollWheel:` + * NSEvent to the tao content view — the entry a real trackpad or wheel + * takes once the WindowServer has routed it — without an Accessibility + * grant or the cursor over the window. [x] / [y] are content-local points + * with a top-left origin; [dx] / [dy] are AppKit `scrollingDelta*` values + * (points when [precise], lines otherwise); [phase] / [momentumPhase] are + * the IOHID field encodings `NSEvent(cgEvent:)` decodes (documented on the + * test-side `MacScrollWheelProbe`), `0` = unset. `false` when the view or + * its window is gone. + */ + @JvmStatic + @Suppress("LongParameterList") + external fun nativeDiagInjectScrollWheel( + nsViewPtr: Long, + x: Float, + y: Float, + dx: Float, + dy: Float, + precise: Boolean, + phase: Int, + momentumPhase: Int, + ): Boolean + /** * Disables native → JVM callbacks and removes any active menu bar * monitors. Called from a JVM shutdown hook so AppKit can't fire a diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt index 2674d53d8..5e0ea85c9 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt @@ -116,6 +116,25 @@ internal object NativeTaoBridge { ) { } + /** + * macOS-only trackpad scroll gesture (#654): a precise scroll whose + * AppKit `phase` / `momentumPhase` is set. It takes this callback + * instead of [onEvent] `SCROLL_PIXEL` so the host can surface Compose + * Pan events. [phase] is a [dev.nucleusframework.window.tao.TaoScrollGesturePhase] + * code; [dxFixed] / [dyFixed] are logical points (AppKit + * `scrollingDelta*`, tao sign) × 100, exactly like `SCROLL_PIXEL`. + * + * Default implementation no-ops so non-macOS callers can ignore it. + */ + @Suppress("FunctionParameterNaming") + fun onScrollGesture( + handle: Long, + phase: Int, + dxFixed: Int, + dyFixed: Int, + ) { + } + /** * Windows touchscreen input. Tao emits one `WindowEvent::Touch` per * finger update (WM_POINTER / WM_TOUCH), forwarded here verbatim. diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt index 477eb8928..915f634a5 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt @@ -280,7 +280,7 @@ internal class TaoPopupSceneLayer( innerScene.dispatchAwtShapedScroll( x, y, - appKitWheelToAwtScrollEvent(dx, dy, precise, scale), + appKitWheelToAwtScrollEvent(dx, dy, precise), ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt index e927944dc..b68f7efeb 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt @@ -443,7 +443,7 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { scene?.dispatchAwtShapedScroll( x, y, - appKitWheelToAwtScrollEvent(dx, dy, precise, scale), + appKitWheelToAwtScrollEvent(dx, dy, precise), ) } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index 7de190355..9f10a112a 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -31,11 +31,14 @@ import dev.nucleusframework.window.tao.TaoKeyLocation import dev.nucleusframework.window.tao.TaoModifierMask import dev.nucleusframework.window.tao.TaoNativeViewHost import dev.nucleusframework.window.tao.TaoPointerScrollEvent +import dev.nucleusframework.window.tao.TaoScrollGesturePhase import dev.nucleusframework.window.tao.TaoTrackpadGesture import dev.nucleusframework.window.tao.TaoTrackpadPhase import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher +import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll +import dev.nucleusframework.window.tao.event.dispatchTrackpadPan import dev.nucleusframework.window.tao.event.taoKeyEvent import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.event.taoTypedKeyEvent @@ -50,9 +53,12 @@ import dev.nucleusframework.window.tao.render.LocalTaoTextSelectionA11yPublisher import dev.nucleusframework.window.tao.render.TaoSelectionAccessibilityObserver import dev.nucleusframework.window.tao.shouldApplyLargeCornerRadius import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -1062,6 +1068,11 @@ internal class TaoComposeSceneHost( fun onPointerScroll(event: TaoPointerScrollEvent) { currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers + if (event.gesturePhase != TaoScrollGesturePhase.NONE) { + // Trackpad gesture step: Compose Pan events, not Scroll (#654). + trackpadPan.onGesture(event.gesturePhase, Offset(event.dxAwt, event.dyAwt)) + return + } scene?.dispatchAwtShapedScroll( x = pointerDeadband.x, y = pointerDeadband.y, @@ -1070,6 +1081,38 @@ internal class TaoComposeSceneHost( ) } + // Deferred PanEnd timer of [trackpadPan]; lives on the Tao main thread + // like every other host callback. + private val trackpadPanScope = CoroutineScope(TaoMainDispatcher + SupervisorJob()) + + /** + * Turns the macOS trackpad scroll gesture stream into Compose Pan events + * (#654). Offsets arrive in AWT wheel units and leave in pixels: one unit + * is `10.dp` — the factor Compose Desktop's `MacOSCocoaConfig` applies to + * a wheel notch — so a trackpad pan and a wheel scroll move content by the + * same amount, as they do under AWT. + */ + private val trackpadPan = + TaoTrackpadPanRouter( + schedule = { delayMillis, action -> + val job = + trackpadPanScope.launch { + delay(delayMillis) + exceptionHandler.catchExceptions(action) + } + ({ job.cancel() }) + }, + send = { type, panAwt -> + scene?.dispatchTrackpadPan( + x = pointerDeadband.x, + y = pointerDeadband.y, + type = type, + panOffset = panAwt * (AWT_PIXEL_TO_ROTATION * scale), + keyboardModifiers = currentKeyboardModifiers, + ) + }, + ) + // ── Trackpad gestures (macOS pinch / rotate / smart-magnify) ────────── // // Tao 0.35 doesn't expose these events; an NSEvent local monitor in @@ -1551,6 +1594,7 @@ internal class TaoComposeSceneHost( window.imePreedit = null window.imeCommit = null imeSession.onInputSession(null) + trackpadPan.cancel() // The native cache keys on the NSView pointer; leaving it set would // let a later view allocated at the same address inherit this // window's text and caret. diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt new file mode 100644 index 000000000..0a9580133 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt @@ -0,0 +1,116 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import dev.nucleusframework.window.tao.TaoScrollGesturePhase + +/** + * Turns the macOS trackpad scroll gesture stream ([TaoScrollGesturePhase]) + * into Compose's `PanStart` / `PanMove` / `PanEnd` (#654). + * + * Why the stream is not mapped one-to-one: Compose's `TrackpadScrollingLogic` + * runs its own fling from the tracked velocity as soon as it sees `PanEnd`, + * while AppKit keeps delivering the inertial *momentum* tail after the fingers + * lift (`momentumPhase`). Closing the pan on the finger `Ended` would stack + * the two animations and the tail would re-open a second pan. The pan is + * therefore kept open across the momentum tail and closed on `MomentumEnded`. + * AppKit does not say in advance whether a tail will follow, so the finger + * `Ended` only *schedules* the `PanEnd` and a momentum event arriving within + * [MOMENTUM_GRACE_MILLIS] cancels it. By the time the pan really ends the + * tracked velocity is ~0 and Compose adds no fling of its own — the platform + * drives the inertia, exactly as under AWT where every step is a plain wheel + * event. + * + * [send] receives the pan offset in AWT `preciseWheelRotation` units (the + * shape of [dev.nucleusframework.window.tao.TaoPointerScrollEvent.dxAwt]); the + * caller converts to pixels. [schedule] runs `action` after `delayMillis` on + * the UI thread and returns a cancel handle. UI thread only. + */ +internal class TaoTrackpadPanRouter( + private val schedule: (delayMillis: Long, action: () -> Unit) -> (() -> Unit), + private val send: (type: PointerEventType, panAwt: Offset) -> Unit, +) { + private var active = false + private var cancelPendingEnd: (() -> Unit)? = null + + /** True between `PanStart` and `PanEnd` (including a pending deferred end). */ + val isPanning: Boolean get() = active + + fun onGesture( + phase: Int, + deltaAwt: Offset, + ) { + when (phase) { + // Fingers resting on the glass: nothing to pan yet. A Cancelled + // that follows without a Began is a no-op below. + TaoScrollGesturePhase.MAY_BEGIN -> Unit + TaoScrollGesturePhase.BEGAN, + TaoScrollGesturePhase.CHANGED, + TaoScrollGesturePhase.MOMENTUM_BEGAN, + TaoScrollGesturePhase.MOMENTUM_CHANGED, + -> { + clearPendingEnd() + start() + move(deltaAwt) + } + TaoScrollGesturePhase.ENDED -> { + if (!active) return + move(deltaAwt) + clearPendingEnd() + cancelPendingEnd = + schedule(MOMENTUM_GRACE_MILLIS) { + cancelPendingEnd = null + finish() + } + } + TaoScrollGesturePhase.CANCELLED, + TaoScrollGesturePhase.MOMENTUM_ENDED, + -> { + if (!active) return + move(deltaAwt) + finish() + } + // Unknown wire value: ignore rather than desynchronise the pan. + else -> Unit + } + } + + /** Teardown: drops a pending deferred end and forgets the open pan (no `PanEnd` is sent). */ + fun cancel() { + clearPendingEnd() + active = false + } + + private fun start() { + if (active) return + active = true + send(PointerEventType.PanStart, Offset.Zero) + } + + private fun move(deltaAwt: Offset) { + // Float compares, not `!= Offset.Zero`: the wire negation turns a + // zero delta (Began / Ended steps) into -0.0, whose packed bits differ + // from +0.0 and would leak zero-offset PanMoves into Compose. + if (deltaAwt.x != 0f || deltaAwt.y != 0f) send(PointerEventType.PanMove, deltaAwt) + } + + private fun finish() { + clearPendingEnd() + if (!active) return + active = false + send(PointerEventType.PanEnd, Offset.Zero) + } + + private fun clearPendingEnd() { + cancelPendingEnd?.invoke() + cancelPendingEnd = null + } + + internal companion object { + /** + * AppKit posts the first momentum event within a frame or two of the + * finger `Ended`; anything past this is a swipe with no inertia. + */ + const val MOMENTUM_GRACE_MILLIS: Long = 100L + } +} diff --git a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m index 87071438d..f84407306 100644 --- a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m +++ b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m @@ -2872,6 +2872,66 @@ static void ensureInteropModeSource(void) { return packed; } +/* macOS only, headful e2e (#652 / #653 / #654): hands a synthetic + * `scrollWheel:` NSEvent to the tao content view — the entry point a real + * trackpad or wheel event takes once the WindowServer has routed it. Skipping + * the WindowServer (CGEventPost) means no Accessibility grant and no cursor + * parked over the window are needed, and the delivery is deterministic. + * + * (x, y) are content-view-local points with a top-left origin (Compose dp). + * (dx, dy) are AppKit `scrollingDelta*` values: points when `precise` + * (`hasPreciseScrollingDeltas == YES`, trackpad), lines otherwise (wheel). + * `phase` / `momentumPhase` use the IOHID field encodings that + * `+[NSEvent eventWithCGEvent:]` decodes into `NSEventPhase` — phase: 1 began, + * 2 changed, 4 ended, 8 cancelled, 128 may-begin; momentum: 1 began, 2 changed, + * 3 ended. 0 leaves the field unset (a wheel / phase-less device). + * + * A CGEvent-built NSEvent has no window: its `locationInWindow` is the CG + * location flipped against the primary display. The location is therefore + * chosen so that the flipped value equals the wanted window point, which is + * what tao's `mouse_motion` (run first by `scroll_wheel`) resolves back to + * the view-local cursor position. Returns JNI false when the view or its + * window is gone or the event could not be built. */ +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeMetalBridge_nativeDiagInjectScrollWheel( + JNIEnv *env, jclass clazz, jlong nsViewPtr, + jfloat x, jfloat y, jfloat dx, jfloat dy, jboolean precise, + jint phase, jint momentumPhase) { + (void)env; (void)clazz; + if (nsViewPtr == 0) return JNI_FALSE; + void *rawPtr = (void *)(uintptr_t)nsViewPtr; + __block jboolean delivered = JNI_FALSE; + dispatch_block_t deliver = ^{ + NSView *view = (__bridge NSView *)rawPtr; + NSWindow *window = view.window; + NSView *content = window.contentView; + if (window == nil || content == nil) return; + // Content-local top-left → window base coordinates (bottom-left). + NSPoint local = NSMakePoint(x, content.isFlipped ? y : content.bounds.size.height - y); + NSPoint inWindow = [content convertPoint:local toView:nil]; + CGEventRef cg = CGEventCreateScrollWheelEvent( + NULL, precise ? kCGScrollEventUnitPixel : kCGScrollEventUnitLine, 2, + (int32_t)lroundf(dy), (int32_t)lroundf(dx)); + if (cg == NULL) return; + if (phase != 0) { + CGEventSetIntegerValueField(cg, kCGScrollWheelEventScrollPhase, phase); + } + if (momentumPhase != 0) { + CGEventSetIntegerValueField(cg, kCGScrollWheelEventMomentumPhase, momentumPhase); + } + CGFloat primaryHeight = NSScreen.screens.firstObject.frame.size.height; + CGEventSetLocation(cg, CGPointMake(inWindow.x, primaryHeight - inWindow.y)); + NSEvent *event = [NSEvent eventWithCGEvent:cg]; + CFRelease(cg); + if (event == nil) return; + [content scrollWheel:event]; + delivered = JNI_TRUE; + }; + if ([NSThread isMainThread]) deliver(); + else dispatch_sync(dispatch_get_main_queue(), deliver); + return delivered; +} + /* CFGetRetainCount of view.window. Only deltas are meaningful (AppKit holds * its own references); the set_focusable leak regression compares the count * before/after a burst of calls. Returns -1 when view/window is gone. */ diff --git a/decorated-window-tao/src/main/native/macos/native_view.m b/decorated-window-tao/src/main/native/macos/native_view.m index 4a0bf7ecd..6df76e442 100644 --- a/decorated-window-tao/src/main/native/macos/native_view.m +++ b/decorated-window-tao/src/main/native/macos/native_view.m @@ -479,16 +479,14 @@ static NSPoint window_point_from_compose_px(NSView *content, jfloat xPx, jfloat return; } // Fallback: Compose/AWT scrollDelta is the inverse of AppKit - // `scrollingDelta` (TaoWindow.kt SCROLL_PIXEL/LINE) and pixel - // wheels are divided by 10. Reconstruct AppKit units. - // Y: rust keeps scrollingDeltaY, Kotlin negates → nativeY = -dy*10 - // X: rust already flips scrollingDeltaX, Kotlin negates again - // → nativeX = dx*10 + // `scrollingDelta` on both axes (TaoWindow.kt SCROLL_PIXEL/LINE, #652) + // and precise deltas are divided by 10. Reconstruct AppKit points: + // native = -awt * 10 for X and Y alike. const float kAwtPixelToRotation = 10.f; CGEventRef cg = CGEventCreateScrollWheelEvent( NULL, kCGScrollEventUnitPixel, 2, (int32_t)lroundf(-dy * kAwtPixelToRotation), - (int32_t)lroundf(dx * kAwtPixelToRotation)); + (int32_t)lroundf(-dx * kAwtPixelToRotation)); if (cg == NULL) return; CGEventSetLocation(cg, NSPointToCGPoint( [hit.window convertRectToScreen:NSMakeRect(windowPoint.x, windowPoint.y, 0, 0)].origin)); diff --git a/decorated-window-tao/src/main/native/src/event_loop.rs b/decorated-window-tao/src/main/native/src/event_loop.rs index 046f5bf6c..80eaefa4f 100644 --- a/decorated-window-tao/src/main/native/src/event_loop.rs +++ b/decorated-window-tao/src/main/native/src/event_loop.rs @@ -12,15 +12,17 @@ use tao::window::WindowBuilder; use crate::events::{ current_modifier_bits, dispatch, dispatch_ime_commit, dispatch_ime_preedit, - dispatch_ime_replace_commit, dispatch_key, - dispatch_touch_input, handle_for, mouse_button_code, pack_modifiers, UserEvent, - CURSOR_FIXED_SCALE, EVENT_CLOSE_REQUESTED, EVENT_CURSOR_LEFT, EVENT_CURSOR_MOVED, - EVENT_DESTROYED, EVENT_FOCUSED, EVENT_KEY_DOWN, EVENT_KEY_TYPED, EVENT_KEY_UP, EVENT_LAUNCHED, - EVENT_MAIN_EVENTS_CLEARED, EVENT_MODIFIERS_CHANGED, EVENT_MOUSE_DOWN, EVENT_MOUSE_UP, - EVENT_MOVED, EVENT_REDRAW_REQUESTED, EVENT_RESIZED, EVENT_SCALE_FACTOR_CHANGED, - EVENT_SCROLL_LINE, EVENT_SCROLL_PIXEL, EVENT_UNFOCUSED, EVENT_WINDOW_READY, SCROLL_FIXED_SCALE, - TOUCH_EVENT_CANCEL, TOUCH_EVENT_MOVE, TOUCH_EVENT_PRESS, TOUCH_EVENT_RELEASE, - TOUCH_FORCE_FIXED_SCALE, TOUCH_FORCE_UNKNOWN, + dispatch_ime_replace_commit, dispatch_key, dispatch_scroll_gesture, dispatch_touch_input, + handle_for, mouse_button_code, pack_modifiers, UserEvent, CURSOR_FIXED_SCALE, + EVENT_CLOSE_REQUESTED, EVENT_CURSOR_LEFT, EVENT_CURSOR_MOVED, EVENT_DESTROYED, EVENT_FOCUSED, + EVENT_KEY_DOWN, EVENT_KEY_TYPED, EVENT_KEY_UP, EVENT_LAUNCHED, EVENT_MAIN_EVENTS_CLEARED, + EVENT_MODIFIERS_CHANGED, EVENT_MOUSE_DOWN, EVENT_MOUSE_UP, EVENT_MOVED, EVENT_REDRAW_REQUESTED, + EVENT_RESIZED, EVENT_SCALE_FACTOR_CHANGED, EVENT_SCROLL_LINE, EVENT_SCROLL_PIXEL, + EVENT_UNFOCUSED, EVENT_WINDOW_READY, SCROLL_FIXED_SCALE, SCROLL_GESTURE_BEGAN, + SCROLL_GESTURE_CANCELLED, SCROLL_GESTURE_CHANGED, SCROLL_GESTURE_ENDED, + SCROLL_GESTURE_MAY_BEGIN, SCROLL_GESTURE_MOMENTUM_BEGAN, SCROLL_GESTURE_MOMENTUM_CHANGED, + SCROLL_GESTURE_MOMENTUM_ENDED, TOUCH_EVENT_CANCEL, TOUCH_EVENT_MOVE, TOUCH_EVENT_PRESS, + TOUCH_EVENT_RELEASE, TOUCH_FORCE_FIXED_SCALE, TOUCH_FORCE_UNKNOWN, }; #[cfg(target_os = "windows")] use crate::events::{ @@ -824,24 +826,65 @@ pub(crate) fn run_event_loop_blocking() { }; dispatch(handle, code, mouse_button_code(button), 0); } - WindowEvent::MouseWheel { delta, .. } => { - // Pass the raw NSEvent values straight through; the JVM - // side reshapes them to match AWT's `preciseWheelRotation` - // semantics so Compose's `MacOSCocoaConfig` can apply its - // standard `× 10dp × -scrollAmount` formula. + WindowEvent::MouseWheel { + delta, + scroll_phase, + .. + } => { + // The JVM side reshapes the deltas to AWT's + // `preciseWheelRotation` semantics so Compose's + // `MacOSCocoaConfig` can apply its standard + // `× 10dp × -scrollAmount` formula. AWT never scales + // by the display factor, so tao's physical `PixelDelta` + // goes back to logical points first (#653). let (code, dx, dy) = match delta { MouseScrollDelta::LineDelta(x, y) => { (EVENT_SCROLL_LINE, x as f64, y as f64) } - MouseScrollDelta::PixelDelta(p) => (EVENT_SCROLL_PIXEL, p.x, p.y), + MouseScrollDelta::PixelDelta(p) => { + let scale = WINDOWS + .lock() + .ok() + .and_then(|guard| { + guard.as_ref().and_then(|map| { + map.get(&handle).map(|w| w.scale_factor()) + }) + }) + .unwrap_or(1.0); + let logical = p.to_logical::(scale); + (EVENT_SCROLL_PIXEL, logical.x, logical.y) + } _ => return, }; - dispatch( - handle, - code, - (dx * SCROLL_FIXED_SCALE) as jint, - (dy * SCROLL_FIXED_SCALE) as jint, - ); + let dx_fixed = (dx * SCROLL_FIXED_SCALE) as jint; + let dy_fixed = (dy * SCROLL_FIXED_SCALE) as jint; + // A precise scroll that belongs to a trackpad gesture + // (finger or momentum phase) is reported as a gesture + // so the JVM can surface Compose Pan events (#654); + // everything else stays an ordinary wheel scroll. + let gesture = match scroll_phase { + tao::event::ScrollPhase::None => None, + tao::event::ScrollPhase::MayBegin => Some(SCROLL_GESTURE_MAY_BEGIN), + tao::event::ScrollPhase::Began => Some(SCROLL_GESTURE_BEGAN), + tao::event::ScrollPhase::Changed => Some(SCROLL_GESTURE_CHANGED), + tao::event::ScrollPhase::Ended => Some(SCROLL_GESTURE_ENDED), + tao::event::ScrollPhase::Cancelled => Some(SCROLL_GESTURE_CANCELLED), + tao::event::ScrollPhase::MomentumBegan => { + Some(SCROLL_GESTURE_MOMENTUM_BEGAN) + } + tao::event::ScrollPhase::MomentumChanged => { + Some(SCROLL_GESTURE_MOMENTUM_CHANGED) + } + tao::event::ScrollPhase::MomentumEnded => { + Some(SCROLL_GESTURE_MOMENTUM_ENDED) + } + }; + match gesture { + Some(phase) if code == EVENT_SCROLL_PIXEL => { + dispatch_scroll_gesture(handle, phase, dx_fixed, dy_fixed); + } + _ => dispatch(handle, code, dx_fixed, dy_fixed), + } } WindowEvent::ReceivedImeText(text) => { let mods = current_modifier_bits(); diff --git a/decorated-window-tao/src/main/native/src/events.rs b/decorated-window-tao/src/main/native/src/events.rs index fd4b37d35..7e2c7225e 100644 --- a/decorated-window-tao/src/main/native/src/events.rs +++ b/decorated-window-tao/src/main/native/src/events.rs @@ -137,13 +137,32 @@ pub(crate) const EVENT_MAIN_EVENTS_CLEARED: jint = 20; // the JVM side using the cached scale factor. pub(crate) const EVENT_MOVED: jint = 21; pub(crate) const EVENT_WINDOW_READY: jint = 16; // a = width, b = height (logical) - // Scroll deltas come either as line counts (mouse wheel) or pixel deltas - // (trackpad). Compose's `MacOSCocoaConfig` (cf. compose-multiplatform-core) - // expects each kind to be shaped like AWT `MouseWheelEvent.preciseWheelRotation`, - // which has different scaling: lines map ≈ 1 notch, pixels map ≈ scrollingDelta/10. - // We split the event code so the JVM side can apply the right factor. + +// Scroll deltas come either as line counts (mouse wheel) or precise deltas +// (trackpad, smooth-scroll mice). Compose's `MacOSCocoaConfig` (cf. +// compose-multiplatform-core) expects each kind to be shaped like AWT +// `MouseWheelEvent.preciseWheelRotation`, which has different scaling: lines +// map ≈ 1 notch, precise deltas map ≈ scrollingDelta/10. We split the event +// code so the JVM side can apply the right factor. Both carry tao's sign +// (positive = content moves down / right, i.e. AppKit's); the JVM negates. pub(crate) const EVENT_SCROLL_LINE: jint = 17; // a = dx * SCROLL_FIXED_SCALE, b = dy * SCROLL_FIXED_SCALE + +// a/b = LOGICAL points (AppKit `scrollingDelta*`) * SCROLL_FIXED_SCALE — tao's +// `PixelDelta` is converted back from physical pixels in the loop because AWT +// never applies the display scale to `preciseWheelRotation` (Nucleus #653). pub(crate) const EVENT_SCROLL_PIXEL: jint = 18; +// Trackpad scroll gesture phases (`EventCallback.onScrollGesture`); mirror +// Kotlin `TaoScrollGesturePhase`. A precise scroll that belongs to a gesture +// (AppKit `phase` / `momentumPhase` set) takes this callback instead of +// EVENT_SCROLL_PIXEL so the JVM can surface it as Compose Pan events (#654). +pub(crate) const SCROLL_GESTURE_BEGAN: jint = 0; +pub(crate) const SCROLL_GESTURE_CHANGED: jint = 1; +pub(crate) const SCROLL_GESTURE_ENDED: jint = 2; +pub(crate) const SCROLL_GESTURE_CANCELLED: jint = 3; +pub(crate) const SCROLL_GESTURE_MOMENTUM_BEGAN: jint = 4; +pub(crate) const SCROLL_GESTURE_MOMENTUM_CHANGED: jint = 5; +pub(crate) const SCROLL_GESTURE_MOMENTUM_ENDED: jint = 6; +pub(crate) const SCROLL_GESTURE_MAY_BEGIN: jint = 7; pub(crate) const EVENT_MODIFIERS_CHANGED: jint = 22; // Linux only. Dispatched synchronously on the event-loop thread right // BEFORE the GTK window is hidden, so the JVM can suspend its EGL rendering @@ -503,6 +522,39 @@ pub(crate) fn dispatch_ime_replace_commit(handle: u64, text: &str, start: u64, l ); } +/// Trackpad scroll gesture (macOS): `EventCallback.onScrollGesture`. [phase] +/// is one of the `SCROLL_GESTURE_*` codes; the deltas are LOGICAL points +/// (AppKit `scrollingDelta*`, tao's sign) × SCROLL_FIXED_SCALE, like +/// EVENT_SCROLL_PIXEL. +#[allow(dead_code)] +pub(crate) fn dispatch_scroll_gesture(handle: u64, phase: jint, dx_fixed: jint, dy_fixed: jint) { + let Some(vm) = JAVA_VM.get() else { return }; + let Ok(guard) = EVENT_CALLBACK.lock() else { + return; + }; + let Some(callback) = guard.as_ref() else { + return; + }; + let Ok(mut env) = vm.attach_current_thread_permanently() else { + return; + }; + let _ = env.call_method( + callback.as_obj(), + "onScrollGesture", + "(JIII)V", + &[ + JValue::Long(handle as jlong), + JValue::Int(phase), + JValue::Int(dx_fixed), + JValue::Int(dy_fixed), + ], + ); + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } +} + #[allow(clippy::too_many_arguments, dead_code)] pub(crate) fn dispatch_trackpad_gesture( handle: u64, diff --git a/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch new file mode 100644 index 000000000..a8108d1c9 --- /dev/null +++ b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch @@ -0,0 +1,164 @@ +diff --git a/src/event.rs b/src/event.rs +index 00585857..2b80b12e 100644 +--- a/src/event.rs ++++ b/src/event.rs +@@ -444,6 +444,9 @@ pub enum WindowEvent<'a> { + device_id: DeviceId, + delta: MouseScrollDelta, + phase: TouchPhase, ++ /// PATCH(nucleus): fine-grained trackpad gesture / momentum phase; ++ /// [`ScrollPhase::None`] for a mouse wheel. See [`ScrollPhase`]. ++ scroll_phase: ScrollPhase, + #[deprecated = "Deprecated in favor of WindowEvent::ModifiersChanged"] + modifiers: ModifiersState, + }, +@@ -570,11 +573,13 @@ impl Clone for WindowEvent<'static> { + device_id, + delta, + phase, ++ scroll_phase, + modifiers, + } => MouseWheel { + device_id: *device_id, + delta: *delta, + phase: *phase, ++ scroll_phase: *scroll_phase, + modifiers: *modifiers, + }, + #[allow(deprecated)] +@@ -668,11 +673,13 @@ impl<'a> WindowEvent<'a> { + device_id, + delta, + phase, ++ scroll_phase, + modifiers, + } => Some(MouseWheel { + device_id, + delta, + phase, ++ scroll_phase, + modifiers, + }), + #[allow(deprecated)] +@@ -904,6 +911,27 @@ pub enum TouchPhase { + Cancelled, + } + ++/// PATCH(nucleus): fine-grained phase of a trackpad scroll, next to the ++/// coarser [`TouchPhase`] on [`WindowEvent::MouseWheel`]. `TouchPhase` can ++/// neither say "not a gesture at all" (a mouse wheel notch) nor tell the ++/// inertial momentum tail that follows a swipe from the fingers-on-glass part; ++/// a toolkit that routes trackpad panning and wheel scrolling differently ++/// needs both. Only the macOS backend reports anything but `None`. ++#[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)] ++pub enum ScrollPhase { ++ /// Not part of a gesture: mouse wheel, or a device without phase reporting. ++ None, ++ /// Fingers touched the trackpad, no scroll yet (`NSEventPhaseMayBegin`). ++ MayBegin, ++ Began, ++ Changed, ++ Ended, ++ Cancelled, ++ MomentumBegan, ++ MomentumChanged, ++ MomentumEnded, ++} ++ + /// Represents a touch event + /// + /// Every time the user touches the screen, a new `Start` event with an unique +diff --git a/src/platform_impl/linux/event_loop.rs b/src/platform_impl/linux/event_loop.rs +index 9d0ab3fb..6cf94fb6 100644 +--- a/src/platform_impl/linux/event_loop.rs ++++ b/src/platform_impl/linux/event_loop.rs +@@ -963,6 +963,7 @@ impl EventLoop { + ScrollDirection::Smooth => TouchPhase::Moved, + _ => TouchPhase::Ended, + }, ++ scroll_phase: crate::event::ScrollPhase::None, + modifiers: ModifiersState::empty(), + }, + }) { +diff --git a/src/platform_impl/macos/view.rs b/src/platform_impl/macos/view.rs +index b0fb8472..f3b19e96 100644 +--- a/src/platform_impl/macos/view.rs ++++ b/src/platform_impl/macos/view.rs +@@ -33,7 +33,8 @@ use once_cell::sync::Lazy; + use crate::{ + dpi::LogicalPosition, + event::{ +- DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent, ++ DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, ScrollPhase, TouchPhase, ++ WindowEvent, + }, + keyboard::{KeyCode, ModifiersState}, + platform_impl::platform::{ +@@ -1263,8 +1264,13 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { + let state = &mut *(state_ptr as *mut ViewState); + + let delta = { +- // macOS horizontal sign convention is the inverse of tao. +- let (x, y) = (event.scrollingDeltaX() * -1.0, event.scrollingDeltaY()); ++ // PATCH(nucleus): keep AppKit's sign on both axes — positive means the ++ // content moves down / right, which is exactly the convention ++ // `MouseScrollDelta` documents. Upstream negated X here "because macOS ++ // is the inverse of tao"; it is not, and a consumer that negates both ++ // axes for the AWT convention then ended up with X reversed (Nucleus ++ // #652). Same as winit. ++ let (x, y) = (event.scrollingDeltaX(), event.scrollingDeltaY()); + if event.hasPreciseScrollingDeltas() { + let delta = LogicalPosition::new(x, y).to_physical(state.get_scale_factor()); + MouseScrollDelta::PixelDelta(delta) +@@ -1277,6 +1283,23 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { + NSEventPhase::Ended => TouchPhase::Ended, + _ => TouchPhase::Moved, + }; ++ // PATCH(nucleus): full gesture / momentum phase (Nucleus #654). AppKit ++ // reports the fingers-on-glass part in `phase` and the inertial tail that ++ // follows in `momentumPhase`, never both at once; a wheel notch or a ++ // phase-less device has neither. ++ let scroll_phase = match event.phase() { ++ NSEventPhase::MayBegin => ScrollPhase::MayBegin, ++ NSEventPhase::Began => ScrollPhase::Began, ++ NSEventPhase::Changed | NSEventPhase::Stationary => ScrollPhase::Changed, ++ NSEventPhase::Ended => ScrollPhase::Ended, ++ NSEventPhase::Cancelled => ScrollPhase::Cancelled, ++ _ => match event.momentumPhase() { ++ NSEventPhase::Began => ScrollPhase::MomentumBegan, ++ NSEventPhase::Changed => ScrollPhase::MomentumChanged, ++ NSEventPhase::Ended | NSEventPhase::Cancelled => ScrollPhase::MomentumEnded, ++ _ => ScrollPhase::None, ++ }, ++ }; + + let device_event = Event::DeviceEvent { + device_id: DEVICE_ID, +@@ -1294,6 +1317,7 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { + device_id: DEVICE_ID, + delta, + phase, ++ scroll_phase, + modifiers: event_mods(event), + }, + }; +diff --git a/src/platform_impl/windows/event_loop.rs b/src/platform_impl/windows/event_loop.rs +index 3390e24f..38fe50f1 100644 +--- a/src/platform_impl/windows/event_loop.rs ++++ b/src/platform_impl/windows/event_loop.rs +@@ -1474,6 +1474,7 @@ unsafe fn public_window_callback_inner( + device_id: DEVICE_ID, + delta: LineDelta(0.0, value), + phase: TouchPhase::Moved, ++ scroll_phase: crate::event::ScrollPhase::None, + modifiers, + }, + }); +@@ -1498,6 +1499,7 @@ unsafe fn public_window_callback_inner( + device_id: DEVICE_ID, + delta: LineDelta(value, 0.0), + phase: TouchPhase::Moved, ++ scroll_phase: crate::event::ScrollPhase::None, + modifiers, + }, + }); diff --git a/decorated-window-tao/src/main/native/vendor/tao-patches/README.md b/decorated-window-tao/src/main/native/vendor/tao-patches/README.md index a5b95a8cf..63399febb 100644 --- a/decorated-window-tao/src/main/native/vendor/tao-patches/README.md +++ b/decorated-window-tao/src/main/native/vendor/tao-patches/README.md @@ -20,6 +20,7 @@ Tao 0.35.0 is already vendored; this file is the living list of patches. | 0004 | `0004-linux-drain-draw-queue.patch` | 4 | Linux | `run_return`: treat pending redraws like pending events (don't park in the blocking `gtk_main_iteration` while `draws` is non-empty) and drain the whole draw channel per cycle instead of one redraw per wakeup. Fixes multi-window frame starvation (each window rendered at ~refresh/N). | | 0005 | `0005-linux-restore-activation-timestamp.patch` | 5 | Linux | Stamp `Focus` and `Minimized(false)` activations with a real X server timestamp (`gdk_x11_get_server_time`). Mutter's focus-stealing prevention drops `_NET_ACTIVE_WINDOW` requests carrying `GDK_CURRENT_TIME` (0) and keeps a deiconified window Iconic with `_NET_WM_STATE_DEMANDS_ATTENTION`, so restore/focus silently no-op and `EVENT_MINIMIZED(false)` never fires on GNOME X11/XWayland (openbox honors the 0 timestamp, which is why CI never saw it). No-op on Wayland. | | 0006 | `0006-linux-cursor-ignore-events-region.patch` | 6 | Linux | `CursorIgnoreEvents`: install a genuinely *empty* input region instead of upstream's 1x1 rectangle at the origin (which leaves the top-left pixel clickable), and clear it through the same `GdkWindow` with a NULL region. Upstream cleared it on the `GtkWidget`, which never undid a shape installed on the `GdkWindow`, so click-through could not be switched back off. | +| 0007 | `0007-macos-scroll-phase-and-horizontal-sign.patch` | 7 | macOS (+ field on all backends) | `WindowEvent::MouseWheel` gains `scroll_phase: ScrollPhase` — the full AppKit `phase` / `momentumPhase` of a trackpad scroll (`None` for a wheel, and on Windows / Linux), which `TouchPhase` cannot express; Nucleus routes gesture steps to Compose Pan events (#654). Also stops negating `scrollingDeltaX` in `scroll_wheel`: AppKit's sign already matches `MouseScrollDelta`'s documented convention (and winit), and the extra flip reversed horizontal trackpad scrolling once the consumer applied the AWT convention (#652). | ## Bump procedure (e.g. 0.35 → 0.36) diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/event.rs b/decorated-window-tao/src/main/native/vendor/tao/src/event.rs index 005858573..2b80b12ec 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/event.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/event.rs @@ -444,6 +444,9 @@ pub enum WindowEvent<'a> { device_id: DeviceId, delta: MouseScrollDelta, phase: TouchPhase, + /// PATCH(nucleus): fine-grained trackpad gesture / momentum phase; + /// [`ScrollPhase::None`] for a mouse wheel. See [`ScrollPhase`]. + scroll_phase: ScrollPhase, #[deprecated = "Deprecated in favor of WindowEvent::ModifiersChanged"] modifiers: ModifiersState, }, @@ -570,11 +573,13 @@ impl Clone for WindowEvent<'static> { device_id, delta, phase, + scroll_phase, modifiers, } => MouseWheel { device_id: *device_id, delta: *delta, phase: *phase, + scroll_phase: *scroll_phase, modifiers: *modifiers, }, #[allow(deprecated)] @@ -668,11 +673,13 @@ impl<'a> WindowEvent<'a> { device_id, delta, phase, + scroll_phase, modifiers, } => Some(MouseWheel { device_id, delta, phase, + scroll_phase, modifiers, }), #[allow(deprecated)] @@ -904,6 +911,27 @@ pub enum TouchPhase { Cancelled, } +/// PATCH(nucleus): fine-grained phase of a trackpad scroll, next to the +/// coarser [`TouchPhase`] on [`WindowEvent::MouseWheel`]. `TouchPhase` can +/// neither say "not a gesture at all" (a mouse wheel notch) nor tell the +/// inertial momentum tail that follows a swipe from the fingers-on-glass part; +/// a toolkit that routes trackpad panning and wheel scrolling differently +/// needs both. Only the macOS backend reports anything but `None`. +#[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)] +pub enum ScrollPhase { + /// Not part of a gesture: mouse wheel, or a device without phase reporting. + None, + /// Fingers touched the trackpad, no scroll yet (`NSEventPhaseMayBegin`). + MayBegin, + Began, + Changed, + Ended, + Cancelled, + MomentumBegan, + MomentumChanged, + MomentumEnded, +} + /// Represents a touch event /// /// Every time the user touches the screen, a new `Start` event with an unique diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs index 9d0ab3fb9..6cf94fb63 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs @@ -963,6 +963,7 @@ impl EventLoop { ScrollDirection::Smooth => TouchPhase::Moved, _ => TouchPhase::Ended, }, + scroll_phase: crate::event::ScrollPhase::None, modifiers: ModifiersState::empty(), }, }) { diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs index b0fb8472f..f3b19e96a 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs @@ -33,7 +33,8 @@ use once_cell::sync::Lazy; use crate::{ dpi::LogicalPosition, event::{ - DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent, + DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, ScrollPhase, TouchPhase, + WindowEvent, }, keyboard::{KeyCode, ModifiersState}, platform_impl::platform::{ @@ -1263,8 +1264,13 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { let state = &mut *(state_ptr as *mut ViewState); let delta = { - // macOS horizontal sign convention is the inverse of tao. - let (x, y) = (event.scrollingDeltaX() * -1.0, event.scrollingDeltaY()); + // PATCH(nucleus): keep AppKit's sign on both axes — positive means the + // content moves down / right, which is exactly the convention + // `MouseScrollDelta` documents. Upstream negated X here "because macOS + // is the inverse of tao"; it is not, and a consumer that negates both + // axes for the AWT convention then ended up with X reversed (Nucleus + // #652). Same as winit. + let (x, y) = (event.scrollingDeltaX(), event.scrollingDeltaY()); if event.hasPreciseScrollingDeltas() { let delta = LogicalPosition::new(x, y).to_physical(state.get_scale_factor()); MouseScrollDelta::PixelDelta(delta) @@ -1277,6 +1283,23 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { NSEventPhase::Ended => TouchPhase::Ended, _ => TouchPhase::Moved, }; + // PATCH(nucleus): full gesture / momentum phase (Nucleus #654). AppKit + // reports the fingers-on-glass part in `phase` and the inertial tail that + // follows in `momentumPhase`, never both at once; a wheel notch or a + // phase-less device has neither. + let scroll_phase = match event.phase() { + NSEventPhase::MayBegin => ScrollPhase::MayBegin, + NSEventPhase::Began => ScrollPhase::Began, + NSEventPhase::Changed | NSEventPhase::Stationary => ScrollPhase::Changed, + NSEventPhase::Ended => ScrollPhase::Ended, + NSEventPhase::Cancelled => ScrollPhase::Cancelled, + _ => match event.momentumPhase() { + NSEventPhase::Began => ScrollPhase::MomentumBegan, + NSEventPhase::Changed => ScrollPhase::MomentumChanged, + NSEventPhase::Ended | NSEventPhase::Cancelled => ScrollPhase::MomentumEnded, + _ => ScrollPhase::None, + }, + }; let device_event = Event::DeviceEvent { device_id: DEVICE_ID, @@ -1294,6 +1317,7 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { device_id: DEVICE_ID, delta, phase, + scroll_phase, modifiers: event_mods(event), }, }; diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/windows/event_loop.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/windows/event_loop.rs index 3390e24fd..38fe50f16 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/windows/event_loop.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/windows/event_loop.rs @@ -1474,6 +1474,7 @@ unsafe fn public_window_callback_inner( device_id: DEVICE_ID, delta: LineDelta(0.0, value), phase: TouchPhase::Moved, + scroll_phase: crate::event::ScrollPhase::None, modifiers, }, }); @@ -1498,6 +1499,7 @@ unsafe fn public_window_callback_inner( device_id: DEVICE_ID, delta: LineDelta(value, 0.0), phase: TouchPhase::Moved, + scroll_phase: crate::event::ScrollPhase::None, modifiers, }, }); diff --git a/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json b/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json index 1a6547508..305ab2f59 100644 --- a/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json +++ b/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json @@ -65,6 +65,15 @@ "int" ] }, + { + "name": "onScrollGesture", + "parameterTypes": [ + "long", + "int", + "int", + "int" + ] + }, { "name": "onTouchInput", "parameterTypes": [ @@ -137,6 +146,15 @@ "int" ] }, + { + "name": "onScrollGesture", + "parameterTypes": [ + "long", + "int", + "int", + "int" + ] + }, { "name": "onTouchInput", "parameterTypes": [ diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index 0dd03591e..7172eadd2 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -26,6 +26,8 @@ import dev.nucleusframework.window.tao.scene.TaoScenePopupTest import dev.nucleusframework.window.tao.scene.TaoSceneRenderTest import dev.nucleusframework.window.tao.scene.TaoSceneScrollTest import dev.nucleusframework.window.tao.scene.TaoSceneSemanticsTest +import dev.nucleusframework.window.tao.scene.TaoSceneTrackpadPanTest +import dev.nucleusframework.window.tao.scene.TaoTrackpadPanRouterTest /** * Programmatic, reflection-free registry of the stage-1 offscreen battery so @@ -127,14 +129,14 @@ public object TaoSceneTestBattery { run("MacOsWheelDeltaTest: scrollUpMatchesTaoWindowAwtSign") { MacOsWheelDeltaTest().scrollUpMatchesTaoWindowAwtSign() } - run("MacOsWheelDeltaTest: horizontalDeltaKeepsAppKitX") { - MacOsWheelDeltaTest().horizontalDeltaKeepsAppKitX() + run("MacOsWheelDeltaTest: horizontalDeltaFlipsLikeVertical") { + MacOsWheelDeltaTest().horizontalDeltaFlipsLikeVertical() } - run("MacOsWheelDeltaTest: precisePixelDeltaMatchesTaoWindowScale") { - MacOsWheelDeltaTest().precisePixelDeltaMatchesTaoWindowScale() + run("MacOsWheelDeltaTest: precisePixelDeltaIgnoresDisplayScale") { + MacOsWheelDeltaTest().precisePixelDeltaIgnoresDisplayScale() } - run("MacOsWheelDeltaTest: precisePixelHorizontalMatchesTaoWindowScale") { - MacOsWheelDeltaTest().precisePixelHorizontalMatchesTaoWindowScale() + run("MacOsWheelDeltaTest: precisePixelHorizontalFlipsAndDividesByTen") { + MacOsWheelDeltaTest().precisePixelHorizontalFlipsAndDividesByTen() } run("MacOsWheelDeltaTest: lineDeltaCarriesMacOsScrollAmount") { MacOsWheelDeltaTest().lineDeltaCarriesMacOsScrollAmount() @@ -190,6 +192,27 @@ public object TaoSceneTestBattery { run("TaoWindowScrollTest: pixelScrollMirrorsMacOsAwtPreciseWheelRotationScale") { TaoWindowScrollTest().pixelScrollMirrorsMacOsAwtPreciseWheelRotationScale() } + run("TaoWindowScrollTest: scrollGestureIsShapedLikePixelScrollWithItsPhase") { + TaoWindowScrollTest().scrollGestureIsShapedLikePixelScrollWithItsPhase() + } + run("TaoTrackpadPanRouterTest: swipe without momentum ends after the grace period") { + TaoTrackpadPanRouterTest().`swipe without momentum ends after the grace period`() + } + run("TaoTrackpadPanRouterTest: momentum tail continues the pan and ends it once") { + TaoTrackpadPanRouterTest().`momentum tail continues the pan and ends it once`() + } + run("TaoTrackpadPanRouterTest: pan offsets pass through unchanged and zero deltas send no move") { + TaoTrackpadPanRouterTest().`pan offsets pass through unchanged and zero deltas send no move`() + } + run("TaoTrackpadPanRouterTest: cancelled closes immediately and may-begin alone is silent") { + TaoTrackpadPanRouterTest().`cancelled closes immediately and may-begin alone is silent`() + } + run("TaoTrackpadPanRouterTest: a new swipe during the grace period keeps the same pan open") { + TaoTrackpadPanRouterTest().`a new swipe during the grace period keeps the same pan open`() + } + run("TaoTrackpadPanRouterTest: cancel drops the pending end without sending PanEnd") { + TaoTrackpadPanRouterTest().`cancel drops the pending end without sending PanEnd`() + } run("TaoWindowResizableTest: reflectsCreationFlag") { TaoWindowResizableTest().reflectsCreationFlag() } run("WindowWrapContentTest: creationSizeUsesSpecifiedAxis") { WindowWrapContentTest().creationSizeUsesSpecifiedAxis() @@ -354,6 +377,18 @@ public object TaoSceneTestBattery { run("TaoSceneScrollTest: scrolled content repaints at the new offset") { TaoSceneScrollTest().`scrolled content repaints at the new offset`() } + run("TaoSceneTrackpadPanTest: positive vertical pan scrolls a column down") { + TaoSceneTrackpadPanTest().`positive vertical pan scrolls a column down`() + } + run("TaoSceneTrackpadPanTest: positive horizontal pan scrolls a row forward") { + TaoSceneTrackpadPanTest().`positive horizontal pan scrolls a row forward`() + } + run("TaoSceneTrackpadPanTest: negative pan at the origin is a no-op") { + TaoSceneTrackpadPanTest().`negative pan at the origin is a no-op`() + } + run("TaoSceneTrackpadPanTest: pan moves content by its pixel offset") { + TaoSceneTrackpadPanTest().`pan moves content by its pixel offset`() + } run("TaoScenePopupTest: popup renders above the window content") { TaoScenePopupTest().`popup renders above the window content`() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index 37c75fdc6..dcc372af6 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt @@ -27,6 +27,8 @@ import dev.nucleusframework.window.tao.scene.TaoSceneRectManagerRaceTest import dev.nucleusframework.window.tao.scene.TaoSceneRenderTest import dev.nucleusframework.window.tao.scene.TaoSceneScrollTest import dev.nucleusframework.window.tao.scene.TaoSceneSemanticsTest +import dev.nucleusframework.window.tao.scene.TaoSceneTrackpadPanTest +import dev.nucleusframework.window.tao.scene.TaoTrackpadPanRouterTest import java.io.File import kotlin.test.Test import kotlin.test.assertEquals @@ -66,6 +68,8 @@ class TaoSceneTestBatteryDriftTest { TaoScenePointerTest::class.java, TaoScenePointerSlopTest::class.java, TaoSceneScrollTest::class.java, + TaoSceneTrackpadPanTest::class.java, + TaoTrackpadPanRouterTest::class.java, TaoScenePopupTest::class.java, TaoSceneOuterLocalsBridgeTest::class.java, TaoSceneAnimationTest::class.java, diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWindowScrollTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWindowScrollTest.kt index 962393101..d3c0fc424 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWindowScrollTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWindowScrollTest.kt @@ -16,11 +16,28 @@ class TaoWindowScrollTest { @Test fun pixelScrollMirrorsMacOsAwtPreciseWheelRotationScale() { + // Wire = logical AppKit points × 100 (#653): 10 pt right, 20 pt up. val event = dispatchScroll(TaoEventCode.SCROLL_PIXEL, dx = 1000, dy = -2000) assertEquals(-1f, event.dxAwt) assertEquals(2f, event.dyAwt) assertEquals(1, event.scrollAmount) + assertEquals(TaoScrollGesturePhase.NONE, event.gesturePhase) + } + + @Test + fun scrollGestureIsShapedLikePixelScrollWithItsPhase() { + var event: TaoPointerScrollEvent? = null + TaoWindow(handle = 1L).apply { + onPointerScroll { event = it } + dispatchScrollGesture(TaoScrollGesturePhase.MOMENTUM_CHANGED, dxFixed = 1000, dyFixed = -2000) + } + val gesture = requireNotNull(event) + + assertEquals(-1f, gesture.dxAwt) + assertEquals(2f, gesture.dyAwt) + assertEquals(1, gesture.scrollAmount) + assertEquals(TaoScrollGesturePhase.MOMENTUM_CHANGED, gesture.gesturePhase) } private fun dispatchScroll( diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt index 5f2a9075f..0f06164cf 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt @@ -3,47 +3,50 @@ package dev.nucleusframework.window.tao.event import kotlin.test.Test import kotlin.test.assertEquals +/** + * Popup NSPanel scroll conversion (#652 / #653): raw AppKit `scrollingDelta*` + * → AWT `preciseWheelRotation`, i.e. OpenJDK's `-[event deltaX/Y]` with the + * precise legacy delta being `scrollingDelta × 0.1` — both axes flip and the + * display scale never enters. + */ class MacOsWheelDeltaTest { @Test fun scrollUpMatchesTaoWindowAwtSign() { - // AppKit scrollingDeltaY > 0 is a scroll up. Popup NSPanels report - // that raw. TaoWindow SCROLL_LINE negates, so AWT/Compose get -1. - val delta = - appKitWheelToAwtScrollDelta(dx = 0f, dy = 1f, precise = false, scale = 2f) + // AppKit scrollingDeltaY > 0 is a scroll up (content moves down). + // Popup NSPanels report that raw; AWT / Compose get -1. + val delta = appKitWheelToAwtScrollDelta(dx = 0f, dy = 1f, precise = false) assertEquals(0f, delta.x, absoluteTolerance = 0f) assertEquals(-1f, delta.y, absoluteTolerance = 0f) } @Test - fun horizontalDeltaKeepsAppKitX() { - // tao flips X then TaoWindow negates, net identity vs raw AppKit X. - val delta = - appKitWheelToAwtScrollDelta(dx = 1f, dy = 0f, precise = false, scale = 2f) - assertEquals(1f, delta.x, absoluteTolerance = 0f) + fun horizontalDeltaFlipsLikeVertical() { + // #652: AppKit scrollingDeltaX > 0 is content moving right, i.e. a + // scroll *left*; AWT reports that as -1 — same as TaoWindow now that + // tao no longer pre-flips X. + val delta = appKitWheelToAwtScrollDelta(dx = 1f, dy = 0f, precise = false) + assertEquals(-1f, delta.x, absoluteTolerance = 0f) assertEquals(0f, delta.y, absoluteTolerance = 0f) } @Test - fun precisePixelDeltaMatchesTaoWindowScale() { - // 10 AppKit points at 2x → physical 20 → AWT preciseWheelRotation -2 - // after TaoWindow SCROLL_PIXEL's negate and /10. - val delta = - appKitWheelToAwtScrollDelta(dx = 0f, dy = 10f, precise = true, scale = 2f) + fun precisePixelDeltaIgnoresDisplayScale() { + // #653: 10 AppKit points → AWT preciseWheelRotation -1, on any display. + val delta = appKitWheelToAwtScrollDelta(dx = 0f, dy = 10f, precise = true) assertEquals(0f, delta.x, absoluteTolerance = 0f) - assertEquals(-2f, delta.y, absoluteTolerance = 0f) + assertEquals(-1f, delta.y, absoluteTolerance = 0f) } @Test - fun precisePixelHorizontalMatchesTaoWindowScale() { - val delta = - appKitWheelToAwtScrollDelta(dx = 10f, dy = 0f, precise = true, scale = 2f) - assertEquals(2f, delta.x, absoluteTolerance = 0f) + fun precisePixelHorizontalFlipsAndDividesByTen() { + val delta = appKitWheelToAwtScrollDelta(dx = 10f, dy = 0f, precise = true) + assertEquals(-1f, delta.x, absoluteTolerance = 0f) assertEquals(0f, delta.y, absoluteTolerance = 0f) } @Test fun lineDeltaCarriesMacOsScrollAmount() { - val event = appKitWheelToAwtScrollEvent(dx = 0f, dy = 1f, precise = false, scale = 2f) + val event = appKitWheelToAwtScrollEvent(dx = 0f, dy = 1f, precise = false) assertEquals(0f, event.dxAwt, absoluteTolerance = 0f) assertEquals(-1f, event.dyAwt, absoluteTolerance = 0f) assertEquals(MACOS_AWT_SCROLL_AMOUNT, event.scrollAmount) @@ -51,9 +54,9 @@ class MacOsWheelDeltaTest { @Test fun preciseDeltaCarriesMacOsScrollAmount() { - val event = appKitWheelToAwtScrollEvent(dx = 0f, dy = 10f, precise = true, scale = 2f) + val event = appKitWheelToAwtScrollEvent(dx = 0f, dy = 10f, precise = true) assertEquals(0f, event.dxAwt, absoluteTolerance = 0f) - assertEquals(-2f, event.dyAwt, absoluteTolerance = 0f) + assertEquals(-1f, event.dyAwt, absoluteTolerance = 0f) assertEquals(MACOS_AWT_SCROLL_AMOUNT, event.scrollAmount) } } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt new file mode 100644 index 000000000..f77457fe5 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt @@ -0,0 +1,468 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.headful.MacScrollWheelProbe.Momentum +import dev.nucleusframework.window.tao.headful.MacScrollWheelProbe.Phase +import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.abs + +/** + * macOS trackpad / wheel parity with the AWT backend — issues #652, #653 and + * #654. Every case injects real `scrollWheel:` NSEvents into the tao content + * view ([MacScrollWheelProbe]) and observes what Compose receives at the + * root, so the whole chain runs: tao `scroll_wheel` → JNI loop → `TaoWindow` + * → scene host → `ComposeScene`. + * + * The AWT reference (OpenJDK `AWTView.m` + `CPlatformResponder`) is + * `preciseWheelRotation = -[event deltaX/Y]`, where the legacy delta of a + * precise (trackpad) event is `scrollingDelta × 0.1` in points — no display + * scale anywhere. Compose Desktop's `MacOSCocoaConfig` then turns one unit + * into `10.dp`, which is also the pixel amount a trackpad pan must carry in + * `PointerInputChange.panOffset` for the two paths to move content equally. + */ +internal object MacOsTrackpadScrollHeadfulCases { + fun all(): List = + listOf( + swipeLeftScrollsHorizontalContentForward(), + preciseDeltasMatchAwtWithoutDisplayScale(), + trackpadGestureArrivesAsPanAndWheelStaysScroll(), + trackpadPanScrollsVerticalColumn(), + ) + + // ── #652 ──────────────────────────────────────────────────────────────── + + /** + * A two-finger swipe *left* reveals content on the right — the row's + * scroll offset must grow, exactly as it does under AWT. Before the fix + * the horizontal sign was inverted (tao already flips `scrollingDeltaX` + * and the Kotlin side negated it again), so the row tried to scroll + * *before* its start and never moved. + */ + private fun swipeLeftScrollsHorizontalContentForward(): TaoWindowTestCase { + val recorder = PointerRecorder() + val scrollPx = AtomicInteger(0) + val scrollMax = AtomicInteger(0) + return TaoWindowTestCase( + name = "#652 two-finger swipe left scrolls a horizontal row forward, like AWT", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Recording(recorder) { ScrollableRow(scrollPx, scrollMax) } }, + ) { + awaitUntil("window mapped") { bounds() != null } + awaitUntil("row has overflow") { scrollMax.get() > 0 } + settle() + swipe(dx = -SWIPE_DELTA_PT, dy = 0f, steps = SWIPE_STEPS, momentum = false) + waitFor(SCROLL_REACTION_MILLIS) { scrollPx.get() != 0 } + check(scrollPx.get() > 0) { + "swipe left (scrollingDeltaX < 0) must scroll the row forward as under AWT; " + + "offset=${scrollPx.get()} recorded=${recorder.describe()}" + } + } + } + + // ── #653 (and the #652 sign on the plain Scroll path) ─────────────────── + + /** + * A precise scroll that is *not* part of a trackpad gesture (no phase — + * e.g. a mouse with smooth-scroll firmware) stays a Compose `Scroll` + * event and must carry AWT's `preciseWheelRotation`: + * `-scrollingDelta / 10`, independent of the display scale. Before the + * fix tao converted the delta to physical pixels first, so a Retina + * display doubled it (2.0 instead of 1.0) and X still had the wrong sign. + */ + private fun preciseDeltasMatchAwtWithoutDisplayScale(): TaoWindowTestCase { + val recorder = PointerRecorder() + return TaoWindowTestCase( + name = "#653 precise scroll deltas match AWT preciseWheelRotation regardless of display scale", + timeoutMillis = HIDPI_CASE_TIMEOUT_MILLIS, + skip = { macOnly() }, + // The suite's default chrome is a fillMaxSize sibling stacked above + // [content]; leaving it on gives the recorder 0 height. + paintDefaultBackground = false, + content = { Recording(recorder) {} }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + // The doubling only shows on a HiDPI display: flip the screen to + // its 2x twin for the duration of the case when the window sits on + // a 1x display (same trick as the #507 probe), else run as-is. + val baseScale = window.scaleFactor + val switched = baseScale < HIDPI && switchDisplayTo2x() + try { + val scale = window.scaleFactor + System.err.println("[probe] window scale factor = $scale (switched to HiDPI: $switched)") + + // Fingers up on a natural-scrolling trackpad: AppKit -10 points → AWT +1. + inject(dx = 0f, dy = -SWIPE_DELTA_PT, precise = true) + awaitUntil("vertical Scroll event recorded") { recorder.count(PointerEventType.Scroll) >= 1 } + val vertical = recorder.snapshot().first { it.type == PointerEventType.Scroll } + checkClose(vertical.scrollDelta, Offset(0f, 1f)) { + "vertical precise delta -10pt at scale $scale must reach Compose as AWT +1.0 " + + "(got ${vertical.scrollDelta}; recorded=${recorder.describe()})" + } + + // Fingers left: AppKit -10 points → AWT +1 on X. + inject(dx = -SWIPE_DELTA_PT, dy = 0f, precise = true) + awaitUntil("horizontal Scroll event recorded") { recorder.count(PointerEventType.Scroll) >= 2 } + val horizontal = recorder.snapshot().filter { it.type == PointerEventType.Scroll }[1] + checkClose(horizontal.scrollDelta, Offset(1f, 0f)) { + "horizontal precise delta -10pt at scale $scale must reach Compose as AWT +1.0 on X " + + "(got ${horizontal.scrollDelta}; recorded=${recorder.describe()})" + } + } finally { + if (switched) restoreDisplayTo1x(baseScale) + } + } + } + + /** + * Flips the main display to the HiDPI twin of its current mode and waits + * for the window to report the new backing scale. `false` (and a log line) + * when the helper or a twin mode is unavailable — the case then runs at + * the current scale, which still checks the sign. + */ + private suspend fun TaoWindowTestScope.switchDisplayTo2x(): Boolean { + val unavailable = MacDisplayModeTool.unavailableReason() + if (unavailable != null) { + System.err.println("[probe] HiDPI switch unavailable: $unavailable") + return false + } + System.err.println("[probe] setmode 2x -> ${MacDisplayModeTool.run("2x")}") + awaitUntil("window reports a HiDPI backing scale") { window.scaleFactor >= HIDPI } + settle(DISPLAY_SETTLE_MILLIS) + return true + } + + private suspend fun TaoWindowTestScope.restoreDisplayTo1x(baseScale: Float) { + System.err.println("[probe] restoring 1x -> ${MacDisplayModeTool.run("1x")}") + awaitUntil("window back at the original scale ($baseScale)") { + abs(window.scaleFactor - baseScale) < SCALE_TOLERANCE + } + settle(DISPLAY_SETTLE_MILLIS) + } + + // ── #654 ──────────────────────────────────────────────────────────────── + + /** + * A phased trackpad gesture (Began → Changed… → Ended, then the inertial + * momentum tail) must surface as `PanStart` / `PanMove` / `PanEnd` with + * `panOffset` in pixels — never as `Scroll` — and the pan must stay open + * across the momentum tail so Compose does not add its own fling on top of + * macOS's. A wheel notch afterwards is still an ordinary `Scroll`. + */ + private fun trackpadGestureArrivesAsPanAndWheelStaysScroll(): TaoWindowTestCase { + val recorder = PointerRecorder() + return TaoWindowTestCase( + name = "#654 trackpad gesture arrives as Compose Pan events and a wheel notch stays Scroll", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Recording(recorder) {} }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + val scale = window.scaleFactor + + swipe(dx = 0f, dy = -SWIPE_DELTA_PT, steps = SWIPE_STEPS, momentum = true) + waitFor(PAN_END_MILLIS) { recorder.count(PointerEventType.PanEnd) >= 1 } + val gesture = recorder.snapshot() + check(gesture.isNotEmpty() && gesture.first().type == PointerEventType.PanStart) { + "a trackpad gesture must open with PanStart; recorded=${recorder.describe()}" + } + check(gesture.none { it.type == PointerEventType.Scroll }) { + "a trackpad gesture must not also be delivered as Scroll; recorded=${recorder.describe()}" + } + val moves = gesture.filter { it.type == PointerEventType.PanMove } + // SWIPE_STEPS finger moves + 2 momentum moves, and nothing else: + // the zero-delta Began / Ended steps must not leak as PanMove(0, 0). + check(moves.size == SWIPE_STEPS + 2) { + "expected exactly ${SWIPE_STEPS + 2} PanMove (fingers + momentum), no zero-offset ones; " + + "recorded=${recorder.describe()}" + } + check(moves.none { it.panOffset.x == 0f && it.panOffset.y == 0f }) { + "zero-delta gesture steps must not reach Compose as PanMove; recorded=${recorder.describe()}" + } + val fingerMoves = moves.take(SWIPE_STEPS) + fingerMoves.forEach { move -> + // AppKit -10 points (fingers up) → Compose pan +10 dp = 10 × scale px. + checkClose(move.panOffset, Offset(0f, SWIPE_DELTA_PT * scale)) { + "PanMove.panOffset must be -scrollingDelta × scale px (scale=$scale); " + + "got ${move.panOffset}; recorded=${recorder.describe()}" + } + } + check(gesture.last().type == PointerEventType.PanEnd) { + "PanEnd must close the gesture after the momentum tail; recorded=${recorder.describe()}" + } + check(gesture.count { it.type == PointerEventType.PanEnd } == 1) { + "exactly one PanEnd per gesture (the momentum tail must not restart the pan); " + + "recorded=${recorder.describe()}" + } + + // A classic wheel notch: AppKit +1 line (scroll up) → AWT -1. + val before = gesture.size + inject(dx = 0f, dy = 1f, precise = false) + awaitUntil("wheel notch recorded as Scroll") { recorder.count(PointerEventType.Scroll) >= 1 } + val afterWheel = recorder.snapshot().drop(before) + val wheel = afterWheel.single { it.type == PointerEventType.Scroll } + checkClose(wheel.scrollDelta, Offset(0f, -1f)) { + "wheel notch +1 line must reach Compose as AWT -1.0 (got ${wheel.scrollDelta})" + } + check(afterWheel.none { it.type == PointerEventType.PanMove }) { + "a wheel notch must not produce Pan events; recorded=${recorder.describe()}" + } + } + } + + /** + * End-to-end through foundation: Compose's `TrackpadScrollingLogic` + * consumes the pan and moves a `verticalScroll` column, and a gesture + * with no momentum tail still gets its `PanEnd` (deferred, then flushed). + */ + private fun trackpadPanScrollsVerticalColumn(): TaoWindowTestCase { + val recorder = PointerRecorder() + val scrollPx = AtomicInteger(0) + val scrollMax = AtomicInteger(0) + return TaoWindowTestCase( + name = "#654 trackpad pan scrolls a vertical column through Compose's trackpad logic", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Recording(recorder) { ScrollableColumn(scrollPx, scrollMax) } }, + ) { + awaitUntil("window mapped") { bounds() != null } + awaitUntil("column has overflow") { scrollMax.get() > 0 } + settle() + swipe(dx = 0f, dy = -SWIPE_DELTA_PT, steps = SWIPE_STEPS, momentum = false) + waitFor(SCROLL_REACTION_MILLIS) { scrollPx.get() > 0 } + check(scrollPx.get() > 0) { + "fingers up must scroll the column down; offset=${scrollPx.get()} recorded=${recorder.describe()}" + } + check(recorder.count(PointerEventType.PanMove) >= SWIPE_STEPS) { + "the column must have been driven by Pan events; recorded=${recorder.describe()}" + } + waitFor(PAN_END_MILLIS) { recorder.count(PointerEventType.PanEnd) >= 1 } + check(recorder.count(PointerEventType.PanEnd) == 1) { + "a gesture without momentum must still end with exactly one PanEnd; recorded=${recorder.describe()}" + } + } + } + + // ── Injection ─────────────────────────────────────────────────────────── + + /** + * Two-finger swipe: Began, [steps] × Changed([dx], [dy]) points, Ended, + * optionally followed by AppKit's decaying momentum tail. + */ + private suspend fun TaoWindowTestScope.swipe( + dx: Float, + dy: Float, + steps: Int, + momentum: Boolean, + ) { + inject(dx = 0f, dy = 0f, precise = true, phase = Phase.BEGAN) + repeat(steps) { + settle(STEP_MILLIS) + inject(dx = dx, dy = dy, precise = true, phase = Phase.CHANGED) + } + settle(STEP_MILLIS) + inject(dx = 0f, dy = 0f, precise = true, phase = Phase.ENDED) + if (momentum) { + settle(STEP_MILLIS) + inject(dx = dx / 2, dy = dy / 2, precise = true, momentum = Momentum.BEGAN) + settle(STEP_MILLIS) + inject(dx = dx / 4, dy = dy / 4, precise = true, momentum = Momentum.CHANGED) + settle(STEP_MILLIS) + inject(dx = 0f, dy = 0f, precise = true, momentum = Momentum.ENDED) + } + } + + private fun TaoWindowTestScope.inject( + dx: Float, + dy: Float, + precise: Boolean, + phase: Int = Phase.NONE, + momentum: Int = Momentum.NONE, + ) { + val delivered = + MacScrollWheelProbe.inject( + window = window, + x = TARGET_X, + y = TARGET_Y, + dx = dx, + dy = dy, + precise = precise, + phase = phase, + momentum = momentum, + ) + check(delivered) { "nativeDiagInjectScrollWheel returned false (window or content view gone?)" } + } + + /** Polls [predicate] for up to [millis] without failing — the caller asserts. */ + private suspend fun TaoWindowTestScope.waitFor( + millis: Long, + predicate: () -> Boolean, + ) { + val deadline = System.currentTimeMillis() + millis + while (!predicate() && System.currentTimeMillis() < deadline) settle(POLL_MILLIS) + } + + // ── Compose content ───────────────────────────────────────────────────── + + private class Recorded( + val type: PointerEventType, + val scrollDelta: Offset, + val panOffset: Offset, + ) { + override fun toString(): String = + when (type) { + PointerEventType.Scroll -> "Scroll$scrollDelta" + PointerEventType.PanMove -> "PanMove$panOffset" + else -> type.toString() + } + } + + /** Scroll / Pan events seen at the window root on the Initial pass, in order. */ + private class PointerRecorder { + private val events = Collections.synchronizedList(mutableListOf()) + + fun add(event: PointerEvent) { + val change = event.changes.firstOrNull() ?: return + events += Recorded(event.type, change.scrollDelta, change.panOffset) + } + + fun snapshot(): List = synchronized(events) { events.toList() } + + fun count(type: PointerEventType): Int = snapshot().count { it.type == type } + + fun describe(): String = snapshot().joinToString(prefix = "[", postfix = "]") + } + + @Composable + private fun Recording( + recorder: PointerRecorder, + content: @Composable () -> Unit, + ) { + Box( + Modifier.fillMaxSize().pointerInput(recorder) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + when (event.type) { + PointerEventType.Scroll, + PointerEventType.PanStart, + PointerEventType.PanMove, + PointerEventType.PanEnd, + -> recorder.add(event) + else -> Unit + } + } + } + }, + ) { + content() + } + } + + @Composable + private fun ScrollableColumn( + scrollPx: AtomicInteger, + scrollMax: AtomicInteger, + ) { + val state = rememberScrollState() + scrollPx.set(state.value) + scrollMax.set(state.maxValue) + Column(Modifier.fillMaxSize().verticalScroll(state)) { + repeat(CELL_COUNT) { i -> + Box( + Modifier + .fillMaxWidth() + .height(CELL_SIZE_DP.dp) + .background(if (i % 2 == 0) Color.DarkGray else Color.Gray), + ) + } + } + } + + @Composable + private fun ScrollableRow( + scrollPx: AtomicInteger, + scrollMax: AtomicInteger, + ) { + val state = rememberScrollState() + scrollPx.set(state.value) + scrollMax.set(state.maxValue) + Row(Modifier.fillMaxSize().horizontalScroll(state)) { + repeat(CELL_COUNT) { i -> + Box( + Modifier + .fillMaxHeight() + .width(CELL_SIZE_DP.dp) + .background(if (i % 2 == 0) Color.DarkGray else Color.Gray), + ) + } + } + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private fun macOnly(): String? = + when { + Platform.Current != Platform.MacOS -> "macOS only — AppKit scrollWheel: injection" + !MacScrollWheelProbe.available -> "nucleus_tao_metal not loaded" + else -> null + } + + private inline fun checkClose( + actual: Offset, + expected: Offset, + message: () -> String, + ) { + check(abs(actual.x - expected.x) <= DELTA_TOLERANCE && abs(actual.y - expected.y) <= DELTA_TOLERANCE, message) + } + + /** Content-local injection point (points, top-left origin), well inside the 800×600 default window. */ + private const val TARGET_X = 400f + private const val TARGET_Y = 300f + + /** AppKit points per injected finger move. */ + private const val SWIPE_DELTA_PT = 10f + private const val SWIPE_STEPS = 3 + private const val STEP_MILLIS = 16L + private const val POLL_MILLIS = 16L + + /** How long a scrollable gets to react before the (soft) wait gives up. */ + private const val SCROLL_REACTION_MILLIS = 2_000L + + /** Upper bound for the deferred PanEnd (momentum grace + delivery). */ + private const val PAN_END_MILLIS = 3_000L + + private const val DELTA_TOLERANCE = 0.05f + + private const val HIDPI = 2f + private const val SCALE_TOLERANCE = 0.01f + private const val DISPLAY_SETTLE_MILLIS = 1_000L + private const val HIDPI_CASE_TIMEOUT_MILLIS = 90_000L + + private const val CELL_COUNT = 80 + private const val CELL_SIZE_DP = 48 +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacScrollWheelProbe.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacScrollWheelProbe.kt new file mode 100644 index 000000000..0a76a38cf --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacScrollWheelProbe.kt @@ -0,0 +1,72 @@ +package dev.nucleusframework.window.tao.headful + +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.ffi.NativeMetalBridge + +/** + * macOS headful helper: delivers a synthetic `scrollWheel:` NSEvent to the + * tao content view of [TaoWindow] through + * [NativeMetalBridge.nativeDiagInjectScrollWheel] — the same entry a real + * trackpad or mouse wheel takes after the WindowServer, so tao's + * `scroll_wheel`, the JNI loop and the Compose host all run for real. + * + * Deltas are raw AppKit `scrollingDelta*` values: points for a [precise] + * (trackpad) event, lines for a wheel notch. AppKit's sign convention is + * "positive = content moves down / right", i.e. a two-finger swipe *up* or + * *left* (natural scrolling) is a negative delta. Compose / AWT use the + * opposite sign; see `MacOsWheelDelta.kt`. + * + * [Phase] and [Momentum] are the IOHID field encodings that + * `+[NSEvent eventWithCGEvent:]` maps onto `NSEventPhase` — NOT the + * `NSEventPhase` bit values themselves. + */ +internal object MacScrollWheelProbe { + /** `kCGScrollWheelEventScrollPhase` encodings → `NSEvent.phase`. */ + object Phase { + const val NONE: Int = 0 + const val BEGAN: Int = 1 + const val CHANGED: Int = 2 + const val ENDED: Int = 4 + const val CANCELLED: Int = 8 + const val MAY_BEGIN: Int = 128 + } + + /** `kCGScrollWheelEventMomentumPhase` encodings → `NSEvent.momentumPhase`. */ + object Momentum { + const val NONE: Int = 0 + const val BEGAN: Int = 1 + const val CHANGED: Int = 2 + const val ENDED: Int = 3 + } + + val available: Boolean get() = NativeMetalBridge.isLoaded + + /** + * [x] / [y] are content-local points, top-left origin. Returns `false` + * when the window's NSView or NSWindow is gone. + */ + @Suppress("LongParameterList") + fun inject( + window: TaoWindow, + x: Float, + y: Float, + dx: Float, + dy: Float, + precise: Boolean, + phase: Int = Phase.NONE, + momentum: Int = Momentum.NONE, + ): Boolean { + val nsView = window.nativeHandle + if (nsView == 0L) return false + return NativeMetalBridge.nativeDiagInjectScrollWheel( + nsView, + x, + y, + dx, + dy, + precise, + phase, + momentum, + ) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt index 73f23d8db..611ef736b 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt @@ -358,6 +358,7 @@ public object TaoHeadfulTestSuiteMain { ) + UnspecifiedSizeHeadfulCases.all() + LinuxDiscreteScrollHeadfulCases.all() + + MacOsTrackpadScrollHeadfulCases.all() + ChromeReviewHeadfulCases.all() + ChromeCoverageHeadfulCases.all() + DisplayScaleHeadfulCases.all() + diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt index 5f929c206..56216a935 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt @@ -26,6 +26,7 @@ import dev.nucleusframework.window.tao.GlobalLayoutDirection import dev.nucleusframework.window.tao.TaoPointerScrollEvent import dev.nucleusframework.window.tao.event.TaoSyntheticMouseWheelEvent import dev.nucleusframework.window.tao.event.dispatchNativeKeyEvent +import dev.nucleusframework.window.tao.event.dispatchTrackpadPan import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.ffi.TaoNativeWireFormat import kotlinx.coroutines.CoroutineDispatcher @@ -442,6 +443,25 @@ internal class TaoSceneTestScope( frame() } + /** + * Mirrors the scene host's trackpad pan dispatch (`dispatchTrackpadPan`, + * #654): [panOffsetPx] is in pixels with Compose's sign — positive = + * content scrolls down / right. + */ + fun pan( + type: PointerEventType, + panOffsetPx: Offset, + ) { + scene.dispatchTrackpadPan( + x = pointerDeadband.x, + y = pointerDeadband.y, + type = type, + panOffset = panOffsetPx, + keyboardModifiers = taoKeyboardModifiers(modifierState), + ) + frame() + } + /** Mirrors `TaoComposeSceneHost.onPointerScroll` (AWT-shaped native event attached). */ fun scroll(event: TaoPointerScrollEvent) { val modifiers = taoKeyboardModifiers(modifierState) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt new file mode 100644 index 000000000..1a5f2b355 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt @@ -0,0 +1,118 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Stage-1 trackpad pan tests (#654): the pan events the scene host emits for + * a macOS trackpad gesture (`dispatchTrackpadPan`, mirrored by + * [TaoSceneTestScope.pan]) drive foundation's `TrackpadScrollingLogic` on + * real scrollable content, with the AWT sign convention — positive pan = + * content scrolls down / right — and the `10.dp` per wheel unit magnitude the + * host derives from `MacOSCocoaConfig`. + */ +class TaoSceneTrackpadPanTest { + @Test + fun `positive vertical pan scrolls a column down`() = + runTaoSceneTest(width = 100, height = 200) { + val scrollValue = mutableStateOf(0) + setContent { + val state = rememberScrollState() + scrollValue.value = state.value + Column(Modifier.fillMaxSize().verticalScroll(state)) { + repeat(50) { Box(Modifier.fillMaxWidth().height(20.dp)) } + } + } + moveMouse(50f, 100f) + pan(PointerEventType.PanStart, Offset.Zero) + repeat(3) { pan(PointerEventType.PanMove, Offset(0f, PAN_STEP_PX)) } + pan(PointerEventType.PanEnd, Offset.Zero) + frameUntilIdle() + assertTrue(scrollValue.value > 0, "pan down must advance the scroll state (got ${scrollValue.value})") + } + + @Test + fun `positive horizontal pan scrolls a row forward`() = + runTaoSceneTest(width = 200, height = 100) { + val scrollValue = mutableStateOf(0) + setContent { + val state = rememberScrollState() + scrollValue.value = state.value + Row(Modifier.fillMaxSize().horizontalScroll(state)) { + repeat(50) { Box(Modifier.fillMaxHeight().width(20.dp)) } + } + } + moveMouse(100f, 50f) + pan(PointerEventType.PanStart, Offset.Zero) + repeat(3) { pan(PointerEventType.PanMove, Offset(PAN_STEP_PX, 0f)) } + pan(PointerEventType.PanEnd, Offset.Zero) + frameUntilIdle() + assertTrue(scrollValue.value > 0, "pan right must advance the scroll state (got ${scrollValue.value})") + } + + @Test + fun `negative pan at the origin is a no-op`() = + runTaoSceneTest(width = 100, height = 200) { + val scrollValue = mutableStateOf(-1) + setContent { + val state = rememberScrollState() + scrollValue.value = state.value + Column(Modifier.fillMaxSize().verticalScroll(state)) { + repeat(50) { Box(Modifier.fillMaxWidth().height(20.dp)) } + } + } + moveMouse(50f, 100f) + pan(PointerEventType.PanStart, Offset.Zero) + pan(PointerEventType.PanMove, Offset(0f, -PAN_STEP_PX)) + pan(PointerEventType.PanEnd, Offset.Zero) + frameUntilIdle() + assertEquals(0, scrollValue.value) + } + + @Test + fun `pan moves content by its pixel offset`() = + runTaoSceneTest(width = 100, height = 100) { + setContent { + val state = rememberScrollState() + Column(Modifier.fillMaxSize().verticalScroll(state)) { + Box(Modifier.fillMaxWidth().height(100.dp).background(Color.Red)) + Box(Modifier.fillMaxWidth().height(100.dp).background(Color.Blue)) + } + } + assertEquals(RED, pixelAt(50, 50)) + moveMouse(50f, 50f) + pan(PointerEventType.PanStart, Offset.Zero) + // 100 px of pan on a 100 px viewport: the blue block must be fully in. + repeat(5) { pan(PointerEventType.PanMove, Offset(0f, 20f)) } + pan(PointerEventType.PanEnd, Offset.Zero) + frameUntilIdle() + assertEquals(BLUE, pixelAt(50, 50), "after a 100 px pan the blue block must fill the viewport") + } + + private companion object { + const val RED = 0xFFFF0000.toInt() + const val BLUE = 0xFF0000FF.toInt() + + /** One 10-point finger move at 1x, i.e. one AWT wheel unit × 10 dp. */ + const val PAN_STEP_PX = 10f + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt new file mode 100644 index 000000000..9ab788d01 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt @@ -0,0 +1,157 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import dev.nucleusframework.window.tao.TaoScrollGesturePhase +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * State machine of [TaoTrackpadPanRouter] (#654) against a hand-driven + * scheduler: the finger `Ended` must defer `PanEnd` so AppKit's momentum tail + * continues the same pan, and a swipe with no tail must still close. + */ +class TaoTrackpadPanRouterTest { + private class Harness { + val sent = mutableListOf>() + private var pending: (() -> Unit)? = null + var cancelled = 0 + + val router = + TaoTrackpadPanRouter( + schedule = { _, action -> + pending = action + ( + { + if (pending === action) pending = null + cancelled++ + } + ) + }, + send = { type, delta -> sent += type to delta }, + ) + + /** Fires the deferred end as the grace timer would. */ + fun elapseGrace() { + val action = pending ?: return + pending = null + action() + } + + val hasPendingEnd: Boolean get() = pending != null + + fun types() = sent.map { it.first } + } + + private val down = Offset(0f, 1f) + + @Test + fun `swipe without momentum ends after the grace period`() { + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero) + + assertEquals(listOf(PointerEventType.PanStart, PointerEventType.PanMove), h.types()) + assertTrue(h.hasPendingEnd, "Ended must only schedule the PanEnd") + assertTrue(h.router.isPanning) + + h.elapseGrace() + assertEquals( + listOf(PointerEventType.PanStart, PointerEventType.PanMove, PointerEventType.PanEnd), + h.types(), + ) + assertFalse(h.router.isPanning) + } + + @Test + fun `momentum tail continues the pan and ends it once`() { + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_BEGAN, down / 2f) + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_CHANGED, down / 4f) + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_ENDED, Offset.Zero) + + assertEquals( + listOf( + PointerEventType.PanStart, + PointerEventType.PanMove, + PointerEventType.PanMove, + PointerEventType.PanMove, + PointerEventType.PanEnd, + ), + h.types(), + ) + assertEquals(1, h.cancelled, "the momentum Began must cancel the deferred PanEnd") + assertFalse(h.hasPendingEnd) + // A stale grace timer firing later must not emit a second PanEnd. + h.elapseGrace() + assertEquals(1, h.types().count { it == PointerEventType.PanEnd }) + } + + @Test + fun `pan offsets pass through unchanged and zero deltas send no move`() { + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, Offset(-2.5f, 0.75f)) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, Offset.Zero) + // TaoWindow negates the wire delta, so a zero step arrives as -0.0. + h.router.onGesture(TaoScrollGesturePhase.CHANGED, Offset(-0f, -0f)) + + assertEquals( + listOf( + PointerEventType.PanStart to Offset.Zero, + PointerEventType.PanMove to Offset(-2.5f, 0.75f), + ), + h.sent, + ) + } + + @Test + fun `cancelled closes immediately and may-begin alone is silent`() { + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.MAY_BEGIN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CANCELLED, Offset.Zero) + assertTrue(h.sent.isEmpty(), "resting fingers then lift must not touch Compose") + + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + h.router.onGesture(TaoScrollGesturePhase.CANCELLED, Offset.Zero) + assertEquals( + listOf(PointerEventType.PanStart, PointerEventType.PanMove, PointerEventType.PanEnd), + h.types(), + ) + assertFalse(h.hasPendingEnd) + } + + @Test + fun `a new swipe during the grace period keeps the same pan open`() { + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + + assertEquals(1, h.types().count { it == PointerEventType.PanStart }) + assertEquals(0, h.types().count { it == PointerEventType.PanEnd }) + assertFalse(h.hasPendingEnd) + } + + @Test + fun `cancel drops the pending end without sending PanEnd`() { + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero) + h.router.cancel() + + assertFalse(h.hasPendingEnd) + assertFalse(h.router.isPanning) + h.elapseGrace() + assertEquals(listOf(PointerEventType.PanStart), h.types()) + } +} From 79d91fe7d95b965fe3bbc4925487e9b0aa96c113 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 10:45:13 +0300 Subject: [PATCH 2/7] fix(tao): route macOS scroll input through one router, keep popups in 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 --- .../api/decorated-window-tao.api | 2 - .../nucleusframework/window/tao/NativeView.kt | 16 +-- .../nucleusframework/window/tao/TaoWindow.kt | 46 +++--- .../window/tao/event/MacOsWheelDelta.kt | 5 + .../window/tao/ffi/PopupNativeBridge.kt | 11 +- .../window/tao/popup/TaoPopupSceneLayer.kt | 21 ++- .../tao/popup/TaoStandalonePopupHostMac.kt | 21 ++- .../window/tao/scene/TaoComposeSceneHost.kt | 63 ++------- .../window/tao/scene/TaoSceneScrollRouter.kt | 133 ++++++++++++++++++ .../window/tao/scene/TaoTrackpadPanRouter.kt | 43 ++++-- .../src/main/native/macos/popup_panel.m | 31 +++- .../src/main/native/src/event_loop.rs | 19 +-- .../src/main/native/src/events.rs | 6 +- ...cos-scroll-phase-and-horizontal-sign.patch | 29 ++-- .../main/native/vendor/tao-patches/README.md | 2 +- .../tao/src/platform_impl/macos/view.rs | 11 +- .../reachability-metadata.json | 4 +- .../window/tao/TaoSceneTestBattery.kt | 12 ++ .../tao/TaoSceneTestBatteryDriftTest.kt | 2 + .../window/tao/event/MacOsWheelDeltaTest.kt | 18 +++ .../MacOsTrackpadScrollHeadfulCases.kt | 35 +++-- .../popup/PopupPanelJniSignatureDriftTest.kt | 56 ++++++++ .../window/tao/scene/TaoSceneTestHarness.kt | 43 ++++++ .../tao/scene/TaoSceneTrackpadPanTest.kt | 84 +++++++++++ .../tao/scene/TaoTrackpadPanRouterTest.kt | 29 +++- .../com/example/demo/ScrollTestScreen.kt | 14 +- 26 files changed, 591 insertions(+), 165 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupPanelJniSignatureDriftTest.kt diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index d64a1f2b1..1d332a9e5 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -701,9 +701,7 @@ public final class dev/nucleusframework/window/tao/TaoTrackpadPhase { public final class dev/nucleusframework/window/tao/TaoWindow { public static final field $stable I - public static final field AWT_PIXEL_TO_ROTATION F public static final field LINUX_AWT_SCROLL_AMOUNT_DEFAULT I - public static final field MACOS_AWT_SCROLL_AMOUNT I public static final field SCROLL_FIXED_SCALE F public static final field WAYLAND_HANDLE_KIND J public static final field WINDOWS_TOUCH_DRAG_THRESHOLD_PX I diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt index 445ea5779..7fbd83694 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt @@ -275,15 +275,15 @@ private fun Modifier.nativeViewPointerInterop( ) true } - PointerEventType.PanStart, - PointerEventType.PanMove, - PointerEventType.PanEnd, - -> { + PointerEventType.PanMove -> { // Trackpad pan (#654), handed over in AWT wheel - // units: panOffset is 10 dp per unit (see the - // scene host), so the native view keeps scrolling - // under a two-finger swipe exactly like under a - // wheel. + // units: panOffset is 10 dp per unit (see + // TaoSceneScrollRouter), so the native view keeps + // scrolling under a two-finger swipe exactly like + // under a wheel. PanStart / PanEnd carry no offset + // and are not forwarded: the native side replays + // `NSApp.currentEvent`, and for the deferred PanEnd + // that is an unrelated, stale event. val unitPx = AWT_PIXEL_TO_ROTATION * density host.dispatchScrollToNative( handle, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index 41a41bcd0..6abac3251 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -5,6 +5,8 @@ package dev.nucleusframework.window.tao import androidx.compose.runtime.mutableStateOf import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher +import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION +import dev.nucleusframework.window.tao.event.MACOS_AWT_SCROLL_AMOUNT import dev.nucleusframework.window.tao.ffi.NativeTaoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoLinuxTouchBridge import dev.nucleusframework.window.tao.ffi.NativeTaoMacOsDecoBridge @@ -1097,16 +1099,27 @@ public class TaoWindow internal constructor( dxFixed: Int, dyFixed: Int, ) { - pointerScrollListener?.invoke( - TaoPointerScrollEvent( - dxAwt = -(dxFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION, - dyAwt = -(dyFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION, - scrollAmount = MACOS_AWT_SCROLL_AMOUNT, - gesturePhase = phase, - ), - ) + pointerScrollListener?.invoke(preciseScrollEvent(dxFixed, dyFixed, gesturePhase = phase)) } + /** + * AWT's macOS NSEvent → MouseWheelEvent conversion: `preciseWheelRotation + * = -scrollingDelta / 10`, no display scale (#652 / #653). The wire carries + * LOGICAL AppKit points × [SCROLL_FIXED_SCALE]; tao (and AppKit) count + * positive as "content moves down / right", AWT as "scroll down / right", + * hence the negation on both axes. + */ + private fun preciseScrollEvent( + dxFixed: Int, + dyFixed: Int, + gesturePhase: Int, + ) = TaoPointerScrollEvent( + dxAwt = -(dxFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION, + dyAwt = -(dyFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION, + scrollAmount = MACOS_AWT_SCROLL_AMOUNT, + gesturePhase = gesturePhase, + ) + internal fun dispatchKey( type: Int, vkCode: Int, @@ -1247,19 +1260,10 @@ public class TaoWindow internal constructor( ) } TaoEventCode.SCROLL_PIXEL -> { - // The loop hands over LOGICAL points (AppKit scrollingDelta*, - // never physical pixels — AWT applies no display scale, #653). - // AWT's macOS NSEvent → MouseWheelEvent conversion divides - // scrollingDelta by 10 to obtain preciseWheelRotation; we mirror - // it. Negate as above for the AWT sign convention (#652). - val dx = -(a / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION - val dy = -(b / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION + // Precise scroll outside a gesture (smooth-scroll mice); see + // [preciseScrollEvent] for the AWT shaping. pointerScrollListener?.invoke( - TaoPointerScrollEvent( - dxAwt = dx, - dyAwt = dy, - scrollAmount = MACOS_AWT_SCROLL_AMOUNT, - ), + preciseScrollEvent(a, b, gesturePhase = TaoScrollGesturePhase.NONE), ) } // KEY_DOWN / KEY_UP: routed in Phase 2b (no logical-key encoding yet) @@ -1269,8 +1273,6 @@ public class TaoWindow internal constructor( private companion object { const val SCROLL_FIXED_SCALE: Float = 100f const val LINUX_AWT_SCROLL_AMOUNT_DEFAULT: Int = 3 - const val MACOS_AWT_SCROLL_AMOUNT: Int = 1 - const val AWT_PIXEL_TO_ROTATION: Float = 10f const val WINDOWS_TOUCH_DRAG_THRESHOLD_PX: Int = 16 val platformLineScrollAmount: Int diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt index 566f5ed71..f6371a95e 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt @@ -2,6 +2,7 @@ package dev.nucleusframework.window.tao.event import androidx.compose.ui.geometry.Offset import dev.nucleusframework.window.tao.TaoPointerScrollEvent +import dev.nucleusframework.window.tao.TaoScrollGesturePhase /** Same factor [dev.nucleusframework.window.tao.TaoWindow] uses on `SCROLL_PIXEL`. */ internal const val AWT_PIXEL_TO_ROTATION: Float = 10f @@ -30,10 +31,12 @@ internal fun appKitWheelToAwtScrollDelta( return if (precise) awtSign / AWT_PIXEL_TO_ROTATION else awtSign } +/** [gesturePhase] is the [TaoScrollGesturePhase] of a trackpad step, `NONE` for a wheel. */ internal fun appKitWheelToAwtScrollEvent( dx: Float, dy: Float, precise: Boolean, + gesturePhase: Int = TaoScrollGesturePhase.NONE, ): TaoPointerScrollEvent { val delta = appKitWheelToAwtScrollDelta(dx, dy, precise) return TaoPointerScrollEvent( @@ -43,5 +46,7 @@ internal fun appKitWheelToAwtScrollEvent( // lines-per-notch multiplier out of scrollAmount the way LinuxGtkConfig // does. Do not copy LINUX_AWT_SCROLL_AMOUNT_DEFAULT here. scrollAmount = MACOS_AWT_SCROLL_AMOUNT, + // A wheel notch has no phase; only precise events can belong to a gesture. + gesturePhase = if (precise) gesturePhase else TaoScrollGesturePhase.NONE, ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/PopupNativeBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/PopupNativeBridge.kt index 39c65bccd..3271d109c 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/PopupNativeBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/PopupNativeBridge.kt @@ -118,19 +118,22 @@ internal object PopupNativeBridge { /** * Raw AppKit `scrollingDelta*` units. [precise] is - * `NSEvent.hasPreciseScrollingDeltas` (trackpad / Magic Mouse). + * `NSEvent.hasPreciseScrollingDeltas` (trackpad / Magic Mouse); + * [gesturePhase] is the [dev.nucleusframework.window.tao.TaoScrollGesturePhase] + * of a trackpad gesture step (`NONE` for a wheel notch), mapped in + * `popup_panel.m` exactly like the vendored tao does for the window. * Callers must map through * [dev.nucleusframework.window.tao.event.appKitWheelToAwtScrollEvent] - * then [dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll] - * before Compose. + * then a `TaoSceneScrollRouter` before Compose. */ - @Suppress("FunctionParameterNaming") + @Suppress("FunctionParameterNaming", "LongParameterList") fun onScroll( x: Float, y: Float, dx: Float, dy: Float, precise: Boolean, + gesturePhase: Int, ) /** [type] = 1 down, 2 up. */ diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt index 915f634a5..2be11372d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import dev.nucleusframework.window.tao.TaoCursorIcon import dev.nucleusframework.window.tao.event.appKitWheelToAwtScrollEvent -import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll import dev.nucleusframework.window.tao.event.dispatchNativeKeyEvent import dev.nucleusframework.window.tao.event.toTaoCursorIconCode import dev.nucleusframework.window.tao.ffi.NativeMetalBridge @@ -32,6 +31,7 @@ import dev.nucleusframework.window.tao.scene.TaoMetalTextureHost import dev.nucleusframework.window.tao.scene.TaoPlatformContextBase import dev.nucleusframework.window.tao.scene.TaoRecordedSurface import dev.nucleusframework.window.tao.scene.TaoSceneBundle +import dev.nucleusframework.window.tao.scene.TaoSceneScrollRouter import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.catchExceptions import dev.nucleusframework.window.tao.scene.recordSceneToPicture @@ -230,6 +230,17 @@ internal class TaoPopupSceneLayer( private val innerScene: ComposeScene get() = sceneBundle.scene + // Wheel → Scroll, trackpad gesture → Pan, same as the window host (#654). + private val scrollRouter = + TaoSceneScrollRouter( + object : TaoSceneScrollRouter.Target { + override val scene: ComposeScene get() = innerScene + override val scale: Float get() = this@TaoPopupSceneLayer.scale + + override fun guard(block: () -> Unit) = host.exceptionHandler.catchExceptions(block) + }, + ) + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null @@ -276,12 +287,9 @@ internal class TaoPopupSceneLayer( dx: Float, dy: Float, precise: Boolean, + gesturePhase: Int, ) = host.exceptionHandler.catchExceptions { - innerScene.dispatchAwtShapedScroll( - x, - y, - appKitWheelToAwtScrollEvent(dx, dy, precise), - ) + scrollRouter.onScroll(x, y, appKitWheelToAwtScrollEvent(dx, dy, precise, gesturePhase)) } override fun onKeyEvent( @@ -407,6 +415,7 @@ internal class TaoPopupSceneLayer( // AppKit event doesn't deref a half-disposed scene. PopupNativeBridge.nativeUninstallOutsideClickMonitor(panelHandle) PopupNativeBridge.nativeSetEventCallback(panelHandle, null) + scrollRouter.cancel() host.setCursor(TaoCursorIcon.DEFAULT) sceneBundle.close() // Close the Skia context on its owning render thread. close() runs in diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt index b68f7efeb..cb9d5961d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt @@ -23,7 +23,6 @@ import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.dnd.TaoDragAndDropManager import dev.nucleusframework.window.tao.dnd.TaoSceneDnD import dev.nucleusframework.window.tao.event.appKitWheelToAwtScrollEvent -import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll import dev.nucleusframework.window.tao.event.dispatchNativeKeyEvent import dev.nucleusframework.window.tao.event.toTaoCursorIconCode import dev.nucleusframework.window.tao.ffi.NativeMetalBridge @@ -35,6 +34,7 @@ import dev.nucleusframework.window.tao.scene.MetalTextureHostCache import dev.nucleusframework.window.tao.scene.TaoMetalTextureHost import dev.nucleusframework.window.tao.scene.TaoPlatformContextBase import dev.nucleusframework.window.tao.scene.TaoSceneBundle +import dev.nucleusframework.window.tao.scene.TaoSceneScrollRouter import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.newMetalRenderExecutor import dev.nucleusframework.window.tao.scene.recordSceneToPicture @@ -98,6 +98,17 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { private val windowInfo = StandalonePopupWindowInfo() private val framePump = StandaloneFramePump { renderNow() } + + // Wheel → Scroll, trackpad gesture → Pan, same as the window host (#654). + private val scrollRouter = + TaoSceneScrollRouter( + object : TaoSceneScrollRouter.Target { + override val scene: ComposeScene? get() = this@TaoStandalonePopupHostMac.scene + override val scale: Float get() = this@TaoStandalonePopupHostMac.scale + + override fun guard(block: () -> Unit) = framePump.nonReentrant(block) + }, + ) private val replayInFlight = AtomicBoolean(false) private var nextFrameNs = 0L private var visible = false @@ -287,6 +298,7 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { if (!isValid || disposed) return disposed = true framePump.disposed = true + scrollRouter.cancel() revokeInboundDnD() PopupNativeBridge.nativeUninstallOutsideClickMonitor(panel) PopupNativeBridge.nativeSetEventCallback(panel, null) @@ -438,13 +450,10 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { dx: Float, dy: Float, precise: Boolean, + gesturePhase: Int, ) { framePump.nonReentrant { - scene?.dispatchAwtShapedScroll( - x, - y, - appKitWheelToAwtScrollEvent(dx, dy, precise), - ) + scrollRouter.onScroll(x, y, appKitWheelToAwtScrollEvent(dx, dy, precise, gesturePhase)) } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index 9f10a112a..7d313b9b8 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -31,14 +31,10 @@ import dev.nucleusframework.window.tao.TaoKeyLocation import dev.nucleusframework.window.tao.TaoModifierMask import dev.nucleusframework.window.tao.TaoNativeViewHost import dev.nucleusframework.window.tao.TaoPointerScrollEvent -import dev.nucleusframework.window.tao.TaoScrollGesturePhase import dev.nucleusframework.window.tao.TaoTrackpadGesture import dev.nucleusframework.window.tao.TaoTrackpadPhase import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher -import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION -import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll -import dev.nucleusframework.window.tao.event.dispatchTrackpadPan import dev.nucleusframework.window.tao.event.taoKeyEvent import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.event.taoTypedKeyEvent @@ -53,12 +49,9 @@ import dev.nucleusframework.window.tao.render.LocalTaoTextSelectionA11yPublisher import dev.nucleusframework.window.tao.render.TaoSelectionAccessibilityObserver import dev.nucleusframework.window.tao.shouldApplyLargeCornerRadius import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -1061,55 +1054,25 @@ internal class TaoComposeSceneHost( } /** - * [event] is pre-shaped to match AWT `MouseWheelEvent.preciseWheelRotation` - * and carries a synthetic native event so Compose's desktop scroll config - * can read `scrollAmount` and precise-wheel metadata like the AWT backend. + * [event] is pre-shaped to match AWT `MouseWheelEvent.preciseWheelRotation`; + * wheel notches reach Compose as `Scroll` events with a synthetic native + * event attached (so the desktop scroll config can read `scrollAmount`), + * trackpad gesture steps as Pan events — see [TaoSceneScrollRouter]. */ fun onPointerScroll(event: TaoPointerScrollEvent) { + if (sceneBundle == null) return currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers - if (event.gesturePhase != TaoScrollGesturePhase.NONE) { - // Trackpad gesture step: Compose Pan events, not Scroll (#654). - trackpadPan.onGesture(event.gesturePhase, Offset(event.dxAwt, event.dyAwt)) - return - } - scene?.dispatchAwtShapedScroll( - x = pointerDeadband.x, - y = pointerDeadband.y, - event = event, - keyboardModifiers = currentKeyboardModifiers, - ) + scrollRouter.onScroll(pointerDeadband.x, pointerDeadband.y, event, currentKeyboardModifiers) } - // Deferred PanEnd timer of [trackpadPan]; lives on the Tao main thread - // like every other host callback. - private val trackpadPanScope = CoroutineScope(TaoMainDispatcher + SupervisorJob()) + private val scrollRouter = + TaoSceneScrollRouter( + object : TaoSceneScrollRouter.Target { + override val scene: ComposeScene? get() = this@TaoComposeSceneHost.scene + override val scale: Float get() = this@TaoComposeSceneHost.scale - /** - * Turns the macOS trackpad scroll gesture stream into Compose Pan events - * (#654). Offsets arrive in AWT wheel units and leave in pixels: one unit - * is `10.dp` — the factor Compose Desktop's `MacOSCocoaConfig` applies to - * a wheel notch — so a trackpad pan and a wheel scroll move content by the - * same amount, as they do under AWT. - */ - private val trackpadPan = - TaoTrackpadPanRouter( - schedule = { delayMillis, action -> - val job = - trackpadPanScope.launch { - delay(delayMillis) - exceptionHandler.catchExceptions(action) - } - ({ job.cancel() }) - }, - send = { type, panAwt -> - scene?.dispatchTrackpadPan( - x = pointerDeadband.x, - y = pointerDeadband.y, - type = type, - panOffset = panAwt * (AWT_PIXEL_TO_ROTATION * scale), - keyboardModifiers = currentKeyboardModifiers, - ) + override fun guard(block: () -> Unit) = exceptionHandler.catchExceptions(block) }, ) @@ -1594,7 +1557,7 @@ internal class TaoComposeSceneHost( window.imePreedit = null window.imeCommit = null imeSession.onInputSession(null) - trackpadPan.cancel() + scrollRouter.cancel() // The native cache keys on the NSView pointer; leaving it set would // let a later view allocated at the same address inherit this // window's text and caret. diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt new file mode 100644 index 000000000..a53fc82d5 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt @@ -0,0 +1,133 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers +import androidx.compose.ui.scene.ComposeScene +import dev.nucleusframework.window.tao.TaoPointerScrollEvent +import dev.nucleusframework.window.tao.TaoScrollGesturePhase +import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher +import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION +import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll +import dev.nucleusframework.window.tao.event.dispatchTrackpadPan +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Single front door for wheel and trackpad input into a [ComposeScene], + * shared by the macOS window host and both NSPanel popup hosts so a + * two-finger swipe behaves the same over a popup list and the window behind it. + * + * - A wheel notch or a phase-less precise scroll (smooth-scroll mice) becomes + * an AWT-shaped `Scroll` event ([dispatchAwtShapedScroll]). + * - A trackpad gesture step ([TaoPointerScrollEvent.gesturePhase] set) goes + * through [TaoTrackpadPanRouter] and reaches Compose as `PanStart` / + * `PanMove` / `PanEnd` (#654), the offset converted from AWT wheel units to + * pixels at `10.dp` per unit — the factor Compose Desktop's + * `MacOSCocoaConfig` applies to a wheel notch, so a pan and a notch move + * content by the same distance, as they do under AWT. + * + * Pan events are what Compose's `Modifier.scrollable` consumes for trackpads + * and what lets a map bind panning and zooming to different gestures. Code + * that only listens to `PointerEventType.Scroll` no longer sees trackpad + * input on this backend; until it handles Pan (see `PointerInputChange.panOffset`), + * `-Dnucleus.tao.trackpadPanEvents=false` restores the AWT-style behaviour + * where every gesture step is a `Scroll`. + * + * [schedule] is only supplied by tests; production routers own a coroutine + * scope on the UI dispatcher for the deferred `PanEnd`. UI thread only. + */ +@OptIn(InternalComposeUiApi::class) +internal class TaoSceneScrollRouter( + private val target: Target, + schedule: ((delayMillis: Long, action: () -> Unit) -> (() -> Unit))? = null, + private val panEnabled: Boolean = trackpadPanEventsEnabled, +) { + /** What the router needs from its host, read live at dispatch time. */ + interface Target { + val scene: ComposeScene? + + /** Px per dp of the scene, for the pan offset. */ + val scale: Float + + /** Wraps the deferred `PanEnd` delivery (exception handler, frame pump). */ + fun guard(block: () -> Unit) = block() + } + + private val scope: CoroutineScope? = + if (schedule == null) CoroutineScope(TaoMainDispatcher + SupervisorJob()) else null + + private val pan = + TaoTrackpadPanRouter( + schedule = schedule ?: ::scheduleOnMain, + send = ::sendPan, + ) + + // Where the pan is, in scene px, plus the modifiers of the last step; the + // deferred PanEnd has no event of its own to read them from. + private var x = 0f + private var y = 0f + private var keyboardModifiers = PointerKeyboardModifiers() + + fun onScroll( + x: Float, + y: Float, + event: TaoPointerScrollEvent, + keyboardModifiers: PointerKeyboardModifiers = PointerKeyboardModifiers(), + ) { + this.x = x + this.y = y + this.keyboardModifiers = keyboardModifiers + if (panEnabled && event.gesturePhase != TaoScrollGesturePhase.NONE) { + pan.onGesture(event.gesturePhase, Offset(event.dxAwt, event.dyAwt)) + } else { + target.scene?.dispatchAwtShapedScroll(x, y, event, keyboardModifiers) + } + } + + /** Teardown: drops the pending deferred end and the timer scope. */ + fun cancel() { + pan.cancel() + scope?.cancel() + } + + private fun sendPan( + type: PointerEventType, + panAwt: Offset, + ) { + target.scene?.dispatchTrackpadPan( + x = x, + y = y, + type = type, + panOffset = panAwt * (AWT_PIXEL_TO_ROTATION * target.scale), + keyboardModifiers = keyboardModifiers, + ) + } + + private fun scheduleOnMain( + delayMillis: Long, + action: () -> Unit, + ): () -> Unit { + val job = + requireNotNull(scope).launch { + delay(delayMillis) + target.guard(action) + } + return { job.cancel() } + } + + internal companion object { + /** + * `-Dnucleus.tao.trackpadPanEvents=false` sends trackpad gesture steps + * down the wheel path as AWT-shaped `Scroll` events instead of Compose + * Pan events — for apps whose custom pointer handlers only know + * `PointerEventType.Scroll`. Read once. + */ + val trackpadPanEventsEnabled: Boolean = + System.getProperty("nucleus.tao.trackpadPanEvents", "true").toBoolean() + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt index 0a9580133..8c40782fa 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt @@ -16,10 +16,14 @@ import dev.nucleusframework.window.tao.TaoScrollGesturePhase * therefore kept open across the momentum tail and closed on `MomentumEnded`. * AppKit does not say in advance whether a tail will follow, so the finger * `Ended` only *schedules* the `PanEnd` and a momentum event arriving within - * [MOMENTUM_GRACE_MILLIS] cancels it. By the time the pan really ends the - * tracked velocity is ~0 and Compose adds no fling of its own — the platform - * drives the inertia, exactly as under AWT where every step is a plain wheel - * event. + * [graceMillis] cancels it. By the time the pan really ends the tracked + * velocity is ~0 and Compose adds no fling of its own — the platform drives + * the inertia, exactly as under AWT where every step is a plain wheel event. + * + * A terminal step (`Ended`, `Cancelled`, `MomentumEnded`) that still carries a + * delta is delivered even when no pan is open — AppKit's `Ended` can hold the + * last finger movement, and a `Began` may have been missed (window became key + * mid-gesture) — so no scroll distance is ever dropped. * * [send] receives the pan offset in AWT `preciseWheelRotation` units (the * shape of [dev.nucleusframework.window.tao.TaoPointerScrollEvent.dxAwt]); the @@ -29,13 +33,11 @@ import dev.nucleusframework.window.tao.TaoScrollGesturePhase internal class TaoTrackpadPanRouter( private val schedule: (delayMillis: Long, action: () -> Unit) -> (() -> Unit), private val send: (type: PointerEventType, panAwt: Offset) -> Unit, + private val graceMillis: Long = momentumGraceMillis, ) { private var active = false private var cancelPendingEnd: (() -> Unit)? = null - /** True between `PanStart` and `PanEnd` (including a pending deferred end). */ - val isPanning: Boolean get() = active - fun onGesture( phase: Int, deltaAwt: Offset, @@ -54,11 +56,11 @@ internal class TaoTrackpadPanRouter( move(deltaAwt) } TaoScrollGesturePhase.ENDED -> { - if (!active) return move(deltaAwt) + if (!active) return clearPendingEnd() cancelPendingEnd = - schedule(MOMENTUM_GRACE_MILLIS) { + schedule(graceMillis) { cancelPendingEnd = null finish() } @@ -66,7 +68,6 @@ internal class TaoTrackpadPanRouter( TaoScrollGesturePhase.CANCELLED, TaoScrollGesturePhase.MOMENTUM_ENDED, -> { - if (!active) return move(deltaAwt) finish() } @@ -87,11 +88,14 @@ internal class TaoTrackpadPanRouter( send(PointerEventType.PanStart, Offset.Zero) } + /** Opens the pan if needed and moves it; a zero delta is not a move. */ private fun move(deltaAwt: Offset) { // Float compares, not `!= Offset.Zero`: the wire negation turns a // zero delta (Began / Ended steps) into -0.0, whose packed bits differ // from +0.0 and would leak zero-offset PanMoves into Compose. - if (deltaAwt.x != 0f || deltaAwt.y != 0f) send(PointerEventType.PanMove, deltaAwt) + if (deltaAwt.x == 0f && deltaAwt.y == 0f) return + start() + send(PointerEventType.PanMove, deltaAwt) } private fun finish() { @@ -108,9 +112,20 @@ internal class TaoTrackpadPanRouter( internal companion object { /** - * AppKit posts the first momentum event within a frame or two of the - * finger `Ended`; anything past this is a swipe with no inertia. + * How long a finger `Ended` waits for AppKit's momentum tail before the + * pan is closed. AppKit posts the first momentum event within a frame + * or two; the default leaves ample room and costs nothing for a swipe + * without inertia (its fling velocity is ~0 anyway). Override with + * `-Dnucleus.tao.trackpadMomentumGraceMillis=` if a machine ever + * shows a stacked fling at the end of a flick. */ - const val MOMENTUM_GRACE_MILLIS: Long = 100L + const val DEFAULT_MOMENTUM_GRACE_MILLIS: Long = 150L + + val momentumGraceMillis: Long = + System + .getProperty("nucleus.tao.trackpadMomentumGraceMillis") + ?.toLongOrNull() + ?.takeIf { it >= 0 } + ?: DEFAULT_MOMENTUM_GRACE_MILLIS } } diff --git a/decorated-window-tao/src/main/native/macos/popup_panel.m b/decorated-window-tao/src/main/native/macos/popup_panel.m index e3de08265..91cd8cd28 100644 --- a/decorated-window-tao/src/main/native/macos/popup_panel.m +++ b/decorated-window-tao/src/main/native/macos/popup_panel.m @@ -49,7 +49,7 @@ static JavaVM *sJVM = NULL; static jclass sCallbackClass = NULL; // global ref to the Java callback interface static jmethodID sOnPointerMethod = NULL; // (IFFII)V — type, x, y, button, modifiers -static jmethodID sOnScrollMethod = NULL; // (FFFFZ)V — x, y, dx, dy, precise +static jmethodID sOnScrollMethod = NULL; // (FFFFZI)V — x, y, dx, dy, precise, gesturePhase static jmethodID sOnKeyMethod = NULL; // (IIII)V — type, vkCode, codePoint, modifiers static jclass sOutsideListenerClass = NULL; static jmethodID sOutsideOnClickMethod = NULL; // (II)V — eventType, button @@ -70,7 +70,7 @@ static void ensureCallbackCache(JNIEnv *env, jobject cbSample, jobject outsideSa sCallbackClass = (*env)->NewGlobalRef(env, local); (*env)->DeleteLocalRef(env, local); sOnPointerMethod = (*env)->GetMethodID(env, sCallbackClass, "onPointerEvent", "(IFFII)V"); - sOnScrollMethod = (*env)->GetMethodID(env, sCallbackClass, "onScroll", "(FFFFZ)V"); + sOnScrollMethod = (*env)->GetMethodID(env, sCallbackClass, "onScroll", "(FFFFZI)V"); sOnKeyMethod = (*env)->GetMethodID(env, sCallbackClass, "onKeyEvent", "(IIII)V"); } } @@ -275,6 +275,30 @@ - (void)mouseDragged:(NSEvent *)event { [self dispatchPointer:event type:EVT_ - (void)rightMouseDown:(NSEvent *)event { [self maybeBecomeKey:event]; [self dispatchPointer:event type:EVT_PTR_DOWN button:2]; } - (void)rightMouseUp:(NSEvent *)event { [self dispatchPointer:event type:EVT_PTR_UP button:2]; } +/* Trackpad gesture phase of a scroll event, encoded like the vendored tao's + * `ScrollPhase` -> Kotlin `TaoScrollGesturePhase` (events.rs SCROLL_GESTURE_*), + * so a popup routes a two-finger swipe exactly like the window behind it (#654). + * AppKit sets `phase` for the fingers-on-glass part and `momentumPhase` for the + * inertial tail, never both; a wheel notch has neither (-1 = NONE). */ +static jint scrollGesturePhase(NSEvent *event) { + switch (event.phase) { + case NSEventPhaseMayBegin: return 7; + case NSEventPhaseBegan: return 0; + case NSEventPhaseChanged: + case NSEventPhaseStationary: return 1; + case NSEventPhaseEnded: return 2; + case NSEventPhaseCancelled: return 3; + default: break; + } + switch (event.momentumPhase) { + case NSEventPhaseBegan: return 4; + case NSEventPhaseChanged: return 5; + case NSEventPhaseEnded: + case NSEventPhaseCancelled: return 6; + default: return -1; + } +} + - (void)scrollWheel:(NSEvent *)event { jobject cb = [self takeCallbackOrNil]; if (cb == NULL) { [super scrollWheel:event]; return; } @@ -284,7 +308,8 @@ - (void)scrollWheel:(NSEvent *)event { [self pixelsForEvent:event outX:&x outY:&y]; (*env)->CallVoidMethod(env, cb, sOnScrollMethod, x, y, (jfloat)event.scrollingDeltaX, (jfloat)event.scrollingDeltaY, - event.hasPreciseScrollingDeltas ? JNI_TRUE : JNI_FALSE); + event.hasPreciseScrollingDeltas ? JNI_TRUE : JNI_FALSE, + scrollGesturePhase(event)); if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); } diff --git a/decorated-window-tao/src/main/native/src/event_loop.rs b/decorated-window-tao/src/main/native/src/event_loop.rs index 80eaefa4f..392ba119e 100644 --- a/decorated-window-tao/src/main/native/src/event_loop.rs +++ b/decorated-window-tao/src/main/native/src/event_loop.rs @@ -835,25 +835,14 @@ pub(crate) fn run_event_loop_blocking() { // `preciseWheelRotation` semantics so Compose's // `MacOSCocoaConfig` can apply its standard // `× 10dp × -scrollAmount` formula. AWT never scales - // by the display factor, so tao's physical `PixelDelta` - // goes back to logical points first (#653). + // by the display factor, so the vendored tao hands + // `PixelDelta` over in LOGICAL points (patch 0007, + // #653) — nothing to undo here. let (code, dx, dy) = match delta { MouseScrollDelta::LineDelta(x, y) => { (EVENT_SCROLL_LINE, x as f64, y as f64) } - MouseScrollDelta::PixelDelta(p) => { - let scale = WINDOWS - .lock() - .ok() - .and_then(|guard| { - guard.as_ref().and_then(|map| { - map.get(&handle).map(|w| w.scale_factor()) - }) - }) - .unwrap_or(1.0); - let logical = p.to_logical::(scale); - (EVENT_SCROLL_PIXEL, logical.x, logical.y) - } + MouseScrollDelta::PixelDelta(p) => (EVENT_SCROLL_PIXEL, p.x, p.y), _ => return, }; let dx_fixed = (dx * SCROLL_FIXED_SCALE) as jint; diff --git a/decorated-window-tao/src/main/native/src/events.rs b/decorated-window-tao/src/main/native/src/events.rs index 7e2c7225e..841e92f7f 100644 --- a/decorated-window-tao/src/main/native/src/events.rs +++ b/decorated-window-tao/src/main/native/src/events.rs @@ -147,9 +147,9 @@ pub(crate) const EVENT_WINDOW_READY: jint = 16; // a = width, b = height (logica // (positive = content moves down / right, i.e. AppKit's); the JVM negates. pub(crate) const EVENT_SCROLL_LINE: jint = 17; // a = dx * SCROLL_FIXED_SCALE, b = dy * SCROLL_FIXED_SCALE -// a/b = LOGICAL points (AppKit `scrollingDelta*`) * SCROLL_FIXED_SCALE — tao's -// `PixelDelta` is converted back from physical pixels in the loop because AWT -// never applies the display scale to `preciseWheelRotation` (Nucleus #653). +// a/b = LOGICAL points (AppKit `scrollingDelta*`) * SCROLL_FIXED_SCALE — the +// vendored tao (patch 0007) leaves `PixelDelta` in points because AWT never +// applies the display scale to `preciseWheelRotation` (Nucleus #653). pub(crate) const EVENT_SCROLL_PIXEL: jint = 18; // Trackpad scroll gesture phases (`EventCallback.onScrollGesture`); mirror // Kotlin `TaoScrollGesturePhase`. A precise scroll that belongs to a gesture diff --git a/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch index a8108d1c9..801986a5f 100644 --- a/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch +++ b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch @@ -81,12 +81,15 @@ index 9d0ab3fb..6cf94fb6 100644 }, }) { diff --git a/src/platform_impl/macos/view.rs b/src/platform_impl/macos/view.rs -index b0fb8472..f3b19e96 100644 +index b0fb8472..e6f1e8d5 100644 --- a/src/platform_impl/macos/view.rs +++ b/src/platform_impl/macos/view.rs -@@ -33,7 +33,8 @@ use once_cell::sync::Lazy; +@@ -31,9 +31,10 @@ use objc2_foundation::{ + use once_cell::sync::Lazy; + use crate::{ - dpi::LogicalPosition, +- dpi::LogicalPosition, ++ dpi::{LogicalPosition, PhysicalPosition}, event::{ - DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent, + DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, ScrollPhase, TouchPhase, @@ -94,7 +97,7 @@ index b0fb8472..f3b19e96 100644 }, keyboard::{KeyCode, ModifiersState}, platform_impl::platform::{ -@@ -1263,8 +1264,13 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { +@@ -1263,11 +1264,21 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { let state = &mut *(state_ptr as *mut ViewState); let delta = { @@ -108,9 +111,19 @@ index b0fb8472..f3b19e96 100644 + // #652). Same as winit. + let (x, y) = (event.scrollingDeltaX(), event.scrollingDeltaY()); if event.hasPreciseScrollingDeltas() { - let delta = LogicalPosition::new(x, y).to_physical(state.get_scale_factor()); - MouseScrollDelta::PixelDelta(delta) -@@ -1277,6 +1283,23 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { +- let delta = LogicalPosition::new(x, y).to_physical(state.get_scale_factor()); +- MouseScrollDelta::PixelDelta(delta) ++ // PATCH(nucleus): carry AppKit's LOGICAL points as-is instead of ++ // multiplying by the view's cached backing scale. The only consumer ++ // (the Nucleus loop) wants points — AWT's `preciseWheelRotation` is ++ // `scrollingDelta / 10` with no display scale (Nucleus #653) — and ++ // converting back with a second, independently cached scale can ++ // disagree with this one for a frame during a display hop. ++ MouseScrollDelta::PixelDelta(PhysicalPosition::new(x, y)) + } else { + MouseScrollDelta::LineDelta(x as f32, y as f32) + } +@@ -1277,6 +1288,23 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { NSEventPhase::Ended => TouchPhase::Ended, _ => TouchPhase::Moved, }; @@ -134,7 +147,7 @@ index b0fb8472..f3b19e96 100644 let device_event = Event::DeviceEvent { device_id: DEVICE_ID, -@@ -1294,6 +1317,7 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { +@@ -1294,6 +1322,7 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { device_id: DEVICE_ID, delta, phase, diff --git a/decorated-window-tao/src/main/native/vendor/tao-patches/README.md b/decorated-window-tao/src/main/native/vendor/tao-patches/README.md index 63399febb..4485214a1 100644 --- a/decorated-window-tao/src/main/native/vendor/tao-patches/README.md +++ b/decorated-window-tao/src/main/native/vendor/tao-patches/README.md @@ -20,7 +20,7 @@ Tao 0.35.0 is already vendored; this file is the living list of patches. | 0004 | `0004-linux-drain-draw-queue.patch` | 4 | Linux | `run_return`: treat pending redraws like pending events (don't park in the blocking `gtk_main_iteration` while `draws` is non-empty) and drain the whole draw channel per cycle instead of one redraw per wakeup. Fixes multi-window frame starvation (each window rendered at ~refresh/N). | | 0005 | `0005-linux-restore-activation-timestamp.patch` | 5 | Linux | Stamp `Focus` and `Minimized(false)` activations with a real X server timestamp (`gdk_x11_get_server_time`). Mutter's focus-stealing prevention drops `_NET_ACTIVE_WINDOW` requests carrying `GDK_CURRENT_TIME` (0) and keeps a deiconified window Iconic with `_NET_WM_STATE_DEMANDS_ATTENTION`, so restore/focus silently no-op and `EVENT_MINIMIZED(false)` never fires on GNOME X11/XWayland (openbox honors the 0 timestamp, which is why CI never saw it). No-op on Wayland. | | 0006 | `0006-linux-cursor-ignore-events-region.patch` | 6 | Linux | `CursorIgnoreEvents`: install a genuinely *empty* input region instead of upstream's 1x1 rectangle at the origin (which leaves the top-left pixel clickable), and clear it through the same `GdkWindow` with a NULL region. Upstream cleared it on the `GtkWidget`, which never undid a shape installed on the `GdkWindow`, so click-through could not be switched back off. | -| 0007 | `0007-macos-scroll-phase-and-horizontal-sign.patch` | 7 | macOS (+ field on all backends) | `WindowEvent::MouseWheel` gains `scroll_phase: ScrollPhase` — the full AppKit `phase` / `momentumPhase` of a trackpad scroll (`None` for a wheel, and on Windows / Linux), which `TouchPhase` cannot express; Nucleus routes gesture steps to Compose Pan events (#654). Also stops negating `scrollingDeltaX` in `scroll_wheel`: AppKit's sign already matches `MouseScrollDelta`'s documented convention (and winit), and the extra flip reversed horizontal trackpad scrolling once the consumer applied the AWT convention (#652). | +| 0007 | `0007-macos-scroll-phase-and-horizontal-sign.patch` | 7 | macOS (+ field on all backends) | `WindowEvent::MouseWheel` gains `scroll_phase: ScrollPhase` — the full AppKit `phase` / `momentumPhase` of a trackpad scroll (`None` for a wheel, and on Windows / Linux), which `TouchPhase` cannot express; Nucleus routes gesture steps to Compose Pan events (#654). Also stops negating `scrollingDeltaX` in `scroll_wheel`: AppKit's sign already matches `MouseScrollDelta`'s documented convention (and winit), and the extra flip reversed horizontal trackpad scrolling once the consumer applied the AWT convention (#652). `PixelDelta` carries AppKit's logical points instead of `x backing scale`: AWT's `preciseWheelRotation` never sees the display scale (#653), and undoing the multiplication downstream with a second scale cache disagreed with the view's for a frame during display hops. | ## Bump procedure (e.g. 0.35 → 0.36) diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs index f3b19e96a..e6f1e8d5d 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs @@ -31,7 +31,7 @@ use objc2_foundation::{ use once_cell::sync::Lazy; use crate::{ - dpi::LogicalPosition, + dpi::{LogicalPosition, PhysicalPosition}, event::{ DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, ScrollPhase, TouchPhase, WindowEvent, @@ -1272,8 +1272,13 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { // #652). Same as winit. let (x, y) = (event.scrollingDeltaX(), event.scrollingDeltaY()); if event.hasPreciseScrollingDeltas() { - let delta = LogicalPosition::new(x, y).to_physical(state.get_scale_factor()); - MouseScrollDelta::PixelDelta(delta) + // PATCH(nucleus): carry AppKit's LOGICAL points as-is instead of + // multiplying by the view's cached backing scale. The only consumer + // (the Nucleus loop) wants points — AWT's `preciseWheelRotation` is + // `scrollingDelta / 10` with no display scale (Nucleus #653) — and + // converting back with a second, independently cached scale can + // disagree with this one for a frame during a display hop. + MouseScrollDelta::PixelDelta(PhysicalPosition::new(x, y)) } else { MouseScrollDelta::LineDelta(x as f32, y as f32) } diff --git a/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json b/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json index 305ab2f59..56360162f 100644 --- a/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json +++ b/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json @@ -353,7 +353,7 @@ "jniAccessible": true, "methods": [ { "name": "onPointerEvent", "parameterTypes": ["int","float","float","int","int"] }, - { "name": "onScroll", "parameterTypes": ["float","float","float","float","boolean"] }, + { "name": "onScroll", "parameterTypes": ["float","float","float","float","boolean","int"] }, { "name": "onKeyEvent", "parameterTypes": ["int","int","int","int"] } ] }, @@ -409,7 +409,7 @@ "jniAccessible": true, "methods": [ { "name": "onPointerEvent", "parameterTypes": ["int","float","float","int","int"] }, - { "name": "onScroll", "parameterTypes": ["float","float","float","float","boolean"] }, + { "name": "onScroll", "parameterTypes": ["float","float","float","float","boolean","int"] }, { "name": "onKeyEvent", "parameterTypes": ["int","int","int","int"] } ] }, diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index 7172eadd2..cebaaac42 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -144,6 +144,9 @@ public object TaoSceneTestBattery { run("MacOsWheelDeltaTest: preciseDeltaCarriesMacOsScrollAmount") { MacOsWheelDeltaTest().preciseDeltaCarriesMacOsScrollAmount() } + run("MacOsWheelDeltaTest: gesturePhaseRidesAlongForPreciseEventsOnly") { + MacOsWheelDeltaTest().gesturePhaseRidesAlongForPreciseEventsOnly() + } run("StandaloneFramePumpTest: scheduleOnMainRunsInline") { StandaloneFramePumpTest().scheduleOnMainRunsInline() } @@ -198,6 +201,9 @@ public object TaoSceneTestBattery { run("TaoTrackpadPanRouterTest: swipe without momentum ends after the grace period") { TaoTrackpadPanRouterTest().`swipe without momentum ends after the grace period`() } + run("TaoTrackpadPanRouterTest: terminal steps carrying a delta still pan when no gesture is open") { + TaoTrackpadPanRouterTest().`terminal steps carrying a delta still pan when no gesture is open`() + } run("TaoTrackpadPanRouterTest: momentum tail continues the pan and ends it once") { TaoTrackpadPanRouterTest().`momentum tail continues the pan and ends it once`() } @@ -389,6 +395,12 @@ public object TaoSceneTestBattery { run("TaoSceneTrackpadPanTest: pan moves content by its pixel offset") { TaoSceneTrackpadPanTest().`pan moves content by its pixel offset`() } + run("TaoSceneTrackpadPanTest: routed gesture steps pan a column and close after the grace") { + TaoSceneTrackpadPanTest().`routed gesture steps pan a column and close after the grace`() + } + run("TaoSceneTrackpadPanTest: with pan events disabled gesture steps scroll as wheel events") { + TaoSceneTrackpadPanTest().`with pan events disabled gesture steps scroll as wheel events`() + } run("TaoScenePopupTest: popup renders above the window content") { TaoScenePopupTest().`popup renders above the window content`() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index dcc372af6..0bb8d4089 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt @@ -100,6 +100,8 @@ class TaoSceneTestBatteryDriftTest { TaoSceneRectManagerRaceTest::class.java to "races the real AWT EDT against wall-clock frames; the no-AWT image never initialises AWT", TaoTransferableAccessGuardTest::class.java to "Compose interop ABI guard, not a scene behaviour", + dev.nucleusframework.window.tao.popup.PopupPanelJniSignatureDriftTest::class.java to + "reads popup_panel.m from the repo; JNI descriptor guard, not a scene behaviour", dev.nucleusframework.window.tao.scene.TaoKeepScreenOnTest::class.java to "acquires real EnergyManager awake handles against the host OS", TaoSceneTestBatteryDriftTest::class.java to "meta-test for the battery itself", diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt index 0f06164cf..19708e551 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.window.tao.event +import dev.nucleusframework.window.tao.TaoScrollGesturePhase import kotlin.test.Test import kotlin.test.assertEquals @@ -52,6 +53,23 @@ class MacOsWheelDeltaTest { assertEquals(MACOS_AWT_SCROLL_AMOUNT, event.scrollAmount) } + @Test + fun gesturePhaseRidesAlongForPreciseEventsOnly() { + // Popups forward the AppKit phase; a wheel notch can never be a gesture step. + val step = + appKitWheelToAwtScrollEvent( + dx = 0f, + dy = -10f, + precise = true, + gesturePhase = TaoScrollGesturePhase.CHANGED, + ) + assertEquals(TaoScrollGesturePhase.CHANGED, step.gesturePhase) + assertEquals(1f, step.dyAwt, absoluteTolerance = 0f) + val notch = + appKitWheelToAwtScrollEvent(dx = 0f, dy = 1f, precise = false, gesturePhase = TaoScrollGesturePhase.CHANGED) + assertEquals(TaoScrollGesturePhase.NONE, notch.gesturePhase) + } + @Test fun preciseDeltaCarriesMacOsScrollAmount() { val event = appKitWheelToAwtScrollEvent(dx = 0f, dy = 10f, precise = true) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt index f77457fe5..5bb088970 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt @@ -73,6 +73,7 @@ internal object MacOsTrackpadScrollHeadfulCases { awaitUntil("window mapped") { bounds() != null } awaitUntil("row has overflow") { scrollMax.get() > 0 } settle() + recorder.reset() swipe(dx = -SWIPE_DELTA_PT, dy = 0f, steps = SWIPE_STEPS, momentum = false) waitFor(SCROLL_REACTION_MILLIS) { scrollPx.get() != 0 } check(scrollPx.get() > 0) { @@ -108,9 +109,13 @@ internal object MacOsTrackpadScrollHeadfulCases { // The doubling only shows on a HiDPI display: flip the screen to // its 2x twin for the duration of the case when the window sits on // a 1x display (same trick as the #507 probe), else run as-is. + // Decided before the try so the finally restores the mode even + // when the window never reports the new scale. val baseScale = window.scaleFactor - val switched = baseScale < HIDPI && switchDisplayTo2x() + val switched = baseScale < HIDPI && hiDpiSwitchAvailable() try { + if (switched) switchDisplayTo2x() + recorder.reset() val scale = window.scaleFactor System.err.println("[probe] window scale factor = $scale (switched to HiDPI: $switched)") @@ -138,21 +143,22 @@ internal object MacOsTrackpadScrollHeadfulCases { } /** - * Flips the main display to the HiDPI twin of its current mode and waits - * for the window to report the new backing scale. `false` (and a log line) - * when the helper or a twin mode is unavailable — the case then runs at - * the current scale, which still checks the sign. + * Whether the main display can be flipped to the HiDPI twin of its current + * mode. `false` (and a log line) when the helper or a twin mode is + * unavailable — the case then runs at the current scale, which still + * checks the sign. */ - private suspend fun TaoWindowTestScope.switchDisplayTo2x(): Boolean { - val unavailable = MacDisplayModeTool.unavailableReason() - if (unavailable != null) { - System.err.println("[probe] HiDPI switch unavailable: $unavailable") - return false - } + private fun hiDpiSwitchAvailable(): Boolean { + val unavailable = MacDisplayModeTool.unavailableReason() ?: return true + System.err.println("[probe] HiDPI switch unavailable: $unavailable") + return false + } + + /** Flips the display and waits for the window to report the new backing scale. */ + private suspend fun TaoWindowTestScope.switchDisplayTo2x() { System.err.println("[probe] setmode 2x -> ${MacDisplayModeTool.run("2x")}") awaitUntil("window reports a HiDPI backing scale") { window.scaleFactor >= HIDPI } settle(DISPLAY_SETTLE_MILLIS) - return true } private suspend fun TaoWindowTestScope.restoreDisplayTo1x(baseScale: Float) { @@ -184,6 +190,7 @@ internal object MacOsTrackpadScrollHeadfulCases { settle() val scale = window.scaleFactor + recorder.reset() swipe(dx = 0f, dy = -SWIPE_DELTA_PT, steps = SWIPE_STEPS, momentum = true) waitFor(PAN_END_MILLIS) { recorder.count(PointerEventType.PanEnd) >= 1 } val gesture = recorder.snapshot() @@ -252,6 +259,7 @@ internal object MacOsTrackpadScrollHeadfulCases { awaitUntil("window mapped") { bounds() != null } awaitUntil("column has overflow") { scrollMax.get() > 0 } settle() + recorder.reset() swipe(dx = 0f, dy = -SWIPE_DELTA_PT, steps = SWIPE_STEPS, momentum = false) waitFor(SCROLL_REACTION_MILLIS) { scrollPx.get() > 0 } check(scrollPx.get() > 0) { @@ -352,6 +360,9 @@ internal object MacOsTrackpadScrollHeadfulCases { fun snapshot(): List = synchronized(events) { events.toList() } + /** Cases share their recorder with the registry; start each run clean. */ + fun reset() = events.clear() + fun count(type: PointerEventType): Int = snapshot().count { it.type == type } fun describe(): String = snapshot().joinToString(prefix = "[", postfix = "]") diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupPanelJniSignatureDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupPanelJniSignatureDriftTest.kt new file mode 100644 index 000000000..56a5bdbbf --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupPanelJniSignatureDriftTest.kt @@ -0,0 +1,56 @@ +package dev.nucleusframework.window.tao.popup + +import dev.nucleusframework.window.tao.ffi.PopupNativeBridge +import java.io.File +import java.lang.reflect.Method +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `popup_panel.m` resolves the [PopupNativeBridge.EventCallback] methods by + * hand-written JNI descriptors (`GetMethodID(..., "onScroll", "(FFFFZI)V")`). + * A descriptor that drifts from the Kotlin signature fails silently at run + * time: the lookup throws, the callback cache never initialises and the popup + * simply stops receiving input. Compare the two here, where it is loud. + */ +class PopupPanelJniSignatureDriftTest { + @Test + fun `popup_panel m GetMethodID descriptors match the Kotlin callback`() { + val source = File("src/main/native/macos/popup_panel.m") + assertTrue(source.isFile, "expected ${source.absolutePath} (run from the module directory)") + val declared = + GET_METHOD_ID + .findAll(source.readText()) + .associate { it.groupValues[1] to it.groupValues[2] } + .filterKeys { it != "onOutsideClick" } // lives on a different listener class + assertTrue(declared.isNotEmpty(), "no GetMethodID(...) found in popup_panel.m") + + val callback = PopupNativeBridge.EventCallback::class.java + declared.forEach { (name, descriptor) -> + val method = + callback.methods.singleOrNull { it.name == name } + ?: error("popup_panel.m looks up '$name' but EventCallback has no single method of that name") + assertEquals(descriptor, method.jniDescriptor(), "JNI descriptor of EventCallback.$name") + } + } + + private fun Method.jniDescriptor(): String = + parameterTypes.joinToString(prefix = "(", postfix = ")", separator = "") { it.descriptor() } + + returnType.descriptor() + + private fun Class<*>.descriptor(): String = + when (this) { + Void.TYPE -> "V" + java.lang.Boolean.TYPE -> "Z" + java.lang.Integer.TYPE -> "I" + java.lang.Long.TYPE -> "J" + java.lang.Float.TYPE -> "F" + java.lang.Double.TYPE -> "D" + else -> "L${name.replace('.', '/')};" + } + + private companion object { + val GET_METHOD_ID = Regex("""GetMethodID\(env,\s*\w+,\s*"(\w+)",\s*"([^"]+)"\)""") + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt index 56216a935..3f4e4465f 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt @@ -284,6 +284,26 @@ internal class TaoSceneTestScope( private var isPressed = false private var modifierState = 0 + // The deferred PanEnd of the scroll routers, fired by hand (see elapsePanGrace). + private var pendingPanEnd: (() -> Unit)? = null + + private val scrollTarget = + object : TaoSceneScrollRouter.Target { + override val scene: ComposeScene get() = this@TaoSceneTestScope.scene + override val scale: Float get() = density + } + + private fun manualSchedule( + @Suppress("UNUSED_PARAMETER") delayMillis: Long, + action: () -> Unit, + ): () -> Unit { + pendingPanEnd = action + return { if (pendingPanEnd === action) pendingPanEnd = null } + } + + private val scrollRouter = TaoSceneScrollRouter(scrollTarget, ::manualSchedule, panEnabled = true) + private val legacyScrollRouter = TaoSceneScrollRouter(scrollTarget, ::manualSchedule, panEnabled = false) + var lastPicture: Picture? = null private set @@ -443,6 +463,29 @@ internal class TaoSceneTestScope( frame() } + /** + * Full production scroll routing (`TaoSceneScrollRouter`, as the macOS + * hosts call it from `onPointerScroll` / popup `onScroll`): wheel notches + * become Scroll, trackpad gesture steps become Pan — or Scroll too when + * [panEvents] is false, mirroring `-Dnucleus.tao.trackpadPanEvents=false`. + */ + fun routeScroll( + event: TaoPointerScrollEvent, + panEvents: Boolean = true, + ) { + val router = if (panEvents) scrollRouter else legacyScrollRouter + router.onScroll(pointerDeadband.x, pointerDeadband.y, event, taoKeyboardModifiers(modifierState)) + frame() + } + + /** Fires the deferred PanEnd the momentum grace timer would. */ + fun elapsePanGrace() { + val action = pendingPanEnd ?: return + pendingPanEnd = null + action() + frame() + } + /** * Mirrors the scene host's trackpad pan dispatch (`dispatchTrackpadPan`, * #654): [panOffsetPx] is in pixels with Compose's sign — positive = diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt index 1a5f2b355..2dbe5b6a8 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt @@ -16,8 +16,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.TaoPointerScrollEvent +import dev.nucleusframework.window.tao.TaoScrollGesturePhase import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -108,6 +112,86 @@ class TaoSceneTrackpadPanTest { assertEquals(BLUE, pixelAt(50, 50), "after a 100 px pan the blue block must fill the viewport") } + @Test + fun `routed gesture steps pan a column and close after the grace`() = + runTaoSceneTest(width = 100, height = 200) { + val scrollValue = mutableStateOf(0) + val seen = mutableListOf() + setContent { + val state = rememberScrollState() + scrollValue.value = state.value + Column(Modifier.fillMaxSize().verticalScroll(state).recording(seen)) { + repeat(50) { Box(Modifier.fillMaxWidth().height(20.dp)) } + } + } + moveMouse(50f, 100f) + // Fingers up (AppKit -10 pt → AWT +1) three times, then lift. + routeScroll(gestureStep(TaoScrollGesturePhase.BEGAN, dyAwt = 0f)) + repeat(3) { routeScroll(gestureStep(TaoScrollGesturePhase.CHANGED, dyAwt = 1f)) } + routeScroll(gestureStep(TaoScrollGesturePhase.ENDED, dyAwt = 0f)) + frameUntilIdle() + assertTrue(scrollValue.value > 0, "routed pan must scroll the column (got ${scrollValue.value})") + assertEquals( + listOf( + PointerEventType.PanStart, + PointerEventType.PanMove, + PointerEventType.PanMove, + PointerEventType.PanMove, + ), + seen.toList(), + "a gesture must reach Compose as Pan, never as Scroll", + ) + elapsePanGrace() + assertEquals(PointerEventType.PanEnd, seen.last(), "the deferred PanEnd must close the gesture") + } + + @Test + fun `with pan events disabled gesture steps scroll as wheel events`() = + runTaoSceneTest(width = 100, height = 200) { + val scrollValue = mutableStateOf(0) + val seen = mutableListOf() + setContent { + val state = rememberScrollState() + scrollValue.value = state.value + Column(Modifier.fillMaxSize().verticalScroll(state).recording(seen)) { + repeat(50) { Box(Modifier.fillMaxWidth().height(20.dp)) } + } + } + moveMouse(50f, 100f) + routeScroll(gestureStep(TaoScrollGesturePhase.BEGAN, dyAwt = 0f), panEvents = false) + repeat(3) { routeScroll(gestureStep(TaoScrollGesturePhase.CHANGED, dyAwt = 1f), panEvents = false) } + routeScroll(gestureStep(TaoScrollGesturePhase.ENDED, dyAwt = 0f), panEvents = false) + frameUntilIdle() + assertTrue(scrollValue.value > 0, "legacy routing must still scroll the column (got ${scrollValue.value})") + assertTrue( + seen.isNotEmpty() && seen.all { it == PointerEventType.Scroll }, + "expected Scroll only, got $seen", + ) + } + + private fun gestureStep( + phase: Int, + dyAwt: Float, + ) = TaoPointerScrollEvent(dxAwt = 0f, dyAwt = dyAwt, scrollAmount = 1, gesturePhase = phase) + + /** Records Scroll / Pan event types seen on the Initial pass, without consuming. */ + private fun Modifier.recording(seen: MutableList): Modifier = + pointerInput(seen) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + when (event.type) { + PointerEventType.Scroll, + PointerEventType.PanStart, + PointerEventType.PanMove, + PointerEventType.PanEnd, + -> seen += event.type + else -> Unit + } + } + } + } + private companion object { const val RED = 0xFFFF0000.toInt() const val BLUE = 0xFF0000FF.toInt() diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt index 9ab788d01..1c64079cd 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt @@ -18,10 +18,12 @@ class TaoTrackpadPanRouterTest { val sent = mutableListOf>() private var pending: (() -> Unit)? = null var cancelled = 0 + var lastDelayMillis = -1L val router = TaoTrackpadPanRouter( - schedule = { _, action -> + schedule = { delayMillis, action -> + lastDelayMillis = delayMillis pending = action ( { @@ -56,14 +58,34 @@ class TaoTrackpadPanRouterTest { assertEquals(listOf(PointerEventType.PanStart, PointerEventType.PanMove), h.types()) assertTrue(h.hasPendingEnd, "Ended must only schedule the PanEnd") - assertTrue(h.router.isPanning) + assertEquals(TaoTrackpadPanRouter.momentumGraceMillis, h.lastDelayMillis) h.elapseGrace() assertEquals( listOf(PointerEventType.PanStart, PointerEventType.PanMove, PointerEventType.PanEnd), h.types(), ) - assertFalse(h.router.isPanning) + } + + @Test + fun `terminal steps carrying a delta still pan when no gesture is open`() { + // AppKit's Ended can hold the last finger movement, and the Began may + // have been missed (window became key mid-gesture): the distance must + // not be dropped. + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.ENDED, down) + assertEquals(listOf(PointerEventType.PanStart, PointerEventType.PanMove), h.types()) + assertTrue(h.hasPendingEnd) + h.elapseGrace() + assertEquals(PointerEventType.PanEnd, h.types().last()) + + h.sent.clear() + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_ENDED, down) + assertEquals( + listOf(PointerEventType.PanStart, PointerEventType.PanMove, PointerEventType.PanEnd), + h.types(), + ) + assertFalse(h.hasPendingEnd) } @Test @@ -150,7 +172,6 @@ class TaoTrackpadPanRouterTest { h.router.cancel() assertFalse(h.hasPendingEnd) - assertFalse(h.router.isPanning) h.elapseGrace() assertEquals(listOf(PointerEventType.PanStart), h.types()) } diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt index 2b646f48f..9824902bc 100644 --- a/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt @@ -178,8 +178,18 @@ fun ScrollTestScreen() { // consumes it. We never consume — scrolling // must still happen normally. val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.type != PointerEventType.Scroll) continue - val d = event.changes.first().scrollDelta + // Wheel notches arrive as Scroll (AWT wheel units); + // on the Tao backend a trackpad gesture arrives as + // Pan with a pixel offset — 10 dp per wheel unit + // (Compose's MacOSCocoaConfig factor), so both are + // logged in the same unit. + val change = event.changes.first() + val d = + when (event.type) { + PointerEventType.Scroll -> change.scrollDelta + PointerEventType.PanMove -> change.panOffset / (10f * density) + else -> continue + } val now = System.nanoTime() / 1_000_000 if (!meter.inGesture || now - meter.lastTimeMs > IDLE_MS) { meter.inGesture = true From 980eb8890d2c08698d56503f962ce42775a7cc96 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 11:53:30 +0300 Subject: [PATCH 3/7] fix(tao): bound every trackpad pan, keep popups and native views in step (review 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 1 + README.md | 8 ++ decorated-window-tao/build.gradle.kts | 3 + .../nucleusframework/window/tao/NativeView.kt | 15 ++- .../window/tao/TaoEventConstants.kt | 46 +++++---- .../nucleusframework/window/tao/TaoWindow.kt | 17 ++-- .../window/tao/event/MacOsWheelDelta.kt | 9 +- .../window/tao/popup/TaoPopupSceneLayer.kt | 5 +- .../tao/popup/TaoStandalonePopupHostMac.kt | 11 ++- .../window/tao/scene/TaoComposeSceneHost.kt | 3 + .../window/tao/scene/TaoSceneScrollRouter.kt | 65 ++++++++++--- .../window/tao/scene/TaoTrackpadPanRouter.kt | 56 +++++++---- .../src/main/native/macos/NucleusTaoMetal.m | 21 ++++- .../src/main/native/macos/popup_panel.m | 41 +++++--- .../src/main/native/src/event_loop.rs | 59 ++++++++---- .../src/main/native/src/events.rs | 5 +- ...cos-scroll-phase-and-horizontal-sign.patch | 14 ++- .../tao/src/platform_impl/macos/view.rs | 3 - .../window/tao/TaoSceneTestBattery.kt | 12 +++ .../tao/TaoSceneTestBatteryDriftTest.kt | 4 +- .../window/tao/TaoScrollWireDriftTest.kt | 93 +++++++++++++++++++ .../window/tao/TaoWindowScrollTest.kt | 30 ++++-- .../window/tao/event/MacOsWheelDeltaTest.kt | 14 +-- .../popup/PopupPanelJniSignatureDriftTest.kt | 56 ----------- .../window/tao/scene/TaoSceneTestHarness.kt | 5 + .../tao/scene/TaoSceneTrackpadPanTest.kt | 2 +- .../tao/scene/TaoTrackpadPanRouterTest.kt | 66 +++++++++++-- 27 files changed, 470 insertions(+), 194 deletions(-) create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt delete mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupPanelJniSignatureDriftTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 363999a0c..9c4a23770 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ Published releases are `2.4.x` (latest tag `v2.4.4`). Do not treat `IDEAL_API.md - **KDoc on public API**: `UndocumentedPublicClass` / `UndocumentedPublicFunction` are enforced by detekt (`detekt` is wired into `check` / `preMerge`). Pre-existing gaps are grandfathered in per-module `/detekt-baseline.xml` files — any *new* undocumented public class or function fails the build. Do not regenerate a baseline to silence a new finding; write the KDoc. `UndocumentedPublicProperty` stays off because the generated icon/symbol catalogs (`sf-symbols`, `freedesktop-icons`) would swamp it - **Logging**: `java.util.logging` is the single facade for every runtime module — no SLF4J dependency forced on consumers, no raw `println` / `System.err` in `src/main`. Logger names must be the fully-qualified class name (or an explicit `dev.nucleusframework.*` string) so the whole framework sits under one JUL namespace. `allowNucleusRuntimeLogging = true` is an opt-in convenience that raises the `dev.nucleusframework` logger to `nucleusLoggingLevel` and attaches a colored console handler; apps that configure JUL themselves (`logging.properties`, `jul-to-slf4j`) leave it `false` and Nucleus never touches the JUL configuration - `decorated-window-tao` is the recommended backend for new projects (no AWT, native event-loop-driven, true Windows fullscreen, GraalVM native-image first-class). `decorated-window-jni` and `decorated-window-jbr` (the AWT-based backends) are legacy/maintenance-only +- **macOS trackpad on Tao** (#652–#654): scroll deltas are AWT-shaped (`preciseWheelRotation`, no display scale). Trackpad gestures reach Compose as `PanStart` / `PanMove` / `PanEnd` (`panOffset` = AWT delta × 10 dp), wheel notches as `Scroll`; foundation's `Modifier.scrollable` handles both. Custom handlers that only listen for `PointerEventType.Scroll` must also handle Pan, or the app can set `-Dnucleus.tao.trackpadPanEvents=false` to get AWT-style `Scroll` for everything. Everything scroll-related enters the scene through `TaoSceneScrollRouter` (window + NSPanel popups); the phase wire (Rust `SCROLL_GESTURE_*`, `popup_panel.m`, `TaoScrollGesturePhase`) is guarded by `TaoScrollWireDriftTest` - macOS Liquid Glass enabled by default via `macOsSdkVersion = "26.0"` (vtool SDK patching) - The HotSpot GC is selected type-safely with `application { garbageCollector = GarbageCollector.Z }` (unset = JVM ergonomics). The flags are prepended to the launcher `.cfg` java-options and to the `run` task — before `jvmArgs`, so an explicit `-XX:+Use…GC` there still wins — and the AOT training run inherits them from the `.cfg` diff --git a/README.md b/README.md index a665f38db..457dcdae0 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,14 @@ otherwise AWT). Inside the block you can call `onDeepLink { }` and `aotTraining()`; plugin-injected metadata is `NucleusApp`, not a generated constants object. +On macOS the Tao backend delivers trackpad gestures to Compose as pan events +(`PointerEventType.PanStart` / `PanMove` / `PanEnd`, with `panOffset` in +pixels) and mouse-wheel notches as `Scroll`, with the same distances the AWT +backend produces. Foundation's `Modifier.scrollable` handles both; a custom +`pointerInput` that only reacts to `PointerEventType.Scroll` must also handle +pan, or start the app with `-Dnucleus.tao.trackpadPanEvents=false` to receive +AWT-style `Scroll` events for everything. + Then configure packaging in `build.gradle.kts`: ```kotlin diff --git a/decorated-window-tao/build.gradle.kts b/decorated-window-tao/build.gradle.kts index 388ff2219..a6add5433 100644 --- a/decorated-window-tao/build.gradle.kts +++ b/decorated-window-tao/build.gradle.kts @@ -128,6 +128,9 @@ val taoHeadfulTest by tasks.registering(JavaExec::class) { // Unattended: a fatal must fail the suite loudly, not block in the #622 // native dialog until the global watchdog halts and eats the real result. systemProperty("nucleus.tao.fatalErrorDialog", "false") + // Arms the macOS scrollWheel: injector (nativeDiagInjectScrollWheel) the + // trackpad cases drive; it is inert in any process without this variable. + environment("NUCLEUS_TAO_INPUT_INJECTION", "1") // Same Kover JVM agent the `test` task uses, so headful window coverage // is counted. JavaExec is otherwise invisible to Kover. dependsOn(tasks.named("koverFindJar")) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt index 7fbd83694..337336a33 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt @@ -275,15 +275,22 @@ private fun Modifier.nativeViewPointerInterop( ) true } + PointerEventType.PanStart, + PointerEventType.PanEnd, + -> { + // Consumed, not forwarded: the gesture belongs to + // the native view, so the Compose scrollable above + // must not open a pan session of its own, and the + // native side would only replay `NSApp.currentEvent` + // — stale for the deferred PanEnd. + true + } PointerEventType.PanMove -> { // Trackpad pan (#654), handed over in AWT wheel // units: panOffset is 10 dp per unit (see // TaoSceneScrollRouter), so the native view keeps // scrolling under a two-finger swipe exactly like - // under a wheel. PanStart / PanEnd carry no offset - // and are not forwarded: the native side replays - // `NSApp.currentEvent`, and for the deferred PanEnd - // that is an unrelated, stale event. + // under a wheel. val unitPx = AWT_PIXEL_TO_ROTATION * density host.dispatchScrollToNative( handle, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt index 86c41e302..352f64283 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt @@ -99,26 +99,40 @@ public object TaoTrackpadPhase { } /** - * Phase of a macOS trackpad scroll gesture as delivered by - * `EventCallback.onScrollGesture` (mirrors the Rust `SCROLL_GESTURE_*` codes, - * #654). AppKit reports the fingers-on-glass part in `NSEvent.phase` and the - * inertial tail that follows in `momentumPhase`, never both at once. [NONE] is - * the JVM-side marker for a scroll that belongs to no gesture (mouse wheel, - * phase-less device); it never travels over the wire. + * Phase of a macOS trackpad scroll gesture step as delivered by + * `EventCallback.onScrollGesture` (#654). AppKit reports the fingers-on-glass + * part in `NSEvent.phase` and the inertial tail that follows in + * `momentumPhase`, never both at once. [wire] is the code the Rust loop + * (`events.rs` `SCROLL_GESTURE_*`) and the popup panel (`popup_panel.m` + * `NucleusScrollGesture*`) send; a scroll that belongs to no gesture (wheel + * notch, phase-less device) has no phase — `null` on the JVM, + * [NONE_WIRE] on the popup wire. Distinct from the public + * [TaoTrackpadPhase] of magnify / rotate gestures on purpose: the two streams + * are different and must not be passed for one another. */ @Suppress("MagicNumber") -internal object TaoScrollGesturePhase { - const val NONE: Int = -1 - const val BEGAN: Int = 0 - const val CHANGED: Int = 1 - const val ENDED: Int = 2 - const val CANCELLED: Int = 3 - const val MOMENTUM_BEGAN: Int = 4 - const val MOMENTUM_CHANGED: Int = 5 - const val MOMENTUM_ENDED: Int = 6 +internal enum class TaoScrollGesturePhase( + val wire: Int, +) { + BEGAN(0), + CHANGED(1), + ENDED(2), + CANCELLED(3), + MOMENTUM_BEGAN(4), + MOMENTUM_CHANGED(5), + MOMENTUM_ENDED(6), /** Fingers touched the trackpad, no scroll yet (`NSEventPhaseMayBegin`). */ - const val MAY_BEGIN: Int = 7 + MAY_BEGIN(7), + ; + + companion object { + /** Wire code for "not a gesture step" (only the popup wire carries it). */ + const val NONE_WIRE: Int = -1 + + /** `null` for [NONE_WIRE] and for any code this build does not know. */ + fun fromWire(code: Int): TaoScrollGesturePhase? = entries.firstOrNull { it.wire == code } + } } /** Modifier-state bitmask that mirrors the Rust side. */ diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index 6abac3251..cb49f4d49 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -1095,10 +1095,13 @@ public class TaoWindow internal constructor( * scene host turns the stream into Compose Pan events. */ internal fun dispatchScrollGesture( - phase: Int, + phaseWire: Int, dxFixed: Int, dyFixed: Int, ) { + // A code this build does not know degrades to a plain precise scroll + // rather than a pan step the router cannot place. + val phase = TaoScrollGesturePhase.fromWire(phaseWire) pointerScrollListener?.invoke(preciseScrollEvent(dxFixed, dyFixed, gesturePhase = phase)) } @@ -1112,7 +1115,7 @@ public class TaoWindow internal constructor( private fun preciseScrollEvent( dxFixed: Int, dyFixed: Int, - gesturePhase: Int, + gesturePhase: TaoScrollGesturePhase?, ) = TaoPointerScrollEvent( dxAwt = -(dxFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION, dyAwt = -(dyFixed / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION, @@ -1262,9 +1265,7 @@ public class TaoWindow internal constructor( TaoEventCode.SCROLL_PIXEL -> { // Precise scroll outside a gesture (smooth-scroll mice); see // [preciseScrollEvent] for the AWT shaping. - pointerScrollListener?.invoke( - preciseScrollEvent(a, b, gesturePhase = TaoScrollGesturePhase.NONE), - ) + pointerScrollListener?.invoke(preciseScrollEvent(a, b, gesturePhase = null)) } // KEY_DOWN / KEY_UP: routed in Phase 2b (no logical-key encoding yet) } @@ -1294,14 +1295,14 @@ public class TaoWindow internal constructor( * [dxAwt] / [dyAwt] are `preciseWheelRotation` (positive = scroll down / * right), [scrollAmount] the platform line-count policy Compose Desktop reads. * [gesturePhase] is the [TaoScrollGesturePhase] of a macOS trackpad gesture - * step, or [TaoScrollGesturePhase.NONE] for a wheel notch / phase-less device - * — gesture steps become Compose Pan events, the rest ordinary Scroll events. + * step, or `null` for a wheel notch / phase-less device — gesture steps + * become Compose Pan events, the rest ordinary Scroll events. */ internal data class TaoPointerScrollEvent( val dxAwt: Float, val dyAwt: Float, val scrollAmount: Int, - val gesturePhase: Int = TaoScrollGesturePhase.NONE, + val gesturePhase: TaoScrollGesturePhase? = null, ) private data class WindowsTitleBarTouchDrag( diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt index f6371a95e..b514a8d05 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt @@ -31,12 +31,15 @@ internal fun appKitWheelToAwtScrollDelta( return if (precise) awtSign / AWT_PIXEL_TO_ROTATION else awtSign } -/** [gesturePhase] is the [TaoScrollGesturePhase] of a trackpad step, `NONE` for a wheel. */ +/** + * [gesturePhaseWire] is the [TaoScrollGesturePhase.wire] of a trackpad step, + * [TaoScrollGesturePhase.NONE_WIRE] for a wheel notch. + */ internal fun appKitWheelToAwtScrollEvent( dx: Float, dy: Float, precise: Boolean, - gesturePhase: Int = TaoScrollGesturePhase.NONE, + gesturePhaseWire: Int = TaoScrollGesturePhase.NONE_WIRE, ): TaoPointerScrollEvent { val delta = appKitWheelToAwtScrollDelta(dx, dy, precise) return TaoPointerScrollEvent( @@ -47,6 +50,6 @@ internal fun appKitWheelToAwtScrollEvent( // does. Do not copy LINUX_AWT_SCROLL_AMOUNT_DEFAULT here. scrollAmount = MACOS_AWT_SCROLL_AMOUNT, // A wheel notch has no phase; only precise events can belong to a gesture. - gesturePhase = if (precise) gesturePhase else TaoScrollGesturePhase.NONE, + gesturePhase = if (precise) TaoScrollGesturePhase.fromWire(gesturePhaseWire) else null, ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt index 2be11372d..19066c049 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt @@ -235,7 +235,9 @@ internal class TaoPopupSceneLayer( TaoSceneScrollRouter( object : TaoSceneScrollRouter.Target { override val scene: ComposeScene get() = innerScene - override val scale: Float get() = this@TaoPopupSceneLayer.scale + + // Live: Compose re-assigns the layer density on a display hop. + override val scale: Float get() = _density.density override fun guard(block: () -> Unit) = host.exceptionHandler.catchExceptions(block) }, @@ -273,6 +275,7 @@ internal class TaoPopupSceneLayer( TaoNativeWireFormat.PTR_UP -> PointerEventType.Release else -> PointerEventType.Move } + if (eventType == PointerEventType.Press) scrollRouter.finishPan() innerScene.sendPointerEvent( eventType = eventType, position = Offset(x, y), diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt index cb9d5961d..625d0933d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt @@ -295,13 +295,19 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { } override fun dispose() { - if (!isValid || disposed) return + if (!isValid) { + scrollRouter.cancel() + return + } + if (disposed) return disposed = true framePump.disposed = true - scrollRouter.cancel() revokeInboundDnD() PopupNativeBridge.nativeUninstallOutsideClickMonitor(panel) PopupNativeBridge.nativeSetEventCallback(panel, null) + // After the native callback is gone: no scroll can reach a router + // whose timer scope is already dead. + scrollRouter.cancel() sceneBundle?.close() sceneBundle = null metalTextureHostCache.invalidate() @@ -434,6 +440,7 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { TaoNativeWireFormat.PTR_UP -> PointerEventType.Release else -> PointerEventType.Move } + if (eventType == PointerEventType.Press) scrollRouter.finishPan() framePump.nonReentrant { sc.sendPointerEvent( eventType = eventType, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index 7d313b9b8..d21606bde 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -1012,6 +1012,9 @@ internal class TaoComposeSceneHost( // comment on `hasReceivedCursorMove` for the rationale. return } + // A click ends a trackpad gesture for Compose too (a tap to stop a + // fling must not race an open pan session). + if (pressed) scrollRouter.finishPan() val composeButton = mapButton(buttonCode) currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt index a53fc82d5..5f00fee07 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt @@ -5,8 +5,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.scene.ComposeScene +import dev.nucleusframework.window.tao.TaoFatalCoroutineExceptionHandler import dev.nucleusframework.window.tao.TaoPointerScrollEvent -import dev.nucleusframework.window.tao.TaoScrollGesturePhase import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll @@ -16,6 +16,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Logger /** * Single front door for wheel and trackpad input into a [ComposeScene], @@ -31,6 +33,13 @@ import kotlinx.coroutines.launch * `MacOSCocoaConfig` applies to a wheel notch, so a pan and a notch move * content by the same distance, as they do under AWT. * + * Every step of one pan, the deferred `PanEnd` included, is dispatched at the + * position and with the modifiers of the last gesture step, deliberately: + * Compose hit-tests Pan events, and the node that received the `PanMove`s is + * the one whose scroll session has to be closed — a `PanEnd` sent where the + * pointer moved to in the meantime would leave it open. A click or a wheel + * notch while a pan is open closes it first ([finishPan]). + * * Pan events are what Compose's `Modifier.scrollable` consumes for trackpads * and what lets a map bind panning and zooming to different gestures. Code * that only listens to `PointerEventType.Scroll` no longer sees trackpad @@ -39,7 +48,7 @@ import kotlinx.coroutines.launch * where every gesture step is a `Scroll`. * * [schedule] is only supplied by tests; production routers own a coroutine - * scope on the UI dispatcher for the deferred `PanEnd`. UI thread only. + * scope on the UI dispatcher for the end timer. UI thread only. */ @OptIn(InternalComposeUiApi::class) internal class TaoSceneScrollRouter( @@ -54,12 +63,20 @@ internal class TaoSceneScrollRouter( /** Px per dp of the scene, for the pan offset. */ val scale: Float - /** Wraps the deferred `PanEnd` delivery (exception handler, frame pump). */ + /** + * Wraps the deferred `PanEnd` delivery. Hosts route it through their + * window exception handler / frame pump; whatever escapes lands in + * [TaoFatalCoroutineExceptionHandler], never in the default handler. + */ fun guard(block: () -> Unit) = block() } private val scope: CoroutineScope? = - if (schedule == null) CoroutineScope(TaoMainDispatcher + SupervisorJob()) else null + if (schedule == null) { + CoroutineScope(TaoMainDispatcher + SupervisorJob() + TaoFatalCoroutineExceptionHandler) + } else { + null + } private val pan = TaoTrackpadPanRouter( @@ -67,8 +84,9 @@ internal class TaoSceneScrollRouter( send = ::sendPan, ) - // Where the pan is, in scene px, plus the modifiers of the last step; the - // deferred PanEnd has no event of its own to read them from. + private var cancelled = false + + // Where the pan is, in scene px, plus the modifiers of its last step. private var x = 0f private var y = 0f private var keyboardModifiers = PointerKeyboardModifiers() @@ -79,18 +97,36 @@ internal class TaoSceneScrollRouter( event: TaoPointerScrollEvent, keyboardModifiers: PointerKeyboardModifiers = PointerKeyboardModifiers(), ) { - this.x = x - this.y = y - this.keyboardModifiers = keyboardModifiers - if (panEnabled && event.gesturePhase != TaoScrollGesturePhase.NONE) { - pan.onGesture(event.gesturePhase, Offset(event.dxAwt, event.dyAwt)) + if (cancelled) return + val phase = event.gesturePhase + if (panEnabled && phase != null) { + this.x = x + this.y = y + this.keyboardModifiers = keyboardModifiers + if (panAnnounced.compareAndSet(false, true)) { + logger.config { + "Trackpad gestures reach Compose as Pan events (PanStart / PanMove / PanEnd); " + + "handlers listening only for PointerEventType.Scroll do not see them. " + + "-Dnucleus.tao.trackpadPanEvents=false restores AWT-style Scroll events." + } + } + pan.onGesture(phase, Offset(event.dxAwt, event.dyAwt)) } else { + // A different device took over: close the pan where it was. + pan.finishNow() target.scene?.dispatchAwtShapedScroll(x, y, event, keyboardModifiers) } } - /** Teardown: drops the pending deferred end and the timer scope. */ + /** Closes an open pan now — a pointer press ends the gesture for Compose too. */ + fun finishPan() { + if (cancelled) return + pan.finishNow() + } + + /** Teardown: drops the pending end, the timer scope, and ignores anything that still arrives. */ fun cancel() { + cancelled = true pan.cancel() scope?.cancel() } @@ -121,6 +157,11 @@ internal class TaoSceneScrollRouter( } internal companion object { + private val logger = Logger.getLogger(TaoSceneScrollRouter::class.java.name) + + /** One CONFIG line per process the first time a gesture is routed as Pan. */ + private val panAnnounced = AtomicBoolean(false) + /** * `-Dnucleus.tao.trackpadPanEvents=false` sends trackpad gesture steps * down the wheel path as AWT-shaped `Scroll` events instead of Compose diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt index 8c40782fa..d6357a612 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt @@ -20,10 +20,14 @@ import dev.nucleusframework.window.tao.TaoScrollGesturePhase * velocity is ~0 and Compose adds no fling of its own — the platform drives * the inertia, exactly as under AWT where every step is a plain wheel event. * - * A terminal step (`Ended`, `Cancelled`, `MomentumEnded`) that still carries a - * delta is delivered even when no pan is open — AppKit's `Ended` can hold the - * last finger movement, and a `Began` may have been missed (window became key - * mid-gesture) — so no scroll distance is ever dropped. + * An open pan is always bounded: every step re-arms an end timer ([graceMillis] + * after the finger `Ended`, [stallMillis] otherwise), so a stream that is cut + * short — fingers resting on the glass during the tail (`MayBegin`, which + * closes the pan at once), a window losing key status, a terminal step that + * never arrives — cannot leave Compose's scroll session open. A terminal step + * that still carries a delta is delivered even when no pan is open (AppKit's + * `Ended` can hold the last finger movement, and a `Began` may have been + * missed), so no scroll distance is dropped. * * [send] receives the pan offset in AWT `preciseWheelRotation` units (the * shape of [dev.nucleusframework.window.tao.TaoPointerScrollEvent.dxAwt]); the @@ -34,36 +38,32 @@ internal class TaoTrackpadPanRouter( private val schedule: (delayMillis: Long, action: () -> Unit) -> (() -> Unit), private val send: (type: PointerEventType, panAwt: Offset) -> Unit, private val graceMillis: Long = momentumGraceMillis, + private val stallMillis: Long = DEFAULT_STALL_MILLIS, ) { private var active = false private var cancelPendingEnd: (() -> Unit)? = null fun onGesture( - phase: Int, + phase: TaoScrollGesturePhase, deltaAwt: Offset, ) { when (phase) { - // Fingers resting on the glass: nothing to pan yet. A Cancelled - // that follows without a Began is a no-op below. - TaoScrollGesturePhase.MAY_BEGIN -> Unit + // Fingers touched the glass: a running momentum tail is over (AppKit + // does not always follow with MomentumEnded); with no pan open, + // nothing to do until Began. + TaoScrollGesturePhase.MAY_BEGIN -> finish() TaoScrollGesturePhase.BEGAN, TaoScrollGesturePhase.CHANGED, TaoScrollGesturePhase.MOMENTUM_BEGAN, TaoScrollGesturePhase.MOMENTUM_CHANGED, -> { - clearPendingEnd() start() move(deltaAwt) + armEnd(stallMillis) } TaoScrollGesturePhase.ENDED -> { move(deltaAwt) - if (!active) return - clearPendingEnd() - cancelPendingEnd = - schedule(graceMillis) { - cancelPendingEnd = null - finish() - } + if (active) armEnd(graceMillis) } TaoScrollGesturePhase.CANCELLED, TaoScrollGesturePhase.MOMENTUM_ENDED, @@ -71,11 +71,12 @@ internal class TaoTrackpadPanRouter( move(deltaAwt) finish() } - // Unknown wire value: ignore rather than desynchronise the pan. - else -> Unit } } + /** Closes an open pan now (a click, a wheel notch: the gesture is over). */ + fun finishNow() = finish() + /** Teardown: drops a pending deferred end and forgets the open pan (no `PanEnd` is sent). */ fun cancel() { clearPendingEnd() @@ -105,6 +106,17 @@ internal class TaoTrackpadPanRouter( send(PointerEventType.PanEnd, Offset.Zero) } + /** (Re-)arms the single end timer of the open pan. */ + private fun armEnd(delayMillis: Long) { + clearPendingEnd() + if (!active) return + cancelPendingEnd = + schedule(delayMillis) { + cancelPendingEnd = null + finish() + } + } + private fun clearPendingEnd() { cancelPendingEnd?.invoke() cancelPendingEnd = null @@ -121,6 +133,14 @@ internal class TaoTrackpadPanRouter( */ const val DEFAULT_MOMENTUM_GRACE_MILLIS: Long = 150L + /** + * Watchdog between two steps of an open pan. Finger and momentum steps + * arrive at frame rate, so a gap this long means the stream was cut + * short; the price of closing a pan whose fingers merely paused on the + * glass is a new `PanStart` when they move again. + */ + const val DEFAULT_STALL_MILLIS: Long = 1_000L + val momentumGraceMillis: Long = System .getProperty("nucleus.tao.trackpadMomentumGraceMillis") diff --git a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m index f84407306..56013e8f9 100644 --- a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m +++ b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m @@ -22,6 +22,7 @@ #import #import #include +#include #include #import @@ -2890,15 +2891,26 @@ static void ensureInteropModeSource(void) { * location flipped against the primary display. The location is therefore * chosen so that the flipped value equals the wanted window point, which is * what tao's `mouse_motion` (run first by `scroll_wheel`) resolves back to - * the view-local cursor position. Returns JNI false when the view or its - * window is gone or the event could not be built. */ + * the view-local cursor position. + * + * This DRIVES the app rather than reading it, so unlike the other nativeDiag* + * entries it is inert unless the process was started with + * NUCLEUS_TAO_INPUT_INJECTION=1 (the taoHeadfulTest Gradle task sets it) and + * it only runs on the main thread — no dispatch_sync that a caller holding a + * lock the main thread wants could deadlock on. Returns JNI false when + * disabled, off the main thread, or when the view / window is gone. */ JNIEXPORT jboolean JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeMetalBridge_nativeDiagInjectScrollWheel( JNIEnv *env, jclass clazz, jlong nsViewPtr, jfloat x, jfloat y, jfloat dx, jfloat dy, jboolean precise, jint phase, jint momentumPhase) { (void)env; (void)clazz; - if (nsViewPtr == 0) return JNI_FALSE; + static int sEnabled = -1; + if (sEnabled < 0) { + const char *flag = getenv("NUCLEUS_TAO_INPUT_INJECTION"); + sEnabled = (flag != NULL && strcmp(flag, "1") == 0) ? 1 : 0; + } + if (!sEnabled || nsViewPtr == 0 || ![NSThread isMainThread]) return JNI_FALSE; void *rawPtr = (void *)(uintptr_t)nsViewPtr; __block jboolean delivered = JNI_FALSE; dispatch_block_t deliver = ^{ @@ -2927,8 +2939,7 @@ static void ensureInteropModeSource(void) { [content scrollWheel:event]; delivered = JNI_TRUE; }; - if ([NSThread isMainThread]) deliver(); - else dispatch_sync(dispatch_get_main_queue(), deliver); + deliver(); return delivered; } diff --git a/decorated-window-tao/src/main/native/macos/popup_panel.m b/decorated-window-tao/src/main/native/macos/popup_panel.m index 91cd8cd28..d8abaf7f9 100644 --- a/decorated-window-tao/src/main/native/macos/popup_panel.m +++ b/decorated-window-tao/src/main/native/macos/popup_panel.m @@ -275,27 +275,40 @@ - (void)mouseDragged:(NSEvent *)event { [self dispatchPointer:event type:EVT_ - (void)rightMouseDown:(NSEvent *)event { [self maybeBecomeKey:event]; [self dispatchPointer:event type:EVT_PTR_DOWN button:2]; } - (void)rightMouseUp:(NSEvent *)event { [self dispatchPointer:event type:EVT_PTR_UP button:2]; } -/* Trackpad gesture phase of a scroll event, encoded like the vendored tao's - * `ScrollPhase` -> Kotlin `TaoScrollGesturePhase` (events.rs SCROLL_GESTURE_*), - * so a popup routes a two-finger swipe exactly like the window behind it (#654). - * AppKit sets `phase` for the fingers-on-glass part and `momentumPhase` for the - * inertial tail, never both; a wheel notch has neither (-1 = NONE). */ +/* Trackpad gesture phase wire codes: one copy of the vendored tao's + * `ScrollPhase` -> Kotlin `TaoScrollGesturePhase` mapping (events.rs + * SCROLL_GESTURE_*), so a popup routes a two-finger swipe exactly like the + * window behind it (#654). Kept in sync by TaoScrollWireDriftTest. */ +typedef NS_ENUM(jint, NucleusScrollGesture) { + NucleusScrollGestureNone = -1, + NucleusScrollGestureBegan = 0, + NucleusScrollGestureChanged = 1, + NucleusScrollGestureEnded = 2, + NucleusScrollGestureCancelled = 3, + NucleusScrollGestureMomentumBegan = 4, + NucleusScrollGestureMomentumChanged = 5, + NucleusScrollGestureMomentumEnded = 6, + NucleusScrollGestureMayBegin = 7, +}; + +/* AppKit sets `phase` for the fingers-on-glass part and `momentumPhase` for the + * inertial tail, never both; a wheel notch has neither. */ static jint scrollGesturePhase(NSEvent *event) { switch (event.phase) { - case NSEventPhaseMayBegin: return 7; - case NSEventPhaseBegan: return 0; + case NSEventPhaseMayBegin: return NucleusScrollGestureMayBegin; + case NSEventPhaseBegan: return NucleusScrollGestureBegan; case NSEventPhaseChanged: - case NSEventPhaseStationary: return 1; - case NSEventPhaseEnded: return 2; - case NSEventPhaseCancelled: return 3; + case NSEventPhaseStationary: return NucleusScrollGestureChanged; + case NSEventPhaseEnded: return NucleusScrollGestureEnded; + case NSEventPhaseCancelled: return NucleusScrollGestureCancelled; default: break; } switch (event.momentumPhase) { - case NSEventPhaseBegan: return 4; - case NSEventPhaseChanged: return 5; + case NSEventPhaseBegan: return NucleusScrollGestureMomentumBegan; + case NSEventPhaseChanged: return NucleusScrollGestureMomentumChanged; case NSEventPhaseEnded: - case NSEventPhaseCancelled: return 6; - default: return -1; + case NSEventPhaseCancelled: return NucleusScrollGestureMomentumEnded; + default: return NucleusScrollGestureNone; } } diff --git a/decorated-window-tao/src/main/native/src/event_loop.rs b/decorated-window-tao/src/main/native/src/event_loop.rs index 392ba119e..03c081570 100644 --- a/decorated-window-tao/src/main/native/src/event_loop.rs +++ b/decorated-window-tao/src/main/native/src/event_loop.rs @@ -13,13 +13,13 @@ use tao::window::WindowBuilder; use crate::events::{ current_modifier_bits, dispatch, dispatch_ime_commit, dispatch_ime_preedit, dispatch_ime_replace_commit, dispatch_key, dispatch_scroll_gesture, dispatch_touch_input, - handle_for, mouse_button_code, pack_modifiers, UserEvent, CURSOR_FIXED_SCALE, - EVENT_CLOSE_REQUESTED, EVENT_CURSOR_LEFT, EVENT_CURSOR_MOVED, EVENT_DESTROYED, EVENT_FOCUSED, - EVENT_KEY_DOWN, EVENT_KEY_TYPED, EVENT_KEY_UP, EVENT_LAUNCHED, EVENT_MAIN_EVENTS_CLEARED, - EVENT_MODIFIERS_CHANGED, EVENT_MOUSE_DOWN, EVENT_MOUSE_UP, EVENT_MOVED, EVENT_REDRAW_REQUESTED, - EVENT_RESIZED, EVENT_SCALE_FACTOR_CHANGED, EVENT_SCROLL_LINE, EVENT_SCROLL_PIXEL, - EVENT_UNFOCUSED, EVENT_WINDOW_READY, SCROLL_FIXED_SCALE, SCROLL_GESTURE_BEGAN, - SCROLL_GESTURE_CANCELLED, SCROLL_GESTURE_CHANGED, SCROLL_GESTURE_ENDED, + handle_for, mouse_button_code, pack_modifiers, UserEvent, AWT_LINE_TO_POINTS, + CURSOR_FIXED_SCALE, EVENT_CLOSE_REQUESTED, EVENT_CURSOR_LEFT, EVENT_CURSOR_MOVED, + EVENT_DESTROYED, EVENT_FOCUSED, EVENT_KEY_DOWN, EVENT_KEY_TYPED, EVENT_KEY_UP, EVENT_LAUNCHED, + EVENT_MAIN_EVENTS_CLEARED, EVENT_MODIFIERS_CHANGED, EVENT_MOUSE_DOWN, EVENT_MOUSE_UP, + EVENT_MOVED, EVENT_REDRAW_REQUESTED, EVENT_RESIZED, EVENT_SCALE_FACTOR_CHANGED, + EVENT_SCROLL_LINE, EVENT_SCROLL_PIXEL, EVENT_UNFOCUSED, EVENT_WINDOW_READY, SCROLL_FIXED_SCALE, + SCROLL_GESTURE_BEGAN, SCROLL_GESTURE_CANCELLED, SCROLL_GESTURE_CHANGED, SCROLL_GESTURE_ENDED, SCROLL_GESTURE_MAY_BEGIN, SCROLL_GESTURE_MOMENTUM_BEGAN, SCROLL_GESTURE_MOMENTUM_CHANGED, SCROLL_GESTURE_MOMENTUM_ENDED, TOUCH_EVENT_CANCEL, TOUCH_EVENT_MOVE, TOUCH_EVENT_PRESS, TOUCH_EVENT_RELEASE, TOUCH_FORCE_FIXED_SCALE, TOUCH_FORCE_UNKNOWN, @@ -838,15 +838,11 @@ pub(crate) fn run_event_loop_blocking() { // by the display factor, so the vendored tao hands // `PixelDelta` over in LOGICAL points (patch 0007, // #653) — nothing to undo here. - let (code, dx, dy) = match delta { - MouseScrollDelta::LineDelta(x, y) => { - (EVENT_SCROLL_LINE, x as f64, y as f64) - } - MouseScrollDelta::PixelDelta(p) => (EVENT_SCROLL_PIXEL, p.x, p.y), + let (precise, dx, dy) = match delta { + MouseScrollDelta::LineDelta(x, y) => (false, x as f64, y as f64), + MouseScrollDelta::PixelDelta(p) => (true, p.x, p.y), _ => return, }; - let dx_fixed = (dx * SCROLL_FIXED_SCALE) as jint; - let dy_fixed = (dy * SCROLL_FIXED_SCALE) as jint; // A precise scroll that belongs to a trackpad gesture // (finger or momentum phase) is reported as a gesture // so the JVM can surface Compose Pan events (#654); @@ -869,10 +865,39 @@ pub(crate) fn run_event_loop_blocking() { } }; match gesture { - Some(phase) if code == EVENT_SCROLL_PIXEL => { - dispatch_scroll_gesture(handle, phase, dx_fixed, dy_fixed); + Some(phase) => { + // The phase decides the route for the WHOLE + // gesture: a step whose `hasPreciseScrollingDeltas` + // flag differs from its siblings (seen on some + // devices for zero-delta terminal steps) must + // still reach the pan router, or the pan is + // never closed. Line-shaped steps are scaled to + // their point equivalent to keep one wire shape. + let (dx, dy) = if precise { + (dx, dy) + } else { + (dx * AWT_LINE_TO_POINTS, dy * AWT_LINE_TO_POINTS) + }; + dispatch_scroll_gesture( + handle, + phase, + (dx * SCROLL_FIXED_SCALE) as jint, + (dy * SCROLL_FIXED_SCALE) as jint, + ); + } + None => { + let code = if precise { + EVENT_SCROLL_PIXEL + } else { + EVENT_SCROLL_LINE + }; + dispatch( + handle, + code, + (dx * SCROLL_FIXED_SCALE) as jint, + (dy * SCROLL_FIXED_SCALE) as jint, + ); } - _ => dispatch(handle, code, dx_fixed, dy_fixed), } } WindowEvent::ReceivedImeText(text) => { diff --git a/decorated-window-tao/src/main/native/src/events.rs b/decorated-window-tao/src/main/native/src/events.rs index 841e92f7f..d2bdc4b95 100644 --- a/decorated-window-tao/src/main/native/src/events.rs +++ b/decorated-window-tao/src/main/native/src/events.rs @@ -163,6 +163,10 @@ pub(crate) const SCROLL_GESTURE_MOMENTUM_BEGAN: jint = 4; pub(crate) const SCROLL_GESTURE_MOMENTUM_CHANGED: jint = 5; pub(crate) const SCROLL_GESTURE_MOMENTUM_ENDED: jint = 6; pub(crate) const SCROLL_GESTURE_MAY_BEGIN: jint = 7; +// AWT: one wheel line is one unit of `preciseWheelRotation`, one point of a +// precise delta is a tenth of one — so a gesture step that arrives in lines is +// scaled to its point equivalent before it joins the (point-shaped) gesture wire. +pub(crate) const AWT_LINE_TO_POINTS: f64 = 10.0; pub(crate) const EVENT_MODIFIERS_CHANGED: jint = 22; // Linux only. Dispatched synchronously on the event-loop thread right // BEFORE the GTK window is hidden, so the JVM can suspend its EGL rendering @@ -526,7 +530,6 @@ pub(crate) fn dispatch_ime_replace_commit(handle: u64, text: &str, start: u64, l /// is one of the `SCROLL_GESTURE_*` codes; the deltas are LOGICAL points /// (AppKit `scrollingDelta*`, tao's sign) × SCROLL_FIXED_SCALE, like /// EVENT_SCROLL_PIXEL. -#[allow(dead_code)] pub(crate) fn dispatch_scroll_gesture(handle: u64, phase: jint, dx_fixed: jint, dy_fixed: jint) { let Some(vm) = JAVA_VM.get() else { return }; let Ok(guard) = EVENT_CALLBACK.lock() else { diff --git a/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch index 801986a5f..b1864d197 100644 --- a/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch +++ b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch @@ -81,7 +81,7 @@ index 9d0ab3fb..6cf94fb6 100644 }, }) { diff --git a/src/platform_impl/macos/view.rs b/src/platform_impl/macos/view.rs -index b0fb8472..e6f1e8d5 100644 +index b0fb8472..80004bd1 100644 --- a/src/platform_impl/macos/view.rs +++ b/src/platform_impl/macos/view.rs @@ -31,9 +31,10 @@ use objc2_foundation::{ @@ -97,9 +97,13 @@ index b0fb8472..e6f1e8d5 100644 }, keyboard::{KeyCode, ModifiersState}, platform_impl::platform::{ -@@ -1263,11 +1264,21 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { - let state = &mut *(state_ptr as *mut ViewState); +@@ -1259,15 +1260,22 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { + mouse_motion(this, event); + unsafe { +- let state_ptr: *mut c_void = *this.get_ivar("taoState"); +- let state = &mut *(state_ptr as *mut ViewState); +- let delta = { - // macOS horizontal sign convention is the inverse of tao. - let (x, y) = (event.scrollingDeltaX() * -1.0, event.scrollingDeltaY()); @@ -123,7 +127,7 @@ index b0fb8472..e6f1e8d5 100644 } else { MouseScrollDelta::LineDelta(x as f32, y as f32) } -@@ -1277,6 +1288,23 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { +@@ -1277,6 +1285,23 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { NSEventPhase::Ended => TouchPhase::Ended, _ => TouchPhase::Moved, }; @@ -147,7 +151,7 @@ index b0fb8472..e6f1e8d5 100644 let device_event = Event::DeviceEvent { device_id: DEVICE_ID, -@@ -1294,6 +1322,7 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { +@@ -1294,6 +1319,7 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { device_id: DEVICE_ID, delta, phase, diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs index e6f1e8d5d..80004bd16 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs @@ -1260,9 +1260,6 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { mouse_motion(this, event); unsafe { - let state_ptr: *mut c_void = *this.get_ivar("taoState"); - let state = &mut *(state_ptr as *mut ViewState); - let delta = { // PATCH(nucleus): keep AppKit's sign on both axes — positive means the // content moves down / right, which is exactly the convention diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index cebaaac42..2502bc18e 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -198,6 +198,9 @@ public object TaoSceneTestBattery { run("TaoWindowScrollTest: scrollGestureIsShapedLikePixelScrollWithItsPhase") { TaoWindowScrollTest().scrollGestureIsShapedLikePixelScrollWithItsPhase() } + run("TaoWindowScrollTest: unknownGestureWireCodeDegradesToPlainPreciseScroll") { + TaoWindowScrollTest().unknownGestureWireCodeDegradesToPlainPreciseScroll() + } run("TaoTrackpadPanRouterTest: swipe without momentum ends after the grace period") { TaoTrackpadPanRouterTest().`swipe without momentum ends after the grace period`() } @@ -207,6 +210,15 @@ public object TaoSceneTestBattery { run("TaoTrackpadPanRouterTest: momentum tail continues the pan and ends it once") { TaoTrackpadPanRouterTest().`momentum tail continues the pan and ends it once`() } + run("TaoTrackpadPanRouterTest: fingers resting on the glass during the tail close the pan at once") { + TaoTrackpadPanRouterTest().`fingers resting on the glass during the tail close the pan at once`() + } + run("TaoTrackpadPanRouterTest: a truncated stream is closed by the stall watchdog") { + TaoTrackpadPanRouterTest().`a truncated stream is closed by the stall watchdog`() + } + run("TaoTrackpadPanRouterTest: finishNow closes an open pan and is a no-op otherwise") { + TaoTrackpadPanRouterTest().`finishNow closes an open pan and is a no-op otherwise`() + } run("TaoTrackpadPanRouterTest: pan offsets pass through unchanged and zero deltas send no move") { TaoTrackpadPanRouterTest().`pan offsets pass through unchanged and zero deltas send no move`() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index 0bb8d4089..bcab9428a 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt @@ -100,8 +100,8 @@ class TaoSceneTestBatteryDriftTest { TaoSceneRectManagerRaceTest::class.java to "races the real AWT EDT against wall-clock frames; the no-AWT image never initialises AWT", TaoTransferableAccessGuardTest::class.java to "Compose interop ABI guard, not a scene behaviour", - dev.nucleusframework.window.tao.popup.PopupPanelJniSignatureDriftTest::class.java to - "reads popup_panel.m from the repo; JNI descriptor guard, not a scene behaviour", + TaoScrollWireDriftTest::class.java to + "reads popup_panel.m / events.rs from the repo; wire guard, not a scene behaviour", dev.nucleusframework.window.tao.scene.TaoKeepScreenOnTest::class.java to "acquires real EnergyManager awake handles against the host OS", TaoSceneTestBatteryDriftTest::class.java to "meta-test for the battery itself", diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt new file mode 100644 index 000000000..b5f243dfa --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt @@ -0,0 +1,93 @@ +package dev.nucleusframework.window.tao + +import dev.nucleusframework.window.tao.ffi.PopupNativeBridge +import java.io.File +import java.lang.reflect.Method +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The macOS scroll wire is written by hand in three places that the compiler + * cannot check against each other: the Rust loop (`events.rs` + * `SCROLL_GESTURE_*`), the popup panel (`popup_panel.m`, its + * `NucleusScrollGesture*` enum and the JNI descriptors it resolves with + * `GetMethodID`) and Kotlin ([TaoScrollGesturePhase], [PopupNativeBridge.EventCallback]). + * A drift is silent at run time — a mis-numbered phase closes a pan mid-tail, + * a wrong descriptor leaves the popup callback uninstalled — so compare them + * here, where it is loud. + */ +class TaoScrollWireDriftTest { + @Test + fun `popup_panel m GetMethodID descriptors match the Kotlin callback`() { + val declared = + GET_METHOD_ID + .findAll(POPUP_PANEL.readText()) + .associate { it.groupValues[1] to it.groupValues[2] } + .filterKeys { it != "onOutsideClick" } // lives on a different listener class + assertTrue(declared.isNotEmpty(), "no GetMethodID(...) found in ${POPUP_PANEL.path}") + + val callback = PopupNativeBridge.EventCallback::class.java + declared.forEach { (name, descriptor) -> + val method = + callback.methods.singleOrNull { it.name == name } + ?: error("popup_panel.m looks up '$name' but EventCallback has no single method of that name") + assertEquals(descriptor, method.jniDescriptor(), "JNI descriptor of EventCallback.$name") + } + } + + @Test + fun `Rust SCROLL_GESTURE codes match TaoScrollGesturePhase`() { + val rust = + RUST_CODE + .findAll(EVENTS_RS.readText()) + .associate { it.groupValues[1] to it.groupValues[2].toInt() } + assertEquals(kotlinWire(), rust, "events.rs SCROLL_GESTURE_* vs TaoScrollGesturePhase.wire") + } + + @Test + fun `popup_panel m NucleusScrollGesture codes match TaoScrollGesturePhase`() { + val objc = + OBJC_CODE + .findAll(POPUP_PANEL.readText()) + .associate { it.groupValues[1].toScreamingSnake() to it.groupValues[2].toInt() } + assertEquals(TaoScrollGesturePhase.NONE_WIRE, objc["NONE"], "NucleusScrollGestureNone") + assertEquals(kotlinWire(), objc - "NONE", "popup_panel.m NucleusScrollGesture* vs TaoScrollGesturePhase.wire") + } + + private fun kotlinWire(): Map = TaoScrollGesturePhase.entries.associate { it.name to it.wire } + + /** `MomentumBegan` → `MOMENTUM_BEGAN`, `MayBegin` → `MAY_BEGIN`. */ + private fun String.toScreamingSnake(): String = replace(Regex("(?<=[a-z])(?=[A-Z])"), "_").uppercase() + + private fun Method.jniDescriptor(): String = + parameterTypes.joinToString(prefix = "(", postfix = ")", separator = "") { it.descriptor() } + + returnType.descriptor() + + private fun Class<*>.descriptor(): String = + when (this) { + Void.TYPE -> "V" + java.lang.Boolean.TYPE -> "Z" + java.lang.Integer.TYPE -> "I" + java.lang.Long.TYPE -> "J" + java.lang.Float.TYPE -> "F" + java.lang.Double.TYPE -> "D" + else -> "L${name.replace('.', '/')};" + } + + private companion object { + // Gradle runs tests from the module directory. + val POPUP_PANEL = + File("src/main/native/macos/popup_panel.m").also { + require(it.isFile) { "missing ${it.absolutePath}" } + } + val EVENTS_RS = + File( + "src/main/native/src/events.rs", + ).also { require(it.isFile) { "missing ${it.absolutePath}" } } + + val GET_METHOD_ID = Regex("""GetMethodID\(env,\s*\w+,\s*"(\w+)",\s*"([^"]+)"\)""") + val RUST_CODE = Regex("""pub\(crate\) const SCROLL_GESTURE_(\w+): jint = (\d+);""") + val OBJC_CODE = Regex("""NucleusScrollGesture(\w+)\s*=\s*(-?\d+)""") + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWindowScrollTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWindowScrollTest.kt index d3c0fc424..566125ef9 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWindowScrollTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWindowScrollTest.kt @@ -22,17 +22,12 @@ class TaoWindowScrollTest { assertEquals(-1f, event.dxAwt) assertEquals(2f, event.dyAwt) assertEquals(1, event.scrollAmount) - assertEquals(TaoScrollGesturePhase.NONE, event.gesturePhase) + assertEquals(null, event.gesturePhase) } @Test fun scrollGestureIsShapedLikePixelScrollWithItsPhase() { - var event: TaoPointerScrollEvent? = null - TaoWindow(handle = 1L).apply { - onPointerScroll { event = it } - dispatchScrollGesture(TaoScrollGesturePhase.MOMENTUM_CHANGED, dxFixed = 1000, dyFixed = -2000) - } - val gesture = requireNotNull(event) + val gesture = dispatchGesture(TaoScrollGesturePhase.MOMENTUM_CHANGED.wire, dxFixed = 1000, dyFixed = -2000) assertEquals(-1f, gesture.dxAwt) assertEquals(2f, gesture.dyAwt) @@ -40,6 +35,27 @@ class TaoWindowScrollTest { assertEquals(TaoScrollGesturePhase.MOMENTUM_CHANGED, gesture.gesturePhase) } + @Test + fun unknownGestureWireCodeDegradesToPlainPreciseScroll() { + val event = dispatchGesture(phaseWire = 99, dxFixed = 0, dyFixed = -1000) + + assertEquals(1f, event.dyAwt) + assertEquals(null, event.gesturePhase) + } + + private fun dispatchGesture( + phaseWire: Int, + dxFixed: Int, + dyFixed: Int, + ): TaoPointerScrollEvent { + var event: TaoPointerScrollEvent? = null + TaoWindow(handle = 1L).apply { + onPointerScroll { event = it } + dispatchScrollGesture(phaseWire, dxFixed, dyFixed) + } + return requireNotNull(event) + } + private fun dispatchScroll( code: Int, dx: Int, diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt index 19708e551..646d91f90 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt @@ -56,18 +56,12 @@ class MacOsWheelDeltaTest { @Test fun gesturePhaseRidesAlongForPreciseEventsOnly() { // Popups forward the AppKit phase; a wheel notch can never be a gesture step. - val step = - appKitWheelToAwtScrollEvent( - dx = 0f, - dy = -10f, - precise = true, - gesturePhase = TaoScrollGesturePhase.CHANGED, - ) + val changed = TaoScrollGesturePhase.CHANGED.wire + val step = appKitWheelToAwtScrollEvent(dx = 0f, dy = -10f, precise = true, gesturePhaseWire = changed) assertEquals(TaoScrollGesturePhase.CHANGED, step.gesturePhase) assertEquals(1f, step.dyAwt, absoluteTolerance = 0f) - val notch = - appKitWheelToAwtScrollEvent(dx = 0f, dy = 1f, precise = false, gesturePhase = TaoScrollGesturePhase.CHANGED) - assertEquals(TaoScrollGesturePhase.NONE, notch.gesturePhase) + val notch = appKitWheelToAwtScrollEvent(dx = 0f, dy = 1f, precise = false, gesturePhaseWire = changed) + assertEquals(null, notch.gesturePhase) } @Test diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupPanelJniSignatureDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupPanelJniSignatureDriftTest.kt deleted file mode 100644 index 56a5bdbbf..000000000 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupPanelJniSignatureDriftTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package dev.nucleusframework.window.tao.popup - -import dev.nucleusframework.window.tao.ffi.PopupNativeBridge -import java.io.File -import java.lang.reflect.Method -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * `popup_panel.m` resolves the [PopupNativeBridge.EventCallback] methods by - * hand-written JNI descriptors (`GetMethodID(..., "onScroll", "(FFFFZI)V")`). - * A descriptor that drifts from the Kotlin signature fails silently at run - * time: the lookup throws, the callback cache never initialises and the popup - * simply stops receiving input. Compare the two here, where it is loud. - */ -class PopupPanelJniSignatureDriftTest { - @Test - fun `popup_panel m GetMethodID descriptors match the Kotlin callback`() { - val source = File("src/main/native/macos/popup_panel.m") - assertTrue(source.isFile, "expected ${source.absolutePath} (run from the module directory)") - val declared = - GET_METHOD_ID - .findAll(source.readText()) - .associate { it.groupValues[1] to it.groupValues[2] } - .filterKeys { it != "onOutsideClick" } // lives on a different listener class - assertTrue(declared.isNotEmpty(), "no GetMethodID(...) found in popup_panel.m") - - val callback = PopupNativeBridge.EventCallback::class.java - declared.forEach { (name, descriptor) -> - val method = - callback.methods.singleOrNull { it.name == name } - ?: error("popup_panel.m looks up '$name' but EventCallback has no single method of that name") - assertEquals(descriptor, method.jniDescriptor(), "JNI descriptor of EventCallback.$name") - } - } - - private fun Method.jniDescriptor(): String = - parameterTypes.joinToString(prefix = "(", postfix = ")", separator = "") { it.descriptor() } + - returnType.descriptor() - - private fun Class<*>.descriptor(): String = - when (this) { - Void.TYPE -> "V" - java.lang.Boolean.TYPE -> "Z" - java.lang.Integer.TYPE -> "I" - java.lang.Long.TYPE -> "J" - java.lang.Float.TYPE -> "F" - java.lang.Double.TYPE -> "D" - else -> "L${name.replace('.', '/')};" - } - - private companion object { - val GET_METHOD_ID = Regex("""GetMethodID\(env,\s*\w+,\s*"(\w+)",\s*"([^"]+)"\)""") - } -} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt index 3f4e4465f..086ecfeec 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt @@ -417,6 +417,11 @@ internal class TaoSceneTestScope( button: PointerButton, pressed: Boolean, ) { + // Like the hosts: a click ends an open trackpad pan first. + if (pressed) { + scrollRouter.finishPan() + legacyScrollRouter.finishPan() + } if (!hasReceivedCursorMove) return // host guard: no click before a cursor move val modifiers = taoKeyboardModifiers(modifierState) if (pressed && isPressed) { diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt index 2dbe5b6a8..8d58affed 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt @@ -170,7 +170,7 @@ class TaoSceneTrackpadPanTest { } private fun gestureStep( - phase: Int, + phase: TaoScrollGesturePhase, dyAwt: Float, ) = TaoPointerScrollEvent(dxAwt = 0f, dyAwt = dyAwt, scrollAmount = 1, gesturePhase = phase) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt index 1c64079cd..9bd6d9c82 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt @@ -11,7 +11,8 @@ import kotlin.test.assertTrue /** * State machine of [TaoTrackpadPanRouter] (#654) against a hand-driven * scheduler: the finger `Ended` must defer `PanEnd` so AppKit's momentum tail - * continues the same pan, and a swipe with no tail must still close. + * continues the same pan, a swipe with no tail must still close, and no + * truncated stream may leave the pan open. */ class TaoTrackpadPanRouterTest { private class Harness { @@ -35,8 +36,8 @@ class TaoTrackpadPanRouterTest { send = { type, delta -> sent += type to delta }, ) - /** Fires the deferred end as the grace timer would. */ - fun elapseGrace() { + /** Fires the pending end timer (grace or stall) as the scheduler would. */ + fun elapseTimer() { val action = pending ?: return pending = null action() @@ -60,7 +61,7 @@ class TaoTrackpadPanRouterTest { assertTrue(h.hasPendingEnd, "Ended must only schedule the PanEnd") assertEquals(TaoTrackpadPanRouter.momentumGraceMillis, h.lastDelayMillis) - h.elapseGrace() + h.elapseTimer() assertEquals( listOf(PointerEventType.PanStart, PointerEventType.PanMove, PointerEventType.PanEnd), h.types(), @@ -76,7 +77,7 @@ class TaoTrackpadPanRouterTest { h.router.onGesture(TaoScrollGesturePhase.ENDED, down) assertEquals(listOf(PointerEventType.PanStart, PointerEventType.PanMove), h.types()) assertTrue(h.hasPendingEnd) - h.elapseGrace() + h.elapseTimer() assertEquals(PointerEventType.PanEnd, h.types().last()) h.sent.clear() @@ -108,10 +109,45 @@ class TaoTrackpadPanRouterTest { ), h.types(), ) - assertEquals(1, h.cancelled, "the momentum Began must cancel the deferred PanEnd") assertFalse(h.hasPendingEnd) - // A stale grace timer firing later must not emit a second PanEnd. - h.elapseGrace() + // A stale timer firing later must not emit a second PanEnd. + h.elapseTimer() + assertEquals(1, h.types().count { it == PointerEventType.PanEnd }) + } + + @Test + fun `fingers resting on the glass during the tail close the pan at once`() { + // AppKit interrupts a momentum tail with MayBegin and does not always + // follow with MomentumEnded; the next swipe must get its own PanStart. + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_BEGAN, down) + h.router.onGesture(TaoScrollGesturePhase.MAY_BEGIN, Offset.Zero) + assertEquals(PointerEventType.PanEnd, h.types().last()) + assertFalse(h.hasPendingEnd) + + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + assertEquals(2, h.types().count { it == PointerEventType.PanStart }) + } + + @Test + fun `a truncated stream is closed by the stall watchdog`() { + // Every open step arms a stall timer, so a tail that simply stops + // (window lost key status, terminal step never delivered) still ends. + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + assertTrue(h.hasPendingEnd, "an open pan must always have an end timer armed") + assertEquals(TaoTrackpadPanRouter.DEFAULT_STALL_MILLIS, h.lastDelayMillis) + + h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_BEGAN, down) + assertEquals(TaoTrackpadPanRouter.DEFAULT_STALL_MILLIS, h.lastDelayMillis, "momentum re-arms the stall timer") + h.elapseTimer() + assertEquals(PointerEventType.PanEnd, h.types().last()) assertEquals(1, h.types().count { it == PointerEventType.PanEnd }) } @@ -161,6 +197,18 @@ class TaoTrackpadPanRouterTest { assertEquals(1, h.types().count { it == PointerEventType.PanStart }) assertEquals(0, h.types().count { it == PointerEventType.PanEnd }) + } + + @Test + fun `finishNow closes an open pan and is a no-op otherwise`() { + val h = Harness() + h.router.finishNow() + assertTrue(h.sent.isEmpty()) + + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + h.router.finishNow() + assertEquals(PointerEventType.PanEnd, h.types().last()) assertFalse(h.hasPendingEnd) } @@ -172,7 +220,7 @@ class TaoTrackpadPanRouterTest { h.router.cancel() assertFalse(h.hasPendingEnd) - h.elapseGrace() + h.elapseTimer() assertEquals(listOf(PointerEventType.PanStart), h.types()) } } From f93cc8cbdbc56449e289017d69449628169018dc Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 12:45:36 +0300 Subject: [PATCH 4/7] fix(tao): give native views the whole pan, never stack a late momentum tail (review 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../nucleusframework/window/tao/NativeView.kt | 45 ++++++++---- .../window/tao/TaoEventConstants.kt | 4 +- .../window/tao/event/MacOsWheelDelta.kt | 7 +- .../window/tao/ffi/NativeTaoBridge.kt | 1 - .../tao/ffi/NativeTaoMacOsNativeViewBridge.kt | 1 + .../tao/popup/TaoStandalonePopupHostMac.kt | 7 +- .../window/tao/scene/TaoComposeSceneHost.kt | 2 + .../tao/scene/TaoComposeSceneHostLinux.kt | 2 + .../tao/scene/TaoComposeSceneHostWindows.kt | 2 + .../window/tao/scene/TaoSceneScrollRouter.kt | 32 +++++---- .../window/tao/scene/TaoTrackpadPanRouter.kt | 28 ++++++-- .../src/main/native/macos/NucleusTaoMetal.m | 68 +++++++++---------- .../src/main/native/macos/native_view.m | 41 +++++++++-- .../window/tao/TaoSceneTestBattery.kt | 7 +- .../window/tao/TaoScrollWireDriftTest.kt | 33 +++++---- .../window/tao/event/MacOsWheelDeltaTest.kt | 11 ++- .../MacOsTrackpadScrollHeadfulCases.kt | 13 +++- .../window/tao/headful/MacScrollWheelProbe.kt | 3 +- .../window/tao/scene/TaoSceneTestHarness.kt | 4 +- .../tao/scene/TaoTrackpadPanRouterTest.kt | 21 +++++- .../com/example/demo/ScrollTestScreen.kt | 42 +++++++++--- 21 files changed, 261 insertions(+), 113 deletions(-) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt index 337336a33..f7fbfe7c8 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt @@ -272,25 +272,21 @@ private fun Modifier.nativeViewPointerInterop( yPx, change.scrollDelta.x, change.scrollDelta.y, + TaoNativeViewHost.SCROLL_WHEEL, ) true } + // Trackpad pan (#654): the whole gesture belongs to the + // native view — begin and end included, so its own + // scroll view finishes rubber-banding / fades its + // scrollers — and is consumed so the Compose scrollable + // above never opens a pan session of its own. Offsets + // are handed over in AWT wheel units: panOffset is + // 10 dp per unit (see TaoSceneScrollRouter). PointerEventType.PanStart, + PointerEventType.PanMove, PointerEventType.PanEnd, -> { - // Consumed, not forwarded: the gesture belongs to - // the native view, so the Compose scrollable above - // must not open a pan session of its own, and the - // native side would only replay `NSApp.currentEvent` - // — stale for the deferred PanEnd. - true - } - PointerEventType.PanMove -> { - // Trackpad pan (#654), handed over in AWT wheel - // units: panOffset is 10 dp per unit (see - // TaoSceneScrollRouter), so the native view keeps - // scrolling under a two-finger swipe exactly like - // under a wheel. val unitPx = AWT_PIXEL_TO_ROTATION * density host.dispatchScrollToNative( handle, @@ -298,6 +294,11 @@ private fun Modifier.nativeViewPointerInterop( yPx, change.panOffset.x / unitPx, change.panOffset.y / unitPx, + when (event.type) { + PointerEventType.PanStart -> TaoNativeViewHost.PAN_START + PointerEventType.PanEnd -> TaoNativeViewHost.PAN_END + else -> TaoNativeViewHost.PAN_MOVE + }, ) true } @@ -357,16 +358,32 @@ internal interface TaoNativeViewHost { ) { } - /** Forwards an unconsumed Compose scroll onto the native view. */ + /** + * Forwards an unconsumed Compose scroll onto the native view. [dx] / [dy] + * are AWT wheel units; [phase] is one of [SCROLL_WHEEL], [PAN_START], + * [PAN_MOVE], [PAN_END] so the native side can hand the embedded view a + * gesture with a proper begin and end (macOS replays the live AppKit event + * when it is the matching one, else synthesises a phased scroll). + */ + @Suppress("LongParameterList") fun dispatchScrollToNative( handle: Long, xPx: Float, yPx: Float, dx: Float, dy: Float, + phase: Int, ) { } + companion object { + /** Mouse-wheel notch / phase-less precise scroll. */ + const val SCROLL_WHEEL: Int = 0 + const val PAN_START: Int = 1 + const val PAN_MOVE: Int = 2 + const val PAN_END: Int = 3 + } + /** * Marks that the in-flight pointer Press was handed to a native * view (so the host must not steal first-responder back). diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt index 352f64283..d31f4c62e 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt @@ -130,8 +130,10 @@ internal enum class TaoScrollGesturePhase( /** Wire code for "not a gesture step" (only the popup wire carries it). */ const val NONE_WIRE: Int = -1 + private val byWire: Map = entries.associateBy { it.wire } + /** `null` for [NONE_WIRE] and for any code this build does not know. */ - fun fromWire(code: Int): TaoScrollGesturePhase? = entries.firstOrNull { it.wire == code } + fun fromWire(code: Int): TaoScrollGesturePhase? = byWire[code] } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt index b514a8d05..9dcedcbd2 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDelta.kt @@ -49,7 +49,10 @@ internal fun appKitWheelToAwtScrollEvent( // lines-per-notch multiplier out of scrollAmount the way LinuxGtkConfig // does. Do not copy LINUX_AWT_SCROLL_AMOUNT_DEFAULT here. scrollAmount = MACOS_AWT_SCROLL_AMOUNT, - // A wheel notch has no phase; only precise events can belong to a gesture. - gesturePhase = if (precise) TaoScrollGesturePhase.fromWire(gesturePhaseWire) else null, + // The phase, not the precision flag, says whether this step belongs to + // a gesture: AppKit has been seen reporting a zero-delta terminal step + // with hasPreciseScrollingDeltas == NO, and dropping its phase would + // close the pan mid-gesture (the Rust window path routes the same way). + gesturePhase = TaoScrollGesturePhase.fromWire(gesturePhaseWire), ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt index 5e0ea85c9..bd7174633 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt @@ -126,7 +126,6 @@ internal object NativeTaoBridge { * * Default implementation no-ops so non-macOS callers can ignore it. */ - @Suppress("FunctionParameterNaming") fun onScrollGesture( handle: Long, phase: Int, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsNativeViewBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsNativeViewBridge.kt index f48c29476..ec0048edb 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsNativeViewBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsNativeViewBridge.kt @@ -88,6 +88,7 @@ internal object NativeTaoMacOsNativeViewBridge { yPx: Float, dx: Float, dy: Float, + phase: Int, ) /** Makes [nsView] the window's first responder (native IME / typing). */ diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt index 625d0933d..1245ed5ad 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt @@ -296,7 +296,10 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { override fun dispose() { if (!isValid) { + // Never came up (bridges missing, panel creation failed): only the + // eagerly created pieces need releasing. scrollRouter.cancel() + renderExecutor.shutdown() return } if (disposed) return @@ -440,8 +443,10 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { TaoNativeWireFormat.PTR_UP -> PointerEventType.Release else -> PointerEventType.Move } - if (eventType == PointerEventType.Press) scrollRouter.finishPan() framePump.nonReentrant { + // A click ends an open trackpad pan first — inside the pump, + // like every other scene dispatch here. + if (eventType == PointerEventType.Press) scrollRouter.finishPan() sc.sendPointerEvent( eventType = eventType, position = Offset(x, y), diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index d21606bde..05db862d4 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -846,6 +846,7 @@ internal class TaoComposeSceneHost( yPx: Float, dx: Float, dy: Float, + phase: Int, ) { if (outer.nsViewHandle == 0L || handle == 0L) return NativeTaoMacOsNativeViewBridge.nativeDispatchScroll( @@ -855,6 +856,7 @@ internal class TaoComposeSceneHost( yPx, dx, dy, + phase, ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt index d2269c1f0..54f92e34e 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt @@ -2213,7 +2213,9 @@ internal class TaoComposeSceneHostLinux( yPx: Float, dx: Float, dy: Float, + phase: Int, ) { + // GTK gets a plain scroll-event per step; phases are macOS only. val s = if (outer.scale > 0f) outer.scale else 1f val rect = outer.nativeViewRects[handle] val xLogical = ((xPx - (rect?.get(0)?.toFloat() ?: 0f)) / s).toInt() diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt index 32a7a6680..bd35e749b 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt @@ -1745,7 +1745,9 @@ internal class TaoComposeSceneHostWindows( yPx: Float, dx: Float, dy: Float, + phase: Int, ) { + // Windows has no gesture phases on the WM_MOUSEWHEEL wire. if (parent == 0L) return outer.nativePointerRedispatchInFlight = true try { diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt index 5f00fee07..2ccbfa148 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt @@ -5,7 +5,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.scene.ComposeScene -import dev.nucleusframework.window.tao.TaoFatalCoroutineExceptionHandler +import dev.nucleusframework.window.tao.TaoNonFatalCoroutineExceptionHandler import dev.nucleusframework.window.tao.TaoPointerScrollEvent import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION @@ -47,8 +47,8 @@ import java.util.logging.Logger * `-Dnucleus.tao.trackpadPanEvents=false` restores the AWT-style behaviour * where every gesture step is a `Scroll`. * - * [schedule] is only supplied by tests; production routers own a coroutine - * scope on the UI dispatcher for the end timer. UI thread only. + * [schedule] is only supplied by tests; production routers lazily own a + * coroutine scope on the UI dispatcher for the end timer. UI thread only. */ @OptIn(InternalComposeUiApi::class) internal class TaoSceneScrollRouter( @@ -65,22 +65,27 @@ internal class TaoSceneScrollRouter( /** * Wraps the deferred `PanEnd` delivery. Hosts route it through their - * window exception handler / frame pump; whatever escapes lands in - * [TaoFatalCoroutineExceptionHandler], never in the default handler. + * window exception handler / frame pump; whatever escapes is logged by + * [TaoNonFatalCoroutineExceptionHandler] — a broken PanEnd costs one + * gesture, not the app, exactly like the synchronous popup path where + * `popup_panel.m` clears the pending JNI exception. */ fun guard(block: () -> Unit) = block() } - private val scope: CoroutineScope? = - if (schedule == null) { - CoroutineScope(TaoMainDispatcher + SupervisorJob() + TaoFatalCoroutineExceptionHandler) - } else { - null - } + private val testSchedule = schedule + + // Created on the first deferred end: most popup layers never see a + // trackpad gesture and must not pay for a scope each. + private var scope: CoroutineScope? = null + + private fun timerScope(): CoroutineScope = + scope ?: CoroutineScope(TaoMainDispatcher + SupervisorJob() + TaoNonFatalCoroutineExceptionHandler) + .also { scope = it } private val pan = TaoTrackpadPanRouter( - schedule = schedule ?: ::scheduleOnMain, + schedule = testSchedule ?: ::scheduleOnMain, send = ::sendPan, ) @@ -129,6 +134,7 @@ internal class TaoSceneScrollRouter( cancelled = true pan.cancel() scope?.cancel() + scope = null } private fun sendPan( @@ -149,7 +155,7 @@ internal class TaoSceneScrollRouter( action: () -> Unit, ): () -> Unit { val job = - requireNotNull(scope).launch { + timerScope().launch { delay(delayMillis) target.guard(action) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt index d6357a612..0b3caae08 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt @@ -24,10 +24,12 @@ import dev.nucleusframework.window.tao.TaoScrollGesturePhase * after the finger `Ended`, [stallMillis] otherwise), so a stream that is cut * short — fingers resting on the glass during the tail (`MayBegin`, which * closes the pan at once), a window losing key status, a terminal step that - * never arrives — cannot leave Compose's scroll session open. A terminal step + * never arrives — cannot leave Compose's scroll session open. A finger step * that still carries a delta is delivered even when no pan is open (AppKit's * `Ended` can hold the last finger movement, and a `Began` may have been - * missed), so no scroll distance is dropped. + * missed), so no finger distance is dropped; momentum steps, in contrast, only + * continue an open pan — a late tail after the grace already closed the pan + * is dropped rather than stacked on Compose's fling. * * [send] receives the pan offset in AWT `preciseWheelRotation` units (the * shape of [dev.nucleusframework.window.tao.TaoPointerScrollEvent.dxAwt]); the @@ -54,8 +56,6 @@ internal class TaoTrackpadPanRouter( TaoScrollGesturePhase.MAY_BEGIN -> finish() TaoScrollGesturePhase.BEGAN, TaoScrollGesturePhase.CHANGED, - TaoScrollGesturePhase.MOMENTUM_BEGAN, - TaoScrollGesturePhase.MOMENTUM_CHANGED, -> { start() move(deltaAwt) @@ -65,9 +65,25 @@ internal class TaoTrackpadPanRouter( move(deltaAwt) if (active) armEnd(graceMillis) } - TaoScrollGesturePhase.CANCELLED, - TaoScrollGesturePhase.MOMENTUM_ENDED, + TaoScrollGesturePhase.CANCELLED -> { + move(deltaAwt) + finish() + } + // The inertial tail only ever continues an open pan. Once the pan + // is closed — the grace elapsed before AppKit's first momentum + // step, or the Began was never seen — Compose's own fling is + // running, and opening a second pan would stack the platform + // inertia on top of it (content overshoots by ~2×). Dropping the + // late tail is the safe outcome. + TaoScrollGesturePhase.MOMENTUM_BEGAN, + TaoScrollGesturePhase.MOMENTUM_CHANGED, -> { + if (!active) return + move(deltaAwt) + armEnd(stallMillis) + } + TaoScrollGesturePhase.MOMENTUM_ENDED -> { + if (!active) return move(deltaAwt) finish() } diff --git a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m index 56013e8f9..aaef74af6 100644 --- a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m +++ b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m @@ -2874,14 +2874,19 @@ static void ensureInteropModeSource(void) { } /* macOS only, headful e2e (#652 / #653 / #654): hands a synthetic - * `scrollWheel:` NSEvent to the tao content view — the entry point a real + * `scrollWheel:` NSEvent to the tao NSView passed in — the entry point a real * trackpad or wheel event takes once the WindowServer has routed it. Skipping * the WindowServer (CGEventPost) means no Accessibility grant and no cursor * parked over the window are needed, and the delivery is deterministic. * - * (x, y) are content-view-local points with a top-left origin (Compose dp). + * (x, y) are view-local points with a top-left origin (Compose dp). * (dx, dy) are AppKit `scrollingDelta*` values: points when `precise` * (`hasPreciseScrollingDeltas == YES`, trackpad), lines otherwise (wheel). + * They are whole numbers by construction: the CGEvent point/line delta fields + * are integers and `+[NSEvent eventWithCGEvent:]` derives `scrollingDelta*` + * from them (setting the fixed-point fields only changes the legacy + * `deltaX/Y`) — verified, not assumed; cases that need sub-point steps have + * to go through the JVM-side scene harness instead. * `phase` / `momentumPhase` use the IOHID field encodings that * `+[NSEvent eventWithCGEvent:]` decodes into `NSEventPhase` — phase: 1 began, * 2 changed, 4 ended, 8 cancelled, 128 may-begin; momentum: 1 began, 2 changed, @@ -2896,9 +2901,9 @@ static void ensureInteropModeSource(void) { * This DRIVES the app rather than reading it, so unlike the other nativeDiag* * entries it is inert unless the process was started with * NUCLEUS_TAO_INPUT_INJECTION=1 (the taoHeadfulTest Gradle task sets it) and - * it only runs on the main thread — no dispatch_sync that a caller holding a - * lock the main thread wants could deadlock on. Returns JNI false when - * disabled, off the main thread, or when the view / window is gone. */ + * it only runs on the main thread. Returns JNI false when disabled, off the + * main thread, or when the view, its window or the primary screen is gone; + * JNI true only once `scrollWheel:` was actually sent to the given view. */ JNIEXPORT jboolean JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeMetalBridge_nativeDiagInjectScrollWheel( JNIEnv *env, jclass clazz, jlong nsViewPtr, @@ -2911,36 +2916,29 @@ static void ensureInteropModeSource(void) { sEnabled = (flag != NULL && strcmp(flag, "1") == 0) ? 1 : 0; } if (!sEnabled || nsViewPtr == 0 || ![NSThread isMainThread]) return JNI_FALSE; - void *rawPtr = (void *)(uintptr_t)nsViewPtr; - __block jboolean delivered = JNI_FALSE; - dispatch_block_t deliver = ^{ - NSView *view = (__bridge NSView *)rawPtr; - NSWindow *window = view.window; - NSView *content = window.contentView; - if (window == nil || content == nil) return; - // Content-local top-left → window base coordinates (bottom-left). - NSPoint local = NSMakePoint(x, content.isFlipped ? y : content.bounds.size.height - y); - NSPoint inWindow = [content convertPoint:local toView:nil]; - CGEventRef cg = CGEventCreateScrollWheelEvent( - NULL, precise ? kCGScrollEventUnitPixel : kCGScrollEventUnitLine, 2, - (int32_t)lroundf(dy), (int32_t)lroundf(dx)); - if (cg == NULL) return; - if (phase != 0) { - CGEventSetIntegerValueField(cg, kCGScrollWheelEventScrollPhase, phase); - } - if (momentumPhase != 0) { - CGEventSetIntegerValueField(cg, kCGScrollWheelEventMomentumPhase, momentumPhase); - } - CGFloat primaryHeight = NSScreen.screens.firstObject.frame.size.height; - CGEventSetLocation(cg, CGPointMake(inWindow.x, primaryHeight - inWindow.y)); - NSEvent *event = [NSEvent eventWithCGEvent:cg]; - CFRelease(cg); - if (event == nil) return; - [content scrollWheel:event]; - delivered = JNI_TRUE; - }; - deliver(); - return delivered; + NSView *view = (__bridge NSView *)(void *)(uintptr_t)nsViewPtr; + NSWindow *window = view.window; + NSScreen *primary = NSScreen.screens.firstObject; + if (window == nil || primary == nil) return JNI_FALSE; + // View-local top-left → window base coordinates (bottom-left). + NSPoint local = NSMakePoint(x, view.isFlipped ? y : view.bounds.size.height - y); + NSPoint inWindow = [view convertPoint:local toView:nil]; + CGEventRef cg = CGEventCreateScrollWheelEvent( + NULL, precise ? kCGScrollEventUnitPixel : kCGScrollEventUnitLine, 2, + (int32_t)lroundf(dy), (int32_t)lroundf(dx)); + if (cg == NULL) return JNI_FALSE; + if (phase != 0) { + CGEventSetIntegerValueField(cg, kCGScrollWheelEventScrollPhase, phase); + } + if (momentumPhase != 0) { + CGEventSetIntegerValueField(cg, kCGScrollWheelEventMomentumPhase, momentumPhase); + } + CGEventSetLocation(cg, CGPointMake(inWindow.x, primary.frame.size.height - inWindow.y)); + NSEvent *event = [NSEvent eventWithCGEvent:cg]; + CFRelease(cg); + if (event == nil) return JNI_FALSE; + [view scrollWheel:event]; + return JNI_TRUE; } /* CFGetRetainCount of view.window. Only deltas are meaningful (AppKit holds diff --git a/decorated-window-tao/src/main/native/macos/native_view.m b/decorated-window-tao/src/main/native/macos/native_view.m index 6df76e442..77abad6f7 100644 --- a/decorated-window-tao/src/main/native/macos/native_view.m +++ b/decorated-window-tao/src/main/native/macos/native_view.m @@ -457,11 +457,32 @@ static NSPoint window_point_from_compose_px(NSView *content, jfloat xPx, jfloat } } +/* Phase of a Compose scroll handed to the native view — mirrors Kotlin + * `TaoNativeViewHost.SCROLL_WHEEL / PAN_START / PAN_MOVE / PAN_END`. */ +enum { kNvScrollWheel = 0, kNvPanStart = 1, kNvPanMove = 2, kNvPanEnd = 3 }; + +/* Is `event` the live AppKit scroll event this Compose step came from? A + * wheel step matches any scrollWheel event (previous behaviour); a pan step + * must match its phase class, because a PanEnd deferred by the pan router's + * grace timer runs long after AppKit moved on to some unrelated event. */ +static BOOL nvScrollEventMatches(NSEvent *event, jint phase) { + if (event == nil || event.type != NSEventTypeScrollWheel) return NO; + NSEventPhase p = event.phase, m = event.momentumPhase; + switch (phase) { + case kNvPanStart: return (p & (NSEventPhaseBegan | NSEventPhaseMayBegin)) != 0; + case kNvPanMove: return (p & (NSEventPhaseChanged | NSEventPhaseStationary)) != 0 + || (m & (NSEventPhaseBegan | NSEventPhaseChanged)) != 0; + case kNvPanEnd: return (p & (NSEventPhaseEnded | NSEventPhaseCancelled)) != 0 + || (m & (NSEventPhaseEnded | NSEventPhaseCancelled)) != 0; + default: return YES; + } +} + JNIEXPORT void JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsNativeViewBridge_nativeDispatchScroll( JNIEnv *env, jclass clazz, jlong contentPtr, jlong childPtr, - jfloat xPx, jfloat yPx, jfloat dx, jfloat dy) + jfloat xPx, jfloat yPx, jfloat dx, jfloat dy, jint phase) { (void)env; (void)clazz; NSView *content = view_from_long(contentPtr); @@ -471,23 +492,33 @@ static NSPoint window_point_from_compose_px(NSView *content, jfloat xPx, jfloat NSView *hit = hit_native_child(child, windowPoint); if (hit == nil) return; // Prefer the original AppKit event: same sign, precise-pixel flag, - // momentum phase. Tao queues the scroll onto Compose on the same - // turn, so currentEvent is still the scrollWheel that started this. + // gesture / momentum phase. The pan router dispatches each step on the + // same turn as the scrollWheel that produced it, so currentEvent is + // still that event — except for the deferred PanEnd, caught by the + // phase check below. NSEvent *current = NSApp.currentEvent; - if (current != nil && current.type == NSEventTypeScrollWheel) { + if (nvScrollEventMatches(current, phase)) { [hit scrollWheel:current]; return; } // Fallback: Compose/AWT scrollDelta is the inverse of AppKit // `scrollingDelta` on both axes (TaoWindow.kt SCROLL_PIXEL/LINE, #652) // and precise deltas are divided by 10. Reconstruct AppKit points: - // native = -awt * 10 for X and Y alike. + // native = -awt * 10 for X and Y alike, and give a pan step the phase + // the native scroll view expects (IOHID encoding, see popup_panel.m / + // NucleusTaoMetal.m: 1 began, 2 changed, 4 ended). const float kAwtPixelToRotation = 10.f; CGEventRef cg = CGEventCreateScrollWheelEvent( NULL, kCGScrollEventUnitPixel, 2, (int32_t)lroundf(-dy * kAwtPixelToRotation), (int32_t)lroundf(-dx * kAwtPixelToRotation)); if (cg == NULL) return; + switch (phase) { + case kNvPanStart: CGEventSetIntegerValueField(cg, kCGScrollWheelEventScrollPhase, 1); break; + case kNvPanMove: CGEventSetIntegerValueField(cg, kCGScrollWheelEventScrollPhase, 2); break; + case kNvPanEnd: CGEventSetIntegerValueField(cg, kCGScrollWheelEventScrollPhase, 4); break; + default: break; + } CGEventSetLocation(cg, NSPointToCGPoint( [hit.window convertRectToScreen:NSMakeRect(windowPoint.x, windowPoint.y, 0, 0)].origin)); NSEvent *event = [NSEvent eventWithCGEvent:cg]; diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index 2502bc18e..80b77476a 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -144,8 +144,8 @@ public object TaoSceneTestBattery { run("MacOsWheelDeltaTest: preciseDeltaCarriesMacOsScrollAmount") { MacOsWheelDeltaTest().preciseDeltaCarriesMacOsScrollAmount() } - run("MacOsWheelDeltaTest: gesturePhaseRidesAlongForPreciseEventsOnly") { - MacOsWheelDeltaTest().gesturePhaseRidesAlongForPreciseEventsOnly() + run("MacOsWheelDeltaTest: gesturePhaseRidesAlongWhateverThePrecisionFlag") { + MacOsWheelDeltaTest().gesturePhaseRidesAlongWhateverThePrecisionFlag() } run("StandaloneFramePumpTest: scheduleOnMainRunsInline") { StandaloneFramePumpTest().scheduleOnMainRunsInline() @@ -210,6 +210,9 @@ public object TaoSceneTestBattery { run("TaoTrackpadPanRouterTest: momentum tail continues the pan and ends it once") { TaoTrackpadPanRouterTest().`momentum tail continues the pan and ends it once`() } + run("TaoTrackpadPanRouterTest: a momentum tail arriving after the pan closed is dropped") { + TaoTrackpadPanRouterTest().`a momentum tail arriving after the pan closed is dropped`() + } run("TaoTrackpadPanRouterTest: fingers resting on the glass during the tail close the pan at once") { TaoTrackpadPanRouterTest().`fingers resting on the glass during the tail close the pan at once`() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt index b5f243dfa..b21434ff6 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt @@ -6,6 +6,7 @@ import java.lang.reflect.Method import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import kotlin.test.fail /** * The macOS scroll wire is written by hand in three places that the compiler @@ -22,10 +23,10 @@ class TaoScrollWireDriftTest { fun `popup_panel m GetMethodID descriptors match the Kotlin callback`() { val declared = GET_METHOD_ID - .findAll(POPUP_PANEL.readText()) + .findAll(popupPanel().readText()) .associate { it.groupValues[1] to it.groupValues[2] } .filterKeys { it != "onOutsideClick" } // lives on a different listener class - assertTrue(declared.isNotEmpty(), "no GetMethodID(...) found in ${POPUP_PANEL.path}") + assertTrue(declared.isNotEmpty(), "no GetMethodID(...) found in popup_panel.m") val callback = PopupNativeBridge.EventCallback::class.java declared.forEach { (name, descriptor) -> @@ -40,7 +41,7 @@ class TaoScrollWireDriftTest { fun `Rust SCROLL_GESTURE codes match TaoScrollGesturePhase`() { val rust = RUST_CODE - .findAll(EVENTS_RS.readText()) + .findAll(eventsRs().readText()) .associate { it.groupValues[1] to it.groupValues[2].toInt() } assertEquals(kotlinWire(), rust, "events.rs SCROLL_GESTURE_* vs TaoScrollGesturePhase.wire") } @@ -49,12 +50,26 @@ class TaoScrollWireDriftTest { fun `popup_panel m NucleusScrollGesture codes match TaoScrollGesturePhase`() { val objc = OBJC_CODE - .findAll(POPUP_PANEL.readText()) + .findAll(popupPanel().readText()) .associate { it.groupValues[1].toScreamingSnake() to it.groupValues[2].toInt() } assertEquals(TaoScrollGesturePhase.NONE_WIRE, objc["NONE"], "NucleusScrollGestureNone") assertEquals(kotlinWire(), objc - "NONE", "popup_panel.m NucleusScrollGesture* vs TaoScrollGesturePhase.wire") } + private fun popupPanel() = sourceFile("src/main/native/macos/popup_panel.m") + + private fun eventsRs() = sourceFile("src/main/native/src/events.rs") + + /** + * Gradle runs tests from the module directory; an IDE run configuration + * may use the repository root. Either way the failure names the file. + */ + private fun sourceFile(relative: String): File { + val candidates = listOf(File(relative), File("decorated-window-tao", relative)) + return candidates.firstOrNull { it.isFile } + ?: fail("cannot find $relative from ${File("").absolutePath} (tried ${candidates.map { it.path }})") + } + private fun kotlinWire(): Map = TaoScrollGesturePhase.entries.associate { it.name to it.wire } /** `MomentumBegan` → `MOMENTUM_BEGAN`, `MayBegin` → `MAY_BEGIN`. */ @@ -76,16 +91,6 @@ class TaoScrollWireDriftTest { } private companion object { - // Gradle runs tests from the module directory. - val POPUP_PANEL = - File("src/main/native/macos/popup_panel.m").also { - require(it.isFile) { "missing ${it.absolutePath}" } - } - val EVENTS_RS = - File( - "src/main/native/src/events.rs", - ).also { require(it.isFile) { "missing ${it.absolutePath}" } } - val GET_METHOD_ID = Regex("""GetMethodID\(env,\s*\w+,\s*"(\w+)",\s*"([^"]+)"\)""") val RUST_CODE = Regex("""pub\(crate\) const SCROLL_GESTURE_(\w+): jint = (\d+);""") val OBJC_CODE = Regex("""NucleusScrollGesture(\w+)\s*=\s*(-?\d+)""") diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt index 646d91f90..27ff50d07 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/MacOsWheelDeltaTest.kt @@ -54,13 +54,18 @@ class MacOsWheelDeltaTest { } @Test - fun gesturePhaseRidesAlongForPreciseEventsOnly() { - // Popups forward the AppKit phase; a wheel notch can never be a gesture step. + fun gesturePhaseRidesAlongWhateverThePrecisionFlag() { + // Popups forward the AppKit phase. A step reported without precise + // deltas keeps its phase too (AppKit does that for some zero-delta + // terminal steps) — dropping it would close the pan mid-gesture. val changed = TaoScrollGesturePhase.CHANGED.wire val step = appKitWheelToAwtScrollEvent(dx = 0f, dy = -10f, precise = true, gesturePhaseWire = changed) assertEquals(TaoScrollGesturePhase.CHANGED, step.gesturePhase) assertEquals(1f, step.dyAwt, absoluteTolerance = 0f) - val notch = appKitWheelToAwtScrollEvent(dx = 0f, dy = 1f, precise = false, gesturePhaseWire = changed) + val lineStep = appKitWheelToAwtScrollEvent(dx = 0f, dy = -1f, precise = false, gesturePhaseWire = changed) + assertEquals(TaoScrollGesturePhase.CHANGED, lineStep.gesturePhase) + assertEquals(1f, lineStep.dyAwt, absoluteTolerance = 0f) + val notch = appKitWheelToAwtScrollEvent(dx = 0f, dy = 1f, precise = false) assertEquals(null, notch.gesturePhase) } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt index 5bb088970..f3a7f3d18 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt @@ -295,15 +295,22 @@ internal object MacOsTrackpadScrollHeadfulCases { settle(STEP_MILLIS) inject(dx = 0f, dy = 0f, precise = true, phase = Phase.ENDED) if (momentum) { + // Whole points: the CGEvent delta fields are integers, so the + // injector cannot carry fractions (see nativeDiagInjectScrollWheel). settle(STEP_MILLIS) - inject(dx = dx / 2, dy = dy / 2, precise = true, momentum = Momentum.BEGAN) + inject(dx = momentumStep(dx), dy = momentumStep(dy), precise = true, momentum = Momentum.BEGAN) settle(STEP_MILLIS) - inject(dx = dx / 4, dy = dy / 4, precise = true, momentum = Momentum.CHANGED) + inject(dx = momentumTail(dx), dy = momentumTail(dy), precise = true, momentum = Momentum.CHANGED) settle(STEP_MILLIS) inject(dx = 0f, dy = 0f, precise = true, momentum = Momentum.ENDED) } } + /** Decaying momentum tail of a finger delta [d], in whole points. */ + private fun momentumStep(d: Float): Float = (d * MOMENTUM_STEP_RATIO).toInt().toFloat() + + private fun momentumTail(d: Float): Float = (d * MOMENTUM_TAIL_RATIO).toInt().toFloat() + private fun TaoWindowTestScope.inject( dx: Float, dy: Float, @@ -458,6 +465,8 @@ internal object MacOsTrackpadScrollHeadfulCases { /** AppKit points per injected finger move. */ private const val SWIPE_DELTA_PT = 10f private const val SWIPE_STEPS = 3 + private const val MOMENTUM_STEP_RATIO = 0.6f + private const val MOMENTUM_TAIL_RATIO = 0.3f private const val STEP_MILLIS = 16L private const val POLL_MILLIS = 16L diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacScrollWheelProbe.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacScrollWheelProbe.kt index 0a76a38cf..fe759cccf 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacScrollWheelProbe.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacScrollWheelProbe.kt @@ -11,7 +11,8 @@ import dev.nucleusframework.window.tao.ffi.NativeMetalBridge * `scroll_wheel`, the JNI loop and the Compose host all run for real. * * Deltas are raw AppKit `scrollingDelta*` values: points for a [precise] - * (trackpad) event, lines for a wheel notch. AppKit's sign convention is + * (trackpad) event, lines for a wheel notch — whole numbers only, the CGEvent + * delta fields are integers (fractions are rounded). AppKit's sign convention is * "positive = content moves down / right", i.e. a two-finger swipe *up* or * *left* (natural scrolling) is a negative delta. Compose / AWT use the * opposite sign; see `MacOsWheelDelta.kt`. diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt index 086ecfeec..ce0c91907 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt @@ -417,12 +417,12 @@ internal class TaoSceneTestScope( button: PointerButton, pressed: Boolean, ) { - // Like the hosts: a click ends an open trackpad pan first. + if (!hasReceivedCursorMove) return // host guard: no click before a cursor move + // Like the host, after the guard: a click ends an open trackpad pan first. if (pressed) { scrollRouter.finishPan() legacyScrollRouter.finishPan() } - if (!hasReceivedCursorMove) return // host guard: no click before a cursor move val modifiers = taoKeyboardModifiers(modifierState) if (pressed && isPressed) { scene.sendPointerEvent( diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt index 9bd6d9c82..92f807f36 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt @@ -81,7 +81,7 @@ class TaoTrackpadPanRouterTest { assertEquals(PointerEventType.PanEnd, h.types().last()) h.sent.clear() - h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_ENDED, down) + h.router.onGesture(TaoScrollGesturePhase.CANCELLED, down) assertEquals( listOf(PointerEventType.PanStart, PointerEventType.PanMove, PointerEventType.PanEnd), h.types(), @@ -89,6 +89,25 @@ class TaoTrackpadPanRouterTest { assertFalse(h.hasPendingEnd) } + @Test + fun `a momentum tail arriving after the pan closed is dropped`() { + // Grace elapsed before AppKit's first momentum step (loaded machine): + // Compose is already flinging; a second pan would stack the inertia. + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero) + h.elapseTimer() + assertEquals(PointerEventType.PanEnd, h.types().last()) + + h.sent.clear() + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_BEGAN, down) + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_CHANGED, down) + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_ENDED, down) + assertTrue(h.sent.isEmpty(), "late momentum must not open a second pan, got ${h.types()}") + assertFalse(h.hasPendingEnd) + } + @Test fun `momentum tail continues the pan and ends it once`() { val h = Harness() diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt index 9824902bc..7ee51f0bc 100644 --- a/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt @@ -52,6 +52,14 @@ import kotlin.math.roundToInt * i.e. after Compose's WindowsWinUIConfig `height/20` scaling). */ private const val IDLE_MS = 180L + +/** + * Pixels of trackpad pan per AWT wheel unit on the Tao backend: Compose + * Desktop's `MacOSCocoaConfig` turns one `preciseWheelRotation` into 10 dp, + * and Nucleus sizes `panOffset` the same way so both gestures move content + * equally (`decorated-window-tao` `AWT_PIXEL_TO_ROTATION`). + */ +private const val PAN_DP_PER_WHEEL_UNIT = 10f private const val ROWS = 600 private const val MAX_LOG = 14 @@ -66,6 +74,9 @@ private class ScrollMeter { var maxRawAbsY = 0f var lastRawY = 0f + // Set by PanEnd / a new PanStart: the gesture is over now, no need to wait for IDLE_MS. + var endRequested = false + // Monotonic frame-clock tick counter (incremented in the withFrameNanos // loop) and its value at gesture start. The difference over the gesture // window measures render FPS — the metric the scroll-cadence fix changes. @@ -116,15 +127,16 @@ fun ScrollTestScreen() { } } - // Finalize a gesture once the scroll events go quiet for IDLE_MS. Both this - // ticker and the pointer handler run on the UI dispatcher, so the shared - // ScrollMeter needs no extra synchronization. + // Finalize a gesture once its PanEnd arrived (trackpad on Tao) or the + // scroll events went quiet for IDLE_MS (wheel, or backends without pan + // events). Both the ticker and the pointer handler run on the UI + // dispatcher, so the shared ScrollMeter needs no extra synchronization. LaunchedEffect(Unit) { while (true) { delay(40) liveValue = scrollState.value val now = System.nanoTime() / 1_000_000 - if (meter.inGesture && now - meter.lastTimeMs >= IDLE_MS) { + if (meter.inGesture && (meter.endRequested || now - meter.lastTimeMs >= IDLE_MS)) { val px = scrollState.value - meter.startValuePx // Render FPS over the whole gesture window (start → finalize, i.e. // including the post-input animation tail) = frames rendered ÷ @@ -146,6 +158,7 @@ fun ScrollTestScreen() { ) if (gestures.size > MAX_LOG) gestures.removeAt(gestures.lastIndex) meter.inGesture = false + meter.endRequested = false } } } @@ -180,18 +193,27 @@ fun ScrollTestScreen() { val event = awaitPointerEvent(PointerEventPass.Initial) // Wheel notches arrive as Scroll (AWT wheel units); // on the Tao backend a trackpad gesture arrives as - // Pan with a pixel offset — 10 dp per wheel unit - // (Compose's MacOSCocoaConfig factor), so both are - // logged in the same unit. + // PanStart / PanMove / PanEnd with a pixel offset, + // logged in wheel units via PAN_DP_PER_WHEEL_UNIT. val change = event.changes.first() + val now = System.nanoTime() / 1_000_000 val d = when (event.type) { PointerEventType.Scroll -> change.scrollDelta - PointerEventType.PanMove -> change.panOffset / (10f * density) + PointerEventType.PanMove -> + change.panOffset / (PAN_DP_PER_WHEEL_UNIT * density) + PointerEventType.PanStart, + PointerEventType.PanEnd, + -> { + // A gesture boundary: close the open gesture + // now instead of merging across IDLE_MS. + if (meter.inGesture) meter.endRequested = true + continue + } else -> continue } - val now = System.nanoTime() / 1_000_000 - if (!meter.inGesture || now - meter.lastTimeMs > IDLE_MS) { + if (!meter.inGesture || meter.endRequested || now - meter.lastTimeMs > IDLE_MS) { + meter.endRequested = false meter.inGesture = true meter.startValuePx = scrollState.value meter.startTimeMs = now From 72ba91bca9e75913d31182078cfba23ff24579fc Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 13:10:02 +0300 Subject: [PATCH 5/7] fix(tao): deliver each AppKit scroll event to a native view once, carry 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 --- .../api/decorated-window-tao.api | 2 + .../nucleusframework/window/tao/TaoWindow.kt | 9 +- .../tao/popup/TaoStandalonePopupHostMac.kt | 6 +- .../window/tao/scene/TaoComposeSceneHost.kt | 22 +++-- .../window/tao/scene/TaoSceneScrollRouter.kt | 24 ++++- .../window/tao/scene/TaoTrackpadPanRouter.kt | 18 ++-- .../src/main/native/macos/native_view.m | 98 ++++++++++++++----- .../src/main/native/macos/popup_panel.m | 30 +++--- ...cos-scroll-phase-and-horizontal-sign.patch | 41 +++++--- .../tao/src/platform_impl/macos/view.rs | 35 ++++--- .../window/tao/TaoSceneTestBattery.kt | 10 +- .../window/tao/TaoScrollWireDriftTest.kt | 50 ++++++++++ .../window/tao/scene/TaoSceneScrollTest.kt | 26 +++++ .../window/tao/scene/TaoSceneTestHarness.kt | 41 +++++--- .../tao/scene/TaoSceneTrackpadPanTest.kt | 37 +++++++ .../tao/scene/TaoTrackpadPanRouterTest.kt | 17 ++-- .../com/example/demo/ScrollTestScreen.kt | 69 +++++++------ 17 files changed, 379 insertions(+), 156 deletions(-) diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index 1d332a9e5..d64a1f2b1 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -701,7 +701,9 @@ public final class dev/nucleusframework/window/tao/TaoTrackpadPhase { public final class dev/nucleusframework/window/tao/TaoWindow { public static final field $stable I + public static final field AWT_PIXEL_TO_ROTATION F public static final field LINUX_AWT_SCROLL_AMOUNT_DEFAULT I + public static final field MACOS_AWT_SCROLL_AMOUNT I public static final field SCROLL_FIXED_SCALE F public static final field WAYLAND_HANDLE_KIND J public static final field WINDOWS_TOUCH_DRAG_THRESHOLD_PX I diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index cb49f4d49..5f0f2a1e7 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -5,8 +5,6 @@ package dev.nucleusframework.window.tao import androidx.compose.runtime.mutableStateOf import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher -import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION -import dev.nucleusframework.window.tao.event.MACOS_AWT_SCROLL_AMOUNT import dev.nucleusframework.window.tao.ffi.NativeTaoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoLinuxTouchBridge import dev.nucleusframework.window.tao.ffi.NativeTaoMacOsDecoBridge @@ -1274,6 +1272,13 @@ public class TaoWindow internal constructor( private companion object { const val SCROLL_FIXED_SCALE: Float = 100f const val LINUX_AWT_SCROLL_AMOUNT_DEFAULT: Int = 3 + + // ABI: a `const val` in a private companion still compiles to a public + // static on TaoWindow, and these two are part of the validated 2.4.x + // surface (api/decorated-window-tao.api). Aliases of the shared + // definitions in event/MacOsWheelDelta.kt so they cannot diverge. + const val AWT_PIXEL_TO_ROTATION: Float = dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION + const val MACOS_AWT_SCROLL_AMOUNT: Int = dev.nucleusframework.window.tao.event.MACOS_AWT_SCROLL_AMOUNT const val WINDOWS_TOUCH_DRAG_THRESHOLD_PX: Int = 16 val platformLineScrollAmount: Int diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt index 1245ed5ad..e2765b5a6 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt @@ -295,6 +295,9 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { } override fun dispose() { + if (disposed) return + disposed = true + framePump.disposed = true if (!isValid) { // Never came up (bridges missing, panel creation failed): only the // eagerly created pieces need releasing. @@ -302,9 +305,6 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { renderExecutor.shutdown() return } - if (disposed) return - disposed = true - framePump.disposed = true revokeInboundDnD() PopupNativeBridge.nativeUninstallOutsideClickMonitor(panel) PopupNativeBridge.nativeSetEventCallback(panel, null) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index 05db862d4..51c04c5f3 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -205,6 +205,18 @@ internal class TaoComposeSceneHost( private var heightPx: Int = 0 private var scale: Float = 1f + // Wheel → Scroll, trackpad gesture → Pan (#654). Declared with the rest of + // the input state, ahead of every handler that reads it. + private val scrollRouter = + TaoSceneScrollRouter( + object : TaoSceneScrollRouter.Target { + override val scene: ComposeScene? get() = this@TaoComposeSceneHost.scene + override val scale: Float get() = this@TaoComposeSceneHost.scale + + override fun guard(block: () -> Unit) = exceptionHandler.catchExceptions(block) + }, + ) + // Sub-pixel deadband (#615): the wire delivers 1/1024-px positions and // macOS emits a CursorMoved before every mouseDown/mouseUp, so click // jitter under 1 dp must not reach the scene — Compose's mouse slop is @@ -1071,16 +1083,6 @@ internal class TaoComposeSceneHost( scrollRouter.onScroll(pointerDeadband.x, pointerDeadband.y, event, currentKeyboardModifiers) } - private val scrollRouter = - TaoSceneScrollRouter( - object : TaoSceneScrollRouter.Target { - override val scene: ComposeScene? get() = this@TaoComposeSceneHost.scene - override val scale: Float get() = this@TaoComposeSceneHost.scale - - override fun guard(block: () -> Unit) = exceptionHandler.catchExceptions(block) - }, - ) - // ── Trackpad gestures (macOS pinch / rotate / smart-magnify) ────────── // // Tao 0.35 doesn't expose these events; an NSEvent local monitor in diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt index 2ccbfa148..e736455e1 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.scene.ComposeScene import dev.nucleusframework.window.tao.TaoNonFatalCoroutineExceptionHandler import dev.nucleusframework.window.tao.TaoPointerScrollEvent +import dev.nucleusframework.window.tao.TaoScrollGesturePhase import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll @@ -105,17 +106,30 @@ internal class TaoSceneScrollRouter( if (cancelled) return val phase = event.gesturePhase if (panEnabled && phase != null) { - this.x = x - this.y = y - this.keyboardModifiers = keyboardModifiers - if (panAnnounced.compareAndSet(false, true)) { + // MayBegin belongs to the NEXT gesture: it closes the previous pan, + // whose PanEnd must land where that pan's moves went, so the + // position is not moved for it. + if (phase != TaoScrollGesturePhase.MAY_BEGIN) { + this.x = x + this.y = y + this.keyboardModifiers = keyboardModifiers + } + if (!panAnnounced.get() && panAnnounced.compareAndSet(false, true)) { logger.config { "Trackpad gestures reach Compose as Pan events (PanStart / PanMove / PanEnd); " + "handlers listening only for PointerEventType.Scroll do not see them. " + "-Dnucleus.tao.trackpadPanEvents=false restores AWT-style Scroll events." } } - pan.onGesture(phase, Offset(event.dxAwt, event.dyAwt)) + if (!pan.onGesture(phase, Offset(event.dxAwt, event.dyAwt))) { + // An orphaned momentum step (the grace closed the pan before + // AppKit's tail arrived): Compose is flinging on its own, so a + // second pan would stack on it — but dropping the tail would + // stop a flick dead. Deliver it as the wheel scroll it would + // have been under AWT; the wheel logic interrupts the fling + // and carries the distance. + target.scene?.dispatchAwtShapedScroll(x, y, event, keyboardModifiers) + } } else { // A different device took over: close the pan where it was. pan.finishNow() diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt index 0b3caae08..f6fbf9846 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt @@ -29,7 +29,7 @@ import dev.nucleusframework.window.tao.TaoScrollGesturePhase * `Ended` can hold the last finger movement, and a `Began` may have been * missed), so no finger distance is dropped; momentum steps, in contrast, only * continue an open pan — a late tail after the grace already closed the pan - * is dropped rather than stacked on Compose's fling. + * is handed back to the caller (`false`) rather than stacked on Compose's fling. * * [send] receives the pan offset in AWT `preciseWheelRotation` units (the * shape of [dev.nucleusframework.window.tao.TaoPointerScrollEvent.dxAwt]); the @@ -45,10 +45,15 @@ internal class TaoTrackpadPanRouter( private var active = false private var cancelPendingEnd: (() -> Unit)? = null + /** + * Routes one gesture step. Returns `false` for a momentum step that found + * no open pan (the grace closed it first): the caller decides what to do + * with its delta — the router itself never opens a pan for the tail. + */ fun onGesture( phase: TaoScrollGesturePhase, deltaAwt: Offset, - ) { + ): Boolean { when (phase) { // Fingers touched the glass: a running momentum tail is over (AppKit // does not always follow with MomentumEnded); with no pan open, @@ -73,21 +78,22 @@ internal class TaoTrackpadPanRouter( // is closed — the grace elapsed before AppKit's first momentum // step, or the Began was never seen — Compose's own fling is // running, and opening a second pan would stack the platform - // inertia on top of it (content overshoots by ~2×). Dropping the - // late tail is the safe outcome. + // inertia on top of it (content overshoots by ~2×). The step is + // reported as unhandled instead. TaoScrollGesturePhase.MOMENTUM_BEGAN, TaoScrollGesturePhase.MOMENTUM_CHANGED, -> { - if (!active) return + if (!active) return false move(deltaAwt) armEnd(stallMillis) } TaoScrollGesturePhase.MOMENTUM_ENDED -> { - if (!active) return + if (!active) return false move(deltaAwt) finish() } } + return true } /** Closes an open pan now (a click, a wheel notch: the gesture is over). */ diff --git a/decorated-window-tao/src/main/native/macos/native_view.m b/decorated-window-tao/src/main/native/macos/native_view.m index 77abad6f7..1d3ffacbb 100644 --- a/decorated-window-tao/src/main/native/macos/native_view.m +++ b/decorated-window-tao/src/main/native/macos/native_view.m @@ -458,22 +458,45 @@ static NSPoint window_point_from_compose_px(NSView *content, jfloat xPx, jfloat } /* Phase of a Compose scroll handed to the native view — mirrors Kotlin - * `TaoNativeViewHost.SCROLL_WHEEL / PAN_START / PAN_MOVE / PAN_END`. */ + * `TaoNativeViewHost.SCROLL_WHEEL / PAN_START / PAN_MOVE / PAN_END` + * (TaoScrollWireDriftTest keeps the two in step). */ enum { kNvScrollWheel = 0, kNvPanStart = 1, kNvPanMove = 2, kNvPanEnd = 3 }; -/* Is `event` the live AppKit scroll event this Compose step came from? A - * wheel step matches any scrollWheel event (previous behaviour); a pan step - * must match its phase class, because a PanEnd deferred by the pan router's - * grace timer runs long after AppKit moved on to some unrelated event. */ +/* Compose/AWT wheel unit → AppKit points (TaoSceneScrollRouter, MacOSCocoaConfig). */ +static const float kAwtPixelToRotation = 10.f; + +/* The AppKit scroll event most recently handed to a native child, so no + * event is ever delivered twice: NSApp.currentEvent is not cleared between + * events, so an idle app still reports the gesture's last event when the pan + * router's deferred PanEnd fires 150 ms later — and one AppKit event can + * yield two Compose steps (a Began carrying a delta is PanStart + PanMove). + * Identity is the unretained pointer plus the timestamp. */ +static __unsafe_unretained NSEvent *sConsumedScroll = nil; +static NSTimeInterval sConsumedScrollTs = -1; +static BOOL sConsumedScrollTerminal = NO; + +/* Sub-point residue of the synthesised fallback, per target view: CGEvent + * deltas are whole points and a slow two-finger drag yields < 0.5 pt per + * frame, which rounding alone would zero out step after step. */ +static __unsafe_unretained NSView *sResidueView = nil; +static float sResidueX = 0.f, sResidueY = 0.f; + +static BOOL nvIsTerminal(NSEvent *e) { + return (e.phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) != 0 + || (e.momentumPhase & (NSEventPhaseEnded | NSEventPhaseCancelled)) != 0; +} + +/* Does the live AppKit scroll event belong to the phase class of this Compose + * step? A wheel step accepts any scroll event; a pan step must match, so a + * step derived from an event of another class (an Ended that carries the + * last finger delta becomes a PanMove) is synthesised with its own phase. */ static BOOL nvScrollEventMatches(NSEvent *event, jint phase) { - if (event == nil || event.type != NSEventTypeScrollWheel) return NO; NSEventPhase p = event.phase, m = event.momentumPhase; switch (phase) { case kNvPanStart: return (p & (NSEventPhaseBegan | NSEventPhaseMayBegin)) != 0; case kNvPanMove: return (p & (NSEventPhaseChanged | NSEventPhaseStationary)) != 0 || (m & (NSEventPhaseBegan | NSEventPhaseChanged)) != 0; - case kNvPanEnd: return (p & (NSEventPhaseEnded | NSEventPhaseCancelled)) != 0 - || (m & (NSEventPhaseEnded | NSEventPhaseCancelled)) != 0; + case kNvPanEnd: return nvIsTerminal(event); default: return YES; } } @@ -491,27 +514,52 @@ static BOOL nvScrollEventMatches(NSEvent *event, jint phase) { NSPoint windowPoint = window_point_from_compose_px(content, xPx, yPx); NSView *hit = hit_native_child(child, windowPoint); if (hit == nil) return; - // Prefer the original AppKit event: same sign, precise-pixel flag, - // gesture / momentum phase. The pan router dispatches each step on the - // same turn as the scrollWheel that produced it, so currentEvent is - // still that event — except for the deferred PanEnd, caught by the - // phase check below. + NSEvent *current = NSApp.currentEvent; - if (nvScrollEventMatches(current, phase)) { - [hit scrollWheel:current]; - return; + BOOL isScroll = current != nil && current.type == NSEventTypeScrollWheel; + BOOL consumed = isScroll && current == sConsumedScroll && current.timestamp == sConsumedScrollTs; + if (isScroll && !consumed) { + // Fresh AppKit event — the source of this Compose step. Replay it + // whole when its phase class matches (sign, precision and phase come + // for free) and mark it delivered either way, so a second Compose + // step from the same event, or the deferred PanEnd, never applies + // its delta again. + sConsumedScroll = current; + sConsumedScrollTs = current.timestamp; + if (nvScrollEventMatches(current, phase)) { + sConsumedScrollTerminal = nvIsTerminal(current); + [hit scrollWheel:current]; + return; + } + sConsumedScrollTerminal = NO; + } else if (consumed) { + // Already delivered in full. Only a PanEnd still owes the child its + // terminal phase, and only if what it got was not terminal already. + if (phase != kNvPanEnd || sConsumedScrollTerminal) return; + dx = 0.f; + dy = 0.f; + sConsumedScrollTerminal = YES; } + // Fallback: Compose/AWT scrollDelta is the inverse of AppKit // `scrollingDelta` on both axes (TaoWindow.kt SCROLL_PIXEL/LINE, #652) - // and precise deltas are divided by 10. Reconstruct AppKit points: - // native = -awt * 10 for X and Y alike, and give a pan step the phase - // the native scroll view expects (IOHID encoding, see popup_panel.m / - // NucleusTaoMetal.m: 1 began, 2 changed, 4 ended). - const float kAwtPixelToRotation = 10.f; - CGEventRef cg = CGEventCreateScrollWheelEvent( - NULL, kCGScrollEventUnitPixel, 2, - (int32_t)lroundf(-dy * kAwtPixelToRotation), - (int32_t)lroundf(-dx * kAwtPixelToRotation)); + // and precise deltas are divided by 10. Reconstruct AppKit points — + // native = -awt * 10 for X and Y alike — carrying the sub-point residue + // forward, and give a pan step the phase the native scroll view expects + // (IOHID encoding, see popup_panel.m / NucleusTaoMetal.m: 1 began, + // 2 changed, 4 ended). + if (hit != sResidueView) { + sResidueView = hit; + sResidueX = 0.f; + sResidueY = 0.f; + } + float px = -dx * kAwtPixelToRotation + sResidueX; + float py = -dy * kAwtPixelToRotation + sResidueY; + int32_t ix = (int32_t)lroundf(px), iy = (int32_t)lroundf(py); + sResidueX = px - (float)ix; + sResidueY = py - (float)iy; + if (ix == 0 && iy == 0 && phase == kNvPanMove) return; // not a whole point yet + CGEventRef cg = CGEventCreateScrollWheelEvent(NULL, kCGScrollEventUnitPixel, 2, iy, ix); if (cg == NULL) return; switch (phase) { case kNvPanStart: CGEventSetIntegerValueField(cg, kCGScrollWheelEventScrollPhase, 1); break; diff --git a/decorated-window-tao/src/main/native/macos/popup_panel.m b/decorated-window-tao/src/main/native/macos/popup_panel.m index d8abaf7f9..7b1d11cd8 100644 --- a/decorated-window-tao/src/main/native/macos/popup_panel.m +++ b/decorated-window-tao/src/main/native/macos/popup_panel.m @@ -292,24 +292,20 @@ typedef NS_ENUM(jint, NucleusScrollGesture) { }; /* AppKit sets `phase` for the fingers-on-glass part and `momentumPhase` for the - * inertial tail, never both; a wheel notch has neither. */ + * inertial tail, never both; a wheel notch has neither. NSEventPhase is an + * NS_OPTIONS mask, so bits are tested rather than switched on. Same order as + * the vendored tao `scroll_wheel`. */ static jint scrollGesturePhase(NSEvent *event) { - switch (event.phase) { - case NSEventPhaseMayBegin: return NucleusScrollGestureMayBegin; - case NSEventPhaseBegan: return NucleusScrollGestureBegan; - case NSEventPhaseChanged: - case NSEventPhaseStationary: return NucleusScrollGestureChanged; - case NSEventPhaseEnded: return NucleusScrollGestureEnded; - case NSEventPhaseCancelled: return NucleusScrollGestureCancelled; - default: break; - } - switch (event.momentumPhase) { - case NSEventPhaseBegan: return NucleusScrollGestureMomentumBegan; - case NSEventPhaseChanged: return NucleusScrollGestureMomentumChanged; - case NSEventPhaseEnded: - case NSEventPhaseCancelled: return NucleusScrollGestureMomentumEnded; - default: return NucleusScrollGestureNone; - } + NSEventPhase p = event.phase, m = event.momentumPhase; + if (p & NSEventPhaseMayBegin) return NucleusScrollGestureMayBegin; + if (p & NSEventPhaseBegan) return NucleusScrollGestureBegan; + if (p & (NSEventPhaseChanged | NSEventPhaseStationary)) return NucleusScrollGestureChanged; + if (p & NSEventPhaseEnded) return NucleusScrollGestureEnded; + if (p & NSEventPhaseCancelled) return NucleusScrollGestureCancelled; + if (m & NSEventPhaseBegan) return NucleusScrollGestureMomentumBegan; + if (m & NSEventPhaseChanged) return NucleusScrollGestureMomentumChanged; + if (m & (NSEventPhaseEnded | NSEventPhaseCancelled)) return NucleusScrollGestureMomentumEnded; + return NucleusScrollGestureNone; } - (void)scrollWheel:(NSEvent *)event { diff --git a/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch index b1864d197..306540bd6 100644 --- a/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch +++ b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch @@ -81,7 +81,7 @@ index 9d0ab3fb..6cf94fb6 100644 }, }) { diff --git a/src/platform_impl/macos/view.rs b/src/platform_impl/macos/view.rs -index b0fb8472..80004bd1 100644 +index b0fb8472..b3b19bd3 100644 --- a/src/platform_impl/macos/view.rs +++ b/src/platform_impl/macos/view.rs @@ -31,9 +31,10 @@ use objc2_foundation::{ @@ -127,7 +127,7 @@ index b0fb8472..80004bd1 100644 } else { MouseScrollDelta::LineDelta(x as f32, y as f32) } -@@ -1277,6 +1285,23 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { +@@ -1277,6 +1285,34 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { NSEventPhase::Ended => TouchPhase::Ended, _ => TouchPhase::Moved, }; @@ -135,23 +135,34 @@ index b0fb8472..80004bd1 100644 + // reports the fingers-on-glass part in `phase` and the inertial tail that + // follows in `momentumPhase`, never both at once; a wheel notch or a + // phase-less device has neither. -+ let scroll_phase = match event.phase() { -+ NSEventPhase::MayBegin => ScrollPhase::MayBegin, -+ NSEventPhase::Began => ScrollPhase::Began, -+ NSEventPhase::Changed | NSEventPhase::Stationary => ScrollPhase::Changed, -+ NSEventPhase::Ended => ScrollPhase::Ended, -+ NSEventPhase::Cancelled => ScrollPhase::Cancelled, -+ _ => match event.momentumPhase() { -+ NSEventPhase::Began => ScrollPhase::MomentumBegan, -+ NSEventPhase::Changed => ScrollPhase::MomentumChanged, -+ NSEventPhase::Ended | NSEventPhase::Cancelled => ScrollPhase::MomentumEnded, -+ _ => ScrollPhase::None, -+ }, ++ // `NSEventPhase` is an NS_OPTIONS mask: test bits, do not match values. ++ let scroll_phase = { ++ let p = event.phase(); ++ let m = event.momentumPhase(); ++ if p.contains(NSEventPhase::MayBegin) { ++ ScrollPhase::MayBegin ++ } else if p.contains(NSEventPhase::Began) { ++ ScrollPhase::Began ++ } else if p.intersects(NSEventPhase::Changed | NSEventPhase::Stationary) { ++ ScrollPhase::Changed ++ } else if p.contains(NSEventPhase::Ended) { ++ ScrollPhase::Ended ++ } else if p.contains(NSEventPhase::Cancelled) { ++ ScrollPhase::Cancelled ++ } else if m.contains(NSEventPhase::Began) { ++ ScrollPhase::MomentumBegan ++ } else if m.contains(NSEventPhase::Changed) { ++ ScrollPhase::MomentumChanged ++ } else if m.intersects(NSEventPhase::Ended | NSEventPhase::Cancelled) { ++ ScrollPhase::MomentumEnded ++ } else { ++ ScrollPhase::None ++ } + }; let device_event = Event::DeviceEvent { device_id: DEVICE_ID, -@@ -1294,6 +1319,7 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { +@@ -1294,6 +1330,7 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { device_id: DEVICE_ID, delta, phase, diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs index 80004bd16..b3b19bd3b 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs @@ -1289,18 +1289,29 @@ extern "C" fn scroll_wheel(this: &NSView, _sel: Sel, event: &NSEvent) { // reports the fingers-on-glass part in `phase` and the inertial tail that // follows in `momentumPhase`, never both at once; a wheel notch or a // phase-less device has neither. - let scroll_phase = match event.phase() { - NSEventPhase::MayBegin => ScrollPhase::MayBegin, - NSEventPhase::Began => ScrollPhase::Began, - NSEventPhase::Changed | NSEventPhase::Stationary => ScrollPhase::Changed, - NSEventPhase::Ended => ScrollPhase::Ended, - NSEventPhase::Cancelled => ScrollPhase::Cancelled, - _ => match event.momentumPhase() { - NSEventPhase::Began => ScrollPhase::MomentumBegan, - NSEventPhase::Changed => ScrollPhase::MomentumChanged, - NSEventPhase::Ended | NSEventPhase::Cancelled => ScrollPhase::MomentumEnded, - _ => ScrollPhase::None, - }, + // `NSEventPhase` is an NS_OPTIONS mask: test bits, do not match values. + let scroll_phase = { + let p = event.phase(); + let m = event.momentumPhase(); + if p.contains(NSEventPhase::MayBegin) { + ScrollPhase::MayBegin + } else if p.contains(NSEventPhase::Began) { + ScrollPhase::Began + } else if p.intersects(NSEventPhase::Changed | NSEventPhase::Stationary) { + ScrollPhase::Changed + } else if p.contains(NSEventPhase::Ended) { + ScrollPhase::Ended + } else if p.contains(NSEventPhase::Cancelled) { + ScrollPhase::Cancelled + } else if m.contains(NSEventPhase::Began) { + ScrollPhase::MomentumBegan + } else if m.contains(NSEventPhase::Changed) { + ScrollPhase::MomentumChanged + } else if m.intersects(NSEventPhase::Ended | NSEventPhase::Cancelled) { + ScrollPhase::MomentumEnded + } else { + ScrollPhase::None + } }; let device_event = Event::DeviceEvent { diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index 80b77476a..3472c67eb 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -210,8 +210,8 @@ public object TaoSceneTestBattery { run("TaoTrackpadPanRouterTest: momentum tail continues the pan and ends it once") { TaoTrackpadPanRouterTest().`momentum tail continues the pan and ends it once`() } - run("TaoTrackpadPanRouterTest: a momentum tail arriving after the pan closed is dropped") { - TaoTrackpadPanRouterTest().`a momentum tail arriving after the pan closed is dropped`() + run("TaoTrackpadPanRouterTest: a momentum tail arriving after the pan closed is handed back unhandled") { + TaoTrackpadPanRouterTest().`a momentum tail arriving after the pan closed is handed back unhandled`() } run("TaoTrackpadPanRouterTest: fingers resting on the glass during the tail close the pan at once") { TaoTrackpadPanRouterTest().`fingers resting on the glass during the tail close the pan at once`() @@ -416,6 +416,12 @@ public object TaoSceneTestBattery { run("TaoSceneTrackpadPanTest: with pan events disabled gesture steps scroll as wheel events") { TaoSceneTrackpadPanTest().`with pan events disabled gesture steps scroll as wheel events`() } + run("TaoSceneTrackpadPanTest: an orphaned momentum tail scrolls as wheel events instead of stalling") { + TaoSceneTrackpadPanTest().`an orphaned momentum tail scrolls as wheel events instead of stalling`() + } + run("TaoSceneScrollTest: one wheel unit scrolls ten dp on macOS") { + TaoSceneScrollTest().`one wheel unit scrolls ten dp on macOS`() + } run("TaoScenePopupTest: popup renders above the window content") { TaoScenePopupTest().`popup renders above the window content`() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt index b21434ff6..71bd04862 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.window.tao +import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION import dev.nucleusframework.window.tao.ffi.PopupNativeBridge import java.io.File import java.lang.reflect.Method @@ -56,8 +57,52 @@ class TaoScrollWireDriftTest { assertEquals(kotlinWire(), objc - "NONE", "popup_panel.m NucleusScrollGesture* vs TaoScrollGesturePhase.wire") } + @Test + fun `native_view m kNv codes match TaoNativeViewHost`() { + val objc = + NV_CODE + .findAll(nativeView().readText()) + .associate { it.groupValues[1].toScreamingSnake() to it.groupValues[2].toInt() } + val kotlin = + mapOf( + "SCROLL_WHEEL" to TaoNativeViewHost.SCROLL_WHEEL, + "PAN_START" to TaoNativeViewHost.PAN_START, + "PAN_MOVE" to TaoNativeViewHost.PAN_MOVE, + "PAN_END" to TaoNativeViewHost.PAN_END, + ) + assertEquals(kotlin, objc, "native_view.m kNv* vs TaoNativeViewHost") + } + + @Test + fun `the ten units per wheel factor agrees everywhere it is written down`() { + val expected = AWT_PIXEL_TO_ROTATION.toDouble() + assertEquals(expected, firstNumber(RUST_LINE_TO_POINTS, eventsRs()), "events.rs AWT_LINE_TO_POINTS") + assertEquals(expected, firstNumber(OBJC_PIXEL_TO_ROTATION, nativeView()), "native_view.m kAwtPixelToRotation") + assertEquals( + expected, + firstNumber(DEMO_PAN_FACTOR, demoScrollScreen()), + "ScrollTestScreen PAN_DP_PER_WHEEL_UNIT", + ) + } + + private fun firstNumber( + regex: Regex, + file: File, + ): Double = + regex + .find(file.readText()) + ?.groupValues + ?.get(1) + ?.toDouble() + ?: fail("no match for $regex in ${file.path}") + private fun popupPanel() = sourceFile("src/main/native/macos/popup_panel.m") + private fun nativeView() = sourceFile("src/main/native/macos/native_view.m") + + private fun demoScrollScreen() = + sourceFile("../examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt") + private fun eventsRs() = sourceFile("src/main/native/src/events.rs") /** @@ -65,6 +110,7 @@ class TaoScrollWireDriftTest { * may use the repository root. Either way the failure names the file. */ private fun sourceFile(relative: String): File { + // Module directory first (Gradle), then the repository root (IDE). val candidates = listOf(File(relative), File("decorated-window-tao", relative)) return candidates.firstOrNull { it.isFile } ?: fail("cannot find $relative from ${File("").absolutePath} (tried ${candidates.map { it.path }})") @@ -94,5 +140,9 @@ class TaoScrollWireDriftTest { val GET_METHOD_ID = Regex("""GetMethodID\(env,\s*\w+,\s*"(\w+)",\s*"([^"]+)"\)""") val RUST_CODE = Regex("""pub\(crate\) const SCROLL_GESTURE_(\w+): jint = (\d+);""") val OBJC_CODE = Regex("""NucleusScrollGesture(\w+)\s*=\s*(-?\d+)""") + val NV_CODE = Regex("""kNv(\w+)\s*=\s*(\d+)""") + val RUST_LINE_TO_POINTS = Regex("""const AWT_LINE_TO_POINTS: f64 = ([0-9.]+);""") + val OBJC_PIXEL_TO_ROTATION = Regex("""kAwtPixelToRotation = ([0-9.]+)f;""") + val DEMO_PAN_FACTOR = Regex("""PAN_DP_PER_WHEEL_UNIT = ([0-9.]+)f""") } } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollTest.kt index d77632f9f..b40c933fb 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollTest.kt @@ -12,7 +12,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.window.tao.TaoPointerScrollEvent +import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION +import kotlin.math.roundToInt import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -143,6 +146,29 @@ class TaoSceneScrollTest { ) } + @Test + fun `one wheel unit scrolls ten dp on macOS`() { + // The factor TaoSceneScrollRouter sizes trackpad pans with + // (AWT_PIXEL_TO_ROTATION) is Compose Desktop's MacOSCocoaConfig + // `10.dp` per preciseWheelRotation; pin it so a Compose change shows + // up here rather than as pans and notches drifting apart. + if (Platform.Current != Platform.MacOS) return // LinuxGnomeConfig / WindowsWinUIConfig scale differently + runTaoSceneTest(width = 100, height = 200, density = 2f) { + val scrollValue = mutableStateOf(0) + setContent { + val state = rememberScrollState() + scrollValue.value = state.value + Column(Modifier.fillMaxSize().verticalScroll(state)) { + repeat(50) { Box(Modifier.fillMaxWidth().height(20.dp)) } + } + } + moveMouse(50f, 100f) + scroll(scrollEvent(dy = 1f, scrollAmount = 1)) + frameUntilIdle() + assertEquals((AWT_PIXEL_TO_ROTATION * 2f).roundToInt(), scrollValue.value) + } + } + @Test fun `scrolled content repaints at the new offset`() = runTaoSceneTest(width = 100, height = 100) { diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt index ce0c91907..3151d98bf 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt @@ -284,8 +284,24 @@ internal class TaoSceneTestScope( private var isPressed = false private var modifierState = 0 - // The deferred PanEnd of the scroll routers, fired by hand (see elapsePanGrace). - private var pendingPanEnd: (() -> Unit)? = null + /** A router's deferred PanEnd, fired by hand (see [elapsePanGrace]); one slot per router. */ + private class ManualPanTimer { + private var pending: (() -> Unit)? = null + + fun schedule( + @Suppress("UNUSED_PARAMETER") delayMillis: Long, + action: () -> Unit, + ): () -> Unit { + pending = action + return { if (pending === action) pending = null } + } + + fun fire() { + val action = pending ?: return + pending = null + action() + } + } private val scrollTarget = object : TaoSceneScrollRouter.Target { @@ -293,16 +309,10 @@ internal class TaoSceneTestScope( override val scale: Float get() = density } - private fun manualSchedule( - @Suppress("UNUSED_PARAMETER") delayMillis: Long, - action: () -> Unit, - ): () -> Unit { - pendingPanEnd = action - return { if (pendingPanEnd === action) pendingPanEnd = null } - } - - private val scrollRouter = TaoSceneScrollRouter(scrollTarget, ::manualSchedule, panEnabled = true) - private val legacyScrollRouter = TaoSceneScrollRouter(scrollTarget, ::manualSchedule, panEnabled = false) + private val panTimer = ManualPanTimer() + private val legacyPanTimer = ManualPanTimer() + private val scrollRouter = TaoSceneScrollRouter(scrollTarget, panTimer::schedule, panEnabled = true) + private val legacyScrollRouter = TaoSceneScrollRouter(scrollTarget, legacyPanTimer::schedule, panEnabled = false) var lastPicture: Picture? = null private set @@ -483,11 +493,10 @@ internal class TaoSceneTestScope( frame() } - /** Fires the deferred PanEnd the momentum grace timer would. */ + /** Fires the deferred PanEnd the momentum grace timer would, on both routers. */ fun elapsePanGrace() { - val action = pendingPanEnd ?: return - pendingPanEnd = null - action() + panTimer.fire() + legacyPanTimer.fire() frame() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt index 8d58affed..67c76ca1b 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt @@ -169,6 +169,43 @@ class TaoSceneTrackpadPanTest { ) } + @Test + fun `an orphaned momentum tail scrolls as wheel events instead of stalling`() = + runTaoSceneTest(width = 100, height = 200) { + val scrollValue = mutableStateOf(0) + val seen = mutableListOf() + setContent { + val state = rememberScrollState() + scrollValue.value = state.value + Column(Modifier.fillMaxSize().verticalScroll(state).recording(seen)) { + repeat(50) { Box(Modifier.fillMaxWidth().height(20.dp)) } + } + } + moveMouse(50f, 100f) + routeScroll(gestureStep(TaoScrollGesturePhase.BEGAN, dyAwt = 0f)) + routeScroll(gestureStep(TaoScrollGesturePhase.CHANGED, dyAwt = 1f)) + routeScroll(gestureStep(TaoScrollGesturePhase.ENDED, dyAwt = 0f)) + // The grace fires before AppKit's tail shows up. + elapsePanGrace() + frameUntilIdle() + val afterPan = scrollValue.value + assertEquals(PointerEventType.PanEnd, seen.last()) + + seen.clear() + routeScroll(gestureStep(TaoScrollGesturePhase.MOMENTUM_BEGAN, dyAwt = 1f)) + routeScroll(gestureStep(TaoScrollGesturePhase.MOMENTUM_CHANGED, dyAwt = 1f)) + routeScroll(gestureStep(TaoScrollGesturePhase.MOMENTUM_ENDED, dyAwt = 0f)) + frameUntilIdle() + assertTrue( + seen.isNotEmpty() && seen.all { it == PointerEventType.Scroll }, + "expected Scroll only, got $seen", + ) + assertTrue( + scrollValue.value > afterPan, + "the tail must still move content (${scrollValue.value} vs $afterPan)", + ) + } + private fun gestureStep( phase: TaoScrollGesturePhase, dyAwt: Float, diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt index 92f807f36..1061a77f8 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt @@ -90,20 +90,21 @@ class TaoTrackpadPanRouterTest { } @Test - fun `a momentum tail arriving after the pan closed is dropped`() { + fun `a momentum tail arriving after the pan closed is handed back unhandled`() { // Grace elapsed before AppKit's first momentum step (loaded machine): - // Compose is already flinging; a second pan would stack the inertia. + // Compose is already flinging; a second pan would stack the inertia, so + // the router reports the steps unhandled for the caller to scroll with. val h = Harness() - h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) - h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) - h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero) + assertTrue(h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero)) + assertTrue(h.router.onGesture(TaoScrollGesturePhase.CHANGED, down)) + assertTrue(h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero)) h.elapseTimer() assertEquals(PointerEventType.PanEnd, h.types().last()) h.sent.clear() - h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_BEGAN, down) - h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_CHANGED, down) - h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_ENDED, down) + assertFalse(h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_BEGAN, down)) + assertFalse(h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_CHANGED, down)) + assertFalse(h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_ENDED, down)) assertTrue(h.sent.isEmpty(), "late momentum must not open a second pan, got ${h.types()}") assertFalse(h.hasPendingEnd) } diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt index 7ee51f0bc..b0285d308 100644 --- a/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt @@ -74,9 +74,6 @@ private class ScrollMeter { var maxRawAbsY = 0f var lastRawY = 0f - // Set by PanEnd / a new PanStart: the gesture is over now, no need to wait for IDLE_MS. - var endRequested = false - // Monotonic frame-clock tick counter (incremented in the withFrameNanos // loop) and its value at gesture start. The difference over the gesture // window measures render FPS — the metric the scroll-cadence fix changes. @@ -127,39 +124,42 @@ fun ScrollTestScreen() { } } - // Finalize a gesture once its PanEnd arrived (trackpad on Tao) or the - // scroll events went quiet for IDLE_MS (wheel, or backends without pan - // events). Both the ticker and the pointer handler run on the UI - // dispatcher, so the shared ScrollMeter needs no extra synchronization. + // Closes the open gesture and logs it. Called inline on a PanEnd or on the + // next PanStart (trackpad on Tao — two quick swipes must not merge, nor + // lose the first one to a ticker race) and by the idle ticker below for + // wheel input and backends without pan events. Everything here runs on the + // UI dispatcher, so the shared ScrollMeter needs no extra synchronization. + fun finalizeGesture(now: Long) { + if (!meter.inGesture) return + val px = scrollState.value - meter.startValuePx + // Render FPS over the whole gesture window (start → finalize, i.e. + // including the post-input animation tail) = frames rendered ÷ + // wall-clock. This is what the cadence fix should lift toward the + // display refresh; ~20 means the tween only ticks at wheel rate. + val windowMs = (now - meter.startTimeMs).coerceAtLeast(1) + val gestureFrames = meter.frameCount - meter.startFrameCount + gestures.add( + 0, + GestureStat( + index = ++counter, + events = meter.events, + rawSumY = meter.rawSumY, + pxScrolled = px, + durationMs = (meter.lastTimeMs - meter.startTimeMs).coerceAtLeast(0), + maxRawAbsY = meter.maxRawAbsY, + fps = (gestureFrames * 1000L / windowMs).toInt(), + ), + ) + if (gestures.size > MAX_LOG) gestures.removeAt(gestures.lastIndex) + meter.inGesture = false + } + LaunchedEffect(Unit) { while (true) { delay(40) liveValue = scrollState.value val now = System.nanoTime() / 1_000_000 - if (meter.inGesture && (meter.endRequested || now - meter.lastTimeMs >= IDLE_MS)) { - val px = scrollState.value - meter.startValuePx - // Render FPS over the whole gesture window (start → finalize, i.e. - // including the post-input animation tail) = frames rendered ÷ - // wall-clock. This is what the cadence fix should lift toward the - // display refresh; ~20 means the tween only ticks at wheel rate. - val windowMs = (now - meter.startTimeMs).coerceAtLeast(1) - val gestureFrames = meter.frameCount - meter.startFrameCount - gestures.add( - 0, - GestureStat( - index = ++counter, - events = meter.events, - rawSumY = meter.rawSumY, - pxScrolled = px, - durationMs = (meter.lastTimeMs - meter.startTimeMs).coerceAtLeast(0), - maxRawAbsY = meter.maxRawAbsY, - fps = (gestureFrames * 1000L / windowMs).toInt(), - ), - ) - if (gestures.size > MAX_LOG) gestures.removeAt(gestures.lastIndex) - meter.inGesture = false - meter.endRequested = false - } + if (meter.inGesture && now - meter.lastTimeMs >= IDLE_MS) finalizeGesture(now) } } @@ -205,15 +205,14 @@ fun ScrollTestScreen() { PointerEventType.PanStart, PointerEventType.PanEnd, -> { - // A gesture boundary: close the open gesture + // A gesture boundary: log the open gesture // now instead of merging across IDLE_MS. - if (meter.inGesture) meter.endRequested = true + finalizeGesture(now) continue } else -> continue } - if (!meter.inGesture || meter.endRequested || now - meter.lastTimeMs > IDLE_MS) { - meter.endRequested = false + if (!meter.inGesture || now - meter.lastTimeMs > IDLE_MS) { meter.inGesture = true meter.startValuePx = scrollState.value meter.startTimeMs = now From 1ffdf8c7fd29bce82f6b7de125de41237f2e97d7 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 14:13:48 +0300 Subject: [PATCH 6/7] fix(tao): one timer per pan, each AppKit delta to a native view once (review 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../nucleusframework/window/tao/NativeView.kt | 43 ++++--- .../nucleusframework/window/tao/TaoWindow.kt | 6 +- .../window/tao/popup/TaoPopupSceneLayer.kt | 9 +- .../window/tao/scene/TaoComposeSceneHost.kt | 26 +++- .../tao/scene/TaoComposeSceneHostLinux.kt | 2 - .../tao/scene/TaoComposeSceneHostWindows.kt | 2 - .../window/tao/scene/TaoSceneScrollRouter.kt | 17 ++- .../window/tao/scene/TaoTrackpadPanRouter.kt | 39 ++++-- .../src/main/native/macos/NucleusTaoMetal.m | 4 +- .../src/main/native/macos/native_view.m | 111 ++++++++++-------- .../window/tao/TaoSceneTestBattery.kt | 3 + .../window/tao/TaoScrollWireDriftTest.kt | 9 -- .../window/tao/headful/HeadfulScrollables.kt | 68 +++++++++++ .../LinuxDiscreteScrollHeadfulCases.kt | 35 ------ .../MacOsTrackpadScrollHeadfulCases.kt | 77 ++---------- .../tao/headful/TaoWindowTestHarness.kt | 17 +++ .../window/tao/scene/TaoSceneTestHarness.kt | 16 ++- .../tao/scene/TaoSceneTrackpadPanTest.kt | 7 +- .../tao/scene/TaoTrackpadPanRouterTest.kt | 45 ++++++- 19 files changed, 324 insertions(+), 212 deletions(-) create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/HeadfulScrollables.kt diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt index f7fbfe7c8..b243fcf76 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerButton @@ -24,7 +25,6 @@ import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import dev.nucleusframework.core.runtime.Platform -import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION import kotlin.math.min import kotlin.math.roundToInt @@ -272,7 +272,6 @@ private fun Modifier.nativeViewPointerInterop( yPx, change.scrollDelta.x, change.scrollDelta.y, - TaoNativeViewHost.SCROLL_WHEEL, ) true } @@ -280,20 +279,18 @@ private fun Modifier.nativeViewPointerInterop( // native view — begin and end included, so its own // scroll view finishes rubber-banding / fades its // scrollers — and is consumed so the Compose scrollable - // above never opens a pan session of its own. Offsets - // are handed over in AWT wheel units: panOffset is - // 10 dp per unit (see TaoSceneScrollRouter). + // above never opens a pan session of its own. The + // offset stays in scene px; the host converts it back + // to wheel units with the scale the router used. PointerEventType.PanStart, PointerEventType.PanMove, PointerEventType.PanEnd, -> { - val unitPx = AWT_PIXEL_TO_ROTATION * density - host.dispatchScrollToNative( + host.dispatchPanToNative( handle, xPx, yPx, - change.panOffset.x / unitPx, - change.panOffset.y / unitPx, + change.panOffset, when (event.type) { PointerEventType.PanStart -> TaoNativeViewHost.PAN_START PointerEventType.PanEnd -> TaoNativeViewHost.PAN_END @@ -358,26 +355,36 @@ internal interface TaoNativeViewHost { ) { } - /** - * Forwards an unconsumed Compose scroll onto the native view. [dx] / [dy] - * are AWT wheel units; [phase] is one of [SCROLL_WHEEL], [PAN_START], - * [PAN_MOVE], [PAN_END] so the native side can hand the embedded view a - * gesture with a proper begin and end (macOS replays the live AppKit event - * when it is the matching one, else synthesises a phased scroll). - */ - @Suppress("LongParameterList") + /** Forwards an unconsumed Compose scroll (AWT wheel units) onto the native view. */ fun dispatchScrollToNative( handle: Long, xPx: Float, yPx: Float, dx: Float, dy: Float, + ) { + } + + /** + * Forwards one step of an unconsumed trackpad pan onto the native view + * (#654). [panOffsetPx] is Compose's `panOffset` in scene px; the host + * converts it back to wheel units with the same scale the scroll router + * sized it with, so an app-level `LocalDensity` override cannot skew it. + * [phase] is [PAN_START], [PAN_MOVE] or [PAN_END], so the native side can + * hand the embedded view a gesture with a proper begin and end. macOS + * only: the other backends never produce Pan events. + */ + fun dispatchPanToNative( + handle: Long, + xPx: Float, + yPx: Float, + panOffsetPx: Offset, phase: Int, ) { } companion object { - /** Mouse-wheel notch / phase-less precise scroll. */ + /** Mouse-wheel notch / phase-less precise scroll (`native_view.m` `kNvScrollWheel`). */ const val SCROLL_WHEEL: Int = 0 const val PAN_START: Int = 1 const val PAN_MOVE: Int = 2 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index 5f0f2a1e7..f183feecf 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -13,6 +13,8 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean import java.util.logging.Logger +import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION as SHARED_AWT_PIXEL_TO_ROTATION +import dev.nucleusframework.window.tao.event.MACOS_AWT_SCROLL_AMOUNT as SHARED_MACOS_AWT_SCROLL_AMOUNT /** * Phase 2 handle to a window owned by the Tao event loop. @@ -1277,8 +1279,8 @@ public class TaoWindow internal constructor( // static on TaoWindow, and these two are part of the validated 2.4.x // surface (api/decorated-window-tao.api). Aliases of the shared // definitions in event/MacOsWheelDelta.kt so they cannot diverge. - const val AWT_PIXEL_TO_ROTATION: Float = dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION - const val MACOS_AWT_SCROLL_AMOUNT: Int = dev.nucleusframework.window.tao.event.MACOS_AWT_SCROLL_AMOUNT + const val AWT_PIXEL_TO_ROTATION: Float = SHARED_AWT_PIXEL_TO_ROTATION + const val MACOS_AWT_SCROLL_AMOUNT: Int = SHARED_MACOS_AWT_SCROLL_AMOUNT const val WINDOWS_TOUCH_DRAG_THRESHOLD_PX: Int = 16 val platformLineScrollAmount: Int diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt index 19066c049..feb709d90 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt @@ -236,7 +236,14 @@ internal class TaoPopupSceneLayer( object : TaoSceneScrollRouter.Target { override val scene: ComposeScene get() = innerScene - // Live: Compose re-assigns the layer density on a display hop. + // The layer scene's own density (live: Compose re-assigns it + // on a display hop, and it carries an app-level LocalDensity + // override at the Popup call site). That is the density the + // popup content measures with and the one MacOSCocoaConfig + // sizes a wheel notch with — so it is the one a pan must use + // to move the same distance. `host.scale` stays the surface's + // pixel-per-point ratio for nativeResize; the two differ on + // purpose whenever the app zooms its UI through LocalDensity. override val scale: Float get() = _density.density override fun guard(block: () -> Unit) = host.exceptionHandler.catchExceptions(block) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index 51c04c5f3..c61d7c81c 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -35,6 +35,7 @@ import dev.nucleusframework.window.tao.TaoTrackpadGesture import dev.nucleusframework.window.tao.TaoTrackpadPhase import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher +import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION import dev.nucleusframework.window.tao.event.taoKeyEvent import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.event.taoTypedKeyEvent @@ -858,7 +859,6 @@ internal class TaoComposeSceneHost( yPx: Float, dx: Float, dy: Float, - phase: Int, ) { if (outer.nsViewHandle == 0L || handle == 0L) return NativeTaoMacOsNativeViewBridge.nativeDispatchScroll( @@ -868,6 +868,29 @@ internal class TaoComposeSceneHost( yPx, dx, dy, + TaoNativeViewHost.SCROLL_WHEEL, + ) + } + + override fun dispatchPanToNative( + handle: Long, + xPx: Float, + yPx: Float, + panOffsetPx: Offset, + phase: Int, + ) { + if (outer.nsViewHandle == 0L || handle == 0L) return + // Back to wheel units with the scale TaoSceneScrollRouter used + // (10 dp per unit at the window's scale), not the content's + // LocalDensity, which an app may override. + val unitPx = AWT_PIXEL_TO_ROTATION * outer.scale + NativeTaoMacOsNativeViewBridge.nativeDispatchScroll( + outer.nsViewHandle, + handle, + xPx, + yPx, + panOffsetPx.x / unitPx, + panOffsetPx.y / unitPx, phase, ) } @@ -1077,7 +1100,6 @@ internal class TaoComposeSceneHost( * trackpad gesture steps as Pan events — see [TaoSceneScrollRouter]. */ fun onPointerScroll(event: TaoPointerScrollEvent) { - if (sceneBundle == null) return currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers scrollRouter.onScroll(pointerDeadband.x, pointerDeadband.y, event, currentKeyboardModifiers) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt index 54f92e34e..d2269c1f0 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt @@ -2213,9 +2213,7 @@ internal class TaoComposeSceneHostLinux( yPx: Float, dx: Float, dy: Float, - phase: Int, ) { - // GTK gets a plain scroll-event per step; phases are macOS only. val s = if (outer.scale > 0f) outer.scale else 1f val rect = outer.nativeViewRects[handle] val xLogical = ((xPx - (rect?.get(0)?.toFloat() ?: 0f)) / s).toInt() diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt index bd35e749b..32a7a6680 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt @@ -1745,9 +1745,7 @@ internal class TaoComposeSceneHostWindows( yPx: Float, dx: Float, dy: Float, - phase: Int, ) { - // Windows has no gesture phases on the WM_MOUSEWHEEL wire. if (parent == 0L) return outer.nativePointerRedispatchInFlight = true try { diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt index e736455e1..ecdcceb81 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean import java.util.logging.Logger /** @@ -56,6 +55,7 @@ internal class TaoSceneScrollRouter( private val target: Target, schedule: ((delayMillis: Long, action: () -> Unit) -> (() -> Unit))? = null, private val panEnabled: Boolean = trackpadPanEventsEnabled, + clock: () -> Long = { System.nanoTime() / NANOS_PER_MILLI }, ) { /** What the router needs from its host, read live at dispatch time. */ interface Target { @@ -88,6 +88,7 @@ internal class TaoSceneScrollRouter( TaoTrackpadPanRouter( schedule = testSchedule ?: ::scheduleOnMain, send = ::sendPan, + clock = clock, ) private var cancelled = false @@ -114,20 +115,24 @@ internal class TaoSceneScrollRouter( this.y = y this.keyboardModifiers = keyboardModifiers } - if (!panAnnounced.get() && panAnnounced.compareAndSet(false, true)) { + if (!panAnnounced) { + // A racing duplicate line is harmless; a CAS per step is not free. + panAnnounced = true logger.config { "Trackpad gestures reach Compose as Pan events (PanStart / PanMove / PanEnd); " + "handlers listening only for PointerEventType.Scroll do not see them. " + "-Dnucleus.tao.trackpadPanEvents=false restores AWT-style Scroll events." } } - if (!pan.onGesture(phase, Offset(event.dxAwt, event.dyAwt))) { + val orphanedMomentum = !pan.onGesture(phase, Offset(event.dxAwt, event.dyAwt)) + if (orphanedMomentum && (event.dxAwt != 0f || event.dyAwt != 0f)) { // An orphaned momentum step (the grace closed the pan before // AppKit's tail arrived): Compose is flinging on its own, so a // second pan would stack on it — but dropping the tail would // stop a flick dead. Deliver it as the wheel scroll it would // have been under AWT; the wheel logic interrupts the fling - // and carries the distance. + // and carries the distance. A zero-delta tail end is skipped, + // as AWT skips zero deltas. target.scene?.dispatchAwtShapedScroll(x, y, event, keyboardModifiers) } } else { @@ -178,9 +183,11 @@ internal class TaoSceneScrollRouter( internal companion object { private val logger = Logger.getLogger(TaoSceneScrollRouter::class.java.name) + private const val NANOS_PER_MILLI = 1_000_000L /** One CONFIG line per process the first time a gesture is routed as Pan. */ - private val panAnnounced = AtomicBoolean(false) + @Volatile + private var panAnnounced = false /** * `-Dnucleus.tao.trackpadPanEvents=false` sends trackpad gesture steps diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt index f6fbf9846..f08845dd8 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt @@ -41,9 +41,19 @@ internal class TaoTrackpadPanRouter( private val send: (type: PointerEventType, panAwt: Offset) -> Unit, private val graceMillis: Long = momentumGraceMillis, private val stallMillis: Long = DEFAULT_STALL_MILLIS, + private val clock: () -> Long = { System.nanoTime() / NANOS_PER_MILLI }, ) { private var active = false - private var cancelPendingEnd: (() -> Unit)? = null + + // The end of the open pan is a deadline, not a timer per step: steps arrive + // at frame rate and re-arming a coroutine for each would cost a launch, a + // main-loop wake and a cancel every few milliseconds. One timer is in + // flight at a time; it is only re-scheduled when the deadline moves + // EARLIER (the grace after the finger Ended), and on firing it either + // closes the pan or re-arms for the remainder. + private var endDeadlineMillis = 0L + private var timerFiresAtMillis = 0L + private var cancelTimer: (() -> Unit)? = null /** * Routes one gesture step. Returns `false` for a momentum step that found @@ -128,20 +138,31 @@ internal class TaoTrackpadPanRouter( send(PointerEventType.PanEnd, Offset.Zero) } - /** (Re-)arms the single end timer of the open pan. */ + /** + * Moves the end deadline of the open pan. A timer is scheduled only when + * none is in flight or the new deadline is earlier than its firing time. + */ private fun armEnd(delayMillis: Long) { - clearPendingEnd() if (!active) return - cancelPendingEnd = + endDeadlineMillis = clock() + delayMillis + if (cancelTimer != null && timerFiresAtMillis <= endDeadlineMillis) return + clearPendingEnd() + scheduleTimer(delayMillis) + } + + private fun scheduleTimer(delayMillis: Long) { + timerFiresAtMillis = clock() + delayMillis + cancelTimer = schedule(delayMillis) { - cancelPendingEnd = null - finish() + cancelTimer = null + val remaining = endDeadlineMillis - clock() + if (active && remaining > 0) scheduleTimer(remaining) else finish() } } private fun clearPendingEnd() { - cancelPendingEnd?.invoke() - cancelPendingEnd = null + cancelTimer?.invoke() + cancelTimer = null } internal companion object { @@ -163,6 +184,8 @@ internal class TaoTrackpadPanRouter( */ const val DEFAULT_STALL_MILLIS: Long = 1_000L + private const val NANOS_PER_MILLI = 1_000_000L + val momentumGraceMillis: Long = System .getProperty("nucleus.tao.trackpadMomentumGraceMillis") diff --git a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m index aaef74af6..1bb0bdfb2 100644 --- a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m +++ b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m @@ -2910,12 +2910,14 @@ static void ensureInteropModeSource(void) { jfloat x, jfloat y, jfloat dx, jfloat dy, jboolean precise, jint phase, jint momentumPhase) { (void)env; (void)clazz; + if (![NSThread isMainThread] || nsViewPtr == 0) return JNI_FALSE; + // Main thread only from here on, so the lazy flag needs no atomics. static int sEnabled = -1; if (sEnabled < 0) { const char *flag = getenv("NUCLEUS_TAO_INPUT_INJECTION"); sEnabled = (flag != NULL && strcmp(flag, "1") == 0) ? 1 : 0; } - if (!sEnabled || nsViewPtr == 0 || ![NSThread isMainThread]) return JNI_FALSE; + if (!sEnabled) return JNI_FALSE; NSView *view = (__bridge NSView *)(void *)(uintptr_t)nsViewPtr; NSWindow *window = view.window; NSScreen *primary = NSScreen.screens.firstObject; diff --git a/decorated-window-tao/src/main/native/macos/native_view.m b/decorated-window-tao/src/main/native/macos/native_view.m index 1d3ffacbb..02fdc0a01 100644 --- a/decorated-window-tao/src/main/native/macos/native_view.m +++ b/decorated-window-tao/src/main/native/macos/native_view.m @@ -465,21 +465,30 @@ static NSPoint window_point_from_compose_px(NSView *content, jfloat xPx, jfloat /* Compose/AWT wheel unit → AppKit points (TaoSceneScrollRouter, MacOSCocoaConfig). */ static const float kAwtPixelToRotation = 10.f; -/* The AppKit scroll event most recently handed to a native child, so no - * event is ever delivered twice: NSApp.currentEvent is not cleared between +/* One trackpad gesture reaches one native child at a time, so the per-child + * bookkeeping is a single record keyed on the child handle Kotlin passes — a + * stable per-NativeView identity, unlike a raw NSView* that a later child + * could be allocated at. */ +static struct { + jlong child; + /* The child was given a begin / move and still owes an end: keeps a + * deferred PanEnd from sending a second terminal phase, whatever + * NSApp.currentEvent happens to be by then. */ + BOOL gestureOpen; + /* Sub-point residue of the synthesised fallback: CGEvent deltas are whole + * points and a slow two-finger drag yields < 0.5 pt per frame. Reset at + * every gesture boundary. */ + float residueX, residueY; +} sChild = { 0, NO, 0.f, 0.f }; + +/* The AppKit scroll event whose DELTA was already handed to a native child, + * so no delta is applied twice: NSApp.currentEvent is not cleared between * events, so an idle app still reports the gesture's last event when the pan - * router's deferred PanEnd fires 150 ms later — and one AppKit event can - * yield two Compose steps (a Began carrying a delta is PanStart + PanMove). - * Identity is the unretained pointer plus the timestamp. */ -static __unsafe_unretained NSEvent *sConsumedScroll = nil; -static NSTimeInterval sConsumedScrollTs = -1; -static BOOL sConsumedScrollTerminal = NO; - -/* Sub-point residue of the synthesised fallback, per target view: CGEvent - * deltas are whole points and a slow two-finger drag yields < 0.5 pt per - * frame, which rounding alone would zero out step after step. */ -static __unsafe_unretained NSView *sResidueView = nil; -static float sResidueX = 0.f, sResidueY = 0.f; + * router's deferred PanEnd fires 150 ms later — and one AppKit event can yield + * two Compose steps (an Ended carrying the last finger delta is PanStart + + * PanMove). Identity is the unretained pointer plus the timestamp. */ +static __unsafe_unretained NSEvent *sSpentScroll = nil; +static NSTimeInterval sSpentScrollTs = -1; static BOOL nvIsTerminal(NSEvent *e) { return (e.phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) != 0 @@ -501,6 +510,11 @@ static BOOL nvScrollEventMatches(NSEvent *event, jint phase) { } } +static void nvNoteDelivered(jint phase, BOOL terminal) { + if (terminal || phase == kNvPanEnd) sChild.gestureOpen = NO; + else if (phase == kNvPanStart || phase == kNvPanMove) sChild.gestureOpen = YES; +} + JNIEXPORT void JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsNativeViewBridge_nativeDispatchScroll( JNIEnv *env, jclass clazz, @@ -515,31 +529,33 @@ static BOOL nvScrollEventMatches(NSEvent *event, jint phase) { NSView *hit = hit_native_child(child, windowPoint); if (hit == nil) return; + if (childPtr != sChild.child) { + sChild.child = childPtr; + sChild.gestureOpen = NO; + sChild.residueX = sChild.residueY = 0.f; + } else if (phase == kNvPanStart || phase == kNvScrollWheel) { + // Gesture boundary: the residue belonged to what came before. + sChild.residueX = sChild.residueY = 0.f; + } + NSEvent *current = NSApp.currentEvent; BOOL isScroll = current != nil && current.type == NSEventTypeScrollWheel; - BOOL consumed = isScroll && current == sConsumedScroll && current.timestamp == sConsumedScrollTs; - if (isScroll && !consumed) { - // Fresh AppKit event — the source of this Compose step. Replay it - // whole when its phase class matches (sign, precision and phase come - // for free) and mark it delivered either way, so a second Compose - // step from the same event, or the deferred PanEnd, never applies - // its delta again. - sConsumedScroll = current; - sConsumedScrollTs = current.timestamp; - if (nvScrollEventMatches(current, phase)) { - sConsumedScrollTerminal = nvIsTerminal(current); - [hit scrollWheel:current]; - return; - } - sConsumedScrollTerminal = NO; - } else if (consumed) { - // Already delivered in full. Only a PanEnd still owes the child its - // terminal phase, and only if what it got was not terminal already. - if (phase != kNvPanEnd || sConsumedScrollTerminal) return; - dx = 0.f; - dy = 0.f; - sConsumedScrollTerminal = YES; + BOOL spent = isScroll && current == sSpentScroll && current.timestamp == sSpentScrollTs; + BOOL hasDelta = dx != 0.f || dy != 0.f; + if (isScroll && !spent && nvScrollEventMatches(current, phase)) { + // Fresh AppKit event of the right phase class: replay it whole — sign, + // precision, phase and delta come for free — and mark its delta spent. + sSpentScroll = current; + sSpentScrollTs = current.timestamp; + nvNoteDelivered(phase, nvIsTerminal(current)); + [hit scrollWheel:current]; + return; } + // This step's delta already travelled with the replayed / synthesised + // event it was derived from (a Began carrying a delta is PanStart+PanMove). + if (spent && hasDelta) return; + // The child already received its terminal phase for this gesture. + if (phase == kNvPanEnd && !sChild.gestureOpen) return; // Fallback: Compose/AWT scrollDelta is the inverse of AppKit // `scrollingDelta` on both axes (TaoWindow.kt SCROLL_PIXEL/LINE, #652) @@ -547,17 +563,18 @@ static BOOL nvScrollEventMatches(NSEvent *event, jint phase) { // native = -awt * 10 for X and Y alike — carrying the sub-point residue // forward, and give a pan step the phase the native scroll view expects // (IOHID encoding, see popup_panel.m / NucleusTaoMetal.m: 1 began, - // 2 changed, 4 ended). - if (hit != sResidueView) { - sResidueView = hit; - sResidueX = 0.f; - sResidueY = 0.f; + // 2 changed, 4 ended). A fresh event whose delta rides in this step is + // spent by it; a zero-delta step (PanStart / PanEnd) leaves the event's + // delta to the sibling step that carries it. + if (isScroll && !spent && hasDelta) { + sSpentScroll = current; + sSpentScrollTs = current.timestamp; } - float px = -dx * kAwtPixelToRotation + sResidueX; - float py = -dy * kAwtPixelToRotation + sResidueY; + float px = -dx * kAwtPixelToRotation + sChild.residueX; + float py = -dy * kAwtPixelToRotation + sChild.residueY; int32_t ix = (int32_t)lroundf(px), iy = (int32_t)lroundf(py); - sResidueX = px - (float)ix; - sResidueY = py - (float)iy; + sChild.residueX = px - (float)ix; + sChild.residueY = py - (float)iy; if (ix == 0 && iy == 0 && phase == kNvPanMove) return; // not a whole point yet CGEventRef cg = CGEventCreateScrollWheelEvent(NULL, kCGScrollEventUnitPixel, 2, iy, ix); if (cg == NULL) return; @@ -571,7 +588,9 @@ static BOOL nvScrollEventMatches(NSEvent *event, jint phase) { [hit.window convertRectToScreen:NSMakeRect(windowPoint.x, windowPoint.y, 0, 0)].origin)); NSEvent *event = [NSEvent eventWithCGEvent:cg]; CFRelease(cg); - if (event != nil) [hit scrollWheel:event]; + if (event == nil) return; + nvNoteDelivered(phase, NO); + [hit scrollWheel:event]; } JNIEXPORT void JNICALL diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index 3472c67eb..ebe35ccd4 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -219,6 +219,9 @@ public object TaoSceneTestBattery { run("TaoTrackpadPanRouterTest: a truncated stream is closed by the stall watchdog") { TaoTrackpadPanRouterTest().`a truncated stream is closed by the stall watchdog`() } + run("TaoTrackpadPanRouterTest: finger steps move the deadline without re-scheduling the timer") { + TaoTrackpadPanRouterTest().`finger steps move the deadline without re-scheduling the timer`() + } run("TaoTrackpadPanRouterTest: finishNow closes an open pan and is a no-op otherwise") { TaoTrackpadPanRouterTest().`finishNow closes an open pan and is a no-op otherwise`() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt index 71bd04862..aeffc4881 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt @@ -78,11 +78,6 @@ class TaoScrollWireDriftTest { val expected = AWT_PIXEL_TO_ROTATION.toDouble() assertEquals(expected, firstNumber(RUST_LINE_TO_POINTS, eventsRs()), "events.rs AWT_LINE_TO_POINTS") assertEquals(expected, firstNumber(OBJC_PIXEL_TO_ROTATION, nativeView()), "native_view.m kAwtPixelToRotation") - assertEquals( - expected, - firstNumber(DEMO_PAN_FACTOR, demoScrollScreen()), - "ScrollTestScreen PAN_DP_PER_WHEEL_UNIT", - ) } private fun firstNumber( @@ -100,9 +95,6 @@ class TaoScrollWireDriftTest { private fun nativeView() = sourceFile("src/main/native/macos/native_view.m") - private fun demoScrollScreen() = - sourceFile("../examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt") - private fun eventsRs() = sourceFile("src/main/native/src/events.rs") /** @@ -143,6 +135,5 @@ class TaoScrollWireDriftTest { val NV_CODE = Regex("""kNv(\w+)\s*=\s*(\d+)""") val RUST_LINE_TO_POINTS = Regex("""const AWT_LINE_TO_POINTS: f64 = ([0-9.]+);""") val OBJC_PIXEL_TO_ROTATION = Regex("""kAwtPixelToRotation = ([0-9.]+)f;""") - val DEMO_PAN_FACTOR = Regex("""PAN_DP_PER_WHEEL_UNIT = ([0-9.]+)f""") } } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/HeadfulScrollables.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/HeadfulScrollables.kt new file mode 100644 index 000000000..b050c9bde --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/HeadfulScrollables.kt @@ -0,0 +1,68 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import java.util.concurrent.atomic.AtomicInteger + +/* + * Scrollable fixtures shared by the scroll headful cases (Linux discrete wheel, + * macOS trackpad). Both publish the live scroll offset and its maximum into + * the atomics so a driver can await overflow and observe movement. + */ + +private const val CELL_COUNT = 80 +private const val CELL_SIZE_DP = 24 + +@Composable +internal fun ScrollableColumn( + scrollPx: AtomicInteger, + scrollMax: AtomicInteger, +) { + val state = rememberScrollState() + scrollPx.set(state.value) + scrollMax.set(state.maxValue) + Column(Modifier.fillMaxSize().verticalScroll(state)) { + repeat(CELL_COUNT) { i -> + Box( + Modifier + .fillMaxWidth() + .height(CELL_SIZE_DP.dp) + .background(if (i % 2 == 0) Color.DarkGray else Color.Gray), + ) + } + } +} + +@Composable +internal fun ScrollableRow( + scrollPx: AtomicInteger, + scrollMax: AtomicInteger, +) { + val state = rememberScrollState() + scrollPx.set(state.value) + scrollMax.set(state.maxValue) + Row(Modifier.fillMaxSize().horizontalScroll(state)) { + repeat(CELL_COUNT) { i -> + Box( + Modifier + .fillMaxHeight() + .width(CELL_SIZE_DP.dp) + .background(if (i % 2 == 0) Color.DarkGray else Color.Gray), + ) + } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/LinuxDiscreteScrollHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/LinuxDiscreteScrollHeadfulCases.kt index 5340bc7eb..098830f62 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/LinuxDiscreteScrollHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/LinuxDiscreteScrollHeadfulCases.kt @@ -1,17 +1,5 @@ package dev.nucleusframework.window.tao.headful -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import dev.nucleusframework.window.tao.TaoEventCode import java.util.concurrent.atomic.AtomicInteger @@ -87,26 +75,6 @@ internal object LinuxDiscreteScrollHeadfulCases { } } - @Composable - private fun ScrollableColumn( - scrollPx: AtomicInteger, - scrollMax: AtomicInteger, - ) { - val state = rememberScrollState() - scrollPx.set(state.value) - scrollMax.set(state.maxValue) - Column(Modifier.fillMaxSize().verticalScroll(state)) { - repeat(ROW_COUNT) { i -> - Box( - Modifier - .fillMaxWidth() - .height(ROW_HEIGHT_DP.dp) - .background(if (i % 2 == 0) Color.DarkGray else Color.Gray), - ) - } - } - } - /** * Place Compose's last pointer over the scrollable, through the same * CURSOR_MOVED wire the host uses for a real mouse. GTK motion injection @@ -137,7 +105,4 @@ internal object LinuxDiscreteScrollHeadfulCases { /** Thousandths: 1.0 in GDK's smooth-delta convention (positive Y = down). */ private const val SMOOTH_DELTA_Y_MILLI = 1000 - - private const val ROW_COUNT = 80 - private const val ROW_HEIGHT_DP = 24 } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt index f3a7f3d18..4a90b3e42 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt @@ -1,26 +1,14 @@ package dev.nucleusframework.window.tao.headful -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.dp import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.window.tao.headful.MacScrollWheelProbe.Momentum import dev.nucleusframework.window.tao.headful.MacScrollWheelProbe.Phase @@ -75,7 +63,7 @@ internal object MacOsTrackpadScrollHeadfulCases { settle() recorder.reset() swipe(dx = -SWIPE_DELTA_PT, dy = 0f, steps = SWIPE_STEPS, momentum = false) - waitFor(SCROLL_REACTION_MILLIS) { scrollPx.get() != 0 } + awaitUntilOrTimeout(SCROLL_REACTION_MILLIS) { scrollPx.get() != 0 } check(scrollPx.get() > 0) { "swipe left (scrollingDeltaX < 0) must scroll the row forward as under AWT; " + "offset=${scrollPx.get()} recorded=${recorder.describe()}" @@ -192,7 +180,7 @@ internal object MacOsTrackpadScrollHeadfulCases { recorder.reset() swipe(dx = 0f, dy = -SWIPE_DELTA_PT, steps = SWIPE_STEPS, momentum = true) - waitFor(PAN_END_MILLIS) { recorder.count(PointerEventType.PanEnd) >= 1 } + awaitUntilOrTimeout(PAN_END_MILLIS) { recorder.count(PointerEventType.PanEnd) >= 1 } val gesture = recorder.snapshot() check(gesture.isNotEmpty() && gesture.first().type == PointerEventType.PanStart) { "a trackpad gesture must open with PanStart; recorded=${recorder.describe()}" @@ -227,7 +215,9 @@ internal object MacOsTrackpadScrollHeadfulCases { } // A classic wheel notch: AppKit +1 line (scroll up) → AWT -1. - val before = gesture.size + // Baseline taken right before the injection: anything the gesture + // still delivers meanwhile must not land in the wheel's window. + val before = recorder.snapshot().size inject(dx = 0f, dy = 1f, precise = false) awaitUntil("wheel notch recorded as Scroll") { recorder.count(PointerEventType.Scroll) >= 1 } val afterWheel = recorder.snapshot().drop(before) @@ -261,14 +251,14 @@ internal object MacOsTrackpadScrollHeadfulCases { settle() recorder.reset() swipe(dx = 0f, dy = -SWIPE_DELTA_PT, steps = SWIPE_STEPS, momentum = false) - waitFor(SCROLL_REACTION_MILLIS) { scrollPx.get() > 0 } + awaitUntilOrTimeout(SCROLL_REACTION_MILLIS) { scrollPx.get() > 0 } check(scrollPx.get() > 0) { "fingers up must scroll the column down; offset=${scrollPx.get()} recorded=${recorder.describe()}" } check(recorder.count(PointerEventType.PanMove) >= SWIPE_STEPS) { "the column must have been driven by Pan events; recorded=${recorder.describe()}" } - waitFor(PAN_END_MILLIS) { recorder.count(PointerEventType.PanEnd) >= 1 } + awaitUntilOrTimeout(PAN_END_MILLIS) { recorder.count(PointerEventType.PanEnd) >= 1 } check(recorder.count(PointerEventType.PanEnd) == 1) { "a gesture without momentum must still end with exactly one PanEnd; recorded=${recorder.describe()}" } @@ -332,15 +322,6 @@ internal object MacOsTrackpadScrollHeadfulCases { check(delivered) { "nativeDiagInjectScrollWheel returned false (window or content view gone?)" } } - /** Polls [predicate] for up to [millis] without failing — the caller asserts. */ - private suspend fun TaoWindowTestScope.waitFor( - millis: Long, - predicate: () -> Boolean, - ) { - val deadline = System.currentTimeMillis() + millis - while (!predicate() && System.currentTimeMillis() < deadline) settle(POLL_MILLIS) - } - // ── Compose content ───────────────────────────────────────────────────── private class Recorded( @@ -401,46 +382,6 @@ internal object MacOsTrackpadScrollHeadfulCases { } } - @Composable - private fun ScrollableColumn( - scrollPx: AtomicInteger, - scrollMax: AtomicInteger, - ) { - val state = rememberScrollState() - scrollPx.set(state.value) - scrollMax.set(state.maxValue) - Column(Modifier.fillMaxSize().verticalScroll(state)) { - repeat(CELL_COUNT) { i -> - Box( - Modifier - .fillMaxWidth() - .height(CELL_SIZE_DP.dp) - .background(if (i % 2 == 0) Color.DarkGray else Color.Gray), - ) - } - } - } - - @Composable - private fun ScrollableRow( - scrollPx: AtomicInteger, - scrollMax: AtomicInteger, - ) { - val state = rememberScrollState() - scrollPx.set(state.value) - scrollMax.set(state.maxValue) - Row(Modifier.fillMaxSize().horizontalScroll(state)) { - repeat(CELL_COUNT) { i -> - Box( - Modifier - .fillMaxHeight() - .width(CELL_SIZE_DP.dp) - .background(if (i % 2 == 0) Color.DarkGray else Color.Gray), - ) - } - } - } - // ── Helpers ───────────────────────────────────────────────────────────── private fun macOnly(): String? = @@ -468,7 +409,6 @@ internal object MacOsTrackpadScrollHeadfulCases { private const val MOMENTUM_STEP_RATIO = 0.6f private const val MOMENTUM_TAIL_RATIO = 0.3f private const val STEP_MILLIS = 16L - private const val POLL_MILLIS = 16L /** How long a scrollable gets to react before the (soft) wait gives up. */ private const val SCROLL_REACTION_MILLIS = 2_000L @@ -482,7 +422,4 @@ internal object MacOsTrackpadScrollHeadfulCases { private const val SCALE_TOLERANCE = 0.01f private const val DISPLAY_SETTLE_MILLIS = 1_000L private const val HIDPI_CASE_TIMEOUT_MILLIS = 90_000L - - private const val CELL_COUNT = 80 - private const val CELL_SIZE_DP = 48 } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoWindowTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoWindowTestHarness.kt index e94067d37..d09334793 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoWindowTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoWindowTestHarness.kt @@ -96,6 +96,23 @@ internal class TaoWindowTestScope( } } + /** + * [awaitUntil] that reports instead of throwing: `true` once [predicate] + * held within [timeoutMillis], `false` otherwise — for cases whose real + * assertion (with its own diagnostics) follows. + */ + suspend fun awaitUntilOrTimeout( + timeoutMillis: Long, + predicate: () -> Boolean, + ): Boolean { + val deadline = System.currentTimeMillis() + timeoutMillis + while (!predicate()) { + if (System.currentTimeMillis() >= deadline) return false + delay(POLL_MILLIS) + } + return true + } + /** Lets the loop breathe for a fixed settle period. */ suspend fun settle(millis: Long = SETTLE_MILLIS) = delay(millis) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt index 3151d98bf..78a0349c8 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt @@ -284,14 +284,19 @@ internal class TaoSceneTestScope( private var isPressed = false private var modifierState = 0 + // Manual clock of the scroll routers, advanced by their timers when fired. + private var routerNowMillis = 0L + /** A router's deferred PanEnd, fired by hand (see [elapsePanGrace]); one slot per router. */ - private class ManualPanTimer { + private inner class ManualPanTimer { private var pending: (() -> Unit)? = null + private var fireAtMillis = 0L fun schedule( - @Suppress("UNUSED_PARAMETER") delayMillis: Long, + delayMillis: Long, action: () -> Unit, ): () -> Unit { + fireAtMillis = routerNowMillis + delayMillis pending = action return { if (pending === action) pending = null } } @@ -299,6 +304,7 @@ internal class TaoSceneTestScope( fun fire() { val action = pending ?: return pending = null + routerNowMillis = fireAtMillis action() } } @@ -311,8 +317,10 @@ internal class TaoSceneTestScope( private val panTimer = ManualPanTimer() private val legacyPanTimer = ManualPanTimer() - private val scrollRouter = TaoSceneScrollRouter(scrollTarget, panTimer::schedule, panEnabled = true) - private val legacyScrollRouter = TaoSceneScrollRouter(scrollTarget, legacyPanTimer::schedule, panEnabled = false) + private val scrollRouter = + TaoSceneScrollRouter(scrollTarget, panTimer::schedule, panEnabled = true, clock = { routerNowMillis }) + private val legacyScrollRouter = + TaoSceneScrollRouter(scrollTarget, legacyPanTimer::schedule, panEnabled = false, clock = { routerNowMillis }) var lastPicture: Picture? = null private set diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt index 67c76ca1b..55c8aea52 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt @@ -196,10 +196,9 @@ class TaoSceneTrackpadPanTest { routeScroll(gestureStep(TaoScrollGesturePhase.MOMENTUM_CHANGED, dyAwt = 1f)) routeScroll(gestureStep(TaoScrollGesturePhase.MOMENTUM_ENDED, dyAwt = 0f)) frameUntilIdle() - assertTrue( - seen.isNotEmpty() && seen.all { it == PointerEventType.Scroll }, - "expected Scroll only, got $seen", - ) + // Two Scroll for the two steps with a delta; the zero-delta tail + // end is skipped, as AWT skips zero deltas. + assertEquals(listOf(PointerEventType.Scroll, PointerEventType.Scroll), seen.toList()) assertTrue( scrollValue.value > afterPan, "the tail must still move content (${scrollValue.value} vs $afterPan)", diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt index 1061a77f8..1ae4ae06a 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt @@ -18,6 +18,8 @@ class TaoTrackpadPanRouterTest { private class Harness { val sent = mutableListOf>() private var pending: (() -> Unit)? = null + private var fireAtMillis = 0L + var nowMillis = 0L var cancelled = 0 var lastDelayMillis = -1L @@ -25,6 +27,7 @@ class TaoTrackpadPanRouterTest { TaoTrackpadPanRouter( schedule = { delayMillis, action -> lastDelayMillis = delayMillis + fireAtMillis = nowMillis + delayMillis pending = action ( { @@ -34,12 +37,14 @@ class TaoTrackpadPanRouterTest { ) }, send = { type, delta -> sent += type to delta }, + clock = { nowMillis }, ) - /** Fires the pending end timer (grace or stall) as the scheduler would. */ + /** Advances the clock to the pending timer and fires it, as the scheduler would. */ fun elapseTimer() { val action = pending ?: return pending = null + nowMillis = fireAtMillis action() } @@ -155,7 +160,7 @@ class TaoTrackpadPanRouterTest { @Test fun `a truncated stream is closed by the stall watchdog`() { - // Every open step arms a stall timer, so a tail that simply stops + // Every open step moves the end deadline, so a tail that simply stops // (window lost key status, terminal step never delivered) still ends. val h = Harness() h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) @@ -164,13 +169,44 @@ class TaoTrackpadPanRouterTest { assertEquals(TaoTrackpadPanRouter.DEFAULT_STALL_MILLIS, h.lastDelayMillis) h.router.onGesture(TaoScrollGesturePhase.ENDED, Offset.Zero) + assertEquals(TaoTrackpadPanRouter.momentumGraceMillis, h.lastDelayMillis, "Ended pulls the deadline in") h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_BEGAN, down) - assertEquals(TaoTrackpadPanRouter.DEFAULT_STALL_MILLIS, h.lastDelayMillis, "momentum re-arms the stall timer") + // The momentum step pushes the deadline back out to the stall window + // without touching the in-flight timer: on firing, that timer re-arms + // for the remainder instead of ending the pan. + h.elapseTimer() + assertEquals( + 0, + h.types().count { it == PointerEventType.PanEnd }, + "grace timer must defer to the later deadline", + ) + assertEquals( + TaoTrackpadPanRouter.DEFAULT_STALL_MILLIS - TaoTrackpadPanRouter.momentumGraceMillis, + h.lastDelayMillis, + "re-armed for the remainder of the stall window", + ) h.elapseTimer() assertEquals(PointerEventType.PanEnd, h.types().last()) assertEquals(1, h.types().count { it == PointerEventType.PanEnd }) } + @Test + fun `finger steps move the deadline without re-scheduling the timer`() { + // One coroutine per gesture, not one per 120 Hz step. + val h = Harness() + h.router.onGesture(TaoScrollGesturePhase.BEGAN, Offset.Zero) + repeat(10) { + h.nowMillis += 8 + h.router.onGesture(TaoScrollGesturePhase.CHANGED, down) + } + assertEquals(0, h.cancelled, "steps that only push the deadline out must not cancel the timer") + h.elapseTimer() + assertEquals(0, h.types().count { it == PointerEventType.PanEnd }, "the timer fired before the moved deadline") + assertTrue(h.hasPendingEnd, "…and re-armed for the remainder") + h.elapseTimer() + assertEquals(PointerEventType.PanEnd, h.types().last()) + } + @Test fun `pan offsets pass through unchanged and zero deltas send no move`() { val h = Harness() @@ -217,6 +253,9 @@ class TaoTrackpadPanRouterTest { assertEquals(1, h.types().count { it == PointerEventType.PanStart }) assertEquals(0, h.types().count { it == PointerEventType.PanEnd }) + // The grace timer still in flight defers to the stall deadline. + h.elapseTimer() + assertEquals(0, h.types().count { it == PointerEventType.PanEnd }) } @Test From 412ab0d4907bf8c4abdead34fd0fd67a3ff35207 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 15:02:44 +0300 Subject: [PATCH 7/7] =?UTF-8?q?feat(demo):=20Trackpad=20Lab=20tab=20?= =?UTF-8?q?=E2=80=94=20manual=20rig=20for=20#652=20/=20#653=20/=20#654?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= opens the demo straight on a tab. Adds the composewebview dependency to nucleus-demo (in-tree Nucleus modules excluded, as in tao-demo). --- examples/nucleus-demo/build.gradle.kts | 6 + .../src/main/kotlin/com/example/demo/Main.kt | 18 +- .../com/example/demo/TrackpadLabScreen.kt | 583 ++++++++++++++++++ 3 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt diff --git a/examples/nucleus-demo/build.gradle.kts b/examples/nucleus-demo/build.gradle.kts index a19df362f..db669b63a 100644 --- a/examples/nucleus-demo/build.gradle.kts +++ b/examples/nucleus-demo/build.gradle.kts @@ -49,6 +49,12 @@ dependencies { implementation(libs.reorderable) implementation("com.materialkolor:material-kolor:4.1.1") implementation(libs.compose.material.icons.extended) + // Trackpad Lab: an embedded native WebView (WKWebView / WebKitGTK / WebView2) + // to check trackpad scrolling over a NativeView. The published artifact was + // built against an older Nucleus; the in-tree modules must win. + implementation(libs.composewebview) { + exclude(group = "dev.nucleusframework") + } } java { diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt index 449f79b52..672eb053e 100644 --- a/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt @@ -119,6 +119,7 @@ fun main(args: Array) = var themeMode by remember { mutableStateOf(ThemeMode.System) } var showInfoDialog by remember { mutableStateOf(false) } var isFillCenterWindowVisible by remember { mutableStateOf(false) } + var isTrackpadLabWindowVisible by remember { mutableStateOf(false) } val isDark = when (themeMode) { @@ -154,7 +155,7 @@ fun main(args: Array) = ) { val tabs = buildList { - addAll(listOf("Nucleus", "Fill Title", "Gallery", "Taskbar", "Scroll Test")) + addAll(listOf("Nucleus", "Fill Title", "Gallery", "Taskbar", "Scroll Test", "Trackpad Lab")) add("Notifications (Common)") add("Notifications") add("Launcher") @@ -165,7 +166,11 @@ fun main(args: Array) = add("Menu") } } - var selectedTab by remember { mutableStateOf("Nucleus") } + // NUCLEUS_DEMO_TAB= opens straight on a tab (manual + // test rigs such as the Trackpad Lab, automation). + var selectedTab by remember { + mutableStateOf(System.getenv("NUCLEUS_DEMO_TAB")?.takeIf { it in tabs } ?: "Nucleus") + } MaterialTitleBar(modifier = Modifier.newFullscreenControls().macOSLargeCornerRadius()) { _ -> val titleBarAlignment = @@ -280,6 +285,11 @@ fun main(args: Array) = } "Taskbar" -> TaskbarProgressScreen(nucleusWindow) "Scroll Test" -> ScrollTestScreen() + "Trackpad Lab" -> + TrackpadLabScreen(onOpenNativePopupWindow = { + isTrackpadLabWindowVisible = + true + }) "Notifications" -> { when (Platform.Current) { Platform.MacOS -> NotificationsScreen() @@ -363,6 +373,10 @@ fun main(args: Array) = onCloseRequest = { isFillCenterWindowVisible = false }, seedColor = seedColor, ) + TrackpadLabWindow( + visible = isTrackpadLabWindowVisible, + onCloseRequest = { isTrackpadLabWindowVisible = false }, + ) } } } diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt new file mode 100644 index 000000000..b48db7690 --- /dev/null +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt @@ -0,0 +1,583 @@ +package com.example.demo + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.application.LocalNucleusApplicationScope +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.rememberWebViewNavigator +import dev.nucleusframework.webview.web.rememberWebViewStateWithHTMLData +import dev.nucleusframework.window.macOSLargeCornerRadius +import dev.nucleusframework.window.material.MaterialDecoratedWindow +import dev.nucleusframework.window.material.MaterialTitleBar +import dev.nucleusframework.window.newFullscreenControls +import kotlin.math.max + +/** + * Manual test rig for scroll input on the Tao backend — the three macOS + * trackpad issues (#652 sign, #653 magnitude, #654 Pan vs Scroll) side by + * side, each with the expected behaviour written next to it: + * + * - **Inspector**: every `Scroll` / `PanStart` / `PanMove` / `PanEnd` + * reaching Compose at the root, with the gap since the previous event, + * counters, and one summary per gesture (steps, distance in wheel units, + * how long after the last move the `PanEnd` arrived — ~150 ms means the + * grace timer closed it, ~0 ms means AppKit's momentum tail did). + * - **Sign & magnitude**: a vertical column and a horizontal row; fingers + * up / left must make the offsets grow, one wheel notch must move exactly + * `10 dp`. + * - **Map canvas**: pans on Pan events, zooms on Scroll — the MapLibre use + * case. A trackpad swipe that zooms means #654 is back. + * - **Popup**: a scrollable `DropdownMenu`; inline in the main window, an + * NSPanel in the window opened with native popup layers. + * - **NativeView**: a WKWebView with a long page and its own HUD (scrollY, + * wheel events, last deltaY) — the native child must follow a two-finger + * swipe, keep its momentum and rubber-band at the ends. + * + * `-Dnucleus.tao.trackpadPanEvents=false` (shown in the header) turns every + * gesture step back into `Scroll`, AWT style. + */ +@Composable +fun TrackpadLabScreen(onOpenNativePopupWindow: () -> Unit) { + TrackpadLab(nativePopups = false, onOpenNativePopupWindow = onOpenNativePopupWindow) +} + +/** The same lab in a window created with `nativePopupLayers = true` (popups become NSPanels on macOS). */ +@Composable +fun TrackpadLabWindow( + visible: Boolean, + onCloseRequest: () -> Unit, +) { + if (!visible) return + val state = + rememberWindowState( + position = WindowPosition.Aligned(Alignment.Center), + placement = WindowPlacement.Floating, + size = DpSize(1400.dp, 920.dp), + ) + val applicationScope = LocalNucleusApplicationScope.current + applicationScope.MaterialDecoratedWindow( + state = state, + onCloseRequest = onCloseRequest, + title = "Trackpad Lab — native popup layers", + nativePopupLayers = true, + ) { + MaterialTitleBar(modifier = Modifier.newFullscreenControls().macOSLargeCornerRadius()) { _ -> + Text("Trackpad Lab — popups are NSPanels here", style = MaterialTheme.typography.titleSmall) + } + TrackpadLab(nativePopups = true, onOpenNativePopupWindow = null) + } +} + +@Composable +private fun TrackpadLab( + nativePopups: Boolean, + onOpenNativePopupWindow: (() -> Unit)?, +) { + val density = LocalDensity.current.density + val log = remember { PointerLog() } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + // Initial pass, never consuming: sees what every child will + // get, scrolling below still happens normally. + .pointerInput(log) { + awaitPointerEventScope { + while (true) { + log.record(awaitPointerEvent(PointerEventPass.Initial), density) + } + } + }.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + LabHeader(density, nativePopups, onOpenNativePopupWindow, onReset = log::reset) + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + InspectorPanel(log, modifier = Modifier.weight(1.15f).fillMaxHeight()) + SignAndMagnitudePanel(density, modifier = Modifier.weight(1f).fillMaxHeight()) + Column( + modifier = Modifier.weight(1f).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + MapCanvasPanel(modifier = Modifier.weight(1f).fillMaxWidth().heightIn(min = MAP_MIN_HEIGHT_DP.dp)) + PopupPanel(nativePopups) + } + } + NativeViewPanel(modifier = Modifier.fillMaxWidth().weight(WEBVIEW_WEIGHT)) + } + } +} + +// ── Header ───────────────────────────────────────────────────────────────── + +@Composable +private fun LabHeader( + density: Float, + nativePopups: Boolean, + onOpenNativePopupWindow: (() -> Unit)?, + onReset: () -> Unit, +) { + val panEvents = System.getProperty("nucleus.tao.trackpadPanEvents", "true").toBoolean() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Trackpad Lab", style = MaterialTheme.typography.titleMedium) + Mono( + "os=${Platform.Current} density=${"%.2f".format(density)} " + + "px/wheel-unit=${"%.0f".format(PAN_DP_PER_WHEEL_UNIT_LAB * density)} " + + "trackpadPanEvents=$panEvents popups=${if (nativePopups) "NSPanel" else "inline"}", + ) + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = onReset) { Text("Reset") } + if (onOpenNativePopupWindow != null) { + Button(onClick = onOpenNativePopupWindow) { Text("Open with native popup layers") } + } + } + if (!panEvents) { + Text( + "Pan events are OFF (-Dnucleus.tao.trackpadPanEvents=false): every gesture step arrives as Scroll, AWT style.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +// ── Inspector ────────────────────────────────────────────────────────────── + +private class GestureSummary( + val index: Int, + val steps: Int, + val wheelUnits: Offset, + val durationMs: Long, + val longestGapMs: Long, + val endAfterLastMoveMs: Long, +) + +/** Root-level observation of what Compose receives; UI-thread only. */ +private class PointerLog { + val lines = mutableStateListOf() + val gestures = mutableStateListOf() + var panStarts by mutableIntStateOf(0) + var panMoves by mutableIntStateOf(0) + var panEnds by mutableIntStateOf(0) + var scrolls by mutableIntStateOf(0) + + private var gestureIndex = 0 + private var lastEventMs = 0L + private var gestureStartMs = 0L + private var lastMoveMs = 0L + private var steps = 0 + private var sumPx = Offset.Zero + private var longestGapMs = 0L + + fun record( + event: PointerEvent, + density: Float, + ) { + val change = event.changes.firstOrNull() ?: return + val now = System.nanoTime() / NANOS_PER_MILLI + val gap = if (lastEventMs == 0L) 0L else now - lastEventMs + val unitPx = PAN_DP_PER_WHEEL_UNIT_LAB * density + when (event.type) { + PointerEventType.PanStart -> { + panStarts++ + gestureStartMs = now + lastMoveMs = now + steps = 0 + sumPx = Offset.Zero + longestGapMs = 0L + add(gap, "PanStart") + } + PointerEventType.PanMove -> { + panMoves++ + steps++ + sumPx += change.panOffset + longestGapMs = max(longestGapMs, now - lastMoveMs) + lastMoveMs = now + add(gap, "PanMove Δpx=${change.panOffset.fmt()} =${(change.panOffset / unitPx).fmt()} wheel units") + } + PointerEventType.PanEnd -> { + panEnds++ + val endAfter = now - lastMoveMs + gestures.add( + 0, + GestureSummary( + index = ++gestureIndex, + steps = steps, + wheelUnits = sumPx / unitPx, + durationMs = now - gestureStartMs, + longestGapMs = longestGapMs, + endAfterLastMoveMs = endAfter, + ), + ) + if (gestures.size > MAX_GESTURES) gestures.removeAt(gestures.lastIndex) + add(gap, "PanEnd (+$endAfter ms after the last move)") + } + PointerEventType.Scroll -> { + scrolls++ + add( + gap, + "Scroll Δ=${change.scrollDelta.fmt()} wheel units =${(change.scrollDelta * unitPx).fmt()} px", + ) + } + else -> return + } + lastEventMs = now + } + + fun reset() { + lines.clear() + gestures.clear() + panStarts = 0 + panMoves = 0 + panEnds = 0 + scrolls = 0 + lastEventMs = 0L + } + + private fun add( + gapMs: Long, + text: String, + ) { + lines.add(0, "+%4d ms %s".format(gapMs, text)) + if (lines.size > MAX_LINES) lines.removeAt(lines.lastIndex) + } +} + +@Composable +private fun InspectorPanel( + log: PointerLog, + modifier: Modifier = Modifier, +) { + Panel("Inspector — what Compose receives at the root", modifier) { + Mono( + "PanStart ${log.panStarts} PanMove ${log.panMoves} PanEnd ${log.panEnds} Scroll ${log.scrolls}", + bold = true, + ) + Text( + "Trackpad ⇒ PanStart, PanMove…, ONE PanEnd (end ≈0 ms: momentum closed it, ≈150 ms: grace timer). " + + "Wheel ⇒ Scroll only.", + style = MaterialTheme.typography.bodySmall, + ) + Mono("# steps Σ units (x, y) dur gap end", bold = true) + log.gestures.forEach { g -> + Mono( + "%-3d %-6d %-18s %4dms %4dms %4dms".format( + g.index, + g.steps, + g.wheelUnits.fmt(), + g.durationMs, + g.longestGapMs, + g.endAfterLastMoveMs, + ), + ) + } + Mono("event log (newest first)", bold = true) + log.lines.forEach { Mono(it) } + } +} + +// ── Sign & magnitude ─────────────────────────────────────────────────────── + +@Composable +private fun SignAndMagnitudePanel( + density: Float, + modifier: Modifier = Modifier, +) { + Panel("Sign & magnitude — #652 / #653", modifier) { + val vertical = rememberScrollState() + val horizontal = rememberScrollState() + Mono("vertical ${vertical.value} px — fingers UP ⇒ grows", bold = true) + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .heightIn(min = STRIP_MIN_HEIGHT_DP.dp) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(6.dp)) + .verticalScroll(vertical), + ) { + repeat(STRIP_CELLS) { i -> + Text( + "Row %03d".format(i), + modifier = + Modifier + .fillMaxWidth() + .background(if (i % 2 == 0) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent) + .padding(horizontal = 8.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + Mono("horizontal ${horizontal.value} px — fingers LEFT ⇒ grows (#652)", bold = true) + Row( + modifier = + Modifier + .fillMaxWidth() + .height(STRIP_HEIGHT_DP.dp) + .padding(top = 2.dp) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(6.dp)) + .horizontalScroll(horizontal), + ) { + repeat(STRIP_CELLS) { i -> + Box( + Modifier + .width(STRIP_CELL_DP.dp) + .fillMaxHeight() + .background(if (i % 2 == 0) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent), + contentAlignment = Alignment.Center, + ) { Text("%02d".format(i), style = MaterialTheme.typography.bodySmall) } + } + } + Text( + "1 wheel notch = 10 dp = ${"%.0f".format( + PAN_DP_PER_WHEEL_UNIT_LAB * density, + )} px = a 10-point trackpad step, " + + "on any display scale (#653).", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +// ── Map canvas ───────────────────────────────────────────────────────────── + +@Composable +private fun MapCanvasPanel(modifier: Modifier = Modifier) { + var offset by remember { mutableStateOf(Offset.Zero) } + var zoom by remember { mutableFloatStateOf(1f) } + Panel("Map canvas — #654: trackpad pans, wheel zooms", modifier) { + Mono("offset=${offset.fmt()} px zoom=${"%.2f".format(zoom)}", bold = true) + Text("Two fingers move the grid (never zoom); a wheel notch zooms.", style = MaterialTheme.typography.bodySmall) + Canvas( + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF10131A)) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: continue + when (event.type) { + // Content follows the fingers: positive panOffset + // means "scroll down / right", so the grid moves + // up / left. + PointerEventType.PanMove -> { + offset -= change.panOffset + change.consume() + } + PointerEventType.PanStart, PointerEventType.PanEnd -> change.consume() + PointerEventType.Scroll -> { + zoom = + (zoom * (1f - change.scrollDelta.y * ZOOM_PER_NOTCH)).coerceIn( + MIN_ZOOM, + MAX_ZOOM, + ) + change.consume() + } + else -> Unit + } + } + } + }, + ) { + val spacing = GRID_SPACING_DP.dp.toPx() * zoom + val origin = Offset(size.width / 2f, size.height / 2f) + offset + val startX = ((origin.x % spacing) + spacing) % spacing + val startY = ((origin.y % spacing) + spacing) % spacing + var x = startX + while (x < size.width) { + drawLine(Color(0xFF2A3142), Offset(x, 0f), Offset(x, size.height)) + x += spacing + } + var y = startY + while (y < size.height) { + drawLine(Color(0xFF2A3142), Offset(0f, y), Offset(size.width, y)) + y += spacing + } + // The world origin: a landmark that must stay under the fingers. + drawCircle(Color(0xFFFF5252), radius = 6.dp.toPx() * zoom, center = origin) + drawLine(Color(0xFF80D8FF), origin - Offset(spacing, 0f), origin + Offset(spacing, 0f), strokeWidth = 2f) + drawLine(Color(0xFF80D8FF), origin - Offset(0f, spacing), origin + Offset(0f, spacing), strokeWidth = 2f) + } + } +} + +// ── Popup ────────────────────────────────────────────────────────────────── + +@Composable +private fun PopupPanel(nativePopups: Boolean) { + var expanded by remember { mutableStateOf(false) } + Panel("Popup — ${if (nativePopups) "NSPanel (native popup layer)" else "inline layer"}", Modifier.fillMaxWidth()) { + Box { + OutlinedButton(onClick = { expanded = true }) { Text("Open a 40-item list and two-finger scroll it") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + repeat(POPUP_ITEMS) { i -> + DropdownMenuItem(text = { Text("Item %02d".format(i)) }, onClick = { expanded = false }) + } + } + } + } +} + +// ── NativeView ───────────────────────────────────────────────────────────── + +@Composable +private fun NativeViewPanel(modifier: Modifier = Modifier) { + Panel("NativeView — embedded WKWebView, HUD drawn by the page itself", modifier) { + Text( + "The page must follow two fingers, keep its momentum after they lift and rubber-band at the ends; " + + "the HUD counts the wheel events the native view gets.", + style = MaterialTheme.typography.bodySmall, + ) + Box(Modifier.fillMaxSize().clip(RoundedCornerShape(6.dp))) { + WebView( + state = rememberWebViewStateWithHTMLData(LAB_HTML), + navigator = rememberWebViewNavigator(), + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +@Composable +private fun Panel( + title: String, + modifier: Modifier = Modifier, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + Column( + modifier = + modifier + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(title, style = MaterialTheme.typography.titleSmall) + content() + } +} + +@Composable +private fun Mono( + text: String, + bold: Boolean = false, +) { + Text( + text, + fontFamily = FontFamily.Monospace, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) +} + +// `+ 0f` folds IEEE -0.0 (a negated zero wire delta) into 0.0 for display. +private fun Offset.fmt(): String = "(%.1f, %.1f)".format(x + 0f, y + 0f) + +private const val NANOS_PER_MILLI = 1_000_000L + +/** Compose Desktop's `MacOSCocoaConfig` factor; Nucleus sizes trackpad pans the same way. */ +private const val PAN_DP_PER_WHEEL_UNIT_LAB = 10f +private const val MAX_LINES = 26 +private const val MAX_GESTURES = 6 +private const val STRIP_CELLS = 120 +private const val STRIP_CELL_DP = 48 +private const val STRIP_HEIGHT_DP = 40 +private const val STRIP_MIN_HEIGHT_DP = 72 +private const val WEBVIEW_WEIGHT = 0.8f +private const val MAP_MIN_HEIGHT_DP = 110 +private const val POPUP_ITEMS = 40 +private const val GRID_SPACING_DP = 48 +private const val ZOOM_PER_NOTCH = 0.1f +private const val MIN_ZOOM = 0.25f +private const val MAX_ZOOM = 8f + +// No template literals in the JS below: `${` would be a Kotlin template. +private val LAB_HTML = + """ +
+ + """.trimIndent()