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 edce3bc57..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 @@ -274,6 +275,30 @@ private fun Modifier.nativeViewPointerInterop( ) 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. 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, + -> { + host.dispatchPanToNative( + handle, + xPx, + yPx, + change.panOffset, + when (event.type) { + PointerEventType.PanStart -> TaoNativeViewHost.PAN_START + PointerEventType.PanEnd -> TaoNativeViewHost.PAN_END + else -> TaoNativeViewHost.PAN_MOVE + }, + ) + true + } else -> false } if (dispatched) event.changes.forEach { it.consume() } @@ -330,7 +355,7 @@ internal interface TaoNativeViewHost { ) { } - /** Forwards an unconsumed Compose scroll onto the native view. */ + /** Forwards an unconsumed Compose scroll (AWT wheel units) onto the native view. */ fun dispatchScrollToNative( handle: Long, xPx: Float, @@ -340,6 +365,32 @@ internal interface TaoNativeViewHost { ) { } + /** + * 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 (`native_view.m` `kNvScrollWheel`). */ + 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/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..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 @@ -98,6 +98,45 @@ public object TaoTrackpadPhase { public const val CANCELLED: Int = 3 } +/** + * 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 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`). */ + MAY_BEGIN(7), + ; + + companion object { + /** 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? = byWire[code] + } +} + /** 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..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. @@ -1085,6 +1087,42 @@ 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( + 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)) + } + + /** + * 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: TaoScrollGesturePhase?, + ) = 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, @@ -1202,6 +1240,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,18 +1263,9 @@ public class TaoWindow internal constructor( ) } TaoEventCode.SCROLL_PIXEL -> { - // AWT's macOS NSEvent → MouseWheelEvent conversion divides - // scrollingDelta by ~10 to obtain preciseWheelRotation; we mirror it. - // Negate as above for the AWT sign convention. - val dx = -(a / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION - val dy = -(b / SCROLL_FIXED_SCALE) / AWT_PIXEL_TO_ROTATION - pointerScrollListener?.invoke( - TaoPointerScrollEvent( - dxAwt = dx, - dyAwt = dy, - scrollAmount = MACOS_AWT_SCROLL_AMOUNT, - ), - ) + // Precise scroll outside a gesture (smooth-scroll mice); see + // [preciseScrollEvent] for the AWT shaping. + pointerScrollListener?.invoke(preciseScrollEvent(a, b, gesturePhase = null)) } // KEY_DOWN / KEY_UP: routed in Phase 2b (no logical-key encoding yet) } @@ -1242,8 +1274,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 - const val MACOS_AWT_SCROLL_AMOUNT: Int = 1 - const val AWT_PIXEL_TO_ROTATION: Float = 10f + + // 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 = 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 @@ -1260,10 +1297,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 `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: 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 9c5aea5be..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 @@ -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 @@ -10,34 +11,37 @@ 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 } +/** + * [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, - scale: Float, + gesturePhaseWire: Int = TaoScrollGesturePhase.NONE_WIRE, ): TaoPointerScrollEvent { - val delta = appKitWheelToAwtScrollDelta(dx, dy, precise, scale) + val delta = appKitWheelToAwtScrollDelta(dx, dy, precise) return TaoPointerScrollEvent( dxAwt = delta.x, dyAwt = delta.y, @@ -45,5 +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, + // 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/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..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 @@ -116,6 +116,24 @@ 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. + */ + 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/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/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 477eb8928..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 @@ -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,26 @@ 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 + + // 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) + }, + ) + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null @@ -262,6 +282,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), @@ -276,12 +297,9 @@ internal class TaoPopupSceneLayer( dx: Float, dy: Float, precise: Boolean, + gesturePhase: Int, ) = host.exceptionHandler.catchExceptions { - innerScene.dispatchAwtShapedScroll( - x, - y, - appKitWheelToAwtScrollEvent(dx, dy, precise, scale), - ) + scrollRouter.onScroll(x, y, appKitWheelToAwtScrollEvent(dx, dy, precise, gesturePhase)) } override fun onKeyEvent( @@ -407,6 +425,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 e927944dc..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 @@ -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 @@ -284,12 +295,22 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { } override fun dispose() { - if (!isValid || disposed) return + 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. + scrollRouter.cancel() + renderExecutor.shutdown() + return + } 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() @@ -423,6 +444,9 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { else -> PointerEventType.Move } 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), @@ -438,13 +462,10 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { dx: Float, dy: Float, precise: Boolean, + gesturePhase: Int, ) { framePump.nonReentrant { - scene?.dispatchAwtShapedScroll( - x, - y, - appKitWheelToAwtScrollEvent(dx, dy, precise, scale), - ) + 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 7de190355..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,7 +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.dispatchAwtShapedScroll +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 @@ -206,6 +206,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 @@ -856,6 +868,30 @@ 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, ) } @@ -1013,6 +1049,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 @@ -1055,19 +1094,15 @@ 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) { currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers - scene?.dispatchAwtShapedScroll( - x = pointerDeadband.x, - y = pointerDeadband.y, - event = event, - keyboardModifiers = currentKeyboardModifiers, - ) + scrollRouter.onScroll(pointerDeadband.x, pointerDeadband.y, event, currentKeyboardModifiers) } // ── Trackpad gestures (macOS pinch / rotate / smart-magnify) ────────── @@ -1551,6 +1586,7 @@ internal class TaoComposeSceneHost( window.imePreedit = null window.imeCommit = null imeSession.onInputSession(null) + 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..ecdcceb81 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt @@ -0,0 +1,201 @@ +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.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 +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 +import java.util.logging.Logger + +/** + * 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. + * + * 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 + * 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 lazily own a + * coroutine scope on the UI dispatcher for the end timer. 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, + clock: () -> Long = { System.nanoTime() / NANOS_PER_MILLI }, +) { + /** 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. Hosts route it through their + * 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 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 = testSchedule ?: ::scheduleOnMain, + send = ::sendPan, + clock = clock, + ) + + 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() + + fun onScroll( + x: Float, + y: Float, + event: TaoPointerScrollEvent, + keyboardModifiers: PointerKeyboardModifiers = PointerKeyboardModifiers(), + ) { + if (cancelled) return + val phase = event.gesturePhase + if (panEnabled && phase != null) { + // 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) { + // 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." + } + } + 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. A zero-delta tail end is skipped, + // as AWT skips zero deltas. + target.scene?.dispatchAwtShapedScroll(x, y, event, keyboardModifiers) + } + } else { + // A different device took over: close the pan where it was. + pan.finishNow() + target.scene?.dispatchAwtShapedScroll(x, y, event, keyboardModifiers) + } + } + + /** 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() + scope = null + } + + 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 = + timerScope().launch { + delay(delayMillis) + target.guard(action) + } + return { job.cancel() } + } + + 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. */ + @Volatile + private var panAnnounced = false + + /** + * `-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 new file mode 100644 index 000000000..f08845dd8 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt @@ -0,0 +1,196 @@ +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 + * [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. + * + * 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 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 finger distance is dropped; momentum steps, in contrast, only + * continue an open pan — a late tail after the grace already closed the pan + * 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 + * 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 val graceMillis: Long = momentumGraceMillis, + private val stallMillis: Long = DEFAULT_STALL_MILLIS, + private val clock: () -> Long = { System.nanoTime() / NANOS_PER_MILLI }, +) { + private var active = false + + // 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 + * 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, + // nothing to do until Began. + TaoScrollGesturePhase.MAY_BEGIN -> finish() + TaoScrollGesturePhase.BEGAN, + TaoScrollGesturePhase.CHANGED, + -> { + start() + move(deltaAwt) + armEnd(stallMillis) + } + TaoScrollGesturePhase.ENDED -> { + move(deltaAwt) + if (active) armEnd(graceMillis) + } + 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×). The step is + // reported as unhandled instead. + TaoScrollGesturePhase.MOMENTUM_BEGAN, + TaoScrollGesturePhase.MOMENTUM_CHANGED, + -> { + if (!active) return false + move(deltaAwt) + armEnd(stallMillis) + } + TaoScrollGesturePhase.MOMENTUM_ENDED -> { + if (!active) return false + move(deltaAwt) + finish() + } + } + return true + } + + /** 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() + active = false + } + + private fun start() { + if (active) return + active = true + 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) return + start() + send(PointerEventType.PanMove, deltaAwt) + } + + private fun finish() { + clearPendingEnd() + if (!active) return + active = false + send(PointerEventType.PanEnd, Offset.Zero) + } + + /** + * 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) { + if (!active) return + endDeadlineMillis = clock() + delayMillis + if (cancelTimer != null && timerFiresAtMillis <= endDeadlineMillis) return + clearPendingEnd() + scheduleTimer(delayMillis) + } + + private fun scheduleTimer(delayMillis: Long) { + timerFiresAtMillis = clock() + delayMillis + cancelTimer = + schedule(delayMillis) { + cancelTimer = null + val remaining = endDeadlineMillis - clock() + if (active && remaining > 0) scheduleTimer(remaining) else finish() + } + } + + private fun clearPendingEnd() { + cancelTimer?.invoke() + cancelTimer = null + } + + internal companion object { + /** + * 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 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 + + private const val NANOS_PER_MILLI = 1_000_000L + + 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/NucleusTaoMetal.m b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m index 87071438d..1bb0bdfb2 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 @@ -2872,6 +2873,76 @@ static void ensureInteropModeSource(void) { return packed; } +/* macOS only, headful e2e (#652 / #653 / #654): hands a synthetic + * `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 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, + * 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. + * + * 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. 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, + 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) return JNI_FALSE; + 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 * 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..02fdc0a01 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,69 @@ 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` + * (TaoScrollWireDriftTest keeps the two in step). */ +enum { kNvScrollWheel = 0, kNvPanStart = 1, kNvPanMove = 2, kNvPanEnd = 3 }; + +/* Compose/AWT wheel unit → AppKit points (TaoSceneScrollRouter, MacOSCocoaConfig). */ +static const float kAwtPixelToRotation = 10.f; + +/* 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 (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 + || (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) { + 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 nvIsTerminal(event); + default: return YES; + } +} + +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, 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); @@ -470,31 +528,69 @@ static NSPoint window_point_from_compose_px(NSView *content, jfloat xPx, jfloat 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, - // momentum phase. Tao queues the scroll onto Compose on the same - // turn, so currentEvent is still the scrollWheel that started this. + + 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; - if (current != nil && current.type == NSEventTypeScrollWheel) { + BOOL isScroll = current != nil && current.type == NSEventTypeScrollWheel; + 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` (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 - const float kAwtPixelToRotation = 10.f; - CGEventRef cg = CGEventCreateScrollWheelEvent( - NULL, kCGScrollEventUnitPixel, 2, - (int32_t)lroundf(-dy * kAwtPixelToRotation), - (int32_t)lroundf(dx * kAwtPixelToRotation)); + // `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 — 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). 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 + sChild.residueX; + float py = -dy * kAwtPixelToRotation + sChild.residueY; + int32_t ix = (int32_t)lroundf(px), iy = (int32_t)lroundf(py); + 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; + 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]; 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/main/native/macos/popup_panel.m b/decorated-window-tao/src/main/native/macos/popup_panel.m index e3de08265..7b1d11cd8 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,39 @@ - (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 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. 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) { + 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 { jobject cb = [self takeCallbackOrNil]; if (cb == NULL) { [super scrollWheel:event]; return; } @@ -284,7 +317,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 046f5bf6c..03c081570 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, + dispatch_ime_replace_commit, dispatch_key, dispatch_scroll_gesture, dispatch_touch_input, + 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, - TOUCH_EVENT_CANCEL, TOUCH_EVENT_MOVE, TOUCH_EVENT_PRESS, TOUCH_EVENT_RELEASE, - TOUCH_FORCE_FIXED_SCALE, TOUCH_FORCE_UNKNOWN, + 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,79 @@ 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. - 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), + 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 the vendored tao hands + // `PixelDelta` over in LOGICAL points (patch 0007, + // #653) — nothing to undo here. + 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, }; - dispatch( - handle, - code, - (dx * SCROLL_FIXED_SCALE) as jint, - (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) => { + // 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, + ); + } + } } 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..d2bdc4b95 100644 --- a/decorated-window-tao/src/main/native/src/events.rs +++ b/decorated-window-tao/src/main/native/src/events.rs @@ -137,13 +137,36 @@ 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 — 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 +// (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; +// 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 @@ -503,6 +526,38 @@ 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. +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..306540bd6 --- /dev/null +++ b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch @@ -0,0 +1,192 @@ +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..b3b19bd3 100644 +--- a/src/platform_impl/macos/view.rs ++++ b/src/platform_impl/macos/view.rs +@@ -31,9 +31,10 @@ use objc2_foundation::{ + use once_cell::sync::Lazy; + + use crate::{ +- dpi::LogicalPosition, ++ dpi::{LogicalPosition, PhysicalPosition}, + event::{ +- DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent, ++ DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, ScrollPhase, TouchPhase, ++ WindowEvent, + }, + keyboard::{KeyCode, ModifiersState}, + platform_impl::platform::{ +@@ -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()); ++ // 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) ++ // 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 +1285,34 @@ 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. ++ // `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 +1330,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..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,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). `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/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..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 @@ -31,9 +31,10 @@ use objc2_foundation::{ use once_cell::sync::Lazy; use crate::{ - dpi::LogicalPosition, + dpi::{LogicalPosition, PhysicalPosition}, event::{ - DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent, + DeviceEvent, ElementState, Event, MouseButton, MouseScrollDelta, ScrollPhase, TouchPhase, + WindowEvent, }, keyboard::{KeyCode, ModifiersState}, platform_impl::platform::{ @@ -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()); + // 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) + // 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 +1285,34 @@ 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. + // `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 +1330,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..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 @@ -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": [ @@ -335,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"] } ] }, @@ -391,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 0dd03591e..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 @@ -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() @@ -142,6 +144,9 @@ public object TaoSceneTestBattery { run("MacOsWheelDeltaTest: preciseDeltaCarriesMacOsScrollAmount") { MacOsWheelDeltaTest().preciseDeltaCarriesMacOsScrollAmount() } + run("MacOsWheelDeltaTest: gesturePhaseRidesAlongWhateverThePrecisionFlag") { + MacOsWheelDeltaTest().gesturePhaseRidesAlongWhateverThePrecisionFlag() + } run("StandaloneFramePumpTest: scheduleOnMainRunsInline") { StandaloneFramePumpTest().scheduleOnMainRunsInline() } @@ -190,6 +195,48 @@ public object TaoSceneTestBattery { run("TaoWindowScrollTest: pixelScrollMirrorsMacOsAwtPreciseWheelRotationScale") { TaoWindowScrollTest().pixelScrollMirrorsMacOsAwtPreciseWheelRotationScale() } + 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`() + } + 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`() + } + 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`() + } + 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`() + } + 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 +401,30 @@ 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("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("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/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index 37c75fdc6..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 @@ -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, @@ -96,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", + 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..aeffc4881 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoScrollWireDriftTest.kt @@ -0,0 +1,139 @@ +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 +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 + * 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(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.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") + } + } + + @Test + fun `Rust SCROLL_GESTURE codes match TaoScrollGesturePhase`() { + val rust = + RUST_CODE + .findAll(eventsRs().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(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") + } + + @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") + } + + 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 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 { + // 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 }})") + } + + 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 { + 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;""") + } +} 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..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 @@ -16,11 +16,44 @@ 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(null, event.gesturePhase) + } + + @Test + fun scrollGestureIsShapedLikePixelScrollWithItsPhase() { + val gesture = dispatchGesture(TaoScrollGesturePhase.MOMENTUM_CHANGED.wire, dxFixed = 1000, dyFixed = -2000) + + assertEquals(-1f, gesture.dxAwt) + assertEquals(2f, gesture.dyAwt) + assertEquals(1, gesture.scrollAmount) + 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( 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..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 @@ -1,59 +1,79 @@ package dev.nucleusframework.window.tao.event +import dev.nucleusframework.window.tao.TaoScrollGesturePhase 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) } + @Test + 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 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) + } + @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/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 new file mode 100644 index 000000000..4a90b3e42 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScrollHeadfulCases.kt @@ -0,0 +1,425 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +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 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() + recorder.reset() + swipe(dx = -SWIPE_DELTA_PT, dy = 0f, steps = SWIPE_STEPS, momentum = false) + 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()}" + } + } + } + + // ── #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. + // 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 && hiDpiSwitchAvailable() + try { + if (switched) switchDisplayTo2x() + recorder.reset() + 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) + } + } + } + + /** + * 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 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) + } + + 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 + + recorder.reset() + swipe(dx = 0f, dy = -SWIPE_DELTA_PT, steps = SWIPE_STEPS, momentum = true) + 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()}" + } + 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. + // 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) + 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() + recorder.reset() + swipe(dx = 0f, dy = -SWIPE_DELTA_PT, steps = SWIPE_STEPS, momentum = false) + 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()}" + } + 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()}" + } + } + } + + // ── 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) { + // Whole points: the CGEvent delta fields are integers, so the + // injector cannot carry fractions (see nativeDiagInjectScrollWheel). + settle(STEP_MILLIS) + inject(dx = momentumStep(dx), dy = momentumStep(dy), precise = true, momentum = Momentum.BEGAN) + settle(STEP_MILLIS) + 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, + 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?)" } + } + + // ── 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() } + + /** 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 = "]") + } + + @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() + } + } + + // ── 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 MOMENTUM_STEP_RATIO = 0.6f + private const val MOMENTUM_TAIL_RATIO = 0.3f + private const val STEP_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 +} 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..fe759cccf --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacScrollWheelProbe.kt @@ -0,0 +1,73 @@ +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 — 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`. + * + * [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/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/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 5f929c206..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 @@ -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 @@ -283,6 +284,44 @@ 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 inner class ManualPanTimer { + private var pending: (() -> Unit)? = null + private var fireAtMillis = 0L + + fun schedule( + delayMillis: Long, + action: () -> Unit, + ): () -> Unit { + fireAtMillis = routerNowMillis + delayMillis + pending = action + return { if (pending === action) pending = null } + } + + fun fire() { + val action = pending ?: return + pending = null + routerNowMillis = fireAtMillis + action() + } + } + + private val scrollTarget = + object : TaoSceneScrollRouter.Target { + override val scene: ComposeScene get() = this@TaoSceneTestScope.scene + override val scale: Float get() = density + } + + private val panTimer = ManualPanTimer() + private val legacyPanTimer = ManualPanTimer() + 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 @@ -397,6 +436,11 @@ internal class TaoSceneTestScope( pressed: Boolean, ) { 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() + } val modifiers = taoKeyboardModifiers(modifierState) if (pressed && isPressed) { scene.sendPointerEvent( @@ -442,6 +486,47 @@ 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, on both routers. */ + fun elapsePanGrace() { + panTimer.fire() + legacyPanTimer.fire() + 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..55c8aea52 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadPanTest.kt @@ -0,0 +1,238 @@ +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.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 + +/** + * 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") + } + + @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", + ) + } + + @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() + // 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)", + ) + } + + private fun gestureStep( + phase: TaoScrollGesturePhase, + 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() + + /** 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..1ae4ae06a --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouterTest.kt @@ -0,0 +1,285 @@ +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, a swipe with no tail must still close, and no + * truncated stream may leave the pan open. + */ +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 + + val router = + TaoTrackpadPanRouter( + schedule = { delayMillis, action -> + lastDelayMillis = delayMillis + fireAtMillis = nowMillis + delayMillis + pending = action + ( + { + if (pending === action) pending = null + cancelled++ + } + ) + }, + send = { type, delta -> sent += type to delta }, + clock = { nowMillis }, + ) + + /** 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() + } + + 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") + assertEquals(TaoTrackpadPanRouter.momentumGraceMillis, h.lastDelayMillis) + + h.elapseTimer() + assertEquals( + listOf(PointerEventType.PanStart, PointerEventType.PanMove, PointerEventType.PanEnd), + h.types(), + ) + } + + @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.elapseTimer() + assertEquals(PointerEventType.PanEnd, h.types().last()) + + h.sent.clear() + h.router.onGesture(TaoScrollGesturePhase.CANCELLED, down) + assertEquals( + listOf(PointerEventType.PanStart, PointerEventType.PanMove, PointerEventType.PanEnd), + h.types(), + ) + assertFalse(h.hasPendingEnd) + } + + @Test + 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, so + // the router reports the steps unhandled for the caller to scroll with. + val h = Harness() + 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() + 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) + } + + @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(), + ) + assertFalse(h.hasPendingEnd) + // 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 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) + 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) + assertEquals(TaoTrackpadPanRouter.momentumGraceMillis, h.lastDelayMillis, "Ended pulls the deadline in") + h.router.onGesture(TaoScrollGesturePhase.MOMENTUM_BEGAN, down) + // 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() + 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 }) + // The grace timer still in flight defers to the stall deadline. + h.elapseTimer() + 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) + } + + @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) + h.elapseTimer() + assertEquals(listOf(PointerEventType.PanStart), h.types()) + } +} 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/ScrollTestScreen.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/ScrollTestScreen.kt index 2b646f48f..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 @@ -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 @@ -116,37 +124,42 @@ 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. + // 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 && 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 - } + if (meter.inGesture && now - meter.lastTimeMs >= IDLE_MS) finalizeGesture(now) } } @@ -178,9 +191,27 @@ 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 + // 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 / (PAN_DP_PER_WHEEL_UNIT * density) + PointerEventType.PanStart, + PointerEventType.PanEnd, + -> { + // A gesture boundary: log the open gesture + // now instead of merging across IDLE_MS. + finalizeGesture(now) + continue + } + else -> continue + } if (!meter.inGesture || now - meter.lastTimeMs > IDLE_MS) { meter.inGesture = true meter.startValuePx = scrollState.value 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()