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