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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user