Pad/doc/spec.md
Greg Pomerantz 180fa966c8 Pinch-to-font-size (continuous, content-point pinned) + IME-open scroll fix
Two feature bodies accumulated in the working tree:

1. Pinch to change the app font size, continuously (no snapping):
   - internal/ui/pinch_tracker.go: logic-free touch state machine.
     Two-mover formation (the resting palm can land first or last;
     movement is the only signal valid for both), pair = the mover
     pair whose distance changed most, baseline = press distance
     (formDist), lazy pending releases, survivor-scroll forwarding
     after a pair break. Robust to ~1 fps frames: a whole pinch can
     land in one drain (formDist/brokeFactor/lazy releases).
   - render.go: pinch probe (raw pointer events) + grab lifecycle so
     the pair is exclusive (scroll sees nothing of the pair) and the
     survivor's finger keeps working as a scroll after the pinch.
   - state.go/logic.go/session.go/frame.go: app-local float font
     scale, content-point pin (buffer byte + offset from baseline,
     not a layout point, so rewrap keeps the same character under
     the center), restore/font pins, session persistence.
   - pinch_test.go, pinch_font_test.go, tag_identity_test.go,
     real_draw_probe_test.go: unit + real-Renderer/real-Router tests.

2. Soft keyboard must not shift content:
   - Root cause: gioui.org/app calls Router.RevealFocus on any frame
     the viewport shrinks (IME open under adjustResize) and
     synthesizes a pointer.Scroll nudge aimed at the focused field's
     stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
     content jump.
   - Fix: main.go flags the shrink frame; render.go drains that one
     synthetic scroll for the gesture's tag before Update (scroll-
     range clamping cannot work: the router UNIONs ranges across
     frames). Finger scroll (pointer.Drag) and the flinger are
     untouched. reveal_focus_drain_test.go reproduces RevealFocus at
     the router level and verifies the drain + zero delta.

Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
2026-08-23 09:00:51 -04:00

273 lines
16 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.

