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) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 14:45:04 -07:00
parent 5eeedff464
commit 34ad68d1ce
7 changed files with 397 additions and 8 deletions

View File

@@ -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.17.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

View File

@@ -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

View File

@@ -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(

View File

@@ -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<Boolean> = _hasSelection
private var selectedIds = emptySet<Long>()
// 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<Long>) {
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() }
}

View File

@@ -48,6 +48,17 @@ class PadCanvasView(context: Context) : View(context) {
private var currentPaint: Paint? = null
private val currentPoints = mutableListOf<Float>()
// --- 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<Long>()
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<Long>) -> Unit)? = null
var onSelectionMoved: ((selectedIds: Set<Long>, 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<Long> = selectedStrokeIds.toSet()
// --- Eraser ---
private fun handleEraserInput(event: MotionEvent): Boolean {

View File

@@ -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")

View File

@@ -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<Stroke>,
private val repository: PageRepository,
private val onExecute: (List<Long>) -> Unit,
private val onUndo: (List<Stroke>) -> 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<Stroke>,
private val deltaX: Float,
private val deltaY: Float,
private val repository: PageRepository,
private val onExecute: (List<Stroke>) -> Unit,
private val onUndo: (List<Stroke>) -> 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<Stroke>, dx: Float, dy: Float): List<Stroke> {
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<Stroke>,
private val pageId: Long,
private val repository: PageRepository,
private val onExecute: (List<Stroke>) -> Unit,
private val onUndo: (List<Long>) -> Unit,
) : UndoableAction {
override val description = "Copy ${strokes.size} strokes"
private val insertedIds = mutableListOf<Long>()
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())
}
}