diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt index a4768f2..a04dfb7 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt @@ -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 } 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 b1fadbf..3c60a2a 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 @@ -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() - 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() + // --- 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) -> Unit)? = null var onSelectionMoved: ((selectedIds: Set, 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 } } diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt index 412b87f..45711d7 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt @@ -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") }