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

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

View File

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

View File

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

View File

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