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).
204 lines
11 KiB
Markdown
204 lines
11 KiB
Markdown
# 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.)
|
||
- **In-file search:** a find icon on the top bar (right side) toggles a
|
||
find bar directly below it, with a text input, an "N / M" counter,
|
||
next/prev navigation, and a clear (X) button. The X empties the query and
|
||
the results but leaves the bar open; the top-bar icon closes it (and
|
||
re-opens it). Opening the bar moves key focus to the search input and
|
||
raises the soft keyboard. The query is a plain substring,
|
||
case-insensitive, no regex; each change is scanned over the entire
|
||
in-memory content by a worker (`Search` task), so typing never blocks the
|
||
UI and stale scans are dropped by generation. Next/prev select and scroll
|
||
to the surrounding matches (wrapping around); the first discovery selects
|
||
and scrolls to the first match. Edits do not invalidate the result set:
|
||
match offsets before the edit are unchanged, matches after it shift by the
|
||
length delta, and matches overlapping the edited range are dropped
|
||
(finding text an edit creates requires re-typing the query).
|
||
- **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`](./architecture.md).
|
||
|
||
## 5. Invariants to preserve (future development must not break these)
|
||
|
||
1. **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.
|
||
2. **Main never reads logic `State`** — only the `Frame` snapshot.
|
||
3. **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).
|
||
4. **The IME snippet is the visible window**, and rune↔byte conversion happens
|
||
in one place (`HandleReplaceRange` / `RuneIndexToByte`).
|
||
5. **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.
|
||
6. **Logic work stays < 16 ms.** Anything that can block or scan more than the
|
||
viewport goes to the worker pool.
|
||
7. **Scroll offset is always clamped to `[0, maxScroll]`,** where
|
||
`maxScroll = contentHeight − viewportHeight` floored at 0 and
|
||
`contentHeight` counts VISUAL lines (a wrapped line is taller than one
|
||
line pitch). A file whose content fits the viewport has `maxScroll = 0`
|
||
and cannot scroll. Verified on-device across 2→130,955 lines
|
||
(`development_plan.md` Phase 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. |
|
||
| Tabs, split view | **not implemented** | In-file search IS implemented (spec §2.2); only tabs/split remain deferred. |
|
||
| 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. |
|