331 lines
14 KiB
Markdown
331 lines
14 KiB
Markdown
# Pad — Text Editor Specification
|
||
|
||
## 1. Overview
|
||
|
||
Pad is a minimal, high-performance plain text editor for Android. It is configured with a single directory of text files and provides instant file access, automatic persistence, and seamless recovery from process death. The editor is designed for a directory synced across devices, with reactive awareness of external file modifications.
|
||
|
||
**Design philosophy:**
|
||
|
||
- **No practical limits** — files and directories of arbitrary size must work without degradation
|
||
- **Minimal surface area** — plain text only, no formatting, no file management UI
|
||
- **Automatic everything** — save, state persistence, sync awareness happen without user intervention
|
||
- **Survive anything** — process kill, battery death, device restart: the editor restores exactly where the user left off
|
||
|
||
## 2. Core Requirements
|
||
|
||
| Requirement | Detail |
|
||
|---|---|
|
||
| Instant open | Files open immediately regardless of size; content loads on demand |
|
||
| Auto-save | Every edit is persisted to disk with a short debounce; no save button exists |
|
||
| Unlimited scale | No caps on file size, line count, or directory entry count |
|
||
| External change detection | The editor monitors the configured directory for modifications from sync tools |
|
||
| Cross-session undo | Full undo history survives process death, file close, and device restart |
|
||
| State restoration | On relaunch, the editor restores the exact file, cursor position, and scroll state |
|
||
| Per-file cursor memory | Reopening any previously visited file restores the last cursor position |
|
||
| Plain text only | No formatting, no syntax highlighting, no markdown rendering |
|
||
|
||
## 3. Architecture
|
||
|
||
```
|
||
cmd/
|
||
pad/ # Mobile entry point
|
||
|
||
internal/
|
||
browser/ # Directory listing, pagination, search, alphabetical index
|
||
editor/ # Virtual scroll renderer, chunked edit buffer, input handling
|
||
filesystem/ # Chunked file I/O, debounced writes, atomic operations
|
||
watcher/ # File system monitoring, change detection, notification
|
||
conflict/ # External change reconciliation, merge strategies
|
||
state/ # App state persistence, per-file cursor map, restore logic
|
||
undo/ # Undo operation log, context anchoring, rebasing
|
||
```
|
||
|
||
## 4. File Browser
|
||
|
||
The browser presents the configured directory's contents. It must handle directories with hundreds of thousands of entries.
|
||
|
||
### Behavior
|
||
|
||
- **Lazy loading** — directory entries are loaded in pages, not all at once
|
||
- **Alphabetical index** — a tap-on-letter sidebar jumps to the corresponding section
|
||
- **Search** — incremental text filter over entry names
|
||
- **Single tap to open** — no long-press menus, no file management actions
|
||
|
||
### Performance
|
||
|
||
- First paint: directory root visible before all entries are read
|
||
- Scroll: smooth at 60 fps with virtualized list (only visible rows rendered)
|
||
- Search: results filter in real time, no debounce on keystrokes
|
||
|
||
## 5. Text Editor
|
||
|
||
The editor renders plain text with virtual scrolling and chunked loading. The entire file is never loaded into memory.
|
||
|
||
### Virtual Scrolling
|
||
|
||
Only lines visible on screen (plus a small prefetch buffer) are rendered. Scrolling triggers on-demand loading of adjacent chunks.
|
||
|
||
### Chunked Buffer
|
||
|
||
- Files are divided into fixed-size chunks (configurable, e.g., 64 KB)
|
||
- Only chunks near the cursor or visible region reside in memory
|
||
- Chunks are evicted under memory pressure, reloaded from disk on demand
|
||
- Edits at chunk boundaries are handled by splitting/merging chunks
|
||
|
||
### Line Index
|
||
|
||
On first open, the editor builds a line-offset index (mapping line number → byte offset). This index is:
|
||
|
||
- Built in a single pass over the file (streaming, O(n) time, O(1) memory per line)
|
||
- Cached on disk for subsequent opens
|
||
- Invalidated when the file is edited (rebuilt on next open, or incrementally updated)
|
||
|
||
The index enables O(1) or O(log n) jumps to any line number.
|
||
|
||
### Input
|
||
|
||
- Standard text input: insert characters, backspace, delete
|
||
- Selection: tap-and-drag or tap-hold to select a range
|
||
- Paste: clipboard content inserted at cursor or replacing selection
|
||
- No keyboard shortcuts beyond what the OS input method provides
|
||
|
||
## 6. Undo System
|
||
|
||
The undo system records reversible edit operations with context anchoring, persisted to disk for cross-session survival.
|
||
|
||
### 6.1 Operation Model
|
||
|
||
Edits are recorded as a **stack of chained operations**. A chain is a contiguous sequence of operations sharing a common anchor (position and context). A new chain begins whenever the cursor moves or a new selection is made.
|
||
|
||
### 6.2 Entry Formats
|
||
|
||
**Insert chain** (typing at a fixed cursor position):
|
||
|
||
```
|
||
Head: {pos: 1024, before: "<N bytes before>", after: "<M bytes after>", inserted: "h"}
|
||
Tail: {inserted: "e"}
|
||
Tail: {inserted: "l"}
|
||
Tail: {inserted: "l"}
|
||
Tail: {inserted: "o"}
|
||
```
|
||
|
||
Entries without `pos`, `before`, or `after` inherit from the head. Position advances by the length of the previous entry's `inserted` text.
|
||
|
||
**Delete chain** (backspacing without hitting original text):
|
||
|
||
```
|
||
Head: {pos: 1023, after: "<M bytes after>", deleted: "o"}
|
||
Tail: {deleted: "l"}
|
||
Tail: {deleted: "l"}
|
||
```
|
||
|
||
Entries without `pos` or `after` inherit from the head. Position for entry `i` is `head.pos + i`. The `before` field is omitted because it shifts with each deletion; `after` is the stable anchor.
|
||
|
||
**Selection replacement** (select text, type to replace):
|
||
|
||
```
|
||
Head: {pos: 5000, before: "<N>", after: "<M>", deleted: "<selected text>", inserted: "x"}
|
||
Tail: {inserted: "y"}
|
||
Tail: {inserted: "z"}
|
||
```
|
||
|
||
The head records both the deleted selection and the first inserted character. Continuation typing appends to the chain.
|
||
|
||
**Field semantics:**
|
||
|
||
| Field | Present on | Meaning |
|
||
|---|---|---|
|
||
| `pos` | Head only | Byte offset of the first operation in the chain |
|
||
| `before` | Head of insert/replace chains | N bytes immediately before `pos`, used for re-anchoring |
|
||
| `after` | Head of all chains | M bytes immediately after the affected range, used for re-anchoring |
|
||
| `deleted` | Head of delete chains, selection replace | Text that was removed |
|
||
| `inserted` | Every entry | Text that was added (empty string for pure deletion entries) |
|
||
|
||
Fields absent from an entry are inherited from the chain head.
|
||
|
||
### 6.3 Backspace Semantics
|
||
|
||
Backspace interacts with the undo stack:
|
||
|
||
1. **If the last stack entry is an `inserted` entry** and the character immediately before the cursor matches that entry's `inserted` text: **pop** that entry from the stack. No new operation is recorded. The character is removed from the file.
|
||
|
||
2. **Otherwise** (stack empty, or last entry is a delete): record a **new chain head** with `deleted` set to the backspaced character, `pos` set to the byte offset of that character, and `after` set to the context after the cursor.
|
||
|
||
3. **Subsequent backspaces** (after entering delete mode): each appends a `{deleted: "<char>"}` entry to the current chain, inheriting `pos` and `after` from the head.
|
||
|
||
This means backspacing over text the user just typed is "free" — it removes operations from the stack rather than adding new ones. Undo after backspace naturally restores the popped character.
|
||
|
||
### 6.4 Undo Replay
|
||
|
||
To undo, pop the top chain from the stack and replay in reverse:
|
||
|
||
1. **Re-anchor**: search the file for the head's context (`before` + `after` for insert chains, `after` for delete chains). If found, update `pos` to the new location. If not found, skip the entire chain.
|
||
|
||
2. **Apply in reverse**: for each entry from last to first:
|
||
- **Insert entry**: delete `inserted` text at the current position
|
||
- **Delete entry**: insert `deleted` text at the current position
|
||
- Advance/retreat position by the length of the text affected
|
||
|
||
3. For delete chains, replay right-to-left (last entry first) so that each insert shifts subsequent text right without affecting earlier positions.
|
||
|
||
### 6.5 Stack Limits
|
||
|
||
| Parameter | Value | Rationale |
|
||
|---|---|---|
|
||
| Max operations | 1000 | Sufficient for ~1000 typing sessions or individual keystrokes |
|
||
| Context window | 50 bytes each side | Unique enough for prose/code, small enough for persistence |
|
||
| `deleted` field | **No cap** | Large deletions are the most critical undo case; the philosophy of the editor is no practical limits |
|
||
| Redo | Not supported | Can be added later; keeps initial implementation minimal |
|
||
|
||
### 6.6 Persistence
|
||
|
||
The undo stack is serialized into the state file (see §8) and persisted on every edit, debounced separately from auto-save. On restore, each operation's context is verified; operations that cannot be anchored are silently dropped from the stack.
|
||
|
||
The stack survives:
|
||
- Process kill by the OS
|
||
- Device restart
|
||
- File close and reopen
|
||
- App restart
|
||
|
||
The stack does **not** survive:
|
||
- Explicit user action to clear it (if such a feature is added)
|
||
- External file changes that make anchoring impossible (text context no longer exists)
|
||
|
||
## 7. Auto-save
|
||
|
||
Every edit to a file is written to disk automatically.
|
||
|
||
### Behavior
|
||
|
||
- **Debounce**: 500–1000 ms after the last keystroke (configurable)
|
||
- **Atomic write**: write to a temporary file, then rename over the target (prevents corruption if killed mid-write)
|
||
- **No UI**: there is no save button, no unsaved indicator, no "do you want to save?" dialog
|
||
|
||
### Interaction with Undo
|
||
|
||
Auto-save writes the current file state. The undo stack is persisted separately. Undo works transparently across saves — saving does not "commit" or "clear" undo history.
|
||
|
||
## 8. State Management
|
||
|
||
The editor continuously persists its state to a small file (`.pad/state.json`) inside the configured directory.
|
||
|
||
### Persisted State
|
||
|
||
```json
|
||
{
|
||
"active_file": "path/to/open/file.txt",
|
||
"cursor_pos": 1024,
|
||
"scroll_offset": 500,
|
||
"file_cursors": {
|
||
"path/to/file1.txt": 2048,
|
||
"path/to/file2.txt": 512
|
||
},
|
||
"browser_scroll": 1200,
|
||
"undo_stack": [ ... ]
|
||
}
|
||
```
|
||
|
||
| Field | Description |
|
||
|---|---|
|
||
| `active_file` | Path of the currently open file (relative to configured directory) |
|
||
| `cursor_pos` | Byte offset of the cursor in the active file |
|
||
| `scroll_offset` | Vertical scroll position (line or pixel offset) |
|
||
| `file_cursors` | Map of file path → last cursor position, for all visited files |
|
||
| `browser_scroll` | Scroll position in the directory browser |
|
||
| `undo_stack` | Serialized undo operations (see §6) |
|
||
|
||
### Write Strategy
|
||
|
||
- **Continuous**: state is written on every navigation, edit, or cursor movement
|
||
- **Debounced**: writes are batched with a short debounce (separate from auto-save debounce)
|
||
- **Atomic**: write to temp file, then rename (same as auto-save)
|
||
- **Bounded**: `file_cursors` evicts oldest entries to stay under a size limit (e.g., 1000 entries). `undo_stack` is capped at 1000 operations.
|
||
|
||
### Restore Flow
|
||
|
||
1. App launches → read state file
|
||
2. If `active_file` exists on disk → open it, load chunk around `cursor_pos`, place cursor
|
||
3. If `active_file` was deleted → show browser at `browser_scroll` position
|
||
4. If no state file exists → show browser at root
|
||
5. Undo stack is loaded and verified; unanchorable operations are dropped
|
||
|
||
## 9. File System Monitor
|
||
|
||
The editor watches the configured directory for external modifications (e.g., from a sync tool on another device).
|
||
|
||
### Detection
|
||
|
||
- Uses the platform's file system watch API (inotify on Android/Linux equivalent)
|
||
- Detects: file content changes, file creation, file deletion, file rename
|
||
- Watches the entire directory tree recursively
|
||
|
||
### Response
|
||
|
||
| Event | Action |
|
||
|---|---|
|
||
| File modified externally (editor not open) | Update browser listing (timestamp, size) |
|
||
| File modified externally (editor is open) | Trigger conflict resolution (§10) |
|
||
| File created | Appear in browser on next scroll/search |
|
||
| File deleted | Remove from browser; if open, close and show browser |
|
||
| File renamed | Update browser listing |
|
||
|
||
## 10. Conflict Resolution
|
||
|
||
When the file system monitor detects an external modification to the currently open file:
|
||
|
||
### Detection
|
||
|
||
Compare the file's last-modified timestamp and size against the editor's known state. If either differs, the file has changed externally.
|
||
|
||
### Strategies
|
||
|
||
| Strategy | Behavior |
|
||
|---|---|
|
||
| **Discard local** | Replace buffer with disk content, preserve cursor position if possible |
|
||
| **Keep local** | Ignore external change, continue editing (next sync may overwrite) |
|
||
| **Manual** | Show a brief prompt asking the user to choose |
|
||
|
||
The default strategy is configurable. The prompt (if shown) is the only modal UI in the application.
|
||
|
||
### Interaction with Undo
|
||
|
||
When an external change is applied to the buffer:
|
||
|
||
- The undo stack is **preserved** (not flushed)
|
||
- Operations are re-anchored at undo time using context search (§6.4)
|
||
- Operations that cannot be anchored are silently skipped
|
||
- New operations recorded after the external change use positions in the new coordinate space
|
||
|
||
## 11. Performance Guarantees
|
||
|
||
| Operation | Target | Mechanism |
|
||
|---|---|---|
|
||
| Open any file | < 100 ms | Chunked load: only the chunk around the cursor is read |
|
||
| Scroll | 60 fps | Virtual rendering: only visible lines + prefetch buffer |
|
||
| Type | < 16 ms per keystroke | In-memory chunk edit, async chunk flush |
|
||
| Search in directory | < 100 ms for 100k files | Lazy index built on first browse, cached on disk |
|
||
| Undo | < 100 ms | Context search is O(n) in file size but bounded by context window |
|
||
| State restore | < 500 ms | Read state file, open file, load chunk, place cursor |
|
||
| Auto-save | < 100 ms | Debounced atomic write |
|
||
|
||
### Memory
|
||
|
||
| Component | Budget |
|
||
|---|---|
|
||
| Chunk cache | Configurable, e.g., 16–64 MB |
|
||
| Undo stack | ~200 KB for 1000 ops with 50B context (excluding large `deleted` fields) |
|
||
| State file | ~200 KB typical, bounded by largest deletion in undo window |
|
||
| Line index | ~4 bytes per line (stored on disk, paged into memory) |
|
||
|
||
## 12. Out of Scope
|
||
|
||
- Syntax highlighting, themes, or formatting
|
||
- Multiple tabs or split views
|
||
- Find and replace (may be added later)
|
||
- File management (create, delete, rename, move) — handled by external tools
|
||
- Cloud sync — handled by external tools (Syncthing, Dropbox, etc.)
|
||
- Collaborative editing
|
||
- Redo (may be added later)
|
||
- Keyboard shortcut configuration
|
||
- Plugins or extensions
|