Fix line styles, move tool selection, stroke style storage

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 16:39:14 -07:00
parent df08f8a5e5
commit 1f869d556c
5 changed files with 113 additions and 88 deletions

View File

@@ -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 }
}
}
}

View File

@@ -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"
}
}

View File

@@ -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 ->

View File

@@ -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(

View File

@@ -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<Long>) -> 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<Long>()
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<Float>()
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). */