- Deleted (drift-prone point-in-time plans / dead specs): browser_implementation_plan.md, editor_implementation_plan.md, virtual_scroll_render_optimization.md, conflict_resolution.md, touch.md, element_model.md, layout_rendering.md, bugs.txt - Rewrote architecture.md: single-owner/no-lock model, channel topology, Frame handoff contract, ownership rules, editor/browser/render internals, active vs dead task types, testing hooks. - Rewrote spec.md: actual behavior (browser, editor, IME, autosave, 50 MB limit with measured numbers), actual code layout, invariants to preserve, explicit deferred-features table (undo, restoration, sync awareness). - Added doc/README.md: doc index, documentation policy, build/install recipe, on-device observation loop. - Updated development_plan.md: Phase 5 mostly done; §7 spec deltas written with two corrections (no undo at all; no external-change detection).
16 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 (viakey.Filter+key.FocusFilter), thene.Frame(&ops). - 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 |
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 (editor); BuildIndex, LoadPages (browser). 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,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. - 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).- 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.- 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.
6.3 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.4 Autosave
- Any edit calls
markDirty(): a 1 s debounce timer; each keystroke restarts it. On expiry the timer goroutine sends a token onautosaveChan; the owner reconstructs the full content from the chunked buffer and dispatches aWriteFiletask. - Write failures are tracked per file (
writeFailed,retryAttempts) and retried viaretryChan. There is no save button; autosave is the only persistence.
6.5 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. - 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.
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. - 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.