diff --git a/docs/native-text-selection.md b/docs/native-text-selection.md index 28e6d27066..22e5e0c7e1 100644 --- a/docs/native-text-selection.md +++ b/docs/native-text-selection.md @@ -2,6 +2,15 @@ Keep assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view. +## Android inverted chat + +Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility +rectangles ignore list inversion. React Native decomposes Android's `scale: -1` +into rotation with positive `scaleY`, so test the composed matrix when deciding +whether those requests should move the viewport. Native tests must apply the +list's transform through React Native's view manager; assigning `scaleY` directly +misses that decomposition. + ## Diagrams in lists and quotes Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment. diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt index 7d6fcee8ed..3c446d140b 100644 --- a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt @@ -8,6 +8,9 @@ import android.widget.FrameLayout import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.soloader.OpenSourceMergedSoMapping import com.facebook.react.uimanager.ThemedReactContext @@ -47,16 +50,34 @@ class ScrollSelectionTest { assertNotEquals(before, scroll.scrollY) } + @Test fun directVerticalMirroringKeepsSelectionStill() = withScroll(inverted = false) { scroll, text -> + scroll.scaleY = -1f + val before = scroll.scrollY + text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true) + assertEquals(before, scroll.scrollY) + } + + @Test fun horizontalMirroringStillRevealsSelectedText() = withScroll(inverted = false) { scroll, text -> + scroll.scaleX = -1f + val before = scroll.scrollY + text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true) + assertNotEquals(before, scroll.scrollY) + } + private fun withScroll(inverted: Boolean, check: (ReactScrollView, TextView) -> Unit) { val instrumentation = InstrumentationRegistry.getInstrumentation() instrumentation.runOnMainSync { val appContext = instrumentation.targetContext SoLoader.init(appContext, OpenSourceMergedSoMapping) + com.facebook.react.uimanager.DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext) val reactContext = BridgeReactContext(appContext) val context = ThemedReactContext(reactContext, appContext) val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager val scroll = manager.createViewInstance(context) - scroll.scaleY = if (inverted) -1f else 1f + // Exercise VirtualizedList's Android transform through RN's real decomposition. + if (inverted) manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of( + "transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", -1.0)) + ))) val content = FrameLayout(context) val text = TextView(context).apply { setText("Selectable timeline text", TextView.BufferType.SPANNABLE) diff --git a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt index c3a2a4373d..51dc57db5f 100644 --- a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt +++ b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt @@ -1,5 +1,6 @@ package sh.paseo.scroll +import android.graphics.Matrix import android.graphics.Rect import com.facebook.react.ReactPackage import com.facebook.react.ViewManagerOnDemandReactPackage @@ -27,9 +28,15 @@ private class PaseoScrollViewManager : ReactScrollViewManager() { } private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) { - // Both RN focus handling and Android selection visibility requests use this calculation. - // Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline - // movement to user gestures and explicit scroll commands. - override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int = - if (scaleY < 0) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect) + private val transformValues = FloatArray(9) + + // Android VirtualizedList uses scale: -1, which RN decomposes into a 180-degree + // rotation with positive scaleY. Inspect the composed transform so both that + // inversion and a direct scaleY: -1 suppress untransformed visibility requests. + // Keep focus/selection bookkeeping; gestures and explicit commands still scroll. + override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int { + matrix.getValues(transformValues) + return if (transformValues[Matrix.MSCALE_Y] < 0) 0 + else super.computeScrollDeltaToGetChildRectOnScreen(rect) + } }