From 61aaa9ebded0709f8a4c1c94f928a01e9de62ac4 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 15:08:58 -0700 Subject: [PATCH] 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) --- .../engpad/ui/editor/PadCanvasView.kt | 106 ++++++++++++++---- 1 file changed, 83 insertions(+), 23 deletions(-) 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 3c60a2a..d80d638 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 @@ -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 } }