Files
eng-pad/DESIGN.md
Kyle Isom 85a210c001 Update docs for DC-1 testing, fix toolbar clipping
- 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>
2026-03-24 15:13:25 -07:00

357 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```