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.
This commit is contained in:
parent
03fb63863c
commit
def4cec498
|
|
@ -1,280 +1,234 @@
|
||||||
# Development Plan: from skeleton to lean, usable app
|
# Development Plan: reach a lean, usable Android text editor
|
||||||
|
|
||||||
Status: DRAFT — written 2026-08-15 against the stale Backup snapshot (May 31).
|
Status: v2, 2026-08-16. Written against the **live** repo `/home/gmp/pad`
|
||||||
The live repo (~/pad) is 47 commits ahead; see the "Corrections" note at the
|
(HEAD 03fb638). v1 (the widget-rebuild plan) is superseded — see §12 for why.
|
||||||
bottom before acting on anything in this document.
|
|
||||||
|
|
||||||
## 1. Decision summary
|
## 1. Decision summary (updated)
|
||||||
|
|
||||||
- **Tooling:** delete the hand-rolled `internal/ui` entirely. Build on Gio's native
|
- **Primary path: B — finish the live custom path, do NOT rebuild on widgets.**
|
||||||
widgets: `widget.Editor`, `widget.List`, `layout.List`, `widget.Button`,
|
The live repo already has the two hardest, most-asset-heavy pieces that a
|
||||||
`widget.RichText`. This removes the entire bug class the project has been
|
rebuild would throw away: a **real filesystem** backend (`internal/io/pool/real/`)
|
||||||
fighting (positioning/scroll/clipping/gesture) and is the *only* path to Android
|
and a **working Android APK** (JNI + permissions, built + signed). It also has
|
||||||
IME input (swipe typing, autocorrect) without writing Android InputConnection
|
a **chunked buffer** (`internal/editor/chunked_buffer.go`) that already implements
|
||||||
code ourselves.
|
the spec's "file never fully in memory" — the thing v1 said would need fork-level
|
||||||
- **Concurrency:** one main goroutine owns all app state and does all drawing.
|
work on `widget.Editor`. So the only blocker to the headline feature (Android IME:
|
||||||
File I/O runs in background goroutines; results come back over a channel and are
|
swipe typing, autocorrect) is a **small, precisely-defined wiring gap** (§4), not a
|
||||||
applied on the main goroutine. No logic goroutine, no unbuffered
|
rewrite.
|
||||||
display-channels, no globals (`editor.TheState`, `browser.currentBrowserState`,
|
- **Concurrency:** keep the existing logic-goroutine + channels model for now. It's
|
||||||
`ui.OpenFile` all die).
|
proven to build and (after this plan) pass tests. The v1 "one main goroutine"
|
||||||
- **Target size limit:** 50 MB files — but stock `widget.Editor` cannot do this
|
simplification is a *later* cleanup, not a prerequisite for usability.
|
||||||
(evidence below). The plan stages this as an explicit decision point with three
|
- **File-size limit:** the chunked buffer targets large files directly; we validate
|
||||||
options; recommendation: ship with stock editor at a documented limit, and do
|
it against a real 10 MB file on-device and set the honest limit from that, rather
|
||||||
windowed shaping as a separate, isolated project if 50 MB is still required.
|
than inheriting `widget.Editor`'s ~0.5 GB/MB wall (§2).
|
||||||
|
|
||||||
## 2. Evidence (verified against gioui.org v0.9.0 sources)
|
## 2. Evidence (verified against gioui.org v0.10.0 sources, the live repo's version)
|
||||||
|
|
||||||
1. **IME works through `widget.Editor` only.** `GioView.java` implements
|
1. **`widget.Editor` is not virtualized** — it shapes the *entire* document per
|
||||||
`onCreateInputConnection` → `GioInputConnection` (commitText /
|
invalidation and keeps a ~200 B/rune in-memory `glyphIndex`. Measured on this
|
||||||
setComposingText / setComposingRegion); `app/ime.go` exposes
|
box: 1 MB ≈ 17 ms/keystroke & ~0.32 GB; 3 MB ≈ 1.7 GB; 4 MB OOM-killed the host.
|
||||||
`editorState.Replace`; `widget.Editor` pushes `key.SnippetCmd` every frame.
|
Planning figure **~0.5 GB of RAM per 1 MB of text**. A phone cannot edit 10 MB
|
||||||
Our current custom UI never receives IME input at all.
|
in `widget.Editor`. This is *why* the live repo went the chunked-buffer route,
|
||||||
2. **The IME snippet is selection-scoped** (`updateSnippet` reads only the
|
and it's the reason v1's "just use the widget" was wrong for large files.
|
||||||
selected range; empty selection → empty snippet at caret). Consequence:
|
2. **The Android IME works through the op layer, not the widget.** The
|
||||||
windowing the editor's backing text does *not* break IME behavior.
|
`window`'s editor state (`app.Window` → `input.EditorState{Selection, Snippet}`)
|
||||||
3. **`widget.Editor` is not virtualized.** `textView.layoutText` shapes the
|
is what `GioInputConnection` (Android's `InputConnection`) reads. It is fed by
|
||||||
*entire* document on every invalidation (each keystroke, IME commit, wrap
|
`key.SelectionCmd` and `key.SnippetCmd` ops tagged with a **focusable** handler.
|
||||||
toggle, width change — *not* on scroll; scroll only moves a clip) and builds
|
A handler becomes focusable by emitting a `key.FocusFilter{Target: tag}` (that's
|
||||||
an in-memory `glyphIndex` of **~200 bytes per rune** (`combinedPos` +
|
all `widget.Editor` does — it never touches `io/input` at all). The live code
|
||||||
`text.Glyph` per rune).
|
already emits `key.FocusCmd{Tag}` + `key.FocusFilter{Target}` and consumes
|
||||||
4. **Measured on this desktop (shaper.Layout + full glyph iteration, = the
|
`key.EditEvent`/`key.SnippetEvent`, so the *skeleton* is present; the stateful
|
||||||
per-keystroke cost of `widget.Editor`):**
|
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).
|
||||||
|
|
||||||
| text size | time/keystroke | peak RAM |
|
## 3. Current state of the live repo (post housekeeping, 2026-08-16)
|
||||||
|---|---|---|
|
|
||||||
| 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 +
|
- `go build ./...` green. **`go test ./...` now green** (was red: `internal/editor`
|
||||||
shaper store + buffer + undo copies, with reallocation churn). Android app
|
and `internal/test/e2e` failed to build after `NewLogic` gained a `path` and
|
||||||
memory ceilings (lmkd) are typically ~1.5–2 GB on 8 GB phones.
|
`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.
|
||||||
|
|
||||||
Therefore: comfortable editing up to **~2 MB**, a hard guard at **5 MB**
|
## 4. The IME wiring gap (verified, testable) — THE central work item
|
||||||
(≈ 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
|
Android IME text (swipe, autocorrect) arrives as `key.EditEvent{Range, Text}`
|
||||||
|
routed to the focused tag. Four things are missing or wrong:
|
||||||
|
|
||||||
| Item | Where | Why |
|
1. **No `key.SelectionCmd` emitted.** Without it the driver's
|
||||||
|---|---|---|
|
`EditorState.Selection.Caret` stays at origin, so the IME doesn't know where the
|
||||||
| Whole custom toolkit | `internal/ui/` (element.go, render.go, layout.go, unit.go, icons/) ~1,500 L | replaced by Gio widgets |
|
caret/selection is → suggestions anchor wrongly and the caret jumps after a
|
||||||
| Worker pool + mock FS service | `internal/io/pool/worker_pool.go`, `mock/` (429 L) | replaced by plain background goroutines |
|
commit. **Fix:** in the editor's draw/update pass, emit
|
||||||
| 10 of 11 task types | `internal/io/pool/task.go` | only Read/Write are ever used, as plain functions |
|
`gtx.Execute(key.SelectionCmd{Tag, Range:{cursor,cursor}, Caret:{Pos,Ascent,Descent}})`
|
||||||
| 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) |
|
whenever the caret moves. The caret's **pixel** position already exists in
|
||||||
| 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 |
|
`internal/ui/render.go:534-554` (the code that draws the caret) — it must be
|
||||||
| Untyped handlers | `browser/handlers.go`, `editor/logic.go` `func(any)` closures + globals | replaced by direct calls on the main goroutine |
|
plumbed into `app.State` (caret pos in px) and read back here. This is the one
|
||||||
| e2e harness | `internal/test/e2e/` | written against the dead architecture; replaced by widget-level tests after Phase 1 |
|
item coupled to the cursor-positioning work, but it reuses the same geometry.
|
||||||
| Stale docs | `element_model.md`, `layout_rendering.md`, `touch.md`, `conflict_resolution.md`, `browser_implementation_plan.md` | describe deleted systems; `architecture.md` rewritten last |
|
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.
|
||||||
|
|
||||||
**Kept (ported, not rewritten):** `browser/types.go` (Entry), `sort.go` + tests,
|
Acceptance (all verifiable headlessly, §9): after an IME commit, the buffer equals
|
||||||
`search.go` + tests, directory scan from `index.go` (minus cache), the wrap /
|
the expected post-replacement text, the caret is at the commit end, and the next
|
||||||
autosave / per-file-offset concepts from `editor/state.go`.
|
frame's `SelectionCmd`/`SnippetCmd` reflect that state. On a real IME, swipe +
|
||||||
|
autocorrect produce clean, non-duplicated text.
|
||||||
|
|
||||||
## 4. Target architecture (single diagram, no channels except I/O)
|
## 5. Phases (Path B)
|
||||||
|
|
||||||
```
|
### Phase 0 — clean baseline (small)
|
||||||
main goroutine (Gio frame loop, owns ALL state)
|
1. Commit the uncommitted JNI/chunk-buffer work.
|
||||||
app.State {
|
2. `go test -race ./...` green (add `-race`; the channel model may surface races —
|
||||||
page: browser | editor
|
fix any found).
|
||||||
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 +
|
### Phase 1 — complete the IME (§4)
|
||||||
`layout.List` over `entries` (virtualized; 1,000-file directory is a
|
Implement items 1–4, with **Go unit tests** for item 3 (range-replace on the
|
||||||
non-issue). Tap → start background read → on result, switch to editor page.
|
chunked buffer is pure and directly testable) and a state-assertion test for
|
||||||
- **Editor page:** `widget.Editor` bound to the file's text; toolbar (filename,
|
items 1/2 (after N frames, `app.State` holds the expected caret/selection and the
|
||||||
wrap toggle, close). Autosave: `dirty` + 1 s debounce in the frame loop,
|
next emitted op set is correct). This is the make-or-break feature.
|
||||||
background write, status shows saved/dirty. Undo/redo in-session: built into
|
|
||||||
`widget.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)`. `SetCaret` auto-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 + `-race` in CI.
|
|
||||||
|
|
||||||
## 5. Phases
|
### 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 0 — deletion-only (no new features)
|
### Phase 3 — large-file validation + honest size limit
|
||||||
1. Delete everything in §3.
|
1. Open a real **10 MB** file via the chunked buffer; verify smooth scroll + edit
|
||||||
2. `cmd/pad` keeps an `app.Window` with a placeholder screen; build green.
|
and measure RAM on-device.
|
||||||
3. Keep passing tests by porting sort/search tests to the trimmed `browser`
|
2. Set the documented limit from that measurement; guard larger files with a
|
||||||
package; delete the on-disk-cache test (feature deleted) — this removes the
|
clear "too large to edit" state (browser can still list/preview).
|
||||||
flaky `TestBuildIndex_CacheInvalidatedOnMtimeChange` by deleting the cache.
|
|
||||||
|
|
||||||
**Done when:** `go build ./... && go vet ./... && go test -race ./...` green;
|
### Phase 4 — the v1 simplifications, now that it's usable (optional, later)
|
||||||
repo ≤ ~2,500 lines of Go; no `func(any)`, no package-level mutable state,
|
Only after the app is usable: replace globals (`TheState`, `ui.OpenFile`) with
|
||||||
no channels except (added in Phase 1) the I/O result channel.
|
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 1 — browser
|
### Phase 5 — hardening
|
||||||
1. `layout.List` browser page: rows = name + modified time; directories
|
Rewrite `doc/architecture.md` to match reality; amend `spec.md` per §7; README
|
||||||
distinguished; tap on dir descends, breadcrumb back; tap on file opens it
|
(build/install recipe); in-repo device test checklist.
|
||||||
(Phase 2 stub: toast "no editor yet").
|
|
||||||
2. Port sort (name/modified, asc/desc) + search filter onto the page.
|
|
||||||
3. 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
|
## 6. File-size decision (re-framed)
|
||||||
rate; sort and search correct (unit tests); no data races.
|
|
||||||
|
|
||||||
### Phase 2 — editor
|
v1 framed this as "accept a limit vs build a windowed editor." The live repo
|
||||||
1. Open file → background read → `widget.Editor` with `SetText`; toolbar with
|
already chose the better option — a **chunked buffer** (only viewport ± window
|
||||||
wrap toggle (maps to `WrapPolicy`) and close (back to browser).
|
chunks resident, async prefetch, dirty-chunk protection, async line index). So the
|
||||||
2. Autosave: debounce, background write, dirty indicator.
|
decision is now: **validate the existing chunked buffer against a real 10 MB file
|
||||||
3. State restore + per-file offset memory + external-change check (§4).
|
(Phase 3) and set the limit from the measurement.** No fork of Gio is on the
|
||||||
4. IME verification on device: swipe typing, autocorrect, multi-line commits,
|
critical path. If the chunked buffer proves out (likely), 10 MB+ editing is in
|
||||||
caret jump after commit.
|
reach without the 0.5 GB/MB wall that kills `widget.Editor`.
|
||||||
|
|
||||||
**Done when:** edit + close + reopen restores file and caret; kill app,
|
## 7. Spec deltas (to write into spec.md)
|
||||||
reopen, same; external modification handled per §4; `-race` clean.
|
|
||||||
|
|
||||||
### Phase 3 — the 50 MB decision (see §6)
|
1. "No practical limits / file never fully in memory" → backed by the chunked
|
||||||
Either (A) document the limit and ship, or (C) build and integrate the
|
buffer; add a measured hard limit from Phase 3.
|
||||||
windowed editor. (B, full chunked streaming, is out of scope — it implies a
|
2. Undo across sessions → dropped (in-session only; chunked buffer does not keep
|
||||||
custom editor and forfeits the IME advantage.)
|
full-document undo copies).
|
||||||
|
3. Add: IME/swipe/autocorrect support (now explicit, delivered in Phase 1).
|
||||||
### Phase 4 — hardening
|
4. Add: external-change detection as the conflict policy (mtime compare on open
|
||||||
1. Rewrite `doc/architecture.md` to match reality; amend `spec.md` per §7.
|
+ on resume; changed+clean → reload, changed+dirty → Keep/Reload prompt).
|
||||||
2. `README.md`: what it is, how to build/install (Go 1.24, gioui.org, NDK).
|
|
||||||
3. 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)
|
|
||||||
|
|
||||||
1. "No practical limits / file never fully in memory" → "files up to N MB
|
|
||||||
(measured); larger files openable in browser, not editable."
|
|
||||||
2. Undo across sessions → dropped (in-session undo/redo is provided by the
|
|
||||||
editor widget).
|
|
||||||
3. Add: IME/swipe/autocorrect support (was an implicit requirement, now
|
|
||||||
explicit and delivered).
|
|
||||||
4. Add: external-change detection behavior (§4) as the conflict policy,
|
|
||||||
replacing `conflict_resolution.md`.
|
|
||||||
|
|
||||||
## 8. Non-goals (v1)
|
## 8. Non-goals (v1)
|
||||||
|
|
||||||
- File-system watcher / live browser refresh (re-scan on return to browser).
|
- File-system watcher / live browser refresh (re-scan on return to browser).
|
||||||
- Multi-file views, tabs, split.
|
- Tabs, split view, search-in-file, syntax highlighting, diff/merge.
|
||||||
- Syntax highlighting, search-in-file, diff/merge UI.
|
- Desktop/other platforms (Android-first; window `390×844` dp).
|
||||||
- Desktop/other platforms (Android-first; window sized per existing
|
- Rewriting on `widget.Editor` (v1 plan) — kept only as fallback (§12).
|
||||||
`unit.Dp(390) × unit.Dp(844)`).
|
|
||||||
|
|
||||||
## 9. Testing strategy (replaces `internal/test/e2e`)
|
## 9. Testing strategy
|
||||||
|
|
||||||
The old e2e harness is deleted: it existed to test the channel/frame
|
The **existing** e2e harness is **kept** (it builds and passes now) and extended —
|
||||||
choreography, which no longer exists. E2E regression still works, in three
|
v1's "delete the harness" is wrong for Path B. E2E regression works in three layers:
|
||||||
layers:
|
|
||||||
|
|
||||||
**Layer 1 — Go unit tests on pure state functions (the bulk).** The design
|
### Layer 1 — Go unit tests on pure logic (the bulk)
|
||||||
keeps every behavior as a plain function on `app.State` (no goroutines, no
|
- Port/keep `browser` sort/search/scan tests; keep `editor` chunked-buffer,
|
||||||
widgets), so the regression surface is directly testable on a host in
|
cursor, autosave tests.
|
||||||
milliseconds:
|
- **New:** range-replace on `ChunkedBuffer` (§4.3) — pure, directly testable; the
|
||||||
- sort (all modes) / search / directory scan — port the existing
|
exact corruption bug becomes a regression test.
|
||||||
`browser` package tests;
|
- **New:** IME state transitions — after an `EditEvent{Range,Text}`, assert
|
||||||
- `applyReadResult` / `applyWriteResult` / `applyScanResult`, including
|
buffer + caret; assert the emitted `SelectionCmd`/`SnippetCmd` reflect state.
|
||||||
**stale/out-of-order results** (version counters) — the exact race the old
|
- **New:** restore JSON round-trip; offset-vs-mtime validation; external-change
|
||||||
channel design was prone to, now a synchronous decision under test;
|
decision table; file-size guard.
|
||||||
- autosave debounce and dirty-flag decisions (clock injected, no real timers);
|
- CI gate: `go build && go vet && go test -race ./...`.
|
||||||
- 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 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 2 — UI code thin by construction.** Gio has no headless/test window
|
### Layer 3 — scripted on-device e2e (emulator now available)
|
||||||
(verified in v0.9 and v0.10), so widget-level Go tests are not possible.
|
The in-app selftest drives the **real state machine through the real draw path**
|
||||||
Mitigation is structural, not test-based: draw functions are ~30-line wirings
|
(browser entries → open file → edit → autosave deadline → reload-prompt decision),
|
||||||
of `layout.List` / `widget.Editor` over `app.State`, and all positioning math
|
logging PASS/FAIL to a file the host `adb pull`s and asserts.
|
||||||
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
|
### 9.3 Emulator observation layer (this 16 GB VM, KVM verified; agent has NO vision)
|
||||||
(`pad --selftest`, or launched via `am start` with an extra) drives the **real
|
Gio renders into one GL surface, so Android's view hierarchy exposes nothing about
|
||||||
state machine through the real draw path**:
|
our UI. The debug loop is data-based — *more* precise than pixels for this codebase:
|
||||||
1. create a fixture tree under the app's data dir;
|
1. **State-dump debug flag** (add in Phase 1): debug builds write `app.State`
|
||||||
2. browser: assert entry count/names in the rendered state;
|
(browser entries, editor text length, cursor px, scroll, dirty, row geometry)
|
||||||
3. open a file: assert editor text == fixture content;
|
as JSON to the app data dir on a magic tap or 2 s interval; host `adb pull`s
|
||||||
4. force the autosave deadline: assert the file on disk is rewritten;
|
and asserts. Primary "eyes."
|
||||||
5. save restore state, simulate reopen: assert file + caret offset restored;
|
2. **logcat** — Go panics + our logs.
|
||||||
6. touch the file's mtime externally, reopen: assert the reload-prompt
|
3. **`dumpsys gfxinfo <pkg>`** — per-frame timing/jank for the 60 fps claims.
|
||||||
decision fires.
|
4. **Screenshot → PIL color histogram / tesseract OCR** — coarse on-screen checks.
|
||||||
Each step logs PASS/FAIL; a summary is written to a file the host pulls via
|
5. **File diffs** via `adb pull` — autosave/restore correctness.
|
||||||
adb and asserts on. Deterministic, seconds long, runnable in CI against a
|
6. **Driving** — `input tap/swipe/keyevent/text`, `am start/force-stop`,
|
||||||
farm/one attached device.
|
`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.
|
||||||
|
|
||||||
**Manual-only (unavoidable):** swipe typing, autocorrect, caret jump after an
|
## 10. Definition of done (whole effort)
|
||||||
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 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.
|
||||||
|
|
||||||
On a real Android phone: launch → previous file and caret restored → browse a
|
## 11. Risks / watch-items
|
||||||
large directory smoothly → open a real file → swipe-type with autocorrect →
|
- **§4.1 caret plumbing** is coupled to the cursor-positioning geometry; if that
|
||||||
close and reopen, caret where you left it → modify file externally, get the
|
geometry is itself buggy, the IME will inherit it. Mitigation: the state-dump
|
||||||
reload prompt → autosave lands on disk within ~1 s. Repo: ≤ ~3,000 lines of
|
reports caret px, so we can assert it against expected values in tests.
|
||||||
Go, one channel, zero globals, `go test -race ./...` green.
|
- **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
|
||||||
## Corrections (live repo, ~/pad, as of 2026-08-15)
|
`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
|
||||||
The live repo has advanced far beyond the snapshot this document analyzed:
|
(real FS, APK, and the chunked buffer that `widget.Editor` cannot do), and (b) the
|
||||||
real filesystem (`internal/io/pool/real/`), a built Android APK (JNI +
|
Android IME is reachable from the op layer with only the §4 gap — so the "free IME"
|
||||||
permissions), a working editor (click-to-cursor, Enter/Backspace, autosave),
|
that motivated the rebuild is actually ~4 small wirings away on the existing code.
|
||||||
and a **chunked buffer** (`internal/editor/chunked_buffer.go`) implementing the
|
**Path A (widget rebuild) remains the fallback if the Phase 1 IME wiring proves
|
||||||
spec's "file never fully in memory" approach that this plan assumed would
|
fragile on-device** (e.g., the caret/snippet plumbing drags in a long tail of
|
||||||
require fork-level work. Concretely:
|
cursor-positioning bugs that are cheaper to not own). The emulator (Phase 2) is the
|
||||||
|
instrument that makes that call.
|
||||||
- `VisibleCount` is 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.go` consumes
|
|
||||||
`key.EditEvent`/`key.SnippetEvent` and fires `key.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/editor` and `internal/test/e2e` fail
|
|
||||||
to build after `NewLogic`'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.
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user