UI polish: toolbar redesign, clipboard, snap fix, startup state

Toolbar:
- Replace FilterChip with custom ToolButton (no ripple, instant response)
- Reorder tools: 0.38 0.5 Line Box Eraser Select
- DropdownMenus anchored to buttons via Box wrapper (drop below)
- Page indicator styled as OutlinedButton (visible affordance)
- Cut/Del/Copy/Paste operations (internal clipboard)

Line snap fix:
- Check pen position when timer fires instead of canceling on movement
- Handles EMR stylus micro-tremor correctly

EMR eraser button:
- TOOL_TYPE_ERASER events route to eraser handler regardless of active tool
- Physical eraser end of pen works as temporary eraser

App state:
- Startup navigates to last-visited notebook/page via SharedPreferences
- Closing notebook (X button) clears last notebook so app starts at list
- Updated app icon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 16:15:58 -07:00
parent e81dd60f30
commit 408ba57051
9 changed files with 316 additions and 168 deletions

View File

@@ -1,8 +1,32 @@
package net.metacircular.engpad
import android.app.Application
import android.content.Context
import androidx.core.content.edit
import net.metacircular.engpad.data.db.EngPadDatabase
class EngPadApp : Application() {
val database: EngPadDatabase by lazy { EngPadDatabase.getInstance(this) }
fun getLastNotebookId(): Long {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getLong(KEY_LAST_NOTEBOOK, 0)
}
fun setLastNotebookId(id: Long) {
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
putLong(KEY_LAST_NOTEBOOK, id)
}
}
fun clearLastNotebookId() {
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
remove(KEY_LAST_NOTEBOOK)
}
}
companion object {
private const val PREFS_NAME = "engpad_prefs"
private const val KEY_LAST_NOTEBOOK = "last_notebook_id"
}
}

View File

@@ -10,13 +10,18 @@ import net.metacircular.engpad.ui.theme.EngPadTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val database = (application as EngPadApp).database
val app = application as EngPadApp
val database = app.database
val lastNotebookId = app.getLastNotebookId()
setContent {
EngPadTheme {
val navController = rememberNavController()
EngPadNavGraph(
navController = navController,
database = database,
lastNotebookId = lastNotebookId,
onNotebookOpened = { app.setLastNotebookId(it) },
onNotebookClosed = { app.clearLastNotebookId() },
)
}
}

View File

