Fix rendering quality and add line snap + box tool

Rendering fixes for e-ink displays:
- Remove backing bitmap (was 1/4 resolution, caused blurry strokes)
- Draw strokes directly as paths for pixel-perfect rendering
- Disable anti-aliasing on stroke and grid paint (no edge fading)
- Grid: darker color (rgb 180,180,180), 2pt stroke width for crispness

New features:
- Line snap: hold pen still for 1.5s to snap to straight line from origin
- Box tool: draw rectangles by dragging corner to corner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 15:02:18 -07:00
parent 39adcfaaa4
commit e8091ba081
3 changed files with 172 additions and 100 deletions

View File

@@ -7,6 +7,7 @@ enum class Tool {
PEN_MEDIUM, // 0.50mm = 5.91pt at 300 DPI
ERASER,
SELECT,
BOX, // Draw rectangles
}
data class CanvasState(
@@ -20,6 +21,7 @@ data class CanvasState(
get() = when (tool) {
Tool.PEN_FINE -> 4.49f
Tool.PEN_MEDIUM -> 5.91f
Tool.BOX -> 4.49f // Box uses fine pen width
else -> 0f
}

View File

@@ -1,18 +1,17 @@
package net.metacircular.engpad.ui.editor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withMatrix
import androidx.core.graphics.withScale
import net.metacircular.engpad.data.db.toFloatArray
import net.metacircular.engpad.data.model.Stroke
@@ -29,7 +28,6 @@ class PadCanvasView(context: Context) : View(context) {
var canvasState = CanvasState()
set(value) {
field = value
// Sync local zoom/pan from state (e.g. initial load)
zoom = value.zoom
panX = value.panX
panY = value.panY
@@ -39,15 +37,26 @@ class PadCanvasView(context: Context) : View(context) {
// --- Strokes ---
private val completedStrokes = mutableListOf<StrokeRender>()
private var backingBitmap: Bitmap? = null
private var backingCanvas: Canvas? = null
private var bitmapDirty = true
// --- In-progress stroke ---
private var currentPath: Path? = null
private var currentPaint: Paint? = null
private val currentPoints = mutableListOf<Float>()
// --- Line snap ---
private var strokeOriginX = 0f
private var strokeOriginY = 0f
private var isSnappedToLine = false
private val handler = Handler(Looper.getMainLooper())
private val snapRunnable = Runnable { snapToLine() }
// --- Box drawing ---
private var boxStartX = 0f
private var boxStartY = 0f
private var boxEndX = 0f
private var boxEndY = 0f
private var isDrawingBox = false
// --- Selection ---
private var selectionStartX = 0f
private var selectionStartY = 0f
@@ -70,7 +79,7 @@ class PadCanvasView(context: Context) : View(context) {
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> Unit)? = null
// --- Zoom/pan state (managed locally for responsiveness) ---
// --- Zoom/pan state ---
private var zoom = 1f
private var panX = 0f
private var panY = 0f
@@ -85,7 +94,6 @@ class PadCanvasView(context: Context) : View(context) {
val newZoom = (zoom * detector.scaleFactor)
.coerceIn(CanvasState.MIN_ZOOM, CanvasState.MAX_ZOOM)
if (newZoom != zoom) {
// Zoom around the focal point
val focusX = detector.focusX
val focusY = detector.focusY
panX += (focusX - panX) * (1 - newZoom / zoom)
@@ -103,21 +111,21 @@ class PadCanvasView(context: Context) : View(context) {
},
)
// --- Grid paint ---
// --- Grid paint: crisp, uniform lines ---
private val gridPaint = Paint().apply {
color = Color.LTGRAY
strokeWidth = 1f
color = Color.rgb(180, 180, 180) // Medium gray, visible on white
strokeWidth = 2f // 2pt at 300 DPI = thin but visible
style = Paint.Style.STROKE
isAntiAlias = false
isAntiAlias = false // Crisp pixel-aligned lines on e-ink
}
// --- Selection paint ---
private val selectionRectPaint = Paint().apply {
color = Color.BLUE
strokeWidth = 2f
strokeWidth = 4f
style = Paint.Style.STROKE
pathEffect = android.graphics.DashPathEffect(floatArrayOf(20f, 20f), 0f)
isAntiAlias = true
isAntiAlias = false
}
private val selectionHighlightPaint = Paint().apply {
@@ -125,15 +133,20 @@ class PadCanvasView(context: Context) : View(context) {
style = Paint.Style.FILL
}
// --- Reusable rect for draw operations ---
private val tempBounds = android.graphics.RectF()
// --- Page background ---
private val pagePaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.FILL
}
// --- Box preview paint ---
private val boxPreviewPaint = Paint().apply {
color = Color.BLACK
style = Paint.Style.STROKE
isAntiAlias = false
}
init {
setBackgroundColor(Color.DKGRAY)
}
@@ -148,7 +161,6 @@ class PadCanvasView(context: Context) : View(context) {
val paint = buildPaint(stroke.penSize, stroke.color)
completedStrokes.add(StrokeRender(path, paint, stroke.id))
}
bitmapDirty = true
invalidate()
}
@@ -156,13 +168,11 @@ class PadCanvasView(context: Context) : View(context) {
val path = buildPathFromPoints(points)
val paint = buildPaint(penSize, color)
completedStrokes.add(StrokeRender(path, paint, id))
bitmapDirty = true
invalidate()
}
fun removeStroke(id: Long) {
completedStrokes.removeAll { it.id == id }
bitmapDirty = true
invalidate()
}
@@ -171,13 +181,12 @@ class PadCanvasView(context: Context) : View(context) {
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
rebuildViewMatrix()
bitmapDirty = true
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.withMatrix(viewMatrix) {
// Draw page background
// Page background
drawRect(
0f, 0f,
canvasState.pageSize.widthPt.toFloat(),
@@ -185,27 +194,32 @@ class PadCanvasView(context: Context) : View(context) {
pagePaint,
)
// Draw grid
// Grid
drawGrid(this)
// Draw completed strokes from backing bitmap
ensureBacking()
backingBitmap?.let { bmp ->
// Scale backing bitmap back up (it's rendered at 1/4 resolution)
val scaleUp = 1f / BACKING_SCALE
withScale(scaleUp, scaleUp) {
drawBitmap(bmp, 0f, 0f, null)
}
// Completed strokes — draw directly as paths (no backing bitmap)
for (sr in completedStrokes) {
drawPath(sr.path, sr.paint)
}
// Draw in-progress stroke
// In-progress stroke
currentPath?.let { path ->
currentPaint?.let { paint ->
drawPath(path, paint)
}
}
// Draw selection highlights
// Box preview
if (isDrawingBox) {
boxPreviewPaint.strokeWidth = canvasState.penWidthPt
val left = minOf(boxStartX, boxEndX)
val top = minOf(boxStartY, boxEndY)
val right = maxOf(boxStartX, boxEndX)
val bottom = maxOf(boxStartY, boxEndY)
drawRect(left, top, right, bottom, boxPreviewPaint)
}
// Selection highlights
if (selectedStrokeIds.isNotEmpty()) {
for (sr in completedStrokes) {
if (sr.id in selectedStrokeIds) {
@@ -216,7 +230,7 @@ class PadCanvasView(context: Context) : View(context) {
}
}
// Draw selection rectangle
// Selection rectangle
if (isSelecting) {
val left = minOf(selectionStartX, selectionEndX)
val top = minOf(selectionStartY, selectionEndY)
@@ -244,34 +258,9 @@ class PadCanvasView(context: Context) : View(context) {
}
}
private fun ensureBacking() {
val pageW = canvasState.pageSize.widthPt
val pageH = canvasState.pageSize.heightPt
val bmpW = (pageW * BACKING_SCALE).toInt()
val bmpH = (pageH * BACKING_SCALE).toInt()
if (backingBitmap == null || backingBitmap!!.width != bmpW || backingBitmap!!.height != bmpH) {
backingBitmap?.recycle()
backingBitmap = createBitmap(bmpW, bmpH)
backingCanvas = Canvas(backingBitmap!!)
bitmapDirty = true
}
if (bitmapDirty) {
backingBitmap!!.eraseColor(Color.TRANSPARENT)
backingCanvas!!.withScale(BACKING_SCALE, BACKING_SCALE) {
for (sr in completedStrokes) {
drawPath(sr.path, sr.paint)
}
}
bitmapDirty = false
}
}
// --- Input handling ---
@Suppress("ClickableViewAccessibility") // Drawing view — clicks not applicable
@Suppress("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
return when (event.getToolType(0)) {
MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> handleStylusInput(event)
@@ -292,36 +281,72 @@ class PadCanvasView(context: Context) : View(context) {
if (canvasState.tool == Tool.SELECT) {
return handleSelectInput(event)
}
if (canvasState.tool == Tool.BOX) {
return handleBoxInput(event)
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val pt = screenToCanonical(event.x, event.y)
strokeOriginX = pt[0]
strokeOriginY = pt[1]
isSnappedToLine = false
currentPoints.clear()
currentPoints.add(pt[0])
currentPoints.add(pt[1])
currentPath = Path().apply { moveTo(pt[0], pt[1]) }
currentPaint = buildPaint(canvasState.penWidthPt, Color.BLACK)
// Start snap timer
handler.postDelayed(snapRunnable, LINE_SNAP_DELAY_MS)
invalidate()
return true
}
MotionEvent.ACTION_MOVE -> {
val path = currentPath ?: return true
// Process historical points for smoothness
for (i in 0 until event.historySize) {
val pt = screenToCanonical(event.getHistoricalX(i), event.getHistoricalY(i))
// Cancel snap timer on significant movement
if (!isSnappedToLine) {
val pt = screenToCanonical(event.x, event.y)
val dx = pt[0] - strokeOriginX
val dy = pt[1] - strokeOriginY
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
if (dist > LINE_SNAP_MOVE_THRESHOLD) {
handler.removeCallbacks(snapRunnable)
}
}
if (isSnappedToLine) {
// In snap mode: draw straight line from origin to current point
val pt = screenToCanonical(event.x, event.y)
path.reset()
path.moveTo(strokeOriginX, strokeOriginY)
path.lineTo(pt[0], pt[1])
currentPoints.clear()
currentPoints.add(strokeOriginX)
currentPoints.add(strokeOriginY)
currentPoints.add(pt[0])
currentPoints.add(pt[1])
} else {
// Normal freehand drawing
for (i in 0 until event.historySize) {
val pt = screenToCanonical(
event.getHistoricalX(i), event.getHistoricalY(i),
)
path.lineTo(pt[0], pt[1])
currentPoints.add(pt[0])
currentPoints.add(pt[1])
}
val pt = screenToCanonical(event.x, event.y)
path.lineTo(pt[0], pt[1])
currentPoints.add(pt[0])
currentPoints.add(pt[1])
}
val pt = screenToCanonical(event.x, event.y)
path.lineTo(pt[0], pt[1])
currentPoints.add(pt[0])
currentPoints.add(pt[1])
invalidate()
return true
}
MotionEvent.ACTION_UP -> {
if (currentPoints.size >= 4) { // At least 2 points (x,y pairs)
handler.removeCallbacks(snapRunnable)
isSnappedToLine = false
if (currentPoints.size >= 4) {
val points = currentPoints.toFloatArray()
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points)
}
@@ -336,6 +361,62 @@ class PadCanvasView(context: Context) : View(context) {
return true
}
private fun snapToLine() {
if (currentPath != null) {
isSnappedToLine = true
// Reset path to just origin — next MOVE will draw the straight line
currentPath?.reset()
currentPath?.moveTo(strokeOriginX, strokeOriginY)
currentPoints.clear()
currentPoints.add(strokeOriginX)
currentPoints.add(strokeOriginY)
invalidate()
}
}
// --- Box drawing ---
private fun handleBoxInput(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val pt = screenToCanonical(event.x, event.y)
boxStartX = pt[0]
boxStartY = pt[1]
boxEndX = pt[0]
boxEndY = pt[1]
isDrawingBox = true
invalidate()
return true
}
MotionEvent.ACTION_MOVE -> {
val pt = screenToCanonical(event.x, event.y)
boxEndX = pt[0]
boxEndY = pt[1]
invalidate()
return true
}
MotionEvent.ACTION_UP -> {
isDrawingBox = false
val left = minOf(boxStartX, boxEndX)
val top = minOf(boxStartY, boxEndY)
val right = maxOf(boxStartX, boxEndX)
val bottom = maxOf(boxStartY, boxEndY)
// Create a box as 4-point stroke (rectangle path)
val points = floatArrayOf(
left, top,
right, top,
right, bottom,
left, bottom,
left, top, // close the rectangle
)
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points)
invalidate()
return true
}
}
return true
}
private fun handleFingerInput(event: MotionEvent): Boolean {
scaleGestureDetector.onTouchEvent(event)
@@ -365,7 +446,6 @@ class PadCanvasView(context: Context) : View(context) {
onZoomPanChanged?.invoke(zoom, panX, panY)
}
MotionEvent.ACTION_POINTER_DOWN -> {
// When a second finger goes down, update last touch to avoid jump
val newIndex = event.actionIndex
lastTouchX = event.getX(newIndex)
lastTouchY = event.getY(newIndex)
@@ -375,7 +455,6 @@ class PadCanvasView(context: Context) : View(context) {
val upIndex = event.actionIndex
val upId = event.getPointerId(upIndex)
if (upId == activePointerId) {
// Switch to the other finger
val newIndex = if (upIndex == 0) 1 else 0
lastTouchX = event.getX(newIndex)
lastTouchY = event.getY(newIndex)
@@ -393,12 +472,10 @@ class PadCanvasView(context: Context) : View(context) {
MotionEvent.ACTION_DOWN -> {
val pt = screenToCanonical(event.x, event.y)
if (selectedStrokeIds.isNotEmpty() && isPointInSelectionBounds(pt[0], pt[1])) {
// Start dragging existing selection
isDraggingSelection = true
dragStartX = pt[0]
dragStartY = pt[1]
} else {
// Start new selection rectangle
clearSelection()
selectionStartX = pt[0]
selectionStartY = pt[1]
@@ -417,7 +494,6 @@ class PadCanvasView(context: Context) : View(context) {
offsetSelectedStrokes(dx, dy)
dragStartX = pt[0]
dragStartY = pt[1]
bitmapDirty = true
} else if (isSelecting) {
selectionEndX = pt[0]
selectionEndY = pt[1]
@@ -431,7 +507,6 @@ class PadCanvasView(context: Context) : View(context) {
val totalDx = pt[0] - selectionStartX
val totalDy = pt[1] - selectionStartY
isDraggingSelection = false
// Notify of move (ViewModel handles persistence)
if (selectedStrokeIds.isNotEmpty()) {
onSelectionMoved?.invoke(selectedStrokeIds, totalDx, totalDy)
}
@@ -458,9 +533,8 @@ class PadCanvasView(context: Context) : View(context) {
selectedStrokeIds.clear()
for (sr in completedStrokes) {
val bounds = android.graphics.RectF()
sr.path.computeBounds(bounds, true)
if (android.graphics.RectF.intersects(selRect, bounds)) {
sr.path.computeBounds(tempBounds, true)
if (android.graphics.RectF.intersects(selRect, tempBounds)) {
selectedStrokeIds.add(sr.id)
}
}
@@ -469,10 +543,9 @@ class PadCanvasView(context: Context) : View(context) {
private fun isPointInSelectionBounds(x: Float, y: Float): Boolean {
for (sr in completedStrokes) {
if (sr.id !in selectedStrokeIds) continue
val bounds = android.graphics.RectF()
sr.path.computeBounds(bounds, true)
bounds.inset(-20f, -20f)
if (bounds.contains(x, y)) return true
sr.path.computeBounds(tempBounds, true)
tempBounds.inset(-20f, -20f)
if (tempBounds.contains(x, y)) return true
}
return false
}
@@ -499,7 +572,6 @@ class PadCanvasView(context: Context) : View(context) {
private fun handleEraserInput(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
// Check historical points too for thorough erasing
for (i in 0 until event.historySize) {
eraseAtPoint(event.getHistoricalX(i), event.getHistoricalY(i))
}
@@ -516,23 +588,12 @@ class PadCanvasView(context: Context) : View(context) {
onStrokeErased?.invoke(hitId)
}
/**
* Hit test: find the first stroke within ERASER_RADIUS_PT of the given
* canonical point. Uses bounding box pre-filter then point distance check.
*/
private fun hitTestStroke(cx: Float, cy: Float): Long? {
val radius = ERASER_RADIUS_PT
for (sr in completedStrokes) {
// Bounding box pre-filter
val bounds = android.graphics.RectF()
sr.path.computeBounds(bounds, true)
bounds.inset(-radius, -radius)
if (!bounds.contains(cx, cy)) continue
// Point distance check — walk the path data
// We need to check against actual stroke points, which we reconstruct
// from the stored stroke data. For now, check path bounds hit.
// Since we passed the expanded bounds check, this is a hit.
sr.path.computeBounds(tempBounds, true)
tempBounds.inset(-radius, -radius)
if (!tempBounds.contains(cx, cy)) continue
return sr.id
}
return null
@@ -579,17 +640,20 @@ class PadCanvasView(context: Context) : View(context) {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
isAntiAlias = true
isAntiAlias = false // Crisp lines on e-ink — no edge fading
}
}
private data class StrokeRender(val path: Path, val paint: Paint, val id: Long)
companion object {
/** Backing bitmap is rendered at 1/4 canonical resolution to save memory. */
private const val BACKING_SCALE = 0.25f
/** Eraser hit radius in canonical points (~3.5mm at 300 DPI). */
private const val ERASER_RADIUS_PT = 42f
/** Hold pen still for this long to snap to straight line. */
private const val LINE_SNAP_DELAY_MS = 1500L
/** Movement threshold (canonical pts) below which snap timer stays active. */
private const val LINE_SNAP_MOVE_THRESHOLD = 30f
}
}

View File

@@ -54,6 +54,12 @@ fun EditorToolbar(
label = { Text("Select") },
modifier = Modifier.padding(end = 4.dp),
)
FilterChip(
selected = currentTool == Tool.BOX,
onClick = { onToolSelected(Tool.BOX) },
label = { Text("Box") },
modifier = Modifier.padding(end = 4.dp),
)
if (hasSelection) {
TextButton(onClick = onDeleteSelection) { Text("Del") }
TextButton(onClick = onCopySelection) { Text("Copy") }