NewLogic now takes (FileSystem, path, openfunc); update the e2e harness and editor tests accordingly. The harness uses a no-op openfunc, matching impl_other.go (the real app's OpenFile is a platform bridge, no-op off Android). Also adds doc/development_plan.md (draft, with corrections note re: the live repo state).
15 KiB
Development Plan: from skeleton to lean, usable app
Status: DRAFT — written 2026-08-15 against the stale Backup snapshot (May 31). The live repo (~/pad) is 47 commits ahead; see the "Corrections" note at the bottom before acting on anything in this document.
1. Decision summary
- Tooling: delete the hand-rolled
internal/uientirely. Build on Gio's native widgets:widget.Editor,widget.List,layout.List,widget.Button,widget.RichText. This removes the entire bug class the project has been fighting (positioning/scroll/clipping/gesture) and is the only path to Android IME input (swipe typing, autocorrect) without writing Android InputConnection code ourselves. - Concurrency: one main goroutine owns all app state and does all drawing.
File I/O runs in background goroutines; results come back over a channel and are
applied on the main goroutine. No logic goroutine, no unbuffered
display-channels, no globals (
editor.TheState,browser.currentBrowserState,ui.OpenFileall die). - Target size limit: 50 MB files — but stock
widget.Editorcannot do this (evidence below). The plan stages this as an explicit decision point with three options; recommendation: ship with stock editor at a documented limit, and do windowed shaping as a separate, isolated project if 50 MB is still required.
2. Evidence (verified against gioui.org v0.9.0 sources)
-
IME works through
widget.Editoronly.GioView.javaimplementsonCreateInputConnection→GioInputConnection(commitText / setComposingText / setComposingRegion);app/ime.goexposeseditorState.Replace;widget.Editorpusheskey.SnippetCmdevery frame. Our current custom UI never receives IME input at all. -
The IME snippet is selection-scoped (
updateSnippetreads only the selected range; empty selection → empty snippet at caret). Consequence: windowing the editor's backing text does not break IME behavior. -
widget.Editoris not virtualized.textView.layoutTextshapes the entire document on every invalidation (each keystroke, IME commit, wrap toggle, width change — not on scroll; scroll only moves a clip) and builds an in-memoryglyphIndexof ~200 bytes per rune (combinedPos+text.Glyphper rune). -
Measured on this desktop (shaper.Layout + full glyph iteration, = the per-keystroke cost of
widget.Editor):text size time/keystroke peak RAM 1 MB 17 ms ~0.32 GB 2 MB ~35 ms (extrapolated) ~0.9 GB 3 MB — ~1.7 GB (measured, incl. GC churn) 4 MB — OOM-killed the benchmark host Planning figure: ~0.5 GB of RAM per 1 MB of text (editor index + shaper store + buffer + undo copies, with reallocation churn). Android app memory ceilings (lmkd) are typically ~1.5–2 GB on 8 GB phones.
Therefore: comfortable editing up to ~2 MB, a hard guard at 5 MB
(≈ 2.5 GB — survivable on most phones, heavy), 10 MB ≈ 5 GB (OOM), 50 MB is
not deliverable with stock widget.Editor. 10 MB editing requires windowed
shaping (fork-level work on Gio). See §6.
3. What gets deleted
| Item | Where | Why |
|---|---|---|
| Whole custom toolkit | internal/ui/ (element.go, render.go, layout.go, unit.go, icons/) ~1,500 L |
replaced by Gio widgets |
| Worker pool + mock FS service | internal/io/pool/worker_pool.go, mock/ (429 L) |
replaced by plain background goroutines |
| 10 of 11 task types | internal/io/pool/task.go |
only Read/Write are ever used, as plain functions |
| On-disk JSON index cache | internal/browser/index.go .pad/indices/ machinery |
cache-in-indexed-dir is the fragile design behind the failing mtime test; a sync directory needs no cache (re-scan is ms) |
| Dead browser state | state.go: VisibleCount (never set → list rendered 0 rows), RowFilenames, Pages, Page, SortIndex, SortMode (duplicate of browser's), PaginationState, PageSize |
replaced by widget.List + real filenames |
| Untyped handlers | browser/handlers.go, editor/logic.go func(any) closures + globals |
replaced by direct calls on the main goroutine |
| e2e harness | internal/test/e2e/ |
written against the dead architecture; replaced by widget-level tests after Phase 1 |
| Stale docs | element_model.md, layout_rendering.md, touch.md, conflict_resolution.md, browser_implementation_plan.md |
describe deleted systems; architecture.md rewritten last |
Kept (ported, not rewritten): browser/types.go (Entry), sort.go + tests,
search.go + tests, directory scan from index.go (minus cache), the wrap /
autosave / per-file-offset concepts from editor/state.go.
4. Target architecture (single diagram, no channels except I/O)
main goroutine (Gio frame loop, owns ALL state)
app.State {
page: browser | editor
dir: string // current browser directory
entries: []browser.Entry // sorted+filtered, rebuilt on demand
sort: browser.SortMode
query: string
file: openFile{path, modTime, wrap, editor widget.Editor, dirty, lastEdit}
restore: state.RestoreData // persisted JSON
}
on FrameEvent:
- handle I/O results from chan result
- draw current page
background goroutines (one per op):
os.ReadFile / os.WriteFile / dir scan → send result on chan
- Browser page: top bar (path, sort menu) + search field +
layout.Listoverentries(virtualized; 1,000-file directory is a non-issue). Tap → start background read → on result, switch to editor page. - Editor page:
widget.Editorbound to the file's text; toolbar (filename, wrap toggle, close). Autosave:dirty+ 1 s debounce in the frame loop, background write, status shows saved/dirty. Undo/redo in-session: built intowidget.Editor(unexported history, triggered by normal editing) — no work. - State restore (spec: survive close/reopen): small JSON in the app data
dir:
{openFile, wrap, autosave, offsets: {path: {line, col}}}. On open:SetText+SetCaret(line, col).SetCaretauto-scrolls to the caret. Offsets are rune offsets — invalid if the file changed; validate against persisted mtime and fall back to top on mismatch. - External change detection: on open and on app resume, compare mtime to the opened modTime; if changed and we're not dirty → reload; if dirty → one-line banner with Keep / Reload (no full conflict UI in v1).
- File-size guard: on open, if
size > limit→ show "file too large to edit in v1" (limit per §6 decision). - Testability: state transitions (open file, sort, search, autosave due,
external-change decision) are plain functions on
app.State→ directly unit-testable. Device behavior covered by manual checklist +-racein CI.
5. Phases
Phase 0 — deletion-only (no new features)
- Delete everything in §3.
cmd/padkeeps anapp.Windowwith a placeholder screen; build green.- Keep passing tests by porting sort/search tests to the trimmed
browserpackage; delete the on-disk-cache test (feature deleted) — this removes the flakyTestBuildIndex_CacheInvalidatedOnMtimeChangeby deleting the cache.
Done when: go build ./... && go vet ./... && go test -race ./... green;
repo ≤ ~2,500 lines of Go; no func(any), no package-level mutable state,
no channels except (added in Phase 1) the I/O result channel.
Phase 1 — browser
layout.Listbrowser page: rows = name + modified time; directories distinguished; tap on dir descends, breadcrumb back; tap on file opens it (Phase 2 stub: toast "no editor yet").- Port sort (name/modified, asc/desc) + search filter onto the page.
- Background dir-scan for large directories; version counter to drop stale results.
Done when: on-device: browse a real 1,000+ file directory at full frame rate; sort and search correct (unit tests); no data races.
Phase 2 — editor
- Open file → background read →
widget.EditorwithSetText; toolbar with wrap toggle (maps toWrapPolicy) and close (back to browser). - Autosave: debounce, background write, dirty indicator.
- State restore + per-file offset memory + external-change check (§4).
- IME verification on device: swipe typing, autocorrect, multi-line commits, caret jump after commit.
Done when: edit + close + reopen restores file and caret; kill app,
reopen, same; external modification handled per §4; -race clean.
Phase 3 — the 50 MB decision (see §6)
Either (A) document the limit and ship, or (C) build and integrate the windowed editor. (B, full chunked streaming, is out of scope — it implies a custom editor and forfeits the IME advantage.)
Phase 4 — hardening
- Rewrite
doc/architecture.mdto match reality; amendspec.mdper §7. README.md: what it is, how to build/install (Go 1.24, gioui.org, NDK).- Device test checklist in-repo.
6. The 50 MB decision (explicit fork)
| Option | What | IME | Effort | Outcome |
|---|---|---|---|---|
| A. Accept a documented limit (2 MB soft warn, 5 MB hard guard — §2.4) | nothing more than Phase 2 | full | done | the large majority of real text files in a sync dir; honest limit in UI |
| C. Windowed editor | isolated project: shape only viewport ± window; glyphIndex covers the window; offset map rune→window; scroll triggers re-window; undo history kept as per-operation edits, not full copies |
full (snippet is selection-scoped, verified) | large — touches Gio internals (textView, Editor), upstreamable but expect divergence |
10 MB+ editing at 60 fps, if the PoC holds |
| B. Chunked streaming editor (spec's original) | build our own editor on raw ops | lost (no InputConnection) | largest | rejected: forfeits the core reason for moving to native |
Recommendation: A now, C later as its own branch. Reasoning: A delivers a usable app; the Phase 0–2 work is identical under either choice, so nothing is locked in. C is only worth starting if, after real usage, the 5 MB guard bites (i.e. we genuinely need to edit larger files). If C is started, gate it behind a PoC first: windowed shaping of a 10 MB file must sustain 60 fps scroll + sub-100 ms keystroke on the target phone before any integration work.
7. Spec deltas (to write into spec.md after Phase 2)
- "No practical limits / file never fully in memory" → "files up to N MB (measured); larger files openable in browser, not editable."
- Undo across sessions → dropped (in-session undo/redo is provided by the editor widget).
- Add: IME/swipe/autocorrect support (was an implicit requirement, now explicit and delivered).
- Add: external-change detection behavior (§4) as the conflict policy,
replacing
conflict_resolution.md.
8. Non-goals (v1)
- File-system watcher / live browser refresh (re-scan on return to browser).
- Multi-file views, tabs, split.
- Syntax highlighting, search-in-file, diff/merge UI.
- Desktop/other platforms (Android-first; window sized per existing
unit.Dp(390) × unit.Dp(844)).
9. Testing strategy (replaces internal/test/e2e)
The old e2e harness is deleted: it existed to test the channel/frame choreography, which no longer exists. E2E regression still works, in three layers:
Layer 1 — Go unit tests on pure state functions (the bulk). The design
keeps every behavior as a plain function on app.State (no goroutines, no
widgets), so the regression surface is directly testable on a host in
milliseconds:
- sort (all modes) / search / directory scan — port the existing
browserpackage tests; applyReadResult/applyWriteResult/applyScanResult, including stale/out-of-order results (version counters) — the exact race the old channel design was prone to, now a synchronous decision under test;- autosave debounce and dirty-flag decisions (clock injected, no real timers);
- restore JSON encode/decode; offset validation against mtime; external-change decision table (changed+clean → reload; changed+dirty → prompt; unchanged → open);
- file-size guard (2 MB warn / 5 MB reject).
CI gate: go build && go vet && go test -race ./... on every commit.
Layer 2 — UI code thin by construction. Gio has no headless/test window
(verified in v0.9 and v0.10), so widget-level Go tests are not possible.
Mitigation is structural, not test-based: draw functions are ~30-line wirings
of layout.List / widget.Editor over app.State, and all positioning math
lives inside Gio. The bug class the old UI had (positioning/scroll/clipping)
is deleted, not covered by tests.
Layer 3 — scripted on-device e2e: an in-app selftest. A debug-build flag
(pad --selftest, or launched via am start with an extra) drives the real
state machine through the real draw path:
- create a fixture tree under the app's data dir;
- browser: assert entry count/names in the rendered state;
- open a file: assert editor text == fixture content;
- force the autosave deadline: assert the file on disk is rewritten;
- save restore state, simulate reopen: assert file + caret offset restored;
- touch the file's mtime externally, reopen: assert the reload-prompt decision fires. Each step logs PASS/FAIL; a summary is written to a file the host pulls via adb and asserts on. Deterministic, seconds long, runnable in CI against a farm/one attached device.
Manual-only (unavoidable): swipe typing, autocorrect, caret jump after an
IME commit — adb input text bypasses the IME entirely, so these cannot be
scripted by any tool. They live in a short checklist in the repo, run before
releases, alongside the §9/DoD journey.
10. Definition of done for the whole effort
On a real Android phone: launch → previous file and caret restored → browse a
large directory smoothly → open a real file → swipe-type with autocorrect →
close and reopen, caret where you left it → modify file externally, get the
reload prompt → autosave lands on disk within ~1 s. Repo: ≤ ~3,000 lines of
Go, one channel, zero globals, go test -race ./... green.
Corrections (live repo, ~/pad, as of 2026-08-15)
The live repo has advanced far beyond the snapshot this document analyzed:
real filesystem (internal/io/pool/real/), a built Android APK (JNI +
permissions), a working editor (click-to-cursor, Enter/Backspace, autosave),
and a chunked buffer (internal/editor/chunked_buffer.go) implementing the
spec's "file never fully in memory" approach that this plan assumed would
require fork-level work. Concretely:
VisibleCountis now assigned (internal/editor/state.go:174); the "browser renders 0 rows" finding is stale.- The IME path is half-wired at the raw-op layer:
cmd/pad/main.goconsumeskey.EditEvent/key.SnippetEventand fireskey.SoftKeyboardCmd, but nothing creates the window-level editor-state op (app.EditorState), so IME text commits (swipe, autocorrect) likely still have nowhere to land; raw key events (Enter/Backspace) work. Completing this wiring is the pivotal experiment (see emulator decision below). - The live test suite is RED:
internal/editorandinternal/test/e2efail to build afterNewLogic's signature changed to(pool.FileSystem, string, func(string)). - The A/B fork is now: (A) this plan's widget rebuild, vs (B) finish the live path (complete IME wiring, stabilize chunked buffer, fix tests). The emulator swipe-typing experiment decides.
Decision pending: Path A vs Path B. Until decided, this document is a reference for the widget-rebuild option, not an active plan.