- Updated DESIGN.md: rendering strategy (no backing bitmap, screen-space grid, viewport clamping), added line snap and box tool documentation - Updated PROGRESS.md: DC-1 testing results and fixes applied - Updated CLAUDE.md: key conventions for rendering - Fix toolbar overlap: use clipToBounds on canvas AndroidView to prevent it from drawing over the toolbar area Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
357 lines
15 KiB
Markdown
357 lines
15 KiB
Markdown
# DESIGN.md — eng-pad Technical Design
|
||
|
||
## Overview
|
||
|
||
eng-pad is an Android note-taking app for EMR pen devices. It provides a
|
||
notebook-based writing surface with a guide grid, two fixed pen sizes, and
|
||
PDF export. Target devices are the Supernote Manta (Android 11) and the
|
||
Daylight DC-1 (Android 13).
|
||
|
||
## Architecture
|
||
|
||
**Kotlin + Jetpack Compose + Custom Canvas View.**
|
||
|
||
The app uses a single-Activity architecture with Jetpack Compose for UI
|
||
chrome (notebook list, page list, toolbar, dialogs) and Compose Navigation
|
||
for screen transitions. The drawing surface is a custom `View` subclass
|
||
(`PadCanvasView`) hosted inside Compose via `AndroidView` — Compose's
|
||
`Canvas` composable does not provide direct access to `MotionEvent` dispatch
|
||
or hardware-accelerated `Path` rendering, both of which are critical for
|
||
low-latency stylus input.
|
||
|
||
### Screen Flow
|
||
|
||
```
|
||
NotebookListScreen → PageListScreen → EditorScreen
|
||
| | |
|
||
Create/delete Page grid, PadCanvasView +
|
||
notebooks add pages Toolbar (Compose)
|
||
```
|
||
|
||
Three Compose navigation destinations:
|
||
|
||
| Route | Screen | Purpose |
|
||
|------------------------|--------------------|--------------------------------|
|
||
| `notebooks` | NotebookListScreen | List, create, delete notebooks |
|
||
| `pages/{notebookId}` | PageListScreen | Page thumbnails, add pages |
|
||
| `editor/{pageId}` | EditorScreen | Drawing canvas + toolbar |
|
||
|
||
### Dependency Stack
|
||
|
||
| Layer | Technology | Purpose |
|
||
|--------------|-----------------------------|---------------------------------|
|
||
| UI | Jetpack Compose + Material3 | Screens, toolbar, dialogs |
|
||
| Drawing | Custom View + Canvas API | Stroke rendering, input |
|
||
| State | ViewModel + StateFlow | Reactive UI state |
|
||
| Persistence | Room (SQLite) | Notebooks, pages, strokes |
|
||
| Async | Kotlin Coroutines | Background DB operations |
|
||
| Navigation | Compose Navigation | Screen routing |
|
||
| Export | Android PdfDocument API | PDF generation |
|
||
| Share | FileProvider + Intents | PDF sharing (Dropbox, etc.) |
|
||
|
||
No external drawing libraries. The Android `Canvas` + `Path` + `Paint` API
|
||
is sufficient and avoids dependencies that may behave unpredictably on e-ink
|
||
displays.
|
||
|
||
## Coordinate System
|
||
|
||
All stroke data is stored in **canonical coordinates at 300 points per inch**
|
||
for high precision with fine pen strokes. Coordinates are scaled to 72 DPI
|
||
during PDF export (multiply by 72/300 = 0.24).
|
||
|
||
| Page Size | Inches | Canonical Points |
|
||
|-----------|----------|-------------------|
|
||
| Regular | 8.5 × 11 | 2550 × 3300 |
|
||
| Large | 11 × 17 | 3300 × 5100 |
|
||
|
||
### Grid
|
||
|
||
The guide grid uses ~5 squares per inch = 300/5 = **60 points** per grid
|
||
square. The grid is drawn as thin gray lines on screen but excluded from PDF
|
||
export and any other output.
|
||
|
||
### Pen Sizes
|
||
|
||
Two fixed stroke widths, corresponding to Muji gel ink ballpoints:
|
||
|
||
| Pen | Millimeters | Canonical Points |
|
||
|-------|-------------|------------------|
|
||
| Fine | 0.38 mm | 4.49 pt |
|
||
| Medium| 0.50 mm | 5.91 pt |
|
||
|
||
No pressure sensitivity — stroke width is uniform for a given pen size.
|
||
|
||
### Screen Transform
|
||
|
||
A `Matrix` maps canonical coordinates to screen pixels. The matrix encodes
|
||
the current zoom level and pan offset. All stylus input coordinates are
|
||
transformed through the inverse matrix before being stored, ensuring strokes
|
||
are always in canonical space regardless of zoom.
|
||
|
||
```
|
||
canonical → [viewMatrix] → screen pixels
|
||
screen pixels → [inverseMatrix] → canonical
|
||
```
|
||
|
||
Zoom range: 0.5× to 4×. Pan is clamped so the page cannot scroll entirely
|
||
off-screen.
|
||
|
||
## Data Model
|
||
|
||
### SQLite Schema (Room)
|
||
|
||
```sql
|
||
CREATE TABLE notebooks (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
title TEXT NOT NULL,
|
||
page_size TEXT NOT NULL CHECK(page_size IN ('regular', 'large')),
|
||
created_at INTEGER NOT NULL, -- epoch millis
|
||
updated_at INTEGER NOT NULL -- epoch millis
|
||
);
|
||
|
||
CREATE TABLE pages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
|
||
page_number INTEGER NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
UNIQUE(notebook_id, page_number)
|
||
);
|
||
|
||
CREATE TABLE strokes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||
pen_size REAL NOT NULL, -- width in canonical points
|
||
color INTEGER NOT NULL, -- ARGB packed int
|
||
point_data BLOB NOT NULL, -- packed floats: [x0,y0,x1,y1,...]
|
||
stroke_order INTEGER NOT NULL, -- z-order within the page
|
||
created_at INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE INDEX idx_strokes_page ON strokes(page_id);
|
||
CREATE INDEX idx_pages_notebook ON pages(notebook_id);
|
||
```
|
||
|
||
### Stroke Point Encoding
|
||
|
||
Points are stored as a `BLOB` of packed little-endian floats:
|
||
`[x0, y0, x1, y1, x2, y2, ...]`. A stroke with N points uses N × 2 × 4
|
||
bytes. A typical stroke of 200 points = 1600 bytes. This is ~10× more
|
||
compact than JSON and eliminates parsing overhead.
|
||
|
||
```kotlin
|
||
// Encode
|
||
fun FloatArray.toBlob(): ByteArray {
|
||
val buf = ByteBuffer.allocate(size * 4).order(ByteOrder.LITTLE_ENDIAN)
|
||
for (f in this) buf.putFloat(f)
|
||
return buf.array()
|
||
}
|
||
|
||
// Decode
|
||
fun ByteArray.toFloatArray(): FloatArray {
|
||
val buf = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN)
|
||
return FloatArray(size / 4) { buf.getFloat() }
|
||
}
|
||
```
|
||
|
||
## Input Handling
|
||
|
||
### Tool Type Dispatch
|
||
|
||
The EMR digitizer reports stylus events as `TOOL_TYPE_STYLUS` and finger
|
||
touches as `TOOL_TYPE_FINGER`. This provides a clean input split without
|
||
software palm rejection:
|
||
|
||
| Tool Type | Action |
|
||
|------------------|-------------------------------------|
|
||
| `TOOL_TYPE_STYLUS` | Draw, erase, or select (per mode) |
|
||
| `TOOL_TYPE_FINGER` | Pinch-to-zoom, pan |
|
||
|
||
### Historical Points
|
||
|
||
The Wacom EMR digitizer batches events. To capture all intermediate points
|
||
for smooth strokes, `PadCanvasView` processes `MotionEvent.getHistoricalX/Y`
|
||
on every `ACTION_MOVE`, not just the current event coordinates.
|
||
|
||
### Modes
|
||
|
||
The editor toolbar controls the active mode:
|
||
|
||
| Mode | Stylus Behavior |
|
||
|----------|------------------------------------------------------|
|
||
| Draw | Create strokes with the active pen size |
|
||
| Erase | Touch a stroke to delete it (stroke-level eraser) |
|
||
| Select | Draw a rectangle to select strokes, then move/copy/delete |
|
||
| Box | Drag corner-to-corner to draw rectangles |
|
||
|
||
### Line Snap
|
||
|
||
When drawing in pen mode, holding the stylus still for 1.5 seconds
|
||
activates line snap mode. The stroke is replaced with a straight line
|
||
from the original pen-down point to the current position. Moving the
|
||
pen continues the straight line. The snap timer is canceled if the pen
|
||
moves more than ~5mm (60pt at 300 DPI) from the origin before the
|
||
timer fires. This allows drawing straight lines for diagrams without
|
||
a separate ruler tool.
|
||
|
||
## Rendering Strategy
|
||
|
||
### On-Screen
|
||
|
||
1. **Completed strokes** are drawn directly as `Path` objects on each
|
||
`onDraw` call. No backing bitmap — direct path rendering avoids the
|
||
resolution loss that caused blurry strokes on e-ink displays.
|
||
2. **In-progress stroke** (pen is down) is drawn on top of completed strokes.
|
||
3. **Grid** is drawn in screen pixel space (not canonical space) with
|
||
pixel-snapped line positions. This ensures perfectly uniform square
|
||
spacing regardless of zoom level. Grid uses 1px screen-space lines.
|
||
4. **Strokes** are drawn in canonical space via the view `Matrix`.
|
||
Anti-aliasing is disabled on all paint objects for crisp e-ink rendering.
|
||
|
||
### Viewport
|
||
|
||
The page always fills the entire visible canvas area:
|
||
- Minimum zoom is computed dynamically so the page never appears smaller
|
||
than the viewport in either dimension.
|
||
- Pan is clamped so page edges stay at or beyond viewport edges.
|
||
- Background is white to eliminate any visible non-page area.
|
||
|
||
### PDF Export
|
||
|
||
Uses Android's `PdfDocument` API. `PdfDocument` pages use 1/72-inch units,
|
||
so stroke coordinates are scaled by 72/300 = 0.24 during export. A `Matrix`
|
||
pre-concatenated with the export canvas handles this. The grid drawing pass
|
||
is skipped.
|
||
|
||
Export flow:
|
||
1. Create `PdfDocument`.
|
||
2. For each page: add a `PdfDocument.Page` with canonical dimensions,
|
||
draw all strokes using the same `Path`/`Paint` rendering code.
|
||
3. Write to a temp file in the app's cache directory.
|
||
4. Share via `Intent.ACTION_SEND` with a `FileProvider` URI.
|
||
|
||
## Undo/Redo
|
||
|
||
Command pattern with a depth limit of 50:
|
||
|
||
| Action | Execute | Undo |
|
||
|----------------------|-------------------|--------------------|
|
||
| `AddStrokeAction` | Insert stroke | Delete stroke |
|
||
| `DeleteStrokeAction` | Delete stroke | Re-insert stroke |
|
||
| `MoveStrokesAction` | Offset points | Offset back |
|
||
| `DeleteMultipleAction`| Delete strokes | Re-insert strokes |
|
||
| `CopyStrokesAction` | Duplicate strokes | Delete copies |
|
||
|
||
The `UndoManager` maintains two stacks. Performing a new action clears the
|
||
redo stack. Each action also persists or deletes from Room as part of
|
||
execute/undo.
|
||
|
||
## Selection
|
||
|
||
Rectangle selection (initial implementation; lasso selection as a future
|
||
upgrade):
|
||
|
||
1. In select mode, stylus drag draws a selection rectangle.
|
||
2. Strokes whose bounding boxes intersect the rectangle are selected.
|
||
3. Selected strokes get a visual highlight (translucent overlay + bounding box).
|
||
4. Available operations: delete, drag-to-move, copy/paste.
|
||
5. All selection operations are undoable.
|
||
|
||
## Eraser
|
||
|
||
Stroke-level eraser (not pixel-level):
|
||
|
||
1. In erase mode, stylus touch/drag hit-tests against all strokes on the page.
|
||
2. Hit test: check stroke bounding box first (fast reject), then check
|
||
point-by-point distance (threshold: ~42 canonical points / ~3.5mm).
|
||
3. Hit strokes are deleted immediately (with undo support).
|
||
|
||
## E-ink Considerations
|
||
|
||
Both target devices have e-ink or e-ink-like displays with high refresh latency:
|
||
|
||
- Minimize animations and transitions.
|
||
- Use high-contrast colors (black on white).
|
||
- Minimize invalidation regions (invalidate only the bounding box of new
|
||
stroke segments, not the full canvas).
|
||
- Consider a manual "refresh" button to force a full-screen redraw to clear
|
||
e-ink ghosting.
|
||
|
||
## Source Tree
|
||
|
||
```
|
||
eng-pad/
|
||
├── app/
|
||
│ ├── build.gradle.kts -- Module build config
|
||
│ └── src/
|
||
│ ├── main/
|
||
│ │ ├── AndroidManifest.xml
|
||
│ │ ├── kotlin/net/metacircular/engpad/
|
||
│ │ │ ├── EngPadApp.kt -- Application class
|
||
│ │ │ ├── MainActivity.kt -- Single activity, Compose NavHost
|
||
│ │ │ ├── data/
|
||
│ │ │ │ ├── db/
|
||
│ │ │ │ │ ├── EngPadDatabase.kt -- Room database definition
|
||
│ │ │ │ │ ├── NotebookDao.kt -- Notebook CRUD
|
||
│ │ │ │ │ ├── PageDao.kt -- Page CRUD
|
||
│ │ │ │ │ ├── StrokeDao.kt -- Stroke CRUD
|
||
│ │ │ │ │ └── Converters.kt -- Room type converters
|
||
│ │ │ │ ├── model/
|
||
│ │ │ │ │ ├── Notebook.kt -- Room entity
|
||
│ │ │ │ │ ├── Page.kt -- Room entity
|
||
│ │ │ │ │ ├── Stroke.kt -- Room entity
|
||
│ │ │ │ │ └── PageSize.kt -- Enum: REGULAR, LARGE
|
||
│ │ │ │ └── repository/
|
||
│ │ │ │ ├── NotebookRepository.kt -- Notebook operations
|
||
│ │ │ │ └── PageRepository.kt -- Page + stroke operations
|
||
│ │ │ ├── ui/
|
||
│ │ │ │ ├── navigation/
|
||
│ │ │ │ │ └── NavGraph.kt -- Route definitions
|
||
│ │ │ │ ├── notebooks/
|
||
│ │ │ │ │ ├── NotebookListScreen.kt -- Notebook list UI
|
||
│ │ │ │ │ └── NotebookListViewModel.kt -- Notebook list state
|
||
│ │ │ │ ├── pages/
|
||
│ │ │ │ │ ├── PageListScreen.kt -- Page grid UI
|
||
│ │ │ │ │ └── PageListViewModel.kt -- Page list state
|
||
│ │ │ │ ├── editor/
|
||
│ │ │ │ │ ├── PadCanvasView.kt -- Custom View: rendering + input
|
||
│ │ │ │ │ ├── EditorScreen.kt -- Compose wrapper
|
||
│ │ │ │ │ ├── EditorViewModel.kt -- Editor state + persistence
|
||
│ │ │ │ │ ├── CanvasState.kt -- Zoom, pan, tool, selection
|
||
│ │ │ │ │ └── Toolbar.kt -- Editor toolbar
|
||
│ │ │ │ ├── export/
|
||
│ │ │ │ │ └── PdfExporter.kt -- PDF generation + sharing
|
||
│ │ │ │ └── theme/
|
||
│ │ │ │ └── Theme.kt -- Material3 theme
|
||
│ │ │ └── undo/
|
||
│ │ │ ├── UndoManager.kt -- Undo/redo stack
|
||
│ │ │ ├── UndoableAction.kt -- Action interface
|
||
│ │ │ ├── StrokeActions.kt -- Add/delete stroke actions
|
||
│ │ │ └── SelectionActions.kt -- Multi-stroke delete/move/copy
|
||
│ │ └── res/
|
||
│ │ ├── values/
|
||
│ │ │ ├── strings.xml
|
||
│ │ │ └── themes.xml
|
||
│ │ ├── drawable/ -- Toolbar icons
|
||
│ │ └── xml/
|
||
│ │ └── file_provider_paths.xml -- FileProvider config
|
||
│ └── test/
|
||
│ └── kotlin/net/metacircular/engpad/
|
||
│ ├── data/
|
||
│ │ ├── StrokeBlobTest.kt -- Float array ↔ blob roundtrip
|
||
│ │ └── PageSizeTest.kt -- Page size enum tests
|
||
│ └── undo/
|
||
│ └── UndoManagerTest.kt -- Undo/redo logic
|
||
├── build.gradle.kts -- Root build config
|
||
├── settings.gradle.kts -- Project settings (foojay JDK resolver)
|
||
├── gradle.properties -- Gradle properties
|
||
├── gradle/
|
||
│ └── libs.versions.toml -- Version catalog
|
||
├── Makefile -- Build targets (build, test, lint, run, devrun)
|
||
├── .gitignore
|
||
├── CLAUDE.md
|
||
├── README.md
|
||
├── DESIGN.md -- This file
|
||
├── PROJECT_PLAN.md -- Implementation steps
|
||
└── PROGRESS.md -- Completion tracking
|
||
```
|