Files
eng-pad/DESIGN.md
Kyle Isom 6e5c500786 Update project docs to reflect current state after post-Phase 10 polish
- PROGRESS.md: remove stale known issues (all fixed), add post-Phase 10
  feature polish section covering toolbar redesign, 4 pen sizes, line/box/move
  tools, edge swipe nav, page reorder, notebook rename, filter/sort, JPG
  export, clipboard ops, startup state restoration, and DB migrations
- PROJECT_PLAN.md: add Phase 11 (Server Sync Integration) and Phase 12
  (Notebook Backup/Export) with step breakdowns
- DESIGN.md: add Tools table, sync architecture section, backup/export design,
  JPG export, stroke styles, startup state restoration, edge swipe nav; update
  rendering strategy (3-layer compositing), source tree, schema, and pen sizes
- CLAUDE.md: update build commands, architecture, source tree, and key
  conventions to match current codebase

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

20 KiB

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, four fixed pen sizes, line and box drawing tools, and PDF/JPG 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 +
  rename, filter,     add/delete/      Toolbar (Compose)
  sort notebooks      reorder pages

Three Compose navigation destinations:

Route Screen Purpose
notebooks NotebookListScreen List, create, delete, rename notebooks; filter and sort
pages/{notebookId} PageListScreen Page thumbnails, add/delete pages, drag-to-reorder
editor/{notebookId}?pageId={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
Preferences SharedPreferences Last notebook, UI state restore
Async Kotlin Coroutines Background DB operations
Navigation Compose Navigation Screen routing
Export Android PdfDocument API PDF generation
Export Bitmap + JPEG compress JPG generation
Share FileProvider + Intents PDF/JPG 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 x 11 2550 x 3300
Large 11 x 17 3300 x 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, JPG export, and any other output.

Pen Sizes

Four fixed stroke widths:

Pen Millimeters Canonical Points
0.38 0.38 mm 4.49 pt
0.50 0.50 mm 5.91 pt
0.60 0.60 mm 7.09 pt
0.70 0.70 mm 8.27 pt

No pressure sensitivity — stroke width is uniform for a given pen size. The PEN tool remembers the last selected size. Long-press the pen button to open the size picker.

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: dynamic minimum (page fills viewport) to 4x. Pan is clamped so the page cannot scroll entirely off-screen.

Tools

Tool Behavior Sub-options
PEN Freehand drawing with active pen size 4 sizes via long-press menu
LINE Straight line from pen-down to pen-up Plain, arrow, double-arrow, dashed via long-press menu
BOX Rectangle from corner to corner Uses current pen size
ERASER Touch a stroke to delete it (stroke-level)
SELECT Rectangle selection, then cut/copy/delete/paste
MOVE Tap and drag individual strokes

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
    last_page_id INTEGER NOT NULL DEFAULT 0  -- resume position
);

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,
    style        TEXT NOT NULL DEFAULT 'plain'  -- plain, dashed, arrow, double_arrow
);

CREATE INDEX idx_strokes_page ON strokes(page_id);
CREATE INDEX idx_pages_notebook ON pages(notebook_id);

Database version: 3. Migrations add style column to strokes and last_page_id column to notebooks.

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 x 2 x 4 bytes. A typical stroke of 200 points = 1600 bytes. This is ~10x 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() }
}

Stroke Styles

The style column stores a string constant identifying the stroke's visual style. This applies to LINE tool strokes and is stored in the DB so rendering is consistent across sessions.

Style Rendering
plain Solid line (default for freehand, box, and plain lines)
dashed Dashed line pattern
arrow Solid line with arrowhead at endpoint
double_arrow Solid line with arrowheads at both ends

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, select, or move (per tool)
TOOL_TYPE_FINGER Pinch-to-zoom, pan, edge swipe navigation

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.

Edge Swipe Navigation

Finger swipes originating from the left or right 8% of the screen trigger page navigation (previous/next page). Requires a minimum horizontal distance of 100px. Multi-finger gestures cancel edge swipe detection. Swiping forward past the last page auto-creates a new page if the current page has strokes.

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

Three-layer compositing with cached bitmaps:

  1. Grid layer — cached bitmap at screen resolution, redrawn only when zoom, pan, or view size changes. Grid is drawn in screen pixel space (not canonical space) with pixel-snapped line positions for perfectly uniform squares regardless of zoom level. Uses 1px screen-space lines.

  2. Stroke layer — screen-resolution backing bitmap. Completed strokes are drawn as Path objects via the view Matrix. New strokes are added incrementally (drawn onto the existing bitmap without clearing) to avoid full-redraw flicker. The bitmap is fully rebuilt only on zoom/pan changes or stroke deletion.

  3. Dynamic layer — drawn directly on each onDraw call in canonical space via the view Matrix. Includes: in-progress stroke (pen is down), line/box previews, selection rectangle and highlights.

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.

JPG Export

