The tree was formatted with an older gofmt; go1.27's gofmt additionally
wants: EOF exactly one newline (no trailing blank lines), imports sorted
alphabetically within a block, mixed-precedence binary expressions
re-spaced for grouping ((a+b)/c), single-field composite literals
un-aligned, adjacent one-line method signatures aligned, and one-line
bodies containing a compound statement expanded. Applied repo-wide
(31 files under internal/); pure formatting, no semantic changes —
build and the full test suite pass.
Two feature bodies accumulated in the working tree:
1. Pinch to change the app font size, continuously (no snapping):
- internal/ui/pinch_tracker.go: logic-free touch state machine.
Two-mover formation (the resting palm can land first or last;
movement is the only signal valid for both), pair = the mover
pair whose distance changed most, baseline = press distance
(formDist), lazy pending releases, survivor-scroll forwarding
after a pair break. Robust to ~1 fps frames: a whole pinch can
land in one drain (formDist/brokeFactor/lazy releases).
- render.go: pinch probe (raw pointer events) + grab lifecycle so
the pair is exclusive (scroll sees nothing of the pair) and the
survivor's finger keeps working as a scroll after the pinch.
- state.go/logic.go/session.go/frame.go: app-local float font
scale, content-point pin (buffer byte + offset from baseline,
not a layout point, so rewrap keeps the same character under
the center), restore/font pins, session persistence.
- pinch_test.go, pinch_font_test.go, tag_identity_test.go,
real_draw_probe_test.go: unit + real-Renderer/real-Router tests.
2. Soft keyboard must not shift content:
- Root cause: gioui.org/app calls Router.RevealFocus on any frame
the viewport shrinks (IME open under adjustResize) and
synthesizes a pointer.Scroll nudge aimed at the focused field's
stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
content jump.
- Fix: main.go flags the shrink frame; render.go drains that one
synthetic scroll for the gesture's tag before Update (scroll-
range clamping cannot work: the router UNIONs ranges across
frames). Finger scroll (pointer.Drag) and the flinger are
untouched. reveal_focus_drain_test.go reproduces RevealFocus at
the router level and verifies the drain + zero delta.
Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
On device (Pixel 9 Pro) the relaunch landed further UP than the saved
position: while the restore scroll is still armed (scale + size + content
can take ~600 ms), the top-of-file window is what gets rendered, and its
shaping feedback — real wrap counts for lines ABOVE the restored line —
lands before or just after the offset is applied. The counts are correct
data, but they change V(line), so the line-derived offset (synthesized
for the all-estimate index) maps to a shallower line and the viewport
drifts up; the save then persists the drifted line and every subsequent
relaunch lands there.
Fix: the restore pins the logical line. Until the restored window's own
shaping arrives (or a 2 s timeout, or the user scrolls / a search jumps),
every accepted wrap correction re-derives the offset as
VisualsBefore(line)*lh + sub under the corrected index. The pin refresh
does not clamp to MaxScroll: the correction just grew the index, so the
pre-layout clamp value is stale and would under-clamp the re-derived
offset (the layout of the emitted frame clamps to the fresh value).
Also: the apply-time offset is mapped through VisualsBefore under the
current index (identical to line*lh while the index is all estimates),
so pre-apply corrections are absorbed instead of gated away.
Verified on device: saved line 420 -> restored 420 (was 333); saved 573
(near EOF, clamp territory) -> restored 573. New e2e regression
TestRestore_LinePinHoldsAcrossLateWrapFeedback replays the late
top-window feedback and fails pre-fix (window drifts 200 -> 66).
On device: scroll far down a wrapped file, relaunch, and the app lands
further DOWN than where the user left off — the deeper the scroll, the
further off.
Root cause: the persisted Scroll is a pixel offset in VISUAL-line space.
Restoring maps it through the WrapIndex (scrollDecompose -> LineForVisual),
but on relaunch every count is the estimate (1) until the line is shaped,
and shaping covers the visible window only — the lines ABOVE the restored
viewport are never shaped. With all-ones counts LineForVisual maps the
offset 1:1, landing a logical line deeper by every wrapped continuation
above the viewport, and the state is stable (the under-counted lines never
re-enter the window), so it never self-corrects.
The snapshot now persists wrap-independent coordinates: the logical line
at the viewport top (derived with the same mapping the layout uses,
against the current index, so it is exactly the shown line) plus the
sub-line remainder. The restore re-derives the offset as line*lh + sub,
which maps to the saved line under any wrap state (all-ones or populated).
The raw Dp offset is kept for pre-line-coordinate session files
(loadSession defaults the missing key to -1; BeginRestore rejects the
ambiguous zero value: a genuine line-0 snapshot always has Scroll < lh).
TestRestore_ScrollSurvivesWrapState reproduces it: 150 lines recorded as
wrapped x3, viewport at logical line 200 (visual 500); a relaunch with a
fresh WrapIndex must land the window on line 200. Fails pre-fix (window at
line 254, i.e. deeper) and passes with the fix.
Docs: spec §2.4 (line-based scroll persist + current save policy),
architecture §6.7 (why the offset is unrestorable by re-mapping).
The OS provides no user-space hook for a process kill, but the
activity onStop fires on every 'going away' transition the framework
still controls: entering recents (the swipe-wipe path), app switch,
and home. Recents-wipe then kills the process right after onStop
returns, so that moment is the last reliable flush.
- Logic.FlushSession (any goroutine, buffered, non-blocking) ->
flushSessionSave on the owner: persist the snapshot now, bypassing
the rate limit (still honoring the restore-pending suppression).
saveSessionIfChanged's persist tail is deduplicated into writeSession.
- JNI: GioActivity.onStop (patched into the smali by the build
scripts, in sync) now calls the static native padFlushSession, which
maps to the pad_flush_session cgo export.
Verified on emulator: the flush fires on home/app-switch and when the
app is backgrounded into recents before a kill; a state change made
inside the 250ms rate window and then backed out of the app persists
the latest position. A hard kill while foregrounded (force-stop,
memory pressure) still has no hook — the immediate edit saves plus the
250ms rate window bound that loss.
Reproduced on device: open a file, scroll, long-press-highlight a
word, then recents-wipe the app ~1s after the highlight. The session
file held the file but lost the selection and cursor: the 1s
rate-limit had not elapsed since the previous save, so the final
changes never flushed before the process died (a recents-wipe is a
kill, not a clean shutdown, so Shutdown's final save never runs).
Two changes in saveSessionIfChanged:
- Urgent path: save immediately when the file changes or a selection
appears/vanishes. These are low-frequency, high-value changes
(the user just opened a file or highlighted/cleared text), and they
are exactly what 'where I left off' means.
- sessionSaveInterval 1s -> 250ms: bounds how stale scroll/cursor can
be at an unlucky kill. The file is a few hundred bytes, so a few
small writes a second during active scrolling is negligible.
TestRestore_UrgentSaveOnSelection drives the kill race: a selection
made right after the file-open save must land in the saver without
waiting out the interval (fails with the urgent path disabled).
On-device runs exposed three bugs the e2e suite could not (it never
feeds layout feedback, and runs headless without size events):
1. WrapIndex poisoning from zero-width shapes. The first frames are
built before the window size is known (px=0x0); the renderer shapes
the editor window at zero width, where every line wraps into many
visual lines. When that feedback arrives, applyWrapCounts writes the
inflated counts to the window's lines. Normally the app stays on
those lines and re-shapes them at a real width, which corrects the
counts before anyone notices. A restored scroll moves the viewport
away instead, so the poisoned counts persist and map the restored
scroll offset to the wrong line (2000 landed on line 11 of 200).
The frame now carries ViewportDegenerate (set at frame-build time,
since feedback delivery lags shaping by a frame), and the main loop
drops layout feedback for such frames.
2. Session saving suppressed forever after a successful restore.
saveSessionIfChanged suppresses saves while l.session is set, and
only abortRestore cleared it — the success path never did, so an
app restored from a session never persisted new state. The
snapshot has fully landed once the cursor/selection/find have
landed with the content and the armed scroll has landed (or there
was none); clear l.session at both points.
3. gofmt on restore_test.go (comment alignment).
Persist a tiny JSON snapshot (SessionState) written by the cmd layer
($HOME/.pad/session.json off-Android, /storage/emulated/0/Pad/ on
Android) and call the logic-owned snapshot rate-limited (<=1/s, on
change) from emitFrame plus unconditionally at Shutdown. On launch the
cmd layer hands the snapshot to Logic.BeginRestore before Run; the
file re-opens through the normal openFile path and lands straight on
the editor page.
- Cursor/selection land with the content, clamped to a shrunk file
(path-guarded so a late result for a replaced file cannot apply the
snapshot to the wrong buffer); a missing file falls back to the
browser.
- Scroll is applied only after the first ScaleEvent has been laid out:
the size ConfigEvent precedes it and the one-way MaxScroll clamp in a
wrong-unit layout would corrupt the offset (found by e2e).
- Find: query + open/closed + current match are persisted; results are
regenerated by re-scanning and the saved current match is re-selected
by byte offset (Find.Restoring/RestoreMatch) without re-scrolling the
restored viewport. A closed-bar query re-scans on the next bar open
instead (an eager scan would be dropped and leave Scanning stuck).
- The main-owned find_bar widget is seeded with the restored query so
its first frame matches the logic-side query.
Docs: spec.md gains §2.4 and drops the §7 row; invariant 5 updated;
architecture.md gains §6.7. Tests: 7 e2e tests covering cursor/scroll/
selection restore, clamping, missing-file fallback, find-bar restore
(open/closed), and the saver/shutdown persist paths.
Ensures the app never generates frames without a cause (frame emission
is event-driven; a spinner would burn CPU/battery for the app's entire
idle life). Verified on the emulator and the phone: healthy runs show
ZERO frames across all idle windows (PERF-PRESENT fps < 1).
- perf: rows whose previous frame is >= 100ms away are idle gaps, not
slow frames — flagged in the CSV (new gap column) and kept out of the
logcat summary's latency percentiles (reported as gaps=N maxGap=...).
A frame >= 2s after the previous one flushes the CSV, so a burst's
rows are persisted the moment the next burst starts (an idle tail or
force-stop no longer loses the last burst).
- editor: debug 'open <path>' command (cmd-file poller, perf mode):
opens a file from any page via the same OpenFile path as a browser
tap — deterministic, no pixel tapping.
- e2e: TestNoFramesWhileIdle (browser + editor after load/scroll/find)
asserts the logic emits ZERO frames across an idle window once
settled — the headless contract, in the regular go-test suite.
- scripts/profile_emulator.sh: drives launch, 8s idle, open (seeded
4000-line file), 16s idle, 3 scrolls on a device/emulator; groups the
CSV into phases at idle gaps >= 2s and FAILs on a phase over its frame
budget (1/s spinner exceeds a 16s idle budget; faster ones balloon a
phase or show in PERF-PRESENT). Validated both ways: PASS on a healthy
build (emulator + phone), FAIL on an injected 500ms frame spinner.
- doc: architecture.md §11 updated (gap handling, burst flush, debug
open, frame-regression guard, 2026-08 measurements).
The main-loop mirror of the X-button clear was level-triggered:
'FindQuery=="" && widget has text'. But the widget updates on every
keystroke while the logic only stores the query a round-trip later, so any
frame drawn in that window still carried FindQuery=="" and wiped the input
- the periodic self-clearing.
Make it edge-triggered: FindState.ClearSeq bumps once per clear; the frame
carries it as FindClearSeq; main wipes the widget input once per NEW value
(lastFindClearSeq handshake). findClear bumps it; findReset preserves it
(main tracks it monotonically, so a reset to 0 would swallow a later clear).
Tests: unit asserts the bump, monotonicity, and close/reopen preservation.
The X was redundant with the top-bar search icon (both closed the bar).
Now the X empties the query and results but leaves the bar open; closing
is the search icon's toggle.
- editor: new findClear (empties query/matches, bumps Gen so an in-flight
scan of the old query is dropped, disarms the settle) and FindClear
handler; the X icon now runs FindClear.
- main: mirrors the logic-side clear into the main-owned widget input
(frame.FindQuery=="" while the widget still has text -> SetText("")).
- tests: unit findClear (stays open, supersedes in-flight scan); e2e X
clears but keeps the bar open and focus, close now via ToggleFind.
- doc: spec updated (clear button vs icon toggle).
The caret was drawn unconditionally, so it kept showing in the main edit
window while key focus sat in the find bar's search input — reading as
if the editor still had focus. drawWrappedText now takes the field's
focus state and draws the caret only when the field is focused
(editorElem.Focused follows FocusedElementID, which is find_bar while
the find bar is open); the caret returns when focus comes back on close.
e2e: the editor TextField reports unfocused while the find bar is open
and focused again after close.
Two bugs made the search jump land in the wrong place:
1. Double-counted visual lines. The visual line at which logical line li
starts is exactly VisualsBefore(li); the code computed li +
VisualsBefore(li), i.e. 2x the intended depth with the all-ones
pre-shape estimates. The MaxScroll clamp masked it on small files;
long files landed far off. Now uses VisualsBefore(li) (fallback li).
2. Truncation vs non-integer line height. Line height is 16.8dp, so
floor(V*lh) sits just above the target line's top and the window
decomposition floors to the line above it. The target now rounds UP:
ceil(V*lh) is always in [V*lh, (V+1)*lh), so the viewport top
decomposes to exactly V. The line-height source now also prefers the
shaped GlyphLayout.LineHeight like scrollVisualDecompose/MaxScroll.
3. Estimate settle. On long wrapped files the lines above the target are
still estimated at 1 visual line when the jump happens, so the landing
can be short. The scroll now arms a bounded settle (SettleByte /
SettleScroll / SettlePasses on FindState); after each layout-feedback
wrap-count correction the logic goroutine re-runs the target and
re-scrolls until it converges, is exhausted (4 passes), or the user /
the MaxScroll clamp moves the viewport (which cancels it). Edits,
query changes, close, and file open disarm the settle.
Tests: round-trip scroll-target on a 2000-line wrapped file, the
no-wrap identity, settle correction/convergence, and settle cancellation
on user scroll.
- ui: TextField gains MatchRanges (window-relative byte ranges) +
CurrentMatch; drawWrappedText draws a per-glyph highlight pass (same
tiling as the selection, so wrapped lines are covered): all matches in
translucent yellow, the selection in blue as before, and the current
match in stronger orange on top so it stands out.
- editor: EditorLayout windows the absolute FindState matches into the
visible content (binary search on the sorted ranges, clamp to the
window) and stamps them on the editor TextField.
- e2e: poll the captured frames until the highlight data lands; assert
all matches are windowed, each highlights a "needle", and
CurrentMatch follows FindNext.
The find bar (32..72) used to be drawn over the top of the text area,
covering the first visible line. EditorLayout now lowers the text area
top by FindBarHeight (new shared constant) while the find bar is visible;
it snaps back on close. e2e: assert the editor region top moves to
32+FindBarHeight while open.
- pool: SearchTask + FindSubstring (case-insensitive substring scan with
query generation for stale-result dropping)
- editor: FindState on EditorState; find bar handlers (ToggleFind/FindNext/
FindPrev/FindClose); worker-pool scan of the in-memory content; edit-aware
offset remapping (shift after, drop overlapping); next/prev with
wrap-around, selection + scroll to match; main-owned find_bar input
forwarded via FindQueryChan (browser search_bar precedent)
- ui: find-bar layout below the margin-free top bar; search icon on the top
bar right; new search/close/chevron_up/chevron_down icons
- logic: findQueryChan, TypeSearch result handling, findReset on open
- frame: FindQuery field (main syncs the widget text like Query)
- tests: pool scan tests, find-state unit tests (remap, nav, gen gate,
focus), e2e find bar + navigation + edit-survival on real files
- docs: spec/architecture/development_plan updated (search implemented,
channel/task tables)
The 10dp margin used to surround the top and bottom bars, leaving a gap
around them. The bars now span the full screen width and sit flush with
the top/bottom edges (merging with the system UI); their inner content
(icons, labels) keeps the margin so it still aligns with the text area.
The editor text area and the browser file list keep the margin, as before,
and the editor gains the 20dp of height the bar margins used to occupy.
Tests: menu-geometry expectations shifted with editorRegion.Y 42 -> 32;
the below-placement clamp test now expects the window-top clamp; the
stable-end test anchors at line 3 (less headroom above line 2 now).
The bottom-bar 'Wrap: On/Off' toggle flipped State.WordWrap and relabelled
itself, but the render path never read the flag: NewTextField hardcoded
WordWrap: true and drawWrappedText always shaped at the region width with
WrapHeuristically, so lines wrapped in both modes.
NewTextField now takes the wordWrap flag, TextField.Draw passes tf.WordWrap
to drawWrappedText, and with wrap disabled the shaper gets unlimited width
(MaxWidth = maxInt32, as in single-line layout) so over-long lines extend
past the region and are clipped instead of wrapping. EditorLayout passes
its existing wordWrap parameter through.
Selection handles now track the finger 1:1 (anchor grab point + displacement)
instead of snapping by whole lines, and crossing the opposite handle flips
the selection (native behaviour) instead of clearing it.
Caret and tap/handle line resolution use VisualLineStarts instead of the
min-Y baseline: the window's first visual line may be an empty line with no
recorded glyphs, which used to draw boundary carets one line too low per
leading empty line and land taps/dragged handles one line below the finger.
New exported ui.CaretPoint centralises byte->insertion-point mapping.
The off-screen caret no longer clamps to the window edge: EditorLayout ships
the true (possibly negative / past-end) window-relative cursor and the
renderer skips the caret when the cursor is outside the shaped window, so
scrolling past the caret no longer makes it jump onto the top/bottom line.
IME/router replay fixes: key.FocusCmd is issued only on a focus transition
(a per-frame no-op still takes the immediate-command path and re-queues all
pointer events), and the key.SelectionCmd IME sync is deferred while a
handle drag is in progress (each push re-injected the drag into every
gesture). Handle drags forward only Grabbed events; a tap inside a handle
grab box is a no-op.
Also: key.FocusEvent no longer logs as unexpected in main; dead code removed
(worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup
sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses
strings.HasPrefix; build scripts run the new scripts/check.sh static gate
(go vet + staticcheck). Tests: caret_point_test, touch_selection updates
(flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
The relative-line mapping resolved the anchor's base line from the
anchor's CURRENT byte on every drag event. Once the anchor crossed a
line, its own movement fed back into its target: a finger jittering
near a line boundary added one more line per event and raced the
anchor to the bottom of the file (reported: tiny vertical movements
jump the anchor off-screen).
Capture the anchor's visual line once at the grab (SelDragPressLine)
and compute the target from that fixed base. A jitter test
(TestSelDrag_EndHandle_JitterNearLineBoundary_Stable) pins the
runaway: mutation-verified against the per-event re-resolution.
The line-lock from the previous fix made anchors immovable across lines —
dragging a handle vertically no longer extended the selection, losing the
native multi-line behaviour.
New contract: a handle anchor moves RELATIVE to its own visual line. The
finger's vertical displacement from the grab position, in whole visual line
heights, selects the target line (under half a line: the anchor's own
line); the finger's x is projected onto that line. A stationary or
horizontal drag never moves the anchor off its line (the grab-box press
stays safe — the original bug is gone), while a deliberate vertical drag
walks the anchor across lines. The mapping is relative, not the finger's
absolute line, because the 48dp grab box is centred below the line: a low
press tracking the finger's absolute line would first drag the anchor the
wrong way, through a collapse, before reaching the anchor's line.
Verified on device: end handle dragged down extends the selection across
the newline; start handle dragged up extends it onto the previous line;
horizontal drags still shrink along the line without clearing. Tests
mutation-verified against both the pre-fix mapping and the
absolute-finger-line variant. Docs section 17 updated.
Three user-reported selection bugs, one root cause each:
1. Start handle ungrabbable at line start. Two interacting causes:
a) The 48dp grab box straddles two visual lines; a finger in the
lower half mapped (by y-to-line) to the neighbouring line, whose
byte past the other handle clamped to a zero-length selection ->
cleared on the first drag event. The cleared selection
un-registered the drag op, so the router silently stopped
delivering drag events (the observed 'stream cutoff'). Fix:
handle drags now project the finger's x onto the anchor's own
visual line (visualLineOfByte + textPosOnLineAtX); the anchor
never crosses lines during a handle drag.
b) A horizontal flick from the line-start handle (screen x~26px)
started the system back gesture, which cancelled the touch
stream. Fix: report the handle grab rects as system gesture
exclusion rects (setSystemGestureExclusionRects, API 29+),
marshalled to the UI thread via a PadExcl smali Runnable
(generated identically by build_emu.sh/build_phone.sh).
2. End-handle drag downward made the menu chase the finger and cover
the selection. Fix: the menu anchors to the STABLE end of the
selection (the end not being dragged), so it stays parked by the
selection start, clear of the finger and the highlighted text.
3. Menu above the selection vanished permanently when the selection
was extended onto the top line. Fix: off-window anchors no longer
hide the menu while any part of the selection is visible (keep-last
rect, clamped); hiding happens only for fully off-window selections.
Also: registerDrag simplified (single shared drag path, body before
handles in z-order), debug logging removed, regression tests
(mutation-verified) for line projection and menu anchoring, docs
section 17. Verified on device: start-handle drag shrinks the word
without clearing or triggering back navigation; end-handle vertical
drag leaves menu/highlight/handles undisturbed; menu stays visible
with the selection at the top line.
The cut/copy/paste icons were dead placeholders (no handlers);
clipboard actions live in the floating selection menu, so the second
icon row is removed. The bar goes from two rows at 52dp to one row at
32dp, giving the editor 20dp more height.
- filename_test.go now finds the label by type (back icon precedes it)
- menu tracking tests updated for the new editorY (reg.Y = 10+32)
- scroll_cursor e2e simulation updated to match
- On-device verified: back button works, floating menu places above
with room and flips below on the first lines, handles unaffected.
- Handles are now teardrops (stem + filled circle) like the native
Android selector: 20dp circle normally, 28dp while dragging.
- The grab region is a 48dp box around the circle centre, independent
of the visual size; the old 16dp target was not grabbable by finger.
- The copy/cut/paste menu is placed ABOVE the selected line (native
behaviour), flipping below only when there is no room above. The menu
is drawn last (top of the z-order) and Gio routes a touch to the
topmost op whose clip contains it, so a below-placed menu covered the
handles' grab boxes and silently stole every handle-drag press.
- Regressions: menu above-placement arithmetic, first-line flip-below,
and the existing tracking/clamp tests updated for the new policy.
- On-device verified: handle drags work with the menu up (previously
dead lower grab region), first-line handles still grabbable from the
uncovered top strip, menu taps and tap-to-clear unchanged.
- doc/development_plan.md section 15 records the z-order/placement
contract.
- SurfaceView windows are always translucent, so adjustResize cannot
resize them: the keyboard overlapped the bottom of the window and the
top bar sat under the status bar. Consume the insets in app instead:
a smali-injected PadInsetsListener (identical block in build_emu.sh
and build_phone.sh) shrinks the GioView to statusBarBottom..keyboardTop
(or ..navBarTop) on every API 30+ insets dispatch, with
setDecorFitsSystemWindows(false) so IME insets are delivered at
targetSdk 34.
- Fix a change-detection bug in the listener: the height write skipped
the topMargin/bottomMargin checks on every IME state transition, so
the margins were never applied while the keyboard animated. All three
params are now checked with a single requestLayout on any change.
- Selection menu now re-anchors to the selected word every frame while
it is visible and hides when the word scrolls out of the viewport
(positionSelectionMenu + regression test).
- Doc: development_plan.md section 14 (invariants, geometry contract,
build-script identity invariant).
Verified on device: top bar below status bar and clickable, bottom bar
flush with keyboard top (view 128..1517 at 1080x2400), BACK dismiss +
re-tap re-raise without a show loop, typing saves, menu tracks scrolls.
Two user-reported bugs in the post-wrap build:
1. Selection 'jumped' when scrolling: frameOf's chunked branch shadowed the
outer start/end with 'start, end, winLine := cb.VisibleByteRange(...)',
leaving IMEWindowStartByte at 0 for chunked files while scrolled, so the
window-relative selection highlight (and IME commits) mis-mapped near the
file top. Fix: declare winLine outside, assign. Mutation-verified
regression tests: real_file_scroll_selection_test.go.
2. Bottom bar hidden behind the keyboard: GioView extends SurfaceView, which
unconditionally marks the window FORMAT_TRANSLUCENT; translucent windows
are never IME-resized, so adjustResize is dead. Fix (build scripts):
smali-patch a PadInsetsListener OnApplyWindowInsetsListener onto the
GioView (shrinks it by the IME inset bottom) + setDecorFitsSystemWindows
(false) on API 30+ so insets are dispatched. targetSdk kept at 34.
3. (Found while fixing 2) Keyboard could not be dismissed: TextField.Draw
issued SoftKeyboardCmd{Show:true} every frame; with insets-driven
resizes, the hide animation triggered redraws that re-showed the keyboard
mid-animation. Fix: ShowIMESeq pulse from the logic layer (open/tap/
double-tap); renderer shows only on pulse change, re-arming on focus
loss — matching widget.Editor.
On-device (emulator): BACK dismisses and stays down; tap re-shows; typing
works; bottom bar above keyboard; word selection anchored across flick
scroll. go test -race ./... green. Phone APK rebuilt with the same patch.
Every scroll<->content mapping site (window start, sub-line shift, tap
mapping, max-scroll clamp, selection menu/handle positions) assumed
1 logical line = 1 visual line. When the viewport top crossed the
bottom of a wrapped line, the view jumped past the wrapped remainder
(jump magnitude (count-1)*lh) instead of moving pixel-by-pixel.
- WrapIndex (internal/editor/wrap_index.go): Fenwick tree of
per-logical-line visual-line counts, parallel to the LineIndex;
built at index-build time, bookkept by the same
UpdateLineIndexAfter{Insert,Delete} hooks (never under-stale: every
touched line resets to the estimate, the next shaping pass
re-corrects it).
- scrollVisualDecompose: the scroll offset lives in visual-line space:
k = LineForVisual(floor(s/lh)), r = s - V(k)*lh. All mapping sites
go through it, so the viewport top is always exactly s into the
document's visual space (V(k)*lh + r = s) — the jump invariant.
All-ones index reduces to the legacy 1:1 mapping (pre-shaping and
non-wrapped behavior unchanged by construction).
- Correction pipeline: the renderer's per-frame VisualLineStarts are
grouped per logical line and written back (applyWrapCounts). The
layout feedback now carries the exact window text the layout was
shaped for (carried in the frame) plus the window start line and the
content-edit counter; corrections apply only on edit-counter match,
and grouping over the current window text (wrong after a scroll moved
the window) is no longer possible.
- bytePosToScreenXY now applies the sub-line shift and the scaled line
pitch: the selection menu/handles were off by up to a full line.
- maxScroll uses TotalVisuals() with the effective (font-scaled) line
height; the bottom clamp lands exactly on the file end for wrapped
content.
- VisibleByteRange returns the real start line (was hardcoded 0).
- emitFrame: replace the unread handoff frame with the newer snapshot
instead of dropping it — a dropped final frame was never re-emitted
(emission is event-driven), leaving the consumer one state behind
forever; fixes the pre-existing TestRealFile_ShiftSelectionInsert
failure. Still non-blocking.
Tests (mutation-verified where practical): wrap_index_test.go (Fenwick
vs naive model, 3000 ops), wrap_bookkeeping_test.go (edit hooks vs
shadow-string oracle, 400 ops — caught a real m=0 under-marking),
wrap_mapping_test.go (the jump regression: V(k)*lh + r == s over sweeps
+ random offsets; legacy-identity pin; boundary sweep), wrap_apply_test.go
(VisualLineStarts grouping + guards — the first version exposed the
always-true WindowStartByte guard that blocked all post-scroll
corrections). go test -race ./... green.
On-device (emulator, 60 wrapped lines): dp sweep 0/17/50/67/134/340
lands on LINE000-vl0/1/3, LINE001-vl0, LINE002-vl0, LINE005-vl0 —
pixel-exact 1:1, no jump (dp 134 is where the old code jumped to
LINE008); bottom clamp exact.
Docs: architecture.md §6.2 (visual-line space invariant),
development_plan.md (Phase 13), spec.md (wrap + clamp lines).
The review-identified race: saves are async (owner snapshots content,
worker pool writes), nothing serialized per file, and every write of a
file used the SAME deterministic temp ('.<name>.tmp'). Two overlapping
writes (autosave x autosave, retry x autosave, or the synchronous
FlushAll on GoToBrowser/Shutdown x a worker write) interleaved on the
shared temp and could rename a byte-mixture into place; even without
interleaving, last-rename-wins could promote a STALE snapshot.
Owner-side protocol (logic.go, requestSave + result handler):
- at most one write in flight per file (writeInFlight maps filename ->
the file version whose content the in-flight write carries);
- a save requested while one is in flight is deferred (savePending) and
re-issued by the write's result handler with a FRESH snapshot, so
'last rename wins' coincides with 'newest snapshot wins';
- on success the SNAPSHOT version (not the current one) is recorded as
written, so an edit that arrived during the write leaves the file
dirty and triggers the re-issue;
- FlushAll (GoToBrowser, Shutdown) defers via the same protocol instead
of writing concurrently on the shared temp;
- shutdown drain: on done the owner waits (bounded 5 s) for in-flight
writes and armed retries to settle before exiting, so the post-exit
synchronous FlushAll and workerPool.Stop cannot race a straggling
worker write;
- retry timer now sends a non-blocking token (no timer-goroutine stall
on a full channel); emitFrame no longer blocks on a slow/gone main
(frames are snapshots; the next emission wins) - also required so the
drain can never deadlock on frame delivery.
Mechanism (real/filesystem.go):
- WriteFileAtomic uses a unique per-call temp ('.<name>.tmp.<pid>.<seq>'),
making same-file staging-file interleaving structurally impossible even
if the serialization regressed (defense in depth);
- each successful write best-effort removes stale temps of the same file
(crash leftovers, plus the legacy deterministic name for upgraded
installs); a failed write removes its own temp.
Tests:
- write_serialization_test.go (e2e): a counting FS wrapper proves the
peak concurrent same-file saves is 1 across two deliberately
overlapping autosaves (2 s saves; the second edit lands inside the
first save's window and its token is deferred, then re-issued with the
newer content), and that a Flush during an in-flight save adds no
concurrent writer and the newest snapshot still wins. Mutation-verified:
disabling the deferral fails it with peak = 2. (The pool's WriteFileTask
calls FS.WriteFile, not WriteFileAtomic - the real FS is atomic only
because WriteFile delegates to WriteFileAtomic; the wrapper mirrors that
delegation or the overlap window does not exist.)
- filesystem_test.go: stale-temp test updated to the new pattern, also
covering the legacy name and asserting a different file's temp is
untouched.
- real_file_fuzz_test.go: stray-temp check matches both patterns.
Docs: architecture.md 6.5 rewritten (protocol invariants), spec.md
autosave line, development_plan.md v11 + Phase 12.
On-device smoke: open, type, autosave lands exact content on disk, no
temp files left, clean relaunch. Full suite green under -race.
Residuals (documented): no fsync before rename (power-loss window only);
external-change detection absent; a drain-deadline exit with a straggling
write can only lose freshness (unique temps keep every rename a complete
snapshot).
Adds the corruption-proofing layer for the edit/persist path:
- chunked_buffer_fuzz_test.go: differential fuzz of ChunkedBuffer
Insert/Delete against a plain []byte shadow model (arbitrary byte
positions/text, chunk sizes 1..64KB, 500-2000 ops each) verifying
FileLen, FullContent, Content probes and the chunk-size invariant
after every op; rune-aligned variant adds UTF-8 validity and an
independent RuneIndexToByte oracle.
- line_index_fuzz_test.go: differential fuzz of the incremental
LineIndex updates against a full-recomputation oracle (mixed,
no-newline, single-line, CRLF, trailing-newline shapes), plus a
trailing-empty-line structural invariant test.
- state_api_fuzz_test.go: differential fuzz of the production edit
entry points (HandleInsert/Backspace/Delete/ReplaceRange, incl.
selection variants) checking content, UTF-8 validity, chunk
invariant, line index and exact cursor after every op.
- real_file_fuzz_test.go (e2e): random edit sequences (incl.
window-relative IME replaces) against the REAL filesystem with
per-batch IME-window-consistency checks, forced-flush disk
byte-comparison, then a second logic instance (restart simulation)
must reload byte-identical content with a fresh line index;
asserts no stray temp files.
- filesystem_test.go (real): atomicity contract - exact round trip
at edge sizes, concurrent reader never sees a torn file across 150
alternating 2MB writes, failed write (read-only dir) leaves the
original byte-identical, stale temp file is consumed.
Fixes a real invariant violation the fuzzing exposed: Insert halved
an oversized spliced result once, so a large paste into a non-empty
buffer left chunks up to ~P/2 (8x target at 1MB/64KB), breaking the
documented 'no chunk > 2x target' invariant. Insert now re-chunks the
oversized result into pieces of at most chunkSize, making the
invariant hold after every edit. Mutation-tested: dropping one byte
in Insert and one entry in UpdateLineIndexAfterInsert are both
caught by the fuzz suite.
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 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.
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.
- 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).