From 1f869d556cd25fc6609d666d0e7b7d37100d173f Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 16:39:14 -0700 Subject: [PATCH] Fix line styles, move tool selection, stroke style storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line styles: - Store stroke style in DB (style column, migration v2->v3) - Dashed lines rendered with DashPathEffect on the paint - Arrow heads drawn dynamically from stored two-point line data - Line tool stores just two endpoints; style determines rendering - Removed buildLinePoints — arrow heads no longer baked into point data Move tool: - If there's an active selection, Move drags the entire selection - Otherwise, hit-tests a single stroke and drags it - Selection stays highlighted after move completes Line dropdown: - Removed conflicting onClick from Surface, combinedClickable now correctly handles both tap (select tool) and long-press (style menu) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../engpad/data/db/EngPadDatabase.kt | 12 +- .../metacircular/engpad/data/model/Stroke.kt | 19 ++- .../engpad/ui/editor/EditorScreen.kt | 6 +- .../engpad/ui/editor/EditorViewModel.kt | 3 +- .../engpad/ui/editor/PadCanvasView.kt | 161 +++++++++--------- 5 files changed, 113 insertions(+), 88 deletions(-) diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/db/EngPadDatabase.kt b/app/src/main/kotlin/net/metacircular/engpad/data/db/EngPadDatabase.kt index e4e65eb..8b62c85 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/db/EngPadDatabase.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/db/EngPadDatabase.kt @@ -12,7 +12,7 @@ import net.metacircular.engpad.data.model.Stroke @Database( entities = [Notebook::class, Page::class, Stroke::class], - version = 2, + version = 3, exportSchema = false, ) abstract class EngPadDatabase : RoomDatabase() { @@ -32,13 +32,21 @@ abstract class EngPadDatabase : RoomDatabase() { } } + private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE strokes ADD COLUMN style TEXT NOT NULL DEFAULT 'plain'" + ) + } + } + fun getInstance(context: Context): EngPadDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, EngPadDatabase::class.java, "engpad.db", - ).addMigrations(MIGRATION_1_2).build().also { instance = it } + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it } } } } diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/model/Stroke.kt b/app/src/main/kotlin/net/metacircular/engpad/data/model/Stroke.kt index 4b68465..170708b 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/model/Stroke.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/model/Stroke.kt @@ -18,6 +18,13 @@ import androidx.room.PrimaryKey ], indices = [Index(value = ["page_id"])], ) +/** + * Style constants stored in the `style` column. + * - "plain" — solid line (default, freehand strokes, boxes) + * - "dashed" — dashed line + * - "arrow" — solid line with arrow at end + * - "double_arrow" — solid line with arrows at both ends + */ data class Stroke( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "page_id") val pageId: Long, @@ -26,6 +33,7 @@ data class Stroke( @ColumnInfo(name = "point_data") val pointData: ByteArray, @ColumnInfo(name = "stroke_order") val strokeOrder: Int, @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "style", defaultValue = "plain") val style: String = STYLE_PLAIN, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -36,7 +44,8 @@ data class Stroke( color == other.color && pointData.contentEquals(other.pointData) && strokeOrder == other.strokeOrder && - createdAt == other.createdAt + createdAt == other.createdAt && + style == other.style } override fun hashCode(): Int { @@ -47,6 +56,14 @@ data class Stroke( result = 31 * result + pointData.contentHashCode() result = 31 * result + strokeOrder result = 31 * result + createdAt.hashCode() + result = 31 * result + style.hashCode() return result } + + companion object { + const val STYLE_PLAIN = "plain" + const val STYLE_DASHED = "dashed" + const val STYLE_ARROW = "arrow" + const val STYLE_DOUBLE_ARROW = "double_arrow" + } } diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt index ea5d2d1..ac01cc9 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt @@ -65,8 +65,8 @@ fun EditorScreen( // Wire up callbacks LaunchedEffect(Unit) { - canvasView.onStrokeCompleted = { penSize, color, points -> - viewModel.onStrokeCompleted(penSize, color, points) + canvasView.onStrokeCompleted = { penSize, color, points, style -> + viewModel.onStrokeCompleted(penSize, color, points, style) } canvasView.onZoomPanChanged = { zoom, panX, panY -> viewModel.onZoomPanChanged(zoom, panX, panY) @@ -92,7 +92,7 @@ fun EditorScreen( viewModel.onStrokeAdded = { stroke -> canvasView.addCompletedStroke( stroke.id, stroke.penSize, stroke.color, - stroke.pointData.toFloatArray(), + stroke.pointData.toFloatArray(), stroke.style, ) } viewModel.onStrokeRemoved = { id -> diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt index 5666fd3..6998986 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt @@ -152,7 +152,7 @@ class EditorViewModel( _canvasState.value = _canvasState.value.copy(zoom = zoom, panX = panX, panY = panY) } - fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) { + fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray, style: String = Stroke.STYLE_PLAIN) { viewModelScope.launch { val pageId = _currentPageId.value val order = pageRepository.getNextStrokeOrder(pageId) @@ -163,6 +163,7 @@ class EditorViewModel( pointData = points.toBlob(), strokeOrder = order, createdAt = System.currentTimeMillis(), + style = style, ) undoManager.perform( AddStrokeAction( diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt index affa1cc..a87655f 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt @@ -84,7 +84,7 @@ class PadCanvasView(context: Context) : View(context) { private val inverseMatrix = Matrix() // --- Callbacks --- - var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray) -> Unit)? = null + var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray, style: String) -> Unit)? = null var onZoomPanChanged: ((zoom: Float, panX: Float, panY: Float) -> Unit)? = null var onStrokeErased: ((strokeId: Long) -> Unit)? = null var onSelectionComplete: ((selectedIds: Set) -> Unit)? = null @@ -93,7 +93,8 @@ class PadCanvasView(context: Context) : View(context) { var onStrokeMoved: ((strokeId: Long, deltaX: Float, deltaY: Float) -> Unit)? = null // --- Move tool state --- - private var movingStrokeId: Long? = null + private var isMoving = false + private var movingIds = mutableSetOf() private var moveLastX = 0f private var moveLastY = 0f private var moveOriginX = 0f @@ -199,16 +200,16 @@ class PadCanvasView(context: Context) : View(context) { for (stroke in strokes) { val points = stroke.pointData.toFloatArray() val path = buildPathFromPoints(points) - val paint = buildPaint(stroke.penSize, stroke.color) - completedStrokes.add(StrokeRender(path, paint, stroke.id)) + val paint = buildPaint(stroke.penSize, stroke.color, stroke.style) + completedStrokes.add(StrokeRender(path, paint, stroke.id, stroke.style, points)) } invalidate() } - fun addCompletedStroke(id: Long, penSize: Float, color: Int, points: FloatArray) { + fun addCompletedStroke(id: Long, penSize: Float, color: Int, points: FloatArray, style: String = Stroke.STYLE_PLAIN) { val path = buildPathFromPoints(points) - val paint = buildPaint(penSize, color) - completedStrokes.add(StrokeRender(path, paint, id)) + val paint = buildPaint(penSize, color, style) + completedStrokes.add(StrokeRender(path, paint, id, style, points)) invalidate() } @@ -245,6 +246,19 @@ class PadCanvasView(context: Context) : View(context) { // Completed strokes for (sr in completedStrokes) { drawPath(sr.path, sr.paint) + // Draw arrow heads for arrow-style strokes + val pts = sr.points + if (pts != null && pts.size >= 4) { + val x1 = pts[0] + val y1 = pts[1] + val x2 = pts[2] + val y2 = pts[3] + if (sr.style == Stroke.STYLE_ARROW || sr.style == Stroke.STYLE_DOUBLE_ARROW) { + drawArrowHeads(this, x1, y1, x2, y2, + if (sr.style == Stroke.STYLE_ARROW) LineStyle.ARROW else LineStyle.DOUBLE_ARROW, + sr.paint.strokeWidth) + } + } } // In-progress stroke @@ -465,7 +479,7 @@ class PadCanvasView(context: Context) : View(context) { isSnappedToLine = false if (currentPoints.size >= 4) { val points = currentPoints.toFloatArray() - onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points) + onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points, Stroke.STYLE_PLAIN) } currentPath = null currentPaint = null @@ -537,7 +551,7 @@ class PadCanvasView(context: Context) : View(context) { left, bottom, left, top, // close the rectangle ) - onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points) + onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points, Stroke.STYLE_PLAIN) invalidate() return true } @@ -551,42 +565,59 @@ class PadCanvasView(context: Context) : View(context) { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { val pt = screenToCanonical(event.x, event.y) - val hitId = hitTestStroke(pt[0], pt[1]) - if (hitId != null) { - movingStrokeId = hitId - moveLastX = pt[0] - moveLastY = pt[1] - moveOriginX = pt[0] - moveOriginY = pt[1] - // Highlight the stroke being moved - selectedStrokeIds.clear() - selectedStrokeIds.add(hitId) - invalidate() + moveLastX = pt[0] + moveLastY = pt[1] + moveOriginX = pt[0] + moveOriginY = pt[1] + + if (selectedStrokeIds.isNotEmpty()) { + // Move the existing selection + movingIds.clear() + movingIds.addAll(selectedStrokeIds) + isMoving = true + } else { + // Hit-test a single stroke to grab + val hitId = hitTestStroke(pt[0], pt[1]) + if (hitId != null) { + movingIds.clear() + movingIds.add(hitId) + selectedStrokeIds.add(hitId) + isMoving = true + invalidate() + } } return true } MotionEvent.ACTION_MOVE -> { - val id = movingStrokeId ?: return true + if (!isMoving) return true val pt = screenToCanonical(event.x, event.y) val dx = pt[0] - moveLastX val dy = pt[1] - moveLastY - // Offset the path visually - completedStrokes.find { it.id == id }?.path?.offset(dx, dy) + for (sr in completedStrokes) { + if (sr.id in movingIds) { + sr.path.offset(dx, dy) + } + } moveLastX = pt[0] moveLastY = pt[1] invalidate() return true } MotionEvent.ACTION_UP -> { - val id = movingStrokeId ?: return true + if (!isMoving) return true val pt = screenToCanonical(event.x, event.y) val totalDx = pt[0] - moveOriginX val totalDy = pt[1] - moveOriginY - movingStrokeId = null - selectedStrokeIds.clear() + isMoving = false + val ids = movingIds.toSet() if (Math.abs(totalDx) > 1f || Math.abs(totalDy) > 1f) { - onStrokeMoved?.invoke(id, totalDx, totalDy) + if (ids.size == 1) { + onStrokeMoved?.invoke(ids.first(), totalDx, totalDy) + } else { + onSelectionMoved?.invoke(ids, totalDx, totalDy) + } } + // Keep selection highlighted after move invalidate() return true } @@ -617,12 +648,15 @@ class PadCanvasView(context: Context) : View(context) { } MotionEvent.ACTION_UP -> { isDrawingLine = false - // Build the line stroke as points - val points = buildLinePoints( - lineStartX, lineStartY, lineEndX, lineEndY, - canvasState.lineStyle, - ) - onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points) + val lineStyle = canvasState.lineStyle + val points = floatArrayOf(lineStartX, lineStartY, lineEndX, lineEndY) + val strokeStyle = when (lineStyle) { + LineStyle.PLAIN -> Stroke.STYLE_PLAIN + LineStyle.ARROW -> Stroke.STYLE_ARROW + LineStyle.DOUBLE_ARROW -> Stroke.STYLE_DOUBLE_ARROW + LineStyle.DASHED -> Stroke.STYLE_DASHED + } + onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points, strokeStyle) invalidate() return true } @@ -630,50 +664,6 @@ class PadCanvasView(context: Context) : View(context) { return true } - /** - * Build points for a line stroke. For plain/dashed, just two endpoints. - * Arrow heads are encoded as extra path segments. - */ - private fun buildLinePoints( - x1: Float, y1: Float, x2: Float, y2: Float, style: LineStyle, - ): FloatArray { - val points = mutableListOf() - points.add(x1); points.add(y1) - points.add(x2); points.add(y2) - - val arrowLen = 40f // Arrow head length in canonical points - val arrowAngle = Math.toRadians(25.0) - val dx = (x2 - x1).toDouble() - val dy = (y2 - y1).toDouble() - val angle = Math.atan2(dy, dx) - - if (style == LineStyle.ARROW || style == LineStyle.DOUBLE_ARROW) { - // Arrow at end (x2, y2) - val ax1 = x2 - (arrowLen * Math.cos(angle - arrowAngle)).toFloat() - val ay1 = y2 - (arrowLen * Math.sin(angle - arrowAngle)).toFloat() - val ax2 = x2 - (arrowLen * Math.cos(angle + arrowAngle)).toFloat() - val ay2 = y2 - (arrowLen * Math.sin(angle + arrowAngle)).toFloat() - // Draw as: tip -> left wing, then move back to tip -> right wing - points.add(ax1); points.add(ay1) - points.add(x2); points.add(y2) - points.add(ax2); points.add(ay2) - } - if (style == LineStyle.DOUBLE_ARROW) { - // Arrow at start (x1, y1) - val revAngle = angle + Math.PI - val bx1 = x1 - (arrowLen * Math.cos(revAngle - arrowAngle)).toFloat() - val by1 = y1 - (arrowLen * Math.sin(revAngle - arrowAngle)).toFloat() - val bx2 = x1 - (arrowLen * Math.cos(revAngle + arrowAngle)).toFloat() - val by2 = y1 - (arrowLen * Math.sin(revAngle + arrowAngle)).toFloat() - points.add(x1); points.add(y1) - points.add(bx1); points.add(by1) - points.add(x1); points.add(y1) - points.add(bx2); points.add(by2) - } - - return points.toFloatArray() - } - private fun drawArrowHeads( canvas: Canvas, x1: Float, y1: Float, x2: Float, y2: Float, style: LineStyle, strokeWidth: Float, @@ -996,18 +986,27 @@ class PadCanvasView(context: Context) : View(context) { return path } - private fun buildPaint(penSize: Float, color: Int): Paint { + private fun buildPaint(penSize: Float, color: Int, style: String = Stroke.STYLE_PLAIN): Paint { return Paint().apply { this.color = color strokeWidth = penSize - style = Paint.Style.STROKE + this.style = Paint.Style.STROKE strokeCap = Paint.Cap.ROUND strokeJoin = Paint.Join.ROUND - isAntiAlias = false // Crisp lines on e-ink — no edge fading + isAntiAlias = false + if (style == Stroke.STYLE_DASHED) { + pathEffect = android.graphics.DashPathEffect(floatArrayOf(30f, 20f), 0f) + } } } - private data class StrokeRender(val path: Path, val paint: Paint, val id: Long) + private data class StrokeRender( + val path: Path, + val paint: Paint, + val id: Long, + val style: String = Stroke.STYLE_PLAIN, + val points: FloatArray? = null, // Needed for arrow rendering + ) companion object { /** Eraser hit radius in canonical points (~3.5mm at 300 DPI). */