The screen->line tap mapping only needs the sub-line scroll remainder
(tapLocalY adds r, never the full scroll) because the visible glyph
layout is window-relative and IMEWindowStartByte re-anchors it to the
file. That holds for every scroll offset IF the window start line
k=floor(s/lh) and the sub-line remainder r stay consistent.
Property test (4000 random scroll/tap pairs, asserting against an
independent drawn-geometry ground truth, not the tap code's own math)
exposed a real bug: int(s/lh) in the Dp float32 domain rounds the
quotient to nearest and can round UP across an integer boundary while
the float64 mod still reflects the line below. In a sub-pixel-wide
band of scroll offsets the window started one line too far while the
draw shift lagged by one line - the whole rendered window (and every
tapped line) shifted by one.
Fix: one shared float64 floor decomposition (scrollDecompose) used by
the window start (visibleByteRangePrecise/Estimate), the renderer's
sub-line shift (visibleScrollOffset), the tap mapping (tapLocalY), and
chunk prefetching. Also route the shaper's line height (previously
ignored by visibleByteRangePrecise) through VisibleByteRange.
On-device cross-check: at s=4246.9 (r=13.3) and s=4210.7 (r=10.7),
taps on visually identified lines typed markers that landed on exactly
those lines in the file on disk; profiler ScrollDP, screenshot,
formula, and disk all agreed.
Docs: scroll decomposition invariant in architecture.md §6.2, pipeline
hop 2 in doc/README.md, Phase 10 in development_plan.md.
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.
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.
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.
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.
- 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)
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).
- 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
- Fix cursor vertical alignment and ensure it respects ScrollOffset.
- Fix scrolling in editor page by correctly registering scroll interactions in the renderer.
- Ensure cursor is clipped to the editor text area to prevent drawing over status bars.
- Replace gtx.Event(nil) with key.Filter{Focus: focusedID} and
key.FocusFilter{Target: focusedID} so Gio correctly routes key
and edit events to the focused editor text field.
- Convert EditorState.CursorPosition (byte offset) to line/column
coordinates for accurate cursor rendering.
- Add debug logging in HandleKeyDown and HandleCursorMove.
- Remove currentBrowserState global and SetBrowserState from browser package
- Browser now receives sortHandler as a parameter instead of using global state
- Fix sort mode toggle: reload pages after clearing cache (was showing 'Loading...')
- Add String() methods to all UI element types for debugging
- Add e2e tests for sort mode functionality (5 tests)
The editor now owns all state; the browser is a pure function receiving
state and handlers through parameters.
- Add detailed §15 lazy loading plan with 7 implementation steps
- Update Phase 2 checklist with specific tasks
- Add E2E test harness for browser files
- Refactor browser layout and handler structure
- Add .gitignore for common patterns
WIP: Actual lazy loading not yet implemented.
- Added four sort modes: Date ↓, Date ↑, Name ↑, Name ↓
- Sorting happens only when sort mode changes, not every frame
- Sorted entries are cached in SortedEntries field
- Search query changes trigger re-filtering and re-sorting
- Scroll offset resets on sort change