Files
eng-pad/DESIGN.md
Kyle Isom 47778222b7 Add project documentation: design spec, implementation plan, and progress tracking
Initial project setup with README, CLAUDE.md (AI dev context), DESIGN.md
(full technical design covering architecture, data model, coordinate system,
rendering strategy), PROJECT_PLAN.md (phased implementation steps), and
PROGRESS.md (completion tracking).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:42:44 -07:00

14 KiB
Raw Blame History

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)

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.

// 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

Rendering Strategy

On-Screen

  1. Completed strokes are rendered to a backing Bitmap. This bitmap is redrawn only when strokes change (add, delete, move).
  2. In-progress stroke (pen is down) is drawn directly to the canvas on each onDraw call, on top of the backing bitmap.
  3. Grid is drawn as a separate pass — thin gray lines at 14.4pt intervals.
  4. The view Matrix transforms everything from canonical to screen space.

This approach means onDraw is fast for the common case (pen moving): draw the cached bitmap + one active path + grid.

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

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).
  4. Backing bitmap is rebuilt after deletion.

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 + impls
│       │   └── 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
│               │   └── RepositoryTest.kt             -- CRUD + cascade delete
│               └── undo/
│                   └── UndoManagerTest.kt            -- Undo/redo logic
├── build.gradle.kts                                  -- Root build config
├── settings.gradle.kts                               -- Project settings
├── gradle.properties                                 -- Gradle properties
├── gradle/
│   └── libs.versions.toml                            -- Version catalog
├── .gitignore
├── CLAUDE.md
├── README.md
├── DESIGN.md                                         -- This file
├── PROJECT_PLAN.md                                   -- Implementation steps
└── PROGRESS.md                                       -- Completion tracking