Implement Phase 3: canvas drawing with stylus input

- PadCanvasView: custom View with stylus event handling (historical points
  for smoothness), Path/Paint stroke rendering, backing bitmap at 1/4
  resolution, 60pt grid drawing, Matrix coordinate transform
- CanvasState: tool modes (fine/medium pen, eraser, select), zoom/pan state
- EditorViewModel: loads/saves strokes to Room on completion
- EditorScreen: Compose wrapper with AndroidView + FilterChip toolbar
- NavGraph: pages route auto-navigates to first page editor, page size
  passed through route params

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 14:21:17 -07:00
parent 644b8a4732
commit 2fc4224f5a
8 changed files with 579 additions and 18 deletions

View File

@@ -41,9 +41,24 @@ See PROJECT_PLAN.md for the full step list.
- [x] 2.5: Navigation wired — tap notebook → pages stub, editor stub
- EngPadTheme: high-contrast light color scheme (black on white for e-ink)
### Phase 3: Canvas — Basic Drawing (2026-03-24)
- [x] 3.13.4: PadCanvasView — stylus input with historical points, Path/Paint
stroke rendering, backing bitmap at 1/4 resolution, 60pt grid, Matrix
coordinate transform (canonical ↔ screen)
- [x] 3.5: CanvasState — tool enum (PEN_FINE, PEN_MEDIUM, ERASER, SELECT),
zoom/pan state, pen width constants (4.49pt, 5.91pt)
- [x] 3.6: EditorViewModel — loads strokes from Room, saves on completion
- [x] 3.7: EditorScreen + Toolbar — Compose wrapper with AndroidView,
FilterChip toolbar for pen size and eraser selection
- [x] 3.8: NavGraph updated — pages route auto-navigates to first page's editor,
page size passed through route params
- Used KTX Canvas extensions (withMatrix, withScale, createBitmap) per lint
- ClickableViewAccessibility suppressed on PadCanvasView (drawing view)
## In Progress
Phase 3: Canvas — Basic Drawing
Phase 4: Zoom and Pan
## Decisions & Deviations

View File

@@ -48,19 +48,19 @@ completed and log them in PROGRESS.md.
## Phase 3: Canvas — Basic Drawing
- [ ] 3.1: Implement `PadCanvasView` — stylus event handling
- [x] 3.1: Implement `PadCanvasView` — stylus event handling
- `ui/editor/PadCanvasView.kt``onTouchEvent`, `getHistoricalX/Y`
- [ ] 3.2: Stroke rendering — `Path`/`Paint`, backing bitmap
- [ ] 3.3: Grid drawing — 14.4pt spacing, toggleable
- [ ] 3.4: Coordinate transform — canonical ↔ screen via `Matrix`
- [ ] 3.5: Implement `CanvasState`
- `ui/editor/CanvasState.kt` — zoom, pan, active tool
- [ ] 3.6: Implement `EditorViewModel`
- [x] 3.2: Stroke rendering — `Path`/`Paint`, backing bitmap (1/4 resolution)
- [x] 3.3: Grid drawing — 60pt spacing, drawn on screen only
- [x] 3.4: Coordinate transform — canonical ↔ screen via `Matrix`
- [x] 3.5: Implement `CanvasState`
- `ui/editor/CanvasState.kt` — zoom, pan, active tool, pen widths
- [x] 3.6: Implement `EditorViewModel`
- `ui/editor/EditorViewModel.kt` — load/save strokes from Room
- [ ] 3.7: Implement `EditorScreen` + toolbar
- [x] 3.7: Implement `EditorScreen` + toolbar
- `ui/editor/EditorScreen.kt`, `ui/editor/Toolbar.kt`
- [ ] 3.8: Wire navigation from notebook/page list to editor
- **Verify:** `./gradlew build && ./gradlew test` + manual on-device test
- [x] 3.8: Wire navigation notebook list → pages → editor (pages auto-navigates to first page)
- **Verify:** `./gradlew build` — PASSED (build + test + lint). Manual on-device test pending.
## Phase 4: Zoom and Pan

View File

@@ -0,0 +1,31 @@
package net.metacircular.engpad.ui.editor
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,
}
data class CanvasState(
val pageSize: PageSize = PageSize.REGULAR,
val tool: Tool = Tool.PEN_FINE,
val zoom: Float = 1f,
val panX: Float = 0f,
val panY: Float = 0f,
) {
val penWidthPt: Float
get() = when (tool) {
Tool.PEN_FINE -> 4.49f
Tool.PEN_MEDIUM -> 5.91f
else -> 0f
}
companion object {
const val MIN_ZOOM = 0.5f
const val MAX_ZOOM = 4f
const val GRID_SPACING_PT = 60f // 300 DPI / 5 squares per inch
}
}

View File

