Toolbar icon redesign: pen dot, line/box icons, undo/redo arrows

- Pen sizes consolidated into single button with dot icon showing
  current size; long-press for 0.38/0.5/0.6/0.7mm selection
- Line button shows thick line icon; long-press for style variants
- Box button shows rectangle outline icon
- Undo/redo replaced with curved arrow icons (gray when disabled)
- Added PenSize enum (MM_038, MM_050, MM_060, MM_070)
- Removed PEN_FINE/PEN_MEDIUM tools, replaced with single PEN tool
  that uses the selected PenSize from CanvasState

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 16:58:02 -07:00
parent fade0de21b
commit b902aaa721
5 changed files with 231 additions and 77 deletions

View File

@@ -82,7 +82,8 @@ eng-pad/
- Stroke points are packed as little-endian float BLOBs: `[x0,y0,x1,y1,...]` - 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`. - 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. - 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. - Anti-aliasing disabled on all paint objects for crisp e-ink rendering.
- No backing bitmap — strokes draw directly as paths. - No backing bitmap — strokes draw directly as paths.
- Viewport: page always fills screen, pan clamped to edges, dynamic min zoom. - Viewport: page always fills screen, pan clamped to edges, dynamic min zoom.

View File

@@ -3,25 +3,33 @@ package net.metacircular.engpad.ui.editor
import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.model.PageSize
enum class Tool { enum class Tool {
PEN_FINE, // 0.38mm = 4.49pt at 300 DPI PEN, // Freehand drawing
PEN_MEDIUM, // 0.50mm = 5.91pt at 300 DPI LINE, // Straight lines (with style variants)
LINE, // Draw straight lines (with style variants) BOX, // Rectangles
BOX, // Draw rectangles
ERASER, ERASER,
SELECT, // Rectangle select, then cut/copy/del/paste 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 { enum class LineStyle {
PLAIN, PLAIN,
ARROW, // Single arrow at end ARROW,
DOUBLE_ARROW, // Arrows at both ends DOUBLE_ARROW,
DASHED, DASHED,
} }
data class CanvasState( data class CanvasState(
val pageSize: PageSize = PageSize.REGULAR, 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 lineStyle: LineStyle = LineStyle.PLAIN,
val zoom: Float = 1f, val zoom: Float = 1f,
val panX: Float = 0f, val panX: Float = 0f,
@@ -29,17 +37,15 @@ data class CanvasState(
) { ) {
val penWidthPt: Float val penWidthPt: Float
get() = when (tool) { get() = when (tool) {
Tool.PEN_FINE -> 4.49f Tool.PEN -> penSize.pt
Tool.PEN_MEDIUM -> 5.91f Tool.LINE -> penSize.pt
Tool.BOX -> 4.49f Tool.BOX -> penSize.pt
Tool.LINE -> 4.49f
Tool.MOVE -> 0f
else -> 0f else -> 0f
} }
companion object { companion object {
const val MIN_ZOOM = 0.5f const val MIN_ZOOM = 0.5f
const val MAX_ZOOM = 4f const val MAX_ZOOM = 4f
const val GRID_SPACING_PT = 60f // 300 DPI / 5 squares per inch const val GRID_SPACING_PT = 60f
} }
} }

View File

@@ -108,8 +108,9 @@ fun EditorScreen(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
EditorToolbar( EditorToolbar(
currentTool = canvasState.tool, canvasState = canvasState,
onToolSelected = { viewModel.setTool(it) }, onToolSelected = { viewModel.setTool(it) },
onPenSizeSelected = { viewModel.setPenSize(it) },
canUndo = canUndo, canUndo = canUndo,
canRedo = canRedo, canRedo = canRedo,
onUndo = { viewModel.undo() }, onUndo = { viewModel.undo() },
@@ -156,7 +157,6 @@ fun EditorScreen(
onGoToPage = { pageNum -> onGoToPage = { pageNum ->
viewModel.navigateToPage(pageNum - 1) viewModel.navigateToPage(pageNum - 1)
}, },
lineStyle = canvasState.lineStyle,
onLineStyleChanged = { viewModel.setLineStyle(it) }, onLineStyleChanged = { viewModel.setLineStyle(it) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )

View File

@@ -129,6 +129,10 @@ class EditorViewModel(
_canvasState.value = _canvasState.value.copy(tool = tool) _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) { fun setLineStyle(style: LineStyle) {
_canvasState.value = _canvasState.value.copy(lineStyle = style) _canvasState.value = _canvasState.value.copy(lineStyle = style)
} }

View File

@@ -1,19 +1,20 @@
package net.metacircular.engpad.ui.editor package net.metacircular.engpad.ui.editor
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -26,34 +27,136 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
/** /** Icon-style tool button with no ripple for e-ink. */
* Tool button — simple Surface with border. No ripple animation for fast
* e-ink response. Selected state shown with filled background.
*/
@Composable @Composable
private fun ToolButton( private fun ToolButton(
label: String,
selected: Boolean, selected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) { ) {
Surface( Surface(
onClick = onClick,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
color = if (selected) Color.Black else Color.White, color = if (selected) Color.Black else Color.White,
contentColor = if (selected) Color.White else Color.Black, contentColor = if (selected) Color.White else Color.Black,
border = BorderStroke(1.dp, Color.Black), border = BorderStroke(1.dp, Color.Black),
modifier = modifier.padding(end = 4.dp), modifier = modifier.padding(end = 4.dp),
onClick = onClick,
) { ) {
Text( Box(
text = label,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun EditorToolbar( fun EditorToolbar(
currentTool: Tool, canvasState: CanvasState,
onToolSelected: (Tool) -> Unit, onToolSelected: (Tool) -> Unit,
onPenSizeSelected: (PenSize) -> Unit,
canUndo: Boolean, canUndo: Boolean,
canRedo: Boolean, canRedo: Boolean,
onUndo: () -> Unit, onUndo: () -> Unit,
@@ -80,20 +184,22 @@ fun EditorToolbar(
totalPages: Int, totalPages: Int,
onViewAllPages: () -> Unit, onViewAllPages: () -> Unit,
onGoToPage: (Int) -> Unit, onGoToPage: (Int) -> Unit,
lineStyle: LineStyle,
onLineStyleChanged: (LineStyle) -> Unit, onLineStyleChanged: (LineStyle) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val currentTool = canvasState.tool
val penSize = canvasState.penSize
val lineStyle = canvasState.lineStyle
var showGoToPageDialog by remember { mutableStateOf(false) } var showGoToPageDialog by remember { mutableStateOf(false) }
Row( Row(
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp), modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Close button // Close
ToolButton(label = "X", selected = false, onClick = onClose) TextToolButton("X", false, onClose)
// Page indicator / binder — outlined button style // Page indicator / binder
Box { Box {
var showBinderMenu by remember { mutableStateOf(false) } var showBinderMenu by remember { mutableStateOf(false) }
OutlinedButton( OutlinedButton(
@@ -109,40 +215,67 @@ fun EditorToolbar(
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("View all pages") }, text = { Text("View all pages") },
onClick = { onClick = { showBinderMenu = false; onViewAllPages() },
showBinderMenu = false
onViewAllPages()
},
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Go to page\u2026") }, text = { Text("Go to page\u2026") },
onClick = { onClick = { showBinderMenu = false; showGoToPageDialog = true },
showBinderMenu = false
showGoToPageDialog = true
},
) )
} }
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
// Tools: 0.38 0.5 line box eraser select // Pen button — dot icon, long-press for size menu
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
Box { Box {
var showLineMenu by remember { mutableStateOf(false) } var showPenMenu by remember { mutableStateOf(false) }
val lineLabel = when (lineStyle) { // Map pen size to a visual dot size in dp
LineStyle.PLAIN -> "Line" val dotDp = when (penSize) {
LineStyle.ARROW -> "\u2192" PenSize.MM_038 -> 4f
LineStyle.DOUBLE_ARROW -> "\u2194" PenSize.MM_050 -> 6f
LineStyle.DASHED -> "- -" 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( Surface(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
color = if (currentTool == Tool.LINE) Color.Black else Color.White, 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), border = BorderStroke(1.dp, Color.Black),
modifier = Modifier modifier = Modifier
.padding(end = 4.dp) .padding(end = 4.dp)
@@ -151,11 +284,12 @@ fun EditorToolbar(
onLongClick = { showLineMenu = true }, onLongClick = { showLineMenu = true },
), ),
) { ) {
Text( Box(
text = lineLabel,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
) contentAlignment = Alignment.Center,
) {
LineIcon(currentTool == Tool.LINE)
}
} }
DropdownMenu( DropdownMenu(
expanded = showLineMenu, expanded = showLineMenu,
@@ -180,12 +314,16 @@ fun EditorToolbar(
} }
} }
ToolButton("Box", currentTool == Tool.BOX, { onToolSelected(Tool.BOX) }) // Box button — box icon
ToolButton("Eraser", currentTool == Tool.ERASER, { onToolSelected(Tool.ERASER) }) ToolButton(selected = currentTool == Tool.BOX, onClick = { onToolSelected(Tool.BOX) }) {
ToolButton("Select", currentTool == Tool.SELECT, { onToolSelected(Tool.SELECT) }) BoxIcon(currentTool == Tool.BOX)
ToolButton("Move", currentTool == Tool.MOVE, { onToolSelected(Tool.MOVE) }) }
// 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) { if (hasSelection || canPaste) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
if (hasSelection) { if (hasSelection) {
@@ -200,7 +338,7 @@ fun EditorToolbar(
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Export dropdown — anchored to its button // Export dropdown
Box { Box {
var showExportMenu by remember { mutableStateOf(false) } var showExportMenu by remember { mutableStateOf(false) }
TextButton(onClick = { showExportMenu = true }) { Text("Export") } TextButton(onClick = { showExportMenu = true }) { Text("Export") }
@@ -210,24 +348,32 @@ fun EditorToolbar(
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("PDF") }, text = { Text("PDF") },
onClick = { onClick = { showExportMenu = false; onExportPdf() },
showExportMenu = false
onExportPdf()
},
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("JPG") }, text = { Text("JPG") },
onClick = { onClick = { showExportMenu = false; onExportJpg() },
showExportMenu = false
onExportJpg()
},
) )
} }
} }
TextButton(onClick = onUndo, enabled = canUndo) { Text("Undo") } // Undo / Redo icons
Spacer(modifier = Modifier.width(4.dp)) Surface(
TextButton(onClick = onRedo, enabled = canRedo) { Text("Redo") } 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) { if (showGoToPageDialog) {
@@ -257,10 +403,7 @@ private fun GoToPageDialog(
text = { text = {
OutlinedTextField( OutlinedTextField(
value = text, value = text,
onValueChange = { onValueChange = { text = it; error = null },
text = it
error = null
},
label = { Text("Page (1\u2013$totalPages)") }, label = { Text("Page (1\u2013$totalPages)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true, singleLine = true,