Explorar el Código

Merge remote-tracking branch 'origin/main'

HapeLee hace 1 mes
padre
commit
4285f7f1fe

+ 22 - 0
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -117,6 +117,7 @@ import io.legado.app.feature.reader.core.readaloud.ReaderVisibleTextPosition
 import io.legado.app.feature.reader.core.readaloud.ReaderVisibleTextPositionPolicy
 import io.legado.app.feature.reader.core.selection.ReaderPageChangeOrigin
 import io.legado.app.feature.reader.core.selection.ReaderSelection
+import io.legado.app.feature.reader.core.selection.ReaderSelectionDragState
 import io.legado.app.feature.reader.core.selection.ReaderSelectionEndpoint
 import io.legado.app.feature.reader.core.selection.ReaderSelectionLifecyclePolicy
 import io.legado.app.feature.reader.core.selection.ReaderSelectionMenuAnchor
@@ -909,6 +910,10 @@ fun ReaderCanvasSurface(
                 viewConfiguration.touchSlop,
                 configuredTouchSlopPx,
             )
+            // 长按后进拖选的阈值不跟随 pageTouchSlop:后者是防误触翻页设置,可配到 1000px。
+            val selectionDragSlop = ReaderGestureSettingsPolicy.selectionDragSlopPx(
+                viewConfiguration.touchSlop,
+            )
             awaitEachGesture {
                 val down = awaitFirstDown(requireUnconsumed = false)
                 latestReaderInteraction()
@@ -939,6 +944,7 @@ fun ReaderCanvasSurface(
                 var scrollHitBoundary: ReaderTurnDirection? = null
                 var movedPastSlop = false
                 var longPressed = false
+                var selectionDragState = ReaderSelectionDragState()
                 var grabbingStart = false
                 var grabbingEnd = false
                 var grabbedEndpoint: ReaderSelectionEndpoint? = null
@@ -1062,6 +1068,22 @@ fun ReaderCanvasSurface(
                         if (longPressed || grabbingStart || grabbingEnd) {
                             val selection = textSelection
                             val movingEndpoint = grabbedEndpoint ?: ReaderSelectionEndpoint.FOCUS
+                            // 长按刚成立时的手抖不该破坏整词选区:越过拖选阈值前保持初始
+                            // selection,只更新放大镜位置。把手拖动不受阈值限制(见
+                            // ReaderSelectionDragState.handleGrabbed)。
+                            selectionDragState = selectionDragState.update(
+                                longPressed = longPressed,
+                                handleGrabbed = grabbingStart || grabbingEnd,
+                                distancePx = total.getDistance(),
+                                dragSlopPx = selectionDragSlop,
+                            )
+                            if (!selectionDragState.started) {
+                                selectionMagnifierSource = selection?.let {
+                                    selectionCursorCenter(it, movingEndpoint)
+                                }
+                                change.consume()
+                                continue
+                            }
                             // PointerInput 会先派发一个与 DOWN 位置相同的事件。把手尚未移动时
                             // 不能再用行底去 hit-test,否则该边界可能直接吸附到下一行。
                             if (grabbedEndpoint != null && !handleHasMoved) {

+ 12 - 0
app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderGestureSettingsPolicy.kt

@@ -6,6 +6,18 @@ object ReaderGestureSettingsPolicy {
         configuredTouchSlopPx.takeIf { it > 0 }?.toFloat()
             ?: platformTouchSlopPx.coerceAtLeast(0f)
 
+    /**
+     * 长按建立初始选区后,进入 drag 所需的位移阈值。
+     *
+     * 与 [touchSlopPx] 刻意解耦:`pageTouchSlop` 是“防误触翻页”的灵敏度设置,可配到
+     * 1000px;沿用它会让长按后的拖选要移动几十甚至上百像素才开始响应。这里恒用平台
+     * slop,保证拖选启动手感不随翻页灵敏度漂移。
+     */
+    fun selectionDragSlopPx(platformTouchSlopPx: Float): Float =
+        platformTouchSlopPx.coerceAtLeast(MIN_SELECTION_DRAG_SLOP_PX)
+
+    private const val MIN_SELECTION_DRAG_SLOP_PX = 1f
+
     /**
      * 滚动点击翻页是否播放动画。
      *

+ 20 - 0
app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelectionDragState.kt

@@ -0,0 +1,20 @@
+package io.legado.app.feature.reader.core.selection
+
+/**
+ * 区分“长按已成立”与“选区 drag 已开始”的粘性闸门,不是 Compose 状态。
+ *
+ * 长按通过 `startWord()` 建好初始选区后,未达 [update] 阈值的小抖动不应破坏刚选出的
+ * 整词;一旦判定为 drag(或抓到了把手)就永久 started——回到原点也不会退回,避免
+ * 选区在阈值附近来回跳。阈值取自 `ReaderGestureSettingsPolicy.selectionDragSlopPx`,
+ * 与防误触翻页的 `pageTouchSlop` 无关。
+ */
+data class ReaderSelectionDragState(val started: Boolean = false) {
+    fun update(
+        longPressed: Boolean,
+        handleGrabbed: Boolean,
+        distancePx: Float,
+        dragSlopPx: Float,
+    ): ReaderSelectionDragState = if (
+        started || handleGrabbed || (longPressed && distancePx >= dragSlopPx)
+    ) copy(started = true) else this
+}

+ 9 - 0
app/src/test/java/io/legado/app/feature/reader/core/gesture/ReaderGestureSettingsPolicyTest.kt

@@ -16,6 +16,15 @@ class ReaderGestureSettingsPolicyTest {
         assertEquals(30f, ReaderGestureSettingsPolicy.touchSlopPx(12f, 30), 0f)
     }
 
+    @Test
+    fun `selection drag slop ignores the configurable page turn slop`() {
+        // pageTouchSlop 是防误触翻页设置,最大可配到 1000px;拖选启动不能沿用它。
+        assertEquals(12f, ReaderGestureSettingsPolicy.selectionDragSlopPx(12f), 0f)
+        assertEquals(1f, ReaderGestureSettingsPolicy.selectionDragSlopPx(0f), 0f)
+        assertEquals(1f, ReaderGestureSettingsPolicy.selectionDragSlopPx(-3f), 0f)
+        assertEquals(30f, ReaderGestureSettingsPolicy.selectionDragSlopPx(30f), 0f)
+    }
+
     @Test
     fun `no animation collapses a commanded scroll page turn to a single jump`() {
         assertEquals(false, ReaderGestureSettingsPolicy.animatesScrollPage(true))

+ 86 - 0
app/src/test/java/io/legado/app/feature/reader/core/selection/ReaderSelectionDragStateTest.kt

@@ -0,0 +1,86 @@
+package io.legado.app.feature.reader.core.selection
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReaderSelectionDragStateTest {
+    @Test
+    fun movementBelowSlopKeepsInitialLongPressSelection() {
+        val state = ReaderSelectionDragState().update(
+            longPressed = true,
+            handleGrabbed = false,
+            distancePx = 3f,
+            dragSlopPx = 8f,
+        )
+
+        assertFalse(state.started)
+    }
+
+    @Test
+    fun movementAtSlopStartsSelectionDrag() {
+        val state = ReaderSelectionDragState().update(
+            longPressed = true,
+            handleGrabbed = false,
+            distancePx = 8f,
+            dragSlopPx = 8f,
+        )
+
+        assertTrue(state.started)
+    }
+
+    @Test
+    fun dragRemainsStartedAfterCrossingSlop() {
+        val started = ReaderSelectionDragState().update(
+            longPressed = true,
+            handleGrabbed = false,
+            distancePx = 8f,
+            dragSlopPx = 8f,
+        )
+
+        assertTrue(
+            started.update(
+                longPressed = true,
+                handleGrabbed = false,
+                distancePx = 2f,
+                dragSlopPx = 8f,
+            ).started
+        )
+    }
+
+    @Test
+    fun grabbedHandleStartsImmediatelyWithoutSlop() {
+        val state = ReaderSelectionDragState().update(
+            longPressed = false,
+            handleGrabbed = true,
+            distancePx = 0f,
+            dragSlopPx = 8f,
+        )
+
+        assertTrue(state.started)
+    }
+
+    @Test
+    fun movementWithoutLongPressOrHandleNeverStartsDrag() {
+        val state = ReaderSelectionDragState().update(
+            longPressed = false,
+            handleGrabbed = false,
+            distancePx = 200f,
+            dragSlopPx = 8f,
+        )
+
+        assertFalse(state.started)
+    }
+
+    @Test
+    fun aZeroSlopStillRequiresSomeMovement() {
+        val state = ReaderSelectionDragState().update(
+            longPressed = true,
+            handleGrabbed = false,
+            distancePx = 0f,
+            dragSlopPx = 1f,
+        )
+
+        assertFalse(state.started)
+    }
+}

+ 100 - 0
app/src/test/java/io/legado/app/feature/reader/core/selection/ReaderSelectionTest.kt

@@ -21,6 +21,27 @@ class ReaderSelectionTest {
         ), 1L,
     )
 
+    /**
+     * 每个 [ReaderElement.Text] 占 10px 宽、一行高。元素粒度由排版层决定,这里由测试
+     * 自己构造,因此断言只保证“一个元素作为整体被选中/跳过”,不保证排版层如何分词。
+     */
+    private fun textPage(values: List<String>): ReaderPage {
+        var position = 0
+        val elements = values.mapIndexed { index, value ->
+            ReaderElement.Text(
+                ReaderRect(index * 10f, 0f, index * 10f + 10f, 20f),
+                15f,
+                value,
+                style,
+                selected = false,
+                emphasized = false,
+                chapterPosition = position.also { position += value.length },
+                paragraphIndex = 0,
+            )
+        }
+        return page.copy(text = values.joinToString(""), elements = elements)
+    }
+
     @Test fun reverseSelectionNormalizesAndPreservesTextOrder() {
         val selection = ReaderSelection(0, 2, 0)
         assertEquals(0, selection.start)
@@ -96,6 +117,85 @@ class ReaderSelectionTest {
         assertEquals("word", selection.selectedText(paragraphs))
     }
 
+    @Test fun intentionalLatinDragReturnsToElementGranularity() {
+        val latin = textPage("one two three".map { it.toString() })
+        val initial = ReaderSelectionPolicy.startWord(latin, 5f, 10f, Locale.ENGLISH)!!
+
+        val expandedInsideWord = ReaderSelectionPolicy.extend(initial, latin, 105f, 10f)
+        val contractedInsideWord = ReaderSelectionPolicy.extend(
+            ReaderSelection(0, 0, 12), latin, 95f, 10f,
+        )
+
+        assertEquals("one", initial.selectedText(latin))
+        assertEquals("one two thr", expandedInsideWord.selectedText(latin))
+        assertEquals("one two th", contractedInsideWord.selectedText(latin))
+    }
+
+    @Test fun reversedDragRemainsElementGranular() {
+        val latin = textPage("one two three".map { it.toString() })
+        val reversed = ReaderSelection(0, 12, 0)
+
+        val moved = ReaderSelectionPolicy.extend(reversed, latin, 15f, 10f)
+
+        assertEquals(12, moved.anchor)
+        assertEquals(1, moved.focus)
+        assertEquals("ne two three", moved.selectedText(latin))
+    }
+
+    @Test fun bothSemanticEndpointsCanMoveInsideLatinWords() {
+        val latin = textPage("one two three".map { it.toString() })
+        val selection = ReaderSelection(0, 0, 12)
+
+        val anchorMoved = selection.moveEndpoint(ReaderSelectionEndpoint.ANCHOR, 1)
+        val focusMoved = selection.moveEndpoint(ReaderSelectionEndpoint.FOCUS, 9)
+
+        assertEquals("ne two three", anchorMoved.selectedText(latin))
+        assertEquals("one two th", focusMoved.selectedText(latin))
+    }
+
+    @Test fun eitherEndpointCanCrossWithoutChangingItsIdentity() {
+        val anchorCrossed = ReaderSelection(0, 0, 6)
+            .moveEndpoint(ReaderSelectionEndpoint.ANCHOR, 9)
+        val focusCrossed = ReaderSelection(0, 6, 12)
+            .moveEndpoint(ReaderSelectionEndpoint.FOCUS, 5)
+
+        assertEquals(9, anchorCrossed.anchor)
+        assertEquals(6, anchorCrossed.focus)
+        assertEquals(6, focusCrossed.anchor)
+        assertEquals(5, focusCrossed.focus)
+    }
+
+    @Test fun cjkDragKeepsExistingElementGranularity() {
+        val selection = ReaderSelection(0, 0, 2)
+
+        val contracted = ReaderSelectionPolicy.extend(selection, page, 15f, 10f)
+
+        assertEquals("甲乙", contracted.selectedText(page))
+        assertEquals(1, contracted.focus)
+    }
+
+    @Test fun apostrophesRemainOrdinaryReaderElementsDuringDrag() {
+        listOf("'", "’").forEach { apostrophe ->
+            val latin = textPage(listOf("d", "o", "n", apostrophe, "t"))
+            val selection = ReaderSelection(0, 0, 4)
+
+            val contracted = ReaderSelectionPolicy.extend(selection, latin, 35f, 10f)
+
+            assertEquals("don$apostrophe", contracted.selectedText(latin))
+            assertEquals(3, contracted.focus)
+        }
+    }
+
+    @Test fun dragKeepsAMultiCodeUnitElementWhole() {
+        val latin = textPage(listOf("c", "a", "f", "e\u0301", "x"))
+        val selection = ReaderSelection(0, 0, 6)
+
+        val contracted = ReaderSelectionPolicy.extend(selection, latin, 35f, 10f)
+
+        assertEquals(3, contracted.focus)
+        assertEquals("cafe\u0301", contracted.selectedText(latin))
+    }
+
     @Test
     fun longPressSnapsAcrossLetterSpacingGaps() {
         val spaced = page.copy(