diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt index 861eede..de265e9 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt @@ -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 } diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt index dd91ec7..ea5d2d1 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt @@ -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() diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt index 75a1d9c..5666fd3 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt @@ -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 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 a971cfb..affa1cc 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,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) -> Unit)? = null var onSelectionMoved: ((selectedIds: Set, 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 diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt index 61e211d..d968643 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt @@ -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) { diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt index 24f6dd3..4053711 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt @@ -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(