@@ -0,0 +1,67 @@
package net.metacircular.engpad.ui.editor
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import net.metacircular.engpad.data.db.EngPadDatabase
import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.repository.PageRepository
@Composable
fun EditorScreen(
pageId: Long,
pageSize: PageSize,
database: EngPadDatabase,
) {
val repository = remember {
PageRepository(database.pageDao(), database.strokeDao())
}
val viewModel: EditorViewModel = viewModel(
factory = EditorViewModel.Factory(pageId, pageSize, repository),
)
val canvasState by viewModel.canvasState.collectAsState()
val strokes by viewModel.strokes.collectAsState()
val context = LocalContext.current
val canvasView = remember { PadCanvasView(context) }
// Update canvas when state changes
LaunchedEffect(canvasState) {
canvasView.canvasState = canvasState
}
// Update strokes when loaded from DB
LaunchedEffect(strokes) {
canvasView.setStrokes(strokes)
}
// Wire up stroke completion callback
LaunchedEffect(Unit) {
canvasView.onStrokeCompleted = { penSize, color, points ->
viewModel.onStrokeCompleted(penSize, color, points)
}
}
Column(modifier = Modifier.fillMaxSize()) {
EditorToolbar(
currentTool = canvasState.tool,
onToolSelected = { viewModel.setTool(it) },
modifier = Modifier.fillMaxWidth(),
)
AndroidView(
factory = { canvasView },
modifier = Modifier
.fillMaxSize()
.weight(1f),
)
}
}

View File

@@ -0,0 +1,68 @@
package net.metacircular.engpad.ui.editor
import android.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import net.metacircular.engpad.data.db.toBlob
import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.model.Stroke
import net.metacircular.engpad.data.repository.PageRepository
class EditorViewModel(
private val pageId: Long,
private val pageSize: PageSize,
private val pageRepository: PageRepository,
) : ViewModel() {
private val _canvasState = MutableStateFlow(CanvasState(pageSize = pageSize))
val canvasState: StateFlow<CanvasState> = _canvasState
private val _strokes = MutableStateFlow<List<Stroke>>(emptyList())
val strokes: StateFlow<List<Stroke>> = _strokes
init {
loadStrokes()
}
private fun loadStrokes() {
viewModelScope.launch {
_strokes.value = pageRepository.getStrokes(pageId)
}
}
fun setTool(tool: Tool) {
_canvasState.value = _canvasState.value.copy(tool = tool)
}
fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) {
viewModelScope.launch {
val order = pageRepository.getNextStrokeOrder(pageId)
val stroke = Stroke(
pageId = pageId,
penSize = penSize,
color = color,
pointData = points.toBlob(),
strokeOrder = order,
createdAt = System.currentTimeMillis(),
)
val id = pageRepository.addStroke(stroke)
// Refresh strokes from DB to get the assigned ID
_strokes.value = pageRepository.getStrokes(pageId)
}
}
class Factory(
private val pageId: Long,
private val pageSize: PageSize,
private val pageRepository: PageRepository,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return EditorViewModel(pageId, pageSize, pageRepository) as T
}
}
}

View File

