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).
40 KiB
Runtime Architecture
This document describes how Pad actually works: the concurrency model, goroutine responsibilities, the frame handoff contract, ownership rules, and the internal design of the editor and browser. It is written at the level of invariants and contracts, not line-by-line code, so it stays true as implementation details evolve. If a detail below conflicts with the code, the code wins — and this document should be fixed.
1. Concurrency model: single owner, no locks on state
The logic goroutine is the sole owner (reader and writer) of mutable
State. There is no sync.Mutex/sync.RWMutex on application state. Every
other goroutine talks to the owner through channels.
┌────────────────────────────────────────────────────────────────────┐
│ Main goroutine (Gio event loop) │
│ - w.Event() loop: Config / Frame / Destroy │
│ - On FrameEvent: lock(handoff) → read Frame snapshot → │
│ Renderer.Draw → collect gestures + key events → │
│ e.Frame → unlock(handoff) → send inputs/config/query/layout │
│ to logic channels (sends happen OUTSIDE the lock) │
└───────────────▲──────────────────────────────┬─────────────────────┘
│ handoff lock (frame storage) │ inputChan, configChan,
│ + w.Invalidate() │ layoutChan, searchQueryChan
┌───────────────┴───────────┐ ▼
│ frameReceiver goroutine │ ┌──────────────────────────────────────┐
│ tight loop: │ │ Logic goroutine (SOLE OWNER of State)│
│ f := <-frameChan │ │ - select over all input channels │
│ lock; *frame = f; │ │ - mutates State, builds []Element │
│ w.Invalidate(); unlock │ │ - sends Frame on frameChan │
└───────────────────────────┘ │ - dispatches tasks to worker pool │
└───────────────┬──────────────────────┘
│ task dispatch / results
┌───────────────▼──────────────────────┐
│ Worker pool (8 goroutines) │
│ - priority: high > medium > low │
│ - file I/O, directory index, line │
│ index build, autosave writes │
│ - results posted on ResultChan │
└──────────────────────────────────────┘
Key invariants:
- No locks on
State. The only mutex is the handoff mutex incmd/pad/main.go, which guards the one-frame storage slot between frameReceiver and the main goroutine. It is a message-passing handoff, not a state lock; it is never held while touching a channel. - The main goroutine never reads logic
Statedirectly. It reads only the latestFramesnapshot (see §4) and writes only via channels. - Non-owner goroutines never mutate
State. They send a request (channel) or run a callback on the owner (Inspect, test-only). - The autosave timer goroutine reads nothing.
time.AfterFunconly sends a token onautosaveChan; the owner does all state reads and dispatches the write. - Gio-mutable widget state lives in the main-goroutine-owned
Renderer, never in logic-ownedState(see §5). - Logic work is synchronous and fast. Processing input, mutating state, building the element tree, and sending a frame must stay well under one display refresh (~16 ms). File I/O and index building always go to the worker pool.
2. Goroutine responsibilities
2.1 Main goroutine (cmd/pad/main.go)
- Runs the Gio event loop.
- On
app.ConfigEvent: sendsConfigEvent{PixelWidth, PixelHeight}tologic.ConfigChan(). - On
app.FrameEvent:- Reads
newScale = gtx.Metric.PxPerDp; if it differs fromframe.Scale, sendsScaleEvent(after the draw). - Under the handoff lock: reads the
Framesnapshot, callsrenderer.Draw(gtx, frame.Elems, scale), thenrenderer.CheckGesturesand the focused element's key/edit events, thene.Frame(&ops). Key events are queried with a catch-allkey.Filter{Focus: id}plus one named filter per arrow key. This is required on Android: the window layer wraps plain arrow-key presses ininput.SystemEvent(it wants them for focus navigation), and system events match only filters that name the key explicitly. Matching a named filter both makes the press deliverable and suppresses the focus-move side effect (a matched event makesWakeupTimereport handled, skipping the window'smoveFocus). The shift key is tracked here (shiftDown): Gio's Android JNI bridge never readsKeyEvent.getMetaState, sokey.Event.Modifiersis always 0 and shift+arrow is otherwise indistinguishable from a plain arrow.NameShiftpress/release do arrive as plain events; the tracked state is attached to theui.KeyEvent{Shift: ...}forwarded to the logic (OR-ed with the Modifiers field so desktop behavior is unchanged). Shift state is reset when no element is focused. - Outside the lock: sends
[]ui.InputEvent(if any) toInputChan, the search text (if it differs fromframe.Query) toSearchQueryChan, andrenderer.GlyphLayout()toLayoutChan.
- Reads
- Owns
frameReceiver(started inrun).
2.2 Frame receiver
Tight loop: read logic.FrameChan(), store into the shared Frame under the
handoff lock, call w.Invalidate(). The logic goroutine's send on frameChan
(bufsize 1) never blocks for more than one frame cycle.
2.3 Logic goroutine (internal/editor/logic.go)
Sole owner of *State. Run() selects on:
| Channel | From | Payload | Action |
|---|---|---|---|
configChan |
main | ConfigUpdate (pixels or scale) |
store scale/pixels; recompute layout |
inputChan |
main | []ui.InputEvent |
run each Handler(evt.Data) on the owner |
searchQueryChan |
main | string |
store Browser.Query, re-filter, new frame |
findQueryChan |
main | string |
in-file search: store Editor.Find.Query, dispatch Search task, new frame |
openFileChan |
main (tap) | string path |
open file in editor (chunked buffer + async index) |
layoutChan |
main | ui.GlyphLayout |
store on editor state; derive LastLineY for scroll clamping |
retryChan |
logic | string filename |
autosave retry |
autosaveChan |
timer | struct{} token |
reconstruct content, dispatch WriteFile task |
workerPool.ResultChan() |
pool | task Result |
apply async results (stat, read, index, write ack) |
inspectChan |
tests | inspectReq |
run fn(*State) on the owner and reply (test-only) |
resultChan |
legacy | ResultEvent |
drained (legacy channel; the live result path is the pool's) |
done |
main | — | drain pool, stop timer, exit |
After every state change the logic goroutine rebuilds the element tree and
sends a Frame on frameChan.
2.4 Worker pool (internal/io/pool)
Fixed 8 workers, two priority lanes (high > low; dispatch routes
HighPriority tasks to the high lane, everything else to low). Tasks are small
structs with Execute() Result; the FileSystem interface (pool.FileSystem)
has a real implementation (io/pool/real, rooted at /) and a mock
(io/pool/mock) for tests.
Task types in active use: StatFile, ReadFile, BuildLineIndex,
WriteFile, Search (editor); BuildIndex, LoadPages (browser). The
Search task runs a case-insensitive substring scan over a full-content
snapshot (FindSubstring) and returns the match byte ranges plus the query
generation that dispatched it; the logic drops results whose generation no
longer matches (EditorState.applySearchResult), so a superseded scan can
never clobber newer results. Dormant/dead task
types — ReadChunk (no-op for fully-loaded in-range files), ReadDir,
StatDir, ReadCache, WriteCache, Invalidate, SaveState, SaveUndo —
are candidates for removal in a cleanup round.
3. Channel topology summary
- main → logic:
InputChan,ConfigChan,LayoutChan,SearchQueryChan,FindQueryChan,OpenFileChan. - timer → logic:
autosaveChan(token only). - logic → logic:
retryChan(self-piped). - logic → frameReceiver:
FrameChan(the only outbound state carrier). - pool → logic:
WorkerPool.ResultChan(). - tests → logic:
Inspect(owner-executed callback; the production equivalent is "send a request channel message").
4. The Frame handoff contract
editor.Frame is the only data that crosses from the logic side to the
main side:
type Frame struct {
Elems []ui.Element // element tree for the next draw
Scale float32 // current px-per-Dp
FocusedElementID string // which registered element gets key/edit events
Query string // search text logic is filtering with
}
Elemsis the computed tree; the renderer draws it (it is a snapshot, safe to read from main).Scalelets main detect a density change and pass scale toRenderer.Draw.FocusedElementIDdrives which registeredkey.Filter/key.FocusFiltermain uses to harvest keyboard/IME events.Querylets main compare against the (main-owned) searchwidget.Editortext and forward changes — the browser search box is a Gio widget, so its text lives on the main side, not inState.
5. Ownership rules
| Owned by | What |
|---|---|
| Logic goroutine | State (browser + editor + chunked buffers + line indexes), worker pool, autosave timer handle |
| Main goroutine | *app.Window, op.Ops, text shaper, ui.Renderer (gesture state, IME dedup state, glyph layout cache), the search-bar widget.Editor |
Rules:
- Gio mutates widgets during draw, so anything Gio mutates must be
main-owned. The search bar is a
widget.Editorregistered with the renderer by ID ("search_bar"); its text reaches logic only throughSearchQueryChan. - The editor content is NOT a
widget.Editor. It is the customui.TextFieldelement; all editor state (buffer, cursor, scroll) is logic-owned. This is why the editor does not hitwidget.Editor's O(n²)-ish cost on large files. - Per-frame element values are rebuilt by logic (
[]ui.Elementin theFrame); any cross-frame persistent draw-side state (IME dedup, gesture tracking) belongs in the persistent, main-ownedRenderer.
6. Editor internals (internal/editor)
6.1 Chunked buffer
ChunkedBuffer (chunked_buffer.go) is the edit buffer for open files.
- Full load on open for in-range files: the whole file is read into an ordered slice of chunks (64 KB) plus prefix-sum byte offsets over actual chunk lengths. There is no lazy loading and no eviction (both existed as plans and were removed).
- Byte-indexed throughout:
CursorPosition, chunk offsets, and glyphByteOffsetsare byte offsets. All edit primitives are rune-granular:HandleBackspace/HandleDeletecompute the UTF-8 rune width at the cursor (a byte-granular delete corrupts multi-byte characters, e.g. a two-byte character straddling a chunk boundary); IME edits arrive as rune ranges; tap-to-position lands on rune starts. - Edits splice the affected chunk(s) only; chunks are not rebalanced.
LineIndex(per-line byte offsets,int32) is built asynchronously byBuildLineIndexTaskand stored oncb.LineIndex— the single source of truth (the old parallelEditorState.LineIndexfield was removed). A file ending in'\n'has a trailing empty line (one offset past the last\n, equal to the file length).- Incremental line-index maintenance. Every buffer edit updates the index
in place instead of rebuilding it:
UpdateLineIndexAfterInsert(pos, text)(shifts starts aboveposright, keeps a start atpos, adds one start per inserted\n) andUpdateLineIndexAfterDelete(start, end)(drops starts in[start, end), shifts the rest left, re-insertsstartiff it is a line start in the new content). A start exactly atendalways drops: it was created by the'\n'atend-1, which the deletion removes (line merge). These must be called in the same order as the buffer splice, and an IME replace isDeletethenInsertat the same position. - Size guard: files larger than
MaxEditableFileSize(50 MB, measured on-device) open into aTooLargestate: the editor shows a notice and edit handlers are no-ops; the browser still lists the file.
6.2 Virtualized viewport
Only the visible byte range is shaped and drawn each frame:
VisibleByteRangemaps scroll offset + viewport height →[startLine, endLine]via theLineIndex, then to a byte range. The range is always bounded by real lines of the document.- Scroll decomposition invariant. The scroll offset s is split into a
content line k and sub-line remainder r by a single float64 floor
decomposition, and all consumers of that split MUST stay in lockstep: the
window start line (VisibleByteRange), the renderer's sub-line shift (the
windowed layout is drawn shifted up by r), the tap mapping (
tapLocalYadds r), the selection menu/handle positions (bytePosToScreenXY), and the max-scroll clamp. With them consistent, a tap a dp below the region top always maps to the line actually under the finger for every s ≥ 0. The decomposition must be computed in float64: a rawint(s/lh)in the Dp float32 domain can round the quotient UP across an integer boundary while a float64 mod still reflects the line below, so the window start and the remainder disagree by one line in a sub-pixel-wide band of offsets and the whole rendered window (hence every tapped line) shifts by one. - Word wrap: the visual-line space (WrapIndex). A wrapped logical line
occupies several visual lines, so the scroll offset lives in VISUAL-line
space, not logical-line space. Every scroll↔content mapping site
(window start, sub-line shift, tap-to-line, max scroll) MUST go through
the same conversion, or the content jumps: when the viewport top crosses
the bottom of a wrapped line, a 1:1 logical↔visual mapping skips the
wrapped remainder (jump magnitude (count-1)·lh) instead of moving
pixel-by-pixel. The conversion is backed by the
WrapIndex, a Fenwick tree of per-logical-line visual-line counts (built parallel to the LineIndex, same line set, updated by the same edit hooks):- Invariant: with k the logical line at the viewport top and r the
sub-line shift, the viewport top is ALWAYS exactly s into the document's
visual space:
V(k)·lh + r = s, where V(k) is the prefix sum of counts before line k. Equivalently k = LineForVisual(⌊s/lh⌋) and r = s − V(k)·lh (0 ≤ r < count(k)·lh: a wrapped line's top may sit several visual lines above the viewport). Any mapping that breaks the identity silently skips or re-shows content — the scroll jump. An all-ones WrapIndex (the state before any shaping correction lands) makes V(k)=k and the mapping reduces to the legacy 1:1 behavior, so pre-shaping and non-wrapped files are unchanged by construction. - Correction pipeline: the renderer's per-frame
VisualLineStarts(one entry per visual line, window-relative) are grouped per logical line and written back into the WrapIndex (the layout feedback carries the exact window text it was shaped for, the window's first logical line, and the content-edit counter; the correction is applied only if the edit counter matches, so a layout shaped before an edit never stamps shifted lines). Counts are content- and width-dependent, not window-dependent: once known they are globally valid until an edit or a wrap-width change. - Edit staleness: the UpdateLineIndexAfter{Insert,Delete} hooks bookkeep the WrapIndex in the same pass as the LineIndex (line inserts/ deletes shift counts; touched lines reset to the estimate 1). The rule is never under-stale: every line whose content changed is reset, and extra resets (over-stale) are always safe — the next frame that shapes the line re-corrects it. A stale estimate is an under-count (count 1), which only shortens maxScroll and compresses the mapping until the correction lands (a self-heating warm-up, never corruption).
- Max scroll is
TotalVisuals()·lh − regionH + lh/2with the font-scale-effective line height; it grows incrementally as shaped counts arrive (pre-shaping it equals the no-wrap estimate). - Relaunch snapshot (spec §2.4): the counts are process-local (rebuilt
from the all-ones estimate on relaunch; only the visible window is ever
shaped), so a persisted pixel scroll offset cannot be restored by
re-mapping — with every count above the restored viewport at the
estimate,
LineForVisualmaps the offset 1:1 to a logical line deeper by all the wrapped continuations above it, with no self-correction (those lines never enter the window). The snapshot therefore persists the wrap-independent coordinates: the logical line at the viewport top (derived with the same scrollDecompose + LineForVisual mapping the layout uses, against the CURRENT index) plus the sub-line remainder; the restore re-derives the offset asline·lh + sub. The raw offset is kept as the fallback for pre-line-coordinate session files.
- Invariant: with k the logical line at the viewport top and r the
sub-line shift, the viewport top is ALWAYS exactly s into the document's
visual space:
- Font-scale axis. The shaper draws baselines in sp, so on Android the
rendered line pitch in density-dp is
EditorLineHeight()*fontScale(fontScale = Metric.PxPerSp/PxPerDp, the user font-size setting). Every consumer oflhabove therefore usesEffectiveLineHeight()(the font-scale-applied value, tracked inStateviaScaleEvent.FontScale), and the renderer'sGlyphLayout.LineHeight, caret, handles, and selection highlight use the same scaled pitch. With the raw line height, a non-default font setting would misplace taps by up to (fontScale-1) viewportfuls of lines and make scroll clamping stop short of (or run past) the file ends. Density itself (pure hi-DPI) is a separate axis: all geometry bookkeeping is in density-dp and scale enters only at the px↔dp conversion (singler.scale/State.scale), so the invariant above is scale-free and holds at any display density. - Never shape the whole file. Lesson learned (Phase 3): the shaper's
internal
documentretains the backing array of its largest layout forever (reset()keeps the cap), so one whole-file layout permanently inflated memory to ~1 GB for a 10 MB file. Shaping ~50 lines keeps it small and flat. - The renderer reports
GlyphLayoutback viaLayoutChan; logic derivesLastLineYfor scroll clamping and the max scroll offset. GlyphLayoutoffsets are window-relative. The shaper only sees the visible window, soGlyphLayout.ByteOffsets(andVisualLineStarts,Y) are relative toIMEWindowStartByte, not the file start. Any logic-goroutine code that maps a glyph offset to the absoluteCursorPosition(tap-to-position, Home/End, vertical cursor move) must add the window base (IMEWindowStartByte) before storing the cursor and subtract it before searching the offsets. For a whole-file window the base is 0 (a no-op). Getting this wrong snaps the cursor to the window top on scrolled large files.
6.3 Selection and caret
- Key presses arrive as
ui.KeyEvent{Name, Shift}(main.go), not barekey.Name, so handlers can distinguish shift+arrow from plain arrow. On Android theShiftbit comes from main.go's own shift tracking, not from Gio's Modifiers (see §2.1); arrow-key presses reach the handler only because main.go registers the explicit named key filters. - Shift+arrow extends a selection, plain arrow moves the caret and clears
it.
SelectionAnchoris the fixed end,CursorPositionthe active end; both are absolute file byte offsets. No selection ⇔SelectionAnchor == -1. A zero-length shift selection keeps the anchor (SelectionStart/End == -1) so the next shift-move extends from the original spot. - Insert / backspace / delete with a live selection delete the whole selection
first (
deleteRange), then insert; the selection is cleared afterwards. - IME: with an active selection,
HandleReplaceRangeunions the IME-reported range with the selection before splicing, so replacement is deterministic whether the IME reports the caret (empty range) or the full range. - Rendering: the
TextFieldelement carries window-relative selection start/end into the visibleValue(−1 = none) for the highlight, drawn before the glyphs; the IMESelectionCmdpush dedups on the (selectionStart, caret) pair.
6.3a Touch selection (v1)
- Division of labor: the renderer reports finger positions, the logic owns
all geometry. Touch input is delivered to the logic goroutine as
ui.Point(tap),ui.DoubleTapPoint,ui.LongPressPoint, and selection drag events (which handle + app-local Dp position). The logic converts them to text coordinates using theEditorRegionstored onState(set each layout frame) plus the scroll offset, hit-tests the floating-menu items itself, and owns the menu rect, highlight range, and handle positions. The renderer only draws what theFramesnapshot says (menu, handles, highlight) and registers the input regions. - Menu taps are logic-decided. The menu panel is one
Tapinteraction over its whole rect; the editor's tap handler ignores any tap inside a visible menu (pointInMenuguard) so both handlers can coexist regardless of dispatch order. Item identity comes from the tap's X within the panel. - Long press needs frames to elapse. Gio renders on demand; a stationary
finger produces no pointer events and therefore no frames, so the 400 ms
threshold could never be checked. The main loop polls
Renderer.PendingLongPress()and keeps invalidating the window while a press is held still on the editor. A non-grabbing raw pointer probe (plainevent.Optag on the editor region) observes the press's motion and cancels the pending long press on movement; a scroll or handle drag grabs the pointer (pointer.GrabCmd), which cancels it viapointer.Cancel. - A pointer filter with zero
Kindsmatches nothing.pointer.Filter.Matchestestse.Kind & f.Kinds == e.Kind, so a query withoutKindssilently receives no pointer events — probe queries must name the kinds they want. - One event per Update is a trap on Android. A tap's down+up routinely
arrive in a single frame, and
gesture.Click/gesture.Dragreturn at most one event perUpdatecall. If the renderer processed only one event per gesture per frame, the release would sit in the queue until the next redraw — which on an idle window may never come — and the tap is swallowed (this is why menu taps initially required a second tap to "rescue" the first). The renderer therefore drains each click/drag gesture's queue to exhaustion every frame;gesture.Scrollalready drains internally. - Per-frame gesture bookkeeping must survive the frame. Click-registry
state (press time/position, long-press-fired flag) is kept in structs held
by pointer in a map;
rangeover a value-type map yields copies and silently discards the mutations. - Clipboard crosses the goroutine boundary via channels; the ops run on
the main/Gio frame path. Logic→main:
clipboardSetChan(string to write) andpasteReqChan(token). Main→logic:pasteChan(string). All three are buffered (16) so a harness with no main loop can never block the logic goroutine. Main executesclipboard.WriteCmd/clipboard.ReadCmdduring a frame and forwards read results back onpasteChanfrom thetransfer.DataEvent. - Android clipboard reads need an explicit invalidation. Gio v0.10 on
Android answers
ReadCmdsynchronously during the op flush by queueing atransfer.DataEvent; a queued DataEvent schedules no frame wakeup of its own. Main therefore invalidates the window after each read so a follow-up frame exists in which the DataEvent is consumed. - The menu closes on any item tap. Copy keeps the selection (only the
menu disappears); cut removes it (via
ClearSelection, which also hides the menu and cancels drag bookkeeping); paste closes the menu immediately even though the actual insert happens on a later frame when the clipboard content arrives.
6.4 IME (Android soft keyboard)
- The editor exposes to the IME a windowed snippet:
IMEWindowTextis the visible viewport text,IMEWindowStartByteits absolute start. While theTextFieldis focused,drawElementemitskey.SnippetCmd(the window) andkey.SelectionCmd(caret as a rune index into the window). - Dedup in the Renderer (main-owned state): the snippet/selection are
re-emitted only when they actually change, and a fresh push is forced when
the field (re)gains focus. This mirrors
widget.Editor's behavior and prevents per-frame re-push from resetting IME composition on rapid commits. - Incoming
key.EditEvent{Range, Text}has window-relative rune indices;HandleReplaceRangeconverts them to absolute byte offsets (RuneIndexToByte) and splices the chunked buffer. Insertion,deleteSurroundingText(backspace/autocorrect replacement), and composition all arrive through this one path.
6.5 Autosave and the per-file write protocol
- Any edit calls
markDirty(): a 1 s debounce timer; each keystroke restarts it. On expiry the timer goroutine sends a token onautosaveChan; the owner snapshots the full content from the chunked buffer and requests a save. - Per-file write protocol (corruption guard). The worker pool is shared
and the on-disk staging path is per-file, so two concurrent writes of the
same file would race on the staging file. The owner therefore guarantees:
- at most one write in flight per file (
writeInFlight, keyed by filename, recording the file version whose content the write carries); - a save requested while one is in flight is deferred (
savePending) and re-issued by the write's result handler — so the rename that lands last always carries the newest content ("latest state wins"); - on success the recorded written version is the snapshot's version, so any edit that arrived during the write leaves the file dirty and triggers the re-issue.
- at most one write in flight per file (
FlushAll(page switch, shutdown) obeys the same protocol: if a write is in flight it defers instead of writing concurrently.- Shutdown drain: on the
donesignal the owner waits (bounded, 5 s) for in-flight writes and armed retries to settle before exiting, so the post-exitFlushAllandworkerPool.Stopcannot race a straggling worker write. - Staging file:
WriteFileAtomicuses a unique per-call temp name (".<name>.tmp.<pid>.<seq>") in the target directory and renames it into place, so readers and crash recovery only ever see a complete file. Each successful write also best-effort removes stale temps of the same file (crash leftovers, plus the legacy deterministic ".<name>.tmp" name). Unique temp names make same-file interleaving structurally impossible even if the serialization regressed. - Write failures are tracked per file (
writeFailed,retryAttempts) and retried viaretryChan(exponential backoff 1 s … 30 s; the timer sends a non-blocking token and the owner re-snapshots at fire time). There is no save button; autosave is the only persistence. - Known residual (out of scope): no
fsyncbefore the rename — a power loss inside the rename window can lose the last save (process death cannot: the page cache survives).
6.6 Opening a file
- A browser tap sends the path on
OpenFileChan. The logic goroutine creates theChunkedBuffer, dispatchesStatFile(size guard) and the read +BuildLineIndextasks, switchespageto the editor, and setsjustOpenedAt. The relaunch restore (below) reuses the sameopenFilepath. - Opening-tap swallow: the tap that opens the file is also delivered as an
editor tap in the same frame. A short time window (
justOpenedAt) swallows it so the viewport does not jump to the tapped (often EOF) position.
6.7 State restoration on relaunch (session.go)
- The restorable state is a plain comparable value,
SessionState(last file, cursor byte, scroll Dp, selection range, find query/visibility/ current-match byte). The search results themselves are not stored; they are re-scanned on restore. - Ownership: the logic goroutine owns snapshot content. The cmd layer
(
cmd/pad/main.go) owns the JSON file and registers the writer viaLogic.SetSessionSaver; the owner invokes it rate-limited (≤ 1/s, only on change) fromemitFrameand unconditionally atShutdown(post-exit, single-threaded, next toFlushAll). Satisfies the single-owner rule: the callback receives a value copy, and the file I/O is a tiny synchronous write in the cmd layer's closure. - Restore: the cmd layer reads the file at startup and calls
Logic.BeginRestore(s)BEFORERun()(single-threaded window, likeNewLogicitself): it setsFilename, shows the editor page, and arms the scroll.Run()then re-opens the file through the normalopenFilepath.- Stat success: the find state lands (query, visibility, current-match
byte via
Find.Restoring/Find.RestoreMatch, so the re-scan's first result re-selects the saved match WITHOUT re-scrolling the restored viewport). - Content arrival: cursor/selection land, clamped to the file length
(path-guarded, so a late result for a replaced file cannot apply the
snapshot to the wrong buffer); the query re-scans only if the find bar
was open (a closed-bar scan would be dropped and leave
Scanningstuck). - Scroll offset: applied only after the first
ScaleEventhas been laid out: the sizeConfigEventprecedes it, and a layout in between computes the viewport in the wrong unit, whose one-wayMaxScrollclamp would corrupt the offset. - File gone (stat failure): the restore is abandoned and the app lands on the browser page with a clean editor. Any user open of another file cancels an in-flight restore for the same reason as the path-guard.
- Stat success: the find state lands (query, visibility, current-match
byte via
7. Browser internals (internal/browser)
BrowserStateis embedded by value inState(single owner; no pointer indirection).BrowserManagerdrives async directory loading through the worker pool:ReadDir+BuildIndexon navigation,LoadPagesfor pagination.- Features: 4 sort modes (name/date × asc/desc; default newest-first), incremental case-insensitive search over names, directory navigation with back, single-tap file open.
- The browser renders via
ListView/ListItemelements; rows carryInteractionhandlers that send channel requests (tap) or mutate state directly when the handler runs on the owner (scroll).
8. Render pipeline (internal/ui)
- Logic builds a
[]ui.Elementtree. Elements are value types; interactive ones carryInteraction{Gesture, Handler}entries.Handleris a static function that mutatesTheState— handlers run on the logic goroutine only. - Main draws via
Renderer.Draw(gtx, elems, scale):drawElementrecursively walks the tree, applying transforms/clips.- Interactive elements register hit regions in the current clip context
(
gesture.Click.Add/pointer.InputOp), so hit-testing always matches the drawn geometry. - Text elements shape through the shared shaper — only the visible window, never the whole buffer.
e.Frame(&ops)flushes ops.
- After draw,
CheckGesturesturns raw pointer/gesture state into[]ui.InputEvent(each carrying its own handler) for the logic channel. Tap/double-tap/long-press/selection-drag event positions are app-local Dp (window px ÷PxPerDp— the same space as elementRegions, andMenuRect); the logic side converts them to text coordinates with the storedEditorRegion+ scroll (README.md §Screen coordinates, pipeline hop 2). - Units:
ui.Dp/ui.Pxconvert via the currentPxPerDpscale (ToDp/ToPx). The window is 390×844 dp; the real pixel size arrives viaConfigEvent. - Live element catalog:
Container,Label,Icon,TextField(editor window),ListView/Line/ListItem(browser rows),GioEditor(search bar),Cursor. TypesButton,Toast,Spacer,AlphaIndex,Selection,MergeHunkexist inelement.gobut are not used by any page — future work, not current behavior. - Theme:
ui.Theme{FontSize: 14}+ palette inelement.go.
9. Testing hooks
Logic.Inspect(fn)(test-only): runsfn(*State)on the owner and returns its result — the sanctioned way for tests to read state without breaking single ownership. It must never be called from production code.- The e2e harness (
internal/test/e2e) drives the realLogic+ worker pool + mock filesystem through the real channels, and asserts viaInspect. It is logic-only: it does not exerciseRenderer.Draw, so draw-path behavior (IME dedup, gesture routing) is validated on-device with adb (seedevelopment_plan.md§Phase 2/3 observation loop). - CI gate:
go build ./... && go vet ./... && go test -race ./....
10. Known loose ends (code, not spec)
- Globals
TheState/TheLogic/ui.OpenFilestill exist (handlers are static funcs); replacing them with explicit state is a planned cleanup (development_plan.mdPhase 4). - Dead task types (
ReadChunk,SaveState,SaveUndo, …) and unused element types are candidates for removal in the same round. - The Android arrow-key/shift workaround in main.go (named
key.Filters + app-side shift tracking, §2.1) compensates for two Gio v0.10 behaviors: the JNI bridge dropping modifier state, and mobile arrow presses being wrapped ininput.SystemEventfor focus navigation. If Gio changes either behavior (e.g. starts passing meta state), the shift tracking and the named filters must be re-examined — the workaround would become redundant or wrong. Re-verify on-device withadb shell input keyevent 22(cursor must move) andinput keycombination 59 22(selection must extend).
11. Performance profiler (default-off, internal/perf)
Pad ships a built-in profiler that is off by default and costs nothing when
off. It is the in-app frame-timing tool (see development_plan.md §9: gfxinfo
cannot measure this app because it renders into a SurfaceView).
-
Enable by creating the marker file
/storage/emulated/0/PadPerf/enablebefore launch.main.gothen creates aperf.Profilerthat writeslogic_frames.csv(one row per logic frame: seq, ms-since-start, frame delta, page, scroll Dp, max-scroll Dp, total lines, visible byte range, gap flag) and logs a rolling ~1 sPERFsummary.DestroyEventstops it (final flush +PERF stoppedsummary with p50/p90/p99/max). -
Gap handling: a row whose previous frame is ≥ 100 ms away is an idle gap, not a slow frame (the first frame of a burst would otherwise carry the whole idle period in its delta). Gap rows are flagged in the CSV, kept out of the summary's latency percentiles, and reported separately (
gaps=N maxGap=…ms). A frame that arrives ≥ 2 s after the previous one also flushes the CSV immediately: the frame that opens a new burst is the moment the previous burst's rows become final, so an idle tail (or a force-stopped app) does not lose the last burst. -
Main-side presents:
main.goseparately countsapp.FrameEvents and logs a rollingPERF-PRESENT frames=N fps=Fline; fps ≫ 1 while idle means something is invalidating the window continuously. -
Hook:
internal/editor.PerfRecordis a package-level func, set bymain.goonly when enabled.Logic.emitFramecalls it on the owner goroutine with aProbeRecordbefore sending the frame. When disabled it isniland the per-frame cost is a single nil check. -
Debug commands (for testing, off by default): with the profiler on,
main.goalso polls/storage/emulated/0/PadPerf/cmd(a one-shot file consumed on read;Logic.applyDebugCmd).top,bottom,frac <0..1>, anddp <int>jump the editor'sScrollOffset(clamped to[0, MaxScroll]) and emit a frame;open <path>opens a file from any page (sameOpenFilepath as a browser tap) and emits. This lets a test drive scrolls and file opens deterministically without pixel taps. -
Frame-regression guard: frame emission is event-driven, so a healthy app emits small per-action bursts and nothing while idle. The guard has two RELEASE-GATE tiers and one diagnostic use:
TestNoFramesWhileIdle(internal/test/e2e, in the regular go-test suite) asserts the logic emits ZERO frames across an idle window after the browser and the editor (load + scroll + find cycle) settle.scripts/profile_emulator.sh, run on the emulator (auto-selected; physical devices are never auto-picked), as the pre-release checklist item: it drives 8 s idle, open, 16 s idle, three scrolls via the debug commands above, seeds a known 4000-line file for the open, and FAILs if any idle-split phase of the CSV exceeds its frame budget (a ≥ 1/s spinner exceeds a 16 s idle budget; a faster one balloons a phase or shows up in PERF-PRESENT). The same script on a physical device is diagnostic only, never a gate (pass-s <serial>explicitly; a banner says so): the run force-stops the app and seeds/removes a file in its storage, and the developer may be using the phone while a release runs. It exists to investigate a suspected frame/battery problem, not to certify a release.
The full release flow (gates → build → install to all connected devices, and the policy that a phone is install-only during a release) is
doc/release.md+scripts/release.sh. -
The profiler is owned by the goroutine that creates it and is single-goroutine (no locks). It does not
Sync()the CSV per flush (only per row batch) to avoid periodic fsync hitches in the logic path. -
Measured (emulator, 10 MB file, 2026-08): logic-frame cadence is flat across scroll offsets 0.02→1.0 (no large-offset degradation); the visible byte range stays ≤ ~4.3 KB (0.04% of the file); PSS plateaus ~250 MB (bounded high-water mark, no leak). See
development_plan.mdPhase 6. -
Measured (emulator, 4000-line file, 2026-08): the pre-release profile passes with 5 phases — browser startup 5–6 frames, open 5–6, and 1–2 per scroll — and ZERO frames across all idle windows (PERF-PRESENT fps < 1 throughout); an injected 500 ms frame spinner makes the script FAIL (phase 1 = 69 frames vs budget 10, PERF-PRESENT fps ≈ 2.4).