Pad/doc/development_plan.md
Greg Pomerantz def4cec498 Development plan v2: pivot to completing the live path (IME wiring gap)
v1 (widget rebuild) was drafted against a stale snapshot. The live repo
already has the real filesystem, the Android APK, and a chunked buffer
(the 10 MB+ approach widget.Editor cannot do). The Android IME is
reachable from the op layer with only a small, precisely-defined gap:
missing key.SelectionCmd/SnippetCmd, an EditEvent handler that ignores
Range (corrupts swipe/autocorrect replacement commits), and a missing
InputHint. Completing those is now Phase 1; the emulator (now available
on the 16 GB VM) is the verification instrument.
2026-08-15 22:44:53 -04:00

14 KiB
Raw Blame History

Development Plan: reach a lean, usable Android text editor

Status: v2, 2026-08-16. Written against the live repo /home/gmp/pad (HEAD 03fb638). v1 (the widget-rebuild plan) is superseded — see §12 for why.

1. Decision summary (updated)

  • Primary path: B — finish the live custom path, do NOT rebuild on widgets. The live repo already has the two hardest, most-asset-heavy pieces that a rebuild would throw away: a real filesystem backend (internal/io/pool/real/) and a working Android APK (JNI + permissions, built + signed). It also has a chunked buffer (internal/editor/chunked_buffer.go) that already implements the spec's "file never fully in memory" — the thing v1 said would need fork-level work on widget.Editor. So the only blocker to the headline feature (Android IME: swipe typing, autocorrect) is a small, precisely-defined wiring gap (§4), not a rewrite.
  • Concurrency: keep the existing logic-goroutine + channels model for now. It's proven to build and (after this plan) pass tests. The v1 "one main goroutine" simplification is a later cleanup, not a prerequisite for usability.
  • File-size limit: the chunked buffer targets large files directly; we validate it against a real 10 MB file on-device and set the honest limit from that, rather than inheriting widget.Editor's ~0.5 GB/MB wall (§2).

