From f55a43f647d777ca01cc9bec9814698cdef9d4a0 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 7 Sep 2026 14:34:43 +0300 Subject: [PATCH] fix(tao): dispatch recognized trackpad pinch as Compose Scale events (#660) Forward platform pinch (AppKit magnify, GDK pinch, Windows Ctrl+wheel) as ScaleStart / ScaleChange / ScaleEnd at the cursor instead of two synthetic Touch contacts 120 px off it. Rotation stays two-finger Touch; Compose has no rotation event. --- CLAUDE.md | 2 +- .../window/tao/DecoratedWindow.kt | 9 +- .../window/tao/event/TaoTrackpadScale.kt | 90 ++++ .../window/tao/event/TaoWheelPinchZoom.kt | 6 +- .../tao/ffi/NativeTaoLinuxTouchBridge.kt | 10 +- .../window/tao/scene/TaoComposeSceneHost.kt | 166 +++---- .../tao/scene/TaoComposeSceneHostLinux.kt | 142 +++--- .../tao/scene/TaoComposeSceneHostWindows.kt | 98 ++-- .../src/main/native/macos/touchpad_gestures.m | 5 +- .../tao/event/TaoTrackpadScaleSessionTest.kt | 103 +++++ .../window/tao/scene/TaoSceneTestHarness.kt | 20 + .../tao/scene/TaoSceneTrackpadScaleTest.kt | 435 ++++++++++++++++++ .../com/example/demo/TrackpadLabScreen.kt | 50 +- .../nucleusframework/sampleshared/ZoomTab.kt | 28 +- 14 files changed, 894 insertions(+), 270 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScale.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScaleSessionTest.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadScaleTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 5da10110e..9d1e3950d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ Published releases are `2.5.x` (latest tag `v2.5.0`). 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 only window backend (no AWT, native event-loop-driven, true Windows fullscreen, GraalVM native-image first-class). The AWT-based backends (`decorated-window-awt` / `-jbr` / `-jni`), `NucleusBackend`, `LocalNucleusBackend`, the `backend =` parameter of `nucleusApplication`, and `NucleusWindowUnsafe.awtWindow` / `awtDialog` were all removed in 2.6. Compose Desktop's AWT `Window` / `Dialog` / `Tray` are unsupported — use `DecoratedWindow`, `HostedWindow` / `HostedDialog`, and an AWT-free tray -- **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 trackpad on Tao** (#652–#654, #660): scroll deltas are AWT-shaped (`preciseWheelRotation`, no display scale). Trackpad two-finger swipe reaches 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`. Platform-recognized pinch is `ScaleStart` / `ScaleChange` / `ScaleEnd` (`scaleFactor` = per-event ratio) via `dispatchTrackpadScale` — not two synthetic Touch contacts; `Modifier.transformable` and MapLibre consume that path, while `detectTransformGestures` still only sees two-finger rotate. Linux/Windows pinch (GDK / Ctrl+wheel) uses the same Scale events. - 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/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt index 237ebf0ea..4b54b957e 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt @@ -420,8 +420,8 @@ internal fun ApplicationScope.openDecoratedWindow( // Trackpad pinch / rotate / smart-magnify, intercepted before AppKit // dispatches them down the responder chain (Tao 0.35 doesn't surface - // these events). Synthesised as two-finger Touch pointers in the host - // so cross-platform `detectTransformGestures` reacts uniformly. + // these events). Pinch is forwarded as Compose Scale events (#660); + // rotation still synthesises two-finger Touch pointers. window.onTrackpadGesture { kind, phase, x, y, value -> exceptionHandler.catchExceptions { if (enabled) host.onTrackpadGesture(kind, phase, x, y, value) @@ -1105,9 +1105,8 @@ private fun ApplicationScope.openDecoratedWindowWindows( // Trackpad pinch-to-zoom. Windows delivers a precision-touchpad pinch (and // a real Ctrl+wheel) as a Ctrl-flagged WM_MOUSEWHEEL; the Tao patch routes - // those to the magnify hook instead of a scroll, and the host synthesises a - // two-finger Touch pinch so cross-platform `detectTransformGestures` zooms - // uniformly — same model as macOS. + // those to the magnify hook instead of a scroll, and the host forwards + // Compose Scale events (#660) — same model as macOS. window.onTrackpadGesture { kind, phase, x, y, value -> exceptionHandler.catchExceptions { if (enabled) host.onTrackpadGesture(kind, phase, x, y, value) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScale.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScale.kt new file mode 100644 index 000000000..dcf6a8c20 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScale.kt @@ -0,0 +1,90 @@ +package dev.nucleusframework.window.tao.event + +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.input.pointer.PointerType +import androidx.compose.ui.scene.ComposeScene + +/** + * Feeds one step of a platform-recognized pinch into the scene as Compose's + * `ScaleStart` / `ScaleChange` / `ScaleEnd` (#660). [scaleFactor] is a + * multiplicative per-event ratio (`1f` = no change, `> 1f` zoom in, `< 1f` + * zoom out) — the same shape as `NSEvent.magnification` after `1 + delta`, + * and as GDK's per-event pinch ratio. Foundation's `transformable` and + * apps that listen for `PointerEventType.Scale*` consume it directly, so + * unlike the previous two-finger Touch synthesis there is no second pass + * through touch slop, span thresholds or release momentum. + */ +@OptIn(InternalComposeUiApi::class) +internal fun ComposeScene.dispatchTrackpadScale( + x: Float, + y: Float, + type: PointerEventType, + scaleFactor: Float, + keyboardModifiers: PointerKeyboardModifiers = PointerKeyboardModifiers(), +) { + sendPointerEvent( + eventType = type, + position = Offset(x, y), + type = PointerType.Mouse, + keyboardModifiers = keyboardModifiers, + scaleGestureFactor = scaleFactor, + ) +} + +/** + * Open/move/close a Compose scale gesture from a platform pinch stream + * (`TaoTrackpadPhase` on macOS/Linux, a debounced tick stream on + * Windows / Linux Ctrl+wheel). UI thread only. + */ +internal class TaoTrackpadScaleSession( + private val send: (type: PointerEventType, scaleFactor: Float) -> Unit, +) { + var active: Boolean = false + private set + + /** Opens the scale gesture if it is not already open. */ + fun start() { + if (active) return + active = true + send(PointerEventType.ScaleStart, 1f) + } + + /** + * Opens the gesture if needed and reports a multiplicative [scaleFactor]. + * A `1f` factor is not a move (Began / Ended ticks, a zero wheel delta). + */ + fun change(scaleFactor: Float) { + if (scaleFactor == 1f) return + start() + send(PointerEventType.ScaleChange, scaleFactor) + } + + /** + * [delta] is `NSEvent.magnification` / GDK's equivalent: the next factor + * is `1 + delta`, floored so a collapse cannot invert the scale. + */ + fun magnifyBy(delta: Float) { + change((1f + delta).coerceAtLeast(MIN_GESTURE_SCALE)) + } + + /** One-shot smart-magnify: a discrete zoom step, then the gesture closes. */ + fun smartMagnify() { + start() + change(SMART_MAGNIFY_FACTOR) + end() + } + + fun end() { + if (!active) return + active = false + send(PointerEventType.ScaleEnd, 1f) + } + + internal companion object { + const val SMART_MAGNIFY_FACTOR: Float = 1.5f + const val MIN_GESTURE_SCALE: Float = 0.05f + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoWheelPinchZoom.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoWheelPinchZoom.kt index 870bbe1da..db2bc139d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoWheelPinchZoom.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoWheelPinchZoom.kt @@ -4,9 +4,9 @@ import kotlin.math.pow /** * Maps a Ctrl+wheel / precision-touchpad wheel delta to a multiplicative zoom step. - * Shared by the Windows and Linux hosts, which both synthesise a magnify gesture from - * Ctrl+wheel so it zooms (never scrolls) — the AWT backend has no pinch-zoom, so this - * gives Windows/Linux the same behaviour. + * Shared by the Windows and Linux hosts, which both turn Ctrl+wheel into a + * Compose scale gesture so it zooms (never scrolls) — the AWT backend has no + * pinch-zoom, so this gives Windows/Linux the same behaviour. */ internal object TaoWheelPinchZoom { private const val WHEEL_DELTAS_PER_DOUBLING: Float = 12f diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxTouchBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxTouchBridge.kt index 85dc330c2..92591dd7d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxTouchBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxTouchBridge.kt @@ -25,9 +25,9 @@ import dev.nucleusframework.core.runtime.NativeLibraryLoader * single `Release` carrying the final position. * - **Trackpad gesture**: matches [NativeTaoBridge.EventCallback.onTrackpadGesture] * exactly — same kind / phase / fixed-point scaling. The Rust side has - * already converted GDK's absolute pinch scale into per-event ratio - * deltas and GDK's radian angle deltas into degrees, so the JVM-side - * synth math is platform-independent. + * already converted GDK's absolute pinch scale into a per-event ratio + * (forwarded as Compose Scale events, #660) and GDK's radian angle + * deltas into degrees, so the JVM-side math is platform-independent. * * Coordinates passed to [Callback.onTouchEvent] are physical pixels in the * GtkWindow's bin-child coordinate space, encoded as fixed-point ×1024 @@ -71,8 +71,8 @@ internal object NativeTaoLinuxTouchBridge { /** * Trackpad pinch / rotate. Same wire format as * [NativeTaoBridge.EventCallback.onTrackpadGesture] so the JVM-side - * synth math (`TaoComposeSceneHost.onTrackpadGesture`) is reused - * verbatim across macOS and Linux. Wayland-only on Linux. + * scale / rotate dispatch is reused across macOS and Linux. + * Wayland-only on Linux. */ @Suppress("LongParameterList", "FunctionParameterNaming") fun onTrackpadGesture( 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 751454ea4..ece6363cc 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 @@ -38,6 +38,8 @@ import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.clearContentMeasurer import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSession +import dev.nucleusframework.window.tao.event.dispatchTrackpadScale import dev.nucleusframework.window.tao.event.taoKeyEvent import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.event.taoTypedKeyEvent @@ -1319,28 +1321,36 @@ internal class TaoComposeSceneHost( // // Tao 0.35 doesn't expose these events; an NSEvent local monitor in // `macos/touchpad_gestures.m` intercepts them and forwards through - // `EventCallback.onTrackpadGesture`. We synthesize two ComposeScenePointer - // Touch points around the gesture centre — distance varies with the - // accumulated magnification factor, angle with the accumulated rotation. - // detectTransformGestures reacts to the changes between consecutive Move - // events, so pinch-zoom / rotate / pan all work with no app-side change. - - private var gestureActive = false + // `EventCallback.onTrackpadGesture`. Magnify is a platform-recognized + // pinch, so it is forwarded as Compose `ScaleStart` / `ScaleChange` / + // `ScaleEnd` (#660) — MapLibre and `Modifier.transformable` consume that + // path without a second pass through touch slop. Rotation has no Compose + // equivalent, so it still synthesises two Touch pointers around the + // gesture centre and lets `detectTransformGestures` see the angle change. // Centre of the gesture in physical pixels (top-left origin). private var gestureCenterX = 0f private var gestureCenterY = 0f - // Cumulative scale (1.0 at gesture start; multiplied by (1 + magnification) - // on each Magnify event) and angle in radians. - private var gestureScale = 1f + private val scaleSession = + TaoTrackpadScaleSession { type, factor -> + scene?.dispatchTrackpadScale( + x = gestureCenterX, + y = gestureCenterY, + type = type, + scaleFactor = factor, + keyboardModifiers = currentKeyboardModifiers, + ) + } + + private var rotateActive = false private var gestureAngle = 0f /** - * Synthesises a two-finger Touch gesture for `detectTransformGestures`. - * Wire format mirrors `TaoTrackpadGesture` / `TaoTrackpadPhase` constants. - * [valueFixed] is the per-event delta × 10 000 (ratio for magnify, degrees - * for rotate, ignored for smart-magnify). + * Forwards a macOS trackpad gesture. Wire format mirrors + * `TaoTrackpadGesture` / `TaoTrackpadPhase`. [valueFixed] is the + * per-event delta × 10 000 (ratio for magnify, degrees for rotate, + * ignored for smart-magnify). */ @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) fun onTrackpadGesture( @@ -1354,89 +1364,64 @@ internal class TaoComposeSceneHost( val xPx = xFixed / TRACKPAD_POSITION_SCALE val yPx = yFixed / TRACKPAD_POSITION_SCALE val value = valueFixed / TRACKPAD_VALUE_SCALE + gestureCenterX = xPx + gestureCenterY = yPx - // Smart-magnify is one-shot: synthesise a Press → Move → Release burst - // around a fixed scale step so detectTransformGestures sees a discrete - // zoom change. if (kind == TaoTrackpadGesture.SMART_MAGNIFY) { - startGesture(xPx, yPx) - sendGesturePointers(PointerEventType.Press) - gestureScale *= SMART_MAGNIFY_FACTOR - sendGesturePointers(PointerEventType.Move) - endGesture(cancelled = false) + scaleSession.smartMagnify() + return + } + if (kind == TaoTrackpadGesture.MAGNIFY) { + when (phase) { + TaoTrackpadPhase.BEGAN -> { + scaleSession.start() + scaleSession.magnifyBy(value) + } + TaoTrackpadPhase.CHANGED -> scaleSession.magnifyBy(value) + TaoTrackpadPhase.ENDED -> scaleSession.end() + TaoTrackpadPhase.CANCELLED -> scaleSession.end() + } return } when (phase) { TaoTrackpadPhase.BEGAN -> { - startGesture(xPx, yPx) - applyDelta(kind, value) - sendGesturePointers(PointerEventType.Press) + startRotate() + applyRotateDelta(value) + sendRotatePointers(PointerEventType.Press) } TaoTrackpadPhase.CHANGED -> { - if (!gestureActive) { - startGesture(xPx, yPx) - } else { - // Track the real cursor on every tick so the synthesised - // centroid moves with `Δcursor` between events. Without - // this, `calculatePan` would always report 0 from the - // synthetic pair (centroid pinned at gesture start), and - // a pinch-while-dragging would silently lose the pan - // component. Stable PointerIds + symmetric offsets around - // the live cursor = honest pan. - gestureCenterX = xPx - gestureCenterY = yPx - } - applyDelta(kind, value) - sendGesturePointers(PointerEventType.Move) + if (!rotateActive) startRotate() + applyRotateDelta(value) + sendRotatePointers(PointerEventType.Move) } - TaoTrackpadPhase.ENDED -> endGesture(cancelled = false) - TaoTrackpadPhase.CANCELLED -> endGesture(cancelled = true) + TaoTrackpadPhase.ENDED -> endRotate(cancelled = false) + TaoTrackpadPhase.CANCELLED -> endRotate(cancelled = true) } } - private fun startGesture( - centerX: Float, - centerY: Float, - ) { - gestureActive = true - gestureCenterX = centerX - gestureCenterY = centerY - gestureScale = 1f + private fun startRotate() { + rotateActive = true gestureAngle = 0f } - private fun applyDelta( - kind: Int, - value: Float, - ) { - when (kind) { - TaoTrackpadGesture.MAGNIFY -> { - // Compose's pinch detection responds to relative distance change, - // so multiplying preserves the (1 + delta) semantics of - // NSEvent.magnification across the gesture. - gestureScale *= (1f + value).coerceAtLeast(MIN_GESTURE_SCALE) - } - TaoTrackpadGesture.ROTATE -> { - // NSEvent.rotation is positive counter-clockwise in NSView's - // bottom-left (y-up) frame. Compose lives in screen y-down, - // where positive rotation is clockwise — flip the sign so the - // synthesised pointer rotation matches the user's gesture - // direction once detectTransformGestures applies it back to - // graphicsLayer.rotationZ. - gestureAngle -= value * (Math.PI.toFloat() / DEGREES_PER_RADIAN) - } - } + private fun applyRotateDelta(value: Float) { + // NSEvent.rotation is positive counter-clockwise in NSView's + // bottom-left (y-up) frame. Compose lives in screen y-down, + // where positive rotation is clockwise — flip the sign so the + // synthesised pointer rotation matches the user's gesture + // direction once detectTransformGestures applies it back to + // graphicsLayer.rotationZ. + gestureAngle -= value * (Math.PI.toFloat() / DEGREES_PER_RADIAN) } @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) - private fun sendGesturePointers(eventType: PointerEventType) { + private fun sendRotatePointers(eventType: PointerEventType) { val sc = scene ?: return - val radius = TRACKPAD_BASE_RADIUS_PX * gestureScale val cosA = cos(gestureAngle) val sinA = sin(gestureAngle) - val dx = radius * cosA - val dy = radius * sinA + val dx = TRACKPAD_BASE_RADIUS_PX * cosA + val dy = TRACKPAD_BASE_RADIUS_PX * sinA val pressed = eventType != PointerEventType.Release val pointers = listOf( @@ -1460,11 +1445,10 @@ internal class TaoComposeSceneHost( ) } - private fun endGesture(cancelled: Boolean) { - if (!gestureActive) return - sendGesturePointers(PointerEventType.Release) - gestureActive = false - gestureScale = 1f + private fun endRotate(cancelled: Boolean) { + if (!rotateActive) return + sendRotatePointers(PointerEventType.Release) + rotateActive = false gestureAngle = 0f if (cancelled) scene?.cancelPointerInput() } @@ -1550,31 +1534,15 @@ internal class TaoComposeSceneHost( private const val TRACKPAD_POSITION_SCALE: Float = 1024f private const val TRACKPAD_VALUE_SCALE: Float = 10_000f - // Two synthesised touch pointers separated by 2 × this radius at scale 1. - // - // Sized to defeat Compose's `detectTransformGestures` touch-slop check - // for zoom-OUT: that check computes - // zoomMotion = abs(1 - cumulativeZoom) × previousCentroidSize - // and only fires the callback once it exceeds `viewConfiguration.touchSlop`. - // For zoom-out, `previousCentroidSize` shrinks together with the zoom, - // so `zoomMotion` has a hard ceiling ≈ radius × 0.25. With a 50 px - // radius the ceiling sat at ~13 px — below the default 18 px slop, so - // zoom-out gestures were silently dropped. 120 px gives a ceiling of - // ~31 px, comfortably above any reasonable slop value, while the - // initial 240 px pointer separation still fits inside common - // interactive targets (≥ 120 dp at 2× retina). + // Two synthesised touch pointers for rotation only (pinch is a Scale + // event now). 120 px keeps `detectTransformGestures` rotation slop + // reachable: rotationMotion ≈ |Δθ| × π × radius / 180. private const val TRACKPAD_BASE_RADIUS_PX: Float = 120f private const val TRACKPAD_POINTER_ID_A: Long = 0xA001L private const val TRACKPAD_POINTER_ID_B: Long = 0xA002L - // Smart-magnify maps to a single discrete zoom step. macOS's smart-zoom - // toggles between a "fitted" view and a 2× zoom; 1.5× is a reasonable - // default that still triggers detectTransformGestures' zoom callback. - private const val SMART_MAGNIFY_FACTOR: Float = 1.5f - private const val DEGREES_PER_RADIAN: Float = 180f - private const val MIN_GESTURE_SCALE: Float = 0.05f } // ── Background render thread (AWT/skiko `dispatcherToBlockOn` pattern) ── diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt index 58040ccca..a8577511d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt @@ -45,8 +45,10 @@ import dev.nucleusframework.window.tao.clipboard.ProvideTaoClipboard import dev.nucleusframework.window.tao.deco.ResizeFrameDecoration import dev.nucleusframework.window.tao.deco.TaoLinuxOverlayController import dev.nucleusframework.window.tao.deco.TaoLinuxOverlayControllerImpl +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSession import dev.nucleusframework.window.tao.event.TaoWheelPinchZoom import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll +import dev.nucleusframework.window.tao.event.dispatchTrackpadScale import dev.nucleusframework.window.tao.event.taoKeyEvent import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.event.taoTypedKeyEvent @@ -1130,12 +1132,11 @@ internal class TaoComposeSceneHostLinux( // GdkEventTouchpadPinch into the wire format below; we marshal them // into Compose pointer events here. // - // Trackpad gesture path: same trick as the macOS host — synthesise two - // ComposeScenePointer Touch points around the gesture focal point with - // distance varying by accumulated scale and angle by accumulated - // rotation, so `detectTransformGestures` reacts to pinch/rotate with - // strictly cross-platform application code. Smart-magnify is macOS-only - // and is never reported on Linux (no GDK equivalent). + // Trackpad gesture path: magnify is forwarded as Compose `ScaleStart` / + // `ScaleChange` / `ScaleEnd` (#660), matching the macOS host. Rotation + // has no Compose equivalent, so it still synthesises two Touch pointers + // around the focal point. Smart-magnify is macOS-only and is never + // reported on Linux (no GDK equivalent). private fun registerTouch() { if (!NativeTaoLinuxTouchBridge.isLoaded) return @@ -1252,14 +1253,23 @@ internal class TaoComposeSceneHostLinux( // rather than abstracted into a shared helper because the two hosts have // diverged in other dimensions (rendering, scale handling, lifecycle) // and a thin shared trait would obscure more than it factors. - private var gestureActive = false private var gestureCenterX = 0f private var gestureCenterY = 0f - private var gestureScale = 1f + private val scaleSession = + TaoTrackpadScaleSession { type, factor -> + scene?.dispatchTrackpadScale( + x = gestureCenterX, + y = gestureCenterY, + type = type, + scaleFactor = factor, + keyboardModifiers = currentKeyboardModifiers, + ) + } + private var rotateActive = false private var gestureAngle = 0f // Ctrl+wheel is a discrete stream with no ENDED phase (unlike a native trackpad - // gesture), so the synthetic magnify is released by an idle timer on this scope. + // gesture), so the scale gesture is released by an idle timer on this scope. // Deliberately NOT on the #622 fatal path: gesture helpers are isolated // (SupervisorJob) — a crash there costs one gesture, logged at SEVERE. private val gestureScope = @@ -1278,65 +1288,55 @@ internal class TaoComposeSceneHostLinux( val xPx = xFixed / TOUCH_POSITION_SCALE val yPx = yFixed / TOUCH_POSITION_SCALE val value = valueFixed / TRACKPAD_VALUE_SCALE + gestureCenterX = xPx + gestureCenterY = yPx + if (kind == TaoTrackpadGesture.MAGNIFY) { + when (phase) { + TaoTrackpadPhase.BEGAN -> { + scaleSession.start() + scaleSession.magnifyBy(value) + } + TaoTrackpadPhase.CHANGED -> scaleSession.magnifyBy(value) + TaoTrackpadPhase.ENDED -> scaleSession.end() + TaoTrackpadPhase.CANCELLED -> scaleSession.end() + } + return + } when (phase) { TaoTrackpadPhase.BEGAN -> { - startGesture(xPx, yPx) - applyGestureDelta(kind, value) - sendGesturePointers(PointerEventType.Press) + startRotate() + applyRotateDelta(value) + sendRotatePointers(PointerEventType.Press) } TaoTrackpadPhase.CHANGED -> { - if (!gestureActive) { - startGesture(xPx, yPx) - } else { - // Track the focal point on every tick so a pinch-while- - // dragging keeps its pan component (the synthetic centroid - // moves with the focal point between events). - gestureCenterX = xPx - gestureCenterY = yPx - } - applyGestureDelta(kind, value) - sendGesturePointers(PointerEventType.Move) + if (!rotateActive) startRotate() + applyRotateDelta(value) + sendRotatePointers(PointerEventType.Move) } - TaoTrackpadPhase.ENDED -> endGesture(cancelled = false) - TaoTrackpadPhase.CANCELLED -> endGesture(cancelled = true) + TaoTrackpadPhase.ENDED -> endRotate(cancelled = false) + TaoTrackpadPhase.CANCELLED -> endRotate(cancelled = true) } } - private fun startGesture( - centerX: Float, - centerY: Float, - ) { - gestureActive = true - gestureCenterX = centerX - gestureCenterY = centerY - gestureScale = 1f + private fun startRotate() { + rotateActive = true gestureAngle = 0f } - private fun applyGestureDelta( - kind: Int, - value: Float, - ) { - when (kind) { - TaoTrackpadGesture.MAGNIFY -> - gestureScale *= (1f + value).coerceAtLeast(MIN_GESTURE_SCALE) - TaoTrackpadGesture.ROTATE -> { - // Rust converts GDK's per-event radians into degrees so this - // matches the macOS NSEvent.rotation contract exactly. Sign - // flip for Compose's y-down screen frame. - gestureAngle -= value * (Math.PI.toFloat() / DEGREES_PER_RADIAN) - } - } + private fun applyRotateDelta(value: Float) { + // Rust converts GDK's per-event radians into degrees so this + // matches the macOS NSEvent.rotation contract exactly. Sign + // flip for Compose's y-down screen frame. + gestureAngle -= value * (Math.PI.toFloat() / DEGREES_PER_RADIAN) } @OptIn(ExperimentalComposeUiApi::class) - private fun sendGesturePointers(eventType: PointerEventType) { + private fun sendRotatePointers(eventType: PointerEventType) { val sc = scene ?: return - val radius = TRACKPAD_BASE_RADIUS_PX * gestureScale val cosA = cos(gestureAngle) val sinA = sin(gestureAngle) - val dx = radius * cosA - val dy = radius * sinA + val dx = TRACKPAD_BASE_RADIUS_PX * cosA + val dy = TRACKPAD_BASE_RADIUS_PX * sinA val pressed = eventType != PointerEventType.Release val pointers = listOf( @@ -1360,11 +1360,10 @@ internal class TaoComposeSceneHostLinux( ) } - private fun endGesture(cancelled: Boolean) { - if (!gestureActive) return - sendGesturePointers(PointerEventType.Release) - gestureActive = false - gestureScale = 1f + private fun endRotate(cancelled: Boolean) { + if (!rotateActive) return + sendRotatePointers(PointerEventType.Release) + rotateActive = false gestureAngle = 0f if (cancelled) scene?.cancelPointerInput() } @@ -2286,7 +2285,7 @@ internal class TaoComposeSceneHostLinux( currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers - // Ctrl+wheel → synthetic magnify gesture, never a scroll. On Windows the native + // Ctrl+wheel → Scale gesture, never a scroll. On Windows the native // layer routes WM_MOUSEWHEEL+Ctrl to the magnify hook; GTK delivers it here as a // plain scroll, so we do the same routing in Kotlin. Keeps Ctrl+wheel = zoom (not // zoom-and-scroll) and matches the Windows backend — the AWT backend has no @@ -2306,36 +2305,30 @@ internal class TaoComposeSceneHostLinux( } /** - * Feeds one Ctrl+wheel tick into the shared magnify-gesture machinery (Touch pinch), - * so the app's pinch-zoom handler receives it exactly like a trackpad pinch. The - * gesture is opened on the first tick, moved on each tick, and released by an idle - * timer once ticks stop ([scheduleWheelZoomEnd]). + * Feeds one Ctrl+wheel tick into the shared scale-gesture session, so the + * app's pinch-zoom handler receives it exactly like a trackpad pinch. The + * gesture is opened on the first tick, moved on each tick, and released by + * an idle timer once ticks stop ([scheduleWheelZoomEnd]). */ private fun onCtrlWheelZoom(deltaAwt: Float) { if (scene == null) return // AWT sign: wheel-up (zoom in) is a negative rotation, so negate to get a - // positive magnify value that grows the gesture scale. + // positive magnify value that grows the scale factor. val step = TaoWheelPinchZoom.stepFromWheelDelta(-deltaAwt) - if (!gestureActive) { - startGesture(lastPointerX, lastPointerY) - sendGesturePointers(PointerEventType.Press) - } else { - gestureCenterX = lastPointerX - gestureCenterY = lastPointerY - } - gestureScale *= step - sendGesturePointers(PointerEventType.Move) + gestureCenterX = lastPointerX + gestureCenterY = lastPointerY + scaleSession.change(step) scheduleWheelZoomEnd() } - /** Re-arms the idle timer that releases the synthetic wheel-driven magnify. */ + /** Re-arms the idle timer that releases the wheel-driven scale gesture. */ private fun scheduleWheelZoomEnd() { wheelZoomEndJob?.cancel() wheelZoomEndJob = gestureScope.launch { delay(WHEEL_ZOOM_IDLE_END_MS) wheelZoomEndJob = null - endGesture(cancelled = false) + scaleSession.end() } } @@ -2917,13 +2910,12 @@ internal class TaoComposeSceneHostLinux( private const val TOUCH_POSITION_SCALE: Float = 1024f private const val TRACKPAD_VALUE_SCALE: Float = 10_000f - // Synth pinch radius / pointer ids — same values as the macOS host + // Synth rotate radius / pointer ids — same values as the macOS host // (see `TaoComposeSceneHost`'s companion); kept in sync manually. private const val TRACKPAD_BASE_RADIUS_PX: Float = 120f private const val TRACKPAD_POINTER_ID_A: Long = 0xA001L private const val TRACKPAD_POINTER_ID_B: Long = 0xA002L private const val DEGREES_PER_RADIAN: Float = 180f - private const val MIN_GESTURE_SCALE: Float = 0.05f private const val WHEEL_ZOOM_IDLE_END_MS: Long = 120L /** diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt index cb334ac6d..4f879e731 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt @@ -36,8 +36,10 @@ import dev.nucleusframework.window.tao.TaoTouchEvent import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.clearContentMeasurer import dev.nucleusframework.window.tao.event.ProvideTaoWindowsScrollConfig +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSession import dev.nucleusframework.window.tao.event.TaoWheelPinchZoom import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll +import dev.nucleusframework.window.tao.event.dispatchTrackpadScale import dev.nucleusframework.window.tao.event.taoKeyEvent import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.event.taoTypedKeyEvent @@ -696,28 +698,32 @@ internal class TaoComposeSceneHostWindows( // Windows delivers a precision-touchpad pinch (and a real Ctrl+wheel) as a // WM_MOUSEWHEEL carrying the Ctrl flag; the vendored Tao patch routes those // to the magnify hook (instead of a scroll, which would drive the - // scrollable — the bug we're fixing). Each notch/tick is a discrete delta, - // but pinch detection (`detectTransformGestures`) only crosses its touch - // slop once distance has changed enough, so per-tick Press→Release bursts - // would swallow fine touchpad zooms. We instead keep ONE continuous - // two-finger Touch gesture: the first tick presses, every tick moves - // (accumulating scale), and an idle debounce releases it — the same - // continuous model the macOS path uses, so zoom is smooth and the gesture - // never reaches the scrollable. - - private var pinchActive = false - private var pinchScale = 1f + // scrollable). Each notch/tick is a discrete delta with no Began/Ended + // phase, so we keep ONE continuous Compose scale gesture: the first tick + // opens `ScaleStart`, every tick is `ScaleChange`, and an idle debounce + // sends `ScaleEnd` (#660). + private var pinchCenterX = 0f private var pinchCenterY = 0f + private val scaleSession = + TaoTrackpadScaleSession { type, factor -> + scene?.dispatchTrackpadScale( + x = pinchCenterX, + y = pinchCenterY, + type = type, + scaleFactor = factor, + keyboardModifiers = currentKeyboardModifiers, + ) + } private var pinchEndJob: Job? = null /** - * Synthesises a two-finger pinch from one Ctrl+wheel tick. [valueFixed] is - * the normalized wheel delta × [TRACKPAD_VALUE_SCALE] (positive = zoom in). - * Only magnify gestures are produced on Windows, so kind/phase/x/y from the - * shared `onTrackpadGesture` wire are ignored. + * Forwards one Ctrl+wheel / precision-touchpad pinch tick as a Compose + * scale step. [valueFixed] is the normalized wheel delta × + * [TRACKPAD_VALUE_SCALE] (positive = zoom in). Only magnify gestures are + * produced on Windows, so kind/phase/x/y from the shared + * `onTrackpadGesture` wire are ignored. */ - @OptIn(ExperimentalComposeUiApi::class) fun onTrackpadGesture( @Suppress("UNUSED_PARAMETER") kind: Int, @Suppress("UNUSED_PARAMETER") phase: Int, @@ -735,48 +741,13 @@ internal class TaoComposeSceneHostWindows( // ticks accumulate smoothly without each message behaving like a large // zoom step. val step = TaoWheelPinchZoom.stepFromWheelDelta(value) - - if (!pinchActive) { - pinchActive = true - pinchScale = 1f - // Centre on the cursor = zoom focal point (the pinch doesn't move it). - pinchCenterX = lastPointerX - pinchCenterY = lastPointerY - sendPinchPointers(PointerEventType.Press) - } - pinchScale *= step - sendPinchPointers(PointerEventType.Move) + pinchCenterX = lastPointerX + pinchCenterY = lastPointerY + scaleSession.change(step) schedulePinchEnd() } - @OptIn(ExperimentalComposeUiApi::class) - private fun sendPinchPointers(eventType: PointerEventType) { - val sc = scene ?: return - val radius = PINCH_BASE_RADIUS_PX * pinchScale - val pressed = eventType != PointerEventType.Release - val pointers = - listOf( - ComposeScenePointer( - id = PointerId(PINCH_POINTER_ID_A), - position = Offset(pinchCenterX - radius, pinchCenterY), - pressed = pressed, - type = PointerType.Touch, - ), - ComposeScenePointer( - id = PointerId(PINCH_POINTER_ID_B), - position = Offset(pinchCenterX + radius, pinchCenterY), - pressed = pressed, - type = PointerType.Touch, - ), - ) - sc.sendPointerEvent( - eventType = eventType, - pointers = pointers, - keyboardModifiers = currentKeyboardModifiers, - ) - } - - /** Re-arms the idle timer that releases the synthetic pinch once ticks stop. */ + /** Re-arms the idle timer that closes the scale gesture once ticks stop. */ private fun schedulePinchEnd() { pinchEndJob?.cancel() pinchEndJob = @@ -788,10 +759,7 @@ internal class TaoComposeSceneHostWindows( private fun endPinchGesture() { pinchEndJob = null - if (!pinchActive) return - sendPinchPointers(PointerEventType.Release) - pinchActive = false - pinchScale = 1f + scaleSession.end() } @OptIn(InternalComposeUiApi::class, ExperimentalComposeUiApi::class) @@ -2363,10 +2331,9 @@ internal class TaoComposeSceneHostWindows( nativeViewBlending.destroyOverlay() shutdownA11yScheduler() textToolbar.hide() - // Stop the pinch idle timer; the scene is going away so no Release needed. + // Stop the pinch idle timer; the scene is going away so no ScaleEnd needed. pinchEndJob?.cancel() pinchEndJob = null - pinchActive = false gestureScope.cancel() // Make THIS host's ES context current before tearing down Skia // resources. A sibling host (e.g. the main window opened while this @@ -2426,14 +2393,7 @@ internal class TaoComposeSceneHostWindows( */ private const val TRACKPAD_VALUE_SCALE: Float = 10_000f - /** Half-distance of the synthetic two-finger pair at scale 1.0. */ - private const val PINCH_BASE_RADIUS_PX: Float = 120f - - // Stable ids well clear of real touch ids (raw WM_POINTER finger ids). - private const val PINCH_POINTER_ID_A: Long = 0xA001L - private const val PINCH_POINTER_ID_B: Long = 0xA002L - - /** Idle gap after the last tick before the synthetic pinch releases. */ + /** Idle gap after the last tick before the scale gesture closes. */ private const val PINCH_IDLE_END_MS: Long = 120L /** diff --git a/decorated-window-tao/src/main/native/macos/touchpad_gestures.m b/decorated-window-tao/src/main/native/macos/touchpad_gestures.m index 906e47452..582a0084c 100644 --- a/decorated-window-tao/src/main/native/macos/touchpad_gestures.m +++ b/decorated-window-tao/src/main/native/macos/touchpad_gestures.m @@ -6,9 +6,8 @@ // (`WindowEvent` only exposes `TouchpadPressure`), so we intercept them // before AppKit dispatches them down the responder chain. // -// The Rust side then synthesizes two `ComposeScenePointer` Touch points on the -// JVM side so that `detectTransformGestures` reacts to pinch-zoom and rotate -// uniformly across platforms — see TOUCH_PLAN.md, Phase 3. +// The JVM side forwards magnify as Compose Scale events (#660) and still +// synthesises two Touch points for rotate (Compose has no rotation event). // // Threading: the monitor block runs on the AppKit main thread (where Tao's // event loop already lives), so the callback fires on the same thread that diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScaleSessionTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScaleSessionTest.kt new file mode 100644 index 000000000..d5a97df54 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScaleSessionTest.kt @@ -0,0 +1,103 @@ +package dev.nucleusframework.window.tao.event + +import androidx.compose.ui.input.pointer.PointerEventType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TaoTrackpadScaleSessionTest { + @Test + fun startChangeEndEmitsScaleSequence() { + val h = Harness() + h.session.start() + h.session.change(1.05f) + h.session.end() + assertEquals( + listOf( + PointerEventType.ScaleStart to 1f, + PointerEventType.ScaleChange to 1.05f, + PointerEventType.ScaleEnd to 1f, + ), + h.sent, + ) + assertFalse(h.session.active) + } + + @Test + fun changeOpensTheGestureIfNeeded() { + val h = Harness() + h.session.change(1.02f) + assertTrue(h.session.active) + assertEquals( + listOf( + PointerEventType.ScaleStart to 1f, + PointerEventType.ScaleChange to 1.02f, + ), + h.sent, + ) + } + + @Test + fun identityFactorIsNotAMove() { + val h = Harness() + h.session.start() + h.session.change(1f) + h.session.magnifyBy(0f) + assertEquals(listOf(PointerEventType.ScaleStart to 1f), h.sent) + } + + @Test + fun magnifyByUsesOnePlusDelta() { + val h = Harness() + h.session.magnifyBy(0.01f) + assertEquals(PointerEventType.ScaleChange to 1.01f, h.sent.last()) + h.session.magnifyBy(-0.5f) + assertEquals(PointerEventType.ScaleChange to 0.5f, h.sent.last()) + } + + @Test + fun magnifyByFloorsACollapse() { + val h = Harness() + h.session.magnifyBy(-2f) + assertEquals( + TaoTrackpadScaleSession.MIN_GESTURE_SCALE, + h.sent.last().second, + ) + } + + @Test + fun smartMagnifyIsAClosedBurst() { + val h = Harness() + h.session.smartMagnify() + assertEquals( + listOf( + PointerEventType.ScaleStart to 1f, + PointerEventType.ScaleChange to TaoTrackpadScaleSession.SMART_MAGNIFY_FACTOR, + PointerEventType.ScaleEnd to 1f, + ), + h.sent, + ) + assertFalse(h.session.active) + } + + @Test + fun endWithoutStartIsANoOp() { + val h = Harness() + h.session.end() + assertTrue(h.sent.isEmpty()) + } + + @Test + fun aSecondStartIsIgnoredWhileActive() { + val h = Harness() + h.session.start() + h.session.start() + assertEquals(listOf(PointerEventType.ScaleStart to 1f), h.sent) + } + + private class Harness { + val sent = mutableListOf>() + val session = TaoTrackpadScaleSession { type, factor -> sent += type to factor } + } +} 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 4be0e4608..e7168fee1 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 @@ -27,6 +27,7 @@ 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.dispatchTrackpadScale import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.ffi.TaoNativeWireFormat import kotlinx.coroutines.CoroutineDispatcher @@ -555,6 +556,25 @@ internal class TaoSceneTestScope( frame() } + /** + * Mirrors the scene host's trackpad pinch dispatch (`dispatchTrackpadScale`, + * #660): [scaleFactor] is a multiplicative per-event ratio (`1f` = no + * change). The pointer sits at the last cursor position. + */ + fun scale( + type: PointerEventType, + scaleFactor: Float = 1f, + ) { + scene.dispatchTrackpadScale( + x = pointerDeadband.x, + y = pointerDeadband.y, + type = type, + scaleFactor = scaleFactor, + 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/TaoSceneTrackpadScaleTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadScaleTest.kt new file mode 100644 index 000000000..3c60a828b --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadScaleTest.kt @@ -0,0 +1,435 @@ +@file:OptIn(InternalComposeUiApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) + +package dev.nucleusframework.window.tao.scene + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.InternalComposeUiApi +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.PointerId +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.scene.ComposeScenePointer +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSession +import dev.nucleusframework.window.tao.event.dispatchTrackpadScale +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * #660: a platform-recognized pinch must reach Compose as `ScaleStart` / + * `ScaleChange` / `ScaleEnd` at the cursor, not as two synthetic Touch + * contacts 120 px off it. + * + * The first tests replay the pre-#660 synthesis so the bug stays measurable + * (dual-hit at a map edge, touch-slop delay on a 1 % pinch). The rest drive + * [dispatchTrackpadScale] — the production path after the fix. + */ +class TaoSceneTrackpadScaleTest { + // ── Reproduction of the pre-#660 two-touch synthesis ─────────────────── + + @Test + fun `legacy two-touch pinch plants contacts 120 px off the cursor`() = + runTaoSceneTest(width = 400, height = 200) { + val contacts = mutableListOf() + setContent { + Box(Modifier.fillMaxSize().recordingPositions(contacts)) + } + moveMouse(CURSOR_X, CURSOR_Y) + frameUntilIdle() + contacts.clear() + sendLegacyPinch(PointerEventType.Press, scale = 1f, CURSOR_X, CURSOR_Y) + frameUntilIdle() + + val unique = contacts.distinct() + assertEquals(2, unique.size, "legacy pinch must plant two Touch contacts, got $contacts") + val distances = unique.map { hypot(it.x - CURSOR_X, it.y - CURSOR_Y) } + distances.forEach { distance -> + assertEquals( + LEGACY_RADIUS_PX.toDouble(), + distance.toDouble(), + absoluteTolerance = 0.01, + message = "legacy contact $distance px from cursor; expected $LEGACY_RADIUS_PX px", + ) + } + println( + "REPRO #660 geometry: cursor=($CURSOR_X, $CURSOR_Y) contacts=$unique " + + "distances=$distances span=${hypot(unique[0].x - unique[1].x, unique[0].y - unique[1].y)}", + ) + } + + @Test + fun `legacy two-touch pinch at a map edge hits the neighbouring chrome`() = + runTaoSceneTest(width = 400, height = 200) { + val mapHits = mutableListOf() + val chromeHits = mutableListOf() + setContent { + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxHeight() + .width(MAP_WIDTH_DP.dp) + .background(Color.Blue) + .recordingPositions(mapHits), + ) + Box( + Modifier + .offset(x = MAP_WIDTH_DP.dp) + .fillMaxHeight() + .width(CHROME_WIDTH_DP.dp) + .background(Color.Red) + .recordingPositions(chromeHits), + ) + } + } + // Cursor 10 px inside the map, next to the chrome. The 120 px + // synthetic pair straddles the boundary: one contact in the map, + // the other in the chrome — the edge interruption MapLibre saw. + moveMouse(NEAR_EDGE_X, CURSOR_Y) + frameUntilIdle() + mapHits.clear() + chromeHits.clear() + sendLegacyPinch(PointerEventType.Press, scale = 1f, NEAR_EDGE_X, CURSOR_Y) + frameUntilIdle() + + println( + "REPRO #660 dual-hit: cursor=$NEAR_EDGE_X (map is 0..$MAP_WIDTH_PX) " + + "mapHits=$mapHits chromeHits=$chromeHits", + ) + assertTrue(mapHits.isNotEmpty(), "one synthetic contact must land in the map, got mapHits=$mapHits") + assertTrue( + chromeHits.isNotEmpty(), + "the other synthetic contact must land in the neighbouring chrome " + + "(the #660 edge interruption); chromeHits=$chromeHits", + ) + } + + @Test + fun `legacy two-touch pinch delays a 1 percent zoom behind touch slop`() = + runTaoSceneTest(width = 400, height = 200) { + val zoom = mutableStateOf(1f) + val callbacks = mutableStateOf(0) + setContent { + Box( + Modifier.fillMaxSize().pointerInput(Unit) { + detectTransformGestures { _, _, zoomChange, _ -> + callbacks.value++ + zoom.value *= zoomChange + } + }, + ) + } + moveMouse(CURSOR_X, CURSOR_Y) + sendLegacyPinch(PointerEventType.Press, scale = 1f, CURSOR_X, CURSOR_Y) + sendLegacyPinch(PointerEventType.Move, scale = ONE_PERCENT, CURSOR_X, CURSOR_Y) + frameUntilIdle() + + println( + "REPRO #660 slop: 1% pinch through two-touch synthesis → " + + "callbacks=${callbacks.value} zoom=${zoom.value} " + + "(zoomMotion = |1-$ONE_PERCENT| × $LEGACY_RADIUS_PX = " + + "${abs(1f - ONE_PERCENT) * LEGACY_RADIUS_PX} px vs ~18 px touchSlop)", + ) + assertEquals(0, callbacks.value, "a 1% pinch must not cross detectTransformGestures touch slop") + assertEquals(1f, zoom.value) + } + + @Test + fun `legacy two-touch pinch needs about 15 percent before detectTransformGestures zooms`() = + runTaoSceneTest(width = 400, height = 200) { + val zoom = mutableStateOf(1f) + val callbacks = mutableStateOf(0) + setContent { + Box( + Modifier.fillMaxSize().pointerInput(Unit) { + detectTransformGestures { _, _, zoomChange, _ -> + callbacks.value++ + zoom.value *= zoomChange + } + }, + ) + } + moveMouse(CURSOR_X, CURSOR_Y) + sendLegacyPinch(PointerEventType.Press, scale = 1f, CURSOR_X, CURSOR_Y) + var steps = 0 + var scale = 1f + while (callbacks.value == 0 && steps < MAX_SLOP_STEPS) { + scale *= ONE_PERCENT + steps++ + sendLegacyPinch(PointerEventType.Move, scale = scale, CURSOR_X, CURSOR_Y) + frameUntilIdle() + } + println( + "REPRO #660 hesitation: $steps steps of +1% (cumulative scale=$scale, " + + "${((scale - 1f) * 100f).toInt()}%) before detectTransformGestures fired " + + "(callbacks=${callbacks.value} zoom=${zoom.value})", + ) + assertTrue(callbacks.value > 0, "eventually the slop must be crossed") + assertTrue( + steps >= MIN_SLOP_STEPS, + "expected a long slop delay, got a callback after $steps × 1% steps", + ) + } + + // ── Production Scale path (#660) ─────────────────────────────────────── + + @Test + fun `magnify is dispatched as ScaleStart ScaleChange ScaleEnd at the cursor`() = + runTaoSceneTest(width = 400, height = 200) { + val seen = mutableListOf() + setContent { Box(Modifier.fillMaxSize().recordingScale(seen)) } + moveMouse(CURSOR_X, CURSOR_Y) + scale(PointerEventType.ScaleStart) + scale(PointerEventType.ScaleChange, ONE_PERCENT) + scale(PointerEventType.ScaleEnd) + frameUntilIdle() + + assertEquals( + listOf( + PointerEventType.ScaleStart, + PointerEventType.ScaleChange, + PointerEventType.ScaleEnd, + ), + seen.map { it.type }, + "pinch must reach Compose as Scale events, got $seen", + ) + assertEquals(ONE_PERCENT, seen[1].scaleFactor) + seen.forEach { record -> + assertEquals(1, record.pointerCount, "Scale events must carry one pointer, got $record") + assertEquals(PointerType.Mouse, record.pointerType) + assertEquals(CURSOR_X, record.position.x) + assertEquals(CURSOR_Y, record.position.y) + } + println("FIX #660 events: $seen") + } + + @Test + fun `scale events at a map edge hit only the map under the cursor`() = + runTaoSceneTest(width = 400, height = 200) { + val mapHits = mutableListOf() + val chromeHits = mutableListOf() + setContent { + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxHeight() + .width(MAP_WIDTH_DP.dp) + .background(Color.Blue) + .recordingPositions(mapHits), + ) + Box( + Modifier + .offset(x = MAP_WIDTH_DP.dp) + .fillMaxHeight() + .width(CHROME_WIDTH_DP.dp) + .background(Color.Red) + .recordingPositions(chromeHits), + ) + } + } + moveMouse(NEAR_EDGE_X, CURSOR_Y) + frameUntilIdle() + mapHits.clear() + chromeHits.clear() + scale(PointerEventType.ScaleStart) + scale(PointerEventType.ScaleChange, ONE_PERCENT) + scale(PointerEventType.ScaleEnd) + frameUntilIdle() + + println("FIX #660 hit-test: mapHits=$mapHits chromeHits=$chromeHits") + assertTrue(mapHits.isNotEmpty(), "the Scale event must hit the map under the cursor") + assertTrue( + chromeHits.isEmpty(), + "Scale events must not hit neighbouring chrome, got chromeHits=$chromeHits", + ) + } + + @Test + fun `a 1 percent scale change zooms transformable immediately`() = + runTaoSceneTest(width = 400, height = 200) { + val zoom = mutableStateOf(1f) + setContent { + val state = + @Suppress("DEPRECATION") + rememberTransformableState { zoomChange, _, _ -> + zoom.value *= zoomChange + } + Box(Modifier.fillMaxSize().transformable(state)) + } + moveMouse(CURSOR_X, CURSOR_Y) + scale(PointerEventType.ScaleStart) + scale(PointerEventType.ScaleChange, ONE_PERCENT) + scale(PointerEventType.ScaleEnd) + frameUntilIdle() + + println("FIX #660 transformable: 1% ScaleChange → zoom=${zoom.value}") + assertEquals( + ONE_PERCENT.toDouble(), + zoom.value.toDouble(), + absoluteTolerance = 0.0001, + message = "transformable must apply the ScaleChange ratio with no slop, got ${zoom.value}", + ) + } + + @Test + fun `detectTransformGestures is not the Scale path and stays quiet on a 1 percent pinch`() = + runTaoSceneTest(width = 400, height = 200) { + val callbacks = mutableStateOf(0) + setContent { + Box( + Modifier.fillMaxSize().pointerInput(Unit) { + detectTransformGestures { _, _, _, _ -> callbacks.value++ } + }, + ) + } + moveMouse(CURSOR_X, CURSOR_Y) + scale(PointerEventType.ScaleStart) + scale(PointerEventType.ScaleChange, ONE_PERCENT) + scale(PointerEventType.ScaleEnd) + frameUntilIdle() + assertEquals( + 0, + callbacks.value, + "detectTransformGestures must not re-interpret Scale events as a two-finger pinch", + ) + } + + @Test + fun `host-shaped magnify stream zooms transformable without slop`() = + runTaoSceneTest(width = 400, height = 200) { + val zoom = mutableStateOf(1f) + setContent { + val state = + @Suppress("DEPRECATION") + rememberTransformableState { zoomChange, _, _ -> + zoom.value *= zoomChange + } + Box(Modifier.fillMaxSize().transformable(state)) + } + moveMouse(CURSOR_X, CURSOR_Y) + val session = + TaoTrackpadScaleSession { type, factor -> + scene.dispatchTrackpadScale(CURSOR_X, CURSOR_Y, type, factor) + frame() + } + // macOS: Began, then a 1% Changed, then Ended — the AppKit stream. + session.start() + session.magnifyBy(0.01f) + session.end() + frameUntilIdle() + println("FIX #660 host stream: Began + 1% Changed + Ended → zoom=${zoom.value}") + assertEquals( + ONE_PERCENT.toDouble(), + zoom.value.toDouble(), + absoluteTolerance = 0.0001, + message = "the host magnify stream must zoom immediately, got ${zoom.value}", + ) + } + + /** + * Pre-#660 host synthesis: two Touch pointers [LEGACY_RADIUS_PX] either + * side of [centerX]/[centerY], distance scaled by [scale]. + */ + private fun TaoSceneTestScope.sendLegacyPinch( + eventType: PointerEventType, + scale: Float, + centerX: Float, + centerY: Float, + ) { + val radius = LEGACY_RADIUS_PX * scale + val pressed = eventType != PointerEventType.Release + scene.sendPointerEvent( + eventType = eventType, + pointers = + listOf( + ComposeScenePointer( + id = PointerId(LEGACY_POINTER_ID_A), + position = Offset(centerX - radius, centerY), + pressed = pressed, + type = PointerType.Touch, + ), + ComposeScenePointer( + id = PointerId(LEGACY_POINTER_ID_B), + position = Offset(centerX + radius, centerY), + pressed = pressed, + type = PointerType.Touch, + ), + ), + ) + frame() + } + + private fun Modifier.recordingPositions(into: MutableList): Modifier = + pointerInput(into) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { into += it.position } + } + } + } + + private fun Modifier.recordingScale(into: MutableList): Modifier = + pointerInput(into) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + when (event.type) { + PointerEventType.ScaleStart, + PointerEventType.ScaleChange, + PointerEventType.ScaleEnd, + -> { + val change = event.changes.first() + into += + ScaleRecord( + type = event.type, + scaleFactor = change.scaleFactor, + position = change.position, + pointerCount = event.changes.size, + pointerType = change.type, + ) + } + else -> Unit + } + } + } + } + + private data class ScaleRecord( + val type: PointerEventType, + val scaleFactor: Float, + val position: Offset, + val pointerCount: Int, + val pointerType: PointerType, + ) + + private companion object { + const val CURSOR_X = 200f + const val CURSOR_Y = 100f + const val LEGACY_RADIUS_PX = 120f + const val LEGACY_POINTER_ID_A = 0xA001L + const val LEGACY_POINTER_ID_B = 0xA002L + const val ONE_PERCENT = 1.01f + const val MAP_WIDTH_DP = 150 + const val CHROME_WIDTH_DP = 250 + const val MAP_WIDTH_PX = 150f + const val NEAR_EDGE_X = 140f + const val MAX_SLOP_STEPS = 40 + const val MIN_SLOP_STEPS = 10 + } +} 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 index b48db7690..a75f6ebda 100644 --- a/examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt @@ -67,16 +67,18 @@ import kotlin.math.max * 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, + * - **Inspector**: every `Scroll` / `PanStart` / `PanMove` / `PanEnd` / + * `ScaleStart` / `ScaleChange` / `ScaleEnd` 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. + * - **Map canvas**: pans on Pan events, zooms on Scale (pinch, #660) and + * Scroll (wheel). A trackpad swipe that zooms means #654 is back; a + * pinch that arrives as two Touch contacts means #660 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, @@ -212,6 +214,9 @@ private class PointerLog { var panStarts by mutableIntStateOf(0) var panMoves by mutableIntStateOf(0) var panEnds by mutableIntStateOf(0) + var scaleStarts by mutableIntStateOf(0) + var scaleChanges by mutableIntStateOf(0) + var scaleEnds by mutableIntStateOf(0) var scrolls by mutableIntStateOf(0) private var gestureIndex = 0 @@ -265,6 +270,18 @@ private class PointerLog { if (gestures.size > MAX_GESTURES) gestures.removeAt(gestures.lastIndex) add(gap, "PanEnd (+$endAfter ms after the last move)") } + PointerEventType.ScaleStart -> { + scaleStarts++ + add(gap, "ScaleStart") + } + PointerEventType.ScaleChange -> { + scaleChanges++ + add(gap, "ScaleChange ×${"%.4f".format(change.scaleFactor)}") + } + PointerEventType.ScaleEnd -> { + scaleEnds++ + add(gap, "ScaleEnd") + } PointerEventType.Scroll -> { scrolls++ add( @@ -283,6 +300,9 @@ private class PointerLog { panStarts = 0 panMoves = 0 panEnds = 0 + scaleStarts = 0 + scaleChanges = 0 + scaleEnds = 0 scrolls = 0 lastEventMs = 0L } @@ -306,9 +326,13 @@ private fun InspectorPanel( "PanStart ${log.panStarts} PanMove ${log.panMoves} PanEnd ${log.panEnds} Scroll ${log.scrolls}", bold = true, ) + Mono( + "ScaleStart ${log.scaleStarts} ScaleChange ${log.scaleChanges} ScaleEnd ${log.scaleEnds}", + bold = true, + ) Text( - "Trackpad ⇒ PanStart, PanMove…, ONE PanEnd (end ≈0 ms: momentum closed it, ≈150 ms: grace timer). " + - "Wheel ⇒ Scroll only.", + "Trackpad swipe ⇒ PanStart, PanMove…, ONE PanEnd (end ≈0 ms: momentum closed it, ≈150 ms: grace timer). " + + "Pinch ⇒ ScaleStart, ScaleChange…, ScaleEnd. Wheel ⇒ Scroll only.", style = MaterialTheme.typography.bodySmall, ) Mono("# steps Σ units (x, y) dur gap end", bold = true) @@ -397,9 +421,12 @@ private fun SignAndMagnitudePanel( 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) { + Panel("Map canvas — #654 pan / #660 pinch", 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) + Text( + "Two fingers pan the grid; pinch zooms (Scale events); a wheel notch zooms.", + style = MaterialTheme.typography.bodySmall, + ) Canvas( modifier = Modifier @@ -420,6 +447,13 @@ private fun MapCanvasPanel(modifier: Modifier = Modifier) { change.consume() } PointerEventType.PanStart, PointerEventType.PanEnd -> change.consume() + PointerEventType.ScaleStart, PointerEventType.ScaleEnd -> change.consume() + PointerEventType.ScaleChange -> { + if (change.scaleFactor != 1f) { + zoom = (zoom * change.scaleFactor).coerceIn(MIN_ZOOM, MAX_ZOOM) + } + change.consume() + } PointerEventType.Scroll -> { zoom = (zoom * (1f - change.scrollDelta.y * ZOOM_PER_NOTCH)).coerceIn( diff --git a/examples/shared/src/main/kotlin/dev/nucleusframework/sampleshared/ZoomTab.kt b/examples/shared/src/main/kotlin/dev/nucleusframework/sampleshared/ZoomTab.kt index 64de6f2da..4bf6d0ad2 100644 --- a/examples/shared/src/main/kotlin/dev/nucleusframework/sampleshared/ZoomTab.kt +++ b/examples/shared/src/main/kotlin/dev/nucleusframework/sampleshared/ZoomTab.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -31,8 +32,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** - * Demonstrates `detectTransformGestures` driven by macOS trackpad pinch / - * rotate / smart-magnify (Tao backend) and standard mouse drag. + * Demonstrates trackpad pinch / rotate / smart-magnify (Tao backend) and + * standard mouse drag. + * + * Pinch arrives as Compose `ScaleStart` / `ScaleChange` / `ScaleEnd` (#660); + * two-finger rotate still goes through `detectTransformGestures` (Compose + * has no rotation event). * * Modifier topology — important: the gesture detector lives on the **outer** * (viewport) Box, the visual transform lives on the **inner** Box. Compose @@ -76,6 +81,25 @@ fun ZoomTab(modifier: Modifier = Modifier) { .clip(RoundedCornerShape(16.dp)) .background(Color(0xFF15181D)) .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + when (event.type) { + PointerEventType.ScaleStart, + PointerEventType.ScaleChange, + PointerEventType.ScaleEnd, + -> { + var factor = 1f + event.changes.forEach { factor *= it.scaleFactor } + if (factor != 1f) { + scale = (scale * factor).coerceIn(MIN_SCALE, MAX_SCALE) + } + } + else -> Unit + } + } + } + }.pointerInput(Unit) { detectTransformGestures { _, pan, zoom, rot -> scale = (scale * zoom).coerceIn(MIN_SCALE, MAX_SCALE) rotation += rot