From 81aeb8e894c578f70369ec79c5b4a1fde08c052f Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 17 Jul 2026 16:01:44 +0800 Subject: [PATCH] fix(date-recorder): split handwriting strokes on finger lift endStroke() was a no-op, so addStrokePoint() kept appending every new pointer-down to the previous stroke (its only segmentation cue was points.isEmpty()). A second drag after lift got joined into the first, and toPath() drew them as a single polyline. Add isFinished flag to HandStroke and implement real endStroke() that marks the current stroke finished. withAddedPoint() now starts a new segment when the previous stroke is null, empty, or finished. --- .../plus/rua/project/PhotoEditorState.kt | 32 ++++- .../plus/rua/project/PhotoEditorViewModel.kt | 14 +-- .../kotlin/plus/rua/project/HandStrokeTest.kt | 109 ++++++++++++++++++ 3 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 core/src/test/kotlin/plus/rua/project/HandStrokeTest.kt diff --git a/core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt b/core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt index 162693f..682331e 100644 --- a/core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt +++ b/core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt @@ -14,11 +14,14 @@ import androidx.compose.ui.graphics.drawscope.Stroke * @param color 笔触颜色 * @param widthPx 笔触线宽(像素) * @param points 采样点序列(基于图片显示坐标系) + * @param isFinished 该笔触是否已结束(手指已抬起)。下一条笔触必须另起新段, + * 不能再追加到 [points] 上;否则两次手写会被 [toPath] 用一条折线连在一起。 */ data class HandStroke( val color: Color, val widthPx: Float, - val points: List = emptyList() + val points: List = emptyList(), + val isFinished: Boolean = false ) /** @@ -80,3 +83,30 @@ fun HandStroke.toPath(): Path { } return path } + +/** + * 在当前状态下追加一个手写采样点,返回新状态。 + * + * 若上一条笔触不存在、为空、或已结束([HandStroke.isFinished]),则另起新段; + * 否则把点追加到上一条笔触的末尾。 + */ +fun PhotoEditorState.withAddedPoint(offset: Offset): PhotoEditorState { + val last = strokes.lastOrNull() + val newStrokes = if (last == null || last.points.isEmpty() || last.isFinished) { + strokes + HandStroke(strokeColor, strokeWidthPx, listOf(offset)) + } else { + strokes.dropLast(1) + last.copy(points = last.points + offset) + } + return copy(strokes = newStrokes) +} + +/** + * 结束当前正在绘制的笔触,返回新状态。若没有进行中的笔触则原样返回。 + */ +fun PhotoEditorState.withEndedStroke(): PhotoEditorState { + val last = strokes.lastOrNull() ?: return this + if (!last.isFinished && last.points.isNotEmpty()) { + return copy(strokes = strokes.dropLast(1) + last.copy(isFinished = true)) + } + return this +} diff --git a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt index b70ac28..94f9f03 100644 --- a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt @@ -105,22 +105,12 @@ class PhotoEditorViewModel( /** 追加一个手写笔触采样点。 */ fun addStrokePoint(offset: Offset) { - update { - val current = it.strokeColor to it.strokeWidthPx - val newStrokes = if (it.strokes.isEmpty() || it.strokes.last().points.isEmpty()) { - it.strokes + HandStroke(current.first, current.second, listOf(offset)) - } else { - val last = it.strokes.last() - it.strokes.dropLast(1) + last.copy(points = last.points + offset) - } - it.copy(strokes = newStrokes) - } + update { it.withAddedPoint(offset) } } /** 结束当前笔触(抬起手指)。 */ fun endStroke() { - // 当前实现笔触在 addStrokePoint 中即时累积,无需额外处理; - // 保留方法以便未来区分笔触段(如双击结束)。 + update { it.withEndedStroke() } } /** 撤销最近一条笔触。 */ diff --git a/core/src/test/kotlin/plus/rua/project/HandStrokeTest.kt b/core/src/test/kotlin/plus/rua/project/HandStrokeTest.kt new file mode 100644 index 0000000..a51e752 --- /dev/null +++ b/core/src/test/kotlin/plus/rua/project/HandStrokeTest.kt @@ -0,0 +1,109 @@ +package plus.rua.project + +import androidx.compose.ui.geometry.Offset +import sun.misc.Unsafe +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * 手写笔触分段逻辑单元测试。 + * + * 覆盖 [PhotoEditorState.withAddedPoint] / [PhotoEditorState.withEndedStroke]: + * 抬手后再次落笔必须另起新段,不能与上一条笔触连成一条折线。 + */ +class HandStrokeTest { + + private fun emptyState() = PhotoEditorState( + sourceBitmap = UninitializedBitmap, + sourceAbsolutePath = "/tmp/fake.jpg" + ) + + @Test + fun twoStrokes_afterLiftAreSeparate() { + // 画第一笔 A→B + var s = emptyState() + .withAddedPoint(Offset(0f, 0f)) + .withAddedPoint(Offset(10f, 0f)) + // 抬手 + s = s.withEndedStroke() + // 在别处落笔 C→D + s = s.withAddedPoint(Offset(100f, 100f)) + .withAddedPoint(Offset(110f, 100f)) + + // 期望:两条独立笔触,而不是 [A,B,C,D] 一条 + assertEquals(2, s.strokes.size, "两笔应分隔为两段") + assertEquals(listOf(Offset(0f, 0f), Offset(10f, 0f)), s.strokes[0].points) + assertEquals(listOf(Offset(100f, 100f), Offset(110f, 100f)), s.strokes[1].points) + } + + @Test + fun points_accumulateWithinSameStroke() { + val s = emptyState() + .withAddedPoint(Offset(1f, 1f)) + .withAddedPoint(Offset(2f, 2f)) + .withAddedPoint(Offset(3f, 3f)) + + assertEquals(1, s.strokes.size) + assertEquals( + listOf(Offset(1f, 1f), Offset(2f, 2f), Offset(3f, 3f)), + s.strokes[0].points + ) + } + + @Test + fun endStroke_marksCurrentStrokeFinished() { + val s = emptyState() + .withAddedPoint(Offset(0f, 0f)) + .withEndedStroke() + + assertEquals(1, s.strokes.size) + assertTrue(s.strokes[0].isFinished, "抬手后笔触应标记为已结束") + } + + @Test + fun endStroke_withNoStrokes_isNoop() { + val s = emptyState().withEndedStroke() + assertTrue(s.strokes.isEmpty()) + } + + @Test + fun endStroke_calledTwice_keepsStrokeFinished() { + val s = emptyState() + .withAddedPoint(Offset(0f, 0f)) + .withEndedStroke() + .withEndedStroke() + + assertEquals(1, s.strokes.size) + assertTrue(s.strokes[0].isFinished) + } + + @Test + fun addPoint_afterFinished_startsNewStroke() { + // 抬手之后再落点:上一条 isFinished=true,应另起新段 + val s = emptyState() + .withAddedPoint(Offset(0f, 0f)) + .withEndedStroke() + .withAddedPoint(Offset(50f, 50f)) + + assertEquals(2, s.strokes.size) + assertFalse(s.strokes[0].points.contains(Offset(50f, 50f))) + } + + private companion object { + /** + * 通过 Unsafe.allocateInstance 创建 Bitmap 桩,跳过 Android 框架的静态初始化。 + * 笔触分段逻辑不读 Bitmap 任何字段/方法,桩仅用于满足 PhotoEditorState 构造器。 + */ + @Suppress("DiscouragedPrivateApi", "DEPRECATION") + val UninitializedBitmap: android.graphics.Bitmap by lazy { + val unsafeField = Unsafe::class.java.getDeclaredField("theUnsafe").apply { isAccessible = true } + val unsafe = unsafeField.get(null) as Unsafe + @Suppress("UNCHECKED_CAST") + val bmp = unsafe.allocateInstance(android.graphics.Bitmap::class.java) + as android.graphics.Bitmap + bmp + } + } +}