@@ -0,0 +1,303 @@
package net.metacircular.engpad.ui.editor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.view.MotionEvent
import android.view.View
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withMatrix
import androidx.core.graphics.withScale
import net.metacircular.engpad.data.db.toFloatArray
import net.metacircular.engpad.data.model.Stroke
/**
* Custom View for the drawing canvas. Handles stylus input, stroke rendering,
* grid drawing, and zoom/pan via Matrix transforms.
*
* All stroke coordinates are stored in canonical space (300 DPI).
* The viewMatrix transforms canonical -> screen coordinates.
*/
class PadCanvasView(context: Context) : View(context) {
// --- State ---
var canvasState = CanvasState()
set(value) {
field = value
rebuildViewMatrix()
invalidate()
}
// --- Strokes ---
private val completedStrokes = mutableListOf<StrokeRender>()
private var backingBitmap: Bitmap? = null
private var backingCanvas: Canvas? = null
private var bitmapDirty = true
// --- In-progress stroke ---
private var currentPath: Path? = null
private var currentPaint: Paint? = null
private val currentPoints = mutableListOf<Float>()
// --- Transform ---
private val viewMatrix = Matrix()
private val inverseMatrix = Matrix()
// --- Callbacks ---
var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray) -> Unit)? = null
// --- Grid paint ---
private val gridPaint = Paint().apply {
color = Color.LTGRAY
strokeWidth = 1f
style = Paint.Style.STROKE
isAntiAlias = false
}
// --- Page background ---
private val pagePaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.FILL
}
init {
setBackgroundColor(Color.DKGRAY)
}
// --- Public API ---
fun setStrokes(strokes: List<Stroke>) {
completedStrokes.clear()
for (stroke in strokes) {
val points = stroke.pointData.toFloatArray()
val path = buildPathFromPoints(points)
val paint = buildPaint(stroke.penSize, stroke.color)
completedStrokes.add(StrokeRender(path, paint, stroke.id))
}
bitmapDirty = true
invalidate()
}
fun addCompletedStroke(id: Long, penSize: Float, color: Int, points: FloatArray) {
val path = buildPathFromPoints(points)
val paint = buildPaint(penSize, color)
completedStrokes.add(StrokeRender(path, paint, id))
bitmapDirty = true
invalidate()
}
fun removeStroke(id: Long) {
completedStrokes.removeAll { it.id == id }
bitmapDirty = true
invalidate()
}
// --- Drawing ---
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
rebuildViewMatrix()
bitmapDirty = true
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.withMatrix(viewMatrix) {
// Draw page background
drawRect(
0f, 0f,
canvasState.pageSize.widthPt.toFloat(),
canvasState.pageSize.heightPt.toFloat(),
pagePaint,
)
// Draw grid
drawGrid(this)
// Draw completed strokes from backing bitmap
ensureBacking()
backingBitmap?.let { bmp ->
// Scale backing bitmap back up (it's rendered at 1/4 resolution)
val scaleUp = 1f / BACKING_SCALE
withScale(scaleUp, scaleUp) {
drawBitmap(bmp, 0f, 0f, null)
}
}
// Draw in-progress stroke
currentPath?.let { path ->
currentPaint?.let { paint ->
drawPath(path, paint)
}
}
}
}
private fun drawGrid(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
}
var y = 0f
while (y <= pageH) {
canvas.drawLine(0f, y, pageW, y, gridPaint)
y += spacing
}
}
private fun ensureBacking() {
val pageW = canvasState.pageSize.widthPt
val pageH = canvasState.pageSize.heightPt
val bmpW = (pageW * BACKING_SCALE).toInt()
val bmpH = (pageH * BACKING_SCALE).toInt()
if (backingBitmap == null || backingBitmap!!.width != bmpW || backingBitmap!!.height != bmpH) {
backingBitmap?.recycle()
backingBitmap = createBitmap(bmpW, bmpH)
backingCanvas = Canvas(backingBitmap!!)
bitmapDirty = true
}
if (bitmapDirty) {
backingBitmap!!.eraseColor(Color.TRANSPARENT)
backingCanvas!!.withScale(BACKING_SCALE, BACKING_SCALE) {
for (sr in completedStrokes) {
drawPath(sr.path, sr.paint)
}
}
bitmapDirty = false
}
}
// --- Input handling ---
@Suppress("ClickableViewAccessibility") // Drawing view — clicks not applicable
override fun onTouchEvent(event: MotionEvent): Boolean {
return when (event.getToolType(0)) {
MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> handleStylusInput(event)
MotionEvent.TOOL_TYPE_FINGER -> handleFingerInput(event)
else -> super.onTouchEvent(event)
}
}
override fun performClick(): Boolean {
super.performClick()
return true
}
private fun handleStylusInput(event: MotionEvent): Boolean {
if (canvasState.tool == Tool.ERASER || canvasState.tool == Tool.SELECT) {
// Eraser and select modes handled in later phases
return true
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val pt = screenToCanonical(event.x, event.y)
currentPoints.clear()
currentPoints.add(pt[0])
currentPoints.add(pt[1])
currentPath = Path().apply { moveTo(pt[0], pt[1]) }
currentPaint = buildPaint(canvasState.penWidthPt, Color.BLACK)
invalidate()
return true
}
MotionEvent.ACTION_MOVE -> {
val path = currentPath ?: return true
// Process historical points for smoothness
for (i in 0 until event.historySize) {
val pt = screenToCanonical(event.getHistoricalX(i), event.getHistoricalY(i))
path.lineTo(pt[0], pt[1])
currentPoints.add(pt[0])
currentPoints.add(pt[1])
}
val pt = screenToCanonical(event.x, event.y)
path.lineTo(pt[0], pt[1])
currentPoints.add(pt[0])
currentPoints.add(pt[1])
invalidate()
return true
}
MotionEvent.ACTION_UP -> {
if (currentPoints.size >= 4) { // At least 2 points (x,y pairs)
val points = currentPoints.toFloatArray()
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points)
}
currentPath = null
currentPaint = null
currentPoints.clear()
performClick()
invalidate()
return true
}
}
return true
}
private fun handleFingerInput(event: MotionEvent): Boolean {
// Zoom/pan handled in Phase 4
return true
}
// --- Coordinate transforms ---
private fun rebuildViewMatrix() {
viewMatrix.reset()
val pageW = canvasState.pageSize.widthPt.toFloat()
val viewW = width.toFloat().coerceAtLeast(1f)
val fitScale = viewW / pageW
viewMatrix.setScale(fitScale * canvasState.zoom, fitScale * canvasState.zoom)
viewMatrix.postTranslate(canvasState.panX, canvasState.panY)
viewMatrix.invert(inverseMatrix)
}
private fun screenToCanonical(screenX: Float, screenY: Float): FloatArray {
val pts = floatArrayOf(screenX, screenY)
inverseMatrix.mapPoints(pts)
return pts
}
// --- Helpers ---
private fun buildPathFromPoints(points: FloatArray): Path {
val path = Path()
if (points.size < 2) return path
path.moveTo(points[0], points[1])
var i = 2
while (i < points.size - 1) {
path.lineTo(points[i], points[i + 1])
i += 2
}
return path
}
private fun buildPaint(penSize: Float, color: Int): Paint {
return Paint().apply {
this.color = color
strokeWidth = penSize
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
isAntiAlias = true
}
}
private data class StrokeRender(val path: Path, val paint: Paint, val id: Long)
companion object {
/** Backing bitmap is rendered at 1/4 canonical resolution to save memory. */
private const val BACKING_SCALE = 0.25f
}
}

