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:
2026-03-24 15:08:58 -07:00
parent e8091ba081
commit 61aaa9ebde

View File

@@ -46,6 +46,7 @@ class PadCanvasView(context: Context) : View(context) {
// --- Line snap ---
private var strokeOriginX = 0f
private var strokeOriginY = 0f
private var maxDistFromOrigin = 0f
private var isSnappedToLine = false
private val handler = Handler(Looper.getMainLooper())
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 {
color = Color.rgb(180, 180, 180) // Medium gray, visible on white
strokeWidth = 2f // 2pt at 300 DPI = thin but visible
color = Color.rgb(170, 170, 170) // Medium gray, visible on white e-ink
strokeWidth = 1f // 1px in screen space — always crisp
style = Paint.Style.STROKE
isAntiAlias = false // Crisp pixel-aligned lines on e-ink
isAntiAlias = false // Pixel-aligned for e-ink
}
// --- Selection paint ---
@@ -148,7 +149,7 @@ class PadCanvasView(context: Context) : View(context) {
}
init {
setBackgroundColor(Color.DKGRAY)
setBackgroundColor(Color.WHITE)
}
// --- Public API ---
@@ -185,19 +186,23 @@ class PadCanvasView(context: Context) : View(context) {
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// Draw page background in canonical space
canvas.withMatrix(viewMatrix) {
// Page background
drawRect(
0f, 0f,
canvasState.pageSize.widthPt.toFloat(),
canvasState.pageSize.heightPt.toFloat(),
pagePaint,
)
}
// Grid
drawGrid(this)
// Draw grid in screen space with pixel-snapped positions
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) {
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 pageH = canvasState.pageSize.heightPt.toFloat()
val spacing = CanvasState.GRID_SPACING_PT
var x = 0f
while (x <= pageW) {
canvas.drawLine(x, 0f, x, pageH, gridPaint)
x += spacing
// Map page corners to screen space
val topLeft = canonicalToScreen(0f, 0f)
val bottomRight = canonicalToScreen(pageW, pageH)
// 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) {
canvas.drawLine(0f, y, pageW, y, gridPaint)
y += spacing
// Horizontal lines
var row = 0
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 ---
@Suppress("ClickableViewAccessibility")
@@ -290,6 +326,7 @@ class PadCanvasView(context: Context) : View(context) {
val pt = screenToCanonical(event.x, event.y)
strokeOriginX = pt[0]
strokeOriginY = pt[1]
maxDistFromOrigin = 0f
isSnappedToLine = false
currentPoints.clear()
currentPoints.add(pt[0])
@@ -303,13 +340,14 @@ class PadCanvasView(context: Context) : View(context) {
}
MotionEvent.ACTION_MOVE -> {
val path = currentPath ?: return true
// Cancel snap timer on significant movement
// Track max distance from origin; cancel snap if pen moved far
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) {
if (dist > maxDistFromOrigin) maxDistFromOrigin = dist
if (maxDistFromOrigin > LINE_SNAP_MOVE_THRESHOLD) {
handler.removeCallbacks(snapRunnable)
}
}
@@ -604,10 +642,32 @@ class PadCanvasView(context: Context) : View(context) {
private fun rebuildViewMatrix() {
viewMatrix.reset()
val pageW = canvasState.pageSize.widthPt.toFloat()
val pageH = canvasState.pageSize.heightPt.toFloat()
val viewW = width.toFloat().coerceAtLeast(1f)
val viewH = height.toFloat().coerceAtLeast(1f)
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.invert(inverseMatrix)
@@ -653,7 +713,7 @@ class PadCanvasView(context: Context) : View(context) {
/** 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
/** Max distance from origin (canonical pts) before snap is canceled (~5mm). */
private const val LINE_SNAP_MOVE_THRESHOLD = 60f
}
}