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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
|||||||
Reference in New Issue
Block a user