Fix grid evenness, line snap, and viewport clamping
Grid: - Draw grid in screen space with pixel-snapped positions, fixing the uneven rectangles caused by sub-pixel positioning in canonical space - 1px screen-space lines for uniform weight at any zoom level Line snap: - Track max distance from origin (not per-move), increase threshold to 60pt (~5mm) to handle EMR stylus hand tremor - Snap timer stays active until pen moves significantly from start Viewport: - Clamp pan so page always covers the full viewport - Background changed to white (no dark gray borders) - Page centers when smaller than viewport (e.g., zoomed out landscape) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
// --- Line snap ---
|
// --- Line snap ---
|
||||||
private var strokeOriginX = 0f
|
private var strokeOriginX = 0f
|
||||||
private var strokeOriginY = 0f
|
private var strokeOriginY = 0f
|
||||||
|
private var maxDistFromOrigin = 0f
|
||||||
private var isSnappedToLine = false
|
private var isSnappedToLine = false
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private val snapRunnable = Runnable { snapToLine() }
|
private val snapRunnable = Runnable { snapToLine() }
|
||||||
@@ -111,12 +112,12 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Grid paint: crisp, uniform lines ---
|
// --- Grid paint: crisp, uniform 1px lines in screen space ---
|
||||||
private val gridPaint = Paint().apply {
|
private val gridPaint = Paint().apply {
|
||||||
color = Color.rgb(180, 180, 180) // Medium gray, visible on white
|
color = Color.rgb(170, 170, 170) // Medium gray, visible on white e-ink
|
||||||
strokeWidth = 2f // 2pt at 300 DPI = thin but visible
|
strokeWidth = 1f // 1px in screen space — always crisp
|
||||||
style = Paint.Style.STROKE
|
style = Paint.Style.STROKE
|
||||||
isAntiAlias = false // Crisp pixel-aligned lines on e-ink
|
isAntiAlias = false // Pixel-aligned for e-ink
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Selection paint ---
|
// --- Selection paint ---
|
||||||
@@ -148,7 +149,7 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.DKGRAY)
|
setBackgroundColor(Color.WHITE)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Public API ---
|
// --- Public API ---
|
||||||
@@ -185,19 +186,23 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
|
|
||||||
override fun onDraw(canvas: Canvas) {
|
override fun onDraw(canvas: Canvas) {
|
||||||
super.onDraw(canvas)
|
super.onDraw(canvas)
|
||||||
|
|
||||||
|
// Draw page background in canonical space
|
||||||
canvas.withMatrix(viewMatrix) {
|
canvas.withMatrix(viewMatrix) {
|
||||||
// Page background
|
|
||||||
drawRect(
|
drawRect(
|
||||||
0f, 0f,
|
0f, 0f,
|
||||||
canvasState.pageSize.widthPt.toFloat(),
|
canvasState.pageSize.widthPt.toFloat(),
|
||||||
canvasState.pageSize.heightPt.toFloat(),
|
canvasState.pageSize.heightPt.toFloat(),
|
||||||
pagePaint,
|
pagePaint,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Grid
|
// Draw grid in screen space with pixel-snapped positions
|
||||||
drawGrid(this)
|
drawGridScreenSpace(canvas)
|
||||||
|
|
||||||
// Completed strokes — draw directly as paths (no backing bitmap)
|
// Draw everything else in canonical space
|
||||||
|
canvas.withMatrix(viewMatrix) {
|
||||||
|
// Completed strokes
|
||||||
for (sr in completedStrokes) {
|
for (sr in completedStrokes) {
|
||||||
drawPath(sr.path, sr.paint)
|
drawPath(sr.path, sr.paint)
|
||||||
}
|
}
|
||||||
@@ -241,23 +246,54 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun drawGrid(canvas: Canvas) {
|
/**
|
||||||
|
* Draw grid in screen pixel space. This avoids sub-pixel positioning
|
||||||
|
* artifacts that cause uneven grid spacing when drawn in canonical space
|
||||||
|
* and scaled by the view matrix.
|
||||||
|
*/
|
||||||
|
private fun drawGridScreenSpace(canvas: Canvas) {
|
||||||
val pageW = canvasState.pageSize.widthPt.toFloat()
|
val pageW = canvasState.pageSize.widthPt.toFloat()
|
||||||
val pageH = canvasState.pageSize.heightPt.toFloat()
|
val pageH = canvasState.pageSize.heightPt.toFloat()
|
||||||
val spacing = CanvasState.GRID_SPACING_PT
|
val spacing = CanvasState.GRID_SPACING_PT
|
||||||
|
|
||||||
var x = 0f
|
// Map page corners to screen space
|
||||||
while (x <= pageW) {
|
val topLeft = canonicalToScreen(0f, 0f)
|
||||||
canvas.drawLine(x, 0f, x, pageH, gridPaint)
|
val bottomRight = canonicalToScreen(pageW, pageH)
|
||||||
x += spacing
|
|
||||||
|
// Compute screen-space grid spacing
|
||||||
|
val screenSpacingX = canonicalToScreen(spacing, 0f)[0] - topLeft[0]
|
||||||
|
val screenSpacingY = canonicalToScreen(0f, spacing)[1] - topLeft[1]
|
||||||
|
|
||||||
|
// Use 1px stroke in screen space for crisp lines regardless of zoom
|
||||||
|
gridPaint.strokeWidth = 1f
|
||||||
|
|
||||||
|
// Vertical lines
|
||||||
|
var col = 0
|
||||||
|
while (true) {
|
||||||
|
val screenX = (topLeft[0] + col * screenSpacingX)
|
||||||
|
if (screenX > bottomRight[0] + 0.5f) break
|
||||||
|
val snapped = Math.round(screenX).toFloat()
|
||||||
|
canvas.drawLine(snapped, topLeft[1], snapped, bottomRight[1], gridPaint)
|
||||||
|
col++
|
||||||
}
|
}
|
||||||
var y = 0f
|
|
||||||
while (y <= pageH) {
|
// Horizontal lines
|
||||||
canvas.drawLine(0f, y, pageW, y, gridPaint)
|
var row = 0
|
||||||
y += spacing
|
while (true) {
|
||||||
|
val screenY = (topLeft[1] + row * screenSpacingY)
|
||||||
|
if (screenY > bottomRight[1] + 0.5f) break
|
||||||
|
val snapped = Math.round(screenY).toFloat()
|
||||||
|
canvas.drawLine(topLeft[0], snapped, bottomRight[0], snapped, gridPaint)
|
||||||
|
row++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun canonicalToScreen(cx: Float, cy: Float): FloatArray {
|
||||||
|
val pts = floatArrayOf(cx, cy)
|
||||||
|
viewMatrix.mapPoints(pts)
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
|
||||||
// --- Input handling ---
|
// --- Input handling ---
|
||||||
|
|
||||||
@Suppress("ClickableViewAccessibility")
|
@Suppress("ClickableViewAccessibility")
|
||||||
@@ -290,6 +326,7 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
val pt = screenToCanonical(event.x, event.y)
|
val pt = screenToCanonical(event.x, event.y)
|
||||||
strokeOriginX = pt[0]
|
strokeOriginX = pt[0]
|
||||||
strokeOriginY = pt[1]
|
strokeOriginY = pt[1]
|
||||||
|
maxDistFromOrigin = 0f
|
||||||
isSnappedToLine = false
|
isSnappedToLine = false
|
||||||
currentPoints.clear()
|
currentPoints.clear()
|
||||||
currentPoints.add(pt[0])
|
currentPoints.add(pt[0])
|
||||||
@@ -303,13 +340,14 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
}
|
}
|
||||||
MotionEvent.ACTION_MOVE -> {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
val path = currentPath ?: return true
|
val path = currentPath ?: return true
|
||||||
// Cancel snap timer on significant movement
|
// Track max distance from origin; cancel snap if pen moved far
|
||||||
if (!isSnappedToLine) {
|
if (!isSnappedToLine) {
|
||||||
val pt = screenToCanonical(event.x, event.y)
|
val pt = screenToCanonical(event.x, event.y)
|
||||||
val dx = pt[0] - strokeOriginX
|
val dx = pt[0] - strokeOriginX
|
||||||
val dy = pt[1] - strokeOriginY
|
val dy = pt[1] - strokeOriginY
|
||||||
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
|
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
|
||||||
if (dist > LINE_SNAP_MOVE_THRESHOLD) {
|
if (dist > maxDistFromOrigin) maxDistFromOrigin = dist
|
||||||
|
if (maxDistFromOrigin > LINE_SNAP_MOVE_THRESHOLD) {
|
||||||
handler.removeCallbacks(snapRunnable)
|
handler.removeCallbacks(snapRunnable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,10 +642,32 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
private fun rebuildViewMatrix() {
|
private fun rebuildViewMatrix() {
|
||||||
viewMatrix.reset()
|
viewMatrix.reset()
|
||||||
val pageW = canvasState.pageSize.widthPt.toFloat()
|
val pageW = canvasState.pageSize.widthPt.toFloat()
|
||||||
|
val pageH = canvasState.pageSize.heightPt.toFloat()
|
||||||
val viewW = width.toFloat().coerceAtLeast(1f)
|
val viewW = width.toFloat().coerceAtLeast(1f)
|
||||||
|
val viewH = height.toFloat().coerceAtLeast(1f)
|
||||||
|
|
||||||
val fitScale = viewW / pageW
|
val fitScale = viewW / pageW
|
||||||
viewMatrix.setScale(fitScale * zoom, fitScale * zoom)
|
val totalScale = fitScale * zoom
|
||||||
|
|
||||||
|
// Clamp pan so page edges stay at or beyond viewport edges
|
||||||
|
val scaledPageW = pageW * totalScale
|
||||||
|
val scaledPageH = pageH * totalScale
|
||||||
|
|
||||||
|
// X: page must cover full viewport width
|
||||||
|
panX = if (scaledPageW <= viewW) {
|
||||||
|
(viewW - scaledPageW) / 2f // Center if page is narrower than view
|
||||||
|
} else {
|
||||||
|
panX.coerceIn(viewW - scaledPageW, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y: page must cover full viewport height (or start at top if taller)
|
||||||
|
panY = if (scaledPageH <= viewH) {
|
||||||
|
(viewH - scaledPageH) / 2f // Center if page is shorter than view
|
||||||
|
} else {
|
||||||
|
panY.coerceIn(viewH - scaledPageH, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewMatrix.setScale(totalScale, totalScale)
|
||||||
viewMatrix.postTranslate(panX, panY)
|
viewMatrix.postTranslate(panX, panY)
|
||||||
|
|
||||||
viewMatrix.invert(inverseMatrix)
|
viewMatrix.invert(inverseMatrix)
|
||||||
@@ -653,7 +713,7 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
/** Hold pen still for this long to snap to straight line. */
|
/** Hold pen still for this long to snap to straight line. */
|
||||||
private const val LINE_SNAP_DELAY_MS = 1500L
|
private const val LINE_SNAP_DELAY_MS = 1500L
|
||||||
|
|
||||||
/** Movement threshold (canonical pts) below which snap timer stays active. */
|
/** Max distance from origin (canonical pts) before snap is canceled (~5mm). */
|
||||||
private const val LINE_SNAP_MOVE_THRESHOLD = 30f
|
private const val LINE_SNAP_MOVE_THRESHOLD = 60f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user