# Pad — Text Editor Specification
This is the specification of what Pad **actually is** (v1, current code).
Behavior that was once spec'd but never built is listed as deferred in §7, not
described as a requirement. If code and this document disagree, the code wins
— and this document should be fixed.
## 1. Overview
Pad is a minimal plain-text editor for Android, written in Go with the Gio UI
framework. It presents a single root directory of text files (a browser) and a
full-screen editor with the Android soft keyboard. The design goal is a
fast, honest editor for a directory that is synced across devices — instant
open, autosave on every edit, and large files that stay smooth.
**Design philosophy:**
- **Plain text only** — no formatting, no syntax highlighting.
- **Automatic everything** — autosave with a 1 s debounce; there is no save
button.
- **Minimal surface area** — a browser and an editor, nothing else.
- **Bounded memory** — a measured file-size limit with an explicit
"too large" state, instead of silently degrading or OOMing.
**Platform:** Android-first. Window `390×844` dp. Default root directory is
`/storage/emulated/0/Notes` on Android (overridable with `-root`); `.`
elsewhere.
## 2. Implemented behavior
### 2.1 File browser
- Lists files and directories under the current directory.
- **Sort:** four modes — name asc/desc, date asc/desc — cycled by the sort
button. Default: date descending (newest first).
- **Search:** incremental, case-insensitive filter over entry names via the
search bar (a Gio `widget.Editor`).
- **Pagination:** directory entries are loaded asynchronously in pages through
the worker pool; the first paint does not wait for a full directory read.
- **Navigation:** tap a directory to enter it, back to return. Single tap
opens a file — no long-press menus, no file management actions.
### 2.2 Editor
- **Input via the Android IME:** soft-keyboard typing, composition,
backspace, and autocorrect replacements all arrive through one IME
replace-range path and are converted from rune indices to byte offsets.
(Swipe-typing support follows from the same IME path; final sign-off on a
physical device is the one open validation item.)
- **Word wrap:** on by default; wrapped lines are virtual — the buffer stores
only real newlines. Scrolling wrapped content is continuous: the viewport
moves pixel-by-pixel with the finger, including across the bottom of a
wrapped line (no jumping over wrapped remainders) — the scroll offset maps
to content through the per-line visual-line counts corrected by the
renderer every frame (architecture.md §6.2).
- **Virtualized viewport:** only the visible byte range is shaped and drawn
each frame (typically ~4 KB of a large file), keeping frame cost and shaper
memory constant regardless of file size.
- **Cursor + scroll:** tap to place the cursor, drag/scroll to pan; with a
hardware keyboard, arrow keys, Home/End, and Page Up/Down move the cursor
(verified on the Android emulator; Gio's mobile focus-navigation default
for arrow keys is overridden — see architecture.md §2.1).
- **Soft keyboard does not shift content:** opening or closing the IME
resizes the window (adjustResize); the editor is top-anchored, so the
visible text stays put. Gio's window otherwise synthesizes a scroll-to-
focus nudge on any frame the viewport shrinks (RevealFocus, aimed at the
focused field's stale pre-resize bounds), which would shift the content
up; the app drains that one synthetic scroll on the shrink frame
(development_plan.md §20).
- **Pinch to change font size:** a two-finger pinch inside the editor text
changes the app's font size continuously — the rendered size tracks the
inter-finger distance with no snapping to whole points (the scale is a
float32 multiplied by the per-frame distance ratio, clamped to 0.5×3.0×
of the 14sp base). It is an *app-local* scale layered on top of the
system user font setting. The CONTENT UNDER THE PINCH CENTER STAYS
FIXED: the logic captures the **content point** under the finger
midpoint — the glyph byte and the point's offset from that glyph's
baseline — and re-derives the scroll offset to keep that point on the
center. A content point, not a layout point: when the font change
re-wraps a line, a *visual* line's text moves (fragment 2 of the new
wrap is different text), so pinning (line, fragment) would leave a
different character at the center. The pin therefore names the
character itself (byte + baseline offset) and re-anchors it under every
newly shaped layout — the re-wrap included — which is what keeps the
character under the fingers through the rewrap. (A (line, fragment,
sub-line) anchor stands in as fallback for points off any glyph.)
Single-finger scroll is suppressed mid-pinch (the pinch owns the two
fingers) and takes over the viewport on the first scroll after the
pinch. The scale is part of the relaunch session (§2.4) and survives
restarts.
- **Text selection:** two input paths. **Touch** (the Android-native model):
long-press selects the word under the finger (on a blank spot it places the
caret and offers a paste-only menu); double-tap selects the word; the
selection has drag handles at both ends (resize) and the highlighted body
can be dragged to move it (length preserved); a floating menu offers
copy/cut/paste when a selection is active and paste alone for a bare caret.
Any menu tap closes the menu; copy keeps the selection, cut removes it, and
paste inserts at the caret or replaces the selection. A plain tap still
places the caret and clears any selection; taps inside the visible menu are
ignored by the editor. **Hardware keyboard:** shift+arrow extends a
selection. In both cases insert, backspace, and delete replace the selected
text, and the IME replaces a selection when the user types over it. The
selection is highlighted in the editor and pushed to the IME. (Shift state
is tracked by the app, since Gio's Android bridge drops modifier keys —
architecture.md §2.1.)
- **In-file search:** a find icon on the top bar (right side) toggles a
find bar directly below it, with a text input, an "N / M" counter,
next/prev navigation, and a clear (X) button. The X empties the query and
the results but leaves the bar open; the top-bar icon closes it (and
re-opens it). Opening the bar moves key focus to the search input and
raises the soft keyboard. The query is a plain substring,
case-insensitive, no regex; each change is scanned over the entire
in-memory content by a worker (`Search` task), so typing never blocks the
UI and stale scans are dropped by generation. Next/prev select and scroll
to the surrounding matches (wrapping around); the first discovery selects
and scrolls to the first match. Edits do not invalidate the result set:
match offsets before the edit are unchanged, matches after it shift by the
length delta, and matches overlapping the edited range are dropped
(finding text an edit creates requires re-typing the query).
- **Autosave:** every edit restarts a 1 s debounce; on expiry the full content
is written to disk by a worker. At most one write per file is in flight at
any time; saves requested during a write are deferred and re-issued with
the newer content when it completes ("latest state wins"). Writes stage to
a unique per-call temp file and rename into place, so a crash or a
concurrent reader never observes a partial file. Failed writes are retried.
This is the only document persistence mechanism (the app's own state is a
separate tiny session file — §2.4).
### 2.3 Large files (measured)
- Editable limit: **50 MB** (`MaxEditableFileSize`), measured on-device:
a 10 MB file (130,954 lines) opens in ~120 ms (stat ~76 ms, read ~27 ms,
line-index ~18 ms) and idles at **~150 MB PSS / ~230 MB RSS, flat under
scroll**. 50 MB extrapolates to a few hundred MB — acceptable on a modern
phone.
- Files above the limit open into a **"too large to edit"** state: a notice is
shown, edit operations are no-ops, and the browser still lists the file.
- The buffer is chunked (64 KB chunks, prefix-sum offsets, full load for
in-range files); edits splice only affected chunks. Details:
`architecture.md` §6.
### 2.4 State restoration on relaunch
- On launch, Pad re-opens the file from the last session and lands straight
on the editor page, restoring the cursor, scroll position, live selection,
and the find bar (query, open/closed, current match). A relaunch therefore
never requires re-browsing to the last file.
- The scroll position is persisted as the LOGICAL line at the viewport top
plus the sub-line remainder (the raw pixel offset is kept too, for
pre-line-coordinate session files). The pixel offset alone is not
restorable: it lives in visual-line space, and the wrap counts that map
it to a line are rebuilt from estimates on relaunch — lines above the
restored viewport are never shaped, so the offset would land a line
deeper by every wrapped continuation above it. The inverse drift also
happens in the first frames after relaunch: the pre-restore (top-of-file)
window is what gets shaped first, and its real wrap counts landing below
the restored line would drag the line-derived offset to a shallower line.
The restore therefore pins the logical line: while the restore settles
(until the restored window itself has shaped, a 2 s timeout, or the user
scrolls / a search jumps), every wrap-count correction re-derives the
offset as `V(line)·lh + sub` under the current index, so the viewport
stays on the restored line regardless of which counts have landed.
- The snapshot is a tiny JSON file (a few hundred bytes) written by the
cmd layer: `$HOME/.pad/session.json` off-Android, and
`/storage/emulated/0/Pad/session.json` on Android (the dir that already
carries the browser's `.pad` index caches). Search results are NOT stored:
they are regenerated by re-scanning the restored query, and the current
match is re-selected by byte offset.
- The snapshot is written rate-limited (250 ms, only on change) while the
app runs; a file change or a selection appearing/vanishing saves
immediately; on Android the activity's onStop (recents-wipe, app switch)
flushes it one last time; and it is written unconditionally at shutdown.
A kill shortly after a change therefore loses at most a fraction of a
second of state.
- If the restored file no longer exists (deleted/moved, e.g. by Syncthing),
the app falls back to the browser page; positions that exceed a shrunk
file are clamped to its new end.
## 3. Code organization (actual)
```
cmd/pad/ # Gio entry point, main loop, frameReceiver,
# Android defaults (impl_android.go)
internal/
browser/ # BrowserState, BrowserManager, sort, search,
# pagination, layout, handlers
editor/ # Logic goroutine, State, ChunkedBuffer, LineIndex,
# IME handling, autosave, relaunch session (session.go),
# Frame handoff (frame.go)
io/pool/ # Worker pool (priority lanes), task types,
# real/ — real filesystem (rooted at /)
# mock/ — in-memory FS for tests
# types/ — shared types (LineIndex, ...)
ui/ # Element model, Renderer, units (Dp/Px), theme
ui/icons/ # Vector icons
test/e2e/ # Logic-level e2e harness + tests
doc/ # spec.md (this file), architecture.md,
# development_plan.md
```
## 4. Architecture (summary)
The logic goroutine is the **sole owner** of mutable state; the main (Gio)
goroutine reads only a `Frame` snapshot and writes only via channels; a
frame-receiver goroutine stores frames and invalidates the window; a
priority-aware worker pool does all file I/O. No mutex guards application
state. The IME snippet, search box, and gesture state live on the
main-goroutine side because Gio mutates them during draw.
Full details, channel topology, and ownership rules: [`architecture.md`](./architecture.md).
## 5. Invariants to preserve (future development must not break these)
1. **Single owner, no locks on state** (architecture.md §1). Any new feature
adds a channel or an owner-executed callback — never a direct state touch
from another goroutine.
2. **Main never reads logic `State`** — only the `Frame` snapshot.
3. **Never shape the whole file.** The text shaper's internal line storage
retains the largest layout ever performed; one whole-file layout permanently
inflates memory (this caused a 1 GB leak, fixed in Phase 3).
4. **The IME snippet is the visible window**, and rune↔byte conversion happens
in one place (`HandleReplaceRange` / `RuneIndexToByte`).
5. **Persistence goes through the owner.** Autosave dispatches `WriteFile`
tasks for document content; the relaunch session (last file, cursor,
scroll, selection, find state) is marshaled by the owner and written by
the cmd layer's saver callback (tiny JSON, synchronous, torn writes
rejected on load). If session state ever grows beyond a few KB, move the
write to a worker-pool task (autosave pattern).
6. **Logic work stays < 16 ms.** Anything that can block or scan more than the
viewport goes to the worker pool.
7. **Scroll offset is always clamped to `[0, maxScroll]`,** where
`maxScroll = contentHeight viewportHeight` floored at 0 and
`contentHeight` counts VISUAL lines (a wrapped line is taller than one
line pitch). A file whose content fits the viewport has `maxScroll = 0`
and cannot scroll. Verified on-device across 2→130,955 lines
(`development_plan.md` Phase 6); the wrap-aware clamp lands exactly on the
file end for wrapped content (Phase 13).
## 6. Performance expectations (validated on-device)
| Operation | Target | Measured (10 MB file, Android 35 emulator) |
|---|---|---|
| Open file | instant feel | ~120 ms (stat + read + line index) |
| Scroll | 60 fps | logic-frame cadence flat across offsets 0.02→1.0 — **no large-offset degradation**; present fps is emulator-limited (~1427 on the software-rendered emulator, not a Pad quality metric) |
| Type | responsive | single IME path; rapid commits land cleanly |
| Memory | bounded | PSS plateaus ~250 MB at a 10 MB file (bounded high-water mark, no growth under sustained scroll; no OOM) |
## 7. Deferred / not implemented (explicit non-goals for v1)
These were in the original v1 spec but are **not in the code**. They are
recorded here so future rounds don't mistake doc text for behavior:
| Feature | Status | Notes |
|---|---|---|
| Undo (any) | **not implemented** | No undo stack exists; the old `SaveUndoTask` is dead code. |
| External change detection | **not implemented** | No mtime compare on open/resume, no watcher, no Keep/Reload prompt. |
| Syncthing conflict handling | **not implemented** | No `.sync-conflict-*` file detection or merging. |
| File-system watcher | **not implemented** | Browser does not live-refresh; it re-scans on navigation. |
| Alphabetical index sidebar | **not implemented** | `AlphaIndex` element exists but is unused. |
| Tabs, split view | **not implemented** | In-file search IS implemented (spec §2.2); only tabs/split remain deferred. |
| Files > 50 MB | **not supported** | `TooLarge` state instead. |
| Desktop / other platforms | **not supported** | Android-first. |
## 8. Product requirements (standing)
| Requirement | Detail |
|---|---|
| Instant open | Files open without visible lag; content and line index load async. |
| Auto-save | Every edit persisted with a 1 s debounce; no save button. |
| External change awareness | Deferred (§7) — the sync-awareness story is explicitly out of v1. |
| Plain text only | No formatting, no highlighting, no file management UI. |
| Bounded memory | 50 MB editable limit with an explicit "too large" state. |