Fix UI polish: snap, line dropdown, move tool, eraser button, title case

1. Line long-press dropdown: removed conflicting onClick from Surface,
   combinedClickable now works correctly for long-press style menu
2. Move tool: tap a stroke and drag to reposition it, with undo support
   and visual highlight during drag
3. Snap fix: timer now re-arms continuously — pausing at ANY point
   during a stroke (not just near origin) triggers snap. Checks pen
   stillness at the moment the timer fires (~3mm threshold)
4. EMR eraser: now checks BUTTON_STYLUS_PRIMARY in buttonState
   (side button) in addition to TOOL_TYPE_ERASER
5. Notebook title: keyboard defaults to Title Case capitalization
6. Tool order in enum matches toolbar: pen, line, box, eraser, select, move

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 16:27:53 -07:00
parent 408ba57051
commit df08f8a5e5
6 changed files with 142 additions and 17 deletions

View File

@@ -5,10 +5,11 @@ import net.metacircular.engpad.data.model.PageSize
enum class Tool {
PEN_FINE, // 0.38mm = 4.49pt at 300 DPI
PEN_MEDIUM, // 0.50mm = 5.91pt at 300 DPI
ERASER,
SELECT,
BOX, // Draw rectangles
LINE, // Draw straight lines (with style variants)
BOX, // Draw rectangles
ERASER,
SELECT, // Rectangle select, then cut/copy/del/paste
MOVE, // Tap and drag individual strokes
}
enum class LineStyle {
@@ -32,6 +33,7 @@ data class CanvasState(
Tool.PEN_MEDIUM -> 5.91f
Tool.BOX -> 4.49f
Tool.LINE -> 4.49f
Tool.MOVE -> 0f
else -> 0f
}

View File

@@ -80,6 +80,9 @@ fun EditorScreen(
canvasView.onSelectionMoved = { ids, dx, dy ->
viewModel.moveSelection(dx, dy)
}
canvasView.onStrokeMoved = { id, dx, dy ->
viewModel.moveSingleStroke(id, dx, dy)
}
canvasView.onEdgeSwipe = { direction ->
when (direction) {
PadCanvasView.SwipeDirection.LEFT -> viewModel.navigateNext()

View File

@@ -266,6 +266,32 @@ class EditorViewModel(
}
}
fun moveSingleStroke(strokeId: Long, deltaX: Float, deltaY: Float) {
val stroke = _strokes.value.find { it.id == strokeId } ?: return
viewModelScope.launch {
undoManager.perform(
MoveStrokesAction(
strokes = listOf(stroke),
deltaX = deltaX,
deltaY = deltaY,
repository = pageRepository,
onExecute = { moved ->
_strokes.value = _strokes.value.map { s ->
moved.find { it.id == s.id } ?: s
}
// Don't call onStrokesChanged — the view already moved the path
},
onUndo = { restored ->
_strokes.value = _strokes.value.map { s ->
restored.find { it.id == s.id } ?: s
}
onStrokesChanged?.invoke()
},
)
)
}
}
fun moveSelection(deltaX: Float, deltaY: Float) {
val toMove = _strokes.value.filter { it.id in selectedIds }
if (toMove.isEmpty()) return

View File

@@ -46,8 +46,10 @@ class PadCanvasView(context: Context) : View(context) {
// --- Line snap ---
private var strokeOriginX = 0f
private var strokeOriginY = 0f
private var lastStylusX = 0f // Current pen position for snap check
private var lastStylusX = 0f
private var lastStylusY = 0f
private var snapCheckX = 0f // Position when snap timer was started
private var snapCheckY = 0f
private var isSnappedToLine = false
private val handler = Handler(Looper.getMainLooper())
private val snapRunnable = Runnable { trySnapToLine() }
@@ -88,6 +90,14 @@ class PadCanvasView(context: Context) : View(context) {
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> Unit)? = null
var onEdgeSwipe: ((SwipeDirection) -> Unit)? = null
var onStrokeMoved: ((strokeId: Long, deltaX: Float, deltaY: Float) -> Unit)? = null
// --- Move tool state ---
private var movingStrokeId: Long? = null
private var moveLastX = 0f
private var moveLastY = 0f
private var moveOriginX = 0f
private var moveOriginY = 0f
enum class SwipeDirection { LEFT, RIGHT }
@@ -343,8 +353,16 @@ class PadCanvasView(context: Context) : View(context) {
@Suppress("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
return when (event.getToolType(0)) {
MotionEvent.TOOL_TYPE_STYLUS -> handleStylusInput(event)
MotionEvent.TOOL_TYPE_ERASER -> handleEraserInput(event) // EMR pen eraser button
MotionEvent.TOOL_TYPE_STYLUS -> {
// Check for stylus button press (side button = eraser shortcut)
val buttonPressed = event.buttonState and MotionEvent.BUTTON_STYLUS_PRIMARY != 0
if (buttonPressed) {
handleEraserInput(event)
} else {
handleStylusInput(event)
}
}
MotionEvent.TOOL_TYPE_ERASER -> handleEraserInput(event)
MotionEvent.TOOL_TYPE_FINGER -> handleFingerInput(event)
else -> super.onTouchEvent(event)
}
@@ -368,6 +386,9 @@ class PadCanvasView(context: Context) : View(context) {
if (canvasState.tool == Tool.LINE) {
return handleLineInput(event)
}
if (canvasState.tool == Tool.MOVE) {
return handleMoveInput(event)
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
@@ -376,24 +397,40 @@ class PadCanvasView(context: Context) : View(context) {
strokeOriginY = pt[1]
lastStylusX = pt[0]
lastStylusY = pt[1]
snapCheckX = pt[0]
snapCheckY = 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 — will check distance when it fires
// Start snap timer
handler.removeCallbacks(snapRunnable)
handler.postDelayed(snapRunnable, LINE_SNAP_DELAY_MS)
invalidate()
return true
}
MotionEvent.ACTION_MOVE -> {
val path = currentPath ?: return true
// Track current position for snap check
val curPt = screenToCanonical(event.x, event.y)
lastStylusX = curPt[0]
lastStylusY = curPt[1]
// Re-arm snap timer: if pen moved significantly from last
// snap check point, restart the timer from current position
if (!isSnappedToLine) {
val dx = curPt[0] - snapCheckX
val dy = curPt[1] - snapCheckY
val moved = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
if (moved > LINE_SNAP_STILL_THRESHOLD) {
snapCheckX = curPt[0]
snapCheckY = curPt[1]
handler.removeCallbacks(snapRunnable)
handler.postDelayed(snapRunnable, LINE_SNAP_DELAY_MS)
}
}
if (isSnappedToLine) {
// In snap mode: draw straight line from origin to current point
val pt = screenToCanonical(event.x, event.y)
@@ -442,24 +479,27 @@ class PadCanvasView(context: Context) : View(context) {
}
/**
* Called by the snap timer. Checks if pen is still near origin —
* if so, activate line snap. If pen has moved far, do nothing.
* Called by the snap timer. Activates line snap if the pen hasn't moved
* much since the timer was last (re)started. The pen can be anywhere —
* not just near origin — pausing at any point triggers snap.
*/
private fun trySnapToLine() {
if (currentPath == null) return
val dx = lastStylusX - strokeOriginX
val dy = lastStylusY - strokeOriginY
val dx = lastStylusX - snapCheckX
val dy = lastStylusY - snapCheckY
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
if (dist <= LINE_SNAP_MOVE_THRESHOLD) {
if (dist <= LINE_SNAP_STILL_THRESHOLD) {
isSnappedToLine = true
currentPath?.reset()
currentPath?.moveTo(strokeOriginX, strokeOriginY)
currentPath?.lineTo(lastStylusX, lastStylusY)
currentPoints.clear()
currentPoints.add(strokeOriginX)
currentPoints.add(strokeOriginY)
currentPoints.add(lastStylusX)
currentPoints.add(lastStylusY)
invalidate()
}
// If pen has moved far, don't snap — just let freehand continue
}
// --- Box drawing ---
@@ -505,6 +545,55 @@ class PadCanvasView(context: Context) : View(context) {
return true
}
// --- Move tool ---
private fun handleMoveInput(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val pt = screenToCanonical(event.x, event.y)
val hitId = hitTestStroke(pt[0], pt[1])
if (hitId != null) {
movingStrokeId = hitId
moveLastX = pt[0]
moveLastY = pt[1]
moveOriginX = pt[0]
moveOriginY = pt[1]
// Highlight the stroke being moved
selectedStrokeIds.clear()
selectedStrokeIds.add(hitId)
invalidate()
}
return true
}
MotionEvent.ACTION_MOVE -> {
val id = movingStrokeId ?: return true
val pt = screenToCanonical(event.x, event.y)
val dx = pt[0] - moveLastX
val dy = pt[1] - moveLastY
// Offset the path visually
completedStrokes.find { it.id == id }?.path?.offset(dx, dy)
moveLastX = pt[0]
moveLastY = pt[1]
invalidate()
return true
}
MotionEvent.ACTION_UP -> {
val id = movingStrokeId ?: return true
val pt = screenToCanonical(event.x, event.y)
val totalDx = pt[0] - moveOriginX
val totalDy = pt[1] - moveOriginY
movingStrokeId = null
selectedStrokeIds.clear()
if (Math.abs(totalDx) > 1f || Math.abs(totalDy) > 1f) {
onStrokeMoved?.invoke(id, totalDx, totalDy)
}
invalidate()
return true
}
}
return true
}
// --- Line drawing ---
private fun handleLineInput(event: MotionEvent): Boolean {
@@ -927,8 +1016,8 @@ 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
/** Max distance from origin (canonical pts) before snap is canceled (~5mm). */
private const val LINE_SNAP_MOVE_THRESHOLD = 60f
/** Max movement (canonical pts) while pen is "still" for snap (~3mm). */
private const val LINE_SNAP_STILL_THRESHOLD = 36f
/** Fraction of screen width that counts as the edge zone for swipes. */
private const val EDGE_ZONE_FRACTION = 0.08f

View File

@@ -140,7 +140,6 @@ fun EditorToolbar(
LineStyle.DASHED -> "- -"
}
Surface(
onClick = { onToolSelected(Tool.LINE) },
shape = RoundedCornerShape(8.dp),
color = if (currentTool == Tool.LINE) Color.Black else Color.White,
contentColor = if (currentTool == Tool.LINE) Color.White else Color.Black,
@@ -184,6 +183,7 @@ fun EditorToolbar(
ToolButton("Box", currentTool == Tool.BOX, { onToolSelected(Tool.BOX) })
ToolButton("Eraser", currentTool == Tool.ERASER, { onToolSelected(Tool.ERASER) })
ToolButton("Select", currentTool == Tool.SELECT, { onToolSelected(Tool.SELECT) })
ToolButton("Move", currentTool == Tool.MOVE, { onToolSelected(Tool.MOVE) })
// Selection operations: cut / del / copy / paste
if (hasSelection || canPaste) {

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,6 +30,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import net.metacircular.engpad.data.db.EngPadDatabase
@@ -173,6 +175,9 @@ private fun CreateNotebookDialog(
onValueChange = { title = it },
label = { Text("Title") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
),
modifier = Modifier.fillMaxWidth(),
)
Text(