diff --git a/CLAUDE.md b/CLAUDE.md index 6a88b49..3da77ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,8 @@ eng-pad/ - Stroke points are packed as little-endian float BLOBs: `[x0,y0,x1,y1,...]` - All coordinates are in canonical space (300 DPI). Screen transform via `Matrix`. - Grid drawn in **screen space** with pixel-snapped positions (not canonical space) for uniform squares. -- Pen widths: 0.38mm = 4.49pt, 0.5mm = 5.91pt (at 300 DPI). +- Pen widths: 0.38mm (4.49pt), 0.5mm (5.91pt), 0.6mm (7.09pt), 0.7mm (8.27pt) at 300 DPI. +- Single PEN tool with long-press for size selection; remembers last size. - Anti-aliasing disabled on all paint objects for crisp e-ink rendering. - No backing bitmap — strokes draw directly as paths. - Viewport: page always fills screen, pan clamped to edges, dynamic min zoom. 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 de265e9..e9567a8 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 @@ -3,25 +3,33 @@ 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 - LINE, // Draw straight lines (with style variants) - BOX, // Draw rectangles + PEN, // Freehand drawing + LINE, // Straight lines (with style variants) + BOX, // Rectangles ERASER, SELECT, // Rectangle select, then cut/copy/del/paste - MOVE, // Tap and drag individual strokes + MOVE, // Tap and drag strokes +} + +/** Pen size in mm and canonical points (300 DPI). */ +enum class PenSize(val mm: Float, val pt: Float) { + MM_038(0.38f, 4.49f), + MM_050(0.50f, 5.91f), + MM_060(0.60f, 7.09f), + MM_070(0.70f, 8.27f), } enum class LineStyle { PLAIN, - ARROW, // Single arrow at end - DOUBLE_ARROW, // Arrows at both ends + ARROW, + DOUBLE_ARROW, DASHED, } data class CanvasState( val pageSize: PageSize = PageSize.REGULAR, - val tool: Tool = Tool.PEN_FINE, + val tool: Tool = Tool.PEN, + val penSize: PenSize = PenSize.MM_038, val lineStyle: LineStyle = LineStyle.PLAIN, val zoom: Float = 1f, val panX: Float = 0f, @@ -29,17 +37,15 @@ data class CanvasState( ) { val penWidthPt: Float get() = when (tool) { - Tool.PEN_FINE -> 4.49f - Tool.PEN_MEDIUM -> 5.91f - Tool.BOX -> 4.49f - Tool.LINE -> 4.49f - Tool.MOVE -> 0f + Tool.PEN -> penSize.pt + Tool.LINE -> penSize.pt + Tool.BOX -> penSize.pt 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 + const val GRID_SPACING_PT = 60f } } 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 1eb70a0..6a710bd 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 @@ -108,8 +108,9 @@ fun EditorScreen( Column(modifier = Modifier.fillMaxSize()) { EditorToolbar( - currentTool = canvasState.tool, + canvasState = canvasState, onToolSelected = { viewModel.setTool(it) }, + onPenSizeSelected = { viewModel.setPenSize(it) }, canUndo = canUndo, canRedo = canRedo, onUndo = { viewModel.undo() }, @@ -156,7 +157,6 @@ fun EditorScreen( onGoToPage = { pageNum -> viewModel.navigateToPage(pageNum - 1) }, - lineStyle = canvasState.lineStyle, onLineStyleChanged = { viewModel.setLineStyle(it) }, modifier = Modifier.fillMaxWidth(), ) 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 ac31476..445c45d 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 @@ -129,6 +129,10 @@ class EditorViewModel( _canvasState.value = _canvasState.value.copy(tool = tool) } + fun setPenSize(size: PenSize) { + _canvasState.value = _canvasState.value.copy(penSize = size, tool = Tool.PEN) + } + fun setLineStyle(style: LineStyle) { _canvasState.value = _canvasState.value.copy(lineStyle = style) } 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 d968643..438418c 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 @@ -1,19 +1,20 @@ package net.metacircular.engpad.ui.editor import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -26,34 +27,136 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -/** - * Tool button — simple Surface with border. No ripple animation for fast - * e-ink response. Selected state shown with filled background. - */ +/** Icon-style tool button with no ripple for e-ink. */ @Composable private fun ToolButton( - label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + content: @Composable () -> Unit, ) { Surface( - onClick = onClick, shape = RoundedCornerShape(8.dp), color = if (selected) Color.Black else Color.White, contentColor = if (selected) Color.White else Color.Black, border = BorderStroke(1.dp, Color.Black), modifier = modifier.padding(end = 4.dp), + onClick = onClick, ) { - Text( - text = label, - fontSize = 13.sp, + Box( modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + content() + } + } +} + +@Composable +private fun TextToolButton( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ToolButton(selected = selected, onClick = onClick, modifier = modifier) { + Text(label, fontSize = 13.sp) + } +} + +/** Filled circle icon representing the current pen size. */ +@Composable +private fun PenDotIcon(sizeDp: Float, selected: Boolean) { + val color = if (selected) Color.White else Color.Black + Canvas(modifier = Modifier.size(18.dp)) { + val radius = sizeDp * density / 2f + drawCircle(color = color, radius = radius.coerceAtLeast(2f)) + } +} + +/** Thick line icon for the line tool. */ +@Composable +private fun LineIcon(selected: Boolean) { + val color = if (selected) Color.White else Color.Black + Canvas(modifier = Modifier.size(18.dp)) { + drawLine( + color = color, + start = Offset(2f, size.height - 2f), + end = Offset(size.width - 2f, 2f), + strokeWidth = 3f * density, + cap = StrokeCap.Round, + ) + } +} + +/** Box outline icon. */ +@Composable +private fun BoxIcon(selected: Boolean) { + val color = if (selected) Color.White else Color.Black + Canvas(modifier = Modifier.size(18.dp)) { + drawRect( + color = color, + topLeft = Offset(2f, 2f), + size = androidx.compose.ui.geometry.Size(size.width - 4f, size.height - 4f), + style = Stroke(width = 2f * density), + ) + } +} + +/** Undo arrow icon (curved left arrow). */ +@Composable +private fun UndoIcon(enabled: Boolean) { + val color = if (enabled) Color.Black else Color.LightGray + Canvas(modifier = Modifier.size(20.dp)) { + val w = size.width + val h = size.height + val path = androidx.compose.ui.graphics.Path().apply { + // Arrow head pointing left + moveTo(w * 0.15f, h * 0.45f) + lineTo(w * 0.35f, h * 0.25f) + moveTo(w * 0.15f, h * 0.45f) + lineTo(w * 0.35f, h * 0.65f) + // Curved line from arrow to right + moveTo(w * 0.15f, h * 0.45f) + lineTo(w * 0.5f, h * 0.45f) + quadraticTo(w * 0.85f, h * 0.45f, w * 0.85f, h * 0.7f) + } + drawPath( + path = path, + color = color, + style = Stroke(width = 2f * density, cap = StrokeCap.Round), + ) + } +} + +/** Redo arrow icon (curved right arrow). */ +@Composable +private fun RedoIcon(enabled: Boolean) { + val color = if (enabled) Color.Black else Color.LightGray + Canvas(modifier = Modifier.size(20.dp)) { + val w = size.width + val h = size.height + val path = androidx.compose.ui.graphics.Path().apply { + moveTo(w * 0.85f, h * 0.45f) + lineTo(w * 0.65f, h * 0.25f) + moveTo(w * 0.85f, h * 0.45f) + lineTo(w * 0.65f, h * 0.65f) + moveTo(w * 0.85f, h * 0.45f) + lineTo(w * 0.5f, h * 0.45f) + quadraticTo(w * 0.15f, h * 0.45f, w * 0.15f, h * 0.7f) + } + drawPath( + path = path, + color = color, + style = Stroke(width = 2f * density, cap = StrokeCap.Round), ) } } @@ -61,8 +164,9 @@ private fun ToolButton( @OptIn(ExperimentalFoundationApi::class) @Composable fun EditorToolbar( - currentTool: Tool, + canvasState: CanvasState, onToolSelected: (Tool) -> Unit, + onPenSizeSelected: (PenSize) -> Unit, canUndo: Boolean, canRedo: Boolean, onUndo: () -> Unit, @@ -80,20 +184,22 @@ fun EditorToolbar( totalPages: Int, onViewAllPages: () -> Unit, onGoToPage: (Int) -> Unit, - lineStyle: LineStyle, onLineStyleChanged: (LineStyle) -> Unit, modifier: Modifier = Modifier, ) { + val currentTool = canvasState.tool + val penSize = canvasState.penSize + val lineStyle = canvasState.lineStyle var showGoToPageDialog by remember { mutableStateOf(false) } Row( modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Close button - ToolButton(label = "X", selected = false, onClick = onClose) + // Close + TextToolButton("X", false, onClose) - // Page indicator / binder — outlined button style + // Page indicator / binder Box { var showBinderMenu by remember { mutableStateOf(false) } OutlinedButton( @@ -109,40 +215,67 @@ fun EditorToolbar( ) { DropdownMenuItem( text = { Text("View all pages") }, - onClick = { - showBinderMenu = false - onViewAllPages() - }, + onClick = { showBinderMenu = false; onViewAllPages() }, ) DropdownMenuItem( text = { Text("Go to page\u2026") }, - onClick = { - showBinderMenu = false - showGoToPageDialog = true - }, + onClick = { showBinderMenu = false; showGoToPageDialog = true }, ) } } Spacer(modifier = Modifier.width(8.dp)) - // Tools: 0.38 0.5 line box eraser select - ToolButton("0.38", currentTool == Tool.PEN_FINE, { onToolSelected(Tool.PEN_FINE) }) - ToolButton("0.5", currentTool == Tool.PEN_MEDIUM, { onToolSelected(Tool.PEN_MEDIUM) }) - - // Line button with long-press for style + // Pen button — dot icon, long-press for size menu Box { - var showLineMenu by remember { mutableStateOf(false) } - val lineLabel = when (lineStyle) { - LineStyle.PLAIN -> "Line" - LineStyle.ARROW -> "\u2192" - LineStyle.DOUBLE_ARROW -> "\u2194" - LineStyle.DASHED -> "- -" + var showPenMenu by remember { mutableStateOf(false) } + // Map pen size to a visual dot size in dp + val dotDp = when (penSize) { + PenSize.MM_038 -> 4f + PenSize.MM_050 -> 6f + PenSize.MM_060 -> 8f + PenSize.MM_070 -> 10f } + Surface( + shape = RoundedCornerShape(8.dp), + color = if (currentTool == Tool.PEN) Color.Black else Color.White, + border = BorderStroke(1.dp, Color.Black), + modifier = Modifier + .padding(end = 4.dp) + .combinedClickable( + onClick = { onToolSelected(Tool.PEN) }, + onLongClick = { showPenMenu = true }, + ), + ) { + Box( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + PenDotIcon(dotDp, currentTool == Tool.PEN) + } + } + DropdownMenu( + expanded = showPenMenu, + onDismissRequest = { showPenMenu = false }, + ) { + PenSize.entries.forEach { size -> + DropdownMenuItem( + text = { Text("${size.mm}mm") }, + onClick = { + showPenMenu = false + onPenSizeSelected(size) + }, + ) + } + } + } + + // Line button — line icon, long-press for style menu + Box { + var showLineMenu by remember { mutableStateOf(false) } Surface( shape = RoundedCornerShape(8.dp), color = if (currentTool == Tool.LINE) Color.Black else Color.White, - contentColor = if (currentTool == Tool.LINE) Color.White else Color.Black, border = BorderStroke(1.dp, Color.Black), modifier = Modifier .padding(end = 4.dp) @@ -151,11 +284,12 @@ fun EditorToolbar( onLongClick = { showLineMenu = true }, ), ) { - Text( - text = lineLabel, - fontSize = 13.sp, + Box( modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - ) + contentAlignment = Alignment.Center, + ) { + LineIcon(currentTool == Tool.LINE) + } } DropdownMenu( expanded = showLineMenu, @@ -180,12 +314,16 @@ 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) }) + // Box button — box icon + ToolButton(selected = currentTool == Tool.BOX, onClick = { onToolSelected(Tool.BOX) }) { + BoxIcon(currentTool == Tool.BOX) + } - // Selection operations: cut / del / copy / paste + TextToolButton("Eraser", currentTool == Tool.ERASER, { onToolSelected(Tool.ERASER) }) + TextToolButton("Select", currentTool == Tool.SELECT, { onToolSelected(Tool.SELECT) }) + TextToolButton("Move", currentTool == Tool.MOVE, { onToolSelected(Tool.MOVE) }) + + // Selection operations if (hasSelection || canPaste) { Spacer(modifier = Modifier.width(4.dp)) if (hasSelection) { @@ -200,7 +338,7 @@ fun EditorToolbar( Spacer(modifier = Modifier.weight(1f)) - // Export dropdown — anchored to its button + // Export dropdown Box { var showExportMenu by remember { mutableStateOf(false) } TextButton(onClick = { showExportMenu = true }) { Text("Export") } @@ -210,24 +348,32 @@ fun EditorToolbar( ) { DropdownMenuItem( text = { Text("PDF") }, - onClick = { - showExportMenu = false - onExportPdf() - }, + onClick = { showExportMenu = false; onExportPdf() }, ) DropdownMenuItem( text = { Text("JPG") }, - onClick = { - showExportMenu = false - onExportJpg() - }, + onClick = { showExportMenu = false; onExportJpg() }, ) } } - TextButton(onClick = onUndo, enabled = canUndo) { Text("Undo") } - Spacer(modifier = Modifier.width(4.dp)) - TextButton(onClick = onRedo, enabled = canRedo) { Text("Redo") } + // Undo / Redo icons + Surface( + onClick = onUndo, + color = Color.Transparent, + modifier = Modifier.padding(horizontal = 2.dp), + enabled = canUndo, + ) { + UndoIcon(canUndo) + } + Surface( + onClick = onRedo, + color = Color.Transparent, + modifier = Modifier.padding(horizontal = 2.dp), + enabled = canRedo, + ) { + RedoIcon(canRedo) + } } if (showGoToPageDialog) { @@ -257,10 +403,7 @@ private fun GoToPageDialog( text = { OutlinedTextField( value = text, - onValueChange = { - text = it - error = null - }, + onValueChange = { text = it; error = null }, label = { Text("Page (1\u2013$totalPages)") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true,