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