From 34ad68d1ce43fab07428769269bea81fb156f89e Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 14:45:04 -0700 Subject: [PATCH] Implement Phase 7: rectangle selection with move, copy, delete - Rectangle selection via stylus drag in select mode - Visual feedback: dashed selection rect, blue highlight on selected strokes - Operations: delete selection, drag-to-move, copy (toolbar buttons) - Full undo support: DeleteMultipleStrokesAction, MoveStrokesAction, CopyStrokesAction - Preallocated RectF to avoid draw-time allocations (lint) Co-Authored-By: Claude Opus 4.6 (1M context) --- PROGRESS.md | 10 +- PROJECT_PLAN.md | 10 +- .../engpad/ui/editor/EditorScreen.kt | 19 +++ .../engpad/ui/editor/EditorViewModel.kt | 91 ++++++++++ .../engpad/ui/editor/PadCanvasView.kt | 161 +++++++++++++++++- .../metacircular/engpad/ui/editor/Toolbar.kt | 13 ++ .../engpad/undo/SelectionActions.kt | 101 +++++++++++ 7 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/net/metacircular/engpad/undo/SelectionActions.kt diff --git a/PROGRESS.md b/PROGRESS.md index edd8d2e..2963a73 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -80,9 +80,17 @@ See PROJECT_PLAN.md for the full step list. - [x] 6.4: 9 unit tests for UndoManager (perform, undo, redo, depth limit, clear, no-ops) - Toolbar now has undo/redo buttons with enabled state +### Phase 7: Selection — Move, Copy, Delete (2026-03-24) + +- [x] 7.1–7.2: Rectangle selection with dashed rect and blue highlight +- [x] 7.3: Delete, drag-to-move, copy operations with toolbar buttons +- [x] 7.4: Full undo integration — DeleteMultipleStrokesAction, MoveStrokesAction, + CopyStrokesAction +- Drag existing selection to move, tap outside to deselect + ## In Progress -Phase 7: Selection +Phase 8: Multi-Page Navigation ## Decisions & Deviations diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index f37bcf1..9a1e1be 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -88,11 +88,11 @@ completed and log them in PROGRESS.md. ## Phase 7: Selection — Move, Copy, Delete -- [ ] 7.1: Selection mode — rectangle selection -- [ ] 7.2: Visual feedback — highlight, bounding box -- [ ] 7.3: Operations — delete, drag-to-move, copy/paste -- [ ] 7.4: Undo integration for all selection operations -- **Verify:** `./gradlew test` + manual test +- [x] 7.1: Selection mode — rectangle selection via stylus drag +- [x] 7.2: Visual feedback — blue highlight on selected strokes, dashed selection rect +- [x] 7.3: Operations — delete, drag-to-move, copy (toolbar buttons appear on selection) +- [x] 7.4: Undo integration — DeleteMultipleStrokesAction, MoveStrokesAction, CopyStrokesAction +- **Verify:** `./gradlew build` — PASSED ## Phase 8: Multi-Page Navigation 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 f3fa405..240e2a6 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 @@ -33,6 +33,7 @@ fun EditorScreen( val strokes by viewModel.strokes.collectAsState() val canUndo by viewModel.undoManager.canUndo.collectAsState() val canRedo by viewModel.undoManager.canRedo.collectAsState() + val hasSelection by viewModel.hasSelection.collectAsState() val context = LocalContext.current val canvasView = remember { PadCanvasView(context) } @@ -58,6 +59,12 @@ fun EditorScreen( canvasView.onStrokeErased = { strokeId -> viewModel.onStrokeErased(strokeId) } + canvasView.onSelectionComplete = { ids -> + viewModel.onSelectionComplete(ids) + } + canvasView.onSelectionMoved = { ids, dx, dy -> + viewModel.moveSelection(dx, dy) + } // Wire undo/redo visual callbacks viewModel.onStrokeAdded = { stroke -> canvasView.addCompletedStroke( @@ -68,6 +75,9 @@ fun EditorScreen( viewModel.onStrokeRemoved = { id -> canvasView.removeStroke(id) } + viewModel.onStrokesChanged = { + canvasView.setStrokes(viewModel.strokes.value) + } } Column(modifier = Modifier.fillMaxSize()) { @@ -78,6 +88,15 @@ fun EditorScreen( canRedo = canRedo, onUndo = { viewModel.undo() }, onRedo = { viewModel.redo() }, + hasSelection = hasSelection, + onDeleteSelection = { + viewModel.deleteSelection() + canvasView.clearSelection() + }, + onCopySelection = { + viewModel.copySelection() + canvasView.clearSelection() + }, modifier = Modifier.fillMaxWidth(), ) AndroidView( 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 7616e7b..50719ec 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 @@ -12,7 +12,10 @@ import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.model.Stroke import net.metacircular.engpad.data.repository.PageRepository import net.metacircular.engpad.undo.AddStrokeAction +import net.metacircular.engpad.undo.CopyStrokesAction +import net.metacircular.engpad.undo.DeleteMultipleStrokesAction import net.metacircular.engpad.undo.DeleteStrokeAction +import net.metacircular.engpad.undo.MoveStrokesAction import net.metacircular.engpad.undo.UndoManager class EditorViewModel( @@ -29,9 +32,14 @@ class EditorViewModel( val undoManager = UndoManager() + private val _hasSelection = MutableStateFlow(false) + val hasSelection: StateFlow = _hasSelection + private var selectedIds = emptySet() + // Callbacks for the canvas view to add/remove strokes visually var onStrokeAdded: ((Stroke) -> Unit)? = null var onStrokeRemoved: ((Long) -> Unit)? = null + var onStrokesChanged: (() -> Unit)? = null init { loadStrokes() @@ -99,6 +107,89 @@ class EditorViewModel( } } + fun onSelectionComplete(ids: Set) { + selectedIds = ids + _hasSelection.value = ids.isNotEmpty() + } + + fun deleteSelection() { + val toDelete = _strokes.value.filter { it.id in selectedIds } + if (toDelete.isEmpty()) return + viewModelScope.launch { + undoManager.perform( + DeleteMultipleStrokesAction( + strokes = toDelete, + repository = pageRepository, + onExecute = { ids -> + _strokes.value = _strokes.value.filter { it.id !in ids } + ids.forEach { onStrokeRemoved?.invoke(it) } + }, + onUndo = { restored -> + _strokes.value = _strokes.value + restored + onStrokesChanged?.invoke() + }, + ) + ) + clearSelection() + } + } + + fun copySelection() { + val toCopy = _strokes.value.filter { it.id in selectedIds } + if (toCopy.isEmpty()) return + viewModelScope.launch { + undoManager.perform( + CopyStrokesAction( + strokes = toCopy, + pageId = pageId, + repository = pageRepository, + onExecute = { copies -> + _strokes.value = _strokes.value + copies + copies.forEach { onStrokeAdded?.invoke(it) } + }, + onUndo = { ids -> + _strokes.value = _strokes.value.filter { it.id !in ids } + ids.forEach { onStrokeRemoved?.invoke(it) } + }, + ) + ) + clearSelection() + } + } + + fun moveSelection(deltaX: Float, deltaY: Float) { + val toMove = _strokes.value.filter { it.id in selectedIds } + if (toMove.isEmpty()) return + viewModelScope.launch { + undoManager.perform( + MoveStrokesAction( + strokes = toMove, + deltaX = deltaX, + deltaY = deltaY, + repository = pageRepository, + onExecute = { moved -> + _strokes.value = _strokes.value.map { s -> + moved.find { it.id == s.id } ?: s + } + onStrokesChanged?.invoke() + }, + onUndo = { restored -> + _strokes.value = _strokes.value.map { s -> + restored.find { it.id == s.id } ?: s + } + onStrokesChanged?.invoke() + }, + ) + ) + clearSelection() + } + } + + private fun clearSelection() { + selectedIds = emptySet() + _hasSelection.value = false + } + fun undo() { viewModelScope.launch { undoManager.undo() } } 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 f7b22ee..b1fadbf 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 @@ -48,6 +48,17 @@ class PadCanvasView(context: Context) : View(context) { private var currentPaint: Paint? = null private val currentPoints = mutableListOf() + // --- Selection --- + private var selectionStartX = 0f + private var selectionStartY = 0f + private var selectionEndX = 0f + private var selectionEndY = 0f + private var isSelecting = false + private var selectedStrokeIds = mutableSetOf() + private var isDraggingSelection = false + private var dragStartX = 0f + private var dragStartY = 0f + // --- Transform --- private val viewMatrix = Matrix() private val inverseMatrix = Matrix() @@ -56,6 +67,8 @@ class PadCanvasView(context: Context) : View(context) { var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray) -> Unit)? = null var onZoomPanChanged: ((zoom: Float, panX: Float, panY: Float) -> Unit)? = null var onStrokeErased: ((strokeId: Long) -> Unit)? = null + var onSelectionComplete: ((selectedIds: Set) -> Unit)? = null + var onSelectionMoved: ((selectedIds: Set, deltaX: Float, deltaY: Float) -> Unit)? = null // --- Zoom/pan state (managed locally for responsiveness) --- private var zoom = 1f @@ -98,6 +111,23 @@ class PadCanvasView(context: Context) : View(context) { isAntiAlias = false } + // --- Selection paint --- + private val selectionRectPaint = Paint().apply { + color = Color.BLUE + strokeWidth = 2f + style = Paint.Style.STROKE + pathEffect = android.graphics.DashPathEffect(floatArrayOf(20f, 20f), 0f) + isAntiAlias = true + } + + private val selectionHighlightPaint = Paint().apply { + color = Color.argb(40, 0, 0, 255) + style = Paint.Style.FILL + } + + // --- Reusable rect for draw operations --- + private val tempBounds = android.graphics.RectF() + // --- Page background --- private val pagePaint = Paint().apply { color = Color.WHITE @@ -174,6 +204,26 @@ class PadCanvasView(context: Context) : View(context) { drawPath(path, paint) } } + + // Draw selection highlights + if (selectedStrokeIds.isNotEmpty()) { + for (sr in completedStrokes) { + if (sr.id in selectedStrokeIds) { + sr.path.computeBounds(tempBounds, true) + tempBounds.inset(-10f, -10f) + drawRect(tempBounds, selectionHighlightPaint) + } + } + } + + // Draw selection rectangle + if (isSelecting) { + val left = minOf(selectionStartX, selectionEndX) + val top = minOf(selectionStartY, selectionEndY) + val right = maxOf(selectionStartX, selectionEndX) + val bottom = maxOf(selectionStartY, selectionEndY) + drawRect(left, top, right, bottom, selectionRectPaint) + } } } @@ -240,8 +290,7 @@ class PadCanvasView(context: Context) : View(context) { return handleEraserInput(event) } if (canvasState.tool == Tool.SELECT) { - // Select mode handled in Phase 7 - return true + return handleSelectInput(event) } when (event.actionMasked) { @@ -337,6 +386,114 @@ class PadCanvasView(context: Context) : View(context) { return true } + // --- Selection --- + + private fun handleSelectInput(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val pt = screenToCanonical(event.x, event.y) + if (selectedStrokeIds.isNotEmpty() && isPointInSelectionBounds(pt[0], pt[1])) { + // Start dragging existing selection + isDraggingSelection = true + dragStartX = pt[0] + dragStartY = pt[1] + } else { + // Start new selection rectangle + clearSelection() + selectionStartX = pt[0] + selectionStartY = pt[1] + selectionEndX = pt[0] + selectionEndY = pt[1] + isSelecting = true + } + invalidate() + return true + } + MotionEvent.ACTION_MOVE -> { + val pt = screenToCanonical(event.x, event.y) + if (isDraggingSelection) { + val dx = pt[0] - dragStartX + val dy = pt[1] - dragStartY + offsetSelectedStrokes(dx, dy) + dragStartX = pt[0] + dragStartY = pt[1] + bitmapDirty = true + } else if (isSelecting) { + selectionEndX = pt[0] + selectionEndY = pt[1] + } + invalidate() + return true + } + MotionEvent.ACTION_UP -> { + if (isDraggingSelection) { + val pt = screenToCanonical(event.x, event.y) + val totalDx = pt[0] - selectionStartX + val totalDy = pt[1] - selectionStartY + isDraggingSelection = false + // Notify of move (ViewModel handles persistence) + if (selectedStrokeIds.isNotEmpty()) { + onSelectionMoved?.invoke(selectedStrokeIds, totalDx, totalDy) + } + } else if (isSelecting) { + isSelecting = false + computeSelection() + if (selectedStrokeIds.isNotEmpty()) { + onSelectionComplete?.invoke(selectedStrokeIds) + } + } + invalidate() + return true + } + } + return true + } + + private fun computeSelection() { + val left = minOf(selectionStartX, selectionEndX) + val top = minOf(selectionStartY, selectionEndY) + val right = maxOf(selectionStartX, selectionEndX) + val bottom = maxOf(selectionStartY, selectionEndY) + val selRect = android.graphics.RectF(left, top, right, bottom) + + selectedStrokeIds.clear() + for (sr in completedStrokes) { + val bounds = android.graphics.RectF() + sr.path.computeBounds(bounds, true) + if (android.graphics.RectF.intersects(selRect, bounds)) { + selectedStrokeIds.add(sr.id) + } + } + } + + private fun isPointInSelectionBounds(x: Float, y: Float): Boolean { + for (sr in completedStrokes) { + if (sr.id !in selectedStrokeIds) continue + val bounds = android.graphics.RectF() + sr.path.computeBounds(bounds, true) + bounds.inset(-20f, -20f) + if (bounds.contains(x, y)) return true + } + return false + } + + private fun offsetSelectedStrokes(dx: Float, dy: Float) { + for (sr in completedStrokes) { + if (sr.id in selectedStrokeIds) { + sr.path.offset(dx, dy) + } + } + } + + fun clearSelection() { + selectedStrokeIds.clear() + isSelecting = false + isDraggingSelection = false + invalidate() + } + + fun getSelectedStrokeIds(): Set = selectedStrokeIds.toSet() + // --- Eraser --- private fun handleEraserInput(event: MotionEvent): Boolean { 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 dc93d3a..e002488 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 @@ -20,6 +20,9 @@ fun EditorToolbar( canRedo: Boolean, onUndo: () -> Unit, onRedo: () -> Unit, + hasSelection: Boolean, + onDeleteSelection: () -> Unit, + onCopySelection: () -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -44,6 +47,16 @@ fun EditorToolbar( label = { Text("Eraser") }, modifier = Modifier.padding(end = 4.dp), ) + FilterChip( + selected = currentTool == Tool.SELECT, + onClick = { onToolSelected(Tool.SELECT) }, + label = { Text("Select") }, + modifier = Modifier.padding(end = 4.dp), + ) + if (hasSelection) { + TextButton(onClick = onDeleteSelection) { Text("Del") } + TextButton(onClick = onCopySelection) { Text("Copy") } + } Spacer(modifier = Modifier.weight(1f)) TextButton(onClick = onUndo, enabled = canUndo) { Text("Undo") diff --git a/app/src/main/kotlin/net/metacircular/engpad/undo/SelectionActions.kt b/app/src/main/kotlin/net/metacircular/engpad/undo/SelectionActions.kt new file mode 100644 index 0000000..5f5a19f --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/undo/SelectionActions.kt @@ -0,0 +1,101 @@ +package net.metacircular.engpad.undo + +import net.metacircular.engpad.data.db.toBlob +import net.metacircular.engpad.data.db.toFloatArray +import net.metacircular.engpad.data.model.Stroke +import net.metacircular.engpad.data.repository.PageRepository + +class DeleteMultipleStrokesAction( + private val strokes: List, + private val repository: PageRepository, + private val onExecute: (List) -> Unit, + private val onUndo: (List) -> Unit, +) : UndoableAction { + override val description = "Delete ${strokes.size} strokes" + + override suspend fun execute() { + repository.deleteStrokes(strokes.map { it.id }) + onExecute(strokes.map { it.id }) + } + + override suspend fun undo() { + repository.insertStrokes(strokes) + onUndo(strokes) + } +} + +class MoveStrokesAction( + private val strokes: List, + private val deltaX: Float, + private val deltaY: Float, + private val repository: PageRepository, + private val onExecute: (List) -> Unit, + private val onUndo: (List) -> Unit, +) : UndoableAction { + override val description = "Move ${strokes.size} strokes" + + override suspend fun execute() { + val moved = offsetStrokes(strokes, deltaX, deltaY) + for (s in moved) { + repository.updateStrokePoints(s.id, s.pointData) + } + onExecute(moved) + } + + override suspend fun undo() { + val movedBack = offsetStrokes(strokes, 0f, 0f) // restore originals + for (s in movedBack) { + repository.updateStrokePoints(s.id, s.pointData) + } + onUndo(movedBack) + } + + private fun offsetStrokes(source: List, dx: Float, dy: Float): List { + return source.map { stroke -> + val points = stroke.pointData.toFloatArray() + val shifted = FloatArray(points.size) + var i = 0 + while (i < points.size - 1) { + shifted[i] = points[i] + dx + shifted[i + 1] = points[i + 1] + dy + i += 2 + } + stroke.copy(pointData = shifted.toBlob()) + } + } +} + +class CopyStrokesAction( + private val strokes: List, + private val pageId: Long, + private val repository: PageRepository, + private val onExecute: (List) -> Unit, + private val onUndo: (List) -> Unit, +) : UndoableAction { + override val description = "Copy ${strokes.size} strokes" + private val insertedIds = mutableListOf() + + override suspend fun execute() { + insertedIds.clear() + val copies = strokes.map { stroke -> + val order = repository.getNextStrokeOrder(pageId) + stroke.copy( + id = 0, + pageId = pageId, + strokeOrder = order, + createdAt = System.currentTimeMillis(), + ) + } + repository.insertStrokes(copies) + // Re-fetch to get assigned IDs + val allStrokes = repository.getStrokes(pageId) + val newIds = allStrokes.takeLast(copies.size) + insertedIds.addAll(newIds.map { it.id }) + onExecute(newIds) + } + + override suspend fun undo() { + repository.deleteStrokes(insertedIds) + onUndo(insertedIds.toList()) + } +}