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

View File

@@ -1,18 +1,17 @@
package net.metacircular.engpad.ui.editor package net.metacircular.engpad.ui.editor
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Path import android.graphics.Path
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent import android.view.MotionEvent
import android.view.ScaleGestureDetector import android.view.ScaleGestureDetector
import android.view.View import android.view.View
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withMatrix import androidx.core.graphics.withMatrix
import androidx.core.graphics.withScale
import net.metacircular.engpad.data.db.toFloatArray import net.metacircular.engpad.data.db.toFloatArray
import net.metacircular.engpad.data.model.Stroke import net.metacircular.engpad.data.model.Stroke
@@ -29,7 +28,6 @@ class PadCanvasView(context: Context) : View(context) {
var canvasState = CanvasState() var canvasState = CanvasState()
set(value) { set(value) {
field = value field = value
// Sync local zoom/pan from state (e.g. initial load)
zoom = value.zoom zoom = value.zoom
panX = value.panX panX = value.panX
panY = value.panY panY = value.panY
@@ -39,15 +37,26 @@ class PadCanvasView(context: Context) : View(context) {
// --- Strokes --- // --- Strokes ---
private val completedStrokes = mutableListOf<StrokeRender>() private val completedStrokes = mutableListOf<StrokeRender>()
private var backingBitmap: Bitmap? = null
private var backingCanvas: Canvas? = null
private var bitmapDirty = true
// --- In-progress stroke --- // --- In-progress stroke ---
private var currentPath: Path? = null private var currentPath: Path? = null
private var currentPaint: Paint? = null private var currentPaint: Paint? = null
private val currentPoints = mutableListOf<Float>() 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 --- // --- Selection ---
private var selectionStartX = 0f private var selectionStartX = 0f
private var selectionStartY = 0f private var selectionStartY = 0f
@@ -70,7 +79,7 @@ class PadCanvasView(context: Context) : View(context) {
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> 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 zoom = 1f
private var panX = 0f private var panX = 0f
private var panY = 0f private var panY = 0f
@@ -85,7 +94,6 @@ class PadCanvasView(context: Context) : View(context) {
val newZoom = (zoom * detector.scaleFactor) val newZoom = (zoom * detector.scaleFactor)
.coerceIn(CanvasState.MIN_ZOOM, CanvasState.MAX_ZOOM) .coerceIn(CanvasState.MIN_ZOOM, CanvasState.MAX_ZOOM)
if (newZoom != zoom) { if (newZoom != zoom) {
// Zoom around the focal point
val focusX = detector.focusX val focusX = detector.focusX
val focusY = detector.focusY val focusY = detector.focusY
panX += (focusX - panX) * (1 - newZoom / zoom) 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 { private val gridPaint = Paint().apply {
color = Color.LTGRAY color = Color.rgb(180, 180, 180) // Medium gray, visible on white
strokeWidth = 1f strokeWidth = 2f // 2pt at 300 DPI = thin but visible
style = Paint.Style.STROKE style = Paint.Style.STROKE
isAntiAlias = false isAntiAlias = false // Crisp pixel-aligned lines on e-ink
} }
// --- Selection paint --- // --- Selection paint ---
private val selectionRectPaint = Paint().apply { private val selectionRectPaint = Paint().apply {
color = Color.BLUE color = Color.BLUE
strokeWidth = 2f strokeWidth = 4f
style = Paint.Style.STROKE style = Paint.Style.STROKE
pathEffect = android.graphics.DashPathEffect(floatArrayOf(20f, 20f), 0f) pathEffect = android.graphics.DashPathEffect(floatArrayOf(20f, 20f), 0f)
isAntiAlias = true isAntiAlias = false
} }
private val selectionHighlightPaint = Paint().apply { private val selectionHighlightPaint = Paint().apply {
@@ -125,15 +133,20 @@ class PadCanvasView(context: Context) : View(context) {
style = Paint.Style.FILL style = Paint.Style.FILL
} }
// --- Reusable rect for draw operations ---
private val tempBounds = android.graphics.RectF() private val tempBounds = android.graphics.RectF()
// --- Page background ---
private val pagePaint = Paint().apply { private val pagePaint = Paint().apply {
color = Color.WHITE color = Color.WHITE
style = Paint.Style.FILL style = Paint.Style.FILL
} }
// --- Box preview paint ---
private val boxPreviewPaint = Paint().apply {
color = Color.BLACK
style = Paint.Style.STROKE
isAntiAlias = false
}
init { init {
setBackgroundColor(Color.DKGRAY) setBackgroundColor(Color.DKGRAY)
} }
@@ -148,7 +161,6 @@ class PadCanvasView(context: Context) : View(context) {
val paint = buildPaint(stroke.penSize, stroke.color) val paint = buildPaint(stroke.penSize, stroke.color)
completedStrokes.add(StrokeRender(path, paint, stroke.id)) completedStrokes.add(StrokeRender(path, paint, stroke.id))
} }
bitmapDirty = true
invalidate() invalidate()
} }
@@ -156,13 +168,11 @@ class PadCanvasView(context: Context) : View(context) {
val path = buildPathFromPoints(points) val path = buildPathFromPoints(points)
val paint = buildPaint(penSize, color) val paint = buildPaint(penSize, color)
completedStrokes.add(StrokeRender(path, paint, id)) completedStrokes.add(StrokeRender(path, paint, id))
bitmapDirty = true
invalidate() invalidate()
} }
fun removeStroke(id: Long) { fun removeStroke(id: Long) {
completedStrokes.removeAll { it.id == id } completedStrokes.removeAll { it.id == id }
bitmapDirty = true
invalidate() invalidate()
} }
@@ -171,13 +181,12 @@ class PadCanvasView(context: Context) : View(context) {
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh) super.onSizeChanged(w, h, oldw, oldh)
rebuildViewMatrix() rebuildViewMatrix()
bitmapDirty = true
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
canvas.withMatrix(viewMatrix) { canvas.withMatrix(viewMatrix) {
// Draw page background // Page background
drawRect( drawRect(
0f, 0f, 0f, 0f,
canvasState.pageSize.widthPt.toFloat(), canvasState.pageSize.widthPt.toFloat(),
@@ -185,27 +194,32 @@ class PadCanvasView(context: Context) : View(context) {
pagePaint, pagePaint,
) )
// Draw grid // Grid
drawGrid(this) drawGrid(this)
// Draw completed strokes from backing bitmap // Completed strokes — draw directly as paths (no backing bitmap)
ensureBacking() for (sr in completedStrokes) {
backingBitmap?.let { bmp -> drawPath(sr.path, sr.paint)
// 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)
}
} }
// Draw in-progress stroke // In-progress stroke
currentPath?.let { path -> currentPath?.let { path ->
currentPaint?.let { paint -> currentPaint?.let { paint ->
drawPath(path, 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()) { if (selectedStrokeIds.isNotEmpty()) {
for (sr in completedStrokes) { for (sr in completedStrokes) {
if (sr.id in selectedStrokeIds) { if (sr.id in selectedStrokeIds) {
@@ -216,7 +230,7 @@ class PadCanvasView(context: Context) : View(context) {
} }
} }
// Draw selection rectangle // Selection rectangle
if (isSelecting) { if (isSelecting) {
val left = minOf(selectionStartX, selectionEndX) val left = minOf(selectionStartX, selectionEndX)
val top = minOf(selectionStartY, selectionEndY) 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 --- // --- Input handling ---
@Suppress("ClickableViewAccessibility") // Drawing view — clicks not applicable @Suppress("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean { override fun onTouchEvent(event: MotionEvent): Boolean {
return when (event.getToolType(0)) { return when (event.getToolType(0)) {
MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> handleStylusInput(event) MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> handleStylusInput(event)
@@ -292,23 +281,56 @@ class PadCanvasView(context: Context) : View(context) {
if (canvasState.tool == Tool.SELECT) { if (canvasState.tool == Tool.SELECT) {
return handleSelectInput(event) return handleSelectInput(event)
} }
if (canvasState.tool == Tool.BOX) {
return handleBoxInput(event)
}
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
val pt = screenToCanonical(event.x, event.y) val pt = screenToCanonical(event.x, event.y)
strokeOriginX = pt[0]
strokeOriginY = pt[1]
isSnappedToLine = false
currentPoints.clear() currentPoints.clear()
currentPoints.add(pt[0]) currentPoints.add(pt[0])
currentPoints.add(pt[1]) currentPoints.add(pt[1])
currentPath = Path().apply { moveTo(pt[0], pt[1]) } currentPath = Path().apply { moveTo(pt[0], pt[1]) }
currentPaint = buildPaint(canvasState.penWidthPt, Color.BLACK) currentPaint = buildPaint(canvasState.penWidthPt, Color.BLACK)
// Start snap timer
handler.postDelayed(snapRunnable, LINE_SNAP_DELAY_MS)
invalidate() invalidate()
return true return true
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
val path = currentPath ?: return true val path = currentPath ?: return true
// Process historical points for smoothness // 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) { for (i in 0 until event.historySize) {
val pt = screenToCanonical(event.getHistoricalX(i), event.getHistoricalY(i)) val pt = screenToCanonical(
event.getHistoricalX(i), event.getHistoricalY(i),
)
path.lineTo(pt[0], pt[1]) path.lineTo(pt[0], pt[1])
currentPoints.add(pt[0]) currentPoints.add(pt[0])
currentPoints.add(pt[1]) currentPoints.add(pt[1])
@@ -317,11 +339,14 @@ class PadCanvasView(context: Context) : View(context) {
path.lineTo(pt[0], pt[1]) path.lineTo(pt[0], pt[1])
currentPoints.add(pt[0]) currentPoints.add(pt[0])
currentPoints.add(pt[1]) currentPoints.add(pt[1])
}
invalidate() invalidate()
return true return true
} }
MotionEvent.ACTION_UP -> { 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() val points = currentPoints.toFloatArray()
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points) onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points)
} }
@@ -336,6 +361,62 @@ class PadCanvasView(context: Context) : View(context) {
return true 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 { private fun handleFingerInput(event: MotionEvent): Boolean {
scaleGestureDetector.onTouchEvent(event) scaleGestureDetector.onTouchEvent(event)
@@ -365,7 +446,6 @@ class PadCanvasView(context: Context) : View(context) {
onZoomPanChanged?.invoke(zoom, panX, panY) onZoomPanChanged?.invoke(zoom, panX, panY)
} }
MotionEvent.ACTION_POINTER_DOWN -> { MotionEvent.ACTION_POINTER_DOWN -> {
// When a second finger goes down, update last touch to avoid jump
val newIndex = event.actionIndex val newIndex = event.actionIndex
lastTouchX = event.getX(newIndex) lastTouchX = event.getX(newIndex)
lastTouchY = event.getY(newIndex) lastTouchY = event.getY(newIndex)
@@ -375,7 +455,6 @@ class PadCanvasView(context: Context) : View(context) {
val upIndex = event.actionIndex val upIndex = event.actionIndex
val upId = event.getPointerId(upIndex) val upId = event.getPointerId(upIndex)
if (upId == activePointerId) { if (upId == activePointerId) {
// Switch to the other finger
val newIndex = if (upIndex == 0) 1 else 0 val newIndex = if (upIndex == 0) 1 else 0
lastTouchX = event.getX(newIndex) lastTouchX = event.getX(newIndex)
lastTouchY = event.getY(newIndex) lastTouchY = event.getY(newIndex)
@@ -393,12 +472,10 @@ class PadCanvasView(context: Context) : View(context) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
val pt = screenToCanonical(event.x, event.y) val pt = screenToCanonical(event.x, event.y)
if (selectedStrokeIds.isNotEmpty() && isPointInSelectionBounds(pt[0], pt[1])) { if (selectedStrokeIds.isNotEmpty() && isPointInSelectionBounds(pt[0], pt[1])) {
// Start dragging existing selection
isDraggingSelection = true isDraggingSelection = true
dragStartX = pt[0] dragStartX = pt[0]
dragStartY = pt[1] dragStartY = pt[1]
} else { } else {
// Start new selection rectangle
clearSelection() clearSelection()
selectionStartX = pt[0] selectionStartX = pt[0]
selectionStartY = pt[1] selectionStartY = pt[1]
@@ -417,7 +494,6 @@ class PadCanvasView(context: Context) : View(context) {
offsetSelectedStrokes(dx, dy) offsetSelectedStrokes(dx, dy)
dragStartX = pt[0] dragStartX = pt[0]
dragStartY = pt[1] dragStartY = pt[1]
bitmapDirty = true
} else if (isSelecting) { } else if (isSelecting) {
selectionEndX = pt[0] selectionEndX = pt[0]
selectionEndY = pt[1] selectionEndY = pt[1]
@@ -431,7 +507,6 @@ class PadCanvasView(context: Context) : View(context) {
val totalDx = pt[0] - selectionStartX val totalDx = pt[0] - selectionStartX
val totalDy = pt[1] - selectionStartY val totalDy = pt[1] - selectionStartY
isDraggingSelection = false isDraggingSelection = false
// Notify of move (ViewModel handles persistence)
if (selectedStrokeIds.isNotEmpty()) { if (selectedStrokeIds.isNotEmpty()) {
onSelectionMoved?.invoke(selectedStrokeIds, totalDx, totalDy) onSelectionMoved?.invoke(selectedStrokeIds, totalDx, totalDy)
} }
@@ -458,9 +533,8 @@ class PadCanvasView(context: Context) : View(context) {
selectedStrokeIds.clear() selectedStrokeIds.clear()
for (sr in completedStrokes) { for (sr in completedStrokes) {
val bounds = android.graphics.RectF() sr.path.computeBounds(tempBounds, true)
sr.path.computeBounds(bounds, true) if (android.graphics.RectF.intersects(selRect, tempBounds)) {
if (android.graphics.RectF.intersects(selRect, bounds)) {
selectedStrokeIds.add(sr.id) selectedStrokeIds.add(sr.id)
} }
} }
@@ -469,10 +543,9 @@ class PadCanvasView(context: Context) : View(context) {
private fun isPointInSelectionBounds(x: Float, y: Float): Boolean { private fun isPointInSelectionBounds(x: Float, y: Float): Boolean {
for (sr in completedStrokes) { for (sr in completedStrokes) {
if (sr.id !in selectedStrokeIds) continue if (sr.id !in selectedStrokeIds) continue
val bounds = android.graphics.RectF() sr.path.computeBounds(tempBounds, true)
sr.path.computeBounds(bounds, true) tempBounds.inset(-20f, -20f)
bounds.inset(-20f, -20f) if (tempBounds.contains(x, y)) return true
if (bounds.contains(x, y)) return true
} }
return false return false
} }
@@ -499,7 +572,6 @@ class PadCanvasView(context: Context) : View(context) {
private fun handleEraserInput(event: MotionEvent): Boolean { private fun handleEraserInput(event: MotionEvent): Boolean {
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
// Check historical points too for thorough erasing
for (i in 0 until event.historySize) { for (i in 0 until event.historySize) {
eraseAtPoint(event.getHistoricalX(i), event.getHistoricalY(i)) eraseAtPoint(event.getHistoricalX(i), event.getHistoricalY(i))
} }
@@ -516,23 +588,12 @@ class PadCanvasView(context: Context) : View(context) {
onStrokeErased?.invoke(hitId) 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? { private fun hitTestStroke(cx: Float, cy: Float): Long? {
val radius = ERASER_RADIUS_PT val radius = ERASER_RADIUS_PT
for (sr in completedStrokes) { for (sr in completedStrokes) {
// Bounding box pre-filter sr.path.computeBounds(tempBounds, true)
val bounds = android.graphics.RectF() tempBounds.inset(-radius, -radius)
sr.path.computeBounds(bounds, true) if (!tempBounds.contains(cx, cy)) continue
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.
return sr.id return sr.id
} }
return null return null
@@ -579,17 +640,20 @@ class PadCanvasView(context: Context) : View(context) {
style = Paint.Style.STROKE style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.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) private data class StrokeRender(val path: Path, val paint: Paint, val id: Long)
companion object { 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). */ /** Eraser hit radius in canonical points (~3.5mm at 300 DPI). */
private const val ERASER_RADIUS_PT = 42f 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") }, label = { Text("Select") },
modifier = Modifier.padding(end = 4.dp), 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) { if (hasSelection) {
TextButton(onClick = onDeleteSelection) { Text("Del") } TextButton(onClick = onDeleteSelection) { Text("Del") }
TextButton(onClick = onCopySelection) { Text("Copy") } TextButton(onClick = onCopySelection) { Text("Copy") }