Single-page export at 300 DPI (full canonical resolution). Creates a Bitmap, renders all strokes (no grid), compresses to JPEG at 95% quality. Shared via the same FileProvider + intent mechanism as PDF.

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 Restore original points
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: cut, copy, delete, paste (toolbar buttons appear contextually when strokes are selected or clipboard is non-empty).
  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).

Startup State Restoration

The app persists the last-opened notebook ID and a flag for whether the page list was showing in SharedPreferences (via EngPadApp). On launch:

  • If a notebook was previously open, auto-navigate to it.
  • If the page list was showing, navigate to the page list on top.
  • The editor resumes at the notebook's lastPageId (tracked per-notebook in the DB).

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.

Sync Architecture (Planned)

eng-pad will sync notebooks to an eng-pad-server instance over gRPC:

  • Protocol: gRPC with protobuf-lite, over TLS.
  • Authentication: password-based auth to eng-pad-server (not MCIAS — this is a personal-use app, not a Metacircular service).
  • Sync model: manual sync (user-initiated, not automatic). Per-notebook sync button and sync-all from the library.
  • Conflict resolution: last-write-wins based on updated_at timestamps, with potential for manual resolution in future.
  • Data flow: push local changes, pull remote changes. Full notebook content (metadata + pages + strokes) serialized for transfer.
  • Status tracking: per-notebook sync state (synced, pending, error) displayed as badges in the notebook list.

Backup/Export Design (Planned)

Notebook backup as a portable zip file:

  • Format: notebook_title.engpad.zip containing:
    • notebook.json — notebook metadata (title, page_size, page count)
    • pages/001.json, pages/002.json, ... — one file per page with metadata and strokes array
    • Each stroke: {penSize, color, style, points: [x0,y0,x1,y1,...]}
    • Points as JSON float arrays (portable, human-readable)
  • Import: parse zip, create notebook + pages + strokes in Room DB.
  • Export-all: single zip containing all notebooks for full backup.
  • Sharing: via Android share intents (same mechanism as PDF/JPG).

Source Tree

eng-pad/
├── app/
│   ├── build.gradle.kts                              -- Module build config
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── kotlin/net/metacircular/engpad/
│       │   │   ├── EngPadApp.kt                      -- Application class, SharedPreferences
│       │   │   ├── MainActivity.kt                   -- Single activity, Compose NavHost
│       │   │   ├── data/
│       │   │   │   ├── db/
│       │   │   │   │   ├── EngPadDatabase.kt         -- Room database (version 3)
│       │   │   │   │   ├── NotebookDao.kt            -- Notebook CRUD
│       │   │   │   │   ├── PageDao.kt                -- Page CRUD + reorder
│       │   │   │   │   ├── StrokeDao.kt              -- Stroke CRUD
│       │   │   │   │   └── Converters.kt             -- Room type converters
│       │   │   │   ├── model/
│       │   │   │   │   ├── Notebook.kt               -- Room entity (+ lastPageId)
│       │   │   │   │   ├── Page.kt                   -- Room entity
│       │   │   │   │   ├── Stroke.kt                 -- Room entity (+ style column)
│       │   │   │   │   └── PageSize.kt               -- Enum: REGULAR, LARGE
│       │   │   │   └── repository/
│       │   │   │       ├── NotebookRepository.kt     -- Notebook operations + rename
│       │   │   │       └── PageRepository.kt         -- Page + stroke ops + reorder
│       │   │   ├── ui/
│       │   │   │   ├── navigation/
│       │   │   │   │   └── NavGraph.kt               -- Routes, auto-restore last notebook
│       │   │   │   ├── notebooks/
│       │   │   │   │   ├── NotebookListScreen.kt     -- Library: list, filter, sort, rename
│       │   │   │   │   └── NotebookListViewModel.kt  -- Notebook list state + rename
│       │   │   │   ├── pages/
│       │   │   │   │   ├── PageListScreen.kt         -- Page grid, drag-to-reorder
│       │   │   │   │   └── PageListViewModel.kt      -- Page list state + reorder
│       │   │   │   ├── editor/
│       │   │   │   │   ├── PadCanvasView.kt          -- Custom View: rendering + input + edge swipe
│       │   │   │   │   ├── EditorScreen.kt           -- Compose wrapper
│       │   │   │   │   ├── EditorViewModel.kt        -- Editor state, page nav, clipboard
│       │   │   │   │   ├── CanvasState.kt            -- Tool, PenSize, LineStyle, zoom/pan
│       │   │   │   │   └── Toolbar.kt                -- Icon-button toolbar with Canvas icons
│       │   │   │   ├── export/
│       │   │   │   │   └── PdfExporter.kt            -- PDF + JPG generation + sharing
│       │   │   │   └── theme/
│       │   │   │       └── Theme.kt                  -- Material3 high-contrast e-ink 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/                          -- Launcher icons
│       │       ├── mipmap-anydpi/                     -- Adaptive icon
│       │       └── 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