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).
10 KiB
Pad — Text Editor Specification
This is the specification of what Pad actually is (v1, current code). Behavior that was once spec'd but never built is listed as deferred in §7, not described as a requirement. If code and this document disagree, the code wins — and this document should be fixed.
1. Overview
Pad is a minimal plain-text editor for Android, written in Go with the Gio UI framework. It presents a single root directory of text files (a browser) and a full-screen editor with the Android soft keyboard. The design goal is a fast, honest editor for a directory that is synced across devices — instant open, autosave on every edit, and large files that stay smooth.
Design philosophy:
- Plain text only — no formatting, no syntax highlighting.
- Automatic everything — autosave with a 1 s debounce; there is no save button.
- Minimal surface area — a browser and an editor, nothing else.
- Bounded memory — a measured file-size limit with an explicit "too large" state, instead of silently degrading or OOMing.
Platform: Android-first. Window 390×844 dp. Default root directory is
/storage/emulated/0/Notes on Android (overridable with -root); .
elsewhere.
2. Implemented behavior
2.1 File browser
- Lists files and directories under the current directory.
- Sort: four modes — name asc/desc, date asc/desc — cycled by the sort button. Default: date descending (newest first).
- Search: incremental, case-insensitive filter over entry names via the
search bar (a Gio
widget.Editor). - Pagination: directory entries are loaded asynchronously in pages through the worker pool; the first paint does not wait for a full directory read.
- Navigation: tap a directory to enter it, back to return. Single tap opens a file — no long-press menus, no file management actions.
2.2 Editor
- Input via the Android IME: soft-keyboard typing, composition, backspace, and autocorrect replacements all arrive through one IME replace-range path and are converted from rune indices to byte offsets. (Swipe-typing support follows from the same IME path; final sign-off on a physical device is the one open validation item.)
- Word wrap: on by default; wrapped lines are virtual — the buffer stores only real newlines. Scrolling wrapped content is continuous: the viewport moves pixel-by-pixel with the finger, including across the bottom of a wrapped line (no jumping over wrapped remainders) — the scroll offset maps to content through the per-line visual-line counts corrected by the renderer every frame (architecture.md §6.2).
- Virtualized viewport: only the visible byte range is shaped and drawn each frame (typically ~4 KB of a large file), keeping frame cost and shaper memory constant regardless of file size.
- Cursor + scroll: tap to place the cursor, drag/scroll to pan; with a hardware keyboard, arrow keys, Home/End, and Page Up/Down move the cursor (verified on the Android emulator; Gio's mobile focus-navigation default for arrow keys is overridden — see architecture.md §2.1).
- Text selection: two input paths. Touch (the Android-native model): long-press selects the word under the finger (on a blank spot it places the caret and offers a paste-only menu); double-tap selects the word; the selection has drag handles at both ends (resize) and the highlighted body can be dragged to move it (length preserved); a floating menu offers copy/cut/paste when a selection is active and paste alone for a bare caret. Any menu tap closes the menu; copy keeps the selection, cut removes it, and paste inserts at the caret or replaces the selection. A plain tap still places the caret and clears any selection; taps inside the visible menu are ignored by the editor. Hardware keyboard: shift+arrow extends a selection. In both cases insert, backspace, and delete replace the selected text, and the IME replaces a selection when the user types over it. The selection is highlighted in the editor and pushed to the IME. (Shift state is tracked by the app, since Gio's Android bridge drops modifier keys — architecture.md §2.1.)
- Autosave: every edit restarts a 1 s debounce; on expiry the full content is written to disk by a worker. At most one write per file is in flight at any time; saves requested during a write are deferred and re-issued with the newer content when it completes ("latest state wins"). Writes stage to a unique per-call temp file and rename into place, so a crash or a concurrent reader never observes a partial file. Failed writes are retried. This is the only persistence mechanism.
2.3 Large files (measured)
- Editable limit: 50 MB (
MaxEditableFileSize), measured on-device: a 10 MB file (130,954 lines) opens in ~120 ms (stat ~76 ms, read ~27 ms, line-index ~18 ms) and idles at ~150 MB PSS / ~230 MB RSS, flat under scroll. 50 MB extrapolates to a few hundred MB — acceptable on a modern phone. - Files above the limit open into a "too large to edit" state: a notice is shown, edit operations are no-ops, and the browser still lists the file.
- The buffer is chunked (64 KB chunks, prefix-sum offsets, full load for
in-range files); edits splice only affected chunks. Details:
architecture.md§6.
3. Code organization (actual)
cmd/pad/ # Gio entry point, main loop, frameReceiver,
# Android defaults (impl_android.go)
internal/
browser/ # BrowserState, BrowserManager, sort, search,
# pagination, layout, handlers
editor/ # Logic goroutine, State, ChunkedBuffer, LineIndex,
# IME handling, autosave, Frame handoff (frame.go)
io/pool/ # Worker pool (priority lanes), task types,
# real/ — real filesystem (rooted at /)
# mock/ — in-memory FS for tests
# types/ — shared types (LineIndex, ...)
ui/ # Element model, Renderer, units (Dp/Px), theme
ui/icons/ # Vector icons
test/e2e/ # Logic-level e2e harness + tests
doc/ # spec.md (this file), architecture.md,
# development_plan.md
4. Architecture (summary)
The logic goroutine is the sole owner of mutable state; the main (Gio)
goroutine reads only a Frame snapshot and writes only via channels; a
frame-receiver goroutine stores frames and invalidates the window; a
priority-aware worker pool does all file I/O. No mutex guards application
state. The IME snippet, search box, and gesture state live on the
main-goroutine side because Gio mutates them during draw.
Full details, channel topology, and ownership rules: architecture.md.
5. Invariants to preserve (future development must not break these)
- Single owner, no locks on state (architecture.md §1). Any new feature adds a channel or an owner-executed callback — never a direct state touch from another goroutine.
- Main never reads logic
State— only theFramesnapshot. - Never shape the whole file. The text shaper's internal line storage retains the largest layout ever performed; one whole-file layout permanently inflates memory (this caused a 1 GB leak, fixed in Phase 3).
- The IME snippet is the visible window, and rune↔byte conversion happens
in one place (
HandleReplaceRange/RuneIndexToByte). - Autosave is the only persistence. If state persistence (last file, cursor, scroll) is added later, it must go through the same owner-dispatches-a-task pattern.
- Logic work stays < 16 ms. Anything that can block or scan more than the viewport goes to the worker pool.
- Scroll offset is always clamped to
[0, maxScroll], wheremaxScroll = contentHeight − viewportHeightfloored at 0 andcontentHeightcounts VISUAL lines (a wrapped line is taller than one line pitch). A file whose content fits the viewport hasmaxScroll = 0and cannot scroll. Verified on-device across 2→130,955 lines (development_plan.mdPhase 6); the wrap-aware clamp lands exactly on the file end for wrapped content (Phase 13).
6. Performance expectations (validated on-device)
| Operation | Target | Measured (10 MB file, Android 35 emulator) |
|---|---|---|
| Open file | instant feel | ~120 ms (stat + read + line index) |
| Scroll | 60 fps | logic-frame cadence flat across offsets 0.02→1.0 — no large-offset degradation; present fps is emulator-limited (~14–27 on the software-rendered emulator, not a Pad quality metric) |
| Type | responsive | single IME path; rapid commits land cleanly |
| Memory | bounded | PSS plateaus ~250 MB at a 10 MB file (bounded high-water mark, no growth under sustained scroll; no OOM) |
7. Deferred / not implemented (explicit non-goals for v1)
These were in the original v1 spec but are not in the code. They are recorded here so future rounds don't mistake doc text for behavior:
| Feature | Status | Notes |
|---|---|---|
| Undo (any) | not implemented | No undo stack exists; the old SaveUndoTask is dead code. |
| State restoration on relaunch | not implemented | Last file / cursor / scroll are not persisted. |
| External change detection | not implemented | No mtime compare on open/resume, no watcher, no Keep/Reload prompt. |
| Syncthing conflict handling | not implemented | No .sync-conflict-* file detection or merging. |
| File-system watcher | not implemented | Browser does not live-refresh; it re-scans on navigation. |
| Alphabetical index sidebar | not implemented | AlphaIndex element exists but is unused. |
| In-file search, tabs, split view | not implemented | — |
| Files > 50 MB | not supported | TooLarge state instead. |
| Desktop / other platforms | not supported | Android-first. |
8. Product requirements (standing)
| Requirement | Detail |
|---|---|
| Instant open | Files open without visible lag; content and line index load async. |
| Auto-save | Every edit persisted with a 1 s debounce; no save button. |
| External change awareness | Deferred (§7) — the sync-awareness story is explicitly out of v1. |
| Plain text only | No formatting, no highlighting, no file management UI. |
| Bounded memory | 50 MB editable limit with an explicit "too large" state. |