Density (pure hi-DPI) was already scale-free: all bookkeeping is in
density-dp and the scale enters only at the px<->dp boundary. But Android
also has a second axis, the user font-size setting (PxPerSp = fontScale *
PxPerDp), and the shaper draws baselines in sp. At a non-default font
scale the rendered line pitch is 16.8*fontScale dp while every logic-side
consumer used the raw 16.8 dp: taps would misplace by up to (fontScale-1)
viewportfuls of lines and scroll clamping would stop short of the bottom.
- ScaleEvent.FontScale + Frame.FontScale closed loop (main reads
gtx.Metric, logic tracks it in State.fontScale).
- EffectiveLineHeight()/EffectiveLineHeightAt(): the font-scale-applied
line height, now used by every consumer (window start, sub-line
remainder, tap mapping, scroll clamp, page size, cursor vertical move,
menu position, chunk-prefetch fallbacks).
- Renderer: GlyphLayout.LineHeight, caret, selection handles, and
highlight all use the scaled ascent/line-height from gtx.Metric.
- font_scale_test.go: 2000-pair tap property test at fontScale 1.3 with
the glyph layout fabricated at the scaled pitch (independent ground
truth), plus EffectiveLineHeight unit test.
- On-device: tap markers landed on exactly the tapped line at font_scale
1.3 (fsline060/080/081) and 0.8 (fsline039); rendered pitch measured
57/35/44 px at 1.3/0.8/1.0 (matches 16.8*fs*2.625); settled-position
window start k = floor(s/lh_eff) verified against the visible top line.
- Docs: architecture.md 6.2 font-scale axis, README two-scale note +
profiler 2s flush staleness note, development plan v10 Phase 11.
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 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.
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.
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.
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.
- 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).
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).