@@ -46,6 +46,7 @@ fun EditorScreen(
val canUndo by viewModel.undoManager.canUndo.collectAsState()
val canRedo by viewModel.undoManager.canRedo.collectAsState()
val hasSelection by viewModel.hasSelection.collectAsState()
val canPaste by viewModel.canPaste.collectAsState()
val currentPageIndex by viewModel.currentPageIndex.collectAsState()
val pages by viewModel.pages.collectAsState()
@@ -108,14 +109,20 @@ fun EditorScreen(
onUndo = { viewModel.undo() },
onRedo = { viewModel.redo() },
hasSelection = hasSelection,
onCutSelection = {
viewModel.cutSelection()
canvasView.clearSelection()
},
onDeleteSelection = {
viewModel.deleteSelection()
canvasView.clearSelection()
},
onCopySelection = {
viewModel.copySelection()
viewModel.copyToClipboard()
canvasView.clearSelection()
},
onPaste = { viewModel.paste() },
canPaste = canPaste,
onExportPdf = {
val currentPage = pages.getOrNull(currentPageIndex) ?: return@EditorToolbar
val file = PdfExporter.exportPages(

View File

@@ -40,6 +40,10 @@ class EditorViewModel(
val hasSelection: StateFlow<Boolean> = _hasSelection
private var selectedIds = emptySet<Long>()
private val _canPaste = MutableStateFlow(false)
val canPaste: StateFlow<Boolean> = _canPaste
private var clipboard = emptyList<Stroke>()
// Page navigation
private val _pages = MutableStateFlow<List<Page>>(emptyList())
val pages: StateFlow<List<Page>> = _pages
@@ -204,6 +208,40 @@ class EditorViewModel(
}
}
fun cutSelection() {
clipboard = _strokes.value.filter { it.id in selectedIds }
_canPaste.value = clipboard.isNotEmpty()
deleteSelection()
}
fun copyToClipboard() {
clipboard = _strokes.value.filter { it.id in selectedIds }
_canPaste.value = clipboard.isNotEmpty()
clearSelection()
}
fun paste() {
if (clipboard.isEmpty()) return
viewModelScope.launch {
val pageId = _currentPageId.value
undoManager.perform(
CopyStrokesAction(
strokes = clipboard,
pageId = pageId,
repository = pageRepository,
onExecute = { copies ->
_strokes.value = _strokes.value + copies
copies.forEach { onStrokeAdded?.invoke(it) }
},
onUndo = { ids ->
_strokes.value = _strokes.value.filter { it.id !in ids }
ids.forEach { onStrokeRemoved?.invoke(it) }
},
)
)
}
}
fun copySelection() {
val toCopy = _strokes.value.filter { it.id in selectedIds }
if (toCopy.isEmpty()) return

View File

@@ -46,10 +46,11 @@ class PadCanvasView(context: Context) : View(context) {
// --- Line snap ---
private var strokeOriginX = 0f
private var strokeOriginY = 0f
private var maxDistFromOrigin = 0f
private var lastStylusX = 0f // Current pen position for snap check
private var lastStylusY = 0f
private var isSnappedToLine = false
private val handler = Handler(Looper.getMainLooper())
private val snapRunnable = Runnable { snapToLine() }
private val snapRunnable = Runnable { trySnapToLine() }
// --- Box drawing ---
private var boxStartX = 0f
@@ -342,7 +343,8 @@ class PadCanvasView(context: Context) : View(context) {
@Suppress("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
return when (event.getToolType(0)) {
MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> handleStylusInput(event)
MotionEvent.TOOL_TYPE_STYLUS -> handleStylusInput(event)
MotionEvent.TOOL_TYPE_ERASER -> handleEraserInput(event) // EMR pen eraser button
MotionEvent.TOOL_TYPE_FINGER -> handleFingerInput(event)
else -> super.onTouchEvent(event)
}
@@ -372,31 +374,25 @@ class PadCanvasView(context: Context) : View(context) {
val pt = screenToCanonical(event.x, event.y)
strokeOriginX = pt[0]
strokeOriginY = pt[1]
maxDistFromOrigin = 0f
lastStylusX = pt[0]
lastStylusY = pt[1]
isSnappedToLine = false
currentPoints.clear()
currentPoints.add(pt[0])
currentPoints.add(pt[1])
currentPath = Path().apply { moveTo(pt[0], pt[1]) }
currentPaint = buildPaint(canvasState.penWidthPt, Color.BLACK)
// Start snap timer
// Start snap timer — will check distance when it fires
handler.postDelayed(snapRunnable, LINE_SNAP_DELAY_MS)
invalidate()
return true
}
MotionEvent.ACTION_MOVE -> {
val path = currentPath ?: return true
// Track max distance from origin; cancel snap if pen moved far
if (!isSnappedToLine) {
val pt = screenToCanonical(event.x, event.y)
val dx = pt[0] - strokeOriginX
val dy = pt[1] - strokeOriginY
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
if (dist > maxDistFromOrigin) maxDistFromOrigin = dist
if (maxDistFromOrigin > LINE_SNAP_MOVE_THRESHOLD) {
handler.removeCallbacks(snapRunnable)
}
}
// Track current position for snap check
val curPt = screenToCanonical(event.x, event.y)
lastStylusX = curPt[0]
lastStylusY = curPt[1]
if (isSnappedToLine) {
// In snap mode: draw straight line from origin to current point
@@ -445,10 +441,17 @@ class PadCanvasView(context: Context) : View(context) {
return true
}
private fun snapToLine() {
if (currentPath != null) {
/**
* Called by the snap timer. Checks if pen is still near origin —
* if so, activate line snap. If pen has moved far, do nothing.
*/
private fun trySnapToLine() {
if (currentPath == null) return
val dx = lastStylusX - strokeOriginX
val dy = lastStylusY - strokeOriginY
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
if (dist <= LINE_SNAP_MOVE_THRESHOLD) {
isSnappedToLine = true
// Reset path to just origin — next MOVE will draw the straight line
currentPath?.reset()
currentPath?.moveTo(strokeOriginX, strokeOriginY)
currentPoints.clear()
@@ -456,6 +459,7 @@ class PadCanvasView(context: Context) : View(context) {
currentPoints.add(strokeOriginY)
invalidate()
}
// If pen has moved far, don't snap — just let freehand continue
}
// --- Box drawing ---

View File

@@ -1,17 +1,22 @@
package net.metacircular.engpad.ui.editor
import androidx.compose.foundation.BorderStroke
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.width
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
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.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -21,9 +26,39 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.
*/
@Composable
private fun ToolButton(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
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),
) {
Text(
text = label,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EditorToolbar(
currentTool: Tool,
@@ -33,8 +68,11 @@ fun EditorToolbar(
onUndo: () -> Unit,
onRedo: () -> Unit,
hasSelection: Boolean,
onCutSelection: () -> Unit,
onDeleteSelection: () -> Unit,
onCopySelection: () -> Unit,
onPaste: () -> Unit,
canPaste: Boolean,
onExportPdf: () -> Unit,
onExportJpg: () -> Unit,
onClose: () -> Unit,
@@ -46,7 +84,6 @@ fun EditorToolbar(
onLineStyleChanged: (LineStyle) -> Unit,
modifier: Modifier = Modifier,
) {
var showBinderMenu by remember { mutableStateOf(false) }
var showGoToPageDialog by remember { mutableStateOf(false) }
Row(
@@ -54,104 +91,143 @@ fun EditorToolbar(
verticalAlignment = Alignment.CenterVertically,
) {
// Close button
TextButton(onClick = onClose) { Text("X") }
ToolButton(label = "X", selected = false, onClick = onClose)
// Binder dropdown
TextButton(onClick = { showBinderMenu = true }) {
Text("p$currentPageNumber/$totalPages")
}
DropdownMenu(
expanded = showBinderMenu,
onDismissRequest = { showBinderMenu = false },
) {
DropdownMenuItem(
text = { Text("View all pages") },
onClick = {
showBinderMenu = false
onViewAllPages()
},
)
DropdownMenuItem(
text = { Text("Go to page...") },
onClick = {
showBinderMenu = false
showGoToPageDialog = true
},
)
// Page indicator / binder — outlined button style
Box {
var showBinderMenu by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { showBinderMenu = true },
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, Color.Black),
) {
Text("p$currentPageNumber/$totalPages", fontSize = 13.sp)
}
DropdownMenu(
expanded = showBinderMenu,
onDismissRequest = { showBinderMenu = false },
) {
DropdownMenuItem(
text = { Text("View all pages") },
onClick = {
showBinderMenu = false
onViewAllPages()
},
)
DropdownMenuItem(
text = { Text("Go to page\u2026") },
onClick = {
showBinderMenu = false
showGoToPageDialog = true
},
)
}
}
Spacer(modifier = Modifier.width(8.dp))
// Tool chips
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),
)
FilterChip(
selected = currentTool == Tool.SELECT,
onClick = { onToolSelected(Tool.SELECT) },
label = { Text("Select") },
modifier = Modifier.padding(end = 4.dp),
)
FilterChip(
selected = currentTool == Tool.BOX,
onClick = { onToolSelected(Tool.BOX) },
label = { Text("Box") },
modifier = Modifier.padding(end = 4.dp),
)
LineToolChip(
selected = currentTool == Tool.LINE,
lineStyle = lineStyle,
onSelect = { onToolSelected(Tool.LINE) },
onStyleChanged = onLineStyleChanged,
)
if (hasSelection) {
TextButton(onClick = onDeleteSelection) { Text("Del") }
TextButton(onClick = onCopySelection) { Text("Copy") }
// 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
Box {
var showLineMenu by remember { mutableStateOf(false) }
val lineLabel = when (lineStyle) {
LineStyle.PLAIN -> "Line"
LineStyle.ARROW -> "\u2192"
LineStyle.DOUBLE_ARROW -> "\u2194"
LineStyle.DASHED -> "- -"
}
Surface(
onClick = { onToolSelected(Tool.LINE) },
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)
.combinedClickable(
onClick = { onToolSelected(Tool.LINE) },
onLongClick = { showLineMenu = true },
),
) {
Text(
text = lineLabel,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
DropdownMenu(
expanded = showLineMenu,
onDismissRequest = { showLineMenu = false },
) {
LineStyle.entries.forEach { style ->
val name = when (style) {
LineStyle.PLAIN -> "Plain line"
LineStyle.ARROW -> "Arrow \u2192"
LineStyle.DOUBLE_ARROW -> "Double \u2194"
LineStyle.DASHED -> "Dashed"
}
DropdownMenuItem(
text = { Text(name) },
onClick = {
showLineMenu = false
onLineStyleChanged(style)
onToolSelected(Tool.LINE)
},
)
}
}
}
ToolButton("Box", currentTool == Tool.BOX, { onToolSelected(Tool.BOX) })
ToolButton("Eraser", currentTool == Tool.ERASER, { onToolSelected(Tool.ERASER) })
ToolButton("Select", currentTool == Tool.SELECT, { onToolSelected(Tool.SELECT) })
// Selection operations: cut / del / copy / paste
if (hasSelection || canPaste) {
Spacer(modifier = Modifier.width(4.dp))
if (hasSelection) {
TextButton(onClick = onCutSelection) { Text("Cut") }
TextButton(onClick = onDeleteSelection) { Text("Del") }
TextButton(onClick = onCopySelection) { Text("Copy") }
}
if (canPaste) {
TextButton(onClick = onPaste) { Text("Paste") }
}
}
Spacer(modifier = Modifier.weight(1f))
var showExportMenu by remember { mutableStateOf(false) }
TextButton(onClick = { showExportMenu = true }) { Text("Export") }
DropdownMenu(
expanded = showExportMenu,
onDismissRequest = { showExportMenu = false },
) {
DropdownMenuItem(
text = { Text("Export as PDF") },
onClick = {
showExportMenu = false
onExportPdf()
},
)
DropdownMenuItem(
text = { Text("Export as JPG") },
onClick = {
showExportMenu = false
onExportJpg()
},
)
}
TextButton(onClick = onUndo, enabled = canUndo) {
Text("Undo")
// Export dropdown — anchored to its button
Box {
var showExportMenu by remember { mutableStateOf(false) }
TextButton(onClick = { showExportMenu = true }) { Text("Export") }
DropdownMenu(
expanded = showExportMenu,
onDismissRequest = { showExportMenu = false },
) {
DropdownMenuItem(
text = { Text("PDF") },
onClick = {
showExportMenu = false
onExportPdf()
},
)
DropdownMenuItem(
text = { Text("JPG") },
onClick = {
showExportMenu = false
onExportJpg()
},
)
}
}
TextButton(onClick = onUndo, enabled = canUndo) { Text("Undo") }
Spacer(modifier = Modifier.width(4.dp))
TextButton(onClick = onRedo, enabled = canRedo) {
Text("Redo")
}
TextButton(onClick = onRedo, enabled = canRedo) { Text("Redo") }
}
if (showGoToPageDialog) {
@@ -207,53 +283,3 @@ private fun GoToPageDialog(
},
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LineToolChip(
selected: Boolean,
lineStyle: LineStyle,
onSelect: () -> Unit,
onStyleChanged: (LineStyle) -> Unit,
) {
var showStyleMenu by remember { mutableStateOf(false) }
val label = when (lineStyle) {
LineStyle.PLAIN -> "Line"
LineStyle.ARROW -> "Arrow"
LineStyle.DOUBLE_ARROW -> "\u2194" // ↔
LineStyle.DASHED -> "Dash"
}
FilterChip(
selected = selected,
onClick = { onSelect() },
label = { Text(label) },
modifier = Modifier
.padding(end = 4.dp)
.combinedClickable(
onClick = { onSelect() },
onLongClick = { showStyleMenu = true },
),
)
DropdownMenu(
expanded = showStyleMenu,
onDismissRequest = { showStyleMenu = false },
) {
LineStyle.entries.forEach { style ->
val styleName = when (style) {
LineStyle.PLAIN -> "Plain line"
LineStyle.ARROW -> "Single arrow \u2192"
LineStyle.DOUBLE_ARROW -> "Double arrow \u2194"
LineStyle.DASHED -> "Dashed line"
}
DropdownMenuItem(
text = { Text(styleName) },
onClick = {
showStyleMenu = false
onStyleChanged(style)
onSelect()
},
)
}
}
}

View File

@@ -33,12 +33,25 @@ object Routes {
fun EngPadNavGraph(
navController: NavHostController,
database: EngPadDatabase,
lastNotebookId: Long = 0,
onNotebookOpened: (Long) -> Unit = {},
onNotebookClosed: () -> Unit = {},
) {
// Auto-navigate to last notebook on startup
var autoNavigated by remember { mutableStateOf(false) }
LaunchedEffect(lastNotebookId) {
if (!autoNavigated && lastNotebookId > 0) {
autoNavigated = true
navController.navigate(Routes.editor(lastNotebookId))
}
}
NavHost(navController = navController, startDestination = Routes.NOTEBOOKS) {
composable(Routes.NOTEBOOKS) {
NotebookListScreen(
database = database,
onNotebookClick = { notebookId ->
onNotebookOpened(notebookId)
navController.navigate(Routes.editor(notebookId))
},
)
@@ -59,6 +72,7 @@ fun EngPadNavGraph(
var initialPageId by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(notebookId) {
onNotebookOpened(notebookId)
val notebook = notebookRepo.getById(notebookId) ?: return@LaunchedEffect
pageSize = PageSize.fromString(notebook.pageSize)
// Use last page or fall back to first page
@@ -90,6 +104,7 @@ fun EngPadNavGraph(
initialPageId = pid,
database = database,
onClose = {
onNotebookClosed()
navController.popBackStack(Routes.NOTEBOOKS, false)
},
onViewAllPages = {

View File

@@ -4,7 +4,13 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- White background -->
<path
android:fillColor="#FFFFFF"
android:fillColor="#F5F5F5"
android:pathData="M0,0h108v108H0z" />
<!-- Subtle grid lines evoking graph paper / blueprint -->
<path
android:strokeColor="#E0E0E0"
android:strokeWidth="0.5"
android:pathData="M18,0 V108 M30,0 V108 M42,0 V108 M54,0 V108 M66,0 V108 M78,0 V108 M90,0 V108 M0,18 H108 M0,30 H108 M0,42 H108 M0,54 H108 M0,66 H108 M0,78 H108 M0,90 H108" />
</vector>

View File

@@ -4,8 +4,31 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple pen icon -->
<!--
Architecture node graph: three connected nodes in a triangle.
All content within the 66dp adaptive-icon safe zone (2187).
Node centers: top (54,35), bottom-left (37,67), bottom-right (71,67).
-->
<!-- Edges (drawn first, behind nodes) -->
<path
android:fillColor="#000000"
android:pathData="M54,24 L62,72 L54,80 L46,72 Z" />
android:strokeColor="#2C2C2C"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M54,35 L37,67 M54,35 L71,67 M37,67 L71,67" />
<!-- Top node -->
<path
android:fillColor="#2C2C2C"
android:pathData="M48,29 L60,29 Q63,29 63,32 L63,38 Q63,41 60,41 L48,41 Q45,41 45,38 L45,32 Q45,29 48,29 Z" />
<!-- Bottom-left node -->
<path
android:fillColor="#2C2C2C"
android:pathData="M31,61 L43,61 Q46,61 46,64 L46,70 Q46,73 43,73 L31,73 Q28,73 28,70 L28,64 Q28,61 31,61 Z" />
<!-- Bottom-right node -->
<path
android:fillColor="#2C2C2C"
android:pathData="M65,61 L77,61 Q80,61 80,64 L80,70 Q80,73 77,73 L65,73 Q62,73 62,70 L62,64 Q62,61 65,61 Z" />
</vector>