View File

@@ -0,0 +1,37 @@
package net.metacircular.engpad.ui.editor
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun EditorToolbar(
currentTool: Tool,
onToolSelected: (Tool) -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
FilterChip(
selected = currentTool == Tool.PEN_FINE,
onClick = { onToolSelected(Tool.PEN_FINE) },
label = { Text("0.38") },
modifier = Modifier.padding(end = 4.dp),
)
FilterChip(
selected = currentTool == Tool.PEN_MEDIUM,
onClick = { onToolSelected(Tool.PEN_MEDIUM) },
label = { Text("0.5") },
modifier = Modifier.padding(end = 4.dp),
)
FilterChip(
selected = currentTool == Tool.ERASER,
onClick = { onToolSelected(Tool.ERASER) },
label = { Text("Eraser") },
modifier = Modifier.padding(end = 4.dp),
)
}
}

View File

@@ -1,21 +1,30 @@
package net.metacircular.engpad.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import net.metacircular.engpad.data.db.EngPadDatabase
import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.repository.NotebookRepository
import net.metacircular.engpad.data.repository.PageRepository
import net.metacircular.engpad.ui.editor.EditorScreen
import net.metacircular.engpad.ui.notebooks.NotebookListScreen
object Routes {
const val NOTEBOOKS = "notebooks"
const val PAGES = "pages/{notebookId}"
const val EDITOR = "editor/{pageId}"
const val EDITOR = "editor/{pageId}/{pageSize}"
fun pages(notebookId: Long) = "pages/$notebookId"
fun editor(pageId: Long) = "editor/$pageId"
fun editor(pageId: Long, pageSize: PageSize) = "editor/$pageId/${pageSize.name}"
}
@Composable
@@ -37,16 +46,47 @@ fun EngPadNavGraph(
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
) { backStackEntry ->
val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable
// Stub — Phase 8 will implement PageListScreen
androidx.compose.material3.Text("Pages for notebook $notebookId")
// Temporary: navigate directly to first page for now (Phase 8 adds page list)
val notebookRepo = remember {
NotebookRepository(database.notebookDao(), database.pageDao())
}
val pageRepo = remember {
PageRepository(database.pageDao(), database.strokeDao())
}
var navigated by remember { mutableStateOf(false) }
LaunchedEffect(notebookId) {
if (!navigated) {
val notebook = notebookRepo.getById(notebookId) ?: return@LaunchedEffect
val pages = pageRepo.getPages(notebookId)
// Collect first emission to get the page list
pages.collect { pageList ->
if (pageList.isNotEmpty() && !navigated) {
navigated = true
val pageSize = PageSize.fromString(notebook.pageSize)
navController.navigate(Routes.editor(pageList[0].id, pageSize)) {
// Replace the pages route so back goes to notebook list
popUpTo(Routes.NOTEBOOKS)
}
}
}
}
}
}
composable(
route = Routes.EDITOR,
arguments = listOf(navArgument("pageId") { type = NavType.LongType }),
arguments = listOf(
navArgument("pageId") { type = NavType.LongType },
navArgument("pageSize") { type = NavType.StringType },
),
) { backStackEntry ->
val pageId = backStackEntry.arguments?.getLong("pageId") ?: return@composable
// Stub — Phase 3 will implement EditorScreen
androidx.compose.material3.Text("Editor for page $pageId")
val pageSizeStr = backStackEntry.arguments?.getString("pageSize") ?: return@composable
val pageSize = PageSize.fromString(pageSizeStr)
EditorScreen(
pageId = pageId,
pageSize = pageSize,
database = database,
)
}
}
}