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.
179 lines
9.5 KiB
Markdown
179 lines
9.5 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.
|
||
- **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. 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. 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).
|
||
|
||
## 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. |
|