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.
doc/README.md §Screen coordinates: add the complete 4-hop pipeline
(screen px -> app dp -> text-local dp -> window-relative glyph byte ->
absolute file byte), the tapLocalY sub-line-remainder rule, the
glyphBase window-base rule, and the selection/menu/IME coordinate
spaces (absolute file bytes vs window-relative TextField selection vs
app-dp drag events vs menu-local MenuItem offsets). Cross-reference
the architecture.md invariants.
doc/architecture.md §8: state that CheckGestures input-event positions
are app-local Dp (same space as element Regions / MenuRect) and that
logic converts them to text coordinates.
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.
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.
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.
- Update block diagram to show correct channel/mutex flow
- Frame receiver now calls w.Invalidate() inside the mutex lock
- Logic goroutine now waits on batched []InputEvent and configChan
- Main loop batches events and sends outside the lock
- Remove select-with-default batching from logic
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>