Implement the Android-native touch selection model, verified on-device:
- long-press selects the word under the finger (blank -> caret + paste-only
menu); double-tap selects the word; drag handles resize the selection,
drag the highlighted body to move it; floating menu offers copy/cut/paste
(selection) or paste (bare caret), closing on any item tap.
- Renderer reports finger positions (app-local Dp) as tap/double-tap/
long-press/selection-drag events; the logic goroutine owns all geometry
(EditorRegion, menu rect, hit-testing, handles) and the renderer only
draws the frame snapshot.
- Long press: 400 ms still-press on the editor, cancelled by movement
(non-grabbing raw pointer probe) or by a scroll/handle grab. The main
loop keeps invalidating while a press is pending (Gio renders on demand;
a stationary finger produces no frames).
- Clipboard crosses the goroutine boundary via buffered channels
(clipboardSetChan/pasteReqChan logic->main, pasteChan main->logic);
main executes the Gio ops and, on Android, invalidates after ReadCmd
because a queued transfer.DataEvent schedules no frame of its own.
Renderer fixes found while validating on-device:
- clickReg was stored by value in a map; range yielded copies so per-frame
press bookkeeping (long-press state) was silently discarded. Now pointers.
- On Android a tap's press+release arrive in the same frame and
gesture.Click/Drag return one event per Update call; without draining
each gesture's queue every frame the release was lost on an idle window
and every menu tap was swallowed (needed a second tap to 'rescue' it).
Click and drag loops now drain to exhaustion (scroll already does).
- pointer.Filter queries must name Kinds: a zero-kinds filter matches
nothing (the press-probe query was dead).
- Menu.Draw offsets items by the menu origin; the clippable drawElement
branch registers SelDrag (handles now draw for the TextField).
Tests: internal/editor/touch_selection_test.go (word range, long-press,
double-tap, tap/menu guards, handle drags, menu actions, selection edits)
and internal/test/e2e/touch_selection_e2e_test.go; full suite green under
-race. Docs: spec.md §2.2 + §7, architecture.md §6.3a, development_plan.md
Phases 8-9.
Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.
Android key input (the blocker found during on-device validation):
Gio v0.10 on Android (a) drops modifier state in the JNI bridge and
(b) wraps plain arrow-key presses in input.SystemEvent for focus
navigation, so arrow keys never reached the editor. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.
Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
involving newlines left it permanently inconsistent. Replaced with
newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
straddling a chunk boundary). Now rune-granular.
Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).
Full suite green under -race; on-device verified.
The chunked buffer was the right structure for this workload, but the
implementation let the edited chunk grow without bound (the type comment
even admitted 'not rebalanced'): sustained typing at one spot made that
chunk grow monotonically, so each subsequent insert copied the whole
grown chunk -- quadratic total copy work for sustained typing.
Insert now splits any chunk that grows past 2x the target size in half,
and the empty-buffer path chunks a large first paste directly instead of
creating one oversized chunk. The per-edit copy cost is now bounded by
O(chunkSize) by construction. The split cut is an arbitrary byte offset
(like SetContent boundaries): chunk edges may fall inside multi-byte
sequences, which is fine because readers reassemble whole line-aligned
windows from chunk bytes.
Shrunken/empty chunks from deletes are left in place: chunk count never
grows with deletes, all readers walk actual lengths, and removal would be
pure churn with no reader-side benefit.
No behavior change visible to readers (Content/FullContent/LineIndex all
walk actual chunk lengths); covered by three new tests: sustained-typing
chunk-size invariant + content, large paste into empty buffer, and
content integrity across newly created split boundaries. Full suite green
under -race.
The coordinate-space confusion (screen px 1080x2400 for input tap/
screencap, display px 900x2000 for reading PNGs, app-local px offset by
the 128px status bar, pt at density 2.625) cost real debugging time.
Wrote it down in doc/README.md as the permanent reference:
- the four spaces + conversions (display x1.2 = screen; screen Y-128 =
app-local; /2.625 = pt)
- the practical tap rule: read (x,y) off the PNG, x1.2, input tap --
screenshot px ARE screen px, no 128 offset for tap-what-you-see
- fixed geometry (status/nav bars, Gboard rows, editor region in pt),
with re-measure guidance
- tap-test pitfalls (first-tap-swallowed, first input text can fail,
wrong-place text = cursor/selection suspect not tap math, don't
memorize drifting content positions)
All keyboard row values re-measured from a fresh screenshot and verified
live: tapping the Q key at (62, 1712) and backspace at (990, 2020) both
landed first try (file content confirmed). This also exposed that the
old notes had mixed display-px row values with screen-px key values in
the same list -- exactly the confusion this section eliminates.
Also fixed the stale Phase 6 'letterboxed window' note in
development_plan.md (window is full-screen; only the 128px status-bar
offset is real; content positions drift; the OpenFileChan logcat line
was removed) and pointed emu.sh's tap help at the x1.2 rule.
Replaces the ad-hoc adb/nohup sequences used throughout verification with
one self-contained script: up/down/status, app start|stop|restart, shot,
log, tap, type, cmd (one-shot editor debug command), perf on|off|pull,
push/pull of Notes test files. Encodes the machine-local paths
(Notes/, PadPerf/ profiler + cmd file) in one auditable place.
Reliability findings baked in (each hit during testing):
- The AVD auto-saves a 'default_boot' instant-boot snapshot on clean
shutdown; a corrupted one makes the emulator segfault during restore
(device never comes online). up() detects it (process death / no adb
device within PAD_ONLINE_TIMEOUT) and falls back to a cold boot with
-no-snapshot-load; the next clean down() re-saves a good snapshot, so
this self-heals. down() is the only sanctioned stop (clean kill saves
the snapshot; SIGKILL corrupts it).
- The emulator process identity is ambiguous (launcher vs
qemu-system-*-headless), and crashpad/netsimd children carry the AVD
name in their command lines. Liveness and kill therefore use a tight
signature: comm matches qemu-system-* AND cmdline contains ' -avd
<AVD>' (never a broad pkill -f pattern — one matched and killed the
calling shell during testing).
- Before any launch, orphaned qemu instances are cleared (they hold the
AVD lock and make new launches fail silently); before starting a
replacement, the old one must be fully gone (lingering device/lock
causes false success).
- Timeouts: short online window for the snapshot attempt, full
BOOT_TIMEOUT for the fallback cold boot (~20 s).
Tested: idempotent up, 10 s snapshot-restore boot, forced fallback
(30 s end-to-end, verified correct instance), orphan cleanup, perf/cmd/
push/pull/shot/log on-device. doc/README.md now documents it.
The Android build recipe lived at /tmp/build_pad.sh and depended on
/tmp/apktool.jar; /tmp gets wiped between sessions (both were lost once
mid-project and had to be rebuilt). Move the recipe into the repo as
scripts/build_emu.sh:
- checks prerequisites (java, gogio, apksigner, adb, debug keystore) with
actionable error messages
- auto-downloads apktool v3.0.3 to the stable ~/android-sdk/tools/ if
missing (never /tmp)
- uses a mktemp work dir cleaned via trap; no /tmp clutter
- --no-install flag; verifies an adb device is present before installing
- verifies the permission injection actually took effect
Tested end-to-end (build + install + app relaunch) and idempotent on
re-run. doc/README.md and development_plan.md now point here.
Cover the base-offset conversion in HandleEnd (the path that slices the full
file content by the end-of-line offset), mirroring the tap-to-position
regression test.
Add the invariant that GlyphLayout offsets are relative to
IMEWindowStartByte and that cursor positioning must add/subtract the window
base. Record the second tap-to-cursor fix (base offset) alongside the earlier
tapLocalY fix in the development plan.
The GlyphLayout is shaped from the visible window alone, so its
ByteOffsets are relative to the window start (IMEWindowStartByte). But
SetCursorFromPoint, HandleHome, HandleEnd and HandleVerticalCursorMove
treated them as absolute file offsets. On a large file scrolled deep, the
cursor was set to a window-relative offset (near the file start), so the
next EditorLayout computed visibleCursorPos = CursorPosition - start as a
small/negative value and the IME selection snapped to the window top
(regardless of where the user tapped).
Add glyphBase() and convert at the four cursor boundaries: subtract the
base before searching the window-relative offsets, add it back before
storing the absolute CursorPosition. For small files (window == whole
file) the base is 0 and the change is a no-op.
Add TestTapToPosition_WindowBaseOffset, a regression test that sets a
non-zero IMEWindowStartByte and asserts the cursor lands at base + window
byte (fails on the pre-fix window-relative assignment).
Verified on-device: with a 6 MB file scrolled to ~line 1200, a tap now
places the cursor on the tapped line (typed text lands ~16 lines below the
window top), where before it always landed on the window top.
Drop the log.Printf/fmt.Printf traces that fired on every config/input/
click/scroll/open/worker-result during IME and tap debugging. These were
noise (and some were commented out). Error, limit, and recovery logs are
kept; the default-off profiler and gated IME debug remain untouched.
Tapping in the editor repositions the cursor, but it was never actually
verified (the old unit test only checked in-bounds/no-panic). On-device
verification found that on a large file scrolled deep, the cursor clamped to
the bottom of the viewport regardless of tap position.
Root cause: the tap handler computed the tap's text-local Y in content space
(pt.Y - region.Y + full ScrollOffset) and passed it to SetCursorFromPoint,
whose visualLine = y/lineHeight then produced a huge content-line number
(e.g. 91,640). But the GlyphLayout is window-relative (layout.Y==0 is the top
of the visible window), so the number far exceeded the window's line count and
clamped to the last group (bottom line). The Phase 3 windowing refactor
introduced the windowed layout without updating the tap handler.
Fix: tapLocalY() converts the tap Y to window-relative space by adding only the
sub-line remainder (ScrollOffset mod lineHeight), never the full scroll.
Extracted as a named helper so it is unit-testable. Added two regression tests
that fail on the pre-fix formula (cursor clamps to the bottom line) and pass on
the fix. Verified on-device: taps now map linearly across the viewport.
Also removed the per-tap/per-glyph log.Printf debug lines in SetCursorFromPoint.
internal/perf: single-goroutine profiler (no locks). When enabled by the
/storage/emulated/0/PadPerf/enable marker it records one CSV row per logic
frame (seq, ms, frame delta, page, scroll Dp, max-scroll Dp, total lines,
visible byte range), logs a rolling ~1/s summary, and on Stop reports
nearest-rank p50/p90/p99/max. Flushes per row-batch but never fsyncs per
flush (avoids periodic hitches in the logic path).
editor: PerfRecord package-level hook (nil when off) + ProbeRecord;
Logic.emitFrame() now centralizes every frame emission so the profiler sees
each logic frame exactly once, on the owner goroutine. State gains
VisibleStart/VisibleEnd so the probe can confirm shaping stays
viewport-bounded.
logic: optional debug cmd poller (off by default) watches <dir>/cmd as a
one-shot file (top/bottom/frac <0..1>/dp <int>) and the owner applies a
clamped [0,MaxScroll] jump + frame. Enables deterministic large-offset scroll
tests without pixel taps.
main: wires the profiler when the marker file exists; logs present-fps
every 2s; stops the profiler on Destroy.
docs: README package inventory (add internal/perf); architecture §11
profiler facility; spec §5 invariant 7 (scroll always clamped to
[0,maxScroll]) + §6 measured rows; development_plan v5 + Phase 6 results.
Verified: go build/vet + full -race green. On-device 10 MB file:
logic-frame cadence flat across offsets 0.02->1.0 (no large-offset
degradation), visible byte range <=4.3 KB at every offset, clamping exact
across 2->130,955-line files, PSS plateaus ~250 MB (bounded, no leak);
profiler overhead negligible.
TextField.Draw re-pushed key.SnippetCmd and key.SelectionCmd every frame
while focused. Re-pushing an unchanged snippet resets the IME's composition
and caret, so commits arriving faster than a frame interleaved with those
resets and desynced the cursor (garbled/duplicated text).
Mirror widget.Editor's updateSnippet/selection gating: track the last-pushed
snippet and caret in the main-owned Renderer (keyed by field ID, reset on
focus (re)gain), and only emit the ops when they actually change. A fresh
push is forced whenever the field (re)gains focus so the IME always starts
from a known state. Also drop a leftover per-frame 'Focused:' debug print.
On-device: rapid back-to-back commits (0.08-0.1s cadence, faster than a
frame) now land cleanly - type HELLO, append+backspace, and a
browser->editor navigation round-trip all produce exact content.
go test -race ./... green.
Root cause of the ~1GB 'Unknown' memory on large files: with word wrap
on (the default), VisibleByteRange used the previously-shaped
GlyphLayout.VisualLineStarts to bound the visible byte range. That layout
only covers the visible window (~50 lines), not the whole document, so
whenever the viewport's line count exceeded the window's visual-line count
the range fell back to end=fileLen. The text shaper then laid out the ENTIRE
file each frame; its internal line/glyph buffers grow to the largest layout
ever shaped and are never released (document.reset() keeps the backing cap),
so memory ballooned to the file size (~1.1 GB for 10 MB) and OOM-killed the
process under scroll.
Fix: always derive the visible range from the real-line LineIndex (or the
heuristic estimate before it is ready). Word wrap needs no separate path:
each real line yields >= 1 visual line, so shaping viewportHeight/lineHeight
real lines always fills the viewport, and the range can never collapse to the
whole file.
Also fixed the pre-existing LineIndex storage mismatch: EditorLayout read
TheState.Editor.LineIndex (never set; SetLineIndex had zero callers) for
maxScroll and scrollByteOffset, so it always used the huge pre-index estimate.
It now reads cb.LineIndex, the single source of truth; the dead
EditorState.LineIndex field and SetLineIndex are removed.
On-device (emulator/SwiftShader), a 10 MB file now uses ~150 MB PSS / ~230 MB
RSS at steady state and stays flat under scroll (was 1.13 GB PSS / 1.5 GB RSS,
growing, OOM-killed). go test -race ./... green.
- Viewport: swallow the opening tap/scroll (justOpenedAt window) and
re-clamp ScrollOffset to [0,MaxScroll] in EditorLayout so a short file
never opens past its content (blank viewport).
- Chunked buffer: replace the fixed i*chunkSize slot model (which drifted
after length-changing edits and could re-read stale disk for shifted
tail chunks) with an ordered chunk slice + prefix-sum byte offsets.
In-range files now load fully on open (SetContent), so there is no lazy
load and no stale-disk re-read. Edits splice only the affected chunk(s).
- Size guard: files > MaxEditableFileSize (50 MB) show a 'too large to
edit' notice instead of loading; the browser still lists them. Edit
handlers (KeyDown/ReplaceRange) are no-ops for too-large files.
- Tests: rewrite chunked_buffer_test.go for prefix-sum correctness (insert/
delete across chunk boundaries, rune->byte after edit); fix the large-file
e2e expectation to the shift-correct ground truth.
- IME commit path validated on emulator (Gboard/API 35): single-char,
multi-char (human cadence), deletion (deleteSurroundingText->EditEvent),
and Unicode all commit correctly with cursor in sync; autosave persists.
- Build recipe scripted (gogio + apktool MANAGE_EXTERNAL_STORAGE + apksigner).
- Bugs: (1) open-file crash via Termux bridge -> fixed (c9c0d47); (2) viewport
opens at EOF because the opening tap leaks into the editor (open, Phase 3);
(3) rapid synthetic IME commits desync via per-frame snippet re-push (edge).
- ui.OpenFile now calls the in-app editor OpenFile(path) instead of the
external openfunc (Android Termux bridge). The old wiring sent every
browser-row tap through the Termux bridge, which crashed on Android 7+
with FileUriExposedException (file:// Intent URI) and bypassed the editor.
- OpenFile no longer invokes TheState.open; it is retained as a dormant
external hook for a future 'open externally' action.
- Add imeDebugLog (off by default) verbose per-commit IME logging in
HandleReplaceRange, plus currentEditorText helper, to aid on-device
IME commit/cursor-sync debugging.
Finish Phase 1 items 1, 2, 4, on top of the range-handling done in 9b78219:
- TextField.Draw now emits, when focused:
* key.InputHintOp{HintText} (item 4: enable text keyboard/autocorrect)
* key.SnippetCmd (the visible window as the snippet, Range {0,len})
(item 2: swipe/autocorrect source)
* key.SelectionCmd (caret, window-relative rune index)
(item 1: IME selection sync)
The snippet is the visible window (not the whole file), so the IME treats
the window as the document and reports EditEvent.Range window-relative.
- HandleReplaceRange now resolves the window-relative range against
IMEWindowText and offsets by IMEWindowStartByte to address the buffer
(string and chunked paths). Falls back to the whole buffer when layout
has not set the window (tests).
- EditorState gains IMEWindowStartByte / IMEWindowText, set during layout.
- Add runeCount helper (utf8 leading-byte scan) in the ui package.
- Fix a data race in three editor e2e/integration tests: they called
OpenFile from the test goroutine while the logic goroutine ran layout;
now wrapped in withState so the state write happens on the owner.
Tests: ime_range_test.go gains windowed-path coverage (string+chunked).
go build, go test, go vet, and go test -race are all green.
The IME commit path (swipe-to-type, autocorrect replacement) sends a
key.EditEvent with a Range that the editor ignored, causing the replaced
region to be duplicated. Add Logic.HandleReplaceRange which deletes the
rune range [start,end) and inserts text, converting rune indices to byte
offsets (string path: utf8.DecodeRuneInString; chunked path: leading-byte
scan). HandleKeyDown now routes key.EditEvent through it.
Also fixes two pre-existing ChunkedBuffer correctness bugs the tests
exposed:
- Delete mis-computed the per-chunk end using a shrinking 'remaining'
instead of the absolute end, corrupting multi-chunk deletes.
- FullContent derived its chunk bound solely from fileLen, truncating
in-memory chunks grown past the old bound by an insert.
Adds ime_range_test.go covering insert/replace/delete, unicode, chunked,
and swapped bounds. go test -race ./... green.
Frame now carries view state (scale/focus/query) to the main goroutine,
which reads only the frame-receiver-stored snapshot. Renderer owns Gio
widget editors (registered by ID) and the draw scale. Autosave timer
sends a token to the logic goroutine instead of touching state;
Shutdown waits for the owner to exit before FlushAll.
Tests access state only through owner-side Inspect/WithState helpers
(harness + in-package). Fixed double-Harness.Run race in two e2e tests,
made TestWorkerPool_PriorityPreemption deterministic, and raised the
lazy-loading test timeout that was too short under -race.
go test -race ./... is now green.
- JNI: open_file_in_termux via ACTION_SEND intent (text/plain + file:// uri),
global context ref kept from registerFragment
- impl_android.go: OpenFile(path) attaches current thread if needed
- NewLogic takes openfunc; State.open + ui.OpenFile now func(string)
- ChunkedBuffer.VisibleByteRange: word-wrap path using
GlyphLayout.VisualLineStarts (+byteOffset, lineHeight, visual index param)
- GlyphLayout gains LineHeight; drawWrappedText records VisualLineStarts
- WordWrap default true; State.ByteOffset tracks first visible line
- types: VisualLineIndex
- scroll_fix_test.go (new)
- debug prints left in place (WIP; cleanup in later phase)
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.
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).
When directory navigation fails (e.g., permission denied on Android),
the browser now restores the previous path instead of staying in a
broken empty state. NavigateTo saves the current path to History, and
handleError restores it and re-builds the index for the previous
directory when a BuildIndexTask fails.
- Initialize browser path to the actual startup directory instead of '/'
- Improve '..' navigation to be relative to the filesystem root
- Fix path duplication issues by avoiding redundant absolute path joining
- Fix ChunkedBuffer.Insert to always update file length.
- Fix UpdateLineIndexAfterEdit to not shift the first line offset.
- Remove redundant ReadFileTask dispatch in HandleBrowserTap.
- Add end-to-end test for typing at start of buffer.
- Add WriteFile method to mock filesystem (non-atomic path, creates files)
- Implement WriteFileAtomic in mock with temp file + rename pattern
using .tmp/ directory, matching real filesystem semantics
- Update WriteFileTask.Execute() to use WriteFileAtomic
- Update FlushAll() to use WriteFileAtomic
- Fix mock to create files on write (matching os.WriteFile behavior)
- Update tests to match new semantics (create-if-not-exists)
- Define pool.FileSystem interface in internal/io/pool/filesystem.go
- Move DirEntry interface to internal/io/pool/types/types.go to break
the pool <-> mock import cycle
- Implement real.FileSystem with atomic writes (temp file + os.Rename)
- Update mock.FileSystem to satisfy pool.FileSystem interface
- Update worker pool tasks to accept pool.FileSystem interface
- Update main.go to use RealFileSystem by default, with -root flag
- Update all browser/editor/test references to types.DirEntry
- Rewrite cursor_test.go to use the current GlyphLayout-based API
instead of the deleted byteOffsetToLineCol function
- Add guard for empty/missing layout slices in SetCursorFromPoint
to prevent index-out-of-range panic on empty documents
- Add missing Advance field to e2e test GlyphLayout initialization
- Add cmd/pad/pad binary to .gitignore