2. Evidence (verified against gioui.org v0.10.0 sources, the live repo's version)

  1. widget.Editor is not virtualized — it shapes the entire document per invalidation and keeps a ~200 B/rune in-memory glyphIndex. Measured on this box: 1 MB ≈ 17 ms/keystroke & ~0.32 GB; 3 MB ≈ 1.7 GB; 4 MB OOM-killed the host. Planning figure ~0.5 GB of RAM per 1 MB of text. A phone cannot edit 10 MB in widget.Editor. This is why the live repo went the chunked-buffer route, and it's the reason v1's "just use the widget" was wrong for large files.
  2. The Android IME works through the op layer, not the widget. The window's editor state (app.Windowinput.EditorState{Selection, Snippet}) is what GioInputConnection (Android's InputConnection) reads. It is fed by key.SelectionCmd and key.SnippetCmd ops tagged with a focusable handler. A handler becomes focusable by emitting a key.FocusFilter{Target: tag} (that's all widget.Editor does — it never touches io/input at all). The live code already emits key.FocusCmd{Tag} + key.FocusFilter{Target} and consumes key.EditEvent/key.SnippetEvent, so the skeleton is present; the stateful ops are missing (§4).
  3. The IME snippet is selection-scoped — so a chunked/virtualized editor can still feed a correct snippet (only the visible/selected window), which the live chunked-buffer design is compatible with.
  4. No headless test window in v0.9 or v0.10 — widget-level Go tests are impossible; on-device e2e is required (§9).
  5. The dev agent has no vision (verified) — the emulator debug loop is data-based (state dumps, logcat, gfxinfo, PIL/OCR), not screenshot-judgment (§9.3).

3. Current state of the live repo (post housekeeping, 2026-08-16)

  • go build ./... green. go test ./... now green (was red: internal/editor and internal/test/e2e failed to build after NewLogic gained a path and openfunc parameter). Commit 03fb638 fixed the harness + 5 call sites; the e2e harness now uses a no-op openfunc (matching impl_other.go, whose real OpenFile is a no-op off-Android).
  • Uncommitted (yours, left untouched): JNI/Termux bridge, chunk-buffer + logic changes. Recommend committing these so the working tree is a clean baseline before the IME work.

4. The IME wiring gap (verified, testable) — THE central work item

Android IME text (swipe, autocorrect) arrives as key.EditEvent{Range, Text} routed to the focused tag. Four things are missing or wrong:

  1. No key.SelectionCmd emitted. Without it the driver's EditorState.Selection.Caret stays at origin, so the IME doesn't know where the caret/selection is → suggestions anchor wrongly and the caret jumps after a commit. Fix: in the editor's draw/update pass, emit gtx.Execute(key.SelectionCmd{Tag, Range:{cursor,cursor}, Caret:{Pos,Ascent,Descent}}) whenever the caret moves. The caret's pixel position already exists in internal/ui/render.go:534-554 (the code that draws the caret) — it must be plumbed into app.State (caret pos in px) and read back here. This is the one item coupled to the cursor-positioning work, but it reuses the same geometry.
  2. No key.SnippetCmd emitted. Without it the IME receives an empty snippet → no autocorrect context and no in-place swipe replacement. Fix: emit gtx.Execute(key.SnippetCmd{Tag, Snippet:{Range: selection, Text: selectedText}}); with no selection, an empty snippet at the caret (exactly what widget.Editor sends). Feeding it from the chunked buffer is O(selection size), not O(file).
  3. HandleKeyDown ignores EditEvent.Range. state.go:313 does HandleInsert(v.Text) for any non-backspace text and never deletes v.Range.Start..End. A replacement commit (the normal swipe/autocorrect case) therefore inserts the new text without removing the old → duplication/corruption. Fix: Replace(v.Range.Start, v.Range.End, v.Text) on the chunked buffer and advance the cursor to Start + len(Text). (~10 lines; ChunkedBuffer already has Insert/Delete.)
  4. No key.InputHintOp{Tag, Hint: key.HintText}. Cosmetic but correct to add — hints the on-screen keyboard into text/autocorrect mode.

Acceptance (all verifiable headlessly, §9): after an IME commit, the buffer equals the expected post-replacement text, the caret is at the commit end, and the next frame's SelectionCmd/SnippetCmd reflect that state. On a real IME, swipe + autocorrect produce clean, non-duplicated text.

5. Phases (Path B)

Phase 0 — clean baseline (small)

  1. Commit the uncommitted JNI/chunk-buffer work.
  2. go test -race ./... green (add -race; the channel model may surface races — fix any found).

Phase 1 — complete the IME (§4)

Implement items 14, with Go unit tests for item 3 (range-replace on the chunked buffer is pure and directly testable) and a state-assertion test for items 1/2 (after N frames, app.State holds the expected caret/selection and the next emitted op set is correct). This is the make-or-break feature.

Phase 2 — emulator verification (the observation loop, §9.3)

  1. Install Android SDK + NDK + platform-tools; create an x86_64 AVD (16 GB VM, KVM verified present).
  2. Build the APK (GOOS=android go build ./cmd/pad with ANDROID_NDK_HOME), install, launch.
  3. Drive + observe: state-dump debug flag (add in Phase 1 as a debug build), logcat, dumpsys gfxinfo, screenshot→PIL/OCR.
  4. IME experiment with the AOSP keyboard: input swipe over the keyboard area and verify behaviorally (did the commit land, non-duplicated, cursor correct?) via state dump + logcat — this is what confirms §4 works end to end.

Phase 3 — large-file validation + honest size limit

  1. Open a real 10 MB file via the chunked buffer; verify smooth scroll + edit and measure RAM on-device.
  2. Set the documented limit from that measurement; guard larger files with a clear "too large to edit" state (browser can still list/preview).

Phase 4 — the v1 simplifications, now that it's usable (optional, later)

Only after the app is usable: replace globals (TheState, ui.OpenFile) with explicit state, prune dead task types, and then consider collapsing the logic-goroutine/channel model to a single owner. These reduce future bug surface but are not needed for a usable v1.

Phase 5 — hardening

Rewrite doc/architecture.md to match reality; amend spec.md per §7; README (build/install recipe); in-repo device test checklist.

6. File-size decision (re-framed)

v1 framed this as "accept a limit vs build a windowed editor." The live repo already chose the better option — a chunked buffer (only viewport ± window chunks resident, async prefetch, dirty-chunk protection, async line index). So the decision is now: validate the existing chunked buffer against a real 10 MB file (Phase 3) and set the limit from the measurement. No fork of Gio is on the critical path. If the chunked buffer proves out (likely), 10 MB+ editing is in reach without the 0.5 GB/MB wall that kills widget.Editor.

7. Spec deltas (to write into spec.md)

  1. "No practical limits / file never fully in memory" → backed by the chunked buffer; add a measured hard limit from Phase 3.
  2. Undo across sessions → dropped (in-session only; chunked buffer does not keep full-document undo copies).
  3. Add: IME/swipe/autocorrect support (now explicit, delivered in Phase 1).
  4. Add: external-change detection as the conflict policy (mtime compare on open
    • on resume; changed+clean → reload, changed+dirty → Keep/Reload prompt).

8. Non-goals (v1)

  • File-system watcher / live browser refresh (re-scan on return to browser).
  • Tabs, split view, search-in-file, syntax highlighting, diff/merge.
  • Desktop/other platforms (Android-first; window 390×844 dp).
  • Rewriting on widget.Editor (v1 plan) — kept only as fallback (§12).

9. Testing strategy

The existing e2e harness is kept (it builds and passes now) and extended — v1's "delete the harness" is wrong for Path B. E2E regression works in three layers:

Layer 1 — Go unit tests on pure logic (the bulk)

  • Port/keep browser sort/search/scan tests; keep editor chunked-buffer, cursor, autosave tests.
  • New: range-replace on ChunkedBuffer (§4.3) — pure, directly testable; the exact corruption bug becomes a regression test.
  • New: IME state transitions — after an EditEvent{Range,Text}, assert buffer + caret; assert the emitted SelectionCmd/SnippetCmd reflect state.
  • New: restore JSON round-trip; offset-vs-mtime validation; external-change decision table; file-size guard.
  • CI gate: go build && go vet && go test -race ./....

Layer 2 — UI thin by construction

Draw functions are wirings of the existing element model over app.State; positioning math stays in one place (render.go). The cursor/caret geometry that §4.1 reuses is the same code path as drawing, so IME correctness and visual correctness share a foundation.

Layer 3 — scripted on-device e2e (emulator now available)

The in-app selftest drives the real state machine through the real draw path (browser entries → open file → edit → autosave deadline → reload-prompt decision), logging PASS/FAIL to a file the host adb pulls and asserts.

9.3 Emulator observation layer (this 16 GB VM, KVM verified; agent has NO vision)

Gio renders into one GL surface, so Android's view hierarchy exposes nothing about our UI. The debug loop is data-based — more precise than pixels for this codebase:

  1. State-dump debug flag (add in Phase 1): debug builds write app.State (browser entries, editor text length, cursor px, scroll, dirty, row geometry) as JSON to the app data dir on a magic tap or 2 s interval; host adb pulls and asserts. Primary "eyes."
  2. logcat — Go panics + our logs.
  3. dumpsys gfxinfo <pkg> — per-frame timing/jank for the 60 fps claims.
  4. Screenshot → PIL color histogram / tesseract OCR — coarse on-screen checks.
  5. File diffs via adb pull — autosave/restore correctness.
  6. Drivinginput tap/swipe/keyevent/text, am start/force-stop, ime/settings to pick the AOSP keyboard; swipe-typing exercised via input swipe over the keyboard area and verified behaviorally (did the commit land, non-duplicated, cursor correct?), not visually.

10. Definition of done (whole effort)

On an emulator (and a real phone for final IME sign-off): launch → previous file

  • caret restored → browse a large directory smoothly → open a real file → swipe-type with autocorrect, clean and non-duplicated → close and reopen, caret where you left it → external modification → reload prompt → autosave lands on disk within ~1 s. Repo: go test -race ./... green.

11. Risks / watch-items

  • §4.1 caret plumbing is coupled to the cursor-positioning geometry; if that geometry is itself buggy, the IME will inherit it. Mitigation: the state-dump reports caret px, so we can assert it against expected values in tests.
  • Channel model + -race (Phase 0): the existing goroutine/channel design may have latent races; running -race before IME work avoids conflating them.
  • Chunked buffer at 10 MB (Phase 3): unproven on-device; the measurement is the gate for the size claim.
  • AOSP keyboard is a proxy for real IMEs (Gboard, etc.); final swipe/autocorrect sign-off needs a real device with a real IME.

12. Why v1 (widget rebuild) is now a fallback, not the plan

v1 was written against a stale snapshot (May 31) and concluded "delete internal/ui, build on widget.Editor." Two later-verified facts changed that: (a) the live repo already solved the two hardest parts a rebuild would discard (real FS, APK, and the chunked buffer that widget.Editor cannot do), and (b) the Android IME is reachable from the op layer with only the §4 gap — so the "free IME" that motivated the rebuild is actually ~4 small wirings away on the existing code. Path A (widget rebuild) remains the fallback if the Phase 1 IME wiring proves fragile on-device (e.g., the caret/snippet plumbing drags in a long tail of cursor-positioning bugs that are cheaper to not own). The emulator (Phase 2) is the instrument that makes that call.