Compare commits
253 Commits
c12539d933
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c0c90dcc | ||
|
|
4ead64bada | ||
|
|
abbd9d2e6e | ||
|
|
4c29488f8a | ||
|
|
625cd91967 | ||
|
|
43d80b518f | ||
|
|
1493ee246f | ||
|
|
4c61319995 | ||
|
|
1c4bdb78c9 | ||
|
|
459d1a5eb4 | ||
|
|
091688d7c3 | ||
|
|
057d6a6e2e | ||
|
|
d4c59c2929 | ||
|
|
72b9615105 | ||
|
|
2a12f95165 | ||
|
|
6fe8c86708 | ||
|
|
866b7b722a | ||
|
|
60c9aef9dc | ||
|
|
026eaea69a | ||
|
|
3da31de0f2 | ||
|
|
6d5179fa11 | ||
|
|
fe62b47eee | ||
|
|
7378d30450 | ||
|
|
cdfeabc152 | ||
|
|
e161b1eae7 | ||
|
|
761fd3a750 | ||
|
|
11545efb0a | ||
|
|
44c65e9ea5 | ||
|
|
75166f74cf | ||
|
|
329995ab58 | ||
|
|
0734c1f67e | ||
|
|
3009dc4d9c | ||
|
|
8c4866df1f | ||
|
|
a941ae39b3 | ||
|
|
8d22f81804 | ||
|
|
4f0d745e84 | ||
|
|
5d623151a8 | ||
|
|
8d5bd1903d | ||
|
|
028ebc8f5f | ||
|
|
9e99c1767a | ||
|
|
f79d13453a | ||
|
|
05706478a2 | ||
|
|
5f50cf1b45 | ||
|
|
5c97e019ec | ||
|
|
7712de6153 | ||
|
|
1df25f75d6 | ||
|
|
acbafa3d6b | ||
|
|
e801d2d480 | ||
|
|
a771c3f6de | ||
|
|
4c92b0dddc | ||
|
|
af5b41a104 | ||
|
|
2660a3e9dd | ||
|
|
79fad4689c | ||
|
|
98fc817b3f | ||
|
|
d306caacf7 | ||
|
|
2166b932b6 | ||
|
|
02b7ed88b6 | ||
|
|
a5edaed810 | ||
|
|
e86a3b8254 | ||
|
|
9309f3f239 | ||
|
|
1a5e1736f5 | ||
|
|
ce3204a350 | ||
|
|
dd3d70cbec | ||
|
|
2c1c590c9e | ||
|
|
2c24ece3a5 | ||
|
|
da8ca7d8d2 | ||
|
|
dade5e6bb3 | ||
|
|
a6f52df8b6 | ||
|
|
6c5e666f4b | ||
|
|
c5e119f6be | ||
|
|
456c4c43e7 | ||
|
|
beb11eda5e | ||
|
|
88bb2b99cf | ||
|
|
69843086f9 | ||
|
|
45664fc956 | ||
|
|
56f5d18bd8 | ||
|
|
767742ba12 | ||
|
|
85cbeceea9 | ||
|
|
8527f75bf8 | ||
|
|
5a076e3226 | ||
|
|
c969d3e051 | ||
|
|
baf9715570 | ||
|
|
4aaf89d977 | ||
|
|
221135cd9b | ||
|
|
93f3a43b34 | ||
|
|
f37da21462 | ||
|
|
fdd59093db | ||
|
|
55a8d21e0f | ||
|
|
d14d9e77a5 | ||
|
|
d46cdb7e7f | ||
|
|
c535864ee7 | ||
|
|
82f305cea6 | ||
|
|
c9c07165e0 | ||
|
|
e967773bd6 | ||
|
|
2215da939d | ||
|
|
fa0ea56e1a | ||
|
|
470a9d25d0 | ||
|
|
937868dd85 | ||
|
|
884c8f2e48 | ||
|
|
32b9bc6a15 | ||
|
|
f8ef9c821c | ||
|
|
f950abdc4a | ||
|
|
137a51eb76 | ||
|
|
ba8f4e9a92 | ||
|
|
8e4e3a7586 | ||
| 657aba3cf3 | |||
|
|
229c259e7c | ||
|
|
f5a0b45f6f | ||
|
|
cb3160541d | ||
|
|
1d7448ba75 | ||
|
|
5d9591e5ea | ||
|
|
97216db0c0 | ||
|
|
8b30658275 | ||
|
|
54dc7a31d3 | ||
|
|
fc83306a81 | ||
|
|
491dc57144 | ||
| 9f797d814c | |||
|
|
a817229ae1 | ||
|
|
17b6631310 | ||
|
|
fe544ccdd3 | ||
|
|
a700412773 | ||
|
|
92068f1cce | ||
|
|
9d6daf7afd | ||
|
|
c206fc28e4 | ||
|
|
ad023843e9 | ||
|
|
3386969655 | ||
|
|
b50043020d | ||
|
|
e57709a9ee | ||
|
|
8dc6ee362f | ||
|
|
98a9d1dffe | ||
|
|
2673a3377a | ||
|
|
6f7f05baf2 | ||
|
|
f4af38d8ef | ||
|
|
14967a06c6 | ||
|
|
18ed6aa599 | ||
|
|
8e3e6e25a4 | ||
|
|
e797381da6 | ||
|
|
ee00fecdc8 | ||
|
|
9101d61045 | ||
|
|
830701e670 | ||
|
|
235c7e4d24 | ||
|
|
4c8c557e7b | ||
|
|
acecf9998f | ||
|
|
81d5f818cc | ||
|
|
637fe4a4f4 | ||
|
|
a61e0a2196 | ||
| fd62fac10e | |||
|
|
7a1f25f5d1 | ||
|
|
1e3220a95f | ||
|
|
9c338571f0 | ||
|
|
5540f901de | ||
|
|
1b9987c4ee | ||
|
|
4bc9236bd7 | ||
|
|
9129f04d0e | ||
|
|
54a9e31ee5 | ||
|
|
fc4ce4d017 | ||
|
|
5335751323 | ||
|
|
b899286639 | ||
|
|
698c10c8c0 | ||
|
|
bef9587a34 | ||
|
|
195311a395 | ||
|
|
3faff25fba | ||
|
|
208c929650 | ||
|
|
32e9623411 | ||
|
|
c4320e2239 | ||
|
|
6af959f92d | ||
|
|
757dd492b9 | ||
|
|
91287899a4 | ||
|
|
314fefc9b6 | ||
|
|
e8482e4e2d | ||
|
|
d47de98c22 | ||
|
|
896ca8073b | ||
|
|
d63a66f4cf | ||
|
|
e28a6a6d19 | ||
|
|
c521d68ad7 | ||
|
|
8fa727702e | ||
|
|
0c3b5b50b1 | ||
|
|
ff0ff0b5ae | ||
|
|
704fd743a0 | ||
|
|
66595c7767 | ||
|
|
13d49930cc | ||
|
|
78958d9de6 | ||
|
|
e92f9c15d3 | ||
|
|
9ea8e74394 | ||
|
|
da23b95cfd | ||
|
|
e6498cdcb3 | ||
|
|
182526cdc8 | ||
|
|
bc13c5b065 | ||
|
|
bdabfcf935 | ||
|
|
e8063a2b13 | ||
|
|
875658a661 | ||
|
|
4460475fb0 | ||
|
|
6c284a94db | ||
|
|
8edd3badfc | ||
|
|
2cd3bba98d | ||
|
|
f373892870 | ||
|
|
371b542d44 | ||
|
|
f197df6dbe | ||
|
|
ad047cc717 | ||
|
|
ba4a8bfad8 | ||
|
|
50f650fef6 | ||
|
|
ddd9c387d3 | ||
|
|
d6b9e46ec1 | ||
|
|
dbe7720a27 | ||
|
|
fcf5815468 | ||
|
|
aa068147ab | ||
|
|
c4326421fd | ||
|
|
fd6ccb729c | ||
|
|
3128bca4ad | ||
|
|
c82130a616 | ||
|
|
571c5ed825 | ||
| b401ea2906 | |||
|
|
77768cc85f | ||
|
|
b746d6e3f9 | ||
|
|
2aa4032e1f | ||
|
|
ef755bac81 | ||
|
|
8dece735d1 | ||
|
|
8e848894fa | ||
|
|
39e1155bf9 | ||
|
|
4153cae9c4 | ||
|
|
afa61deaa6 | ||
|
|
859c4787cf | ||
|
|
cce62bf9f7 | ||
|
|
2f2f1227ec | ||
|
|
d397b31ca4 | ||
|
|
15955a13f4 | ||
|
|
76fae36178 | ||
|
|
2519ed8650 | ||
|
|
bcb4ffa465 | ||
|
|
a15789a1b4 | ||
|
|
63ffd7e37a | ||
|
|
8d80168bda | ||
|
|
a0e58e029a | ||
|
|
4441618d0f | ||
|
|
ac70d8926f | ||
|
|
b216a67c3b | ||
|
|
2b268fe168 | ||
|
|
bea3058004 | ||
|
|
8883a55df6 | ||
|
|
9f36d786d0 | ||
|
|
80f21648ce | ||
|
|
51c8f461c0 | ||
|
|
9c9ad63793 | ||
|
|
12417a9be7 | ||
|
|
7de05794e6 | ||
|
|
0b52f34940 | ||
|
|
9fc21568e7 | ||
|
|
f77f6697d1 | ||
|
|
de14b8418f | ||
|
|
b5274b6e68 | ||
|
|
ac56bc4aec | ||
|
|
a848c05afa | ||
|
|
d3fd6f6ed1 |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "firmware/components/libgit2/vendor"]
|
||||
path = firmware/components/libgit2/vendor
|
||||
url = https://github.com/libgit2/libgit2.git
|
||||
@@ -4,5 +4,5 @@
|
||||
"semi": false,
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": []
|
||||
"ignorePatterns": ["firmware/components/libgit2/vendor/**"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"arguments": []
|
||||
},
|
||||
"initialization_options": {
|
||||
"linkedProjects": ["firmware/Cargo.toml"],
|
||||
"cargo": {
|
||||
"target": "xtensa-esp32s3-espidf",
|
||||
"allTargets": false
|
||||
|
||||
85
CONTEXT.md
85
CONTEXT.md
@@ -2,7 +2,8 @@
|
||||
|
||||
A single-purpose writing appliance: e-ink + mechanical keyboard + ESP32-S3. The
|
||||
user opens the lid, writes Markdown, and (when they choose) publishes to a git
|
||||
remote. This glossary fixes the language of that workflow.
|
||||
remote. This glossary fixes the language of that workflow, and of the screen
|
||||
the writer looks at while doing it.
|
||||
|
||||
**Related docs:**
|
||||
[`README.md`](README.md) — project overview, hardware, macro roadmap.
|
||||
@@ -14,8 +15,9 @@ by user-facing weight. References the terms in this file as canonical.
|
||||
surface, expressed in this vocabulary.
|
||||
[`docs/v0.1-mvp-technical.md`](docs/v0.1-mvp-technical.md) — how v0.1 is
|
||||
built.
|
||||
[`docs/roadmap.md`](docs/roadmap.md) — per-version scope, where new terms
|
||||
(e.g. multi-file **Buffer** concepts at v0.5) will enter this glossary.
|
||||
[`docs/macroplan.md`](docs/macroplan.md) — per-version scope, where new terms
|
||||
enter this glossary as versions land (the v0.5 multi-file **Buffer** terms
|
||||
are in).
|
||||
|
||||
## Language
|
||||
|
||||
@@ -33,28 +35,77 @@ stay Local for their lifetime. Lives under `/sd/local/`.
|
||||
_Avoid_: draft, private, untracked, scratch (these all imply impermanence or
|
||||
promotability, which is not the model).
|
||||
|
||||
### Editing model
|
||||
|
||||
**Buffer**:
|
||||
A **File** loaded into memory for editing, with its own caret, scroll position,
|
||||
and undo history. Opening a file makes it the **active buffer** — the one the
|
||||
**Writing column** shows. Up to three buffers stay **resident** at once (the
|
||||
active one plus two parked in the background); switching back to a resident
|
||||
buffer restores its caret and undo without re-reading the card. A fourth open
|
||||
**evicts** the least-recently-used resident buffer — saved first if it has
|
||||
unsaved edits, so nothing is lost.
|
||||
_Avoid_: tab, window, document (a buffer is not a UI chrome element); "the file"
|
||||
when you mean the in-memory copy rather than the bytes on the card.
|
||||
|
||||
**Open**:
|
||||
Bringing a **File** into the **active buffer**, via `Cmd-P` (the file
|
||||
palette); new files are created with `:enew <file>` or the palette's
|
||||
`> new file` (`:e` was retired in v0.6). Scope is read from where the file
|
||||
lives (`/sd/repo` → **Tracked**, `/sd/local` → **Local**), never chosen at
|
||||
open time.
|
||||
_Avoid_: load (implementation talk for the disk read behind an Open).
|
||||
|
||||
### User-facing actions
|
||||
|
||||
**Save**:
|
||||
The act of durably writing the current buffer to the SD card. Triggered by
|
||||
`Ctrl-S`. Applies to both **Tracked** and **Local** files.
|
||||
`:w` (and by the idle auto-save when `save_on_idle` is on). Applies to both
|
||||
**Tracked** and **Local** files.
|
||||
_Avoid_: write, flush, persist (use them only in implementation talk).
|
||||
|
||||
**Publish**:
|
||||
The atomic act of pushing the current state of the entire **Tracked** working
|
||||
copy to the git remote. Workspace-scoped, not buffer-scoped: a **Publish**
|
||||
ships every dirty **Tracked** file on the device, not just the one the user is
|
||||
viewing. Triggered by `Ctrl-G`. Internally: stage all → commit with a
|
||||
timestamp message → push → on push failure, pull (merge, no-edit) → push
|
||||
viewing. Triggered by `:gp`. Internally: splice the journaled dirty paths
|
||||
onto HEAD's tree → commit with a timestamped message → push → on a rejected
|
||||
push, fetch and replay the commit onto the remote tip (no merge) → push
|
||||
again. Unavailable in **Local**.
|
||||
_Avoid_: push, commit, sync, upload, git-push (these leak transport details
|
||||
into user-facing language).
|
||||
|
||||
> **Commit** is deliberately _not_ a user-facing term. The device authors all
|
||||
> commit messages itself (ISO-8601 timestamp); the user never sees a commit
|
||||
> commit messages itself (a timestamped message); the user never sees a commit
|
||||
> prompt. A **Publish** is the only user-observable unit of "shipping work";
|
||||
> internal commits are an implementation detail of that.
|
||||
|
||||
### Screen regions
|
||||
|
||||
**Writing column**:
|
||||
The left region of the panel showing the text being edited — the _only_ region
|
||||
that repaints per keystroke. A 63-col region split into a **line-number gutter**
|
||||
(absolute numbers, 2–4 cols wide, sized to the buffer's line count) and the text
|
||||
column it steals from (~60 cols for a file ≤ 99 lines). Full panel height;
|
||||
straddles the driver's `x = 396` seam invisibly.
|
||||
_Avoid_: edit area, text area, main pane (superseded — they named the old
|
||||
full-width text region before the side panel carved out its right edge).
|
||||
|
||||
**Side panel**:
|
||||
The right region (~160 px / ~17 cols at its `FONT_9X15` metadata font, full
|
||||
height) holding all metadata:
|
||||
filename + dirty dot, word count, elapsed time, clock, Wi-Fi,
|
||||
keyboard-disconnect flag, publish state, and the mode indicator at its
|
||||
bottom-left. Sits entirely in the master half
|
||||
(right of the `x = 396` seam). Every field is static, event-driven, or
|
||||
throttled — never per-keystroke.
|
||||
_Avoid_: header, status line, status bar (retired — the old top header band and
|
||||
bottom status band are both collapsed into this one right-hand region); sidebar.
|
||||
Do not write bare **panel**: it collides with the **transient panel** (the
|
||||
modal full-screen help/config view that swaps in over the editor — a later
|
||||
release, see [`docs/spikes.md`](docs/spikes.md) Spike 11). Always qualify:
|
||||
_side panel_ vs _transient panel_.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **File** belongs to exactly one scope (**Tracked** or **Local**), fixed at
|
||||
@@ -66,22 +117,23 @@ into user-facing language).
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "If I'm in a **Local** file and I press `Ctrl-G`, what happens?"
|
||||
> **Dev:** "If I'm in a **Local** file and I run `:gp`, what happens?"
|
||||
> **Domain expert:** "Nothing — **Publish** is unavailable in **Local**. The
|
||||
> status line says so. There is no path from **Local** to the remote."
|
||||
> side panel says so. There is no path from **Local** to the remote."
|
||||
> **Dev:** "So if I want to publish something that started as a journal entry,
|
||||
> I have to copy-paste it into a **Tracked** file?"
|
||||
> **Domain expert:** "Yes, deliberately. Promotion is a manual gesture, not a
|
||||
> built-in operation."
|
||||
> **Dev:** "And if the remote has changed since I last pulled — does
|
||||
> **Publish** fail?"
|
||||
> **Domain expert:** "It pulls (merge, no edit) and pushes again. From the
|
||||
> user's view it's one action with one outcome — success or retry."
|
||||
> **Domain expert:** "It fetches, replays the device's commit onto the remote
|
||||
> tip — no merge commit — and pushes again. From the user's view it's one
|
||||
> action with one outcome — success or retry."
|
||||
|
||||
## Principles
|
||||
|
||||
- **The device is a writing tool, not a sync engine.** Every git operation is
|
||||
the direct, in-session consequence of a `Ctrl-G` the user pressed. The
|
||||
the direct, in-session consequence of a `:gp` (or `:gl` pull) the user ran. The
|
||||
device does not auto-publish, auto-pull, retry-on-boot, or otherwise
|
||||
reconcile remote state in the background. If a previous **Publish** ended
|
||||
mid-flight and left a local commit unpushed, the next user-initiated
|
||||
@@ -93,14 +145,15 @@ into user-facing language).
|
||||
reason — the device tracks one linear stream of work on whichever branch
|
||||
the remote was cloned on, and never switches.
|
||||
- **Durability before delivery.** A **Publish**'s user-meaningful moment is
|
||||
when the local commit lands (~0.2 s), not when the push completes
|
||||
(~5–10 s). The status line surfaces the commit-landed state as soon as
|
||||
when the local commit lands (a few seconds — the splice), not when the push
|
||||
completes (the rest of a ~12–24 s `:gp` on the real notes repo). The side
|
||||
panel surfaces the commit-landed state as soon as
|
||||
it exists; the remaining push time is the transport of an already-safe
|
||||
thing. Long-form rationale:
|
||||
[`docs/notes/ctrl-g-perceived-latency.md`](docs/notes/ctrl-g-perceived-latency.md).
|
||||
- **No state the user didn't ask for.** No banners about pending work, no
|
||||
prompts about divergence, no "did you mean to publish" warnings. The status
|
||||
line reflects the _current_ action's outcome, nothing else.
|
||||
prompts about divergence, no "did you mean to publish" warnings. The side
|
||||
panel reflects the _current_ action's outcome, nothing else.
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
|
||||
@@ -77,6 +77,6 @@ catch them early next time.
|
||||
|
||||
- **Function-vs-characteristic conflation.** Calling HOWs
|
||||
"engineering functions". A function is a transformation; a
|
||||
characteristic is one of its measurable attributes. HOWs *measure*
|
||||
characteristic is one of its measurable attributes. HOWs _measure_
|
||||
functions; they are not functions. (See
|
||||
[`docs/qfd.md`](docs/qfd.md) §8 for the rename cascade.)
|
||||
|
||||
244
MEMORY_AUDIT.md
Normal file
244
MEMORY_AUDIT.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Memory-safety audit — firmware Rust
|
||||
|
||||
> **Snapshot.** Audited at commit `371b542` on 2026-07-10. This is a
|
||||
> point-in-time review of the `unsafe`/FFI surface; line numbers and the
|
||||
> soundness arguments below are only valid against that tree. Re-run it after
|
||||
> any change to `usb_kbd.rs`, the SD/git FFI, or an `esp-idf-sys` bump.
|
||||
>
|
||||
> **Remediation (2026-07-10).** Findings #1 and #3 are addressed in follow-up
|
||||
> commits, and the decode logic is now the host-tested `keymap` crate (14 tests,
|
||||
> including an ASCII-invariant sweep over all 256 usage IDs and a never-panics
|
||||
> fuzz). The `usb_kbd.rs` line numbers below predate that refactor. The fixes are
|
||||
> verified by inspection plus the `keymap` tests, but still need an on-device
|
||||
> hot-plug run to confirm — #1/#3 live in FFI that can't run on the host.
|
||||
> Per-finding status is inline.
|
||||
|
||||
## Scope & method
|
||||
|
||||
Memory-unsafety in Rust can only originate in `unsafe`, so the audit focuses
|
||||
there. The entire `unsafe` surface is FFI into ESP-IDF / libgit2, concentrated
|
||||
in:
|
||||
|
||||
- `firmware/src/usb_kbd.rs` — by far the largest, and the only place with C
|
||||
callbacks, raw transfer buffers, and `slice::from_raw_parts`. **Highest
|
||||
risk.**
|
||||
- `firmware/src/bin/{sd_fat,git_push,git_sync,wifi_tls}.rs` — descriptor-struct
|
||||
zeroing plus simple FFI calls.
|
||||
- `firmware/src/epd.rs`, `firmware/src/editor.rs`,
|
||||
`spikes/spike7-git-push/` — **100% safe Rust, zero `unsafe`.** They cannot
|
||||
cause UB; their failure mode is panic→abort, not corruption.
|
||||
|
||||
Bottom line: the FFI code is careful, with real SAFETY reasoning and one
|
||||
genuinely good defensive clamp. The audit found **one plausible true-UB path**
|
||||
(conditional, ordering-dependent) plus a set of latent footguns and non-UB
|
||||
robustness gaps. Nothing looks like a slam-dunk exploitable bug in normal
|
||||
operation.
|
||||
|
||||
## Score: 8 / 10 (memory safety only)
|
||||
|
||||
- The design leans on safe Rust — only ~40 lines of genuine `unsafe`, all thin
|
||||
FFI wrappers, and the safe core (editor, framebuffer, layout) can't produce
|
||||
UB at all. Right architecture; most of the score.
|
||||
- Every `unsafe` site carries real SAFETY reasoning, and the one place
|
||||
untrusted device data sizes a raw slice (`report_cb`) is correctly clamped.
|
||||
- Not a 9–10 because finding #1 is closed by an _assumed_ event ordering rather
|
||||
than by construction, and findings #2–#3 are latent, dependency-sensitive
|
||||
risks. Real memory safety means the invariant is _enforced_, not hoped for.
|
||||
- Closing #1 so the in-flight invariant is explicit → 9. A 10 on FFI this heavy
|
||||
needs the structural guards in the "Regression testing" section.
|
||||
|
||||
**Update (2026-07-10):** #1 and #3 are now addressed in code and the decode path
|
||||
is host-tested (see per-finding status). Once the hot-plug bench run confirms #1
|
||||
on hardware this becomes a 9; #2 and #4 are the remaining gap to 10.
|
||||
|
||||
This is a _memory-safety_ score. Robustness (leaks on hot-plug) and correctness
|
||||
would score separately and slightly lower.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. Possible use-after-free freeing the interrupt transfer on unplug — `usb_kbd.rs:176-182` (highest)
|
||||
|
||||
On `DEV_GONE`, `client_loop` frees `report_xfer` and closes the device:
|
||||
|
||||
```rust
|
||||
if !report_xfer.is_null() {
|
||||
unsafe { usb_host_transfer_free(report_xfer) }; // line 177
|
||||
report_xfer = ptr::null_mut();
|
||||
}
|
||||
```
|
||||
|
||||
The interrupt-IN transfer is resubmitted on every completion (`report_cb`,
|
||||
line 224), so it is **in-flight** most of the time. `report_cb` only fires from
|
||||
inside `usb_host_client_handle_events` (line 159); the free happens _after_ that
|
||||
call returns. The code implicitly relies on the transfer's final
|
||||
canceled-completion callback having already run in the same `handle_events`
|
||||
batch that delivered `DEV_GONE`.
|
||||
|
||||
If the library delivers the `DEV_GONE` client event **before** the transfer's
|
||||
cancellation callback, then either:
|
||||
|
||||
- `usb_host_transfer_free` refuses an in-flight transfer
|
||||
(`ESP_ERR_INVALID_STATE` — its return value is **ignored** here → silent
|
||||
leak), or
|
||||
- a later `usb_host_client_handle_events` iteration invokes `report_cb` on the
|
||||
freed transfer → `let t = unsafe { &mut *transfer };` (line 219) is a
|
||||
**use-after-free**.
|
||||
|
||||
Ordering-dependent, so medium confidence rather than a definite always-fires
|
||||
bug — but it's the one path in the codebase that can reach real UB, and it's
|
||||
exactly the teardown race ESP-IDF's USB Host contract warns about (free only
|
||||
when not in-flight). Verify against the library semantics rather than assuming
|
||||
the batch ordering holds.
|
||||
|
||||
**Fix.** Halt/dequeue the endpoint and wait for the last completion callback
|
||||
before freeing — or track an in-flight flag set on submit and cleared in
|
||||
`report_cb`, and only free once it's clear (loop `handle_events` until then). At
|
||||
minimum, check the return value of `usb_host_transfer_free` and don't null the
|
||||
pointer / proceed to `device_close` while it reports the transfer busy.
|
||||
|
||||
**Status: fixed in code.** A `REPORT_INFLIGHT` flag is set on submit and cleared
|
||||
first thing in every `report_cb`. The teardown moved into a `close_device`
|
||||
helper that pumps client events (bounded) until the transfer quiesces before
|
||||
freeing it — and _leaks it rather than frees_ if it never does, since a leak is
|
||||
recoverable and a UAF is not. `usb_host_transfer_free`'s return value is now
|
||||
checked. Needs the hot-plug bench run below to confirm on hardware.
|
||||
|
||||
### 2. `mem::zeroed()` / `MaybeUninit::zeroed().assume_init()` on bindgen structs is a latent footgun — `usb_kbd.rs:110,143`, `sd_fat.rs:138,173,192`
|
||||
|
||||
**Sound today**: every field of the zeroed descriptors is valid at all-zero (C
|
||||
fn pointers are `Option<extern fn>` → `None`; floats → `0.0`; enums are `u32`
|
||||
aliases; bools → `false`).
|
||||
|
||||
The risk is that this soundness is **invisible and unenforced**. `esp-idf-sys`
|
||||
is pinned to a git branch (`[patch.crates-io]` in `Cargo.toml`), so a bindgen
|
||||
regen that introduces a field where zero is an invalid bit pattern (a reference,
|
||||
`NonNull`, or a niche enum) turns `assume_init()` into instant UB with no
|
||||
compiler warning — the classic `zeroed`-on-FFI trap.
|
||||
|
||||
**Fix.** These structs are fully overwritten in their meaningful fields anyway.
|
||||
Prefer keeping them as `MaybeUninit` and writing fields via `addr_of_mut!`, or
|
||||
at least add a static/compile-time assertion (or a test) that pins the
|
||||
zero-is-valid assumption so a dependency bump fails loudly.
|
||||
|
||||
**Status: left as-is deliberately** (sound today, low severity). There is no
|
||||
clean _stable_ compile-time "all-zero is a valid bit pattern" assertion, and a
|
||||
runtime canary can't fire before the UB it would guard (`assume_init` is the UB
|
||||
site). The `addr_of_mut!` rewrite is the real fix but churns five call sites for
|
||||
a latent-only risk; deferred until an `esp-idf-sys` bump makes it worth it.
|
||||
|
||||
### 3. Resource leaks on re-attach and on submit error — not UB — `usb_kbd.rs:163-168, 417/436, 449`
|
||||
|
||||
- A second keyboard attaching while one is open makes `setup_keyboard` overwrite
|
||||
`open_dev`/`report_xfer` (lines 164-167) without freeing/closing the previous
|
||||
ones → leaked transfer + device handle.
|
||||
- `control_request`: if `usb_host_transfer_submit_control` errors, the `?` at
|
||||
line 430 returns before `usb_host_transfer_free(xfer)` (line 436) → leaked
|
||||
64-byte transfer. A submit failure in `start_report_polling` (line 458) leaks
|
||||
similarly.
|
||||
|
||||
Not memory-unsafe — worst case is heap exhaustion over many hot-plug cycles,
|
||||
which matters for an always-powered appliance. Guard the re-attach case
|
||||
(`if !open_dev.is_null()` → tear down first) and free-on-error in
|
||||
`control_request`.
|
||||
|
||||
**Status: fixed in code.** A new attach while a device is still open now runs
|
||||
`close_device` on the old one first. `control_request` and `start_report_polling`
|
||||
free the transfer they allocated on a submit error before returning.
|
||||
|
||||
### 4. USB thread stack sizing is unverified — `usb_kbd.rs:121,132`
|
||||
|
||||
Daemon thread = 4096 B, client thread = 8192 B. The client thread runs
|
||||
`report_cb → handle_report → enqueue → log::info!`, and formatting/logging is
|
||||
stack-hungry; a FreeRTOS stack overflow is silent memory corruption unless the
|
||||
canary/MPU check catches it. `git_push.rs` already reasons carefully about this
|
||||
(96 KB, with a comment block on why); the USB threads deserve the same
|
||||
measured-headroom treatment. Low confidence it's actually too small — measure
|
||||
the high-water mark, don't change blindly.
|
||||
|
||||
### 5. `report_cb` bounds clamp — done right (noted, not a defect) — `usb_kbd.rs:221-222`
|
||||
|
||||
```rust
|
||||
let n = (t.actual_num_bytes as usize).min(BOOT_REPORT_LEN);
|
||||
let report = unsafe { core::slice::from_raw_parts(t.data_buffer, n) };
|
||||
```
|
||||
|
||||
The one place device-controlled data sizes a raw slice. `.min(BOOT_REPORT_LEN)`
|
||||
correctly clamps even a negative/garbage `actual_num_bytes` (the `i32 as usize`
|
||||
blows up huge, `.min(8)` reins it back), and `handle_report` re-guards with
|
||||
`report.len() < 3`. A malicious/broken keyboard can't overread the 8-byte
|
||||
buffer here.
|
||||
|
||||
## Safe modules — no UB possible by construction
|
||||
|
||||
`editor.rs`, `epd.rs`, and the spike are safe Rust. Two invariants confirmed
|
||||
rather than assumed:
|
||||
|
||||
- **`editor.rs` byte-indexing invariant holds.** It slices the buffer by byte
|
||||
offset treating it as a char index (`self.text[..self.caret]`,
|
||||
`text.as_bytes()[..]`). Valid only because the buffer is pure ASCII — and it
|
||||
is: the only source of `Key::Char` is `translate()` (`usb_kbd.rs:298`), which
|
||||
emits ASCII exclusively, and every internal insert (TAB, list markers, table
|
||||
formatting) is ASCII too. So byte == char holds and those slices can't hit a
|
||||
char-boundary panic. **When the v0.2 UTF-8 work lands, this invariant breaks
|
||||
into panics** — add a `debug_assert`/comment at the insert boundary then.
|
||||
- **`epd.rs` slicing is bounded by its asserts.** `display_frame*` assert
|
||||
`fb.len() == FB_BYTES` and `y0 + h <= HEIGHT`, the row math stays within
|
||||
`FB_BYTES`, and the u16 arithmetic (`x+w-1`, `y0+h`) doesn't overflow given
|
||||
those bounds.
|
||||
|
||||
`git_push.rs`/`git_sync.rs`: the `RemoteCallbacks` closures capture
|
||||
`Rc<RefCell<…>>` and run synchronously on the git thread during `remote.push` —
|
||||
never sent across threads, so no `Send`/aliasing hazard.
|
||||
`git2::opts::set_ssl_cert_file` (line 267) is `unsafe` because it sets a
|
||||
process-global; called once in single-threaded setup before the git thread
|
||||
spawns — sound.
|
||||
|
||||
## Regression testing
|
||||
|
||||
The honest constraint first: **the on-target binary can't be run under
|
||||
Miri/ASAN** (`target_os = "espidf"`, all `unsafe` is FFI). So the strategy is
|
||||
split by what's reachable where, ranked by leverage.
|
||||
|
||||
1. **Make the pure logic host-testable (highest leverage).** The functions that
|
||||
take untrusted input or do the slicing are FFI-free: `translate`,
|
||||
`handle_report`'s decode, the editor text ops, `changed_rows` /
|
||||
`only_adds_ink` in `main.rs`, the `epd` row math. Pull them into a
|
||||
no-esp-deps module/crate (workspace member or `#[cfg]`-gated) so `cargo test`
|
||||
runs on host.
|
||||
- **Done for the keyboard decode.** `translate` + the report edge-detection
|
||||
are now the `../keymap` crate (`#![no_std]`, `#![forbid(unsafe_code)]`, zero
|
||||
deps), with 14 host tests including a `translate` **ASCII-invariant sweep**
|
||||
over all 256 usage IDs × modifiers and a **never-panics fuzz** feeding
|
||||
arbitrary-length/content byte streams. The slice itself is bounds-clamped in
|
||||
the FFI layer (finding #5), and once it's a safe `&[u8]` the decode is
|
||||
`forbid(unsafe_code)` — so Miri adds nothing over the panic-freedom the fuzz
|
||||
test already proves; the plain `cargo test` run is the guard.
|
||||
- **Still TODO:** the editor text ops and `changed_rows`/`only_adds_ink` are
|
||||
also FFI-free and worth the same treatment, but stay coupled to
|
||||
`embedded-graphics` / `epd` constants for now.
|
||||
2. **Compile-time guards for the `zeroed()` assumption (#2).** Static assertions
|
||||
(or a test constructing the struct and checking a sentinel field is
|
||||
`None`/`0`) so an `esp-idf-sys` bump fails loudly instead of going silently
|
||||
UB.
|
||||
3. **`clippy` as a ratchet.** `#![warn(clippy::undocumented_unsafe_blocks)]` +
|
||||
`clippy::multiple_unsafe_ops_per_block`, deny-warnings in CI. Forces every
|
||||
new `unsafe` to carry a SAFETY comment — keeps the existing discipline from
|
||||
eroding. **Deferred:** turning it on crate-wide today floods warnings on the
|
||||
many existing bare-FFI `unsafe` one-liners, which trains people to ignore it.
|
||||
Worth doing _after_ a pass that adds `// SAFETY:` to the existing blocks.
|
||||
4. **On-device tests for what only exists on device (#1, #3, #4).**
|
||||
- **Hot-plug stress loop**: attach/detach ~100× on a bench script, log
|
||||
`esp_get_free_heap_size` each cycle. A downward trend proves the leaks
|
||||
(#3); a crash/`LoadProhibited` on the freed transfer proves the UAF (#1).
|
||||
- **Stack high-water mark**: `uxTaskGetStackHighWaterMark` on the USB
|
||||
threads, asserted in a debug build, guards #4.
|
||||
5. **Fix #1 by making it impossible, not just tested.** The best defense is an
|
||||
in-flight flag set on submit and cleared in `report_cb`, with a
|
||||
`debug_assert!(!in_flight)` before the free. Any future change that
|
||||
reintroduces the race trips the assert in the hot-plug loop instead of
|
||||
corrupting memory in the field.
|
||||
|
||||
Priority if you only do some: **1 (fuzz `handle_report` under Miri) + 5
|
||||
(in-flight flag)** cover the two real memory-safety concerns; 2 and 3 are cheap
|
||||
insurance against dependency bumps; 4 is the only way to regression-test the
|
||||
on-device races and is worth it for an always-powered appliance.
|
||||
225
README.md
225
README.md
@@ -4,9 +4,15 @@ A distraction-free, hackable, DIY writing machine. ESP32-S3 + e-ink + a real
|
||||
mechanical keyboard. You write Markdown, you commit, you push. Nothing else
|
||||
runs on it.
|
||||
|
||||
> Status: pre-MVP. Hardware not yet on bench. Bring-up in progress.
|
||||
|
||||
How each decision is weighted against the user-facing requirements — and the critical performance budget that falls out — lives in [`docs/qfd.md`](docs/qfd.md). The ontology layers those docs use (WHAT / Function / Characteristic / Metric / Target) are defined in [`GLOSSARY.md`](GLOSSARY.md).
|
||||
> **Status: v0.7 shipped, hardware on bench.** v0.1 (MVP — boots, edits,
|
||||
> publishes) shipped 2026-07-11; v0.2 through v0.7 (vim navigation and editing,
|
||||
> file palette + multi-buffer, Markdown affordances, `/` search, `:gp` push /
|
||||
> `:gl` pull) followed within the week. Firmware is at 0.7.0; next up is v0.8
|
||||
> (battery + sleep). Live per-item status:
|
||||
> [`docs/macroplan.md`](docs/macroplan.md) · doc index:
|
||||
> [`docs/README.md`](docs/README.md) · failure write-ups:
|
||||
> [`docs/postmortems/`](docs/postmortems/README.md) · improvement loops:
|
||||
> [`docs/kaizen/`](docs/kaizen/README.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -21,82 +27,56 @@ Two file scopes coexist on the SD card — formal definitions in
|
||||
[`CONTEXT.md`](CONTEXT.md):
|
||||
|
||||
- **Tracked** — lives in the git working copy, gets **Published** when the
|
||||
user presses `Ctrl-G`.
|
||||
user runs `:gp`.
|
||||
- **Local** — never leaves the device. Permanently-private: journal entries,
|
||||
scratch, things that aren't anyone else's business. There is no "promote
|
||||
to Tracked" gesture — scope is fixed at file creation.
|
||||
|
||||
Same editor, same keymap; the difference is just whether `Ctrl-G` (publish to
|
||||
Same editor, same keymap; the difference is just whether `:gp` (publish to
|
||||
the remote) is offered.
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| Part | Choice | Why |
|
||||
| --------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MCU | **ESP32-S3-N16R8** (16 MB flash, 8 MB octal PSRAM) | USB OTG host (for the keyboard), Wi-Fi, BLE, dual core @ 240 MHz, plenty of PSRAM for git pack data and screen buffer. Best-supported Rust target in the ESP family. |
|
||||
| Display | **GDEY0579T93 + DESPI-c579 breakout** (5.79", 792×272, 1-bit) | Good Display panel matched with its own FPC breakout. Strip aspect (~2.9:1) — Freewrite-coded: ~12 lines of edit area, ~95 cols. Tiny framebuffer (~27 KB) leaves PSRAM headroom. The DESPI-c579 is a passive level-shifter / FPC-to-header board, not an active controller — driven over plain SPI like any other epd. |
|
||||
| Keyboard | **Nuphy Air60/Halo65 wired USB-C** | ESP32-S3 acts as USB host via TinyUSB. BLE-HID is a fallback but contends with Wi-Fi for radio time during push. |
|
||||
| Storage | microSD over SPI | Holds both the git working copy (`/sd/repo/`) **and** the local-only scratch space (`/sd/local/`). Internal flash is for firmware + config only. |
|
||||
| Power | **USB-C wall power for MVP**, 18650 + IP5306 in Phase 3 | Measure power profile on real hardware before sizing the battery. E-ink + sleep should give multi-day battery life but battery introduces charging, safety, and BMS complexity we don't need on day one. |
|
||||
| Enclosure | 3D-printed, hinged lid | Phase 4 concern. |
|
||||
**ESP32-S3-N16R8** (16 MB flash, 8 MB PSRAM) · **GDEY0579T93** 5.79″ e-ink
|
||||
strip (792×272, ~2.9:1 — biases the UX toward "current line + recent context",
|
||||
the writing posture we want) · **Nuphy wired USB keyboard** with the S3 as USB
|
||||
host · **microSD over SPI** · **USB-C wall power** for the MVP, battery in
|
||||
v0.8.
|
||||
|
||||
**Why the strip aspect:** the ~2.9:1 long-narrow shape biases the UX
|
||||
toward "current line + recent context" rather than "full page" — the
|
||||
writing posture we want. The renderer stays resolution-agnostic so a
|
||||
10.3" e-ink upgrade (v1.x) is a swap, not a rewrite. Medium choice
|
||||
(e-ink over LCD / memory LCD / OLED) and panel rationale:
|
||||
[ADR-003](docs/adr.md#adr-003-display-medium--e-ink-gdey0579t93-panel).
|
||||
Full part table, rationale, and bench status:
|
||||
[`docs/hardware.md`](docs/hardware.md). Enclosure — a parametric,
|
||||
3D-printable typewriter-body case (OpenSCAD): [`hardware/case/`](hardware/case/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Software stack
|
||||
|
||||
**Language: Rust on `esp-idf-rs` (std).** Decision is load-bearing — see the
|
||||
rejected alternatives below, and [`docs/adr.md`](docs/adr.md) for the full
|
||||
decision log covering language, UI strategy, display, git lib, auth,
|
||||
concurrency, storage, power, and keyboard transport. How each decision is
|
||||
weighted against the user-facing requirements — and the critical performance
|
||||
budget that falls out — lives in [`docs/qfd.md`](docs/qfd.md). The ontology
|
||||
layers those docs use (WHAT / Function / Characteristic / Metric / Target)
|
||||
are defined in [`GLOSSARY.md`](GLOSSARY.md).
|
||||
**Language: Rust on `esp-idf-rs` (std).** Every stack decision — language, UI
|
||||
strategy, display, git lib, auth, concurrency, storage, power, keyboard
|
||||
transport — has an ADR in [`docs/adr.md`](docs/adr.md), including the rejected
|
||||
alternatives (Ratatui, Gleam + Shore on AtomVM, C/Arduino — ADR-001/002). How
|
||||
each decision is weighted against the user-facing requirements lives in
|
||||
[`docs/qfd.md`](docs/qfd.md); the ontology those docs use is defined in
|
||||
[`GLOSSARY.md`](GLOSSARY.md). Where a default traces to a cost curve rather than
|
||||
a discrete pick — energy, latency, or memory bending against an interval or size
|
||||
— the curve and its knee live in
|
||||
[`docs/tradeoff-curves/README.md`](docs/tradeoff-curves/README.md). A
|
||||
memory-safety review of the Rust `unsafe`/FFI
|
||||
surface (mostly `usb_kbd.rs`) is in [`MEMORY_AUDIT.md`](MEMORY_AUDIT.md).
|
||||
|
||||
| Layer | Crate / Component | Notes |
|
||||
| ---------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| HAL / runtime | `esp-idf-svc`, `esp-idf-hal` | std build, gives us heap, threads, VFS, mbedtls, Wi-Fi stack. |
|
||||
| Display | `embedded-graphics` + `epd-waveshare` (or custom driver) | Pixel framebuffer with partial-refresh regions. We track dirty rects ourselves. The GDEY0579T93 uses an SSD1683-class controller; if it's not already in `epd-waveshare`, we write a small driver against `embedded-hal` SPI — ~300 LoC, low risk. |
|
||||
| Editor core | Custom, in-tree | Rope buffer (`ropey`), mode state machine, Vim keymap table. |
|
||||
| TUI-style layout | Custom thin layer (~500 LoC) | API inspired by Ratatui (`Layout`, `Block`, `Paragraph`) but renders directly to `embedded-graphics`. See below. |
|
||||
| USB host | `esp-idf` TinyUSB bindings | Boot-protocol HID is enough for the keyboard. |
|
||||
| Git | `gitoxide` (`gix`) | Pure-Rust, modular. We only need add / commit / push (smart HTTP). No libgit2, no mbedtls glue beyond what `esp-idf` already gives us. |
|
||||
| TLS | `mbedtls` via `esp-idf` | Used for GitHub HTTPS. ~120 KB heap during handshake — fits in PSRAM. |
|
||||
| Auth | GitHub Personal Access Token in encrypted NVS | SSH on embedded is painful; HTTPS+PAT is the pragmatic path. |
|
||||
| Filesystem | FAT on SD (`esp_vfs_fat`) | Working copy lives here. Internal LittleFS holds config. |
|
||||
|
||||
### Why not Ratatui
|
||||
|
||||
Ratatui assumes a **character-grid terminal** with an ANSI backend. E-ink is a
|
||||
**pixel framebuffer with partial-refresh windows**. The right primitive for
|
||||
e-ink is dirty-rectangle tracking aligned to the panel's refresh regions —
|
||||
Ratatui's per-cell diff model fights this. We can borrow its widget _API
|
||||
shape_ (it's a good one) without dragging in the terminal abstraction. Net
|
||||
saving: probably 200 KB of binary and a lot of pretending the screen is a
|
||||
VT100.
|
||||
|
||||
### Why not Gleam + Shore
|
||||
|
||||
BEAM doesn't run on ESP32. AtomVM does, but: memory budget is tight, Gleam-on-
|
||||
AtomVM is bleeding-edge, and there are no bindings for USB host / e-ink / SD /
|
||||
TLS / git in that ecosystem. Shore is also terminal-oriented, so the same
|
||||
impedance mismatch as Ratatui applies. Building this on Gleam would be a
|
||||
research project stacked on a research project. Revisit in 2-3 years.
|
||||
|
||||
### Why not C / Arduino
|
||||
|
||||
Workable, well-trodden, fastest path to a blinking screen. But this is a
|
||||
project I want to keep evolving — Rust's refactoring leverage and type safety
|
||||
pay off the moment we start adding modes, palette, search, etc.
|
||||
| Layer | Choice | Notes |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| HAL / runtime | `esp-idf-svc`, `esp-idf-hal` | std build: heap, threads, VFS, mbedtls, Wi-Fi stack. |
|
||||
| Display | Custom SSD1683 driver (`firmware/src/epd.rs`) + `embedded-graphics` | Dual-controller 792×272 panel; dirty-rect partial refresh (~630 ms measured). Panel geometry + the in-memory `Frame` live in the shared `display/` crate. |
|
||||
| UI layer | Custom thin widget layer | Ratatui's API _shape_ without its char-grid terminal model ([ADR-002](docs/adr.md#adr-002-ui-strategy--custom-widgets-on-embedded-graphics-not-ratatui)). |
|
||||
| Editor core | Custom, in-tree (`editor/` crate) | Modal (Normal / Insert / Visual / VisualLine / View / Command / Palette), motions, operators + text objects; UTF-8 buffer fed by the dead-key composer; smartcase + accent-folded `/` search. Host-built and host-tested off the xtensa target. |
|
||||
| USB host | `esp-idf` TinyUSB bindings | Boot-protocol HID; verified on hardware (Spike 4). |
|
||||
| Git | **libgit2 via `git2`**, built as an esp-idf component with mbedTLS (`firmware/components/libgit2/`) | `gix` was the original pick but can't push over HTTPS — the [ADR-004](docs/adr.md#adr-004-git-implementation--gitoxide-gix) kill-switch fired ([postmortem](docs/postmortems/2026-07-05-spike7-gix-https-push.md)). `:gp` publish + `:gl` pull verified on device; ~16 s cold-`:gp` [latency breakdown](docs/notes/sync-latency.md); how the real notes repo went from a 611 s brick to a 24 s sync: [kaizen](docs/kaizen/real-repo-sync.md). |
|
||||
| TLS | `mbedtls` via `esp-idf` | GitHub HTTPS with the chain checked against embedded roots; ≈35 KB heap measured during handshake (Spike 6). |
|
||||
| Auth | HTTPS + GitHub PAT | v0.1 bakes credentials in at build time via `TW_*` env vars; provisioning + at-rest protection is [ADR-011](docs/adr.md#adr-011-credential-provisioning--how-the-pat-reaches-the-device-and-is-protected-at-rest) (open), on-device settings land in v0.9. |
|
||||
| Filesystem | FAT on SD (`esp_vfs_fat`) | Working copy lives here (`/sd/repo` + `/sd/local`); editor prefs are a git-tracked [`.typoena.toml`](docs/typoena-toml.md) in the repo. |
|
||||
|
||||
---
|
||||
|
||||
@@ -118,78 +98,79 @@ around, not against:
|
||||
|
||||
## Roadmap
|
||||
|
||||
Frequent releases. Each version is a usable artifact, not a checkpoint.
|
||||
Macro-plan below; per-version scope lives in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
Releases are frequent, and every version is a usable artifact rather than a
|
||||
checkpoint. Per-version scope, current `[x]`/`[~]` marks, and the Macroplan
|
||||
source live in [`docs/macroplan.md`](docs/macroplan.md).
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title Typoena — macro plan
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %b %Y
|
||||
section MVP
|
||||
v0.1 it writes, it pushes :v01, 2026-06-01, 4w
|
||||
section Vim
|
||||
v0.2 navigation :v02, after v01, 3w
|
||||
v0.3 editing :v03, after v02, 3w
|
||||
v0.4 visual + ex :v04, after v03, 2w
|
||||
section Files
|
||||
v0.5 palette + multi-file :v05, after v04, 3w
|
||||
v0.6 markdown :v06, after v05, 2w
|
||||
v0.7 search + git :v07, after v06, 3w
|
||||
section Hardware polish
|
||||
v0.8 battery + sleep :v08, after v07, 4w
|
||||
v0.9 robustness :v09, after v08, 4w
|
||||
v1.0 polish :v10, after v09, 4w
|
||||
```
|
||||
|
||||
| Version | Theme | One-liner |
|
||||
| ------------------------------------------------------- | ------------ | ------------------------------------------ |
|
||||
| [v0.1](docs/roadmap.md#v01--mvp-it-writes-it-pushes--) | MVP | Boots, edits one file, `Ctrl-G` pushes. |
|
||||
| [v0.2](docs/roadmap.md#v02--vim-navigation--) | Vim nav | Normal/Insert, motions, line numbers. |
|
||||
| [v0.3](docs/roadmap.md#v03--vim-editing--) | Vim edit | `dd yy p`, undo/redo, counts. |
|
||||
| [v0.4](docs/roadmap.md#v04--visual-mode--ex-commands--) | Visual + ex | `v V`, `:w :q :e`, status line. |
|
||||
| [v0.5](docs/roadmap.md#v05--file-palette--multi-file--) | Files | `Ctrl-P` over `/repo` + `/local`, buffers. |
|
||||
| [v0.6](docs/roadmap.md#v06--markdown-affordances--) | Markdown | Headings, list continuation, soft-wrap. |
|
||||
| [v0.7](docs/roadmap.md#v07--search--better-git--) | Search + git | `/`, `:Gpull`, `:Gbranch`. |
|
||||
| [v0.8](docs/roadmap.md#v08--power-battery--sleep--) | Power | 18650 + sleep + lid switch. |
|
||||
| [v0.9](docs/roadmap.md#v09--robustness--) | Robustness | Crash-safe writes, reconnect, settings. |
|
||||
| [v1.0](docs/roadmap.md#v10--polish--) | Polish | Boot ≤ 3 s, fonts, enclosure, guide. |
|
||||
| [v1.x](docs/roadmap.md#v1x--stretch--nice-to-have) | Stretch | 10.3" panel, spell-check, themes, BLE. |
|
||||
| Version | Theme | One-liner |
|
||||
| --------------------------------------------------------- | ------------ | -------------------------------------------- |
|
||||
| [v0.1](docs/macroplan.md#v01--mvp-it-writes-it-pushes--) | MVP | Boots, edits one file, `:gp` publishes. |
|
||||
| [v0.2](docs/macroplan.md#v02--vim-navigation--) | Vim nav | Normal/Insert, motions, line numbers. |
|
||||
| [v0.2.5](docs/macroplan.md#v025--international-input--) | Intl input | US-Intl dead keys: à é ê ç, `'`+space = `'`. |
|
||||
| [v0.3](docs/macroplan.md#v03--vim-editing--) | Vim edit | `dd yy p`, undo/redo, counts. |
|
||||
| [v0.4](docs/macroplan.md#v04--visual-mode--ex-commands--) | Visual + ex | `v`/`V` select + `y d c`, `gr` read, `:` ex. |
|
||||
| [v0.5](docs/macroplan.md#v05--file-palette--multi-file--) | Files | `Cmd-P` over `/repo` + `/local`, buffers. |
|
||||
| [v0.6](docs/macroplan.md#v06--markdown-affordances--) | Markdown | Headings, list continuation, soft-wrap. |
|
||||
| [v0.7](docs/macroplan.md#v07--search--better-git--) | Search + git | `/` search, `:gl` pull. |
|
||||
| [v0.8](docs/macroplan.md#v08--power-battery--sleep--) | Power | 18650 + sleep + lid switch. |
|
||||
| [v0.9](docs/macroplan.md#v09--robustness--) | Robustness | Crash-safe writes, reconnect, settings. |
|
||||
| [v1.0](docs/macroplan.md#v10--polish--) | Polish | Boot ≤ 3 s, fonts, themes, enclosure, guide. |
|
||||
| [v1.x](docs/macroplan.md#v1x--stretch--nice-to-have) | Stretch | 10.3″ panel, multi-remote, stats, BLE. |
|
||||
|
||||
---
|
||||
|
||||
## Repo layout (planned)
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
/firmware Rust crate, esp-idf-rs target
|
||||
(SD card mounted at runtime contains /repo and /local)
|
||||
/firmware Rust crate, esp-idf-rs target — the app + on-device bins
|
||||
(SD card mounted at runtime contains /repo and /local)
|
||||
/src
|
||||
editor/ rope buffer, modes, keymap
|
||||
render/ embedded-graphics + dirty rects
|
||||
git/ gitoxide wrapper, auth
|
||||
usb/ TinyUSB host glue
|
||||
fs/ SD + NVS
|
||||
build.rs reads TW_* env vars (Wi-Fi, PAT, author) — v0.1 config path
|
||||
sdkconfig.defaults
|
||||
/hardware BOM, schematic, enclosure (later)
|
||||
/docs ADRs, QFD, roadmap, per-version product + technical specs
|
||||
CONTEXT.md project glossary — Tracked / Local / Save / Publish, and the
|
||||
principles that fall out of them
|
||||
GLOSSARY.md methodology glossary — the WHAT / Function / Characteristic /
|
||||
Metric / Target ontology layers used across docs
|
||||
package.json pnpm + oxfmt — formatting toolchain for docs/JSON
|
||||
(companions: pnpm-lock.yaml, .oxfmtrc.json, .node-version)
|
||||
main.rs app binary — editor + display + USB + SD + git, wired
|
||||
epd.rs SSD1683 dual-controller e-ink driver
|
||||
usb_kbd.rs TinyUSB host glue, HID → key events
|
||||
git_sync.rs :gp/:gl service — dirty-path journal, O(depth) splice
|
||||
commit, push/pull on a dedicated 96 KB git thread
|
||||
persistence.rs SD mount, atomic save, crash recovery
|
||||
net.rs Wi-Fi + SNTP bring-up (bounded retry)
|
||||
/bin on-device spike + bench binaries (sd_fat, sd_bench,
|
||||
wifi_tls, git_push, git_sync, git_smoke, git_bench,
|
||||
splash)
|
||||
/components/libgit2 libgit2 as an esp-idf CMake component (mbedTLS);
|
||||
source vendored as a git submodule
|
||||
build.rs bakes TW_* env vars (Wi-Fi, PAT, author) — the config
|
||||
path until v0.9's on-device settings
|
||||
/editor modal editor core crate — buffer, modes, motions,
|
||||
palette, search, render; host-built and host-tested
|
||||
off the xtensa target
|
||||
/display panel geometry + in-memory Frame (embedded-graphics
|
||||
DrawTarget), shared by the epd driver and the editor
|
||||
/keymap pure HID boot-keyboard decode — host-testable, fuzzable
|
||||
/spikes desktop spikes (spike7 git push proof, pre-device)
|
||||
/docs the design record — index: docs/README.md
|
||||
(ADRs, QFD, macroplan, per-version specs, spikes.md,
|
||||
postmortems/, notes/, tradeoff-curves/, kaizen/)
|
||||
/hardware enclosure — parametric OpenSCAD case (case/) + renders
|
||||
CONTEXT.md project glossary — Tracked / Local / Save / Publish, and
|
||||
the principles that fall out of them
|
||||
GLOSSARY.md methodology glossary — the WHAT / Function /
|
||||
Characteristic / Metric / Target ontology layers
|
||||
package.json pnpm + oxfmt — formatting toolchain for docs/JSON
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open questions / risks (tracked, not yet resolved)
|
||||
|
||||
- [ ] `gix-clone` + `gix-pack` smart-HTTP push working on `esp-idf-rs` with
|
||||
mbedtls — needs an early spike before locking the stack.
|
||||
- [ ] TinyUSB host stability with arbitrary HID descriptors (Nuphy reports
|
||||
consumer-control keys we may need to ignore).
|
||||
- [ ] Heap fragmentation over a long writing session with PSRAM allocator.
|
||||
- [ ] Real-world e-ink ghosting with current partial-refresh cadence.
|
||||
- [ ] Heap fragmentation over a long writing session with the PSRAM allocator.
|
||||
- [ ] Real-world e-ink ghosting with the current partial-refresh cadence.
|
||||
- [~] Use-after-free freeing the in-flight USB transfer on keyboard unplug —
|
||||
fixed in code, pending an on-device hot-plug run to confirm
|
||||
([`MEMORY_AUDIT.md`](MEMORY_AUDIT.md) finding #1).
|
||||
|
||||
Retired risks ([gix push](docs/postmortems/2026-07-05-spike7-gix-https-push.md),
|
||||
[SD CMD59 rejection](docs/postmortems/2026-07-05-spike3-sd-cmd59.md), TinyUSB HID
|
||||
stability, TLS heap, libgit2-on-xtensa) and how they died:
|
||||
[`docs/spikes.md`](docs/spikes.md) and
|
||||
[`docs/postmortems/`](docs/postmortems/README.md).
|
||||
|
||||
These get resolved by writing code, not by deciding harder.
|
||||
|
||||
2
display/.gitignore
vendored
Normal file
2
display/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
10
display/Cargo.toml
Normal file
10
display/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "display"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "The GDEY0579T93 panel's geometry plus an in-memory drawable Frame (an embedded-graphics DrawTarget), split out of the hardware driver so the driver and the host-testable editor crate share one framebuffer definition without pulling in esp-idf."
|
||||
|
||||
# Pure embedded-graphics — no esp/std hardware deps — so the editor crate that
|
||||
# renders onto Frame stays host-buildable and testable off the xtensa target.
|
||||
[dependencies]
|
||||
embedded-graphics = "0.8"
|
||||
341
display/src/glyphs.rs
Normal file
341
display/src/glyphs.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
//! Extra glyphs the ISO-8859-15 render font lacks.
|
||||
//!
|
||||
//! The buffer is UTF-8, so it can hold codepoints outside Latin-9. The
|
||||
//! `embedded-graphics` `iso_8859_15` font draws those as a fallback box. This
|
||||
//! module carries hand-authored 10×20 bitmaps for a curated set of common
|
||||
//! single-width characters — typographic punctuation that arrives in imported
|
||||
//! prose (curly quotes, en/em dashes, ellipsis, bullet) and the math symbols the
|
||||
//! user's Markdown snippets insert (→ ≠ Σ). [`Editor::draw`] overlays these over
|
||||
//! the fallback box after the font has laid out the line.
|
||||
//!
|
||||
//! Every glyph is exactly one 10×20 cell (matching the body `FONT_10X20`, i.e.
|
||||
//! `editor::CW`×`editor::CH`), so the editor's 1-char = 1-cell layout invariant
|
||||
//! is untouched. A row is a 10-bit pattern: the leftmost column (x = 0) is the
|
||||
//! high bit, so a binary literal reads left-to-right as the pixels of that row.
|
||||
//! Rows are top (y = 0) to bottom (y = 19); 1 = ink.
|
||||
|
||||
use crate::Frame;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
|
||||
const GLYPH_W: usize = 10;
|
||||
const GLYPH_H: usize = 20;
|
||||
|
||||
/// A 10×20 1-bit glyph: one `u16` per row, low `GLYPH_W` bits used, high bit =
|
||||
/// leftmost pixel.
|
||||
pub type Glyph = [u16; GLYPH_H];
|
||||
|
||||
// → U+2192 RIGHTWARDS ARROW: a horizontal shaft with a `>` head at the right.
|
||||
#[rustfmt::skip]
|
||||
const ARROW_RIGHT: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000010000,
|
||||
0b0000001000,
|
||||
0b0000000100,
|
||||
0b0000000010,
|
||||
0b0111111111,
|
||||
0b0111111111,
|
||||
0b0000000010,
|
||||
0b0000000100,
|
||||
0b0000001000,
|
||||
0b0000010000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// ≠ U+2260 NOT EQUAL TO: two equals bars with a slash through them.
|
||||
#[rustfmt::skip]
|
||||
const NOT_EQUAL: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000010,
|
||||
0b0000000100,
|
||||
0b0000000100,
|
||||
0b0000001000,
|
||||
0b0111111110,
|
||||
0b0000010000,
|
||||
0b0000010000,
|
||||
0b0000100000,
|
||||
0b0111111110,
|
||||
0b0001000000,
|
||||
0b0001000000,
|
||||
0b0010000000,
|
||||
0b0010000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// Σ U+03A3 GREEK CAPITAL LETTER SIGMA: top & bottom bars with the diagonals
|
||||
// meeting at a vertex on the centre-right.
|
||||
#[rustfmt::skip]
|
||||
const SIGMA: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0111111110,
|
||||
0b0100000000,
|
||||
0b0010000000,
|
||||
0b0001000000,
|
||||
0b0000100000,
|
||||
0b0000010000,
|
||||
0b0000100000,
|
||||
0b0001000000,
|
||||
0b0010000000,
|
||||
0b0100000000,
|
||||
0b0100000000,
|
||||
0b0111111110,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// • U+2022 BULLET: a filled dot at mid-height.
|
||||
#[rustfmt::skip]
|
||||
const BULLET: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0001111000,
|
||||
0b0011111100,
|
||||
0b0011111100,
|
||||
0b0011111100,
|
||||
0b0001111000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// … U+2026 HORIZONTAL ELLIPSIS: three dots on the baseline.
|
||||
#[rustfmt::skip]
|
||||
const ELLIPSIS: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0110110110,
|
||||
0b0110110110,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// – U+2013 EN DASH: a short mid-height bar.
|
||||
#[rustfmt::skip]
|
||||
const EN_DASH: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0011111100,
|
||||
0b0011111100,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// — U+2014 EM DASH: a full-width mid-height bar.
|
||||
#[rustfmt::skip]
|
||||
const EM_DASH: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b1111111111,
|
||||
0b1111111111,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// ' U+2018 LEFT SINGLE QUOTATION MARK: raised, tail up (opening).
|
||||
#[rustfmt::skip]
|
||||
const LEFT_SINGLE_QUOTE: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000100000,
|
||||
0b0001100000,
|
||||
0b0001100000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// ' U+2019 RIGHT SINGLE QUOTATION MARK / apostrophe: raised, tail down (closing).
|
||||
#[rustfmt::skip]
|
||||
const RIGHT_SINGLE_QUOTE: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0001100000,
|
||||
0b0001100000,
|
||||
0b0000100000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// " U+201C LEFT DOUBLE QUOTATION MARK: two opening quotes.
|
||||
#[rustfmt::skip]
|
||||
const LEFT_DOUBLE_QUOTE: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0010010000,
|
||||
0b0011011000,
|
||||
0b0011011000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
// " U+201D RIGHT DOUBLE QUOTATION MARK: two closing quotes.
|
||||
#[rustfmt::skip]
|
||||
const RIGHT_DOUBLE_QUOTE: Glyph = [
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0011011000,
|
||||
0b0011011000,
|
||||
0b0010010000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
0b0000000000,
|
||||
];
|
||||
|
||||
/// The 10×20 bitmap for a character the ISO-8859-15 font can't draw, or `None`
|
||||
/// if the base font already covers it (all ASCII and Latin-9, including `œ €`).
|
||||
pub fn extra_glyph(c: char) -> Option<&'static Glyph> {
|
||||
Some(match c {
|
||||
'\u{2192}' => &ARROW_RIGHT, // →
|
||||
'\u{2260}' => &NOT_EQUAL, // ≠
|
||||
'\u{03A3}' => &SIGMA, // Σ
|
||||
'\u{2022}' => &BULLET, // •
|
||||
'\u{2026}' => &ELLIPSIS, // …
|
||||
'\u{2013}' => &EN_DASH, // –
|
||||
'\u{2014}' => &EM_DASH, // —
|
||||
'\u{2018}' => &LEFT_SINGLE_QUOTE, // '
|
||||
'\u{2019}' => &RIGHT_SINGLE_QUOTE, // '
|
||||
'\u{201C}' => &LEFT_DOUBLE_QUOTE, // "
|
||||
'\u{201D}' => &RIGHT_DOUBLE_QUOTE, // "
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Paint a glyph over one 10×20 cell at `(x, y)`. Fills the whole cell — set
|
||||
/// bits in `ink`, unset bits in `ink.invert()` — so it fully overwrites whatever
|
||||
/// the base font drew there (e.g. the fallback box). Pass `ink = On` for a
|
||||
/// normal black-on-white cell, `ink = Off` for a reverse-video cell (a white
|
||||
/// glyph over the black selection/caret fill).
|
||||
pub fn blit_glyph(f: &mut Frame, x: i32, y: i32, g: &Glyph, ink: BinaryColor) {
|
||||
let bg = ink.invert();
|
||||
let pixels = g.iter().enumerate().flat_map(move |(row, &bits)| {
|
||||
(0..GLYPH_W).map(move |c| {
|
||||
let on = (bits >> (GLYPH_W - 1 - c)) & 1 == 1;
|
||||
Pixel(
|
||||
Point::new(x + c as i32, y + row as i32),
|
||||
if on { ink } else { bg },
|
||||
)
|
||||
})
|
||||
});
|
||||
// Frame's DrawTarget error is Infallible.
|
||||
let _ = f.draw_iter(pixels);
|
||||
}
|
||||
139
display/src/lib.rs
Normal file
139
display/src/lib.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
//! The e-paper panel's geometry and an in-memory drawable frame.
|
||||
//!
|
||||
//! Split out of the hardware driver (`firmware/src/epd.rs`) so the driver and
|
||||
//! the host-testable `editor` crate share one framebuffer definition. `Frame`
|
||||
//! is a pure `embedded-graphics` [`DrawTarget`]; the `Epd` driver in firmware
|
||||
//! consumes its raw bytes via [`Frame::bytes`] and never names the type, so
|
||||
//! nothing here depends on esp-idf and the whole crate builds on the host.
|
||||
|
||||
use embedded_graphics::mono_font::iso_8859_15::FONT_10X20;
|
||||
use embedded_graphics::mono_font::MonoTextStyle;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
use embedded_graphics::primitives::{Circle, PrimitiveStyleBuilder};
|
||||
use embedded_graphics::text::{Alignment, Baseline, Text, TextStyleBuilder};
|
||||
|
||||
mod glyphs;
|
||||
pub use glyphs::{blit_glyph, extra_glyph, Glyph};
|
||||
|
||||
pub const WIDTH: u16 = 792;
|
||||
pub const HEIGHT: u16 = 272;
|
||||
|
||||
/// Full-frame 1-bit framebuffer: 792 px = 99 bytes per row, MSB-first,
|
||||
/// 1 = white, 0 = black (SSD16xx convention).
|
||||
pub const FB_BYTES_W: usize = (WIDTH / 8) as usize; // 99
|
||||
pub const FB_BYTES: usize = FB_BYTES_W * HEIGHT as usize; // 26928
|
||||
|
||||
/// In-memory 792×272 1-bit frame, drawable via `embedded-graphics`.
|
||||
/// `BinaryColor::On` = black ink, `Off` = white paper.
|
||||
pub struct Frame {
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new_white() -> Self {
|
||||
Self { buf: vec![0xFF; FB_BYTES] }
|
||||
}
|
||||
|
||||
/// A zero-capacity placeholder, for the buffer-swap pattern:
|
||||
/// `Editor::draw_into` takes the caller's buffer out through one of these
|
||||
/// and puts it back when done. Not drawable until [`clear_white`]
|
||||
/// (or a `draw_into`) gives it its buffer.
|
||||
///
|
||||
/// [`clear_white`]: Frame::clear_white
|
||||
pub fn empty() -> Self {
|
||||
Self { buf: Vec::new() }
|
||||
}
|
||||
|
||||
/// Reset to all-white paper, reusing the existing allocation when the
|
||||
/// buffer is already full-size. This is what lets firmware repaint without
|
||||
/// allocating: a background `:gp` push can take the heap to the floor,
|
||||
/// and a failed framebuffer alloc aborts the whole app (2026-07-13).
|
||||
pub fn clear_white(&mut self) {
|
||||
self.buf.clear();
|
||||
self.buf.resize(FB_BYTES, 0xFF);
|
||||
}
|
||||
|
||||
pub fn new_black() -> Self {
|
||||
Self { buf: vec![0x00; FB_BYTES] }
|
||||
}
|
||||
|
||||
/// The Typoena boot splash (Spike 9): the wordmark centred inside a stroked
|
||||
/// circle on a white frame. Pure `embedded-graphics`, so it renders the same
|
||||
/// on the host (the preview) as it does through the `Epd` driver at boot.
|
||||
/// `main.rs` shows this once at startup, before the editor opens.
|
||||
pub fn splash() -> Self {
|
||||
// Badge sized to leave a comfortable margin inside the 272 px panel
|
||||
// height (diameter 200 → 36 px clear top and bottom).
|
||||
const WORDMARK: &str = "typoena";
|
||||
const CIRCLE_DIAMETER: u32 = 200;
|
||||
const STROKE_WIDTH: u32 = 4;
|
||||
|
||||
let mut f = Self::new_white();
|
||||
let center = Point::new(WIDTH as i32 / 2, HEIGHT as i32 / 2);
|
||||
|
||||
let stroke = PrimitiveStyleBuilder::new()
|
||||
.stroke_color(BinaryColor::On) // black ink
|
||||
.stroke_width(STROKE_WIDTH)
|
||||
.build();
|
||||
Circle::with_center(center, CIRCLE_DIAMETER)
|
||||
.into_styled(stroke)
|
||||
.draw(&mut f)
|
||||
.unwrap(); // Frame's DrawTarget error is Infallible
|
||||
|
||||
// Centre the wordmark on the panel centre in both axes, so it sits in
|
||||
// the middle of the circle regardless of string length.
|
||||
let char_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
|
||||
let text_style = TextStyleBuilder::new()
|
||||
.alignment(Alignment::Center)
|
||||
.baseline(Baseline::Middle)
|
||||
.build();
|
||||
Text::with_text_style(WORDMARK, center, char_style, text_style)
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
|
||||
f
|
||||
}
|
||||
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.buf
|
||||
}
|
||||
|
||||
/// Flip every pixel black↔white across the whole framebuffer. The editor
|
||||
/// draws its native black-ink-on-white-paper frame, then calls this once at
|
||||
/// the end for the dark theme — so text, selection, caret, panel and palette
|
||||
/// all invert together and each stays legible against the flipped ground.
|
||||
pub fn invert(&mut self) {
|
||||
for b in &mut self.buf {
|
||||
*b = !*b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OriginDimensions for Frame {
|
||||
fn size(&self) -> Size {
|
||||
Size::new(WIDTH as u32, HEIGHT as u32)
|
||||
}
|
||||
}
|
||||
|
||||
impl DrawTarget for Frame {
|
||||
type Color = BinaryColor;
|
||||
type Error = core::convert::Infallible;
|
||||
|
||||
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
|
||||
where
|
||||
I: IntoIterator<Item = Pixel<Self::Color>>,
|
||||
{
|
||||
for Pixel(p, color) in pixels {
|
||||
if (0..WIDTH as i32).contains(&p.x) && (0..HEIGHT as i32).contains(&p.y) {
|
||||
let idx = p.y as usize * FB_BYTES_W + p.x as usize / 8;
|
||||
let bit = 0x80u8 >> (p.x % 8);
|
||||
match color {
|
||||
BinaryColor::On => self.buf[idx] &= !bit, // black ink
|
||||
BinaryColor::Off => self.buf[idx] |= bit, // white paper
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
36
docs/README.md
Normal file
36
docs/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Docs
|
||||
|
||||
> The design record for Typoena — the decisions, specs, and bench write-ups
|
||||
> behind the writing appliance. Start with the [ADRs](adr.md) for the
|
||||
> load-bearing choices, or the [v0.1 specs](v0.1-mvp-product.md) for what the
|
||||
> first release actually does.
|
||||
>
|
||||
> Project overview: [`../README.md`](../README.md).
|
||||
|
||||
## Decisions & specs
|
||||
|
||||
| Doc | What's in it |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`adr.md`](adr.md) | Architecture Decision Records — the load-bearing technical choices and why. |
|
||||
| [`v0.1-mvp-product.md`](v0.1-mvp-product.md) | v0.1 product design — boot, type one file, `Ctrl-S` to save, `Ctrl-G` to publish. |
|
||||
| [`v0.1-mvp-technical.md`](v0.1-mvp-technical.md) | v0.1 technical design — single Rust binary on `esp-idf-rs`, modules, threads, bring-up order. |
|
||||
| [`macroplan.md`](macroplan.md) | Version-by-version plan; each release is a usable artifact, not a checkpoint. |
|
||||
| [`typoena-toml.md`](typoena-toml.md) | `.typoena.toml` reference — the git-tracked editor preferences (auto-save, format-on-save, line numbers, auto-sync). |
|
||||
| [`hardware.md`](hardware.md) | Part choices for the bench build and the rationale behind them. |
|
||||
|
||||
## Quality method
|
||||
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`qfd.md`](qfd.md) | Quality Function Deployment — turns user-facing requirements into technical HOWs; §3 is the filled 14 WHATs × 14 HOWs House of Quality. |
|
||||
| [`quality-house-empty.md`](quality-house-empty.md) | The House chassis, blank — for re-scoring from scratch. |
|
||||
|
||||
## Bench work
|
||||
|
||||
| Area | What's in it |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`spikes.md`](spikes.md) | Rendering & UX spikes — display/UX risks proved outside the hardware stack. |
|
||||
| [`postmortems/`](postmortems/README.md) | Bring-up debugging write-ups: what broke, the root cause, and the decisions that came out of it. |
|
||||
| [`notes/`](notes/README.md) | Longer-form essays on the thinking behind specific choices — e.g. where the ~16 s cold [`:sync`](notes/sync-latency.md) goes. |
|
||||
| [`tradeoff-curves/`](tradeoff-curves/README.md) | Cost-vs-knob curves behind chosen defaults — energy, latency, memory. |
|
||||
| [`kaizen/`](kaizen/README.md) | Six-step kaizen write-ups — the problem→analysis→fix story behind an improvement, e.g. the real-repo [`:sync` brick](kaizen/real-repo-sync.md). |
|
||||
162
docs/adr.md
162
docs/adr.md
@@ -12,7 +12,7 @@ Format inspired by Michael Nygard's ADR template, kept short on purpose.
|
||||
[`../CONTEXT.md`](../CONTEXT.md) — project glossary: **Tracked**, **Local**,
|
||||
**Save**, **Publish**, plus the principles ("writing tool, not sync engine")
|
||||
that constrain [ADR-010] specifically.
|
||||
[`roadmap.md`](roadmap.md) — per-version scope (v0.1 → v1.x).
|
||||
[`macroplan.md`](macroplan.md) — per-version scope (v0.1 → v1.x).
|
||||
[`v0.1-mvp-product.md`](v0.1-mvp-product.md) — what the v0.1 device must do.
|
||||
[`v0.1-mvp-technical.md`](v0.1-mvp-technical.md) — how v0.1 is built.
|
||||
[`qfd.md`](qfd.md) — Quality Function Deployment: requirements → functions →
|
||||
@@ -152,7 +152,7 @@ as the price of those properties.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Visible edit area on this panel is ~11 lines. UI must embrace the
|
||||
- Visible writing column on this panel is ~13 lines. UI must embrace the
|
||||
constraint — no multi-pane, no large headers. See
|
||||
[v0.1 product → screen layout](v0.1-mvp-product.md#screen-layout).
|
||||
- Framebuffer is ~27 KB; keeps PSRAM free for git pack data — a top-3
|
||||
@@ -180,7 +180,7 @@ as the price of those properties.
|
||||
do not promise "instant feedback."
|
||||
- Idle power on e-ink is structurally ~0, which makes the v0.8 battery
|
||||
sizing exercise straightforward — see [ADR-008] and
|
||||
[roadmap → v0.8](roadmap.md#v08--power-battery--sleep--).
|
||||
[macroplan → v0.8](macroplan.md#v08--power-battery--sleep--).
|
||||
- 10.3" e-ink upgrade path is preserved by keeping the renderer
|
||||
resolution-agnostic. A _non_-e-ink swap (e.g. Sharp Memory LCD) would
|
||||
invalidate [ADR-002]'s dirty-rect strategy and force a fresh medium ADR.
|
||||
@@ -227,6 +227,19 @@ risk table).
|
||||
Implementation: [v0.1 technical → `git` module](v0.1-mvp-technical.md#module-breakdown)
|
||||
and [risks table](v0.1-mvp-technical.md#risks-and-how-well-know-they-bit-us).
|
||||
|
||||
### Outcome — Spike 7, 2026-07-05: kill-switch fired
|
||||
|
||||
`gix` was ruled out for v0.1 and the fallback taken. gitoxide supports push only
|
||||
over `file://` and `ssh://` — **not HTTP(S)** — so with HTTPS + PAT fixed by
|
||||
[ADR-005], the smart-HTTP push path this ADR bet on does not exist yet. We
|
||||
switched to **`libgit2` (`git2` crate)** and proved `add → commit → push`
|
||||
(incl. `pull --no-edit` + retry) on desktop
|
||||
([`spikes/spike7-git-push`](../spikes/spike7-git-push/README.md)). The remaining risk is
|
||||
now the on-device **libgit2 → xtensa/mbedtls cross-compile** — the very pain
|
||||
this ADR chose gix to avoid. Full context:
|
||||
[postmortem](postmortems/2026-07-05-spike7-gix-https-push.md). Revisit gix if
|
||||
its HTTP(S) push lands upstream before v0.1 ships.
|
||||
|
||||
---
|
||||
|
||||
## ADR-005: Auth — HTTPS + GitHub Personal Access Token
|
||||
@@ -264,7 +277,7 @@ card alone is not enough.
|
||||
[v0.1 product → provisioning](v0.1-mvp-product.md#provisioning-build-time-dev-only).
|
||||
- PAT is never logged. Validated in code review.
|
||||
- Rotation in v0.1 = wipe NVS and re-run setup. Proper rotation UI is v0.9
|
||||
— see [roadmap → v0.9](roadmap.md#v09--robustness--).
|
||||
— see [macroplan → v0.9](macroplan.md#v09--robustness--).
|
||||
- Revisit if we ever want to support multiple remotes per device with
|
||||
different credentials.
|
||||
|
||||
@@ -342,6 +355,26 @@ derived key.
|
||||
atomic-rename writes (see
|
||||
[v0.1 technical → `persistence`](v0.1-mvp-technical.md#module-breakdown)
|
||||
and [file layout](v0.1-mvp-technical.md#file-layout)).
|
||||
- **FatFS caveat (Spike 3, verified 2026-07-11):** FatFS's `f_rename` returns
|
||||
`FR_EXIST` on an existing destination — it does **not** replace like POSIX
|
||||
`rename(2)`. So the atomic save must `f_unlink` the target before renaming the
|
||||
`*.tmp` over it, and pair that with **boot recovery** of a lingering `*.tmp`.
|
||||
Recovery is _not_ simply "promote the tmp": a crash _during_ the tmp write
|
||||
leaves a partial tmp, so the choice depends on whether the target survived —
|
||||
- **tmp + target both present** → the crash could have been mid-write, so the
|
||||
tmp is untrustworthy. Keep the committed target, discard the tmp (this is the
|
||||
documented "you get the previous version" behaviour).
|
||||
- **tmp only, target absent** → the target was already unlinked, so the tmp is
|
||||
the newest complete, fsync'd copy and the only one left. Promote it.
|
||||
|
||||
Implemented in `firmware::persistence::Storage::{save,recover}`. See the
|
||||
[Spike 3 postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md#resolution-2026-07-11).
|
||||
|
||||
- **SD-card compatibility:** use a genuine card, ideally **≤32 GB (SDHC/FAT32)**.
|
||||
Large or counterfeit SDXC cards may reject `CMD59` (SPI-mode CRC) and fail to
|
||||
mount; we keep CRC required rather than run the user's writing over an
|
||||
unchecked bus. The device reports a swap-the-card message instead of a hex
|
||||
code.
|
||||
|
||||
---
|
||||
|
||||
@@ -376,7 +409,7 @@ Sizing a battery before measuring is guessing.
|
||||
scope is in [v0.1 product → out of scope](v0.1-mvp-product.md#out-of-scope-for-v01).
|
||||
- We can decide cell capacity from real numbers in v0.8, not specs sheets.
|
||||
- Lid-close detection / deep sleep slips to v0.8 with the battery — see
|
||||
[roadmap → v0.8](roadmap.md#v08--power-battery--sleep--).
|
||||
[macroplan → v0.8](macroplan.md#v08--power-battery--sleep--).
|
||||
|
||||
---
|
||||
|
||||
@@ -462,6 +495,125 @@ retry). Failure surfaces as a single retry-able outcome in the status line.
|
||||
|
||||
---
|
||||
|
||||
## ADR-011: Credential provisioning — how the PAT reaches the device and is protected at rest
|
||||
|
||||
**Status:** Proposed / **Open** — 2026-07-07 (deferred beyond v0.1)
|
||||
**Scope:** Gates the first distribution to any non-dev user.
|
||||
|
||||
### Context
|
||||
|
||||
[ADR-005](#adr-005-auth--https--github-personal-access-token) decided the auth
|
||||
model (HTTPS + PAT) and sketched an endgame ("from v0.9 the PAT moves to
|
||||
encrypted storage with an eFuse-derived key"), but left the _mechanics_ open:
|
||||
**how does a token get onto a device, and how is it protected once there?**
|
||||
|
||||
Spike 7 made this concrete. The PAT is currently **baked into the firmware image
|
||||
at build time** (`build.rs` → `env!()` → a `const` in the `.bin`) — ADR-005's
|
||||
deliberate v0.1 shortcut. On a real device that means:
|
||||
|
||||
- the token sits in **plaintext in flash** — anyone with physical access can
|
||||
`esptool read_flash` and extract a working GitHub PAT (push/pull as the user);
|
||||
- it is the **same token on every unit** built from that image — no per-device
|
||||
revocation;
|
||||
- **rotation requires a reflash**.
|
||||
|
||||
This is fine for the dev's own bench unit (it's their token, their device) and is
|
||||
why the [Spike 7 postmortem](postmortems/2026-07-05-spike7-gix-https-push.md)
|
||||
lists it as the _last standing shortcut_. It is not fine for a unit in anyone
|
||||
else's hands. Resolving it needs a **provisioning path**, which the current design
|
||||
(["build-time only, no provisioning module"](v0.1-mvp-technical.md#provisioning--build-time-only-no-module-on-device))
|
||||
deliberately omits.
|
||||
|
||||
### Options considered
|
||||
|
||||
| Option | Pros | Cons |
|
||||
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **Build-time bake** (current, ADR-005 v0.1) | Zero UX; nothing to build. | Plaintext in flash; same token per unit; reflash to rotate. **Dev-bench only.** |
|
||||
| **On-device paste → NVS (plaintext)** | No reflash; per-device token. | Still plaintext at rest; needs a first-run entry UI (captive portal / keyboard). |
|
||||
| **On-device paste → NVS encrypted (eFuse key)** | ADR-005's stated target; a flash dump alone doesn't yield the token; per-device + rotatable. | Needs NVS encryption + eFuse key derivation + first-run UI — the whole provisioning module. |
|
||||
| **USB provisioning tool** (host writes NVS over serial) | No on-device text entry; scriptable at assembly. | Needs a host-side tool; token still needs at-rest protection (combine with encryption). |
|
||||
| **Per-device fine-grained PAT** (orthogonal) | Least blast radius; per-device revoke; repo-scoped. | User mints one PAT per device; pairs with any storage option above. |
|
||||
| **GitHub App installation token** | Strongest, rotating creds. | Heavy for a single-user appliance — rejected in ADR-005 for overhead. |
|
||||
|
||||
### Decision
|
||||
|
||||
**Open — deferred.** v0.1 stays on the build-time bake (dev-bench only). Before
|
||||
shipping to any non-dev user, pick a provisioning mechanism — the likely shape is
|
||||
**on-device paste → eFuse-encrypted NVS** (ADR-005's target) **+ a per-device
|
||||
fine-grained PAT** to bound the blast radius. That requires the provisioning
|
||||
module currently deferred, NVS encryption, and eFuse key derivation, and ties
|
||||
into [ADR-007](#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config)
|
||||
(where config lives).
|
||||
|
||||
### Consequences
|
||||
|
||||
- **Blocks nothing in v0.1** — the bench unit runs on the baked PAT.
|
||||
- **Gates first non-dev distribution** — this ADR must flip to Accepted (with a
|
||||
chosen mechanism) before a unit leaves the dev's hands.
|
||||
- Whatever is chosen, the token must never be logged or written into the working
|
||||
copy — already enforced in the git module (PAT lives only in libgit2's
|
||||
credential callback).
|
||||
- Rotation UX and the first-run flow are the real work here, not the crypto.
|
||||
|
||||
---
|
||||
|
||||
## ADR-012: SD on its own SPI3 host (not shared with the EPD on SPI2)
|
||||
|
||||
**Status:** Accepted — 2026-07-11
|
||||
**Scope:** v0.1 hardware; whole project.
|
||||
|
||||
### Context
|
||||
|
||||
The EPD (SSD1683) and the SD card both want SPI. The v0.1 plan (the boot
|
||||
sequence in [v0.1 technical](v0.1-mvp-technical.md#hardware-bring-up-order) and
|
||||
the storage context of
|
||||
[ADR-007](#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config))
|
||||
assumed **one shared SPI2 bus** with a per-device chip-select. Spike 3 (verified
|
||||
2026-07-11, [postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md)) proved the
|
||||
SD works on the SPI2 wiring, but surfaced the integration blocker: the EPD driver
|
||||
uses esp-idf-hal's `SpiBusDriver`, whose constructor takes an **exclusive
|
||||
`spi_device_acquire_bus(BLOCK)` and holds it for the driver's whole lifetime**
|
||||
(it must keep CS asserted across a cmd→data sequence while toggling DC). While
|
||||
held, no other device on that host can transact — so an SD on SPI2 is locked out
|
||||
for as long as the panel driver is alive. Compounding it, persistence/git runs on
|
||||
a **dedicated thread** (Spike 7) while the EPD refreshes on the main task, so SD
|
||||
and EPD access are genuinely concurrent.
|
||||
|
||||
### Options considered
|
||||
|
||||
| Option | Pros | Cons |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Shared SPI2, arbitrate** | One bus; ~2 fewer GPIOs. | Rewrite the proven EPD SPI layer to per-transaction device drivers; add a cross-thread mutex around all SPI2 access; residual "corruption on save during render" risk on the highest-value path. |
|
||||
| **SD on its own SPI3** | EPD code untouched; no arbitration/mutex; each bus at its own clock; matches the risk-table fallback exactly. | ~2 extra GPIOs + traces. |
|
||||
|
||||
### Decision
|
||||
|
||||
**SD gets its own SPI3 host.** The EPD keeps SPI2 and its exclusive-lock model,
|
||||
unchanged. This is the mitigation the technical-doc risk table already names
|
||||
("move SD to a separate SPI peripheral — ESP32-S3 has two"). SPI3 is free (SPI0/1
|
||||
are flash + PSRAM; nothing else uses SPI3).
|
||||
|
||||
Pins — **SD on SPI3:** SCK 14, MOSI 15, MISO 13, CS 10 (MISO/CS unchanged from
|
||||
the spike; only SCK/MOSI move off the EPD-shared 12/11). **EPD stays on SPI2:**
|
||||
SCK 12, MOSI 11, CS 7, DC 6, RST 5, BUSY 4.
|
||||
|
||||
### Consequences
|
||||
|
||||
- No shared-bus arbitration or mutex — the git thread's SD I/O never contends
|
||||
with an EPD refresh. Removes the "corruption on save during render" risk for
|
||||
the device's first value (not losing the user's writing).
|
||||
- Each bus runs at its own clock (EPD ~4 MHz on jumpers; SD 10 MHz+).
|
||||
- Costs ~2 extra GPIOs + traces; the pin budget has room (avoids flash 26–32,
|
||||
octal PSRAM 33–37, strapping 0/3/45/46, USB 19/20, RGB 38/48, EPD 4–7/11/12).
|
||||
- Supersedes the "shared SPI2, different CS" assumption in the boot sequence and
|
||||
ADR-007's storage context; the `sd_fat` spike is rewired to SPI3 and its
|
||||
EPD-CS-deselect step (only meaningful on a shared bus) is removed.
|
||||
- The `SpiBusDriver`-holds-the-lock mechanism was read from the constructor, not
|
||||
re-verified on silicon; it doesn't affect this decision (SPI3 sidesteps it),
|
||||
but is the first thing to confirm if a shared bus is ever revisited.
|
||||
|
||||
---
|
||||
|
||||
## How to add a new ADR
|
||||
|
||||
1. Append a new `## ADR-NNN: <title>` section to this file.
|
||||
|
||||
34
docs/hardware.md
Normal file
34
docs/hardware.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Hardware — parts and rationale
|
||||
|
||||
Part choices for the bench build, moved here from the README. The display
|
||||
medium decision (e-ink over LCD / memory LCD / OLED) is
|
||||
[ADR-003](adr.md#adr-003-display-medium--e-ink-gdey0579t93-panel); power is
|
||||
[ADR-008](adr.md#adr-008-mvp-power--wall-powered-battery-deferred-to-v08);
|
||||
keyboard transport is
|
||||
[ADR-009](adr.md#adr-009-keyboard-transport--usb-host-tinyusb).
|
||||
|
||||
| Part | Choice | Why |
|
||||
| --------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| MCU | **ESP32-S3-N16R8** (16 MB flash, 8 MB octal PSRAM) | USB OTG host (for the keyboard), Wi-Fi, BLE, dual core @ 240 MHz, plenty of PSRAM for git pack data and screen buffer. Best-supported Rust target in the ESP family. |
|
||||
| Display | **GDEY0579T93 + DESPI-C579 breakout** (5.79", 792×272, 1-bit) | Good Display panel matched with its own FPC breakout. Strip aspect (~2.9:1) — Freewrite-coded: ~13 lines, ~79 cols at the editor's 10px font. Tiny framebuffer (~27 KB) leaves PSRAM headroom. The DESPI-C579 is a passive level-shifter / FPC-to-header board, not an active controller — driven over plain SPI like any other epd. |
|
||||
| Keyboard | **Nuphy Air60/Halo65 wired USB-C** | ESP32-S3 acts as USB host via TinyUSB. BLE-HID is a fallback but contends with Wi-Fi for radio time during push. |
|
||||
| Storage | microSD over SPI | Holds both the git working copy (`/sd/repo/`) **and** the local-only scratch space (`/sd/local/`). Internal flash is for firmware + config only. |
|
||||
| Power | **USB-C wall power for MVP**, 18650 + IP5306 in v0.8 | Measure power profile on real hardware before sizing the battery. E-ink + sleep should give multi-day battery life but battery introduces charging, safety, and BMS complexity we don't need on day one. |
|
||||
| Enclosure | 3D-printed typewriter body — [`hardware/case/`](../hardware/case/README.md) | v1.0 concern. |
|
||||
|
||||
## Why the strip aspect
|
||||
|
||||
The ~2.9:1 long-narrow shape biases the UX toward "current line + recent
|
||||
context" rather than "full page" — the writing posture we want. The renderer
|
||||
stays resolution-agnostic so a 10.3" e-ink upgrade (v1.x) is a swap, not a
|
||||
rewrite.
|
||||
|
||||
## Bench status
|
||||
|
||||
The board is on the bench and bring-up is largely done — per-spike results
|
||||
live in [`spikes.md`](spikes.md) and
|
||||
[`v0.1-mvp-technical.md`](v0.1-mvp-technical.md#hardware-bring-up-order),
|
||||
with failure write-ups in [`postmortems/`](postmortems/README.md). Notable: the
|
||||
keyboard runs bus-powered on the S3's native USB port, and the SD/FAT stack is
|
||||
verified on a 32 GB card on its own SPI3 host (2026-07-11, ADR-012)
|
||||
([postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md)).
|
||||
16
docs/kaizen/README.md
Normal file
16
docs/kaizen/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Kaizen
|
||||
|
||||
> Six-step continuous-improvement write-ups: improvement potential → current
|
||||
> method analysis → ideas → test plan → implementation → evaluation. One file
|
||||
> per loop. Where a postmortem records how an incident was debugged, a kaizen
|
||||
> records how a recurring cost was measured, attacked, and re-measured — and
|
||||
> which standard changed so it stays fixed.
|
||||
>
|
||||
> Docs index: [`../README.md`](../README.md). Project overview:
|
||||
> [`../../README.md`](../../README.md). Sibling write-ups:
|
||||
> [`../postmortems/`](../postmortems/README.md) ·
|
||||
> [`../tradeoff-curves/`](../tradeoff-curves/README.md).
|
||||
|
||||
| Kaizen | Status |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------- |
|
||||
| [`real-repo-sync.md`](real-repo-sync.md) — `:sync` (now `:gp`) never completes on the real notes repo: ∞ (device bricks) → 24.1 s measured, via the O(depth) TreeBuilder splice + a second localization loop on the push half. | Closed 2026-07-13 |
|
||||
352
docs/kaizen/real-repo-sync.md
Normal file
352
docs/kaizen/real-repo-sync.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# Kaizen — `:sync` never completes on the real notes repo
|
||||
|
||||
**Status (2026-07-13): closed.** First end-to-end `:sync` on the real repo —
|
||||
∞ → 24.1 s snackbar-to-snackbar. Getting there took two loops: the commit half
|
||||
(steps 1–5 below) and a second localization loop on the push half, surfaced by
|
||||
the step-6 verification runs and documented inside step 6.
|
||||
|
||||
The writer presses `:sync` on the Typoena appliance and the device freezes for
|
||||
ten minutes; a power-cycle re-enters the same freeze forever. The real
|
||||
`jcalixte/notes` clone (1179 files, 263 MB pack) has never been synced from the
|
||||
device — only the toy test repo has. Full measurement trail:
|
||||
[`../tradeoff-curves/sync-commit-staging.md`](../tradeoff-curves/sync-commit-staging.md).
|
||||
|
||||
## 1) Improvement potential
|
||||
|
||||
**Value model for the writer (Julien on the appliance)**
|
||||
|
||||
| Want more of | Do less of |
|
||||
| ----------------------------------------------- | ------------------------------------------------------- |
|
||||
| + words safely on the remote with one keystroke | – waiting on a frozen device |
|
||||
| + trust that `:sync` completes, every time | – power-cycling mid-sync and losing the session's trust |
|
||||
| + keep writing while it publishes | – babysitting git from a desktop to rescue the card |
|
||||
|
||||
**Measurement**: seconds from `:sync` to the snackbar, on the real
|
||||
`jcalixte/notes` clone on the device.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
xyChart:
|
||||
width: 900
|
||||
height: 500
|
||||
---
|
||||
xychart-beta
|
||||
title "Improvement potential — :sync → snackbar"
|
||||
x-axis ["Before kaizen (~611 s, never completes)", "After kaizen (target ≤ ~10 s)"]
|
||||
y-axis "seconds" 0 --> 650
|
||||
bar [611]
|
||||
line [10, 10]
|
||||
```
|
||||
|
||||
The before bar understates the reality: a reset re-enters the freeze, so the
|
||||
true value is ∞ — 0 successful syncs ever. The line is the ≤ ~10 s target; the
|
||||
after-kaizen slot stays empty here — the measured bar lands in step 6.
|
||||
|
||||
## 2) Current method analysis
|
||||
|
||||
```
|
||||
:sync (current method, real repo)
|
||||
│
|
||||
▼
|
||||
┌─ local commit ─────────────────────────────────────┐ ┌─ network push ──────┐
|
||||
│ stage: add_all(["*"]) + update_all(["*"]) │ │ TLS + push │
|
||||
│ → stat()s 1179 files / 158 dirs over 10 MHz SPI │──▶│ ~6.5 s floor │
|
||||
│ index.write ⚡ ~611 s │ │ (separate curve) │
|
||||
│ → FAT's 2 s mtime marks ~every entry "racy" │ └─────────────────────┘
|
||||
│ → re-hashes ~170 MB (150 MB of images) │
|
||||
│ write_tree + commit-obj ⚡ seconds each │
|
||||
│ → loose writes trigger pack reads through │
|
||||
│ emulated mmap; each far lseek walks the │
|
||||
│ FAT cluster chain (~190 ms) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
⚡ = weak points, measured on device
|
||||
```
|
||||
|
||||
Every factor was benched on the device (`sd_bench` for raw FAT, `git_bench` for
|
||||
git2 primitives on the real clone) — details and full tables in
|
||||
[`../tradeoff-curves/sync-commit-staging.md`](../tradeoff-curves/sync-commit-staging.md):
|
||||
|
||||
| Factor # | Factor | Hypothesis | Test method | Test result | True or false? |
|
||||
| -------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| 1 | SD card raw I/O | The card itself is slow: a loose-object write costs ~700–900 ms | `sd_bench`: raw FAT create/write/rename composite, same card, same 10 MHz | complete loose-object composite **86 ms** — 8× under libgit2's number | **FALSE** |
|
||||
| 2 | `index.write()` racy-clean | FAT's 2 s mtime granularity makes ~all 1179 entries look racy → full ~170 MB re-hash over SPI | `git_bench` on the real clone, 3 consecutive `index.write()` | **611 s → 12.8 s → 360 ms** — the decay is the racy set shrinking as the index mtime advances | **TRUE** |
|
||||
| 3 | Index-free alternative | Skipping `index.write` (fresh in-memory index) escapes the wall | `git_bench`: `Index::new` + `read_tree(HEAD)` + `write_tree_to` | seed `read_tree` **77 s cold** + mmap grows to 7.4 MB → **zlib OOM crash** — still O(N_tree) | **FALSE** (as a fix) |
|
||||
| 4 | FatFS `lseek` | Long/backward seeks walk the FAT cluster chain over SPI (~67 KB of FAT reads for a 263 MB file) | `sd_bench`: seek+read 4 KB @offset 0 vs @end of the pack; A/B with `CONFIG_FATFS_USE_FASTSEEK` | @0 = 5.8 ms, @end = **198.7 ms**; fast-seek → **20.4 ms** | **TRUE** |
|
||||
| 5 | Free-cluster scan | A ~740 MB-full card slows FAT allocation | `sd_bench` re-run on the full card | composite 77 ms — unchanged | **FALSE** |
|
||||
| 6 | Strict object creation | OID validation on commit/treebuilder causes the ~1.3 s commit premium | strict-off A/B in `git_bench` | 1.80 s vs 1.93 s — no premium removed | **FALSE** |
|
||||
| 7 | Repeated small mmap windows | A window cache in `esp_map.c` would absorb the ~8 small pack reads per loose write | instrumented cache (v2), 4 real-repo runs | **0 hits / 313 misses** — every read hits a unique (offset, len); `mwindow` already absorbs true repetition | **FALSE** |
|
||||
| 8 | Cache memory discipline | The OOM in factor 3 is our own cache holding buffers past `p_munmap`, defeating `MWINDOW_MAPPED_LIMIT` | run-4 memory monitor after evict-on-munmap fix, then full removal (run 5) | resident flat at 1833 KB, heap ≥ 6.2 MB, no OOM; removal I/O-neutral | **TRUE** |
|
||||
|
||||
### Details on hypothesis #2 (the freeze itself)
|
||||
|
||||
`git_index_write` unconditionally runs `truncate_racily_clean`, which re-hashes
|
||||
every entry whose mtime is not strictly older than the index stamp. On a fresh
|
||||
FAT clone the 2 s mtime granularity makes the whole tree racy, so a one-line
|
||||
note edit re-hashes ~170 MB — mostly the ~260 images a text edit never touches —
|
||||
over the 10 MHz SPI bus. This is why the device freezes ~10 minutes and why a
|
||||
reset never helps: the index mtime doesn't advance, so it re-hashes forever.
|
||||
|
||||
**Selected weak point**: the local commit stage — its staging strategy is
|
||||
O(N_tree) in files and bytes, on a device where N_tree ≈ 1179 and the bus is
|
||||
10 MHz SPI. (The ~6.5 s network half is real but is a separate curve,
|
||||
[`../notes/sync-latency.md`](../notes/sync-latency.md), and it does not brick
|
||||
the device.)
|
||||
|
||||
## 3) Ideas
|
||||
|
||||
### Bibliography
|
||||
|
||||
- [FatFS `f_lseek` docs (elm-chan.org)](http://elm-chan.org/fsw/ff/doc/lseek.html) — the cluster-chain walk and the CLMT fast-seek mechanism.
|
||||
- [ESP-IDF FatFS docs](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/storage/fatfs.html) — `CONFIG_FATFS_USE_FASTSEEK`, recommended for read-heavy workloads with long backward seeks.
|
||||
- Vendored libgit2 v1.9.4 source — `index.c` `truncate_racily_clean` (the re-hash wall), `odb.c` `git_odb__freshen`/`git_odb_refresh` (the per-write pack probes), `mwindow.c` (32-bit defaults: 32 MB window / 256 MB mapped limit).
|
||||
|
||||
### New ideas
|
||||
|
||||
| Name | Estimated gain | Estimated lead time | Cause addressed | Comments |
|
||||
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **O(depth) TreeBuilder splice** — patch only the edited path's ancestor chain onto HEAD's tree | commit 611 s → ~2–3 s; **flat in repo size forever** | ~2 days (bench op, then plumbing) | #2 + #3 — never opens the index, never materialises the tree | **Chosen.** Bonus: carries the 150 MB of images forward by OID, so the device doesn't even need them in its working tree |
|
||||
| `CONFIG_FATFS_USE_FASTSEEK` + 256-word CLMT buffer | 2.3× on the splice (6.5 → 2.8 s); far seek 198.7 → 20.4 ms | hours — config, not code | #4 | Shipped as the companion fix; write-mode files fall back transparently |
|
||||
| Explicit-path index staging (`add_path` over the editor's dirty set) | removes the O(N) walk term only | ~1 day | #2 partially | Retired — still calls `index.write()`, so it hits the same racy-clean wall |
|
||||
| Index-free `write_tree_to` on a fresh in-memory index | — | ~1 day | #2 | Refuted by factor 3: seeding `read_tree(HEAD)` is 77 s + OOM — trades one O(N) for another |
|
||||
| `esp_map.c` window cache (v2: size-keyed admission, evict-on-munmap) | hoped ~150–250 ms per loose write | ~1 day | #7 | Built and instrumented → **0 hits in 4 runs** → removed entirely. The one build made on an unverified cause; see learnings |
|
||||
| Faster card / SD at 20 MHz | none for the commit | PCB respin budget | #1 | Rejected — factor 1 refuted; the card was exonerated twice (86 ms then 77 ms composites) |
|
||||
| Shrink the repo (images off-card / LFS-style pointers) | shrinks N and the 570 MB clone | unknown | N itself | Rejected for now — the images are load-bearing for another app ([`../notes/git-sync-images-and-repo-size.md`](../notes/git-sync-images-and-repo-size.md)). Composes with the splice later |
|
||||
|
||||
**Chosen idea**: the O(depth) TreeBuilder splice, with fast-seek as its config
|
||||
companion — it is the only candidate whose cost is independent of repo size, so
|
||||
the problem cannot come back as the notes tree grows. Everything index-shaped
|
||||
stays O(N_tree) and merely moves the wall.
|
||||
|
||||
## 4) Test plan
|
||||
|
||||
**What could go wrong?**
|
||||
|
||||
| Lens | Anticipated consequence | Mitigation |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Stable | libgit2's 32-bit mwindow defaults `malloc` a 32 MB window on first pack access → instant OOM on the 8 MB PSRAM heap | `GIT_OPT_SET_MWINDOW_SIZE` 256 KB / `MAPPED_LIMIT` 4 MB set at service start, before any `Repository::open` |
|
||||
| Stable | Power pull between a save and the next `:sync` loses the dirty set — nothing walks the tree anymore, so an unrecorded file would **never** sync | Dirty set journaled to `/sd/.typoena-dirty` (atomic write), recorded _before_ the file write; over-reporting is a free no-op splice, so the semantics stay simple |
|
||||
| Stable | Commit lands, push fails → the commit strands forever behind "up to date" | Stranded-commit recovery: compare HEAD against `refs/remotes/origin/<branch>` and push even when there is nothing new to commit |
|
||||
| Method | Reconcile's `Mixed` reset writes the index → re-enters the racy-clean wall through the back door | `ResetType::Soft` — ref move only; there is no index to reset anymore |
|
||||
| Method | **Deliberate behavior change:** files edited on the card from a desktop are never committed anymore (`add_all` used to sweep them in) | Accepted and documented as intent — the appliance's editor is the only writer that counts |
|
||||
| Machine | libgit2 holds the pack + `.idx` descriptors open and opens loose objects on top → blows the editor's 4-FD mount | git builds mount with 16 FDs (`Storage::mount_for_git()`) |
|
||||
|
||||
The mechanism was stress-tested as a prototype first, never in production: the
|
||||
splice ran as a `git_bench` op against a full real-repo clone through four
|
||||
localization rounds before any firmware plumbing.
|
||||
|
||||
**Who must we convince?**
|
||||
|
||||
- Upstream libgit2 maintainers — the tlsf double-free in the mbedTLS stream
|
||||
error path (found during rollout; `wrap`'s error path frees the caller's
|
||||
socket stream, `new()` frees it again). Needs an upstream report; we ship a
|
||||
whole-file override meanwhile.
|
||||
- Desktop-me (and any future contributor) — the card's working tree now shows a
|
||||
permanent diff against HEAD when inspected on a Mac. That is intent, not a
|
||||
bug.
|
||||
|
||||
**Rollback**: the `git` feature flag gates the whole sync stack (the light
|
||||
build never links it), and the plumbing is a small commit range to revert. The
|
||||
journal file is additive — an old build simply ignores it.
|
||||
|
||||
**Measurement protocol for step 6**: full `:sync` on the device against the
|
||||
real clone — `commit split —` log lines plus snackbar-to-snackbar wall time,
|
||||
cold and steady-state.
|
||||
|
||||
## 5) Implementation
|
||||
|
||||
**Before** — `stage_and_commit` walked and hashed the world through the index
|
||||
(condensed; `firmware/src/git_sync.rs` at `a5edaed~1`):
|
||||
|
||||
```rust
|
||||
fn stage_and_commit(repo: &Repository) -> Result<Option<Oid>> {
|
||||
let mut index = repo.index()?;
|
||||
index.add_all(["*"], IndexAddOption::DEFAULT, Some(&mut skip_macos_cruft))?;
|
||||
index.update_all(["*"], Some(&mut skip_macos_cruft))?; // stat 1179 files
|
||||
index.write()?; // ⚡ racy-clean re-hash: ~611 s on FAT
|
||||
let tree = repo.find_tree(index.write_tree()?)?;
|
||||
// … commit(Some("HEAD"), …, &tree, &parents)
|
||||
}
|
||||
```
|
||||
|
||||
**After** — the editor's journaled dirty paths are spliced onto HEAD's tree;
|
||||
the index never exists (condensed; `firmware/src/git_sync.rs:345`):
|
||||
|
||||
```rust
|
||||
fn stage_and_commit(repo: &Repository, paths: &BTreeSet<String>) -> Result<Option<Oid>> {
|
||||
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
|
||||
let mut tree = parent.as_ref().map(|c| c.tree()).transpose()?;
|
||||
for path in paths {
|
||||
let blob = match fs::read(format!("{REPO_DIR}/{path}")) {
|
||||
Ok(bytes) => Some(repo.blob(&bytes)?), // present → splice in
|
||||
Err(e) if e.kind() == NotFound => None, // deleted → splice out
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let spliced = splice(repo, tree.as_ref(), &parts(path), blob)?;
|
||||
tree = Some(repo.find_tree(spliced)?);
|
||||
}
|
||||
// … same "tree unchanged → nothing to publish" check, same commit call
|
||||
}
|
||||
|
||||
/// Reads ~depth tree objects, writes ~depth new ones; every sibling entry is
|
||||
/// carried by OID — never opened.
|
||||
fn splice(repo: &Repository, base: Option<&Tree>, path: &[&str], blob: Option<Oid>) -> Result<Oid>
|
||||
```
|
||||
|
||||
The same pipeline, redrawn with the change applied:
|
||||
|
||||
```
|
||||
:sync (new method, real repo)
|
||||
│
|
||||
▼
|
||||
┌─ local commit ───────────────────────────────┐ ┌─ network push ──────┐
|
||||
│ splice: for each journaled dirty path (≈1) │ │ TLS + push │
|
||||
│ read file → blob → rebuild its ancestor │──▶│ ~6.5 s floor │
|
||||
│ subtree chain (~depth tree objects) │ │ (untouched — next │
|
||||
│ commit-obj │ │ curve to attack) │
|
||||
│ no index · no walk · no re-hash · images │ └─────────────────────┘
|
||||
│ carried by OID · lseek O(1) via fast-seek │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Around the mechanism, the plumbing that makes it safe day-to-day: the
|
||||
`/sd/.typoena-dirty` journal with its pending → in-flight → settled lifecycle,
|
||||
stranded-commit recovery, a radio-free "up to date" answer (~150 ms instead of
|
||||
a Wi-Fi/TLS round), soft-reset reconcile, the 16-FD git mount, and the mwindow
|
||||
options at service start. Details:
|
||||
[the fix — wiring](../tradeoff-curves/sync-commit-staging.md#the-fix--wiring-the-odepth-splice-into-the-firmware).
|
||||
|
||||
## 6) Evaluation
|
||||
|
||||
**Measurement redone** (seconds, `:sync` → snackbar on the real repo):
|
||||
∞ (never completed) → **24.1 s** measured end-to-end on device, 2026-07-13
|
||||
(commit `6713ea6e` pushed and accepted by origin).
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
xyChart:
|
||||
width: 900
|
||||
height: 500
|
||||
---
|
||||
xychart-beta
|
||||
title "Measurement redone — :sync → snackbar"
|
||||
x-axis ["Before kaizen (~611 s, never completes)", "After kaizen (24.1 s measured)"]
|
||||
y-axis "seconds" 0 --> 650
|
||||
bar [611, 24.1]
|
||||
line [10, 10]
|
||||
```
|
||||
|
||||
Split: Wi-Fi bring-up 3.7 s + clock/TLS 0.6 s + publish 19.8 s (splice 5.0 s,
|
||||
commit-obj 1.2 s, push ≈ 6.1 s, repo open/journal/refs the rest). The ≤ ~10 s
|
||||
target line is **not yet met** — but the failure mode changed category: from
|
||||
"bricks the device, forever" to "a finite number to shave".
|
||||
|
||||
Getting from the working commit half to an accepted push took a second
|
||||
localization loop: five verification runs, each killing one hypothesis with
|
||||
on-device telemetry built for that run.
|
||||
|
||||
### The push half — the second loop (runs 4–9, all 2026-07-13)
|
||||
|
||||
The step-4 mitigation "mwindow 256 KB / 4 MB" turned out to be necessary but
|
||||
nowhere near sufficient. The verification runs surfaced, in order:
|
||||
|
||||
| Factor | Hypothesis | Test method | Test result | True or false? |
|
||||
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| P1 — UI death by heap | The editor's per-draw `vec![0xFF; 26928]` framebuffer aborts the firmware when the push exhausts the heap (run 4: 66 s silent push, then abort on a keystroke) | backtrace + allocator fallback math (internal 23 KB < 27 KB ⇒ PSRAM had no 27 KB block either) | **TRUE** — a C `malloc` failure inside libgit2 returns an error; a Rust `Vec` failure aborts the process | fix: two boot-time frames, `draw_into` + `mem::swap` — steady-state repaints never allocate |
|
||||
| P2 — odb cache is the ~6 MB eater | libgit2's odb object cache (default max 256 MB — unbounded here) caches every tree/commit read during pack building | cap it at 1 MB + read `GIT_OPT_GET_CACHED_MEMORY` in a per-run heap line | cache used **59 KB** of its 1 MB cap at the moment of death | **FALSE** (cap kept as insurance) |
|
||||
| P3 — progress callbacks would show the slope | `pack_progress` throttled by object count will trace the silent stretch | run 6: zero lines in 65 s; then source read | count gate can't fire (`AddingObjects` reports `total = 0`, a 2-commit push inserts dozens of objects) — and deeper: the whole 65 s is `mark_edges_uninteresting` reading origin tip's ~1200-object tree **before the first insert**, a phase with no callbacks at all | **TRUE (as a bug in our telemetry)** — fix: 5 s sibling heartbeat thread through `remote.push()` |
|
||||
| P4 — libgit2's mmaps are the eater | every "mmap" is a real PSRAM malloc in `esp_map.c`; its live-bytes counter should track the drop | wire `esp_map_stats()` into the heap line + heartbeat | run 7: mmap-live tracked the PSRAM drop 1:1, **plateau 7151 KB of the 8 MB pool**, min-ever 472 B, 19.9 MB read off the card to push two commits | **TRUE** |
|
||||
| P5 — the budget itself is impossible | fixed whole-file index maps live **outside** the mwindow budget | inspect the source clone | 5 packs, 562 MB: 1730 KB multi-pack-index + ~1737 KB of `.idx` = **~3.4 MB fixed**, plus windows at the (working, soft) 4 MB limit | **TRUE** — fix: mwindow **64 KB windows / 1.5 MB budget** |
|
||||
| P6 — keep-alive dies during the marking gap | with memory fixed (run 8: min-ever 2.05 MB, marking 32 s), the push died at the **first write** of the upload: `SSL error: Generic error` after ~31 s of wire silence between the negotiation GET and the pack POST | timeline correlation (pack had built fine — 11 objects, deltafication complete) | GitHub/router closes the idle connection ~30 s; libgit2 treats the dead socket as fatal, no reconnect | **TRUE** |
|
||||
| P7 — pack layout is a device-performance variable | `git repack -ad` (5 packs → 1, midx gone) shrinks the fixed maps _and_ clusters trees for the marking walk | repack the source clone, reload the card, run 9 | marking **3.5 s** (was 32 s) — the whole boundary walk hit ONE 1740 KB idx + **two** 64 KB windows; push accepted | **TRUE** |
|
||||
|
||||
Run 9's heap line, for the record: min-ever PSRAM **4.5 MB free** (was 472 B
|
||||
three runs earlier), mmap live 1868 KB, odb cache 70 KB.
|
||||
|
||||
**Learnings**:
|
||||
|
||||
- **Bench the real thing.** The toy repo understated everything by ~2 orders
|
||||
of magnitude; "works on the toy" hid a total brick. The real clone is now
|
||||
the only valid bench target.
|
||||
- **Refute before building.** Four theories died to measurement (card speed,
|
||||
free-cluster scan, strict creation, repeated windows). The one build made on
|
||||
an unverified cause — the `esp_map.c` window cache — was the one wasted
|
||||
build: 0 hits in 4 instrumented runs, removed entirely.
|
||||
- **FAT breaks POSIX assumptions.** 2 s mtime granularity, no inode,
|
||||
cluster-chain seeks: any POSIX-shaped library (libgit2's racy-clean check,
|
||||
the mmap emulation) needs its cost model audited on FAT before trusting it.
|
||||
- **Prefer mechanisms flat in N.** The splice cannot regress as the notes tree
|
||||
grows — the fix that prevents the problem from ever coming back, which is
|
||||
the kind kaizen prefers.
|
||||
- **The UI must not share a fate with git.** A C allocation failure inside
|
||||
libgit2 degrades to an error snackbar; a Rust `Vec` allocation failure aborts
|
||||
the firmware. Steady-state repaints now render into pre-allocated frames so
|
||||
a heap-starved `:sync` can fail without taking the editor down (run 4 → 5).
|
||||
- **Telemetry needs the right axis.** A count-based progress gate logged
|
||||
nothing because libgit2 reports `total = 0` during insertion — and the
|
||||
longest phase fires no callbacks at all. Time-gated logging plus a sibling
|
||||
heartbeat thread is what finally produced the ~6 MB consumer's name in one
|
||||
run.
|
||||
- **Budget the whole emulation, not the knob you can see.** The mwindow
|
||||
mapped-limit worked exactly as configured — and was irrelevant, because
|
||||
whole-file `.idx`/multi-pack-index maps (~3.4 MB) live outside it. On a
|
||||
malloc-backed mmap, the budget is `esp_map` live bytes, not the mwindow
|
||||
limit.
|
||||
- **Repo pack layout is a device-performance variable.** The same clone as
|
||||
5 packs vs 1 pack is the difference between a 32 s marking walk (95 windows,
|
||||
9.2 MB read) and a 3.5 s one (2 windows) — and between losing and winning
|
||||
the keep-alive race. `git repack -ad` on the source clone is card-prep, not
|
||||
an optimization.
|
||||
|
||||
**Standard to update**:
|
||||
|
||||
- Bench and verify against the **real repo clone**, never the toy (the toy is
|
||||
~2 orders of magnitude too kind) — recorded in the tradeoff doc's
|
||||
[how to bench](../tradeoff-curves/sync-commit-staging.md#how-to-bench--flash).
|
||||
- Any new libgit2 entry point **must set the mwindow options before its first
|
||||
`Repository::open`** — the 32-bit defaults OOM the 8 MB heap. Values revised
|
||||
by the push loop: **64 KB windows / 1.5 MB mapped limit** (the step-4 values,
|
||||
256 KB / 4 MB, left no room for the ~3.4 MB of fixed index maps).
|
||||
- Cards are loaded from a **single-pack clone**: `git repack -ad` on the source
|
||||
before `just load` (candidate: fold into the `_load-repo` recipe).
|
||||
- `CONFIG_FATFS_USE_FASTSEEK=y` + 256-word CLMT buffer stay pinned in
|
||||
`sdkconfig.defaults`.
|
||||
- Firmware repaint paths **never allocate the framebuffer per draw** — render
|
||||
into the two persistent frames via `Editor::draw_into`.
|
||||
|
||||
**Share with**:
|
||||
|
||||
- Upstream libgit2 — file the tlsf double-free report (mbedTLS stream error
|
||||
path: `wrap`'s error path frees the caller's socket stream, `new()` frees it
|
||||
again; v1.9.4).
|
||||
- A write-up for the notes/blog — the four-round localization story (611 s →
|
||||
~2.8 s commit on a microcontroller, four theories refuted on the way) stands
|
||||
on its own.
|
||||
|
||||
**Next steps** (each now owned by a version doc or done — this list is the
|
||||
historical record, the owner is authoritative)
|
||||
|
||||
- Close the gap to the ≤ ~10 s target: publish is 19.8 s; the splice's 5.0 s
|
||||
and the ~7 s of repo-open/negotiation overhead are the next curves.
|
||||
→ [v0.7](../v0.7-search-and-git.md).
|
||||
- The keep-alive race is **avoided, not fixed** (3.5 s ≪ ~30 s) — a future
|
||||
multi-pack or cold-cache state could still lose it. Durable fix:
|
||||
reconnect-on-stale-connection in the http/stream layer.
|
||||
→ [v0.9](../v0.9-robustness.md).
|
||||
- ~~Internal RAM min-ever hit **2099 bytes** during the TLS send (the
|
||||
diagnostic heartbeat's 8 KB stack is part of it) — shrink or retire the
|
||||
heartbeat now that it has served its purpose.~~ **Done 2026-07-13** —
|
||||
heartbeat removed; the time-gated `pack_progress` + transfer callbacks stay.
|
||||
- ~~Fold `git repack -ad` into the `_load-repo` recipe so single-pack card
|
||||
prep is enforced, not remembered.~~ **Done 2026-07-13** — `_load-repo`
|
||||
repacks the source clone (best-effort, like the pull) whenever it has more
|
||||
than one pack or a multi-pack-index.
|
||||
- File the libgit2 upstream bug report (tlsf double-free in the mbedTLS
|
||||
stream error path). _(Unscheduled chore — not version-scoped.)_
|
||||
- Instrument the residual ~360 ms/loose-write (suspect: FAT directory-op cost
|
||||
in the freshen/refresh path) — one `sd_bench` + `p_mmap`-miss logging pass.
|
||||
→ [v0.7](../v0.7-search-and-git.md).
|
||||
- The images-off-card lever
|
||||
([`../notes/git-sync-images-and-repo-size.md`](../notes/git-sync-images-and-repo-size.md))
|
||||
composes with the splice. → [v0.7](../v0.7-search-and-git.md).
|
||||
240
docs/macroplan.md
Normal file
240
docs/macroplan.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Macroplan — version details
|
||||
|
||||
Frequent releases. Each version is a usable artifact, not a checkpoint.
|
||||
This file holds the `macroplan` source block (below), a cross-version status
|
||||
roll-up, and a one-line summary of each release linking to its dedicated page.
|
||||
The user-facing requirements and engineering targets each release feeds into are
|
||||
tracked in [`qfd.md`](qfd.md).
|
||||
|
||||
## Macro-plan
|
||||
|
||||
Macroplan source — paste into the macroplan app to render the week-by-week
|
||||
view. `original` dates are the June 2026 baseline and never move; slips get
|
||||
appended as `reestimates`, per-item actuals live in the Status block below.
|
||||
|
||||
```macroplan
|
||||
title = "Typoena — macro plan"
|
||||
|
||||
[[feature]]
|
||||
name = "v0.1 it writes, it pushes"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-29
|
||||
delivered = 2026-07-11
|
||||
learning = "Shipped 12 days late. The long pole was hardware bring-up risk, not the editor: SD on a shared SPI bus (resolved by moving it to its own SPI3, ADR-012) and on-device git (gix killed, pivoted to libgit2 as an esp-idf CMake component, ADR-004). Splash landed as a vector wordmark, not the planned 1-bit bitmap — the asset-embed/blit path is deferred to v1.0."
|
||||
|
||||
[[feature]]
|
||||
name = "v0.2 navigation"
|
||||
start = 2026-06-29
|
||||
original = 2026-07-20
|
||||
delivered = 2026-07-11
|
||||
learning = "Delivered 9 days early. Motions/modes, Ctrl-d/u, the UTF-8 buffer, and the absolute line-number gutter all landed 2026-07-11; the last gate, Spike 13's on-panel gutter refresh check, confirmed a single-line edit repaints only rows at/below it with no extra full refresh. Relative line numbering was dropped as an e-ink ghosting cost with no proportionate gain."
|
||||
|
||||
[[feature]]
|
||||
name = "v0.2.5 international input"
|
||||
start = 2026-07-20
|
||||
original = 2026-08-03
|
||||
delivered = 2026-07-11
|
||||
learning = "Delivered 23 days early — ahead of its own start window. Dead-key accent composer in the keymap crate (US-International, à é ê ë ñ ç), editor buffer made UTF-8-correct, typed on the bench with no panic. The side-panel pending-accent marker was dropped by decision: at typing speed it is stale before the ~630 ms panel repaint, so it conveyed nothing. Bonus: physical Esc (HID 0x29) remapped to backtick/tilde so code fences + grave/tilde accents work on a 60% board without a Fn layer."
|
||||
|
||||
[[feature]]
|
||||
name = "v0.3 editing"
|
||||
start = 2026-08-03
|
||||
original = 2026-08-24
|
||||
delivered = 2026-07-11
|
||||
learning = "Core complete 44 days early, host-tested and partially smoke-tested on the panel. Register + yank/paste (yy/p/P), snapshot undo/redo (u/Ctrl-r, bounded 100 groups in PSRAM), and keystroke-recorded `.` repeat all landed 2026-07-11; the d/c operator grammar + text objects were already done ahead of schedule. Firmware bumped to 0.3.0. On device dd/yy/Ctrl-r confirmed; the one bug found was a multi-line paste leaving its later lines below the fold (adjust_scroll only tracked the caret) — fixed with a reveal() that scrolls the block end into view."
|
||||
|
||||
[[feature]]
|
||||
name = "v0.4 visual + ex"
|
||||
start = 2026-08-24
|
||||
original = 2026-09-07
|
||||
delivered = 2026-07-11
|
||||
learning = "Core complete 58 days early, host-tested. Visual (v) and VisualLine (V) selection with y/d/c landed 2026-07-11 (charwise vim-inclusive of the char under the caret; linewise spans whole lines and pastes like yy/dd), plus the recorded v/V→Visual reassignment: the read-only View mode moved to `gr` (go-read). Selection is drawn as reverse-video cells on the 1-bit panel with the caret punched back to normal video so the active end stands out; 18 new editor tests (83 total). The `:` command mechanism and :fmt were already done; `:e <path>` was deliberately deferred to v0.5 where its multi-file/buffer-lifecycle machinery (Spikes 11/14) lives, rather than half-building file-open here. Firmware bumped to 0.4.0. On-device smoke-test of Visual still pending (pure editor-core, low risk)."
|
||||
|
||||
[[feature]]
|
||||
name = "v0.5 palette + multi-file"
|
||||
start = 2026-09-07
|
||||
original = 2026-09-28
|
||||
delivered = 2026-07-12
|
||||
learning = "Delivered 2026-07-12, well ahead of the 2026-09-28 baseline, and fully on-device confirmed. Four slices: the drained Effect queue + parked-buffer LRU foundation; the Cmd-P fuzzy file palette (Spike 11 — no ghosting on the transient panel); :enew + file delete (Spike 14 caught that add_all alone doesn't stage a deletion on this libgit2 — fixed with update_all, i.e. git add -A); and the git-tracked .typoena.toml prefs with a stay-open palette `>` command mode + :settings. Both directions of the prefs loop are proven on hardware — boot-read (byte-exact parse) and on-device palette edit (a device publish flipped line_numbers on origin). Three decide-before-build calls: the idle auto-save is unformatted, and both the per-device auto_sync override and the `> auto sync` command are deferred to v0.7 where auto_sync gains behaviour. Amended 2026-07-12: a light/dark `theme` key and a set-ahead `> auto sync` preset command (2m/5m/10m/15m/30m) were added on top — the palette generalised so Enter rotates any pref to its next value (a bool is the two-option case); auto_sync is still read by nothing until v0.7. Descoped from v0.5 (not the four slices): explicit buffer close, the grey-Publish-in-Local panel cue, and the multi-file publish count."
|
||||
|
||||
[[feature]]
|
||||
name = "v0.6 markdown"
|
||||
start = 2026-09-28
|
||||
original = 2026-10-12
|
||||
delivered = 2026-07-12
|
||||
learning = "Core complete 2026-07-12, ~92 days ahead of the 2026-10-12 baseline, host-tested (187 editor tests). The snippet feature was reshaped 2026-07-08→07-12 from a hard-coded table into a git-synced, Zed-compatible .typoena.snippets.json library: a forward-only tab-stop session ($1..$n/$0, ${n:label} stripped to $n) driven by two surfaces — inline Tab-expansion in Insert and a $ palette launcher — plus a quiet pause hint in the side panel. The Cmd-P palette generalised into a verb split: bare = files, > = a real command registry (toggles stay open, one-shots format/publish close, the parameterised `new file` two-step), $ = snippets — retiring :e. Firmware bumped 0.5.0→0.6.0; the boot-read of the library was confirmed to build for xtensa (serde_json, the one new dep — cargo check passes). `just init` now seeds a curated 17-snippet catalog (three opt-in groups). On-device smoke-test still pending (pure editor-core + a mirror of the proven prefs boot-read, low risk). Known caveat: two symbols the catalog inserts (arrow →, neq ≠) are outside ISO-8859-15, so they store/sync correctly but need a display-layer glyph overlay (in flight) to draw on the panel; the other 15 render on the stock font."
|
||||
|
||||
[[feature]]
|
||||
name = "v0.7 search + git"
|
||||
start = 2026-10-12
|
||||
original = 2026-11-02
|
||||
delivered = 2026-07-14
|
||||
learning = "Delivered 2026-07-14, ~16 weeks ahead of the 2026-11-02 baseline, and closed on-device across three bench runs in three days. `/` search shipped smartcase + accent-folded (a user decision that superseded the same-day plain-insensitive version; /ete finds été) with n/N, Enter-only jump, and an editor-global pattern. `:gl` pull landed fetch + fast-forward-only in all four shapes; the fast-forward is an O(changed) tree-diff apply (apply_tree_diff) built after run 2 crashed in libgit2's O(tree) checkout_tree — internal-DRAM exhaustion plus an esp-idf spi_master NULL-deref on its own failed-alloc path. Three memory/transport fixes rode along: file-list interning to one PSRAM blob (was 182 KB internal), a 64 KB DMA reserve, and TLS session resumption (third vendor delta), which cut the rejected-push reconcile cycle from 59 s to 24 s. Bonus: the first on-device rejected-push → reconcile → replay → push success, and the sd_bench dir-scaling run convicted FAT linear directory scans as the ~400 ms/loose-write residual (bounded, accepted). :sync was renamed :gp to pair with :gl."
|
||||
|
||||
[[feature]]
|
||||
name = "v0.8 battery + sleep"
|
||||
start = 2026-11-02
|
||||
original = 2026-11-30
|
||||
|
||||
[[feature]]
|
||||
name = "v0.9 robustness"
|
||||
start = 2026-11-30
|
||||
original = 2026-12-28
|
||||
|
||||
[[feature]]
|
||||
name = "v1.0 polish"
|
||||
start = 2026-12-28
|
||||
original = 2027-01-25
|
||||
|
||||
[[milestone]]
|
||||
name = "MVP ships"
|
||||
week = 2026-06-29
|
||||
requires = ["v0.1 it writes, it pushes"]
|
||||
```
|
||||
|
||||
## Status — synced 2026-07-14
|
||||
|
||||
The editor **core** has been built 2–3 versions ahead of the device
|
||||
**releases**, and is now **extracted into a host-testable `editor` crate** (plus
|
||||
a `display` crate for the panel framebuffer) so `cargo test` exercises it off the
|
||||
xtensa target. **v0.1 shipped 2026-07-11** (late against the 2026-06-29
|
||||
baseline): SD storage, save, and **git publish are all wired into the app binary
|
||||
and hardware-verified** (`:sync` commits on the SD `/sd/repo` and pushes to a
|
||||
test repo), and the **boot splash (Spike 9) is confirmed on the panel** — a
|
||||
vector `typoena`-in-a-circle shown at startup while the SD mounts, then the
|
||||
editor comes up. **Cold boot verified at 4258 ms** (power-on → cursor,
|
||||
2026-07-11; 742 ms under the ≤ 5 s gate). It first measured ~5.5 s; the fix was
|
||||
to bring the editor up with a full-area partial (~630 ms) instead of a second
|
||||
full refresh (~1.9 s) — panel confirmed clean, no ghosting. The 1-hour soak is
|
||||
attested from real use; the remaining post-ship acceptance checks are power-pull
|
||||
recovery, 1000-word no-drop, and `Ctrl-G`'s not-yet-built pull-then-retry
|
||||
(→ v0.9). **v0.2 navigation is COMPLETE 2026-07-11** — Spike 13's on-panel gutter
|
||||
refresh check passed (single-line edit repaints only rows at/below it, no extra
|
||||
full refresh), closing the last gate. **v0.2.5 international input** is
|
||||
hardware-verified (2026-07-11), and **v0.3 editing is complete in core** the same
|
||||
day (register + yank/paste, snapshot undo/redo, `.` repeat — host-tested, and
|
||||
partially smoke-tested on the panel: `dd`/`yy`/`Ctrl-r` good, a multi-line-paste
|
||||
scroll bug found + fixed). **v0.4 visual + ex is complete in core** the same day
|
||||
too — charwise/linewise **Visual** selection (`v`/`V` with `y`/`d`/`c`), the
|
||||
read-only View mode moved to `gr`, and the selection drawn as reverse-video on
|
||||
the panel; `:e` was deferred to v0.5. Host-tested (83 editor tests); on-device
|
||||
smoke-test pending. The firmware crate is bumped to **0.4.0**. Most of v0.6
|
||||
Markdown also already runs. Version numbers track shippable device releases, not
|
||||
raw core progress — the 0.4.0 bump reflects the v0.4 feature set being met.
|
||||
**v0.5 palette + multi-file is DELIVERED 2026-07-12** (firmware **0.5.0**), fully
|
||||
on-device confirmed: the Cmd-P fuzzy palette, `:e`/`:enew`/delete across the
|
||||
`/sd/repo` + `/sd/local` scopes, and the git-tracked `.typoena.toml` prefs
|
||||
(boot-read plus a stay-open palette `>` command mode + `:settings` that edits them
|
||||
live and syncs the change). Descoped to later: explicit buffer close, the
|
||||
grey-Publish-in-Local panel cue, and the multi-file publish count.
|
||||
**v0.6 Markdown is COMPLETE in core 2026-07-12** (firmware **0.6.0**), host-tested
|
||||
(187 editor tests), on-device smoke-test pending. The render affordances (heading
|
||||
bold, list continuation, soft-wrap) were done early; the headline is the
|
||||
**snippet engine** — a forward-only tab-stop session reached both inline (type a
|
||||
prefix + Tab in Insert) and from a **`$` palette** launcher, fed by a git-synced,
|
||||
Zed-compatible `.typoena.snippets.json` read at boot (serde_json, confirmed to
|
||||
build for xtensa). The `Cmd-P` palette **generalised** into a verb split — bare =
|
||||
files, `>` = a command registry (toggles stay open, `format`/`publish` one-shots
|
||||
close, a two-step `new file`), `$` = snippets — which **retired `:e`**. `just init`
|
||||
seeds a curated 17-snippet catalog (Symbols · Structure · Prose, opt-in). One
|
||||
caveat: `→`/`≠` sit outside ISO-8859-15 and need a display-layer glyph overlay
|
||||
(in flight) to render on the panel; the other 15 draw on the stock font.
|
||||
**v0.7 search + better git is CLOSED 2026-07-14** (firmware **0.7.0**), verified
|
||||
on-device over three bench runs: `/` search (smartcase + accent-folded, `n`/`N`)
|
||||
panel-confirmed, and `:gl` pull proven in all four shapes — the fast-forward
|
||||
closing gate passed with an O(changed) `apply_tree_diff` written after libgit2's
|
||||
`checkout_tree` crashed the device on run 2. TLS session resumption cut the
|
||||
rejected-push reconcile cycle from 59 s to 24 s, and `:sync` was renamed `:gp`.
|
||||
Still open post-v0.7: the warm clean-publish measurement, the images-off-card
|
||||
decision, and the empty-note trailing-newline watch item.
|
||||
|
||||
Marks: `[x]` done in core · `[~]` partially done · `[ ]` not started. An
|
||||
inline `(✓)` marks the done half of a split item.
|
||||
|
||||
Each version below links to its dedicated page, which carries the full scope
|
||||
checklist and status.
|
||||
|
||||
---
|
||||
|
||||
## v0.1 — MVP: "it writes, it pushes" — [x]
|
||||
|
||||
The minimum thing that justifies the hardware existing — boot, type one file,
|
||||
`:w` to save, `:sync` to publish to GitHub. **SHIPPED 2026-07-11** (late vs the
|
||||
2026-06-29 baseline); cold boot verified at 4258 ms.
|
||||
**Design:** [product](v0.1-mvp-product.md) · [technical](v0.1-mvp-technical.md).
|
||||
|
||||
## v0.2 — Vim navigation — [x]
|
||||
|
||||
Modal Normal/Insert/View, `h j k l`/`w b e`/`0 $`/`gg G` motions, `Ctrl-d/u`
|
||||
half-page scroll, the UTF-8-correct buffer, and the absolute line-number gutter.
|
||||
**COMPLETE 2026-07-11.** Detail: [v0.2-navigation.md](v0.2-navigation.md).
|
||||
|
||||
## v0.2.5 — International input — [x]
|
||||
|
||||
US-International dead-key accent composition (à é ê ë ñ ç) in the `keymap`
|
||||
crate, plus the Esc→backtick/tilde remap for a 60% board.
|
||||
**Hardware-verified 2026-07-11.**
|
||||
Detail: [v0.2.5-international-input.md](v0.2.5-international-input.md).
|
||||
|
||||
## v0.3 — Vim editing — [x]
|
||||
|
||||
Register + yank/paste (`yy`/`p`/`P`), snapshot undo/redo (`u`/`Ctrl-r`), `.`
|
||||
repeat, and the `d`/`c` operator grammar + text objects.
|
||||
**COMPLETE in core 2026-07-11**, partially smoke-tested on the panel.
|
||||
Detail: [v0.3-editing.md](v0.3-editing.md).
|
||||
|
||||
## v0.4 — Visual mode + ex commands — [x]
|
||||
|
||||
Charwise `v` / linewise `V` selection with `y`/`d`/`c`, the `:` command line
|
||||
(`:w`/`:fmt`/`:sync`/`:gl`), and View mode moved to `gr`.
|
||||
**COMPLETE in core 2026-07-11**, on-device smoke-test pending.
|
||||
Detail: [v0.4-visual-and-ex.md](v0.4-visual-and-ex.md).
|
||||
|
||||
## v0.5 — File palette + multi-file — [x]
|
||||
|
||||
The `Cmd-P` fuzzy file palette, `:e`/`:enew`/delete across `/sd/repo` +
|
||||
`/sd/local`, the parked-buffer LRU, and the git-tracked `.typoena.toml` prefs
|
||||
with a palette `>` command mode + `:settings`.
|
||||
**DELIVERED 2026-07-12** (firmware 0.5.0), fully on-device confirmed.
|
||||
Detail: [v0.5-palette-and-multi-file.md](v0.5-palette-and-multi-file.md).
|
||||
|
||||
## v0.6 — Markdown affordances — [x]
|
||||
|
||||
Heading bolding, list continuation, and soft-wrap, plus the trigger-driven
|
||||
snippet engine (net-new scope, added 2026-07-08): a tab-stop session reached
|
||||
inline (prefix + Tab) and from the `$` palette, fed by a git-synced,
|
||||
Zed-compatible `.typoena.snippets.json`; the `Cmd-P` palette generalised into a
|
||||
`files`/`>` commands/`$` snippets split (retiring `:e`); and a `just init`
|
||||
catalog. **COMPLETE in core 2026-07-12** (firmware 0.6.0), on-device smoke-test
|
||||
pending. Detail: [v0.6-markdown.md](v0.6-markdown.md).
|
||||
|
||||
## v0.7 — Search + better git — [x]
|
||||
|
||||
`/` forward search (`n`/`N`, smartcase + accent-folded) and `:gl` pull (fetch +
|
||||
fast-forward only, an O(changed) tree-diff apply); `:sync` renamed `:gp`.
|
||||
**CLOSED 2026-07-14** (firmware 0.7.0), panel- and git-path-verified on-device.
|
||||
Detail: [v0.7-search-and-git.md](v0.7-search-and-git.md).
|
||||
|
||||
## v0.8 — Power: battery + sleep — [ ]
|
||||
|
||||
Bench current-draw measurement, 18650 + charge board, per-sync Wi-Fi teardown,
|
||||
light/deep sleep, the `auto_sync` runtime (re-homed from v0.7), and a battery
|
||||
indicator. **Not started.**
|
||||
Detail: [v0.8-battery-and-sleep.md](v0.8-battery-and-sleep.md).
|
||||
|
||||
## v0.9 — Robustness — [ ]
|
||||
|
||||
Crash-safe writes, interrupted-push recovery, SD removal handling, Wi-Fi
|
||||
reconnect, and on-device provisioning (the first release usable by a non-author).
|
||||
**Not started.** Detail: [v0.9-robustness.md](v0.9-robustness.md).
|
||||
|
||||
## v1.0 — Polish — [ ]
|
||||
|
||||
≤ 3 s boot, runtime-switchable fonts, enclosure files, and a user guide
|
||||
(light/dark theme landed early, in v0.5). **Not started.** Detail:
|
||||
[v1.0-polish.md](v1.0-polish.md).
|
||||
|
||||
## v1.x — Stretch / nice-to-have
|
||||
|
||||
Post-1.0 ideas, not committed to any release (10.3" panel, multiple remotes,
|
||||
writing stats, BLE-HID fallback). Detail: [v1.x-stretch.md](v1.x-stretch.md).
|
||||
14
docs/notes/README.md
Normal file
14
docs/notes/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Notes
|
||||
|
||||
> Longer-form essays on the thinking behind specific Typoena choices — the
|
||||
> arguments too big for an ADR and too durable for a commit message.
|
||||
>
|
||||
> Docs index: [`../README.md`](../README.md). Project overview:
|
||||
> [`../../README.md`](../../README.md).
|
||||
|
||||
| Note | What it argues |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) | Durability before delivery — surfacing "commit landed" at ~0.2 s makes the 5–10 s `Ctrl-G` push feel instant. |
|
||||
| [`git-sync-images-and-repo-size.md`](git-sync-images-and-repo-size.md) | Why we don't shrink the notes repo — its 153 MB of media is remanso's image CDN, so rewriting history to slim the on-device clone breaks the web app. |
|
||||
| [`boot-time-budget.md`](boot-time-budget.md) | Where the ~4.3 s to cursor goes — and why the ≤ 3 s v1.0 target is hard: one ~1.9 s full refresh is unavoidable at cold boot, so the splash is nearly free. |
|
||||
| [`sync-latency.md`](sync-latency.md) | Where the ~16 s cold `:sync` goes — Wi-Fi + SNTP + one TLS push; why optimistic-retry cut a whole handshake, and why the rest is close to the protocol floor. |
|
||||
73
docs/notes/boot-time-budget.md
Normal file
73
docs/notes/boot-time-budget.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Boot-time budget — where the ~4.3 s to cursor goes
|
||||
|
||||
> **Measured 2026-07-11:** cold boot is **4258 ms** power-on → cursor (the
|
||||
> `boot: cursor ready` log prefix). That clears the **≤ 5 s v0.1 gate** with
|
||||
> ~742 ms to spare. This note breaks the number down, and argues that the
|
||||
> **≤ 3 s v1.0 target** is hard on this panel because one ~1.9 s full refresh is
|
||||
> architecturally unavoidable at cold boot.
|
||||
>
|
||||
> Notes index: [`README.md`](README.md). Docs index:
|
||||
> [`../README.md`](../README.md). Backs the boot-time acceptance criterion in
|
||||
> [`../v0.1-mvp-product.md`](../v0.1-mvp-product.md#acceptance-criteria) and the
|
||||
> v1.0 goal in [`../macroplan.md`](../macroplan.md). Refresh cost model:
|
||||
> [`../tradeoff-curves/epd-refresh-latency.md`](../tradeoff-curves/epd-refresh-latency.md).
|
||||
|
||||
## The waterfall
|
||||
|
||||
From the boot serial log (power-on → first editor frame + input loop live):
|
||||
|
||||
| Phase | ~ms | Lever |
|
||||
| ------------------------------------------- | --------: | --------------------------------------------------------------------------------------------------- |
|
||||
| ROM + 2nd-stage bootloader + app image load | ~550 | flash speed (DIO now; QIO / 80 MHz ≈ −200 ms, speculative) |
|
||||
| PSRAM init + **memtest** + heap | ~920 | `CONFIG_SPIRAM_MEMTEST=n` → **−730 ms** (kept **on**: a real HW sanity check on a hand-wired board) |
|
||||
| EPD reset + init | ~130 | fixed panel bring-up |
|
||||
| **Splash full refresh** | ~1850 | e-ink floor — see below |
|
||||
| SD mount + note load | ~70 | quick on the genuine 32 GB SDHC |
|
||||
| USB host install + git thread spawn | ~60 | background |
|
||||
| **First editor render** (full-area partial) | ~680 | already fixed from ~1870 ms (was a _second_ full refresh) |
|
||||
| **Total** | **~4260** | |
|
||||
|
||||
Two lines carry the weight: the **splash full refresh (~1.85 s)** and the
|
||||
**first editor render (~0.68 s)**. Everything else is ≤ ~0.9 s combined, and the
|
||||
biggest of _those_ — the ~0.73 s PSRAM memtest — is a deliberate keep.
|
||||
|
||||
## The insight: one full refresh is unavoidable, so the splash is nearly free
|
||||
|
||||
After power-on the panel controller's `0x26` "previous" RAM bank holds garbage. A
|
||||
partial refresh _diffs the new image against that bank_
|
||||
([`../tradeoff-curves/epd-refresh-latency.md`](../tradeoff-curves/epd-refresh-latency.md)),
|
||||
so the **first clean paint must be a full refresh** (~1.9 s) to establish a known
|
||||
image. There is no way around this on this panel short of a different waveform.
|
||||
|
||||
That reframes two things:
|
||||
|
||||
- **The splash costs almost nothing.** Boot needs one full refresh regardless; the
|
||||
splash simply _is_ that refresh, turned into a "boot is happening" affordance.
|
||||
Dropping the splash would **not** save the 1.9 s — the editor's first frame would
|
||||
then have to be the full refresh instead. (This is exactly what the old boot did
|
||||
and why it paid _two_ full refreshes.)
|
||||
- **The v0.1 win was removing the second full refresh, not the first.** Once the
|
||||
splash has seeded a clean baseline, the editor rides in on a full-area _partial_
|
||||
(~0.63 s) instead of a second full refresh (~1.9 s) — the ~1.25 s saving that
|
||||
took cold boot from ~5.5 s to ~4.26 s. Verified clean on-panel (no splash
|
||||
ghost behind the editor text).
|
||||
|
||||
## Is ≤ 3 s (v1.0) reachable?
|
||||
|
||||
To go from ~4.26 s to ≤ 3 s needs ~1.26 s cut. The honest lever list:
|
||||
|
||||
- **PSRAM memtest off:** −0.73 s → ~3.5 s. Costs the boot-time hardware check;
|
||||
reasonable once the board is no longer hand-wired.
|
||||
- **Faster flash boot (QIO / 80 MHz):** ~−0.2 s, speculative, needs a bench check.
|
||||
- **Overlap cheap init under the splash busy-wait:** SD mount + note load + USB
|
||||
install (~0.13 s total) currently run _after_ the splash refresh returns, but
|
||||
the refresh is a `wait_while_busy` spin — those could be kicked off before it.
|
||||
Saves ~0.1 s at most.
|
||||
|
||||
Even stacked, that lands around **~3.2 s** — still over. The ~1.9 s full-refresh
|
||||
floor is the wall, and it can't be cut without dropping the clean first image or
|
||||
moving to a faster panel/waveform. **Conclusion:** ≤ 3 s is marginal-to-unreachable
|
||||
on the GDEY0579T93 as driven today. When v1.0 comes, either revisit the target
|
||||
(≤ 3.5 s is achievable with the memtest off), or accept the splash as the
|
||||
deliberate cover for the one refresh e-ink makes us pay. Recorded here so the v1.0
|
||||
boot-time item is scoped against physics, not optimism.
|
||||
@@ -17,8 +17,8 @@ Sitting with that, here's the concern I couldn't shake:
|
||||
|
||||
The reframing question: **what is the user actually waiting for?** For `Ctrl-S`, the moment that matters is "my work is saved" — and the SD card completes the write in 50–200 ms. Save = safe. Same instant.
|
||||
|
||||
For `Ctrl-G`, the equivalent moment isn't "push complete." It's "commit landed locally" — which happens at ~0.2 seconds, well before the push even starts. From that moment on, your work is preserved across power loss, SD removal, the apocalypse — everything except remote delivery. The remaining 5–10 seconds is _transport of an already-safe thing_.
|
||||
For `Ctrl-G`, the equivalent moment isn't "push complete." It's "commit landed locally" — which happens at ~0.2 seconds, well before the push even starts. From that moment on, your work is preserved across power loss and SD removal — everything except remote delivery. The remaining 5–10 seconds is _transport of an already-safe thing_.
|
||||
|
||||
Surface that moment in the status line at ~0.2 seconds (`✓ committed abc1234 · pushing…`) and the perceived latency of `Ctrl-G` collapses from 10 seconds to roughly 200 milliseconds. The gap with `Ctrl-S` disappears.
|
||||
Surface that moment in the side panel at ~0.2 seconds (`✓ committed abc1234 · pushing…`) and the perceived latency of `Ctrl-G` collapses from 10 seconds to roughly 200 milliseconds. The gap with `Ctrl-S` disappears.
|
||||
|
||||
**Durability before delivery.** The moment that matters to the user is the moment durability is achieved, not the moment delivery completes. Once you see that, the slow operations stop feeling slow.
|
||||
|
||||
159
docs/notes/git-sync-images-and-repo-size.md
Normal file
159
docs/notes/git-sync-images-and-repo-size.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Why we don't shrink the notes repo
|
||||
|
||||
> The 150 MB of images in my notes repo isn't bloat to offload — it's the
|
||||
> image CDN for the web app that reads the same repo. Shrinking it for
|
||||
> Typoena's sake breaks remanso. So we don't.
|
||||
|
||||
The question that started this: **Typoena** keeps a persistent clone of my
|
||||
notes repo (`github.com/jcalixte/notes`) on its SD card and fast-forwards it
|
||||
on every `Ctrl-G`. The most likely first failure is a cold clone that's too
|
||||
big — so the instinct was "clone the least data possible; ignore
|
||||
`node_modules`; maybe strip the media." That instinct is mostly wrong, for
|
||||
reasons worth writing down before anyone force-pushes a rewritten history.
|
||||
|
||||
## The measured reality
|
||||
|
||||
| Metric (target repo, measured 2026-07-07) | Value |
|
||||
| ----------------------------------------- | ------------------------------------ |
|
||||
| Working tree | 3.9 GB (dominated by `node_modules`) |
|
||||
| `.git` (what a clone actually transfers) | **566 MB** |
|
||||
| Commits / objects | 13,852 / 63,252 |
|
||||
| Depth-1 snapshot (HEAD tree + blobs) | **154.7 MB** |
|
||||
| — of which markdown (the notes) | **1.4 MB** |
|
||||
| — of which media (png/jpg/pdf/gif/bmp) | **153 MB** |
|
||||
| Media across _all_ history (dedup) | 566 MB (715 PNG objects = 463 MB) |
|
||||
|
||||
Two early assumptions corrected:
|
||||
|
||||
- **`node_modules` is a non-issue.** It's gitignored and was never committed,
|
||||
so a clone never touches it. There's nothing to filter. The 3.9 GB working
|
||||
tree is a red herring; a clone transfers the 566 MB `.git`.
|
||||
- **The risk isn't stack overflow, it's transfer size.** 566 MB over Wi-Fi +
|
||||
mbedTLS to an SPI SD card, with no resume, is 30+ minutes and one dropout
|
||||
away from failure. Stack/memory pressure is a symptom of asking the device
|
||||
to cold-clone half a gigabyte.
|
||||
|
||||
## Why shrinking is off the table
|
||||
|
||||
The clean fix for "device only needs 1.4 MB of text" would be a blobless
|
||||
partial clone (`--filter=blob:none`) — **libgit2 does not support it**. The
|
||||
fallbacks are LFS migration, a filter-repo history purge, or `git rm` of media
|
||||
at HEAD. All three remove the image _blobs_ from what a git client sees.
|
||||
|
||||
That breaks **remanso**, the web app that reads the same repo. remanso is a
|
||||
frontend with no server-side git; it displays a note image by reading the
|
||||
image straight out of git as a blob and inlining it as a data URI
|
||||
(`useImages.hook.ts` + `repo.ts`):
|
||||
|
||||
1. Markdown `<img src="relative/path.png">`
|
||||
2. resolve path → find the file's `sha` in the GitHub tree
|
||||
3. `GET /repos/{owner}/{repo}/git/blobs/{sha}` → **base64 of the git blob**
|
||||
4. `img.src = "data:image/jpeg;base64," + thatContent`
|
||||
|
||||
The image _is_ the git blob. GitHub's Git Blobs / Trees / Contents APIs do
|
||||
**not** resolve LFS (only `media.githubusercontent.com` does, which remanso
|
||||
doesn't use). So after an LFS migration those endpoints return the ~130-byte
|
||||
**pointer text**, remanso wraps it in `data:image/jpeg;base64,…`, and every
|
||||
image renders broken. Uploads break too: remanso writes images via
|
||||
`PUT /contents`, which ignores `.gitattributes`/LFS and commits a plain blob.
|
||||
|
||||
And it's not specific to LFS — `git rm` at HEAD or a filter-repo purge remove
|
||||
the blobs from the tree, so remanso can't find them either. **Any approach
|
||||
that takes the images out of the git repo breaks remanso**, because those
|
||||
150 MB are load-bearing infrastructure for the web app, not offloadable bloat.
|
||||
|
||||
| Consumer | How it reads images | If we shrink the repo |
|
||||
| ------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Typoena (libgit2) | doesn't render images; needs valid git objects | fine — would get tiny pointers |
|
||||
| remanso (Blobs API → data URI) | reads image bytes straight out of git | **broken** — pointer/missing bytes render as a dead image; uploads bypass LFS |
|
||||
|
||||
## Decision
|
||||
|
||||
**Leave the notes repo untouched. Pre-seed the device SD card with a full
|
||||
`git clone` from a computer.**
|
||||
|
||||
Repo size is only a _device_ constraint when the _device_ does the cold clone.
|
||||
A laptop clones 566 MB in ~2 minutes onto the SD via a card reader; the SD has
|
||||
GB to spare. The device then only ever takes the `open` + incremental
|
||||
fetch/commit/push path (`open_or_clone` already splits on this). A _full_
|
||||
pre-seed (not depth-1) also sidesteps the shallow-push sharp edge. remanso
|
||||
keeps working, the device gets everything, and repo size stops being anyone's
|
||||
problem.
|
||||
|
||||
Implemented in firmware/justfile as three recipes, each ejecting the card when
|
||||
done so it goes straight into Typoena:
|
||||
|
||||
- `just init <repo-path>` — full prep of a fresh card: copies a full clone to
|
||||
the card's `repo/`, excluding everything the repo's `.gitignore` ignores (so
|
||||
`node_modules` and local secrets like `firmware/.env` never land on the card),
|
||||
then writes `/sd/typoena.conf` — Wi-Fi creds + PAT + git identity — from the
|
||||
`TW_*` vars already in `firmware/.env` (no re-typing, no prompts).
|
||||
- `just load <repo-path>` — the repo copy on its own (refresh after big upstream
|
||||
changes).
|
||||
- `just provision` — the config on its own (rotate the PAT / switch networks
|
||||
without re-copying the repo).
|
||||
|
||||
The firmware still reads those values via `env!()` today; reading
|
||||
`/sd/typoena.conf` at boot is a TODO that rides with the SD wiring into
|
||||
`main.rs`.
|
||||
|
||||
## What happens on an ongoing pull
|
||||
|
||||
In the single-writer model the device usually doesn't pull at all:
|
||||
`fetch_and_integrate` runs only from the rejected-push arm of
|
||||
`push_with_retry`, and a sole writer always fast-forwards. "Images to pull"
|
||||
only arises when a **second writer** (remanso or the desktop) pushed them.
|
||||
When it does, today the device fetches (downloading the image blobs) and then
|
||||
hits the divergence bail (`increment B, deferred`) — no data loss, but no
|
||||
integration until the merge path exists.
|
||||
|
||||
Once integration lands, the costs of carrying media the device never renders:
|
||||
|
||||
1. **Bandwidth for unusable bytes.** No partial fetch in libgit2, so a fetch
|
||||
pulls the full new image blobs. 20 MB of pasted screenshots = a 20 MB fetch
|
||||
before a one-line note can publish.
|
||||
2. **~2× SD storage.** Each image lives in `.git` _and_ in the working-tree
|
||||
checkout that `checkout_head(force)` writes.
|
||||
3. **Memory — the real edge.** libgit2 tends to materialize a whole blob in
|
||||
RAM for checkout. History already has a 38 MB mp3 and 16 MB PNGs. Against
|
||||
**8 MB PSRAM**, a single large image arriving in a pull is a genuine OOM
|
||||
risk on checkout. Verify on hardware (push a 20 MB image from remanso, watch
|
||||
`min_free_heap`) rather than trusting a read of libgit2 internals.
|
||||
|
||||
**The trap:** `publish()` stages with `add_all(["*"])`. A sparse checkout that
|
||||
omitted images would make `add_all` see them as deleted → the device would
|
||||
commit "delete all images" and push it → remanso loses every image. So with
|
||||
the current staging, a full checkout is mandatory — which is what feeds costs
|
||||
2 and 3.
|
||||
|
||||
## Fix for increment C (git module in the editor) — LANDED 2026-07-14
|
||||
|
||||
Both halves shipped, months apart, each by its own route:
|
||||
|
||||
- **Stage specific paths, not `add_all(["*"])`** — delivered by the v0.6
|
||||
splice commit (2026-07-13): the device commits exactly the dirty-journal
|
||||
paths. This is what disarmed the trap below — a working tree missing its
|
||||
images can no longer be committed as "delete all images".
|
||||
- **Skip media in the pull apply** (the sparse-checkout idea, done the
|
||||
`apply_tree_diff` way, 2026-07-14): media extensions are invisible to both
|
||||
of the apply's passes — never written, never deleted, and never
|
||||
belt-hashed (hashing a stale 16 MB image would re-open the same OOM). The
|
||||
blobs still arrive in `.git` on fetch (streamed, no partial clone in
|
||||
libgit2); only the working-tree copy is skipped. `is_media_path` in
|
||||
`git_sync.rs` holds the extension list.
|
||||
|
||||
The accepted trade: the card's media files go stale/absent relative to HEAD,
|
||||
so a computer opening the card clone sees phantom `modified`/`deleted`
|
||||
images in `git status`. The card is device-owned — refresh it with
|
||||
`just load`, never hand-commit from it (`git add -A` there would commit the
|
||||
staleness for real and break remanso). `git checkout -- .` on a computer
|
||||
restores every image from `.git` if a clean tree is ever wanted.
|
||||
|
||||
## Related
|
||||
|
||||
- `firmware/src/bin/git_sync.rs` — the persistent-clone publish cycle analysed
|
||||
here (milestone #2A).
|
||||
- ADR-010 — "writing tool, not sync engine": the principle this decision
|
||||
serves.
|
||||
- remanso: `src/hooks/useImages.hook.ts`, `src/modules/repo/services/repo.ts`
|
||||
(`queryFileContent`) — the image-as-blob pipeline.
|
||||
108
docs/notes/sync-latency.md
Normal file
108
docs/notes/sync-latency.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Sync latency — where the ~16 s cold `:gp` goes
|
||||
|
||||
> **Measured 2026-07-11** on hardware, via the timing log line in
|
||||
> [`firmware::git_sync`](../../firmware/src/git_sync.rs) (`publish_cycle`;
|
||||
> the command was `:sync` then, renamed `:gp` 2026-07-14). A **cold** publish
|
||||
> (first of a power cycle) is **~16.0 s** power-on of Wi-Fi → `push done`; a
|
||||
> **warm** one skips the one-time setup and is just the ~10 s publish. This
|
||||
> note breaks the number down and records why most of it is a floor, not a
|
||||
> bug.
|
||||
>
|
||||
> **Update (2026-07-13/14) — the publish half below is superseded.** The
|
||||
> `add_all` index staging was replaced by the O(depth) splice over the
|
||||
> journaled dirty set ([kaizen](../kaizen/real-repo-sync.md) · [measurement
|
||||
> trail](../tradeoff-curves/sync-commit-staging.md)), reconcile became fetch +
|
||||
> **soft** reset + journal replay, and TLS session resumption reuses the
|
||||
> handshake on reconnects. On the **real notes repo** — which the method below
|
||||
> could never complete at all — a cold `:gp` is **24.1 s**, a warm one ≈ 19 s
|
||||
> (splice depth × loose-write cost dominates), and an up-to-date `:gl` is
|
||||
> ≈ 4.7 s git-side. The waterfall below stands as the dev-repo record of
|
||||
> 2026-07-11.
|
||||
>
|
||||
> Notes index: [`README.md`](README.md). Docs index:
|
||||
> [`../README.md`](../README.md). Why the raw number matters less than it looks:
|
||||
> [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md). Energy/keep-Wi-Fi-up
|
||||
> tradeoff: [`../tradeoff-curves/wifi-auto-sync.md`](../tradeoff-curves/wifi-auto-sync.md).
|
||||
> Sibling timing note: [`boot-time-budget.md`](boot-time-budget.md).
|
||||
|
||||
## The waterfall (cold sync)
|
||||
|
||||
From the serial log, first `:sync` after a cold boot
|
||||
(`… wifi 3654ms, clock 2108ms, tls 304ms, publish(commit+push) 9944ms, total 16012ms`):
|
||||
|
||||
| Phase | ~ms | One-time? | Lever |
|
||||
| --------------------------------- | ---------: | --------------------- | -------------------------------------------------------------------------- |
|
||||
| Wi-Fi assoc + DHCP | ~3650 | yes (per power cycle) | radio off until first `:sync`; association floor |
|
||||
| SNTP first sync | ~2100 | yes | varies with NTP RTT (4.2 s the prior run); needed before TLS + commit time |
|
||||
| TLS trust store install | ~300 | yes | write ~6 KB CA bundle to SD + set libgit2 option |
|
||||
| **publish** = stage+commit + push | **~9900** | **every sync** | see below |
|
||||
| **Total** | **~16000** | | |
|
||||
|
||||
The three one-time phases (~6.1 s) only pay on the _first_ sync of a power cycle —
|
||||
Wi-Fi, the clock, and the trust store are set up once and reused, so a **warm sync
|
||||
is just the ~10 s publish**. Publish splits as:
|
||||
|
||||
| Sub-phase | ~ms | Note |
|
||||
| ----------------------------- | ----: | -------------------------------------------------------------------- |
|
||||
| stage + commit | ~3150 | `add_all(["*"])` walking the SD/FAT working tree, then commit to FAT |
|
||||
| push: TLS handshake | ~2400 | one mbedTLS handshake to github.com |
|
||||
| push: pack negotiate + upload | ~4400 | tiny delta — cost is negotiation/round-trips, not payload |
|
||||
|
||||
## The win: one TLS handshake, not two
|
||||
|
||||
The first hardware run (2026-07-11) measured **23.7 s** because it did a
|
||||
**pre-commit fetch** — a second full TLS handshake plus a ref exchange — on every
|
||||
sync, to absorb a foreign push before committing. That's ~3 s wasted on a normal
|
||||
sync (remote unchanged), and it did ~6 s of real work the one time it absorbed a
|
||||
maintenance commit.
|
||||
|
||||
The optimistic-retry rewrite (commit `3386969`) drops it: **push onto the current
|
||||
tip first**; only if the remote _rejects_ the push non-fast-forward do we fetch,
|
||||
reconcile, and retry. The happy path — what runs ~99 % of the time — is now a
|
||||
**single** handshake. That took the true normal-cold baseline from ~19 s to
|
||||
**16.0 s** (and the inflated 23.7 s figure will never recur, since it was the
|
||||
one-time reconcile).
|
||||
|
||||
## Foreign pushes: reconcile-and-replay, last-writer-wins
|
||||
|
||||
On a rejected push, `reconcile_onto_origin` fetches origin and does a **mixed**
|
||||
reset onto it — moving the branch ref + index but leaving the working tree, so the
|
||||
just-saved note survives — then `stage_and_commit` replays the note on the new tip
|
||||
and retries. For this **single-writer appliance** that resolves last-writer-wins:
|
||||
a concurrent remote _edit_ to the same note loses to ours, and a remote-only
|
||||
_added_ file the card doesn't have would be dropped by the replay's `add --all`.
|
||||
Both need a real merge (increment B) and don't arise from the device's own use.
|
||||
|
||||
**Update 2026-07-14:** the full rejected-push → reconcile → replay → push
|
||||
cycle is now hardware-verified (24.0 s end-to-end with TLS session
|
||||
resumption). The mechanics also changed with the splice: the reset is
|
||||
**soft** (there is no index anymore) and the replay splices only the
|
||||
journaled dirty paths, so a remote-only added file now _survives_ — it is
|
||||
carried forward by OID instead of being dropped by an `add --all`.
|
||||
|
||||
## Can cold sync go lower?
|
||||
|
||||
The big rocks are physics or protocol, not slack:
|
||||
|
||||
- **Wi-Fi assoc ~3.6 s** and **SNTP ~2–4 s** are one-time per power cycle and
|
||||
mostly out of our hands (association floor, NTP RTT). Keeping Wi-Fi up between
|
||||
syncs trades battery for latency — see
|
||||
[`../tradeoff-curves/wifi-auto-sync.md`](../tradeoff-curves/wifi-auto-sync.md).
|
||||
- **TLS handshake ~2.4 s** and **push negotiate/upload ~4.4 s** are inherent to
|
||||
libgit2-over-mbedTLS on this part; the payload is tiny, so there's little to
|
||||
shave.
|
||||
- **stage + commit ~3.1 s** was the one soft spot — and the one that got
|
||||
attacked. **Resolved 2026-07-13:** the index path was replaced outright by
|
||||
the O(depth) TreeBuilder splice over the journaled dirty set (the `add_path`
|
||||
staging this note originally proposed still hits the index's racy-clean
|
||||
wall). The cost
|
||||
model and how each hypothesis died live in
|
||||
[`../tradeoff-curves/sync-commit-staging.md`](../tradeoff-curves/sync-commit-staging.md);
|
||||
the residual is FAT directory-op cost per loose write, bounded and accepted.
|
||||
|
||||
**Conclusion:** ~16 s cold / ~10 s warm is close to the floor for "commit to FAT +
|
||||
one TLS push over Wi-Fi with a fresh clock." It reads as slow only if you wait on
|
||||
it — and by design you don't: `:gp` is a deliberate action with a snackbar, and
|
||||
[`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) argues the perceived
|
||||
cost is set by _when durability is surfaced_, not by wall-clock. Recorded here so
|
||||
the number is scoped against the protocol, not treated as a regression.
|
||||
216
docs/postmortems/2026-07-05-spike3-sd-cmd59.md
Normal file
216
docs/postmortems/2026-07-05-spike3-sd-cmd59.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Spike 3 (SD) — blocked on a card that rejects CMD59 (SPI-mode CRC)
|
||||
|
||||
> Date: 2026-07-05 · Build at time of failure: `07-05 16:07Z @f77f669-dirty`
|
||||
> Status: **RESOLVED 2026-07-11** — a genuine 32 GB SDHC card mounts, negotiates
|
||||
> 10 MHz, and round-trips cleanly on the shared SPI2 bus. Wiring and firmware
|
||||
> were always good; the 133 GB SDXC card's CMD59 rejection was the sole fault.
|
||||
> See [Resolution](#resolution-2026-07-11) below.
|
||||
>
|
||||
> Context: Spike 3 in
|
||||
> [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order),
|
||||
> storage split [ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config),
|
||||
> firmware notes [`../../firmware/README.md`](../../firmware/README.md).
|
||||
> Spike program: [`../../firmware/src/bin/sd_fat.rs`](../../firmware/src/bin/sd_fat.rs).
|
||||
|
||||
## Resolution (2026-07-11)
|
||||
|
||||
Re-ran `sd_fat` with a genuine 32 GB SDHC card (build `07-11 08:53Z
|
||||
@195311a-dirty`). Everything the paused write predicted held:
|
||||
|
||||
- `sdspi_host: data CRC set=1` — **CMD59 now succeeds**. Init runs clean through
|
||||
card identification (`sdmmc_card_init: card type is SD`).
|
||||
- `card mounted at /sd — max 10000 kHz, negotiated 10000 kHz`.
|
||||
- `FAT usage — 29806 MiB total, 29802 MiB free` (~29.8 GiB of the 32 GB card).
|
||||
- `round-trip OK — 92 bytes: create …tmp → fsync → rename → read back identical`.
|
||||
|
||||
Wiring, pull-ups, bus sharing, and the hand-rolled FFI mount path all confirmed
|
||||
correct — the only variable that changed was the card, exactly as diagnosed. The
|
||||
`cmd=52` / `cmd=5` "command not supported" lines still print and are still
|
||||
normal (SDIO probes a memory card ignores).
|
||||
|
||||
### New finding: FatFS `f_rename` does not overwrite (atomic-save is not POSIX)
|
||||
|
||||
The first run created `/sd/spike3.md`; the **second** run failed at `rename tmp
|
||||
-> final` with `File exists (os error 17)`. Root cause is a FatFS semantics
|
||||
difference, not a fault: **`f_rename` returns `FR_EXIST` when the destination
|
||||
already exists** — unlike POSIX `rename(2)`, which atomically replaces it. So the
|
||||
textbook write-`*.tmp` → rename-over-target idiom from ADR-007 does **not** work
|
||||
as-is on FAT; the destination must be unlinked first.
|
||||
|
||||
That unlink opens a crash window: between `f_unlink(target)` and `f_rename`, the
|
||||
target is momentarily gone while `*.tmp` holds the complete, fsync'd new content.
|
||||
The consequence for the real persistence module is a **boot-recovery** rule for a
|
||||
lingering `*.tmp`. Implementing it (`firmware::persistence::Storage::recover`)
|
||||
surfaced that "promote the tmp" is too blunt: a crash _during_ the tmp write (a
|
||||
second window, before the fsync) leaves a **partial** tmp, so recovery must look
|
||||
at whether the target survived —
|
||||
|
||||
- **tmp + target both present** → crash point ambiguous, tmp may be partial →
|
||||
keep the committed target, discard the tmp (the "previous version" guarantee);
|
||||
- **tmp only, target absent** → target was already unlinked → tmp is the newest
|
||||
complete copy → promote it.
|
||||
|
||||
The spike now does unlink-then-rename and tolerates a missing target, so re-runs
|
||||
are idempotent; recorded in
|
||||
[ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config).
|
||||
|
||||
## Summary
|
||||
|
||||
Built the Spike 3 bench program (`sd_fat`): bring up the SD card over the EPD's
|
||||
shared SPI2 bus, mount FAT, and prove the persistence module's atomic-save
|
||||
pattern (write `*.tmp` → fsync → rename → read-back). The card **never mounts**:
|
||||
SD init gets cleanly through CMD0 and CMD8 (the card even identifies as an
|
||||
SDHC/SDXC v2 card), then fails at **CMD59 (CRC_ON_OFF)**, which the card rejects
|
||||
as an illegal command (`ESP_ERR_NOT_SUPPORTED`, `0x106`). `sdmmc_card_init`
|
||||
treats that as fatal.
|
||||
|
||||
Root cause is the **card**, not our wiring or code: the bench card is a 133 GB
|
||||
SDXC that doesn't implement CMD59 in SPI mode — common on large / cheap /
|
||||
counterfeit SDXC cards. It works fine on the Mac because macOS uses native SD
|
||||
mode, never the SPI fallback the ESP32 uses.
|
||||
|
||||
**Decision:** keep CRC required (don't disable it to limp the bad card along —
|
||||
see below) and reject incompatible cards with a clear message. Resume when a
|
||||
genuine ≤32 GB card is on the bench.
|
||||
|
||||
## Bench wiring (shared SPI2, proven good)
|
||||
|
||||
| Signal | GPIO | Shared with EPD? |
|
||||
| ------ | ---- | ------------------------------------------------------------ |
|
||||
| SCK | 12 | yes (epd.rs) |
|
||||
| MOSI | 11 | yes (epd.rs) |
|
||||
| MISO | 13 | **no** — new line; the EPD is write-only and never used MISO |
|
||||
| SD CS | 10 | no — EPD CS is 7 |
|
||||
|
||||
The SD sits on the same SPI2 bus as the panel, on its own chip-select. The spike
|
||||
runs **SD-only** (see "EPD bus lock" below).
|
||||
|
||||
## Timeline
|
||||
|
||||
1. First flash → `sdmmc_send_cmd_crc_on_off returned 0x106`, `sdmmc_card_init
|
||||
failed (0x106)`. Cryptic; could be wiring, pull-ups, speed, or card.
|
||||
2. Added internal pull-ups on SCK/MOSI/MISO/CS and drove the EPD CS (GPIO 7)
|
||||
high (deselect the panel on the shared bus). **No change** — identical
|
||||
failure at the same command. A deterministic failure at one command argues
|
||||
against marginal signal integrity.
|
||||
3. Verified the card on the Mac: `Windows_FAT_32 TYPOENA`, 33.6 GB partition on
|
||||
a 133 GB card, **healthy and readable**. Rules out a dead card / wrong FS.
|
||||
4. Read the esp-idf SD init source: the CMD8 handler _tolerates_ the
|
||||
same `ESP_ERR_NOT_SUPPORTED` (treats it as "not a v2 card"), while CMD59's
|
||||
does not. So the failure might be a persistent bad response, not a CMD59
|
||||
one-off — needed the raw R1 bytes to tell.
|
||||
5. Raised the compile-time log ceiling (`CONFIG_LOG_MAXIMUM_LEVEL_DEBUG`) and
|
||||
bumped the `sdmmc_*` / `sdspi_*` tags to DEBUG at runtime. The dump was
|
||||
decisive:
|
||||
- `cmd=52` / `cmd=5` fail (`0x107`/`0x106`) — **normal**: those are SDIO
|
||||
probes (CMD52/CMD5) a memory card doesn't answer.
|
||||
- `sdmmc_sd: SDHC/SDXC card` — printed **only when CMD8 succeeds**. So CMD0
|
||||
and CMD8 returned clean, correct responses (right 0xAA echo, no error bit).
|
||||
- `cmd=59, R1 response: command not supported` — only CMD59 is rejected.
|
||||
6. Confirmed the breakout is a bare microSD→pin adapter (no level shifter), so
|
||||
the 3.3 V SPI path is clean.
|
||||
|
||||
## Root cause
|
||||
|
||||
The card responds correctly to CMD0 and CMD8 (proving wiring, signal integrity,
|
||||
pull-ups, bus sharing, and the hand-built FFI mount path are all correct) but
|
||||
**rejects CMD59 (CRC_ON_OFF) as an illegal command**. CMD59 is mandatory per the
|
||||
SD spec; some large/counterfeit SDXC cards don't implement it in SPI mode. Since
|
||||
esp-idf's `sdmmc_init_spi_crc` hard-fails when CMD59 fails (and there is **no**
|
||||
Kconfig or host flag to skip it), the mount aborts.
|
||||
|
||||
## What it was _not_
|
||||
|
||||
- **Not wiring / a swap** — CMD0/CMD8 responses are clean and correct.
|
||||
- **Not signal integrity** — internal pull-ups changed nothing; failure is
|
||||
deterministic at one command; init runs at 400 kHz where the jumpers are fine.
|
||||
- **Not the filesystem** — failure is at SD _protocol_ init, before any FAT
|
||||
access. The card is FAT32 and mounts on the Mac.
|
||||
- **Not a level-shifter breakout** — it's a bare 3.3 V adapter.
|
||||
- **Not our FFI** — the hand-rolled `SDSPI_HOST_DEFAULT()` descriptors work; the
|
||||
driver reaches and drives the card correctly.
|
||||
|
||||
## Decision: keep CRC required, reject bad cards clearly
|
||||
|
||||
CMD59's job is to enable **CRC on data transfers**. The only way to mount this
|
||||
card is to tolerate CMD59 failing, i.e. run with data CRC **off**. For a device
|
||||
whose entire value is not losing the user's writing, giving up integrity
|
||||
checking — precisely on the cards that are already the sketchy ones — is the
|
||||
wrong trade. It would also require patching a vendored esp-idf function that
|
||||
re-applies on every update.
|
||||
|
||||
So `sd_fat` now maps `ESP_ERR_NOT_SUPPORTED` from the mount to an actionable
|
||||
message ("card rejected CMD59… use a genuine card, ideally ≤32 GB…") instead of
|
||||
a raw code. This is the behavior the real `persistence` module should carry too:
|
||||
a bad card at boot should say "swap the SD," not hang on a hex code.
|
||||
|
||||
A CRC-off patch remains _possible_ if bench work ever needs this exact card, but
|
||||
it is explicitly **not recommended** and not applied.
|
||||
|
||||
## Other findings worth keeping
|
||||
|
||||
- **EPD bus lock forces SD-only (for now).** The EPD driver uses esp-idf-hal's
|
||||
`SpiBusDriver`, whose constructor calls `spi_device_acquire_bus(BLOCK)` and
|
||||
holds that **exclusive** lock for its whole lifetime (it needs CS held across
|
||||
a cmd→data sequence while DC toggles). While held, any other device on SPI2 —
|
||||
the SD — blocks. So the EPD and an arbitrated SD device can't both be live on
|
||||
one host as things stand. This spike proves the SD stack; the shared-bus
|
||||
**arbitration decision** (release/re-acquire around EPD ops, vs. the
|
||||
risk-table fallback of giving the SD its own SPI3) is still open and is what a
|
||||
follow-up must settle before integration.
|
||||
- **FatFS long filenames are required.** The atomic-save temp name
|
||||
(`notes.md.tmp`, two dots) is not a valid 8.3 name, and FatFS defaults to
|
||||
8.3-only. `CONFIG_FATFS_LFN_HEAP=y` is needed by this spike **and** the real
|
||||
persistence path.
|
||||
- **`SDSPI_*_DEFAULT()` macros are bindgen-invisible.** Built the `sdmmc_host_t`
|
||||
/ `sdspi_device_config_t` descriptors by hand; the `SDMMC_HOST_FLAG_*` values
|
||||
are `BIT(n)` macros bindgen can't fold, so they're inlined (`BIT(3)`/`BIT(5)`).
|
||||
- **Conservative SD clock.** `SD_FREQ_KHZ = 10_000` (vs. the 20 MHz default),
|
||||
since the EPD needed 4 MHz on these bench jumpers. Init runs at 400 kHz
|
||||
regardless, so this only affects post-init throughput — revisit on a real PCB.
|
||||
- **Diagnostic technique.** Raising the log ceiling + `esp_log_level_set` to read
|
||||
the driver's per-command R1 bytes turned a cryptic error into a one-glance
|
||||
root cause. Reusable for any esp-idf init failure.
|
||||
|
||||
## Where the end-to-end (clone → SD → edit → push) stands
|
||||
|
||||
- **Clone is out-of-band in v0.1** — the dev clones the remote onto the mounted
|
||||
SD from a laptop; there is no first-clone on device
|
||||
([ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config),
|
||||
technical doc "Provisioning"). The bench card mounts on the Mac, so that step
|
||||
is not blocked.
|
||||
- **Edit → save-to-SD** becomes testable the moment a compliant card mounts
|
||||
(Spike 3 + the existing editor's atomic save). Closest milestone.
|
||||
- **SD → push** needs **Spike 7 (gitoxide over HTTPS+PAT)**, which is unbuilt and
|
||||
needs **PSRAM enabled first** (not yet done). Spike 6 (Wi-Fi + TLS) proved the
|
||||
network/TLS gate. So the full loop is not testable yet even with a good card.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- [x] Re-run with a genuine ≤32 GB card → clean mount + round-trip (2026-07-11).
|
||||
- [x] Strip the debug logging once green: removed `CONFIG_LOG_MAXIMUM_LEVEL_DEBUG`
|
||||
from `sdkconfig.defaults` and the `esp_log_level_set` block in `sd_fat.rs`
|
||||
(2026-07-11).
|
||||
- [x] Write up Spike 3 as verified in
|
||||
[`../../firmware/README.md`](../../firmware/README.md) (2026-07-11).
|
||||
- [x] Record a **recommended-SD-card note** for v0.1 (genuine, ≤32 GB;
|
||||
large/counterfeit SDXC may fail CMD59) — in
|
||||
[ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config)
|
||||
(2026-07-11).
|
||||
- [x] Enable PSRAM (done, `CONFIG_SPIRAM`) and build Spike 7 (git push) — both
|
||||
complete; see [Spike 7 postmortem](2026-07-05-spike7-gix-https-push.md).
|
||||
- [x] Settle the **shared-bus arbitration** decision → **SD on its own SPI3**
|
||||
(ADR-012, 2026-07-11); spike rewired to SPI3 (SCK 14 / MOSI 15) and
|
||||
**re-verified on hardware 2026-07-11** — same mount + round-trip result.
|
||||
- [ ] **Still open:** implement the FatFS atomic-save (unlink-then-rename +
|
||||
`*.tmp` boot-recovery) in the real `persistence` module — → tracked as
|
||||
the crash-safe-writes item in [v0.9 robustness](../v0.9-robustness.md).
|
||||
|
||||
## Artifacts (this session)
|
||||
|
||||
- `firmware/src/bin/sd_fat.rs` — the spike (mount + atomic round-trip + clear
|
||||
rejection + debug logging).
|
||||
- `firmware/Cargo.toml` — `[[bin]] sd_fat`.
|
||||
- `firmware/justfile` — `build-sd` / `flash-sd` / `monitor-sd`.
|
||||
- `firmware/sdkconfig.defaults` — `CONFIG_FATFS_LFN_HEAP=y` (keep) and
|
||||
`CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y` (temporary, strip when green).
|
||||
458
docs/postmortems/2026-07-05-spike7-gix-https-push.md
Normal file
458
docs/postmortems/2026-07-05-spike7-gix-https-push.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# Spike 7 (git push) — the ADR-004 kill-switch fired: gix can't push over HTTPS
|
||||
|
||||
> Date: 2026-07-05 (on-device push completed 2026-07-06)
|
||||
> Status: **DONE** — gix ruled out for the push path; pivoted to `libgit2`
|
||||
> (`git2`), proved the git mechanics on desktop, then landed the full
|
||||
> `init → commit → push` over mbedTLS HTTPS **on hardware** (2026-07-06). See
|
||||
> "On-device push COMPLETE" below.
|
||||
>
|
||||
> Context: Spike 7 in
|
||||
> [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order),
|
||||
> git impl [ADR-004](../adr.md#adr-004-git-implementation--gitoxide-gix), auth
|
||||
> [ADR-005](../adr.md#adr-005-auth--https--github-personal-access-token).
|
||||
> Spike program: [`../../spikes/spike7-git-push/`](../../spikes/spike7-git-push/README.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Spike 7 was written as the kill-switch for [ADR-004](../adr.md): _"the
|
||||
smart-HTTP path is validated in spike 7 before we commit to integration; if it
|
||||
fails on the device, we fall back to `libgit2-sys`."_ It never needed a device
|
||||
to fire. Before writing any gix code, gitoxide's own crate-status doc settles
|
||||
the question: `gix` has send-pack/receive-pack **plumbing** (report-status,
|
||||
sideband, delete-refs, atomic pushes) but supports push as a **workflow** only
|
||||
over `file://` and `ssh://`. **Push over HTTP(S) is not implemented** — push is
|
||||
still listed under "workflows that still need plumbing." (Clone/fetch, by
|
||||
contrast, are robust over HTTP(S) — which is why Spike 6's TLS GET passed but
|
||||
does not carry over to push.)
|
||||
|
||||
Because [ADR-005](../adr.md) fixes auth as **HTTPS + PAT**, `gix` cannot satisfy
|
||||
the push path today. gix _can_ push over `ssh://`, but that would (a) revisit
|
||||
ADR-005 and (b) still die on device — gix's SSH transport spawns the external
|
||||
`ssh` program, which does not exist on the ESP32. So the kill-switch condition
|
||||
is met at the library level.
|
||||
|
||||
**Decision:** take the fallback the risk table already names — `libgit2` via the
|
||||
[`git2`](https://docs.rs/git2) crate — keeping ADR-005 (HTTPS + PAT) intact.
|
||||
Proved the full `add → commit → push` sequence on desktop
|
||||
([`spikes/spike7-git-push`](../../spikes/spike7-git-push/README.md)).
|
||||
|
||||
## Why not the alternatives
|
||||
|
||||
| Option | Verdict |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **gix + HTTPS** (as ADR-004 intended) | Blocked — gix has no HTTP(S) push. |
|
||||
| **gix + SSH push** | gix supports it, but revisits ADR-005 _and_ gix's SSH transport shells out to an `ssh` binary absent on ESP32 → dead on device. |
|
||||
| **gix-protocol send-pack + custom HTTPS transport** | Pure-Rust, no ADR change, but not smoke-test-sized: hand-wiring send-pack over an mbedtls HTTP transport is real work and unproven upstream. Reconsider only if the libgit2 cross-compile (below) turns out worse. |
|
||||
| **libgit2 (`git2`)** ← chosen | The ADR's named fallback. Trivial on desktop; the risk becomes the on-device cross-compile. |
|
||||
|
||||
## What the desktop spike proves
|
||||
|
||||
Run live against a local `file://` bare remote (no credentials), exercising the
|
||||
exact v0.1 `git` module contract:
|
||||
|
||||
- **first commit + push** from an unborn `HEAD` (fresh clone of an empty repo)
|
||||
→ the commit lands in origin. Message is an ISO-8601 timestamp.
|
||||
- **nothing to publish** → short-circuits when the staged tree matches `HEAD`.
|
||||
- **divergence** → a second clone advances origin; the first clone's push is
|
||||
rejected, `pull --no-edit` merges cleanly (different files), the retry push
|
||||
succeeds, and origin ends with a correct two-parent merge commit.
|
||||
|
||||
Also confirmed 2026-07-05 against a **real GitHub repo** (`jcalixte/typoena-test`)
|
||||
over HTTPS with a fine-grained PAT: `committed → push accepted by remote`, the
|
||||
commit landed on GitHub. So the TLS handshake + PAT auth + smart-HTTP push all
|
||||
work through libgit2's vendored stack (desktop links `openssl-sys` for TLS). The
|
||||
one path still unexercised live is a **non-fast-forward rejection over HTTPS**
|
||||
(the `push_update_reference` callback) — the `file://` transport surfaced that as
|
||||
a `push()` error instead, and the GitHub push was a clean fast-forward.
|
||||
|
||||
Implementation notes that carry into the real module:
|
||||
|
||||
- **`git add --all` semantics.** libgit2's `index.add_all(["*"], DEFAULT)` stages
|
||||
new + modified + **deleted** paths, unlike a naive `git add .`. v0.5 file-delete
|
||||
needs removals to reach the next Publish's staged set — this is that behavior.
|
||||
- **Push rejection is not always a `push()` error.** A non-fast-forward can come
|
||||
back as a transport `Err` (local transport did this) _or_ silently via the
|
||||
`push_update_reference` callback with a status string while `push()` returns
|
||||
`Ok` (the HTTPS/GitHub path). The spike handles both and routes either to the
|
||||
pull-and-retry. The callback path is coded for but not yet exercised live.
|
||||
- **PAT hygiene.** The token is handed only to libgit2's credential callback
|
||||
(`Cred::userpass_plaintext`) and never logged — matches ADR-005.
|
||||
|
||||
## What it does _not_ prove — the next gate
|
||||
|
||||
The risk moved **with** the kill-switch and got harder. ADR-004 chose
|
||||
gix _specifically to avoid_ libgit2's C cross-compile to xtensa; falling back to
|
||||
libgit2 re-introduces exactly that. The open question is now:
|
||||
|
||||
> Can `libgit2` (`git2` / `libgit2-sys`) cross-compile to
|
||||
> `xtensa-esp32s3-espidf` and use esp-idf's **mbedtls** as its TLS backend?
|
||||
|
||||
`libgit2-sys` vendors libgit2 and, on desktop, pulled `openssl-sys` for TLS —
|
||||
there is no openssl on esp-idf, so the device build will need libgit2 pointed at
|
||||
mbedtls (its `MbedTLS` backend) via the esp-idf sysroot, which is unproven. This
|
||||
is the on-device Spike 7 and it also depends on:
|
||||
|
||||
- **PSRAM** (`CONFIG_SPIRAM`) enabled — still off (only ~339 KB internal heap;
|
||||
see firmware README / Spike 6 note). libgit2's pack working set needs it.
|
||||
- **A working SD card** (Spike 3, currently
|
||||
[paused on a CMD59-incompatible card](2026-07-05-spike3-sd-cmd59.md)) for the
|
||||
`/sd/repo` working copy.
|
||||
|
||||
So the full **SD → push** loop is still not testable on hardware; this spike
|
||||
retired the _library/API_ risk and replaced it with a _cross-compile_ risk to
|
||||
tackle once PSRAM + SD are unblocked.
|
||||
|
||||
## On-device probe — 2026-07-05
|
||||
|
||||
Two moves toward the on-device gate, decoupled from the (still-blocked) SD card:
|
||||
|
||||
**PSRAM enabled.** `sdkconfig.defaults` gained `CONFIG_SPIRAM=y` +
|
||||
`CONFIG_SPIRAM_MODE_OCT=y` (the N16R8 is _octal_ PSRAM — quad mode would fail
|
||||
init) + `CONFIG_SPIRAM_USE_MALLOC=y` (adds PSRAM to the heap so large Rust allocs
|
||||
land there). Speed left at 40 MHz for a safe first enable. Octal PSRAM uses
|
||||
GPIO 33–37; the EPD/SD pins (4–13) avoid that range, so no wiring conflict.
|
||||
**Confirmed on hardware 2026-07-05:** boot log shows `Found 8MB PSRAM device`
|
||||
(vendor AP, gen-3, 64 Mbit die), `SPI SRAM memory test OK`, and `Adding pool of
|
||||
8192K of PSRAM memory to heap allocator` — the full 8 MB joins the heap on top of
|
||||
~372 KB internal DRAM. The editor boots and types normally, so PSRAM broke
|
||||
nothing. The ~1.5 MB git working-set budget now has headroom.
|
||||
|
||||
**libgit2 cross-compile probe.** Added `git2` (default-features off → no
|
||||
openssl/ssh, isolating the C build from the TLS question) + a throwaway
|
||||
`git_probe` bin, and built for `xtensa-esp32s3-espidf`. Result reframes the risk
|
||||
in a _more_ encouraging direction than expected:
|
||||
|
||||
- **The C cross-compiles.** `xtensa-esp-elf-gcc` ran on libgit2's sources and
|
||||
several files built with `exit status: 0`. The feared "cmake can't target
|
||||
xtensa / no toolchain" failure did **not** happen.
|
||||
- **The wall is missing esp-idf networking headers.** libgit2's core
|
||||
`git2_util.h` does `#include <arpa/inet.h>`, and the build died with
|
||||
`arpa/inet.h: No such file or directory`. Root cause: `libgit2-sys` builds
|
||||
vendored libgit2 as a **standalone `cc` library**, so it never inherits
|
||||
esp-idf's include paths — esp-idf's BSD-socket headers (lwIP) live under the
|
||||
esp-idf component tree, not the bare toolchain sysroot the `cc` invocation
|
||||
used. (`arpa/inet.h` _does_ exist in esp-idf, via lwIP's POSIX compat layer;
|
||||
it just wasn't on the `-I` path.)
|
||||
|
||||
So the on-device libgit2 question is **not** "impossible," it's "needs esp-idf
|
||||
integration": get the vendored C build to see esp-idf's lwIP/newlib includes.
|
||||
Candidate paths, roughly in order of effort:
|
||||
|
||||
1. **Inject esp-idf include dirs into the `cc` build** via
|
||||
`CFLAGS_xtensa-esp32s3-espidf` (point `-I` at esp-idf's lwIP POSIX-compat
|
||||
headers). Cheapest to try; risk is a _cascade_ — `arpa/inet.h` is likely the
|
||||
first of several missing headers, and then missing lwIP/pthread symbols at
|
||||
final link.
|
||||
2. **Build libgit2 as a proper esp-idf component** (CMake component pulled into
|
||||
the esp-idf build so it inherits all component includes/libs). The "right"
|
||||
way; more plumbing, via esp-idf-sys's extra-components mechanism.
|
||||
3. **Patch/fork `libgit2-sys`** to be esp-idf-aware (read `DEP_ESP_IDF_*` and add
|
||||
the include paths). Upstreamable but the most work.
|
||||
|
||||
The util-layer `arpa/inet.h` include is unconditional (not gated on the transport
|
||||
backend), so path 1/2/3 is needed before even a transport-less build links.
|
||||
|
||||
### Path 1 attempted — 2026-07-05: confirmed a dead end
|
||||
|
||||
Injected esp-idf's lwIP + generated-config include dirs via
|
||||
`CFLAGS_xtensa_esp32s3_espidf` and rebuilt the probe. `arpa/inet.h` **resolved**
|
||||
— then the build immediately hit the next wall: `lwipopts.h → sys/ioctl.h: No
|
||||
such file`, a header from a _different_ esp-idf component (vfs/newlib), not lwIP.
|
||||
That is the whole problem in one line: **path 1 peels the esp-idf component
|
||||
include graph one component at a time**, with fragile absolute `-I` paths into
|
||||
build-output dirs (the config-dir hash even changed between two builds). It does
|
||||
not converge without effectively reconstructing esp-idf's entire per-component
|
||||
include environment by hand.
|
||||
|
||||
### Decision: go straight to path 2 (libgit2 as an esp-idf component)
|
||||
|
||||
Path 2 isn't just the robust include fix — it **solves the TLS backend at the
|
||||
same time**. libgit2 the C library _does_ support mbedTLS (`USE_HTTPS=mbedTLS`);
|
||||
only the `libgit2-sys` Rust wrapper lacks it. Building libgit2 as an esp-idf
|
||||
CMake component lets us (a) inherit every component's includes + link (kills the
|
||||
cascade) and (b) set `USE_HTTPS=mbedTLS` against esp-idf's own mbedtls — which
|
||||
would make a **custom Rust subtransport unnecessary**. Two birds. Sketch:
|
||||
|
||||
- Add libgit2 via esp-idf-sys's extra-components mechanism (a `components/`
|
||||
dir + `CMakeLists.txt` declaring `REQUIRES lwip mbedtls pthread newlib vfs`,
|
||||
wrapping libgit2's own CMake with `USE_HTTPS=mbedTLS`, `USE_SSH=OFF`).
|
||||
- Bind to it from Rust — either `libgit2-sys` in _system_ mode pointing at the
|
||||
component-built lib, or hand-rolled bindings for the handful of calls the
|
||||
`git` module needs.
|
||||
|
||||
This is a real, multi-step chunk (component CMake + bindings + link), not a
|
||||
flag-flip — scoped as the next work item, gated behind nothing now that PSRAM is
|
||||
up.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- [x] Enable PSRAM (`CONFIG_SPIRAM`) — **done + hardware-verified 2026-07-05**
|
||||
(octal, USE_MALLOC): 8 MB detected, memory-tested, added to heap; editor
|
||||
still runs.
|
||||
- [x] On-device Spike 7 libgit2 build — **probed + path 1 attempted 2026-07-05**:
|
||||
the C cross-compiles; the include cascade does not converge via CFLAGS
|
||||
injection (path 1 dead end). Decision: **path 2** (libgit2 as an esp-idf
|
||||
component with `USE_HTTPS=mbedTLS`).
|
||||
- [x] Path 2: add libgit2 as an esp-idf component — **compiles AND links
|
||||
2026-07-05** (Gate A + Gate B). libgit2 1.9.4 built as a component with
|
||||
`REQUIRES mbedtls lwip pthread vfs newlib`; the include cascade vanished as
|
||||
predicted. mbedTLS wired directly (`GIT_MBEDTLS` + `GIT_SHA1/256_MBEDTLS`).
|
||||
A `git_smoke` bin calling `git_libgit2_init/version/shutdown` links clean:
|
||||
538 `git_*` functions in the ELF, +514 KB text. See "Path 2 result" below.
|
||||
- [x] Flash the PSRAM build and confirm the SPIRAM heap region — **done
|
||||
2026-07-05**: 8192K pool added to heap, memory test OK.
|
||||
- [x] Run the desktop spike against a real GitHub test repo — **done 2026-07-05**
|
||||
(`jcalixte/typoena-test`, fine-grained PAT): HTTPS handshake + PAT auth +
|
||||
push confirmed. Still open: the `push_update_reference` rejection path over
|
||||
HTTPS (needs a non-fast-forward against a real remote to trigger it).
|
||||
- [x] On-device `init → commit → push` over mbedTLS HTTPS — **DONE +
|
||||
hardware-verified 2026-07-06** (see "On-device push COMPLETE").
|
||||
- [x] Revise the `git` module section of the technical doc — **DONE 2026-07-07**
|
||||
(commit `2f2f122`): gix → libgit2/git2, transport settled, 96 KB stack,
|
||||
persistent-clone flow.
|
||||
- [x] Real cert trust-store (drop the `certificate_check` bypass) — **DONE +
|
||||
hardware-verified 2026-07-06** (commit `2519ed8`; see "Shortcuts — status").
|
||||
- [x] Settle the product sync transport — **DECIDED 2026-07-06: HTTPS + PAT**
|
||||
(on-device libgit2 is HTTPS-only; no libssh2 port).
|
||||
- [~] Retire the last shortcut: no PAT-in-flash — **decision documented, deferred**
|
||||
as [ADR-011](../adr.md#adr-011-credential-provisioning--how-the-pat-reaches-the-device-and-is-protected-at-rest)
|
||||
(open): on-device paste → eFuse-encrypted NVS + a per-device fine-grained PAT.
|
||||
Gates the first non-dev distribution; nothing to implement until then.
|
||||
- [~] Fold the push into the editor's `git` module (persistent clone +
|
||||
fast-forward) over HTTPS+PAT — **increment A DONE + hardware-verified
|
||||
2026-07-07** (`git_sync.rs`): `clone` + persistent `open` + fast-forward push
|
||||
proven on device (`afa61de`), plus **recursive delete on FATFS solved**
|
||||
(`8dece73`): the EACCES on wiping a repo dir was `std::fs::remove_dir_all`
|
||||
(uses `openat`/`unlinkat`/`fdopendir`, unimplemented in esp-idf's path-based
|
||||
FATFS VFS), NOT read-only — a diagnostic walk showed every entry writable
|
||||
(`ro=false`). Fix = a path-based `remove_tree`; the `p_open`/`p_creat` shim
|
||||
(`39e1155`) stays to keep objects writable. Remaining: **B** = divergence/
|
||||
merge path (merge commit on FATFS; low value for a single writer); **C** =
|
||||
lift the logic into a reusable `git` module wired to the editor's `Ctrl-G`.
|
||||
Storage caveat: the real notes repo is 3.9 GB / 562 MiB pack — needs
|
||||
shallow+sparse or a dedicated small repo (ADR-007), can't clone whole.
|
||||
- [x] Move git to a dedicated large-stack task so the shared main-task stack (and
|
||||
the editor build) can drop back — **DONE + hardware-verified 2026-07-06**.
|
||||
`git_publish` now runs on its own `std::thread` (`GIT_STACK = 96 KB` via
|
||||
`Builder::stack_size`; main joins it), and `CONFIG_ESP_MAIN_TASK_STACK_SIZE`
|
||||
dropped 98304 → **12288** (the Spike-6 value proven with the editor +
|
||||
TLS-on-main). On-device push succeeded off-main — no panic/overflow, no
|
||||
ENOMEM on the spawn — retiring the "time()-only-works-on-main" misdiagnosis.
|
||||
|
||||
## Path 2 result — libgit2 compiles and links on xtensa (Gate A + Gate B)
|
||||
|
||||
The bet paid off. libgit2 **1.9.4** (the exact version `libgit2-sys 0.18.5`
|
||||
vendors, chosen so `git2`'s safe Rust API can bind it in system mode later)
|
||||
builds as an esp-idf component and links into a real image.
|
||||
|
||||
**Why a component beat Path 1.** Registering libgit2 with
|
||||
`REQUIRES mbedtls lwip pthread vfs newlib` makes it inherit those components'
|
||||
include + link graph. Path 1's manual CFLAGS injection died because resolving
|
||||
one component's headers exposes the next (`arpa/inet.h` → `sys/ioctl.h` → …).
|
||||
The component model walks that graph for us — the cascade never appeared.
|
||||
|
||||
**mbedTLS, not OpenSSL.** The libgit2-sys wrapper only offers
|
||||
openssl/securetransport/winhttp, but the C library has an mbedTLS backend
|
||||
(`streams/mbedtls.c`, `hash/mbedtls.c`). A hand-written `git2_features.h` selects
|
||||
`GIT_HTTPS` + `GIT_MBEDTLS` + `GIT_SHA1_MBEDTLS` + `GIT_SHA256_MBEDTLS`, so TLS
|
||||
and hashing reuse the mbedtls esp-idf already ships (and Spike 6 validated).
|
||||
|
||||
**The port surface was small** — four shims, libgit2 sources untouched (so we
|
||||
never fork 1.9.4):
|
||||
|
||||
| Gap on esp-idf (picolibc + VFS) | Shim |
|
||||
| ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| no top-level `<poll.h>` (only `<sys/poll.h>`) | forwarding `poll.h` on the include path |
|
||||
| `lstat` absent (no symlinks) | `#define lstat stat`, force-included via `esp_port.h` |
|
||||
| `<sys/mman.h>` absent | `esp_map.c` — `p_mmap` via `git__malloc` + `read` (pack pages land in PSRAM) |
|
||||
| `getuid`/`geteuid`/`getgid`/`getppid`/`getpgid`/`getsid`/`getpwuid_r`/`readlink`/`utimes` declared but not implemented | `esp_stubs.c` — single-root-user, no-user-db, no-symlink answers |
|
||||
|
||||
Also: gcc 14 promoted `-Wimplicit-function-declaration` /
|
||||
`-Wincompatible-pointer-types` to hard errors; this pre-gcc14 C trips them
|
||||
benignly, so the component downgrades them to warnings. `unix/process.c`
|
||||
(fork/`sys/wait.h`) is excluded — only the SSH-exec transport we don't enable
|
||||
uses it.
|
||||
|
||||
**Verification.** A throwaway `git_smoke` bin (`git_libgit2_init` /
|
||||
`_version` / `_shutdown` via three hand externs) links with **zero undefined
|
||||
references**: `nm` shows **538 `git_*` text symbols** in the ELF (`git_index_*`,
|
||||
`git_repository_*`, `git_commit_*`, `git_remote_*`), the four shims present,
|
||||
+514 KB `.text` (negligible against 16 MB flash).
|
||||
|
||||
**Gate C — RAN ON HARDWARE 2026-07-05.** Flashed `git_smoke` to the S3; the
|
||||
linked library reports `1.9.4`, `git_libgit2_init() -> 1` (global init ran —
|
||||
registers the mbedTLS stream + HTTP transport + hash backends),
|
||||
`git_libgit2_shutdown() -> 0`, clean. No crash/assert/hang. So libgit2 +
|
||||
mbedTLS **compiles, links, and executes** on the ESP32-S3 — the full Path 2
|
||||
de-risk. Still unproven: an actual `repository_init` → `commit` → `push` over
|
||||
mbedTLS HTTPS (needs Wi-Fi/SNTP from Spike 6 + a working-copy location).
|
||||
|
||||
**Build mechanics learned.** The component is wired via
|
||||
`[[package.metadata.esp-idf-sys.extra_components]]` `component_dirs`, pointed at
|
||||
on-disk source through a `LIBGIT2_SRC` env var (probe stage — not yet vendored).
|
||||
esp-idf-sys emits no `rerun-if-*`, so editing the _root_ Cargo.toml or the
|
||||
component doesn't retrigger its build script once it has succeeded; forcing a
|
||||
reconfigure means `rm -rf target/**/.fingerprint/esp-idf-sys-*` (cheap — the
|
||||
159 MB cmake cache in the OUT_DIR persists, so only the changed component
|
||||
recompiles).
|
||||
|
||||
**Gate D — `git2` safe-API binding LINKS 2026-07-05.** Replaced the hand
|
||||
externs with the real path: the `git2` crate (default-features off, so no
|
||||
openssl-sys/libssh2-sys) bound to our component via `libgit2-sys` in **system
|
||||
mode** (`LIBGIT2_NO_VENDOR=1`). The trick: we don't want libgit2-sys to build
|
||||
_or_ link anything — esp-idf already links `liblibgit2.a` inside its component
|
||||
group (verified in `build.ninja`: `esp-idf/libgit2/liblibgit2.a` sits in the
|
||||
`libespidf.elf` `LINK_LIBRARIES`, and the group is repeated ~6× so libgit2's
|
||||
refs to mbedtls/lwip resolve). So a **fake pkg-config with empty `Libs`**
|
||||
(`firmware/pkgconfig/{libgit2,zlib}.pc`, found via `PKG_CONFIG_LIBDIR` +
|
||||
`PKG_CONFIG_ALLOW_CROSS=1`) makes both libgit2-sys's and libz-sys's probes
|
||||
succeed while emitting nothing; the symbols come from the component. `git_smoke`
|
||||
now uses `git2::Version` + `Oid::hash_object` and links with zero undefined refs
|
||||
— `nm` confirms `git_odb_hash`, `git_oid_tostr`, `git_error_last`, and
|
||||
`mbedtls_sha1_starts` all **defined** in the ELF.
|
||||
|
||||
**Build gotcha (important):** esp-idf-sys forwards the app's link args only when
|
||||
its build script reruns, and emits no `rerun-if-*`. After the component set
|
||||
changes, the forwarded args go stale — `rm -rf
|
||||
target/**/.fingerprint/esp-idf-sys-*` before building forces a fresh forward
|
||||
(that was why the first git2 link failed with undefined `git_*`).
|
||||
|
||||
**Build-gating done:** `git2` is an optional dep behind the `git` feature, and
|
||||
`git_smoke` has `required-features = ["git"]`, so the editor build never pulls
|
||||
libgit2-sys/pkg-config. The component's CMake now registers _empty_ when
|
||||
`LIBGIT2_SRC` is unset, so `just build` (no env) still works.
|
||||
|
||||
**Open decisions before commit** (deliberately not done yet):
|
||||
|
||||
1. **Vendoring** — the component points at `~/.cargo`'s unpacked source via
|
||||
`LIBGIT2_SRC`; not reproducible. Needs a submodule pinned to `v1.9.4` (or a
|
||||
vendored copy). This also lets the `just flash-git` recipe drop the env vars.
|
||||
2. **Component build burden** — `extra_components` still compiles all ~200
|
||||
libgit2 files on a clean build even for the editor (cached after; Rust side
|
||||
is already gated). Accept, or gate the C compile too.
|
||||
3. ~~Runtime (Gate D on HW)~~ — **DONE 2026-07-05.** `just flash-git` on the S3:
|
||||
`git2 crate is talking to libgit2 1.9.4`, then `sha1(blob "hello") =
|
||||
b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0` + "hash matches" — i.e. git2 →
|
||||
libgit2 → mbedTLS SHA1 all ran correctly on device. Full chain proven.
|
||||
4. ~~**The real thing** — `repository_init` → `commit` → `push` over mbedTLS
|
||||
HTTPS~~ — **DONE + hardware-verified 2026-07-06** (flash-FAT working copy);
|
||||
see "On-device push COMPLETE" below.
|
||||
|
||||
## On-device push COMPLETE — 2026-07-06
|
||||
|
||||
The real thing runs on hardware. `just flash-git-push` (bin
|
||||
`firmware/src/bin/git_push.rs`, build `@a15789a`) did the whole loop on the
|
||||
ESP32-S3 and pushed to GitHub over HTTPS:
|
||||
|
||||
```
|
||||
init OK at /spiflash/wc-1783370910
|
||||
wrote device.md
|
||||
staged + tree written
|
||||
committed to master
|
||||
origin set; pushing refs/heads/master:refs/heads/device/1783370910
|
||||
cert-check BYPASSED for github.com
|
||||
push accepted by remote
|
||||
✅ Spike 7 complete — pushed master → origin/device/1783370910 over mbedTLS HTTPS
|
||||
```
|
||||
|
||||
Verified from both ends: the device logged `push accepted`, and `git ls-remote
|
||||
https://github.com/jcalixte/typoena-test.git` independently shows
|
||||
`refs/heads/device/1783370910` at commit `a96a7996`. So Wi-Fi/SNTP → mount
|
||||
flash-FAT → `repository_init` → `add_all` → `commit` → smart-HTTP push + pack
|
||||
upload + PAT auth all work on-device through libgit2 + esp-idf mbedTLS.
|
||||
|
||||
**Heap:** started at 8.44 MB free, min-ever **6.85 MB** — the whole TLS handshake
|
||||
|
||||
- packfile build cost ~1.6 MB, all served from PSRAM; internal DRAM was never
|
||||
stressed. **Timing:** first-boot FATFS format of the working-copy dir ~7.7 s,
|
||||
commit sub-second, TLS handshake→accept ~6 s.
|
||||
|
||||
### Three bugs stood between "links + runs" and "pushes"
|
||||
|
||||
Gate D proved SHA1 on device; getting from there to a real push took three fixes
|
||||
on the flash-FAT + FATFS path, each found on hardware. All are committed (5
|
||||
microcommits through `a15789a`).
|
||||
|
||||
1. **Main task stack 12 KB → 96 KB.** libgit2 is stack-hungry: nearly every
|
||||
function puts a `char path[GIT_PATH_MAX]` (4 KB) buffer on the stack, and the
|
||||
`repository_init → config-write → FATFS → wear-leveling` chain nests ~10 of
|
||||
them — a _trivial config write_ measured ~67 KB of stack. At 48 KB it
|
||||
overflowed and smashed an adjacent newlib lock handle → `LoadProhibited` in
|
||||
`xQueueGenericSend`. **This corrected an earlier misdiagnosis:** the "`time()`
|
||||
only works on the main task, not a std::thread" conclusion from the first
|
||||
on-device attempt was wrong — that thread had the _default 4 KB_ stack, so the
|
||||
same deep chain just overflowed sooner. It was always stack depth, not
|
||||
thread-vs-main. (This stack has since moved: `sdkconfig.defaults` is shared
|
||||
with the editor build, so git was later given its OWN 96 KB `std::thread` and
|
||||
the main-task stack dropped back to 12 KB — see the follow-ups. The misdiagnosis
|
||||
is now doubly retired: the push runs fine off the main task.)
|
||||
|
||||
2. **`p_rename` = remove-then-rename** (`esp_stubs.c`). FATFS `f_rename` fails
|
||||
`EEXIST` if the target exists and FAT has no hardlinks, so libgit2's own
|
||||
`p_rename` (link-then-rename in `posix.c`) can't overwrite the
|
||||
`config`/`refs`/`HEAD`/`index` files its lock→commit sequence depends on. Ours
|
||||
drops the target then renames; `posix.c`'s original is compiled under a
|
||||
throwaway name via a file-scoped CMake `COMPILE_DEFINITIONS`, so ours is the
|
||||
`p_rename` every caller links. (Not crash-atomic, but FAT offers no atomic
|
||||
replace — acceptable for the working copy.) Verified on hardware: cleared the
|
||||
`failed to rename lockfile to '.git/config'` error.
|
||||
|
||||
3. **`utimes` existence-gate — the killer.** This one silently defeated every
|
||||
object write. Our first `utimes` stub returned `0` unconditionally ("VFS can't
|
||||
set times; ignore"). But libgit2's `git_futils_touch()` → `p_utimes()` is how
|
||||
the loose ODB's `freshen` probe answers _"does this object already exist?"_,
|
||||
and `git_odb_write()` (`odb.c:1629`) **skips the write entirely** when freshen
|
||||
succeeds. So a blanket `return 0` made freshen always report "exists" →
|
||||
libgit2 believed every object was already on disk → **every** blob/tree/commit
|
||||
write was silently dropped. `.git/objects/` stayed empty (only `info/` +
|
||||
`pack/`), and `write_tree` failed with `invalid object specified - device.md`.
|
||||
Fix: `stat`-gate the stub — present → `0` (setting the time is a cosmetic
|
||||
no-op we skip), absent → `-1`/`ENOENT`, so freshen correctly reports "not
|
||||
found" and the real write proceeds.
|
||||
|
||||
Diagnosed with an in-binary A/B/C/D ODB probe — write an in-memory blob, a
|
||||
file blob, run `add_all`, then walk `.git/objects` — which showed `exists =
|
||||
false` for every OID and an empty objects dir, isolating it to the _write_
|
||||
path (not read, not mmap, not the index). The vendored `odb.c` /
|
||||
`odb_loose.c` / `futils.c` source then pinned it to the freshen→touch→utimes
|
||||
chain. **Lesson:** a "harmless" no-op POSIX stub is actively dangerous when a
|
||||
caller reads its return value as a semantic signal.
|
||||
|
||||
### Shortcuts — status
|
||||
|
||||
- **Cert verification — DONE, hardware-verified 2026-07-06 (commit `2519ed8`).**
|
||||
Was: `certificate_check` blanket-accepted the peer cert (MITM-open). Now: embed
|
||||
GitHub's roots (`firmware/src/bin/github_roots.pem` — USERTrust ECC/RSA +
|
||||
DigiCert G2/Global Root CA, extracted from the macOS root store), write them to
|
||||
`/spiflash/ca.pem`, and load them via `git2::opts::set_ssl_cert_file`
|
||||
(`GIT_OPT_SET_SSL_CERT_LOCATIONS`; `CONFIG_MBEDTLS_FS_IO=y` lets mbedtls fopen
|
||||
the file). The callback returns `CertificatePassthrough`, which the transport
|
||||
maps to `is_valid ? 0 : -1` (`httpclient.c:805`) → **fail-closed**. The push
|
||||
still landed, proving the chain validates against the embedded USERTrust ECC
|
||||
root on-device. Caveat: roots must be refreshed if GitHub rotates CAs; a product
|
||||
would prefer esp-idf's bundle via a custom subtransport (it can't reach
|
||||
libgit2's private mbedtls config without touching libgit2 sources).
|
||||
- **PAT baked into flash — STILL STANDING** (ADR-005 spike shortcut). `build.rs`
|
||||
embeds `TW_PAT` in the git_push/git_sync image via `env!()` — plaintext in
|
||||
flash, extractable with `esptool read_flash`. Fine for the dev's bench unit,
|
||||
not for a shipped one. The provisioning + at-rest-hardening decision is now
|
||||
tracked as an **open** ADR: [ADR-011](../adr.md#adr-011-credential-provisioning--how-the-pat-reaches-the-device-and-is-protected-at-rest)
|
||||
(likely on-device paste → eFuse-encrypted NVS + a per-device fine-grained PAT).
|
||||
- **Product sync transport — DECIDED 2026-07-06: HTTPS + PAT.** On-device libgit2
|
||||
is HTTPS-only (mbedTLS build; no ssh client, libssh2 unported), and the proven
|
||||
path is HTTPS+PAT, so the product keeps ADR-005 rather than porting SSH. The
|
||||
real project remote (`git@github.com:jcalixte/typewriter.git`, SSH) stays for
|
||||
desktop/human use; the device publishes over an HTTPS remote + token (stored
|
||||
securely, not in flash). No libssh2 port needed.
|
||||
|
||||
## Artifacts (this session)
|
||||
|
||||
- `spikes/spike7-git-push/` — the desktop spike crate (`src/main.rs`,
|
||||
`Cargo.toml`, `README.md`, `.env.example`).
|
||||
- `firmware/components/libgit2/` — the esp-idf component (uncommitted probe):
|
||||
`CMakeLists.txt`, `git2_features.h`, `poll.h`, `esp_port.h`, `esp_map.c`,
|
||||
`esp_stubs.c`.
|
||||
- `firmware/src/bin/git_smoke.rs` + Cargo.toml `[[bin]]`/`extra_components`
|
||||
(uncommitted probe wiring).
|
||||
- ADR-004 — outcome note appended (kill-switch fired → libgit2).
|
||||
- `docs/v0.1-mvp-technical.md` — risk-table row updated (gix push → libgit2).
|
||||
@@ -0,0 +1,174 @@
|
||||
# Editor freeze — SPI-DMA out-of-memory during a background `:sync`
|
||||
|
||||
> Date: 2026-07-11 · Build at time of failure: `07-11 18:02Z @229c259-dirty`
|
||||
> Status: **Safety net shipped and hardware-verified** (2026-07-13, during the
|
||||
> [real-repo-sync kaizen](../kaizen/real-repo-sync.md): live scrolling through
|
||||
> a real-repo push — the run-4 crash was this same paint-during-push family,
|
||||
> and after the fixes the UI survived every subsequent push, success and
|
||||
> failure alike). The paint path also **no longer allocates per repaint**
|
||||
> (persistent two-frame `draw_into`/`swap` in `main.rs`, 2026-07-13), removing
|
||||
> the frame-buffer half of the paint-time allocation. **Root cause not fully
|
||||
> eradicated**: the EPD driver's per-refresh internal DMA scratch remains; the
|
||||
> permanent fix (a persistent scratch buffer in `Epd`) is specced below and
|
||||
> now tracked in [v0.9 robustness](../v0.9-robustness.md).
|
||||
>
|
||||
> Context: editor loop [`../../firmware/src/main.rs`](../../firmware/src/main.rs),
|
||||
> EPD driver [`../../firmware/src/epd.rs`](../../firmware/src/epd.rs), git
|
||||
> transport [`../../firmware/src/git_sync.rs`](../../firmware/src/git_sync.rs).
|
||||
> Display medium [ADR-003](../adr.md#adr-003-display-medium--e-ink-gdey0579t93-panel);
|
||||
> concurrency model (dedicated git thread) [ADR-006](../adr.md).
|
||||
|
||||
## Summary
|
||||
|
||||
First-ever field freeze. After an hour of clean editing, the user ran `:sync`;
|
||||
the commit and push **succeeded** (`push accepted by remote`, commit
|
||||
`48a2c0a8`), but partway through the push the panel stopped updating. Keystrokes
|
||||
kept being logged, so the device wasn't hung — but nothing repainted again.
|
||||
|
||||
The editor task had died. A screen refresh that happened to run **while Wi-Fi +
|
||||
TLS were up for the push** failed to allocate an internal DMA buffer
|
||||
(`ESP_ERR_NO_MEM`), and that error propagated through a `?` straight out of
|
||||
`main()`. ESP-IDF's `main_task` returned from `app_main()`; the editor loop lived
|
||||
on that task, so it stopped. The USB-keyboard and git threads are **separate
|
||||
FreeRTOS tasks** (ADR-006) and kept running — hence keys still logged while the
|
||||
panel was frozen. A zombie, not a crash.
|
||||
|
||||
## Symptom
|
||||
|
||||
```
|
||||
I (305749) firmware::git_sync: verifying github.com TLS chain against embedded GitHub CA bundle
|
||||
I (305979) firmware::usb_kbd: key: HalfPageUp ← scroll arrives → editor loop paints
|
||||
E (306259) spi_master: setup_dma_priv_buffer(1206): Failed to allocate priv TX buffer
|
||||
Error: ESP_ERR_NO_MEM
|
||||
I (306259) main_task: Returned from app_main() ← editor task exits
|
||||
...
|
||||
I (310119) firmware::git_sync: push accepted by remote ← git thread unaffected; commit landed
|
||||
I (311069) firmware::usb_kbd: key: Escape ← keys still logged, no refresh ever again
|
||||
I (312729) firmware::usb_kbd: key: HalfPageDown
|
||||
```
|
||||
|
||||
## Root cause
|
||||
|
||||
The EPD is on SPI2 configured `Dma::Auto(4096)` (`main.rs`), and the driver hands
|
||||
frame data to `spi.write()` as ordinary Rust `Vec<u8>` buffers (`epd.rs`
|
||||
`write_frame_bank` / `data`). With PSRAM added to the heap allocator, those
|
||||
`Vec`s can be allocated **from PSRAM**, which is **not DMA-capable**. When a
|
||||
transfer buffer isn't DMA-capable, esp-idf's `spi_master` bounces it through a
|
||||
temporary internal buffer it mallocs on the fly (`setup_dma_priv_buffer`, using
|
||||
`MALLOC_CAP_DMA` → **internal RAM only**).
|
||||
|
||||
For the whole session that bounce allocation succeeded, because internal RAM was
|
||||
plentiful. The instant `:sync` brought up **Wi-Fi + TLS**, they consumed the
|
||||
small internal pool. The next paint's bounce allocation returned
|
||||
`ESP_ERR_NO_MEM`. The real defect is not the low-memory moment itself — it's that
|
||||
the paint's `?` made a **transient, retryable** I/O failure **fatal to the whole
|
||||
appliance**.
|
||||
|
||||
Two things made this easy to misread:
|
||||
|
||||
- **"8.4 MB free heap" is a red herring.** That figure is dominated by PSRAM.
|
||||
The starved pools are the tiny internal ones (~265 KB / 21 KB / 32 KB), and DMA
|
||||
bounce buffers can only come from those.
|
||||
- **It looked like a git/sync bug, but the push was fine.** The git thread
|
||||
finished and the remote accepted the commit. Only the UI task died.
|
||||
|
||||
## Timeline
|
||||
|
||||
1. Hour of normal editing — hundreds of ~630 ms partial refreshes, no issue
|
||||
(internal RAM never under pressure; Wi-Fi off — the radio is lazy, ADR-006).
|
||||
2. `:sync` → save → git thread brings up Wi-Fi, SNTP, TLS, commits `48a2c0a8`,
|
||||
starts the push. Internal RAM now under heavy Wi-Fi/TLS load.
|
||||
3. User keeps scrolling (`HalfPageUp`) during the push. Each scroll triggers a
|
||||
full-area partial refresh → SPI-DMA transfer → bounce-buffer malloc.
|
||||
4. One such malloc fails (`ESP_ERR_NO_MEM`); the `?` in the refresh call
|
||||
propagates out of `main()`; `main_task` returns from `app_main()`.
|
||||
5. Push completes normally; Wi-Fi torn down; internal RAM freed — but the editor
|
||||
task is already gone, so nothing repaints. Keys log into the void.
|
||||
|
||||
## What it was _not_
|
||||
|
||||
- **Not out of heap** — plenty of PSRAM free; it was internal _DMA-capable_ RAM
|
||||
specifically.
|
||||
- **Not a git or TLS bug** — the sync succeeded end to end.
|
||||
- **Not an editor-core bug** — `editor`/`keymap` never ran; the failure is in the
|
||||
firmware paint path's error handling.
|
||||
- **Not a panel/wiring fault** — the same paint path worked for an hour and works
|
||||
again after Wi-Fi is down.
|
||||
|
||||
## Remediation shipped — paints are non-fatal
|
||||
|
||||
A screen refresh is idempotent and retryable: the editor buffer is the source of
|
||||
truth, so a dropped frame costs nothing and the next paint recovers. This is the
|
||||
exact contract already written into `save_note` ("errors are logged, never
|
||||
propagated"). Every paint site in the editor loop (`main.rs`) now:
|
||||
|
||||
- logs the failure and **drops the frame** instead of `?`-propagating it;
|
||||
- leaves `shown` untouched so the next paint repaints the same diff;
|
||||
- sets a `force_full` flag so the next paint is a **full refresh**, which
|
||||
rewrites both RAM banks and recovers from a partial that may have died
|
||||
mid-transfer and left the `0x24`/`0x26` banks inconsistent.
|
||||
|
||||
Effect: a paint that lands during a sync is dropped (panel goes stale for the
|
||||
~15 s of the push), then self-heals the moment the push finishes and internal RAM
|
||||
frees. A permanent brick becomes a brief stale window. The boot-time first render
|
||||
stays `?`-fatal on purpose — it runs before Wi-Fi, can't hit this, and a dead
|
||||
panel at boot is a legitimate hard fault.
|
||||
|
||||
## Root-cause eradication (specced, not yet built)
|
||||
|
||||
The safety net stops the brick but not the underlying **contention**: paints
|
||||
during a sync still fail and drop. To keep the editor fully live while a push
|
||||
runs — the whole point of the async git thread (ADR-006) — remove the on-the-fly
|
||||
DMA allocation entirely.
|
||||
|
||||
**Fix: a persistent, internal, DMA-capable scratch buffer owned by `Epd`.**
|
||||
|
||||
- Allocate it **once at `Epd` construction** (boot, before Wi-Fi is ever up, when
|
||||
internal RAM is plentiful) via
|
||||
`heap_caps_malloc(SPI_CHUNK, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)`.
|
||||
- `data()` already chunks writes to `SPI_CHUNK` (4096 B), so a **4 KB** scratch
|
||||
buffer is sufficient: copy each chunk into it and hand that DMA-capable slice to
|
||||
`spi.write()`.
|
||||
- Because the source is now DMA-capable, `spi_master` DMAs directly from it —
|
||||
`setup_dma_priv_buffer` is never invoked, there is **no per-refresh
|
||||
allocation**, and paints no longer compete with Wi-Fi/TLS for internal RAM.
|
||||
Refreshes then succeed during a sync, and `force_full` recovery is only ever
|
||||
exercised by genuine faults, not by normal syncing.
|
||||
|
||||
**Cost:** ~4 KB of internal RAM reserved for the life of the device; one small
|
||||
`unsafe` block for the alloc + slice; a `memcpy` per 4 KB chunk (negligible
|
||||
against the ~630 ms panel waveform).
|
||||
|
||||
**Alternatives considered and rejected:**
|
||||
|
||||
- _Trim Wi-Fi's internal buffer counts_ to leave headroom — fragile tuning that
|
||||
risks Wi-Fi stability/throughput and only widens the margin instead of removing
|
||||
the failure mode.
|
||||
- _Force the whole ~13.6 KB frame buffer internal_ (custom allocator / full
|
||||
`heap_caps` buffer) — larger reservation and more churn than the 4 KB
|
||||
chunk-scratch, for no extra benefit since `data()` already chunks.
|
||||
- _Serialize paints against sync_ (skip painting while a push is in flight) —
|
||||
defeats the async-git design and freezes the panel for the whole push; the
|
||||
safety net already makes this unnecessary.
|
||||
|
||||
**Verification when built:** reproduce the exact failing scenario — edit,
|
||||
`:sync`, and keep scrolling/paging through the _entire_ push. Expect: every
|
||||
refresh succeeds (no `refresh … FAILED` warnings, no dropped frames), min-ever
|
||||
internal heap stays comfortably above zero throughout, and `force_full` is not
|
||||
triggered by the sync.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- [x] Make all editor-loop paints non-fatal + `force_full` recovery (`main.rs`);
|
||||
release build green.
|
||||
- [x] Reflash and hardware-verify the safety net against the repro (edit →
|
||||
`:sync` → scroll through the push): panel must recover, not freeze.
|
||||
**Verified 2026-07-13** (real-repo-sync kaizen runs 5–9: concurrent
|
||||
typing/scrolling through full pushes, no freeze; bonus fix — repaints no
|
||||
longer allocate at all, closing the run-4 `Frame::new_white` OOM-abort
|
||||
variant of the same failure).
|
||||
- [ ] Implement the persistent internal DMA scratch buffer in `Epd` (eradication
|
||||
above) if the stale-during-sync window proves annoying in real use —
|
||||
→ tracked in [v0.9 robustness](../v0.9-robustness.md).
|
||||
- [ ] After eradication, confirm refreshes succeed _during_ a push and drop the
|
||||
stale window entirely.
|
||||
15
docs/postmortems/README.md
Normal file
15
docs/postmortems/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Postmortems
|
||||
|
||||
> Bench + bring-up debugging write-ups: what broke, how we found the root cause,
|
||||
> and the decisions that came out of it. One file per incident, named
|
||||
> `YYYY-MM-DD-<slug>.md`. These capture _why_ a spike stalled or a design turned
|
||||
> — the kind of context that's expensive to reconstruct later.
|
||||
>
|
||||
> Project overview: [`../../README.md`](../../README.md). Bring-up spikes:
|
||||
> [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order).
|
||||
|
||||
| Date | Incident | Status |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| 2026-07-05 | [Spike 3 (SD) — card rejects CMD59 (SPI-mode CRC)](2026-07-05-spike3-sd-cmd59.md) | Paused — awaiting a compliant microSD; wiring + firmware proven |
|
||||
| 2026-07-05 | [Spike 7 (git push) — ADR-004 kill-switch fired: gix can't push over HTTPS](2026-07-05-spike7-gix-https-push.md) | Turned — pivoted to libgit2; git mechanics proven on desktop, device build next |
|
||||
| 2026-07-11 | [Editor freeze — SPI-DMA OOM during a background `:sync`](2026-07-11-editor-freeze-spi-dma-oom-during-sync.md) | Safety net shipped (paints non-fatal); root-cause eradication specced, not yet built |
|
||||
898
docs/qfd.md
898
docs/qfd.md
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
# Quality House — empty (training)
|
||||
|
||||
Same 14 WHATs × 14 HOWs chassis as [`quality-house.md`](quality-house.md),
|
||||
with the **relations**, **roof correlations**, and basement Σ / Rel %
|
||||
**deliberately blank**. Fill them in yourself, then compare against the
|
||||
populated house to check your reading.
|
||||
Same 14 WHATs × 14 HOWs chassis as the filled House in
|
||||
[`qfd.md` §3](qfd.md#3-house-of-quality--whats--hows), with the **relations**,
|
||||
**roof correlations**, and basement Σ / Rel % **deliberately blank**. Fill them
|
||||
in yourself, then compare against the populated house to check your reading.
|
||||
|
||||
Kept (definitional inputs from `qfd.md`):
|
||||
|
||||
@@ -368,9 +368,10 @@ Blank (training surface):
|
||||
4. **Basement Rel %.** Each Σ ÷ total Σ × 100, rounded to integer percent
|
||||
(should sum to ~100 across HOWs).
|
||||
|
||||
Once filled, diff your numbers against [`quality-house.md`](quality-house.md)
|
||||
basement and roof to see where your reading agrees or differs — divergences
|
||||
are usually the most interesting part.
|
||||
Once filled, diff your numbers against the filled House in
|
||||
[`qfd.md` §3](qfd.md#3-house-of-quality--whats--hows) (basement and roof) to see
|
||||
where your reading agrees or differs — divergences are usually the most
|
||||
interesting part.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -1,675 +0,0 @@
|
||||
# Quality House
|
||||
|
||||
The 14 WHATs × 14 HOWs House of Quality. The roof carries function-vs-
|
||||
function correlations; the basement carries v0.1 targets (mirrored from
|
||||
[`qfd.md`](qfd.md) §2) plus the weighted-vote sums
|
||||
(`Σ = Σ(W weight × cell strength)`) and rounded relative weights. The
|
||||
narrative reading of these numbers lives in `qfd.md` §3 (priority list)
|
||||
and §4 (conflict list).
|
||||
|
||||
This file is the authoritative source for the matrix cells, the roof
|
||||
correlations, and the basement Σ + relative weights. WHATs (§1) and HOWs
|
||||
(§2) are mirrored here from `qfd.md`; if those diverge, trust `qfd.md`.
|
||||
|
||||
Out of scope here: §5 component mapping (would need a second house),
|
||||
§6 critical performance budget (already a curated rank), §7 tradeoffs
|
||||
(narrative), §8 inconsistencies (history).
|
||||
|
||||
For a blank version of the same chassis (WHATs, HOWs, importance, and
|
||||
v0.1 targets kept; relations + roof + Σ basement left empty for practice),
|
||||
see [`quality-house-empty.md`](quality-house-empty.md).
|
||||
|
||||
The perception zone scores five products against the WHATs on a 0–5 scale.
|
||||
**These are educated guesses, not measurements** — see the
|
||||
[scoring rationale](#perception-scores-guessed) below for what each cell
|
||||
is based on. Useful for self-positioning ("where do we land vs the
|
||||
market"), not as a fair head-to-head buyer's guide.
|
||||
|
||||
```tikz
|
||||
% =====================================================================
|
||||
% QFD "House of Quality" preamble
|
||||
% =====================================================================
|
||||
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
|
||||
|
||||
\newif\ifqfdshowroof \qfdshowrooftrue
|
||||
\newif\ifqfdshowbasement \qfdshowbasementtrue
|
||||
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
|
||||
\newif\ifqfdshowlegend \qfdshowlegendtrue
|
||||
\newif\ifqfdshowimportance \qfdshowimportancetrue
|
||||
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
|
||||
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
|
||||
|
||||
\def\qfdNW{5}
|
||||
\def\qfdNH{5}
|
||||
\def\qfdWhatW{4.0}
|
||||
\def\qfdImpW{0.9}
|
||||
\def\qfdCmpW{3}
|
||||
\def\qfdHdrH{2.6}
|
||||
\def\qfdBasementN{4}
|
||||
|
||||
\def\qfdWhatsTitle{Customer needs}
|
||||
\def\qfdImpTitle{Imp.\ \%}
|
||||
\def\qfdPerceptionTitle{Comparative evaluation}
|
||||
\def\qfdPoorLabel{poor}
|
||||
\def\qfdExcellentLabel{excellent}
|
||||
\def\qfdAltOneLabel{Typoena}
|
||||
\def\qfdAltTwoLabel{Competitor A}
|
||||
\def\qfdAltThreeLabel{Competitor B}
|
||||
\def\qfdRelTitle{Relation}
|
||||
\def\qfdCorrTitle{Correlation}
|
||||
\def\qfdEvalTitle{Evaluation}
|
||||
|
||||
\tikzset{
|
||||
qfdthin/.style ={line width=0.35pt},
|
||||
qfdmed/.style ={line width=0.7pt},
|
||||
qfdstrong/.style={circle, draw, fill=black,
|
||||
minimum size=7pt, inner sep=0pt},
|
||||
qfdmod/.style ={circle, draw,
|
||||
minimum size=7pt, inner sep=0pt, line width=0.8pt},
|
||||
qfdweak/.style ={regular polygon, regular polygon sides=3, draw,
|
||||
minimum size=8.5pt, inner sep=0pt, line width=0.7pt},
|
||||
qfdrel/.is choice,
|
||||
qfdrel/S/.style={qfdstrong},
|
||||
qfdrel/M/.style={qfdmod},
|
||||
qfdrel/W/.style={qfdweak},
|
||||
qfdalt1mk/.style={circle, draw, fill=black,
|
||||
minimum size=6pt, inner sep=0pt, line width=1pt},
|
||||
qfdalt1ln/.style={line width=1.2pt},
|
||||
qfdalt2mk/.style={regular polygon, regular polygon sides=3, draw,
|
||||
fill=black, minimum size=6pt, inner sep=0pt,
|
||||
line width=0.7pt},
|
||||
qfdalt2ln/.style={line width=0.7pt, dashed},
|
||||
qfdalt3mk/.style={rectangle, draw, fill=black,
|
||||
minimum size=5pt, inner sep=0pt, line width=0.7pt},
|
||||
qfdalt3ln/.style={line width=0.7pt, dotted},
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawGrid}{%
|
||||
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
|
||||
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
|
||||
\foreach \r in {1,...,\qfdNWm}
|
||||
\draw[qfdthin] (\qfdLeftEdge, -\r) -- (0, -\r);
|
||||
\ifqfdshowroof
|
||||
\foreach \c in {1,...,\qfdNHm}
|
||||
\draw[qfdthin] (\c, 0) -- (\c, \qfdHdrH);
|
||||
\fi
|
||||
\ifqfdshowcompetitive
|
||||
\foreach \r in {1,...,\qfdNWm}
|
||||
\draw[qfdthin] (\qfdNH, -\r) -- (\qfdNH+\qfdCmpW, -\r);
|
||||
\fi
|
||||
\ifqfdshowbasement
|
||||
\foreach \r in {1,...,\qfdBasementN}
|
||||
\draw[qfdthin] (0, -\qfdNW-\r) -- (\qfdNH, -\qfdNW-\r);
|
||||
\foreach \c in {1,...,\qfdNHm}
|
||||
\draw[qfdthin] (\c, -\qfdNW) -- (\c, -\qfdNW-\qfdBasementN);
|
||||
\fi
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawRoof}{%
|
||||
\ifqfdshowroof
|
||||
\foreach \k in {1,...,\qfdNHm} {%
|
||||
\pgfmathsetmacro{\rx}{(\k+\qfdNH)/2}
|
||||
\pgfmathsetmacro{\ry}{\qfdHdrH + (\qfdNH-\k)/2}
|
||||
\pgfmathsetmacro{\lx}{\k/2}
|
||||
\pgfmathsetmacro{\ly}{\qfdHdrH + \k/2}
|
||||
\draw[qfdthin] (\k, \qfdHdrH) -- (\rx, \ry);
|
||||
\draw[qfdthin] (\k, \qfdHdrH) -- (\lx, \ly);
|
||||
}%
|
||||
\draw[qfdmed] (0, \qfdHdrH)
|
||||
-- (\qfdNH/2, \qfdApexY) -- (\qfdNH, \qfdHdrH);
|
||||
\foreach \i in {1,...,\qfdNH}
|
||||
\foreach \k in {1,...,\qfdNH} {%
|
||||
\pgfmathtruncatemacro{\jj}{\i+\k}
|
||||
\ifnum\jj>\qfdNH\relax\else
|
||||
\pgfmathsetmacro{\xx}{\i + \k/2 - 0.5}
|
||||
\pgfmathsetmacro{\yy}{\qfdHdrH + \k/2}
|
||||
\coordinate (C-\i-\jj) at (\xx, \yy);
|
||||
\fi
|
||||
}%
|
||||
\fi
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawScale}{%
|
||||
\ifqfdshowcompetitive
|
||||
\foreach \tk in {0,1,2,3,4,5} {%
|
||||
\pgfmathsetmacro{\tx}{\qfdNH + (\tk+0.5)*\qfdCmpW/6}
|
||||
\node[anchor=south, font=\scriptsize] at (\tx, 0.02) {\tk};
|
||||
}%
|
||||
\node[anchor=south, font=\scriptsize\bfseries, align=center,
|
||||
text width=\qfdCmpW cm]
|
||||
at ({\qfdNH + \qfdCmpW/2}, 0.7) {\qfdPerceptionTitle};
|
||||
\node[anchor=north, font=\scriptsize\itshape]
|
||||
at ({\qfdNH + 0.45}, -\qfdNW) {\qfdPoorLabel};
|
||||
\node[anchor=north, font=\scriptsize\itshape]
|
||||
at ({\qfdNH + \qfdCmpW - 0.45}, -\qfdNW) {\qfdExcellentLabel};
|
||||
\fi
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawZoneTitles}{%
|
||||
\ifqfdshowimportance
|
||||
\node[rotate=90, anchor=west, font=\footnotesize\bfseries]
|
||||
at ({-\qfdImpW/2}, 0.12) {\qfdImpTitle};
|
||||
\fi
|
||||
\node[font=\scriptsize\bfseries, align=center, text width=\qfdWhatW cm]
|
||||
at ({\qfdLeftEdge + \qfdWhatW/2},
|
||||
{\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawFrames}{%
|
||||
\begin{scope}[qfdmed]
|
||||
\draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
|
||||
\ifqfdshowimportance \draw (-\qfdImpW, 0) -- (-\qfdImpW, -\qfdNW); \fi
|
||||
\draw (0, 0) -- (0, -\qfdNW);
|
||||
\ifqfdshowroof
|
||||
\draw (0, 0) rectangle (\qfdNH, \qfdHdrH); \fi
|
||||
\ifqfdshowbasement
|
||||
\draw (0, -\qfdNW) rectangle (\qfdNH, -\qfdNW-\qfdBasementN); \fi
|
||||
\ifqfdshowcompetitive
|
||||
\draw (\qfdNH, 0) rectangle (\qfdNH+\qfdCmpW, -\qfdNW); \fi
|
||||
\end{scope}
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawLegend}{%
|
||||
\ifqfdshowlegend
|
||||
\pgfmathsetmacro{\qfdLegX}{%
|
||||
\qfdNH + \ifqfdshowcompetitive \qfdCmpW + 0.7 \else 0.7 \fi}
|
||||
\pgfmathsetmacro{\qfdLegBottom}{%
|
||||
-2.05
|
||||
\ifqfdshowroof \ifqfdshowcorrlegend - 2.55 \fi \fi
|
||||
\ifqfdshowcompetitive \ifqfdshowevallegend - 2.20 \fi \fi}
|
||||
\pgfmathsetmacro{\qfdLegY}{\qfdHdrH - 0.4}
|
||||
\begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
|
||||
\draw[qfdmed, rounded corners=2pt]
|
||||
(-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
|
||||
{\qfdRelTitle};
|
||||
\draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
|
||||
\node[qfdstrong] at (0.22, -0.5) {};
|
||||
\node[anchor=west] at (0.5, -0.5) {Strong (9)};
|
||||
\node[qfdmod] at (0.22, -0.95) {};
|
||||
\node[anchor=west] at (0.5, -0.95) {Medium (3)};
|
||||
\node[qfdweak] at (0.22, -1.4) {};
|
||||
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
|
||||
\ifqfdshowroof \ifqfdshowcorrlegend
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
|
||||
{\qfdCorrTitle};
|
||||
\draw[qfdthin] (0, -2.35) -- (4.35, -2.35);
|
||||
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
|
||||
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
|
||||
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
|
||||
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
|
||||
\fi \fi
|
||||
\ifqfdshowcompetitive \ifqfdshowevallegend
|
||||
\pgfmathsetmacro{\qfdEvalTop}{%
|
||||
-2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
|
||||
\node[anchor=west, font=\footnotesize\bfseries]
|
||||
at (0, \qfdEvalTop) {\qfdEvalTitle};
|
||||
\pgfmathsetmacro{\qfdEvalSep}{\qfdEvalTop - 0.25}
|
||||
\draw[qfdthin] (0, \qfdEvalSep) -- (4.35, \qfdEvalSep);
|
||||
\pgfmathsetmacro{\qfdLegA}{\qfdEvalTop - 0.55}
|
||||
\draw[qfdalt1ln] (0.05, \qfdLegA) -- (0.45, \qfdLegA);
|
||||
\node[qfdalt1mk] at (0.25, \qfdLegA) {};
|
||||
\node[anchor=west, font=\bfseries] at (0.55, \qfdLegA)
|
||||
{\qfdAltOneLabel};
|
||||
\pgfmathsetmacro{\qfdLegB}{\qfdEvalTop - 0.95}
|
||||
\draw[qfdalt2ln] (0.05, \qfdLegB) -- (0.45, \qfdLegB);
|
||||
\node[qfdalt2mk] at (0.25, \qfdLegB) {};
|
||||
\node[anchor=west] at (0.55, \qfdLegB) {\qfdAltTwoLabel};
|
||||
\pgfmathsetmacro{\qfdLegC}{\qfdEvalTop - 1.35}
|
||||
\draw[qfdalt3ln] (0.05, \qfdLegC) -- (0.45, \qfdLegC);
|
||||
\node[qfdalt3mk] at (0.25, \qfdLegC) {};
|
||||
\node[anchor=west] at (0.55, \qfdLegC) {\qfdAltThreeLabel};
|
||||
\fi \fi
|
||||
\end{scope}
|
||||
\fi
|
||||
}
|
||||
|
||||
\newenvironment{qfdhouse}{%
|
||||
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize,
|
||||
line cap=round, line join=round]
|
||||
\ifqfdshowimportance
|
||||
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW-\qfdImpW}
|
||||
\else
|
||||
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW}
|
||||
\fi
|
||||
\pgfmathsetmacro{\qfdApexY}{\qfdHdrH + \qfdNH/2}
|
||||
\pgfmathtruncatemacro{\qfdNHm}{\qfdNH - 1}
|
||||
\pgfmathtruncatemacro{\qfdNWm}{\qfdNW - 1}
|
||||
\qfdDrawGrid
|
||||
\qfdDrawRoof
|
||||
\qfdDrawScale
|
||||
\qfdDrawZoneTitles
|
||||
}{%
|
||||
\qfdDrawFrames
|
||||
\qfdDrawLegend
|
||||
\end{tikzpicture}%
|
||||
}
|
||||
|
||||
% --- Dimensions tuned for the typewriter QFD (14 W x 15 H) ---
|
||||
\def\qfdNW{14}
|
||||
\def\qfdNH{14}
|
||||
\def\qfdWhatW{4.6}
|
||||
\def\qfdImpW{0.7}
|
||||
\def\qfdHdrH{5.0}
|
||||
\def\qfdBasementN{3}
|
||||
\def\qfdCmpW{3.4}
|
||||
\qfdshowlegendfalse % we draw a 4-alternative legend manually
|
||||
|
||||
\def\qfdWhatsTitle{User-facing requirements (W)}
|
||||
\def\qfdImpTitle{Weight}
|
||||
\def\qfdPerceptionTitle{Competitive perception\\(0–5, guessed)}
|
||||
\def\qfdPoorLabel{poor}
|
||||
\def\qfdExcellentLabel{excellent}
|
||||
|
||||
% Perception-zone markers: shape + colour-blind-safe colour per product.
|
||||
% Palette is Okabe-Ito (blue, vermillion, bluish green, reddish purple).
|
||||
% Light fills + saturated outlines keep stacked markers legible.
|
||||
\definecolor{qfdcTypoena}{RGB}{0,114,178}
|
||||
\definecolor{qfdcRem}{RGB}{213,94,0}
|
||||
\definecolor{qfdcFrw}{RGB}{0,158,115}
|
||||
\definecolor{qfdcPom}{RGB}{204,121,167}
|
||||
\definecolor{qfdcFrwS}{RGB}{86,180,233}
|
||||
|
||||
\tikzset{
|
||||
qfdalt1mk/.style={circle, draw=qfdcTypoena, fill=qfdcTypoena!55!white,
|
||||
minimum size=6.5pt, inner sep=0pt, line width=1.1pt},
|
||||
qfdalt1ln/.style={line width=1.2pt, qfdcTypoena},
|
||||
qfdalt2mk/.style={regular polygon, regular polygon sides=3,
|
||||
draw=qfdcRem, fill=qfdcRem!55!white,
|
||||
minimum size=7pt, inner sep=0pt, line width=0.9pt},
|
||||
qfdalt2ln/.style={line width=0.8pt, dashed, qfdcRem},
|
||||
qfdalt3mk/.style={rectangle, draw=qfdcFrw, fill=qfdcFrw!55!white,
|
||||
minimum size=5.5pt, inner sep=0pt, line width=0.9pt},
|
||||
qfdalt3ln/.style={line width=0.8pt, dotted, qfdcFrw},
|
||||
qfdalt4mk/.style={diamond, aspect=1, draw=qfdcPom,
|
||||
fill=qfdcPom!50!white,
|
||||
minimum size=7pt, inner sep=0pt, line width=1.0pt},
|
||||
qfdalt4ln/.style={line width=0.8pt, dash dot, qfdcPom},
|
||||
qfdalt5mk/.style={regular polygon, regular polygon sides=5,
|
||||
draw=qfdcFrwS, fill=qfdcFrwS!40!white,
|
||||
minimum size=5.5pt, inner sep=0pt, line width=0.8pt},
|
||||
qfdalt5ln/.style={line width=0.7pt, dash dot dot, qfdcFrwS},
|
||||
}
|
||||
|
||||
\begin{document}
|
||||
\begin{qfdhouse}
|
||||
|
||||
% ---------- WHATs (left column) ----------
|
||||
% Box width is 0.2 cm narrower than the column so labels have 0.1 cm
|
||||
% clearance on each side and don't bleed into the Weight column.
|
||||
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
|
||||
\foreach \r/\t in {%
|
||||
1/{W1 Sub-second visible response to typing},
|
||||
2/{W2 Publishing is one deliberate action away},
|
||||
3/{W3 Pulling power never corrupts the file},
|
||||
4/{W4 Provisioning never interrupts a writing session},
|
||||
5/{W5 Quick boot to a writing cursor},
|
||||
6/{W6 Long sessions without crash, lag, drift},
|
||||
7/{W7 Nothing on the device competes with prose},
|
||||
8/{W8 The UI never moves except when I move it},
|
||||
9/{W9 Codebase absorbs the planned roadmap},
|
||||
10/{W10 I can repair or fork it with hobbyist tools},
|
||||
11/{W11 Multi-day battery life (v0.8 onward)},
|
||||
12/{W12 Local-only files coexist with git scope (v0.5+)},
|
||||
13/{W13 Typography sets a writing-tool tone},
|
||||
14/{W14 I can carry the device and write away from a desk}%
|
||||
}
|
||||
\node[anchor=west, font=\scriptsize,
|
||||
text width=\qfdWhatTextW cm, align=left]
|
||||
at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
|
||||
|
||||
% ---------- Importance (raw 1-10 weight) ----------
|
||||
\foreach \r/\w in {1/10, 2/9, 3/10, 4/7, 5/6, 6/9, 7/8, 8/7,
|
||||
9/8, 10/5, 11/4, 12/5, 13/7, 14/8}
|
||||
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\w};
|
||||
|
||||
% ---------- HOWs (rotated column titles) ----------
|
||||
\foreach \c/\t in {%
|
||||
1/{H1 Type latency},
|
||||
2/{H2 Refresh area per keystroke},
|
||||
3/{H3 Full-refresh cadence},
|
||||
4/{H4 Boot latency (cold)},
|
||||
5/{H5 Continuous-typing endurance},
|
||||
6/{H6 Publish reliability},
|
||||
7/{H7 Publish latency},
|
||||
8/{H8 Save durability},
|
||||
9/{H9 PSRAM heap headroom},
|
||||
10/{H10 Firmware binary size},
|
||||
11/{H11 Total stack budget},
|
||||
12/{H12 Network reconnect time},
|
||||
13/{H13 Idle / typing / push current},
|
||||
14/{H15 Clean release build time}%
|
||||
}
|
||||
\node[rotate=90, anchor=west, font=\scriptsize]
|
||||
at ({\c - 0.5}, 0.15) {\t};
|
||||
|
||||
% ---------- Relation matrix (S=9, M=3, W=1) ----------
|
||||
% W1 row 1: H1S H2S H3M H5M H9W H11W
|
||||
\node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({3 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({5 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({9 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({11 - 0.5}, {-1 + 0.5}) {};
|
||||
|
||||
% W2 row 2: H6S H7M H9S H12S
|
||||
\node[qfdrel/S] at ({6 - 0.5}, {-2 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({7 - 0.5}, {-2 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({9 - 0.5}, {-2 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({12 - 0.5}, {-2 + 0.5}) {};
|
||||
|
||||
% W3 row 3: H8S
|
||||
\node[qfdrel/S] at ({8 - 0.5}, {-3 + 0.5}) {};
|
||||
|
||||
% W4 row 4: H6M H12M
|
||||
\node[qfdrel/M] at ({6 - 0.5}, {-4 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({12 - 0.5}, {-4 + 0.5}) {};
|
||||
|
||||
% W5 row 5: H4S H10M
|
||||
\node[qfdrel/S] at ({4 - 0.5}, {-5 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({10 - 0.5}, {-5 + 0.5}) {};
|
||||
|
||||
% W6 row 6: H1M H3M H5S H6M H8M H9S H11M H12M
|
||||
\node[qfdrel/M] at ({1 - 0.5}, {-6 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({3 - 0.5}, {-6 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({5 - 0.5}, {-6 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({6 - 0.5}, {-6 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({8 - 0.5}, {-6 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({9 - 0.5}, {-6 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({11 - 0.5}, {-6 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({12 - 0.5}, {-6 + 0.5}) {};
|
||||
|
||||
% W7 row 7: H1M H2M H3M H13M
|
||||
\node[qfdrel/M] at ({1 - 0.5}, {-7 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({2 - 0.5}, {-7 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({3 - 0.5}, {-7 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({13 - 0.5}, {-7 + 0.5}) {};
|
||||
|
||||
% W8 row 8: H1W H2S H3S
|
||||
\node[qfdrel/W] at ({1 - 0.5}, {-8 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({2 - 0.5}, {-8 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({3 - 0.5}, {-8 + 0.5}) {};
|
||||
|
||||
% W9 row 9: H10W H11W H15M
|
||||
\node[qfdrel/W] at ({10 - 0.5}, {-9 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({11 - 0.5}, {-9 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({14 - 0.5}, {-9 + 0.5}) {};
|
||||
|
||||
% W10 row 10: H10M H13W H15W
|
||||
\node[qfdrel/M] at ({10 - 0.5}, {-10 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({13 - 0.5}, {-10 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({14 - 0.5}, {-10 + 0.5}) {};
|
||||
|
||||
% W11 row 11: H13S
|
||||
\node[qfdrel/S] at ({13 - 0.5}, {-11 + 0.5}) {};
|
||||
|
||||
% W12 row 12: H6W H8M
|
||||
\node[qfdrel/W] at ({6 - 0.5}, {-12 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({8 - 0.5}, {-12 + 0.5}) {};
|
||||
|
||||
% W13 row 13: H9M
|
||||
\node[qfdrel/M] at ({9 - 0.5}, {-13 + 0.5}) {};
|
||||
|
||||
% W14 row 14: H4W H8M H12M H13S
|
||||
\node[qfdrel/W] at ({4 - 0.5}, {-14 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({8 - 0.5}, {-14 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({12 - 0.5}, {-14 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({13 - 0.5}, {-14 + 0.5}) {};
|
||||
|
||||
% ---------- Roof correlations ----------
|
||||
\node[font=\scriptsize] at (C-1-2) {$+\!+$}; % H1-H2 strong reinforce
|
||||
\node[font=\scriptsize] at (C-1-3) {$-$}; % H1-H3 mild conflict
|
||||
\node[font=\scriptsize] at (C-1-5) {$+$}; % H1-H5 mild reinforce
|
||||
\node[font=\scriptsize] at (C-1-13) {$-$}; % H1-H13 mild conflict
|
||||
\node[font=\scriptsize] at (C-2-3) {$+\!+$}; % H2-H3 strong reinforce
|
||||
\node[font=\scriptsize] at (C-2-13) {$+$}; % H2-H13
|
||||
\node[font=\scriptsize] at (C-3-13) {$+$}; % H3-H13
|
||||
\node[font=\scriptsize] at (C-4-10) {$-$}; % H4-H10 boot vs binary
|
||||
\node[font=\scriptsize] at (C-5-6) {$+$}; % H5-H6
|
||||
\node[font=\scriptsize] at (C-5-8) {$+$}; % H5-H8
|
||||
\node[font=\scriptsize] at (C-5-9) {$-\!-$}; % H5-H9 soak vs heap
|
||||
\node[font=\scriptsize] at (C-6-7) {$+$}; % H6-H7
|
||||
\node[font=\scriptsize] at (C-6-9) {$-\!-$}; % H6-H9 push vs heap
|
||||
\node[font=\scriptsize] at (C-6-12) {$+\!+$}; % H6-H12
|
||||
\node[font=\scriptsize] at (C-7-9) {$-$}; % H7-H9
|
||||
\node[font=\scriptsize] at (C-7-12) {$+\!+$}; % H7-H12
|
||||
\node[font=\scriptsize] at (C-9-10) {$-\!-$}; % H9-H10 heap vs binary
|
||||
\node[font=\scriptsize] at (C-10-14) {$-\!-$}; % H10-H15 binary vs build
|
||||
\node[font=\scriptsize] at (C-11-13) {$-$}; % H11-H13
|
||||
|
||||
% ---------- Basement: target / abs weight / rel weight % ----------
|
||||
\foreach \c/\tgt/\abs/\rel in {%
|
||||
1/{$\leq$200\,ms}/148/10,
|
||||
2/{$\leq$1 line}/177/11,
|
||||
3/{1 : 20}/144/9,
|
||||
4/{$\leq$5\,s}/62/4,
|
||||
5/{$\geq$1\,h}/111/7,
|
||||
6/{$\geq$95\,\%}/134/9,
|
||||
7/{$\leq$30\,s}/27/2,
|
||||
8/{100\,\%}/156/10,
|
||||
9/{$\geq$1\,MB}/193/12,
|
||||
10/{$\leq$2\,MB}/41/3,
|
||||
11/{$\leq$80\,KB}/45/3,
|
||||
12/{$\leq$30\,s}/153/10,
|
||||
13/{obs.}/137/9,
|
||||
14/{$\leq$7\,min}/29/2%
|
||||
} {
|
||||
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
|
||||
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\abs};
|
||||
\node[font=\scriptsize\bfseries]
|
||||
at ({\c - 0.5}, {-\qfdNW - 2.5}) {\rel};
|
||||
}
|
||||
|
||||
% ---------- Basement row labels (in the margin below WHATs) ----------
|
||||
\foreach \k/\lbl in {1/{Target (v0.1)}, 2/{$\Sigma$ abs}, 3/{Rel.\ \%}}
|
||||
\node[anchor=east, font=\scriptsize\itshape]
|
||||
at ({-0.1}, {-\qfdNW - \k + 0.5}) {\lbl};
|
||||
|
||||
% ---------- Perception zone: 5 products x 14 WHATs (0-5 scores) ----------
|
||||
% Columns: \so=Typoena target, \st=reMarkable 2 + Type Folio,
|
||||
% \sf=Freewrite Traveler, \sg=Pomera DM250,
|
||||
% \sh=Freewrite Smart Typewriter.
|
||||
% Pass 1: stash each score as a named coordinate so the profile lines
|
||||
% below can reuse it without recomputing.
|
||||
\foreach \r/\so/\st/\sf/\sg/\sh in {%
|
||||
1/4/1/4/5/3,
|
||||
2/5/4/4/2/4,
|
||||
3/4/4/2/2/2,
|
||||
4/5/2/2/5/2,
|
||||
5/4/3/4/5/4,
|
||||
6/3/3/4/5/4,
|
||||
7/5/2/5/5/5,
|
||||
8/4/3/4/5/4,
|
||||
9/4/3/2/1/2,
|
||||
10/5/4/2/1/2,
|
||||
11/1/5/5/4/5,
|
||||
12/3/1/2/3/2,
|
||||
13/3/5/2/2/2,
|
||||
14/2/4/5/2/5%
|
||||
} {
|
||||
\pgfmathsetmacro{\xo}{\qfdNH + (\so + 0.5)*\qfdCmpW/6}
|
||||
\pgfmathsetmacro{\xt}{\qfdNH + (\st + 0.5)*\qfdCmpW/6}
|
||||
\pgfmathsetmacro{\xf}{\qfdNH + (\sf + 0.5)*\qfdCmpW/6}
|
||||
\pgfmathsetmacro{\xg}{\qfdNH + (\sg + 0.5)*\qfdCmpW/6}
|
||||
\pgfmathsetmacro{\xs}{\qfdNH + (\sh + 0.5)*\qfdCmpW/6}
|
||||
\coordinate (po-\r) at (\xo, {-\r + 0.5});
|
||||
\coordinate (pr-\r) at (\xt, {-\r + 0.5});
|
||||
\coordinate (pf-\r) at (\xf, {-\r + 0.5});
|
||||
\coordinate (pp-\r) at (\xg, {-\r + 0.5});
|
||||
\coordinate (ps-\r) at (\xs, {-\r + 0.5});
|
||||
}
|
||||
|
||||
% Pass 2: profile lines per alternative. Drawn before markers so the
|
||||
% dots sit on top of (not under) the line endpoints.
|
||||
\draw[qfdalt1ln] (po-1) \foreach \r in {2,...,\qfdNW} { -- (po-\r) };
|
||||
\draw[qfdalt2ln] (pr-1) \foreach \r in {2,...,\qfdNW} { -- (pr-\r) };
|
||||
\draw[qfdalt3ln] (pf-1) \foreach \r in {2,...,\qfdNW} { -- (pf-\r) };
|
||||
\draw[qfdalt4ln] (pp-1) \foreach \r in {2,...,\qfdNW} { -- (pp-\r) };
|
||||
\draw[qfdalt5ln] (ps-1) \foreach \r in {2,...,\qfdNW} { -- (ps-\r) };
|
||||
|
||||
% Pass 3: markers on top of the lines.
|
||||
\foreach \r in {1,...,\qfdNW} {
|
||||
\node[qfdalt1mk] at (po-\r) {};
|
||||
\node[qfdalt2mk] at (pr-\r) {};
|
||||
\node[qfdalt3mk] at (pf-\r) {};
|
||||
\node[qfdalt4mk] at (pp-\r) {};
|
||||
\node[qfdalt5mk] at (ps-\r) {};
|
||||
}
|
||||
|
||||
% ---------- Manual legend (5 alternatives, placed right of zones) ----------
|
||||
\pgfmathsetmacro{\qfdLegX}{\qfdNH + \qfdCmpW + 0.7}
|
||||
\begin{scope}[shift={(\qfdLegX, \qfdHdrH - 0.4)}]
|
||||
\draw[qfdmed, rounded corners=2pt]
|
||||
(-0.15, 0.4) rectangle (5.1, -7.50);
|
||||
% Relations
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
|
||||
{Relation};
|
||||
\draw[qfdthin] (0, -0.15) -- (4.95, -0.15);
|
||||
\node[qfdstrong] at (0.22, -0.5) {};
|
||||
\node[anchor=west] at (0.5, -0.5) {Strong (9)};
|
||||
\node[qfdmod] at (0.22, -0.95) {};
|
||||
\node[anchor=west] at (0.5, -0.95) {Medium (3)};
|
||||
\node[qfdweak] at (0.22, -1.4) {};
|
||||
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
|
||||
% Correlation
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
|
||||
{Correlation};
|
||||
\draw[qfdthin] (0, -2.35) -- (4.95, -2.35);
|
||||
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
|
||||
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
|
||||
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
|
||||
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
|
||||
% Perception
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, -4.20)
|
||||
{Perception};
|
||||
\draw[qfdthin] (0, -4.45) -- (4.95, -4.45);
|
||||
\draw[qfdalt1ln] (0.05, -4.80) -- (0.45, -4.80);
|
||||
\node[qfdalt1mk] at (0.25, -4.80) {};
|
||||
\node[anchor=west, font=\bfseries] at (0.55, -4.80)
|
||||
{Typoena (v0.1 target)};
|
||||
\draw[qfdalt2ln] (0.05, -5.25) -- (0.45, -5.25);
|
||||
\node[qfdalt2mk] at (0.25, -5.25) {};
|
||||
\node[anchor=west] at (0.55, -5.25) {reMarkable 2 + Type Folio};
|
||||
\draw[qfdalt3ln] (0.05, -5.70) -- (0.45, -5.70);
|
||||
\node[qfdalt3mk] at (0.25, -5.70) {};
|
||||
\node[anchor=west] at (0.55, -5.70) {Freewrite Traveler};
|
||||
\draw[qfdalt5ln] (0.05, -6.15) -- (0.45, -6.15);
|
||||
\node[qfdalt5mk] at (0.25, -6.15) {};
|
||||
\node[anchor=west] at (0.55, -6.15) {Freewrite Smart Typewriter};
|
||||
\draw[qfdalt4ln] (0.05, -6.60) -- (0.45, -6.60);
|
||||
\node[qfdalt4mk] at (0.25, -6.60) {};
|
||||
\node[anchor=west] at (0.55, -6.60) {Pomera DM250};
|
||||
\node[anchor=west, font=\scriptsize\itshape] at (0, -7.15)
|
||||
{0 = poor, 5 = excellent};
|
||||
\end{scope}
|
||||
|
||||
\end{qfdhouse}
|
||||
\end{document}
|
||||
```
|
||||
|
||||
## Perception scores (guessed)
|
||||
|
||||
Five products on the 0–5 scale, scored against each WHAT. Reference
|
||||
configurations: **reMarkable 2 + Type Folio**, **Freewrite Traveler**,
|
||||
**Freewrite Smart Typewriter**, **Pomera DM250** (DM250 has a reflective
|
||||
monochrome LCD, not e-ink — flagged in W1 / W8). "Typoena" is the v0.1
|
||||
target from `qfd.md` §2, not measured yet.
|
||||
|
||||
Freewrite Traveler scores assume the
|
||||
[Sailfish firmware](https://getfreewrite.com/blogs/writing-success/freewrite-sailfish-firmware)
|
||||
(released 2025-11-19), which rewrote the OS in Rust, cut keystroke latency
|
||||
40–100 %, and trimmed power draw −30 % typing / −50 % idle on both
|
||||
Traveler and Smart Typewriter Gen 3. Three rows rescored upward as a
|
||||
result: W1 Traveler 3→4 / Smart 2→3 (Smart's larger panel still trails
|
||||
Traveler by one notch), W5 both 3→4 (boot accelerated, no published
|
||||
number), W9 both 1→2 (Rust rewrite explicitly unblocked features that
|
||||
JS could not carry; still closed so neither reaches reMarkable's
|
||||
hackable-Linux 3).
|
||||
|
||||
| ID | WHAT (truncated) | Typoena | reM. | Frw.T | Frw.S | Pom. | Rationale (shortest defensible) |
|
||||
| --- | ------------------------------------------------- | :-----: | :--: | :---: | :---: | :--: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| W1 | Sub-second response to typing | 4 | 1 | 4 | 3 | 5 | Typoena targets ≤200 ms; reMarkable e-ink visibly laggy on a typing-focused device — tested less responsive than Smart Typewriter, and latency is so load-bearing for W1 that it earns a 1 not a 2; both Freewrites post-Sailfish trimmed latency 40–100 % (Frw.T plausibly inside 200 ms; Frw.S still trails by one notch on larger panel); Pomera LCD ~zero. |
|
||||
| W2 | Publishing is one deliberate action away | 5 | 4 | 4 | 4 | 2 | Ctrl-G atomic; reMarkable + Freewrite cloud-sync is one-tap but not git; Pomera = USB/SD copy or QR transfer. |
|
||||
| W3 | Pulling power never corrupts the file | 4 | 4 | 2 | 2 | 2 | Typoena: atomic-rename + fsync. reMarkable journals. Freewrite + Pomera: forum reports of corruption on yank. |
|
||||
| W4 | Provisioning never interrupts writing | 5 | 2 | 2 | 2 | 5 | Typoena v0.1: build-time config (dev-only). reM/Frw need Wi-Fi + account. Pomera: literally none. |
|
||||
| W5 | Quick boot to a writing cursor | 4 | 3 | 4 | 4 | 5 | Typoena target ≤5 s. reMarkable cold-boots ~20 s (great from sleep). Both Freewrites accelerated post-Sailfish (no published number; were ~10–15 s e-ink wake). Pomera ~3 s. |
|
||||
| W6 | Long sessions without crash / lag / drift | 3 | 3 | 4 | 4 | 5 | Typoena unproven (1 h target). Freewrite famously stable (both variants). Pomera firmware is decades-mature. |
|
||||
| W7 | Nothing on the device competes with prose | 5 | 2 | 5 | 5 | 5 | reMarkable has apps, menus, drawing, PDFs. Freewrite + Pomera are single-purpose; Typoena by design. |
|
||||
| W8 | The UI never moves except when I move it | 4 | 3 | 4 | 4 | 5 | reMarkable animates more; Typoena uses dirty-rects; Freewrites minimal motion; Pomera near-static LCD. |
|
||||
| W9 | Codebase absorbs the planned roadmap | 4 | 3 | 2 | 2 | 1 | Modular Rust Typoena; reMarkable is hackable Linux; both Freewrites carry Sailfish (Rust rewrite explicitly unblocked features JS could not carry) but closed; Pomera closed firmware. |
|
||||
| W10 | I can repair or fork it with hobbyist tools | 5 | 4 | 2 | 2 | 1 | Typoena: open BOM + ESP32. reMarkable: rooted Linux + community ROMs. Freewrite + Pomera: closed. |
|
||||
| W11 | Multi-day battery life (v0.8 onward) | 1 | 5 | 5 | 5 | 4 | Typoena v0.1 = wall-powered (battery deferred). reMarkable + both Freewrites legendary (~4 weeks; Sailfish trimmed −30 % typing / −50 % idle). Pomera ~24 h. |
|
||||
| W12 | Local-only files coexist with git scope | 3 | 1 | 2 | 2 | 3 | Typoena v0.5+ design. reMarkable cloud-only. Freewrites have local + Postbox but no VCS. Pomera = pure local. |
|
||||
| W13 | Typography sets a writing-tool tone | 3 | 5 | 2 | 2 | 2 | Typoena v0.1: single mono (serif option in v1.0). reMarkable: rich type rendering. Freewrite + Pomera: utilitarian. |
|
||||
| W14 | I can carry the device and write away from a desk | 2 | 4 | 5 | 1 | 5 | Typoena v0.1 wall-powered (ADR-008), no enclosure spec yet — desk-bound by design. reMarkable + Type Folio bag-friendly with bulk. Freewrite Traveler is the form-factor reference (~1.6 lb, folds). Smart Typewriter ~5 lb, desk-bound. Pomera DM250 pocketable foldable. |
|
||||
|
||||
**Totals** (sum across 14 WHATs, no weighting): Typoena 52, Pomera 50,
|
||||
Freewrite Traveler 47, reMarkable 44, Freewrite Smart Typewriter 42
|
||||
(Traveler pre-Sailfish 44; Smart pre-Sailfish 39; reMarkable W1 dropped
|
||||
3→2→1 across two rounds of author testing — first to 2 after firsthand
|
||||
typing, then to 1 once latency was recognised as the dominant W1
|
||||
signal). Pomera closing to within 2 of
|
||||
Typoena is W14 doing what W14 should — surfacing the dimension on
|
||||
which v0.1's tethered MVP loses ground that v0.8 is expected to
|
||||
recover. The "Pomera + Wi-Fi + git + hackable BOM" framing from
|
||||
`README.md` still holds and reads stronger.
|
||||
|
||||
Weighted totals (Σ score × W weight) tell the same story with more
|
||||
contrast — left as exercise; the unweighted view is enough to read the
|
||||
picture.
|
||||
|
||||
### Caveats
|
||||
|
||||
- **Single-rater bias.** All thirteen rows are scored from the project
|
||||
author's POV. A reMarkable buyer would weight W11 (battery) at 10 and
|
||||
W12 (git) at 1, flipping the totals.
|
||||
- **Configuration matters.** Freewrite Smart Typewriter and Traveler are
|
||||
both tracked; they diverge most on W1 / W5 because of display tech
|
||||
(Smart's larger panel is slower to refresh). Traveler is still the
|
||||
more direct competitor on form factor.
|
||||
- **W3 / W6 Freewrite scores are anecdotal.** Forum reports, not bench
|
||||
data. Treat the 2 / 4 as "we'd need to test this" rather than fact.
|
||||
- **No price column.** Typoena-as-BOM is materially cheaper than the
|
||||
competitors but cost is not a WHAT in `qfd.md` §1, so it's absent here.
|
||||
Worth a row if a v0.x WHAT ever calls it out.
|
||||
|
||||
## Reading the house
|
||||
|
||||
- **Importance (left column)** is the raw 1–10 weight from `qfd.md` §1, not
|
||||
a normalised %, so adding stays cheap when a WHAT shifts. Sum of weights
|
||||
is 103; treat each unit as ~0.97 % if you want a percentage view.
|
||||
- **Roof** carries the §4 symbols translated into classical QFD glyphs:
|
||||
`++` strong reinforcement (`◎`), `+` mild reinforcement (`○`), `−` mild
|
||||
conflict (`×`), `−−` strong conflict (`⊗`).
|
||||
- **Basement rows** are: v0.1 target → §3 column sum (`Σ` of
|
||||
`weight × strength`) → relative weight as integer % of total (1557).
|
||||
Relative weights round to 100.
|
||||
- **H7, H10, H15** (Publish latency, binary size, build time) sit at the bottom
|
||||
of the basement — knowingly-paid costs per `qfd.md` §7, not signals to
|
||||
optimise harder.
|
||||
|
||||
## Regenerating
|
||||
|
||||
The matrix cells (`\node[qfdrel/{S,M,W}]`), roof symbols (`C-i-j` slots),
|
||||
and basement Σ + Rel% are native to this file — re-score directly in the
|
||||
TikZ source, then update the §3 priority list and §4 conflict list in
|
||||
`qfd.md` to match the new picture.
|
||||
|
||||
When `qfd.md` §1 or §2 changes:
|
||||
|
||||
1. Importance column → §1 weight column.
|
||||
2. HOW titles + v0.1 targets → §2 target column.
|
||||
3. Recompute basement Σ for any HOW whose column changed: per-cell
|
||||
contribution = `(W weight) × (cell strength: 9 / 3 / 1 / 0)`.
|
||||
4. Recompute relative weight: each Σ ÷ total Σ × 100, rounded to integer
|
||||
percent.
|
||||
|
||||
Perception scores are **not** derived from `qfd.md` — they live only in
|
||||
this file. Update them when (a) a competitor ships a relevant change,
|
||||
(b) measurement replaces a guess, or (c) a WHAT is added/removed in §1.
|
||||
Each score keeps its one-line rationale in the table above.
|
||||
|
||||
If a renderer rejects the `tikz` fence, the file is still readable as
|
||||
source — the placement comments name each WHAT, HOW, and cell. The
|
||||
perception-scores table above is the human-readable fallback for the
|
||||
right-hand zone of the diagram.
|
||||
108
docs/roadmap.md
108
docs/roadmap.md
@@ -1,108 +0,0 @@
|
||||
# Roadmap — version details
|
||||
|
||||
Frequent releases. Each version is a usable artifact, not a checkpoint.
|
||||
The macro-plan (Gantt) lives in the [README](../README.md#roadmap); this file
|
||||
holds the per-version scope. The user-facing requirements and engineering
|
||||
targets each release feeds into are tracked in [`qfd.md`](qfd.md).
|
||||
|
||||
---
|
||||
|
||||
## v0.1 — MVP: "it writes, it pushes" — [ ]
|
||||
|
||||
The minimum thing that justifies the hardware existing. Full design:
|
||||
[product](v0.1-mvp-product.md) · [technical](v0.1-mvp-technical.md).
|
||||
|
||||
- [ ] ESP32-S3 boots, e-ink shows Typoena splash + boot log
|
||||
- [ ] USB host enumerates the Nuphy, key events reach the editor
|
||||
- [ ] One hard-coded file (`/sd/repo/notes.md`) opens on boot
|
||||
- [ ] Insert-only editing (no modes yet), backspace, enter, arrow keys
|
||||
- [ ] Line wrap, no line numbers yet
|
||||
- [ ] Save on `Ctrl-S` → SD
|
||||
- [ ] Wi-Fi credentials + remote URL + PAT + author baked into the binary at
|
||||
build time via env vars (no NVS, no on-device provisioning UI in v0.1)
|
||||
- [ ] `Ctrl-G` runs: `git add .` → commit with an ISO-8601 timestamp message →
|
||||
`git push`; on push failure, `git pull --no-edit` then retry the push
|
||||
(no-op short-circuit when nothing is staged). PAT from first-run setup.
|
||||
- [ ] Partial refresh on edits; full refresh on save
|
||||
|
||||
Out of scope: Vim, palette, multiple files, branches, conflict handling.
|
||||
|
||||
## v0.2 — Vim navigation — [ ]
|
||||
|
||||
- [ ] Mode state machine (Normal / Insert), mode indicator in status line
|
||||
- [ ] Movement: `h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d Ctrl-u`
|
||||
- [ ] `i a o O A` to enter Insert
|
||||
- [ ] `Esc` returns to Normal
|
||||
- [ ] Line numbers in the left gutter: relative in Normal mode (current line
|
||||
shown as its absolute number), absolute in Insert mode
|
||||
|
||||
## v0.3 — Vim editing — [ ]
|
||||
|
||||
- [ ] `x dd yy p P`, `dw dd d$`, repeat with `.`
|
||||
- [ ] Undo / redo (`u`, `Ctrl-r`) — bounded history in PSRAM
|
||||
- [ ] Numeric prefixes (`3dd`, `5j`)
|
||||
|
||||
## v0.4 — Visual mode + ex commands — [ ]
|
||||
|
||||
- [ ] Visual char (`v`) and line (`V`) modes, `y d c` on selections
|
||||
- [ ] `:` command line: `:w :q :wq :e <path>`
|
||||
- [ ] Status line shows file path, dirty flag, mode
|
||||
|
||||
## v0.5 — File palette + multi-file — [ ]
|
||||
|
||||
- [ ] `Ctrl-P` opens fuzzy file palette over **both** `/sd/repo/` and
|
||||
`/sd/local/`, with a scope marker (e.g. `[git]` / `[local]`) per result
|
||||
- [ ] Open, switch, close buffers (keep ≤ 3 in memory)
|
||||
- [ ] `:e` and palette share the same recent-files list
|
||||
- [ ] `:enew` creates a new file — prompts for scope (tracked vs local)
|
||||
- [ ] `Ctrl-G` is disabled / hidden when the current buffer is local-scope
|
||||
- [ ] Status line briefly shows file count on `Ctrl-G` when the publish bundles
|
||||
more than one dirty Tracked file (e.g. `"publishing 3 files: abc1234"`),
|
||||
so workspace-scoped behaviour stays visible to the user
|
||||
|
||||
## v0.6 — Markdown affordances — [ ]
|
||||
|
||||
- [ ] Heading lines bolded in render
|
||||
- [ ] List continuation on Enter inside `- ` / `1. `
|
||||
- [ ] Soft-wrap at word boundaries
|
||||
- [ ] Optional column ruler at 80
|
||||
|
||||
## v0.7 — Search + better git — [ ]
|
||||
|
||||
- [ ] `/` forward search, `n N`
|
||||
- [ ] `:Gpull` (fetch + fast-forward only; refuse on conflict and surface it)
|
||||
|
||||
## v0.8 — Power: battery + sleep — [ ]
|
||||
|
||||
- [ ] Measure idle / typing / push current draw on bench
|
||||
- [ ] 18650 + IP5306 charge board, soft power switch
|
||||
- [ ] Light sleep on idle > 30 s (keyboard interrupt wakes)
|
||||
- [ ] Deep sleep on lid close (reed switch); restore cursor + buffer
|
||||
- [ ] Battery indicator in status line
|
||||
|
||||
## v0.9 — Robustness — [ ]
|
||||
|
||||
- [ ] Crash-safe writes (write to `.tmp`, fsync, rename)
|
||||
- [ ] Recover from interrupted push (re-attempt on next save)
|
||||
- [ ] SD card removal / reinsert handling
|
||||
- [ ] Wi-Fi reconnect with backoff
|
||||
- [ ] On-device provisioning + settings screen: SSID, PAT rotation, default
|
||||
remote, commit author (replaces the v0.1 dev-only NVS-flashing path —
|
||||
first release usable by someone who is not the firmware author)
|
||||
|
||||
## v1.0 — Polish — [ ]
|
||||
|
||||
- [ ] Boot time ≤ 3 s to usable cursor
|
||||
- [ ] Font selection (at least one serif + one mono) with adjustable font
|
||||
size, switchable at runtime and persisted across reboots
|
||||
- [ ] Theme: light / dark (inverted e-ink), switchable at runtime and
|
||||
persisted across reboots
|
||||
- [ ] Enclosure design files in `hardware/`
|
||||
- [ ] User guide
|
||||
|
||||
## v1.x — Stretch / nice-to-have
|
||||
|
||||
- 10.3" panel upgrade via IT8951
|
||||
- Multiple remotes / repos
|
||||
- Stats: words today, streak
|
||||
- BLE-HID fallback for wireless keyboards
|
||||
155
docs/spikes.md
Normal file
155
docs/spikes.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Rendering & UX spikes
|
||||
|
||||
> Scope: bench experiments that retire **display/UX** risk, run after the v0.1
|
||||
> hardware bring-up spikes (1–7) in
|
||||
> [`v0.1-mvp-technical.md`](v0.1-mvp-technical.md#hardware-bring-up-order).
|
||||
> Unlike 1–7, these are **not** a v0.1 integration gate, and each feeds a
|
||||
> different release (noted per spike).
|
||||
>
|
||||
> They ride on the `epd::Frame` `DrawTarget` and the Spike 5 partial-refresh
|
||||
> path. Project overview: [`../README.md`](../README.md). Vocabulary:
|
||||
> [`../CONTEXT.md`](../CONTEXT.md). Release sequence: [`macroplan.md`](macroplan.md).
|
||||
|
||||
These prove display/UX risks, not stack risks, so they sit outside the 1–7
|
||||
"prove before integration" gate. **Run Spike 8 first:** it partitions the panel
|
||||
into the writing column and the side panel that the later spikes (scrollbar,
|
||||
line-number gutter, status readouts) draw within and around. Each feeds a
|
||||
different release, but the bench experiment is worth running now to retire the
|
||||
risk early.
|
||||
|
||||
| Spike | Feeds |
|
||||
| --------------------------------------- | --------------------------------------------------- |
|
||||
| 8 — Layout: side panel + writing column | v0.1 |
|
||||
| 9 — Boot splash bitmap | v0.1 |
|
||||
| 10 — Dark / light theme | v1.0 |
|
||||
| 11 — Transient panel (help / config) | v0.4 `:` · v0.5 palette · v0.9 settings (mechanism) |
|
||||
| 12 — Scroll position indicator | reading / `View`-mode UX |
|
||||
| 13 — Line-number gutter | v0.2 |
|
||||
| 14 — Multi-file navigation | v0.5 |
|
||||
|
||||
8. **Spike 8 — Layout: side panel + writing column.** Split the 792×272 landscape
|
||||
into a full-height **writing column** (~60 cols, left) and an always-visible
|
||||
**side panel** (~150 px / ~20 cols, right), per the
|
||||
[Screen layout](v0.1-mvp-product.md#screen-layout). Today the editor fills the
|
||||
full 79-col width with text and a cramped 12 px bottom status
|
||||
([`firmware/src/editor.rs`](../firmware/src/editor.rs), `COLS`/`ROWS` +
|
||||
`draw_status`); this spike narrows the text to a legible measure and moves all
|
||||
metadata into the panel, retiring the header/status bands entirely. Two driver
|
||||
facts shape it. First, narrowing does _not_ speed up typing: `update_part`
|
||||
already drives both controllers full width, windowed only in Y, because "the
|
||||
waveform time dominates, not the data clock-out"
|
||||
([`firmware/src/epd.rs`](../firmware/src/epd.rs)) — the win is line length and
|
||||
persistent info, not latency. Second, since every partial refresh spans the full
|
||||
width, a keystroke's windowed-Y band repaints the panel's pixels on that row
|
||||
_for free_ (redrawn identically), but a panel field that changes on a _different_
|
||||
row than the cursor costs a **second** windowed-Y band — burning the "20 partials
|
||||
→ forced full refresh" ghosting counter twice as fast (render module). That sorts
|
||||
what the panel may hold: static (filename, dirty), event-driven (mode, Wi-Fi,
|
||||
keyboard-disconnect, publish state), and throttled (clock, word count, session)
|
||||
fields only — nothing that repaints per keystroke, so no live cursor _column_.
|
||||
The panel sits entirely in the master half (right of the `x = 396` seam), so its
|
||||
glyphs never split the seam; the writing column still straddles it, as today.
|
||||
Prove: render column + panel; type and confirm the windowed-Y refresh stays a
|
||||
single band (panel redraws in place, no second region); then update a throttled
|
||||
field (word count on a typing pause) and a discrete one (mode flip) and confirm
|
||||
each is one isolated band that forces no extra full refresh. Decides the
|
||||
column/panel split in pixels, the panel's field layout and update cadence, and
|
||||
whether the ghosting counter needs per-region accounting. (Feeds v0.1's screen
|
||||
layout — the writing column + side panel the product doc now draws.)
|
||||
|
||||
9. **Spike 9 — Boot splash bitmap.** Embed the Typoena logo as a 1-bit asset and
|
||||
blit it, with the build tag, at boot before the editor opens. Spike 2 already
|
||||
proved vector + font rendering, so the only new risk is the image-asset
|
||||
pipeline (embedding + blit) and that a large near-full-frame image takes a
|
||||
clean full refresh. Mostly a feature — kept as a spike only to prove the
|
||||
asset path. (Feeds v0.1's "e-ink shows Typoena splash + boot log".)
|
||||
|
||||
**Built 2026-07-11 as a _vector_ splash**, not a bitmap. The frame is
|
||||
[`display::Frame::splash`](../display/src/lib.rs) — the `typoena` wordmark
|
||||
centred inside a stroked `Circle`, drawn with `embedded-graphics`, one clean
|
||||
full refresh. It is shared by two callers: the
|
||||
[`splash`](../firmware/src/bin/splash.rs) bench binary (`just flash-splash`)
|
||||
and **`main.rs`'s boot path**, which shows it right after EPD init — replacing
|
||||
the old white-clear baseline — while the SD mounts and the note loads, then a
|
||||
second full refresh brings up the editor. Nothing is embedded, so the
|
||||
image-asset pipeline named above is deliberately **left unproven** — an
|
||||
acceptable trade because Spike 2 already covered vector + font rendering.
|
||||
**Proposition, deferred to end-of-project polish (v1.0):** replace the vector
|
||||
mark with an embedded 1-bit raster logo, which is when the asset-embed/blit
|
||||
path would finally be exercised. **Confirmed on the panel 2026-07-11** — the
|
||||
splash renders cleanly at boot, then the editor comes up. This closed the last
|
||||
v0.1 display gate; v0.1 shipped the same day.
|
||||
|
||||
10. **Spike 10 — Dark / light theme.** Invert the 1-bit framebuffer (white on
|
||||
black) and refresh. The invert is trivial (XOR at blit); the real unknowns
|
||||
are (a) ghosting and refresh time on a predominantly-black panel — full-black
|
||||
waveforms stress the panel differently — and (b) whether partial-refresh
|
||||
ghosting accumulates faster in dark mode, forcing a more frequent full
|
||||
refresh. Prove: legible inverted text, acceptable ghosting, and
|
||||
partial-refresh latency unchanged from Spike 5. (Feeds v1.0's light/dark
|
||||
theme.)
|
||||
|
||||
11. **Spike 11 — Transient panel (help / config).** Prove a modal full-screen
|
||||
view that swaps in over the editor and restores the buffer exactly on exit —
|
||||
the shared primitive behind a help screen, the v0.9 settings screen, the v0.5
|
||||
file palette (`Ctrl-P`), and the v0.4 `:` command line. The risk is
|
||||
e-ink-specific: entering/leaving with partial refresh leaves ghosting, and
|
||||
the editor viewport underneath must return artifact-free. Prove with a static
|
||||
help screen (trivial content): a full refresh on enter and on exit restores
|
||||
the prior text exactly; measure in/out latency. Decides overlay-box vs.
|
||||
full-screen swap — recommend the swap, since a dimmed partial overlay is both
|
||||
slow and ghost-prone at ~630 ms. Panel _content_ (which config keys, help
|
||||
text) is feature work deferred to the owning release; this spike proves only
|
||||
the mechanism.
|
||||
|
||||
12. **Spike 12 — Scroll position indicator.** The buffer already scrolls (`View`
|
||||
mode `j`/`k`/`space`); this spike proves how to _show_ position without
|
||||
wrecking latency. Measure two affordances on the bench: (a) a right-edge
|
||||
scrollbar repainted each scroll step, and (b) a compact side-panel readout
|
||||
(`34/128` or `27%`). Risk: the scrollbar is a tall pixel column repainted on
|
||||
every scroll — measure its partial-refresh cost against the small
|
||||
side-panel field, on the windowed-Y path, during held-key scroll. Prove:
|
||||
pick the affordance whose refresh cost is acceptable and confirm it forces no
|
||||
extra full refreshes. (Feeds the reading/`View`-mode UX; the a-vs-b choice is
|
||||
a design decision this spike hands data to.)
|
||||
|
||||
13. **Spike 13 — Line-number gutter.** Draw a fixed-width digit column left of
|
||||
the text area. **Absolute numbering only** — relative numbering was dropped
|
||||
(2026-07-11). It renumbered the _entire visible gutter on every `j`/`k`_, so
|
||||
a single cursor move became a partial refresh of a tall digit column,
|
||||
straight into the Spike 5 windowed-Y path and the "20 partials → forced full
|
||||
refresh" ghosting counter ([`firmware/src/epd.rs`](../firmware/src/epd.rs),
|
||||
render module) — real e-ink cost for no proportionate gain on a
|
||||
distraction-first panel. Dropping it removes the genuine e-ink risk this
|
||||
spike existed to prove: absolute numbering is cheap — only the rows below an
|
||||
inserted/deleted line renumber. What remains is a **layout decision**, not a
|
||||
latency one: the gutter steals horizontal columns from the render-time
|
||||
soft-wrap, and wrapped continuation rows have no number (blank vs. tilde).
|
||||
Prove: reserve the gutter width, then confirm a single-line edit repaints
|
||||
only the rows at/below the change and forces no extra full refresh. (Feeds
|
||||
v0.2's line-number gutter; now low-risk after the relative drop.)
|
||||
**VERIFIED on the panel 2026-07-11** — a single-line edit repaints only the
|
||||
rows at/below the change, no extra full refresh; closes the last v0.2 gate.
|
||||
|
||||
14. **Spike 14 — Multi-file navigation (open / switch / new / delete).** The
|
||||
panel _mechanism_ is already Spike 11 (which names this v0.5 file palette),
|
||||
and filename entry reuses the v0.4 `:` command line — so this spike proves
|
||||
neither, and depends on both. What is genuinely new is **persistence and
|
||||
buffer lifecycle**, not rendering: enumerate a FAT directory across _both_
|
||||
`/sd/repo` and `/sd/local`, keep ≤ 3 ropes resident, and on switch swap the
|
||||
active rope + cursor + soft-wrap after an atomic save-of-current
|
||||
(persistence module). Two sharp edges. **New file** (`:enew`) must prompt
|
||||
for scope — tracked (`/sd/repo`, pushed) vs. local (`/sd/local`, `Ctrl-G`
|
||||
disabled). **Delete** extends v0.5 scope (the roadmap lists `:enew` but not
|
||||
delete): removing the currently-open buffer must close it and fall back to
|
||||
another resident buffer or an empty one, and — the load-bearing check — the
|
||||
FAT unlink must reach the next Publish's _staged_ set, which the git
|
||||
module's `git add .`-equivalent staging may not catch for removals (needs
|
||||
`git rm` / `add -A` semantics —
|
||||
[`git` module](v0.1-mvp-technical.md#git--commit--push)). Prove:
|
||||
open/switch/new/delete across both roots with a correct ≤ 3-buffer
|
||||
lifecycle, a delete that propagates into the following commit, and an
|
||||
artifact-free full-refresh swap on every buffer change (leaning on Spike
|
||||
11). Latency is not the risk here; buffer-state and git-staging correctness
|
||||
are. (Feeds v0.5 multi-file, delete included — now authorized in
|
||||
[macroplan](macroplan.md#v05--file-palette--multi-file--) v0.5.)
|
||||
14
docs/tradeoff-curves/README.md
Normal file
14
docs/tradeoff-curves/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Tradeoff curves
|
||||
|
||||
> Where a design knob has a cost that bends — energy, latency, memory — against
|
||||
> an interval or size, the curve and its knee live here, so the chosen default
|
||||
> is traceable to a shape rather than a guess.
|
||||
>
|
||||
> Docs index: [`../README.md`](../README.md). Project overview:
|
||||
> [`../../README.md`](../../README.md).
|
||||
|
||||
| Curve | What it decides |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`wifi-auto-sync.md`](wifi-auto-sync.md) | `auto_sync` interval vs Wi-Fi energy (a `1/T` hyperbola) — why the default is 10 min and opportunistic, not a wall-clock timer. |
|
||||
| [`epd-refresh-latency.md`](epd-refresh-latency.md) | E-ink refresh latency vs rows driven — the full / full-area-partial / windowed-Y cost model behind typing responsiveness and the boot splash→editor swap. |
|
||||
| [`sync-commit-staging.md`](sync-commit-staging.md) | Commit-staging strategy vs working-tree size — RESOLVED: every index-based path is O(N_tree) and fails on the real repo (611 s / OOM); the O(depth) TreeBuilder splice is benched at ~2–2.8 s and ships. Holds the full measurement trail plus the firmware plumbing plan (merged from the retired handoff note). |
|
||||
117
docs/tradeoff-curves/epd-refresh-latency.md
Normal file
117
docs/tradeoff-curves/epd-refresh-latency.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# E-ink refresh latency vs rows driven
|
||||
|
||||
> **Model:** on this GDEY0579T93 (SSD1683 dual-controller) panel, refresh time is
|
||||
> set by **how many gate lines (rows, the Y axis) are driven** and **which
|
||||
> waveform LUT runs** — _not_ by the pixel column width. Three refresh modes fall
|
||||
> out of that: full refresh (~1870 ms), full-area partial (~630 ms), and
|
||||
> windowed-Y partial (~100–130 ms for one text line). This is the cost model
|
||||
> behind per-keystroke typing, the boot splash→editor swap
|
||||
> ([`../notes/boot-time-budget.md`](../notes/boot-time-budget.md)), and the
|
||||
> scroll/gutter spikes.
|
||||
>
|
||||
> Tradeoff-curves index: [`README.md`](README.md). Docs index:
|
||||
> [`../README.md`](../README.md). Driver:
|
||||
> [`../../firmware/src/epd.rs`](../../firmware/src/epd.rs). Bench origin:
|
||||
> [`../spikes.md`](../spikes.md) (Spikes 5 + 8).
|
||||
|
||||
## The model
|
||||
|
||||
A refresh is three serial costs:
|
||||
|
||||
```
|
||||
set RAM window → clock the pixels out over SPI → run the update waveform
|
||||
(fixed) (scales with bytes = rows) (scales with gate lines
|
||||
AND with which LUT)
|
||||
```
|
||||
|
||||
Two things do **not** help, and one thing does:
|
||||
|
||||
- **Column width (X) is free to keep full.** The panel is a master (`0x00`) +
|
||||
slave (`0x80`) pair with the framebuffer split at the seam; every refresh
|
||||
drives _both_ controllers full width so the seam/mirror math stays intact
|
||||
(`update_part` / `write_frame_bank` in
|
||||
[`epd.rs`](../../firmware/src/epd.rs)). Narrowing the _column_ saves nothing —
|
||||
"the waveform time dominates, not the data clock-out."
|
||||
- **Row count (Y) is the knob.** E-paper drive time scales with the number of
|
||||
gate lines transitioned, so restricting the refresh to a horizontal band of
|
||||
rows is the real win — a one-line band is far cheaper than the whole panel.
|
||||
- **The LUT sets the tier.** A _partial_ update (`0x22`←`0xFF`) runs a short
|
||||
waveform that only nudges pixels that changed. A _full_ update (`0x22`←`0xD7`,
|
||||
the fast-full LUT) runs ~3× as many frames per pixel to fully clear-and-set —
|
||||
slower, but it erases ghosting and re-establishes a known image.
|
||||
|
||||
Within partial mode the latency is roughly linear in rows:
|
||||
|
||||
```
|
||||
t_partial(rows) ≈ 90 ms + 2 ms · rows
|
||||
└ floor ┘ └ per-gate-line slope ┘
|
||||
```
|
||||
|
||||
Floor ≈ fixed SPI setup + border/VCOM commands + waveform ramp; slope ≈ the
|
||||
per-row waveform drive. Full refresh sits in its own flat tier (~1870 ms, all 272
|
||||
rows, full-clear LUT) and **cannot be windowed** — the clear waveform needs the
|
||||
whole panel.
|
||||
|
||||
## The curve
|
||||
|
||||
```
|
||||
Partial-refresh latency vs rows driven (Y-band) t ≈ 90 ms + 2 ms · rows
|
||||
|
||||
ms
|
||||
1870 |========================================= FULL refresh — separate tier:
|
||||
| all 272 rows, full-clear LUT,
|
||||
| un-windowable. ~3× the partial
|
||||
| waveform. De-ghosts + seeds a
|
||||
| known image.
|
||||
|
|
||||
630 | * ← full-area partial (272 rows)
|
||||
| . ·
|
||||
| . ·
|
||||
| . · slope ≈ 2 ms / gate line
|
||||
300 | . ·
|
||||
| . ·
|
||||
110 | * ← one text line (~10 rows): the per-keystroke path
|
||||
90 | * ← floor (SPI setup + border/VCOM + waveform ramp)
|
||||
+----+----+----+----+----+----+----+----+----+----+----+---
|
||||
0 25 50 75 100 125 150 175 200 225 250 272 rows
|
||||
```
|
||||
|
||||
| Mode | Rows driven | Latency | LUT | Used for |
|
||||
| ------------------ | -----------: | -------------------------------------: | ---------- | ---------------------------------------------------------------------------------- |
|
||||
| Full refresh | 272 (all) | **~1870 ms** (measured) | full-clear | first cold-boot image; periodic de-ghost (every `FULL_REFRESH_EVERY` = 64 updates) |
|
||||
| Full-area partial | 272 | **~630 ms** (measured; 680 ms at boot) | partial | deletes, caret moves, mode switches, the snackbar, and the boot splash→editor swap |
|
||||
| Windowed-Y partial | ~10 (1 line) | **~100–130 ms** (estimated¹) | partial | additive per-keystroke typing |
|
||||
|
||||
¹ The single-line windowed figure is projected from the floor+slope model and the
|
||||
Spike 5 full-area measurement; the exact bench number is still to be confirmed
|
||||
from the on-device refresh log (`{mode} refresh #N … {ms} ms` in
|
||||
[`main.rs`](../../firmware/src/main.rs)). The 1870 ms full and 630 ms full-area
|
||||
figures are measured.
|
||||
|
||||
## Two things that bound it
|
||||
|
||||
**Ghosting caps the partial streak.** Partial updates leave faint residue, so a
|
||||
full refresh every 64 updates resets clarity and panel state. You can't "always
|
||||
partial" — the ~1870 ms tier is a periodic tax paid for longevity, not a mode you
|
||||
can retire.
|
||||
|
||||
**The first cold-boot image must be a full refresh.** After power-on the `0x26`
|
||||
"previous" bank holds garbage, and a partial refresh _diffs against it_ — so the
|
||||
very first clean paint has to be the full tier. This is why boot pays exactly one
|
||||
unavoidable ~1.9 s full refresh, and why the splash (which rides it) is nearly
|
||||
free while the _editor's_ first frame can be a cheap partial on top. Full
|
||||
derivation: [`../notes/boot-time-budget.md`](../notes/boot-time-budget.md).
|
||||
|
||||
## What it decides
|
||||
|
||||
- **Per-keystroke typing → windowed-Y partial.** Only the touched line's band is
|
||||
driven (~100–130 ms), so typing keeps up with the keyboard; the panel never
|
||||
repaints per keystroke off that line.
|
||||
- **Boot splash→editor → full-area partial (~630 ms), not a second full refresh
|
||||
(~1870 ms).** The splash already seeded the baseline, so the editor rides in on
|
||||
a partial — the ~1.25 s cold-boot win recorded in the boot-time budget.
|
||||
- **Deletes, caret moves, mode flips, snackbar → full-area partial.** These erase
|
||||
ink or change the panel off the cursor line, which a windowed band would ghost,
|
||||
so they take the whole-panel partial rather than a windowed one.
|
||||
- **Splash + periodic → full refresh.** The unavoidable first image and the
|
||||
every-64 de-ghost.
|
||||
675
docs/tradeoff-curves/sync-commit-staging.md
Normal file
675
docs/tradeoff-curves/sync-commit-staging.md
Normal file
@@ -0,0 +1,675 @@
|
||||
# Commit-staging cost vs working-tree size
|
||||
|
||||
> **Decision (RESOLVED 2026-07-12, real-repo bench):** neither `add_all(["*"])`
|
||||
> nor an index-free `read_tree`+`write_tree` is viable on the real
|
||||
> `jcalixte/notes` clone — **both are O(N_tree)** and blow up on the 570 MB pack /
|
||||
> 1179-file tree (611 s hash one end, 77 s tree-read + OOM the other; see
|
||||
> [Real-repo run](#real-repo-run-2026-07-12-jcalixtenotes-570-mb-pack--the-index-is-the-wrong-primitive)
|
||||
> below). The commit must be rebuilt with an **O(depth) TreeBuilder walk** —
|
||||
> patch only the edited file's ancestor subtree chain onto HEAD's tree, never
|
||||
> materialise all 1179 entries.
|
||||
>
|
||||
> **Final state (2026-07-13, after four localization rounds):** the splice walk
|
||||
> is benched and the block is lifted. It first measured 6.5 s (each loose-object
|
||||
> write cost ~1.5 s); the root cause was FatFS's lseek cluster-chain walk, and
|
||||
> the fast-seek fix cut it to **2.8 s cold / ~2 s steady-state**. The `esp_map.c`
|
||||
> window cache was **removed entirely** (0 hits across four instrumented runs —
|
||||
> `mwindow` absorbs any true repetition above `p_mmap`). The sub-second bar
|
||||
> failed, but ~2–2.8 s against 611 s/OOM for every alternative ships: a full
|
||||
> cold real-repo `:sync` lands at ~9–10 s. **Decision: wire the splice in —
|
||||
> done 2026-07-13** ([how it landed](#the-fix--wiring-the-odepth-splice-into-the-firmware),
|
||||
> merged from the retired `notes/sync-commit-handoff.md`; on-device `:sync`
|
||||
> against the real repo still pending). Shrinking the repo is **not** an option
|
||||
> ([`../notes/git-sync-images-and-repo-size.md`](../notes/git-sync-images-and-repo-size.md):
|
||||
> the images are load-bearing for another app), which is exactly why the O(depth)
|
||||
> mechanism is the only lever left. This note records the cost model and the full
|
||||
> measurement trail that got here.
|
||||
>
|
||||
> Tradeoff-curves index: [`README.md`](README.md). Docs index:
|
||||
> [`../README.md`](../README.md). The six-step kaizen retelling of this whole
|
||||
> investigation (problem → factor analysis → chosen fix → evaluation):
|
||||
> [`../kaizen/real-repo-sync.md`](../kaizen/real-repo-sync.md). Where the whole sync goes:
|
||||
> [`../notes/sync-latency.md`](../notes/sync-latency.md). Sibling curve on the
|
||||
> radio cost of _how often_ we sync: [`wifi-auto-sync.md`](wifi-auto-sync.md).
|
||||
|
||||
## The model
|
||||
|
||||
`:sync` commits the working tree on the SD/FAT card before it pushes. The commit
|
||||
is two kinds of work against the card over SPI (10 MHz today, ADR-012):
|
||||
|
||||
```
|
||||
stage write
|
||||
─────────────────────────────── ─────────────────────────────
|
||||
add_all(["*"]) + update_all(["*"]) index.write + write_tree + commit-obj
|
||||
→ stat() every file in the tree, → serialise the index and three loose
|
||||
hash the ones whose stat moved objects, each a FAT create+write+fsync
|
||||
cost ∝ tree size (O(N_tree)) cost ∝ churn (O(N_changed)) + fixed
|
||||
```
|
||||
|
||||
The two have different curves against **N = files in `/sd/repo`**:
|
||||
|
||||
- **Walk** rises with N. `add_all(["*"])` visits the whole working tree every
|
||||
sync regardless of how little changed, and each visit is a FAT `stat` (and a
|
||||
re-hash when the entry looks dirty) over SPI. This is the term explicit-path
|
||||
staging removes: the editor already knows which buffers are dirty and which
|
||||
were `:delete`d, so `index.add_path(p)` / `index.remove_path(p)` over that set
|
||||
touches `N_changed` files (≈1 for a writing appliance), not `N`.
|
||||
- **Write** is flat in N. A text commit is the index + a blob + a tree + a commit
|
||||
object — a handful of small FAT writes whose cost is set by SPI clock and
|
||||
`fsync`, not by tree size. Explicit-path staging cannot shave this; only a
|
||||
faster card bus (SD 10 → 20 MHz on a clean PCB, `persistence.rs`) does.
|
||||
|
||||
```
|
||||
Commit latency vs working-tree size two staging strategies
|
||||
|
||||
ms
|
||||
| walk-all: add_all(["*"])
|
||||
4000 | . * stat()s every file in the
|
||||
| . * tree each sync → O(N)
|
||||
| . *
|
||||
3000 | . *
|
||||
| . *
|
||||
2000 | . *
|
||||
| *
|
||||
1000 |····································· explicit-path: add_path(dirty)
|
||||
| FAT object-write floor → O(churn); flat in N. The gap
|
||||
0 +----+----+----+----+----+----+----+---→ up to walk-all is the avoidable
|
||||
10 50 100 200 400 800 1179 per-sync tree walk.
|
||||
└── jcalixte/notes today (N files)
|
||||
```
|
||||
|
||||
The gap between the lines at a given N is exactly what switching buys, and it
|
||||
**grows without bound** as the notes tree fills.
|
||||
|
||||
### The real operating point (measured 2026-07-12, `jcalixte/notes`)
|
||||
|
||||
The device syncs into a clone of the actual notes repo, not a `notes.md` toy. Its
|
||||
working tree is **not small**:
|
||||
|
||||
| | count | working-tree bytes |
|
||||
| ----------------------------- | -----------------------: | -----------------: |
|
||||
| Markdown (`.md`) | 875 | ~1.5 MB |
|
||||
| Images (png/jpg/webp/bmp/gif) | ~260 | **~150 MB** |
|
||||
| Other (json/ts/pdf/…) | ~44 | ~20 MB |
|
||||
| **Total (N)** | **1179 files, 158 dirs** | **~170 MB** |
|
||||
| `.git` history | | ~570 MB |
|
||||
|
||||
So `add_all(["*"])` walks **1179 files across 158 directories every sync** — and
|
||||
~260 of them are images that a text edit never changes. That does two things the
|
||||
toy-repo baseline hides:
|
||||
|
||||
1. **The walk term is large and paid on every sync** — 1179 `stat`s + 158 dir
|
||||
reads over SPI, for a one-line note change. This is the O(N) cost the curve
|
||||
above predicts, at N ≈ 1179 rather than N ≈ 2.
|
||||
2. **Re-hash risk.** libgit2 decides a file is unchanged from `stat` metadata
|
||||
(mtime/size). FAT's coarse mtime and lack of a stable inode can make entries
|
||||
look racy, forcing a content re-hash. If even a slice of the ~150 MB of images
|
||||
gets re-hashed over a 10 MHz SPI bus, the commit balloons far past 4 s. The
|
||||
`walk` timer will show it; explicit-path staging sidesteps it entirely by never
|
||||
visiting the images.
|
||||
|
||||
## Measurement (2026-07-12, toy `notes.md` tree, N ≈ 2)
|
||||
|
||||
Split from two back-to-back `:sync`es on the small test repo (commits `95ac56ef`
|
||||
cold, `ab260bde` warm), via the `commit split —` log lines:
|
||||
|
||||
| Sub-phase | Kind | Cold (ms) | Warm (ms) |
|
||||
| -------------------------- | --------------------------------- | --------: | --------: |
|
||||
| `walk(add_all+update_all)` | scan (O(N)) + likely 1 blob write | 1402 | 1456 |
|
||||
| `index.write` | FAT write | 204 | 204 |
|
||||
| `write_tree` | **1 tree object → FAT** | 710 | 715 |
|
||||
| `parent-load` | FAT read | 102 | 105 |
|
||||
| `commit-obj` | **1 commit object + ref → FAT** | 914 | 924 |
|
||||
| **commit total** | | **3332** | **3404** |
|
||||
|
||||
### It is not the card — it's libgit2 (`sd_bench`, 2026-07-12)
|
||||
|
||||
My first read of the table was "a loose-object write to this SD card costs
|
||||
~700–900 ms." **That was wrong.** `sd_bench` (`firmware/src/bin/sd_bench.rs`) times
|
||||
the raw FAT primitives on the same card at the same 10 MHz:
|
||||
|
||||
| Raw FAT op (200-byte payload) | p50 |
|
||||
| ----------------------------------------------------------- | --------: |
|
||||
| create + write + close | 21.7 ms |
|
||||
| rename | 12.8 ms |
|
||||
| stat (hit / miss) | ~5 ms |
|
||||
| remove | 14.9 ms |
|
||||
| **loose-object composite** (stat + create + write + rename) | **86 ms** |
|
||||
|
||||
The card does a _complete_ loose-object write in **~86 ms**. Yet `write_tree`
|
||||
(one tree object) took **710 ms** and `commit-obj` **914 ms** — an **~8× gap that
|
||||
is pure libgit2 overhead, not FAT I/O.** So the earlier "object-write floor / SD
|
||||
write amplification / better card / SPI-clock" framing is refuted: **the SD card is
|
||||
not the bottleneck.** fsync is still confirmed off; the extra ~600 ms/op is CPU or
|
||||
repeated `.git` I/O _inside_ libgit2 (candidates: ODB refresh scanning
|
||||
`objects/`, the treebuilder's per-entry `git_odb_exists`, ref-lock + reflog writes,
|
||||
config/attributes re-reads). `git_bench` (`firmware/src/bin/git_bench.rs`) localizes
|
||||
it — see below.
|
||||
|
||||
### It's the pack, read through an un-caching emulated mmap (`git_bench`, 2026-07-12)
|
||||
|
||||
`git_bench` times the git2 primitives in isolation on `/sd/repo` (git ops on the
|
||||
same 96 KB thread the real service uses — the main-task stack overflows on
|
||||
`index.write`, which is itself the reason the service has a dedicated thread):
|
||||
|
||||
| git2 op | p50 | note |
|
||||
| ----------------------------- | ----------: | ------------------------------------------------- |
|
||||
| `Repository::open` | 100 ms | one-time |
|
||||
| `odb.write(blob)` (unique) | **45 ms** | writes a fresh object; touches no existing object |
|
||||
| `repo.index()` open | ~0 ms | cached |
|
||||
| `index.write()` | 376 ms | index + `index.lock` rename + tree-cache |
|
||||
| `write_tree` [unchanged] | ~0 ms | tree exists → freshen-skips the write |
|
||||
| **`write_tree` [changed]** | **1136 ms** | writes ONE 45 ms object |
|
||||
| **`commit(None)` orphan obj** | **563 ms** | writes ONE 45 ms object, no ref/reflog |
|
||||
|
||||
Writing a fresh object is 45 ms; the ops that wrap one are 8–25×. The cause, from
|
||||
the vendored source: `git_odb_write` calls `git_odb__freshen` (odb.c:1011), which
|
||||
on a not-found object runs **`git_odb_refresh`** (re-reads the pack dir + reloads
|
||||
pack indexes), and existence checks (`freshen(tree)` in `commit.c:169`, base-object
|
||||
lookups in `write_tree`) hit the **pack**. Pack access goes through our
|
||||
`p_mmap` (`esp_map.c`), which **`malloc`s and `read()`s the mapped range from the
|
||||
card on every call — no cache** — with a 32 MB window on this 32-bit target. So
|
||||
each write re-reads pack bytes from SD; `odb.write` of a fresh blob is 45 ms only
|
||||
because it touches no packed object.
|
||||
|
||||
**This scales with pack size.** The toy repo's pack is tiny; the real
|
||||
`jcalixte/notes` clone has a **570 MB pack**, and provisioning rsyncs a full clone
|
||||
onto the card — so a real-repo commit has **never been benchmarked** and, on this
|
||||
mechanism, will be far worse than the ~3.3 s toy number. That is the single biggest
|
||||
open risk in sync.
|
||||
|
||||
### Real-repo run (2026-07-12, `jcalixte/notes`, 570 MB pack) — the index is the wrong primitive
|
||||
|
||||
`git_bench` was finally run against a full clone of the real repo (1179 files, 158
|
||||
dirs, 570 MB pack). It settles the design: **any index-based commit is O(N_tree)
|
||||
and does not fit this device.** Two independent walls:
|
||||
|
||||
| op | result | reading |
|
||||
| ------------------------------------- | --------------------------------------: | ------------------------------------------------------- |
|
||||
| `Repository::open` | 88 ms | fine |
|
||||
| odb open (implicit) | ~6 s cold | maps the 1.7 MB pack `.idx` once (16 miss / 1790 KB) |
|
||||
| `odb.write(blob)` | **142 ms** p50 | the mmap cache win **holds** (was 862 ms uncached) ✅ |
|
||||
| `repo.index()` load (1179 entries) | 514 ms max | the on-disk index we were trying to avoid |
|
||||
| `index.write()` | **min 360 ms / p50 12.8 s / max 611 s** | ⚠️ hangs — see root cause |
|
||||
| **seed `read_tree(HEAD)` (cold, 1×)** | **~77 s** | ⚠️ reads all ~158 tree objects, 22.7 MB of pack windows |
|
||||
| `Index::new + read_tree` (warm) | 447 ms p50 | windows still mapped → pure CPU |
|
||||
| **index-free `stage→tree`** | **💥 crash** | `zlib (5)`: `deflateInit` failed, **508 KB heap left** |
|
||||
|
||||
**Wall 1 — `index.write()` hashes the whole working tree (up to 611 s).**
|
||||
`git_index_write` unconditionally calls `truncate_racily_clean` (index.c:822),
|
||||
which runs `git_diff_index_to_workdir` over **every** entry flagged "racy" and
|
||||
re-hashes its file. On a fresh FAT clone the mtime granularity is 2 s and
|
||||
`index.stamp.mtime <= entry.mtime` for ~all 1179 entries (index.h:117), so the
|
||||
whole tree looks racy → it re-hashes ~170 MB (mostly the 150 MB of images) over
|
||||
10 MHz SPI. The 611 s → 12.8 s → 360 ms decay across three iterations is the
|
||||
signature: each write bumps the index mtime, shrinking the racy set. **Implication:
|
||||
the shipping `stage_and_commit` calls `index.write()`, so `:sync` on the real repo
|
||||
effectively bricks on the first commit** — the user sees a 10-minute freeze,
|
||||
resets, the index mtime never advances, and it re-hashes forever. The real repo has
|
||||
almost certainly never completed a sync on device (only the toy `typoena-test` has).
|
||||
|
||||
**Wall 2 — the index-free path is still O(N_tree), and the mmap cache OOMs.**
|
||||
Skipping `index.write()` entirely (fresh `Index::new()`, stamp = 0, so
|
||||
`truncate_racily_clean` can never fire) removes Wall 1. But to seed the in-memory
|
||||
index, `read_tree(HEAD)` materialises all 1179 entries and reads every tree object
|
||||
from the 570 MB pack — **77 s cold** (447 ms only once the windows are resident).
|
||||
`write_tree_to` is O(changed), but you pay O(`N_tree`) to build the cache it needs, so
|
||||
the index-free path only trades a 611 s hash for a 77 s tree-read. Worse, that
|
||||
`read_tree` drove the `esp_map.c` cache to **7.4 MB resident** — past its own 4 MB
|
||||
soft cap — which left 508 KB of heap and made `repo.blob()`'s zlib `deflateInit`
|
||||
fail. **The one write we cannot skip crashed.** Root cause of the OOM: our cache
|
||||
holds pack windows _after_ libgit2 `p_munmap`s them (refcount 0, freed only lazily
|
||||
on the next `p_mmap`), which **defeats `GIT_OPT_SET_MWINDOW_MAPPED_LIMIT`** —
|
||||
libgit2 thinks it released the memory; we didn't.
|
||||
|
||||
**Conclusion — use an O(depth) TreeBuilder walk.** "Replace K files in a 1179-file
|
||||
tree" should touch `O(depth × K)` objects, not `O(N_tree)`. Walk HEAD's tree down
|
||||
the edited path (`tree.get_name`/`get_path` → read ~depth subtree objects), then
|
||||
rebuild bottom-up with `repo.treebuilder(Some(&subtree))` → `insert`/`remove` →
|
||||
`write()`, and `commit` the new root. That never materialises the 1179 entries,
|
||||
never re-hashes anything, never visits the 150 MB of images, reads only a handful
|
||||
of tree windows (so the cache stays small and zlib keeps its heap), and — crucially
|
||||
— **carries the image entries forward untouched from HEAD's tree, so the device
|
||||
does not even need the images in its working tree.** The `esp_map.c` cache still
|
||||
needs an evict-on-`munmap` fix (drop the cap, free past a low-water mark) so it can
|
||||
never again starve a downstream `git__malloc`, but with the TreeBuilder walk the
|
||||
pressure it was under largely disappears.
|
||||
|
||||
### Splice bench (2026-07-12, second real-repo run) — the walk is right, the loose-object write is the new wall
|
||||
|
||||
The O(depth) splice op was added to `git_bench` (1 blob + 3 tree writes onto the
|
||||
depth-3 path `.claude/commands/bsky.md`, run FIRST so its first iteration is
|
||||
cold; the index ops moved last so their OOM can't cost the new data — it did
|
||||
crash again, after everything was logged):
|
||||
|
||||
| op | result | reading |
|
||||
| --------------------------------------- | -------------------------: | ---------------------------------------------------- |
|
||||
| `splice stage→tree` (1 blob + 3 trees) | **6.5 s p50, warm ≈ cold** | O(depth) confirmed — cost is 4 loose writes × ~1.6 s |
|
||||
| `commit(None)` orphan obj | 1.7 s p50 | one more loose write |
|
||||
| `odb.write(blob)` | **1.5 s p50** | ⚠️ was 142 ms in the previous run |
|
||||
| `repo.index()` load | 524 ms max | matches previous run |
|
||||
| seed `read_tree(HEAD)` cold (now timed) | 81.6 s | reproduces the 77 s |
|
||||
| `index-free stage→tree` | 💥 crash, 508 KB heap | reproduces the zlib OOM exactly |
|
||||
|
||||
Three readings:
|
||||
|
||||
1. **The splice mechanism is validated as a mechanism.** Pack reads stayed flat
|
||||
(~40 KB per write; 6.4 MB heap free through splice + commit + odb.write), so
|
||||
it really is O(depth) and it cannot OOM. The 6.5 s is not tree-walk cost.
|
||||
2. **The wall moved to the loose-object write: ~1.5 s each, ×4 per splice.**
|
||||
The isolated `odb.write(blob)` — one tiny orphan blob — took 1.5 s where the
|
||||
raw FAT composite is 86 ms. Projected full commit (splice 6.5 s + commit-obj
|
||||
1.7 s + ref/reflog update) ≈ **8–9 s**: enormously better than 611 s, still
|
||||
far off the bar.
|
||||
3. **The mmap cache scored 0 hits over the entire run** — the documented
|
||||
862→142 ms `odb.write` win did **not reproduce** (same `esp_map.c`, same
|
||||
card). Either the earlier run's conditions differed (orphan-object
|
||||
population? FAT allocation state?) or the win was misattributed. Whatever the
|
||||
1.5 s is, it is _not_ SD data volume: each write moves ~40 KB read + ~1 KB
|
||||
written.
|
||||
|
||||
### ROOT CAUSE FOUND (2026-07-12, `sd_bench` seek op): FatFS lseek walks the cluster chain
|
||||
|
||||
Two `sd_bench` re-runs on the ~740 MB-full card settled it:
|
||||
|
||||
1. **Free-cluster-scan hypothesis: refuted.** Raw FAT write ops are unchanged on
|
||||
the full card — loose-object composite **77 ms p50** (was 86 ms), create
|
||||
20 ms, rename 10 ms. The card is exonerated a second time.
|
||||
2. **Long seeks are the cost.** A new op opens the repo's largest packfile
|
||||
(263 MB — the "570 MB pack" was actually the whole `.git`) read-only and does
|
||||
seek+read(4 KB): **@offset 0 = 5.8 ms; @end = 198.7 ms** — dead constant
|
||||
across 20 iters. Without `CONFIG_FATFS_USE_FASTSEEK`, FatFS resolves lseek by
|
||||
walking the file's FAT cluster chain over SPI: forward from the current
|
||||
position, **from the chain head on any backward seek**. 263 MB ≈ 16.8k
|
||||
clusters ≈ ~67 KB of FAT reads ≈ ~190 ms per long walk.
|
||||
|
||||
**Why FAT behaves this way:** FAT has no extent map or inode — a file is a
|
||||
singly-linked list of clusters, and the only way to find "byte 260,000,000" is
|
||||
to follow that list entry by entry through the allocation table. FatFS walks
|
||||
forward from the current position when it can, but a backward seek restarts
|
||||
from the chain head ([FatFS `f_lseek` docs, elm-chan.org](http://elm-chan.org/fsw/ff/doc/lseek.html)).
|
||||
The fast-seek feature fixes exactly this: a pre-computed **cluster link map
|
||||
table (CLMT)** per file object, "(fragments + 1) × 2" words, after which "no
|
||||
FAT access is occured in subsequent f_read/f_write/f_lseek" (same page). On
|
||||
esp-idf it's `CONFIG_FATFS_USE_FASTSEEK` — the official docs recommend it "for
|
||||
read-heavy workloads with long backward seeks" and note it does not apply to
|
||||
files opened in write mode
|
||||
([ESP-IDF FatFS docs](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/storage/fatfs.html)).
|
||||
|
||||
The budget closes: each loose write does ~8–10 small (~4 KB) `p_mmap`s (freshen
|
||||
→ trailer/idx probes) interleaved with low-offset reads, so ~8 of them pay a
|
||||
fresh ~190 ms walk → **~1.5 s per object**. It also explains everything the
|
||||
cache couldn't: warm ≈ cold (the walk is paid inside `lseek` before any data
|
||||
moves, and the maps are below the 64 KB cache floor), the 142 ms vs 1.5 s
|
||||
run-1/run-2 discrepancy (run 1's `odb.write` bench ran first and hammered only
|
||||
the trailer — the file position stayed there, so its seeks were forward/no-ops),
|
||||
and a large slice of the 81.6 s `read_tree` (133 windows × backward seeks ≈ 25 s
|
||||
of walking on top of the 25 MB of data).
|
||||
|
||||
**Fix (config, not code): `CONFIG_FATFS_USE_FASTSEEK=y` +
|
||||
`CONFIG_FATFS_FAST_SEEK_BUFFER_SIZE=256`** (landed in `sdkconfig.defaults`
|
||||
2026-07-12). Fast seek builds an in-memory cluster-link map per read-mode file —
|
||||
exactly how the pack is opened — making lseek O(1); write-mode files fall back
|
||||
to the walk transparently. 256 words = 1 KB per open read-only file, covering
|
||||
~127 fragments (default 64 covers ~31; a fragmented pack would silently fall
|
||||
back to slow seeks, so the headroom matters).
|
||||
|
||||
**A/B measured (same evening): a 2.3× partial win, not a full one.**
|
||||
|
||||
| op | fast-seek off | fast-seek on |
|
||||
| ------------------- | ------------: | ---------------------: |
|
||||
| `splice stage→tree` | 6.5 s | **2.81 s** |
|
||||
| `odb.write(blob)` | 1.5 s | **416 ms** |
|
||||
| `commit(None)` | 1.7 s | **1.72 s — unchanged** |
|
||||
|
||||
`odb.write` dropped by almost exactly the ~6 chain walks the model predicted —
|
||||
the seek theory holds — but two residuals remain: **~400 ms per loose write**
|
||||
(vs the 77 ms raw-FAT floor) and **`commit(None)`'s ~1.3 s premium over a plain
|
||||
write, which was never seek-bound at all**. Prime suspect for the commit
|
||||
premium: strict object creation makes `git_commit_create` validate its parent +
|
||||
tree OIDs with pack header resolves, and `git_treebuilder_insert` does the same
|
||||
per inserted entry — `git_bench` grew `odb.read_header(packed)` /
|
||||
`odb.exists(missing)` probes and strict-off re-benches to test it.
|
||||
|
||||
### Second localization round (2026-07-12, run 3b + sd_bench re-run)
|
||||
|
||||
**Fast-seek verified on the metal:** re-running the sd_bench seek op with
|
||||
`CONFIG_FATFS_USE_FASTSEEK=y` dropped `pack seek+read 4KB @end` from
|
||||
**198.7 ms → 20.4 ms** (the CLMT fits the pack in the 256-word buffer — the
|
||||
pack is not too fragmented). A far seek is now ~15 ms, i.e. effectively fixed.
|
||||
|
||||
**The strict-creation theory is refuted; the probes found the real unit cost:**
|
||||
|
||||
| op | p50 | reading |
|
||||
| ------------------------- | -------------------: | --------------------------------------------------- |
|
||||
| `odb.read_header(packed)` | **470 ms** | ONE pack header resolve costs ~½ s |
|
||||
| `odb.exists(missing)` | **968 ms** (±0.1 ms) | miss path (scan → refresh → rescan) ≈ 2× |
|
||||
| `commit(None)` strict OFF | 1.80 s | vs 1.93 s strict on — validation is NOT the premium |
|
||||
| `splice` strict OFF | 5.7 s | noise-worse; also not validation |
|
||||
|
||||
The ±0.1 ms constancy of `exists(missing)` = a fixed, deterministic SD-op
|
||||
sequence. The map counters identify it: **~7–8 small (~4 KB) `p_mmap` reads per
|
||||
op** — pack trailer probes, idx fanout reads and delta-base windows, repeated
|
||||
at the _same offsets_ on every freshen/refresh. Post-fast-seek those cost
|
||||
~20 ms each (~150 ms/op); the rest of `read_header`'s 470 ms is CPU-side
|
||||
delta-chain inflation on the 160 MHz core plus repeated re-reads. Two other
|
||||
observations from run 3b: the loose-object orphan population from bench runs
|
||||
is creeping costs upward (splice 2.81 s → 3.21 s between consecutive
|
||||
fast-seek runs — a re-provision resets it), and the mmap cache STILL scored 0
|
||||
hits — because its 64 KB map-length floor excluded exactly these hot small
|
||||
maps.
|
||||
|
||||
**Fix built (esp_map.c v2):** cache admission re-keyed from
|
||||
map length to **file size ≥ 1 MB** — the pack/idx's small repeated windows now
|
||||
cache (RAM hits after first touch) while small mutable working-tree files stay
|
||||
excluded — plus **evict-on-`p_munmap` down to a 2 MB low-water mark**, fixing
|
||||
the 7.4 MB OOM from the first real-repo run (released windows are actually
|
||||
returned to `git__malloc`, so `MWINDOW_MAPPED_LIMIT` stays honest). Expected:
|
||||
`read_header` collapses toward CPU-only, `odb.write` toward ~150–250 ms,
|
||||
splice at or under the sub-second bar, and no end-of-run zlib OOM.
|
||||
|
||||
### Final bench (run 4, esp_map v2) — memory fix works, cache theory dead, bar failed
|
||||
|
||||
Run 4 (2026-07-12 evening, same card state as run 3b plus its orphans):
|
||||
|
||||
| op | run 3b (fast-seek) | run 4 (+ esp_map v2) |
|
||||
| --------------------------------------- | ------------------------: | -------------------------------------: |
|
||||
| `splice stage→tree` (cold, first op) | 2.81 s | **2.83 s — unchanged** |
|
||||
| `splice` again (warm, strict-off phase) | 3.21 s | 1.95 s |
|
||||
| `commit(None)` | 1.72 s | 713 ms |
|
||||
| `odb.write(blob)` | 416 ms | 366 ms |
|
||||
| `odb.read_header(packed)` | 470 ms | 412 ms |
|
||||
| `odb.exists(missing)` | 968 ms | 852 ms |
|
||||
| mmap-cache hits | 0 | **0** (313 misses) |
|
||||
| cache resident / heap free | grew to 7.4 MB → zlib OOM | **1833 KB flat / 6.4 MB free all run** |
|
||||
|
||||
Three findings:
|
||||
|
||||
1. **The memory discipline is verified.** Resident sits at 1833 KB through
|
||||
every phase (under the 2 MB low-water, so nothing is being churned) and
|
||||
heap never drops below 6.2 MB. The one uncaptured datum is the index-free
|
||||
`read_tree` tail (the section that OOM'd runs 1–3) — the monitor was cut
|
||||
before it ran. Not blocking: the shipping splice path never calls
|
||||
`read_tree`; the tail would only re-confirm eviction under burst.
|
||||
2. **The repeated-small-window theory is REFUTED — theory #3 down** (after
|
||||
strict-creation and free-cluster-scan). v2 demonstrably admits and retains
|
||||
the small maps now — the 1833 KB resident _is_ them, held below low-water so
|
||||
nothing is evicted before reuse — and still scored 0 hits in 313 misses.
|
||||
So the ~8 small reads per loose write hit **unique (offset, len) every
|
||||
time**: `mwindow` was already absorbing any true repetition above `p_mmap`,
|
||||
and what reaches the emulation layer is distinct data (different objects,
|
||||
different delta bases). A window cache cannot help. The residual
|
||||
~360 ms/loose-write ≈ 8 distinct small SD round-trips × ~45 ms each
|
||||
(post-fast-seek) — I/O count, not I/O size or seek cost.
|
||||
3. **Within-run drift cuts both ways, so cross-run tables are mushy.** In this
|
||||
single run `commit(None)` degraded 713 ms → 1.79 s between the early and
|
||||
late (strict-off) phases, while splice _improved_ 2.83 → 1.95 s. Two
|
||||
competing effects: first-touch warm-up fading (CLMT build, first pack
|
||||
reads — helps later ops) and orphan loose objects accumulating in
|
||||
`.git/objects/xx/` slowing every freshen existence check (FAT directory
|
||||
lookups are linear scans — hurts later ops). Steady-state on a clean
|
||||
objects dir ≈ **~2 s per splice+commit**.
|
||||
|
||||
**Run 5 (confirmation, cache removed entirely):** esp_map.c stripped back to
|
||||
plain malloc-read/free-at-munmap (stats counters kept). Read pattern
|
||||
**byte-identical** to run 4 at every checkpoint (118 maps / 2314 KB after
|
||||
splice, 148/2434, 163/2494, 208/2674) — except the strict-off phase did **15
|
||||
fewer reads (~60 KB)** than v2: the low-water eviction had been kicking out
|
||||
buffers mwindow still wanted, forcing re-reads. The cache was marginally
|
||||
worse than nothing. Warm splice identical (1953 vs 1949 ms); the cold-op
|
||||
+10–15 % drift (splice 2.83 → 3.26 s) is the known orphan-creep signature,
|
||||
not the removal. Run 5 also reframes run 4's "resident": **1854 KB `live` is
|
||||
mwindow's open-window working set** (pinned mappings under the bench's 4 MB
|
||||
mapped limit), not retained cache buffers — the cache had been retaining
|
||||
essentially nothing. Removal CONFIRMED free; simpler emulation ships.
|
||||
|
||||
**Verdict: the sub-second bar FAILED — wire the splice in anyway.** The bar
|
||||
was aspirational; measured reality is ~2–2.8 s to commit on the real
|
||||
263 MB-pack repo versus 611 s (or a hard OOM) for every alternative benched.
|
||||
That puts a full real-repo `:sync` at roughly **9–10 s cold**, which ships.
|
||||
The remaining ~2 s has survived four localization rounds; the next suspect —
|
||||
FAT _directory-op_ cost in the freshen/refresh path (open/stat/rename by path
|
||||
walk FAT directories linearly; consistent with the orphan-creep signal) — is
|
||||
one instrumentation pass for later (log each `p_mmap` miss + bench dir ops in
|
||||
`sd_bench`), not a prerequisite for the plumbing.
|
||||
|
||||
**Run 6 (2026-07-14, `sd_bench` directory-entry scaling): the residual is
|
||||
CONVICTED — FAT linear directory scans.** Two independent signals, both from
|
||||
the real card at 10 MHz:
|
||||
|
||||
1. `stat` on a **missing** name (the freshen probe's shape — no early exit,
|
||||
the whole parent is scanned) is linear in sibling count: 4.7 ms at 8
|
||||
siblings → 12.8 ms at 64 → 28.9 ms at 256 ≈ **~0.1 ms per directory entry
|
||||
per scan**. (`stat` on a _present_ name plateaued at ~8 ms, but that's the
|
||||
bench's shape — 20 iterations only ever touch the first 20 entries, which
|
||||
sit early in the scan.)
|
||||
2. The **loose-object composite** (freshen stat-miss + temp create + write +
|
||||
remove + rename, libgit2's exact sequence) scales the same way and lands
|
||||
exactly on the field number: mean 95.5 ms at 8 siblings → 163.6 at 64 →
|
||||
**366.8 ms (p50 402.9) at 256** — the observed ~360–400 ms/loose-write,
|
||||
reproduced from primitives. The multiplier over a single scan is the
|
||||
sequence's ~5–6 full parent scans (every miss probe, free-slot search, and
|
||||
rename-target check walks all N entries; 38-hex LFN names burn several
|
||||
entries each, steepening the slope).
|
||||
|
||||
The real repo's `.git/objects/` holds up to 256 two-hex fan-out dirs plus
|
||||
`pack/`/`info/`, so every path resolution under it pays the ~256-entry scan —
|
||||
that's the whole residual. It composes with the earlier finding that the
|
||||
splice does **0 pack reads** during these writes (run-1 `p_mmap` counters):
|
||||
nothing here is data I/O. Consequences: (a) the cost is _bounded_ — ~0.4 s ×
|
||||
loose objects per commit (typically ≤ 5) ≈ ≤ 2 s, already inside the shipped
|
||||
splice numbers, so nothing burns; (b) the only real levers are _fewer
|
||||
path-resolutions per object_ (libgit2 surgery) or _not writing loose objects
|
||||
at all_ (commit straight into a small pack) — both out of scope until a
|
||||
future perf pass; (c) orphan-creep is now mechanically explained: each
|
||||
leftover loose object adds entries to a fan-out dir and slows every later
|
||||
scan of it.
|
||||
|
||||
### The walk is ~1.4 s even at N ≈ 2
|
||||
|
||||
Mostly fixed cost — the worktree-diff setup and the second (`update_all`) pass —
|
||||
not per-file `stat` (one raw `stat` is ~5 ms, so N ≈ 2 can't be the 1.4 s). The
|
||||
O(N) slope only bites on the real `jcalixte/notes` clone (N ≈ 1179), which this run
|
||||
did **not** exercise. That slope is still unmeasured.
|
||||
|
||||
For orientation: `publish(commit+push)` was 9846 ms cold, so the **network half is
|
||||
~6.5 s** — still the biggest single block of a warm sync (10.1 s total), a separate
|
||||
floor ([`../notes/sync-latency.md`](../notes/sync-latency.md)).
|
||||
|
||||
## The verdict
|
||||
|
||||
The real-repo run (above) overturned the earlier ranking. Both index strategies
|
||||
are O(N_tree) and fail on the 570 MB-pack clone, and the repo cannot be shrunk. The
|
||||
work, ranked:
|
||||
|
||||
1. **Rewrite the commit as an O(depth) TreeBuilder walk (the fix — build this).**
|
||||
Rebuild only the edited path's ancestor subtree chain onto HEAD's tree; never
|
||||
materialise the 1179-entry index, never `index.write()`, never `read_tree` the
|
||||
whole tree. This is the ONLY mechanism that fits: O(depth × dirty) reads/writes,
|
||||
flat in repo size, small heap, images carried forward untouched. Replaces
|
||||
`stage_and_commit`'s `add_all`/`update_all`/`index.write`/`write_tree`. Needs the
|
||||
editor's dirty set (+ deleted set) plumbed to the git service — the editor
|
||||
already knows both. **Benched to completion 2026-07-12: 6.5 s → 2.8 s cold /
|
||||
~2 s steady-state after the fast-seek fix (run 4). The sub-second bar failed
|
||||
but the block is lifted — wire it in** (see the final-bench section above:
|
||||
the residual is unique small SD round-trips, not something a cache or seek
|
||||
fix can remove).
|
||||
2. **Fix the `esp_map.c` cache so it can't OOM — RESOLVED BY REMOVAL (run 5).**
|
||||
The cache never scored a hit in four instrumented real-repo runs
|
||||
(`mwindow` absorbs true repetition above `p_mmap`; only new ranges reach
|
||||
the emulation), and the 7.4 MB OOM it was patched to avoid was caused by
|
||||
the cache itself holding buffers past `p_munmap`. `esp_map.c` is now the
|
||||
plain malloc-read/free-at-munmap emulation: honest with
|
||||
`MWINDOW_MAPPED_LIMIT` by construction, ~120 lines lighter, and run 5
|
||||
confirmed removal is I/O-neutral (even 15 reads _better_ than v2, whose
|
||||
low-water eviction fought mwindow). Stats counters kept to spot any future
|
||||
workload that does repeat ranges.
|
||||
3. **Retired: `add_all`/explicit-path _index_ staging.** Explicit-path `add_path`
|
||||
still goes through the index and `index.write` → `truncate_racily_clean`, so it
|
||||
hits Wall 1 just the same. The TreeBuilder walk supersedes it entirely; the
|
||||
"explicit-path staging" idea survives only as "the editor's dirty set feeds the
|
||||
walk."
|
||||
4. **Retired: SD clock / better card.** The card does a full object write in
|
||||
~86 ms; raw I/O is not the bottleneck. Do not spend the PCB's 20 MHz budget
|
||||
expecting a commit-latency win.
|
||||
5. **Kept: the mmap cache + mwindow tuning** (`GIT_OPT_SET_MWINDOW_*`, 256 KB
|
||||
window / 4 MB mapped limit). It fixed `odb.write` and the push read path; #2 just
|
||||
makes it well-behaved under memory pressure.
|
||||
|
||||
**Recommendation:** build #1 (the O(depth) TreeBuilder walk) — #2 resolved
|
||||
itself by removal. The concrete plumbing plan (exact call sites, dirty-set
|
||||
threading, FD budget) is the
|
||||
[next section](#the-fix--wiring-the-odepth-splice-into-the-firmware).
|
||||
|
||||
## The fix — wiring the O(depth) splice into the firmware
|
||||
|
||||
> Merged from `docs/notes/sync-commit-handoff.md` (written 2026-07-12, retired
|
||||
> 2026-07-13 once the bench phase closed). The handoff's bench half is done —
|
||||
> the splice op lives in `git_bench` and the numbers above are its output.
|
||||
> **The firmware plumbing below SHIPPED 2026-07-13** (compile-verified both
|
||||
> feature flavors; on-device `:sync` against the real repo still pending) —
|
||||
> each item now records how it landed rather than what to do.
|
||||
|
||||
### The splice walk
|
||||
|
||||
Rebuild only the edited file's ancestor subtree chain onto HEAD's tree. Never
|
||||
materialise the 1179-entry index; never `index.write()`; never `read_tree` the
|
||||
whole tree. Cost is O(depth × dirty_files), flat in repo size, tiny heap, and it
|
||||
carries every unchanged entry (all 260 images, the other 1176 files) forward
|
||||
untouched — which means **the device doesn't even need the images in its
|
||||
working tree.**
|
||||
|
||||
Shipped as `git_sync::splice` (git2 0.20: `Repository::treebuilder(Option<&Tree>)`
|
||||
|
||||
- `TreeBuilder::{insert,remove,write}`). Signature:
|
||||
|
||||
```rust
|
||||
fn splice(repo: &Repository, base: Option<&Tree>, path: &[&str], blob: Option<Oid>) -> Result<Oid>
|
||||
```
|
||||
|
||||
`Some(blob)` inserts/replaces, `None` removes; `base: None` synthesizes a
|
||||
missing intermediate directory on the way down, and a directory emptied by a
|
||||
remove is pruned on the way up (the empty tree is never re-inserted).
|
||||
`stage_and_commit` folds every dirty path through it (threading the running
|
||||
root tree), then `commit(Some("HEAD"), …)` exactly as before — the
|
||||
`tree unchanged → nothing to publish` check and the `commit split —` timing
|
||||
log survive. The benched reference is `git_bench`'s `splice stage→tree` op.
|
||||
|
||||
### `firmware/src/git_sync.rs` — how it landed
|
||||
|
||||
- **mwindow options set at service start** (top of `run_git_service`, before
|
||||
any `Repository::open`): `set_mwindow_size(256 KB)` +
|
||||
`set_mwindow_mapped_limit(4 MB)`. Without them the 32-bit defaults (32 MB
|
||||
window / 256 MB mapped limit, mwindow.c:16) would `git__malloc` a 32 MB
|
||||
window on the first pack access and die on the 8 MB PSRAM heap. Run 5
|
||||
sharpened the stakes: the ~1.85 MB bench "resident" **is** mwindow's live
|
||||
open-window set, so these opts are the only thing bounding it.
|
||||
- `stage_and_commit(repo, paths)` is the splice walk; `add_all`/`update_all`/
|
||||
`index.write`/`write_tree` are gone. **The request carries one path set, not
|
||||
`{changed, deleted}`:** the working tree is the source of truth, so at commit
|
||||
time a recorded path that exists on the card is spliced in from disk and a
|
||||
missing one is spliced out. An unchanged path is a no-op — over-reporting is
|
||||
free, which is what makes the retry/journal semantics below simple.
|
||||
- **Stranded-commit recovery (new):** when the splice yields the parent's tree
|
||||
(nothing to commit), the service now compares HEAD against
|
||||
`refs/remotes/origin/<branch>` and still pushes if origin lacks HEAD — a
|
||||
previous cycle that committed and then failed mid-push used to strand that
|
||||
commit forever (the old path reported "up to date" and never retried).
|
||||
- **Radio-free up-to-date (new):** an empty dirty set + origin already at HEAD
|
||||
short-circuits before Wi-Fi even comes up — `:sync` with nothing to do
|
||||
answers in ~150 ms instead of a Wi-Fi/TLS round.
|
||||
- `reconcile_onto_origin` now `ResetType::Soft` (ref move only) — there is no
|
||||
index to reset, and a Mixed reset's index write is exactly the racy-clean
|
||||
wall the splice avoids. Side win: a remote-only added file is now _carried_
|
||||
by the replay (origin's tree is the splice base) where the old `add --all`
|
||||
replay dropped it.
|
||||
- The macOS-cruft filter (`skip_macos_cruft`) is gone with the walk — the
|
||||
splice only touches paths the editor recorded, so `._*`/`.DS_Store` can't
|
||||
sneak in the way they once did (07d87772, the Spike-14-era lesson).
|
||||
- **Deliberate behavior change (now in effect):** only paths the editor
|
||||
saved/deleted are ever committed. Files changed on the card outside the
|
||||
editor (card mounted on a Mac) were swept in by `add_all` before; they will
|
||||
now never be committed, and the working tree will show a permanent diff
|
||||
against HEAD if inspected on a desktop. Correct for the appliance; recorded
|
||||
here so it reads as intent, not accident.
|
||||
|
||||
### Dirty-set source — `firmware/src/persistence.rs` + `main.rs` (how it landed)
|
||||
|
||||
- `Storage` owns the record: `save_path`/`delete_path` note their repo-relative
|
||||
path in a `RefCell<Dirty>` (paths outside `/sd/repo` are skipped), and the
|
||||
set is **journaled to `/sd/.typoena-dirty`** — atomic write, rewritten only
|
||||
when the set actually grows. Without the journal a power pull would strand
|
||||
every file saved-but-not-published that session: nothing walks the tree
|
||||
anymore, so an unrecorded change would never reach the remote. The journal
|
||||
is loaded at mount and its paths ride the next `:sync`.
|
||||
- Lifecycle: `take_dirty()` snapshots pending → in-flight for one publish
|
||||
(journal keeps carrying the union); the outcome settles it —
|
||||
`publish_succeeded()` forgets the snapshot and shrinks the journal,
|
||||
`publish_failed()` returns it to pending for the next `:sync`. A save landing
|
||||
_while_ a publish runs re-enters pending and rides the next one. Recording
|
||||
happens _before_ the file write, so a crash between the two only
|
||||
over-approximates (a no-op splice), never under-records.
|
||||
- `Effect::Publish` in `main.rs` sends `PublishRequest { paths: take_dirty() }`;
|
||||
the outcome handler in the idle branch calls the matching settle method.
|
||||
- **FD budget:** a git build now mounts `Storage::mount_for_git()` (16 FDs) in
|
||||
`boot_storage` — libgit2 keeps the pack + `.idx` descriptors open and opens
|
||||
loose objects on top, which overruns the editor's 4-FD budget. The light
|
||||
build keeps the editor's own budget.
|
||||
|
||||
### `esp_map.c` — nothing left to do
|
||||
|
||||
The handoff's third work item (an evict-on-`p_munmap` cache fix) is superseded:
|
||||
run 5 removed the cache outright and `esp_map.c` is already the plain
|
||||
malloc-read/free-at-munmap emulation (see verdict item 2). One surviving
|
||||
operational note: editing `components/libgit2/*.c` won't rebuild via plain
|
||||
`cargo build` — first
|
||||
`rm -rf firmware/target/xtensa-esp32s3-espidf/release/.fingerprint/esp-idf-sys-*`
|
||||
(the `esp-idf-component-rebuild` lesson).
|
||||
|
||||
### How to bench / flash
|
||||
|
||||
`git_bench.rs` runs git ops on the 96 KB `GIT_STACK` thread (the main task stack
|
||||
overflows on these ops — that's why the real service has a dedicated thread). It's
|
||||
Rust-only, so a plain rebuild picks it up (no fingerprint dance unless you also
|
||||
touched `esp_map.c`).
|
||||
|
||||
```
|
||||
just flash-gitbench
|
||||
# = . ~/export-esp.sh && LIBGIT2_SRC=<repo>/firmware/components/libgit2/vendor \
|
||||
# LIBGIT2_NO_VENDOR=1 PKG_CONFIG_ALLOW_CROSS=1 \
|
||||
# PKG_CONFIG_LIBDIR=<repo>/firmware/pkgconfig \
|
||||
# cargo run --release --bin git_bench --features git
|
||||
```
|
||||
|
||||
Bench on the **real repo** clone (`/sd/repo` = full `jcalixte/notes`), not the
|
||||
toy — the toy pack understates everything by ~2 orders of magnitude.
|
||||
|
||||
### Still open (none block the plumbing)
|
||||
|
||||
- ~~The residual ~360 ms/loose-write~~ **CLOSED 2026-07-14** — convicted as
|
||||
FAT linear directory scans of the ~256-entry `.git/objects/` fan-out (run 6
|
||||
above): `sd_bench`'s dir-entry-scaling section reproduced the field number
|
||||
from primitives (loose composite p50 402.9 ms at 256 siblings). Bounded and
|
||||
accepted; the levers (fewer path resolutions, or pack-not-loose commits)
|
||||
are a future perf pass.
|
||||
- Ref/reflog-update cost on the real repo — the bench's `commit(None)` writes
|
||||
no ref, so the shipping commit's last leg is unmeasured.
|
||||
- The push's ~6.5 s network floor
|
||||
([`../notes/sync-latency.md`](../notes/sync-latency.md)) — a separate curve.
|
||||
|
||||
## Adjacent lever: should the images be on the card at all?
|
||||
|
||||
Explicit-path staging makes the walk skip the images, but they still cost 150 MB
|
||||
of SD space, inflate the 570 MB clone, and slow provisioning + the pull-before-push
|
||||
paths. Whether the device should carry image blobs at all — vs. markdown-only, or
|
||||
Git-LFS-style pointers — is a separate decision tracked in
|
||||
[`../notes/git-sync-images-and-repo-size.md`](../notes/git-sync-images-and-repo-size.md).
|
||||
That lever shrinks N and the clone; this one stops the walk from paying for N. They
|
||||
compose.
|
||||
|
||||
## What this does _not_ touch
|
||||
|
||||
The network half of `:sync` (TLS handshake + push round-trips, ~6.5 s of the warm
|
||||
path) is a separate floor covered in [`sync-latency.md`](../notes/sync-latency.md);
|
||||
this curve is only about the local commit. Radio _frequency_ (how often we pay any
|
||||
sync at all) is [`wifi-auto-sync.md`](wifi-auto-sync.md).
|
||||
172
docs/tradeoff-curves/wifi-auto-sync.md
Normal file
172
docs/tradeoff-curves/wifi-auto-sync.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Wi-Fi energy vs auto-sync interval
|
||||
|
||||
> **Decision:** `auto_sync` defaults to **10 min**, and is an _opportunistic,
|
||||
> rate-limited_ push — not a wall-clock timer that wakes the device. See
|
||||
> [Policy](#policy). Backs the `.typoena.toml` `auto_sync` key in
|
||||
> [`../macroplan.md`](../macroplan.md) (v0.5). The runtime behaviour was
|
||||
> pencilled for v0.7, but v0.7 closed 2026-07-14 as search + manual `:gl`/`:gp`
|
||||
> without it — it is **re-homed to v0.8**
|
||||
> ([`../v0.8-battery-and-sleep.md`](../v0.8-battery-and-sleep.md)), alongside
|
||||
> the sleep transitions it must respect and the per-sync radio teardown it
|
||||
> depends on.
|
||||
>
|
||||
> **Revised 2026-07-14** with the first real-device sync measurements (v0.7
|
||||
> runs on the real ~560 MB notes repo, see
|
||||
> [`../v0.7-search-and-git.md`](../v0.7-search-and-git.md)): the per-sync
|
||||
> constant roughly **doubles** and its mix shifts from radio to SD/CPU, but the
|
||||
> shape, the knee, and the decision all survive — and two v0.7 results make the
|
||||
> teardown-between-syncs argument _stronger_.
|
||||
>
|
||||
> Tradeoff-curves index: [`README.md`](README.md). Docs index:
|
||||
> [`../README.md`](../README.md).
|
||||
|
||||
## The model
|
||||
|
||||
For a **text** commit the git payload is a few KB — negligible. The cost of one
|
||||
sync is (almost) independent of how much you wrote — but it is no longer the
|
||||
single fixed radio burst the first version of this doc assumed. Measured on
|
||||
device (v0.7 run 4, 2026-07-14, warm clean `:gp` on the real notes repo):
|
||||
|
||||
```
|
||||
splice commit (SD/CPU, radio not needed) 10.3 s O(depth) loose writes × ~0.4 s FAT dir scan
|
||||
push leg (connect + pack + upload) 5.9 s TLS-resumed connect 2.4 s (4.0 s first-of-session)
|
||||
──────
|
||||
warm `:gp` end-to-end 19.1 s root-level file ≈ 12–13 s — the splice is depth-bound
|
||||
```
|
||||
|
||||
Both halves are paid once per _dirty_ sync, so energy per unit time still
|
||||
scales as **(fixed cost per sync) × (syncs per hour)**:
|
||||
|
||||
```
|
||||
E(T) = K / T T = interval in minutes, K = one sync's worth of energy
|
||||
```
|
||||
|
||||
A hyperbola. Doubling the frequency doubles the cost; the words you actually
|
||||
wrote barely move it. What the measurement changed is **K and its
|
||||
composition**: the awake window is ~12–19 s (not the ~8 s placeholder), and
|
||||
more than half of it is SD/CPU splice work that no radio policy can touch —
|
||||
but the splice re-runs on every sync too, so it rides the same 1/T curve. The
|
||||
splice scales with file _depth_ and dirty-set size, not with words (the
|
||||
FAT-dir-scan story in
|
||||
[`sync-commit-staging.md`](sync-commit-staging.md), run 6).
|
||||
|
||||
Placeholder _currents_, still pending the v0.8 bench measurement ("measure
|
||||
idle / typing / push current draw"): ~10 s of CPU+SD at ~100 mA plus ~8 s of
|
||||
radio-on at ~150 mA ⇒ **≈ 0.6 mAh per sync**, `K ≈ 36 mAh·min/hr` — about 2×
|
||||
the original estimate. The vertical scale below moves with the real current
|
||||
measurement; the _shape_ and the knee do not.
|
||||
|
||||
Two refinements the field runs added:
|
||||
|
||||
- **A clean tick is free.** Since the dirty-journal plumbing (2026-07-13),
|
||||
`:gp` with an empty journal answers "up to date" without touching the radio
|
||||
at all. An auto-sync tick only pays when there is actually something to
|
||||
push — idle hours with no edits cost zero regardless of the interval.
|
||||
- **The worst case is bounded at ~2×.** If the mirror moved underneath (a Mac
|
||||
push), an auto-sync hits the rejected-push → reconcile → replay → push cycle:
|
||||
24.0 s and three connections measured (run 3, _with_ session resumption).
|
||||
Rare, and it doesn't move the knee.
|
||||
|
||||
**One assumption is baked into the burst: the radio is fully off between
|
||||
syncs**, not parked in modem-sleep. Holding the association awake to skip the
|
||||
per-sync handshake costs ~15–20 mAh/hr on the WROOM — more than a 1-min
|
||||
interval and ~10× the 10-min default. Two v0.7 results bury keep-alive
|
||||
further:
|
||||
|
||||
- **TLS session resumption** (the `esp_mbedtls_stream.c` vendor delta) cut a
|
||||
reconnect to 2.4 s vs 4.0 s for the session's first: the handshake that
|
||||
staying-associated was supposed to amortise is now the cheap part, and the
|
||||
session ticket lives in RAM — it survives radio de-init between syncs.
|
||||
- **Held connections die anyway.** In run 8 the idle keep-alive was closed
|
||||
during a ~31 s quiet gap and libgit2 does not reconnect (`SSL Generic
|
||||
error`, push lost). Keep-alive is not just expensive; it's fragile.
|
||||
|
||||
So each sync legitimately pays a fresh `wake → associate → resumed-handshake`
|
||||
burst, and "off" everywhere here means radio **de-init**, not
|
||||
beacon-listening. Tear the connection down immediately after each push, too:
|
||||
with syncs ≥ 2 min apart a keep-alive window saves nothing. (v0.7 added `:gl`
|
||||
pull, so "Typoena only ever pushes" is no longer literally true — but pull is
|
||||
user-initiated and outbound like everything else; nothing unsolicited ever
|
||||
arrives, so there is still no reason to stay reachable.)
|
||||
|
||||
> **Status (v0.7) — the shipped firmware still does _not_ cycle the radio.**
|
||||
> Wi-Fi comes up lazily on the first git op and then stays associated for the
|
||||
> rest of the session: `ensure_online` in
|
||||
> [`../../firmware/src/git_sync.rs`](../../firmware/src/git_sync.rs) owns the
|
||||
> `wifi` handle and the module never stops, disconnects, or drops it (the
|
||||
> `remote.disconnect()` calls in there are git smart-protocol connections, not
|
||||
> the radio). So today's device runs the _stay-associated_ strategy this
|
||||
> section argues against, at ~15–20 mAh/hr after the first git op. Per-sync
|
||||
> teardown remains a v0.8 refactor of the modem ownership — and a prerequisite
|
||||
> before any sleep mode ships.
|
||||
|
||||
## The curve
|
||||
|
||||
`E(T) = K / T` with the revised `K ≈ 36 mAh·min/hr` (≈ 0.6 mAh per dirty
|
||||
sync):
|
||||
|
||||
```mermaid
|
||||
xychart-beta
|
||||
title "Wi-Fi energy vs auto-sync interval (placeholder currents)"
|
||||
x-axis "interval (min)" ["1", "2", "5", "10", "15", "30", "60"]
|
||||
y-axis "mAh/hr" 0 --> 36
|
||||
bar [36, 18, 7.2, 3.6, 2.4, 1.2, 0.6]
|
||||
```
|
||||
|
||||
The knee sits at **5–10 min**: left of it, every extra sync per hour costs a
|
||||
full splice + radio burst for zero payload benefit; right of it, the tail is
|
||||
nearly flat and longer intervals save almost nothing.
|
||||
|
||||
| interval | syncs/hr | Wi-Fi mAh/hr | vs 5-min | per 8 h day |
|
||||
| ---------: | -------: | -----------: | -------: | ----------: |
|
||||
| 1 min | 60 | 36.0 | 5.0× | 288 mAh |
|
||||
| 2 min | 30 | 18.0 | 2.5× | 144 mAh |
|
||||
| 5 min | 12 | 7.2 | 1.0× | 58 mAh |
|
||||
| **10 min** | 6 | **3.6** | **0.5×** | 29 mAh |
|
||||
| 15 min | 4 | 2.4 | 0.33× | 19 mAh |
|
||||
| 30 min | 2 | 1.2 | 0.17× | 9.6 mAh |
|
||||
| 60 min | 1 | 0.6 | 0.08× | 4.8 mAh |
|
||||
|
||||
(Every row assumes the device was dirty at each tick — a writing session in
|
||||
progress. Clean ticks cost nothing, per the journal gate above.)
|
||||
|
||||
## Two things that move where "best" sits
|
||||
|
||||
**`save_on_idle` already prevents data loss — auto-sync is only remote-mirror
|
||||
freshness.** The durable local copy is the SD write on the idle pause. A longer
|
||||
sync interval never risks _losing work_; it only means the GitHub mirror is a
|
||||
few minutes staler. That's a weak cost, and it pushes the optimum toward
|
||||
_longer_ intervals. The doubled K pushes the same direction: each avoided sync
|
||||
now saves twice what the original model claimed.
|
||||
|
||||
**The real battery risk is the sleep interaction, not the awake case.** While
|
||||
you're typing, the CPU/e-ink baseline dwarfs the sync cost — 5 vs 15 min is
|
||||
noise. The damage happens when the device is idle or asleep and a wall-clock
|
||||
timer wakes it _just to push_: each wake pays the radio burst plus the wake/boot
|
||||
cost and blocks the low-power state. That turns "closed on the desk overnight"
|
||||
from weeks of standby into dead-by-morning.
|
||||
|
||||
## Policy
|
||||
|
||||
Ship `auto_sync` as an opportunistic, rate-limited push, with the config value
|
||||
read as a _max-staleness cap_ rather than a timer period:
|
||||
|
||||
- **Push when already awake + dirty**, coalesced into the existing idle-pause,
|
||||
rate-limited to at most once per `auto_sync` — so a fast typist pausing every
|
||||
20 s doesn't sync 100×/hr. The dirty journal makes the check free: a tick
|
||||
with nothing to push never spins the radio (this is `:gp`'s shipped
|
||||
radio-free up-to-date path).
|
||||
- **Push once on the way into sleep** (idle → light sleep, and especially
|
||||
lid-close → deep sleep) if dirty. This is the highest-value sync: nearly free
|
||||
(the device is spinning up anyway) and it's the freshness guarantee.
|
||||
- **Never wake from deep sleep purely to sync.** The one behavior that wrecks
|
||||
standby life.
|
||||
|
||||
On the single number: **10 min** halves the sync energy versus a 5-min default
|
||||
(3.6 vs 7.2 mAh/hr under the revised K) for essentially no real cost, because
|
||||
`save_on_idle` already owns data safety. Clamp the minimum to **~2 min** so a
|
||||
palette command (`> auto sync: 10s`) can't quietly drain the battery. One new
|
||||
UX input since the first version: a dirty auto-sync now occupies the git
|
||||
thread for ~12–19 s (UI stays live — proven by the run-4/5 concurrent-UI
|
||||
work), so at the 2-min clamp a heavy writer's device would be syncing ~15% of
|
||||
the time. The 10-min default keeps that under 3%.
|
||||
147
docs/typoena-snippets.md
Normal file
147
docs/typoena-snippets.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# `.typoena.snippets.json` — snippet library
|
||||
|
||||
> The git-tracked file that holds your trigger-driven text expansions for
|
||||
> Markdown authoring. Hand-editable (and Zed-compatible, so you can paste your
|
||||
> existing snippets straight in), synced across devices like your notes. Landed
|
||||
> in **v0.6** (see [`macroplan.md`](macroplan.md)). The editing surfaces — inline
|
||||
> Tab-expansion and the `$` palette — are specified in
|
||||
> [`v0.6-markdown.md`](v0.6-markdown.md).
|
||||
>
|
||||
> **Three files, three concerns, don't confuse them.** `.typoena.snippets.json`
|
||||
> is _content_ (your templates). [`.typoena.toml`](typoena-toml.md) is _behaviour_
|
||||
> (auto-save, gutter). `/sd/typoena.conf` is _secrets_ (Wi-Fi, PAT), gitignored
|
||||
> and never committed. The first two live in the repo and sync; the third is
|
||||
> per-device.
|
||||
|
||||
## Location
|
||||
|
||||
```
|
||||
/sd/repo/.typoena.snippets.json
|
||||
```
|
||||
|
||||
It sits in the Tracked repo beside [`.typoena.toml`](typoena-toml.md), so it is
|
||||
**committed and pushed** like any note and **syncs to every device** that clones
|
||||
the repo. Your snippet library follows you. It is read **once at boot**; a
|
||||
**missing, empty, or malformed file is fine** — you simply have no snippets, and
|
||||
the editor runs unchanged.
|
||||
|
||||
## Format
|
||||
|
||||
Deliberately **Zed's snippet JSON shape**, so the contents of a Zed
|
||||
`snippets/markdown.json` paste in unmodified:
|
||||
|
||||
```json
|
||||
{
|
||||
"Markdown link": {
|
||||
"prefix": "link",
|
||||
"body": "[$1]($2)$0",
|
||||
"description": "Inline link"
|
||||
},
|
||||
"Book notes": {
|
||||
"prefix": "fiche",
|
||||
"body": ["# $1", "", "## $2 — $3", "", "## What the book is about", ""],
|
||||
"description": "Fiche de lecture"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- The top-level key is the **display name** (what the `$` palette shows).
|
||||
- `prefix` — the word that triggers inline Tab-expansion.
|
||||
- `body` — a **string**, or an **array of lines** joined with `\n` (Zed's form;
|
||||
it sidesteps embedded-newline escaping and reads cleanly for multi-line
|
||||
templates).
|
||||
- `description` — optional but recommended: the `$` palette fuzzy-matches it and
|
||||
shows it, so it is how you find a snippet you don't remember the prefix for.
|
||||
|
||||
### Tab stops
|
||||
|
||||
A body is literal text plus numbered stops:
|
||||
|
||||
- `$1 … $n` — empty stops the caret visits in order.
|
||||
- `$0` — the final resting place (defaults to the end of the insertion if absent).
|
||||
- `${n:label}` — **accepted, but the label is stripped** to a bare `$n`. The
|
||||
editor has no selection/overtype model, so a label would just be text to
|
||||
delete; on a device with no completion popup it could never be shown as a
|
||||
prompt anyway. The **headings and structure carry the template** — the labels
|
||||
were only hints. This is what lets a Zed file with `${1:Titre}` load as-is.
|
||||
- **No dynamic or computed values** (no `date`, no `clipboard`). There is no RTC
|
||||
— the wall clock is valid only after Wi-Fi + SNTP, so a `date` snippet would
|
||||
stamp 1970 on a cold boot. A stop is empty or it is literal; nothing else.
|
||||
|
||||
## The two surfaces
|
||||
|
||||
Every snippet works both ways — there is **no hidden two-tier rule** where some
|
||||
are "inline only" and some are "palette only". Inline Tab is the fast path you
|
||||
reach for once a prefix is in muscle memory; the `$` palette is discovery.
|
||||
|
||||
### Inline Tab-expansion (Insert mode)
|
||||
|
||||
Type a prefix, press **Tab**. If the word immediately before the caret matches a
|
||||
snippet prefix, it expands; otherwise Tab inserts spaces as it does today. (Tab
|
||||
arrives as an ordinary character, so this is a check inside the Insert-mode
|
||||
handler, alongside the existing list-continuation transform.)
|
||||
|
||||
On a **typing pause** — the same throttle as the word-count / cursor refresh, so
|
||||
never a per-keystroke e-ink flash — if the word before the caret is a prefix, the
|
||||
right side panel shows a quiet hint (`» Book notes`), on the row above the mode
|
||||
line. The panel is ~15 columns, so the hint is the **snippet name**, not the whole
|
||||
body; the full preview is what the `$` palette is for. (The marker is `»`, not a
|
||||
tab glyph: the panel font is ISO-8859-15, which has no `↹`.)
|
||||
|
||||
### `$` palette (browse + insert)
|
||||
|
||||
Open the palette (`Cmd-P`) and type **`$`** — the same sigil mechanism as `>` for
|
||||
commands. The query after the `$` fuzzy-matches name, prefix, and description;
|
||||
`Ctrl-N`/`Ctrl-P` move the selection; **Enter inserts the body at the caret** and
|
||||
starts the tab-stop session (dropping you into Insert at `$1`). The empty-palette
|
||||
placeholder legends the sigils: `Go to file · > settings · $ snippets`.
|
||||
|
||||
## The tab-stop session
|
||||
|
||||
Identical whether the snippet was expanded inline or inserted from the palette:
|
||||
|
||||
- After insertion the caret lands on **`$1`** (or the end, if the body has no
|
||||
stops), in **Insert** mode.
|
||||
- **Tab advances** to the next stop, **forward only** (no Shift-Tab). The last
|
||||
Tab lands on `$0` / the end and ends the session.
|
||||
- Pending stop offsets sit **after the caret** and shift with the edits you make
|
||||
at each stop, so typing at `$1` keeps `$2 … $n` correctly placed.
|
||||
- The session **auto-aborts** on Esc, a mode change, or a motion that leaves the
|
||||
stop range — after which the buffer is just text and Tab inserts spaces again.
|
||||
|
||||
## Parsing
|
||||
|
||||
The parse lives in the host-testable `editor` crate (`Snippets::parse`), using
|
||||
`serde_json` — JSON string escapes (`\n`, `\"`, `\uXXXX`) are a foot-gun to
|
||||
hand-roll, and `serde_json` is battle-tested; the editor crate is `std`, so it
|
||||
compiles for xtensa via esp-idf. This is the **one new dependency** the feature
|
||||
adds. The firmware reads the file at boot and hands the parsed list to
|
||||
`Editor::set_snippets`, mirroring how `.typoena.toml` is read and applied via
|
||||
`set_prefs`. A parse error is **non-fatal**: log it and boot with no snippets,
|
||||
rather than refusing to start over a stray comma.
|
||||
|
||||
## Editing it
|
||||
|
||||
- **On your computer (the normal path).** It's plain JSON in your notes repo —
|
||||
edit it in your real editor, copy entries over from Zed, commit, and it reaches
|
||||
the device on the next clone/sync. This is deliberately where the heavy editing
|
||||
happens; the appliance is for writing, not for maintaining a JSON library.
|
||||
- **First-time setup.** [`just init`](../firmware/README.md#provisioning-an-sd-card)
|
||||
seeds this file from a curated catalog (`firmware/snippets-catalog/`) — you pick
|
||||
which snippet groups you want and it `jq`-merges the selected subset into
|
||||
`repo/.typoena.snippets.json` (committed on the device's first `:sync`). It
|
||||
writes **only if the file is absent**, so re-running `init` on a card whose
|
||||
clone already carries your library never overwrites it. See
|
||||
[`v0.6-markdown.md`](v0.6-markdown.md) for the catalog.
|
||||
- **On-device hand-edit — deferred.** The palette hides dotfiles, and `:e` was
|
||||
dropped in v0.6, so there is no in-editor path to this file yet. When one is
|
||||
wanted it returns as a discoverable `> edit snippets` command that opens the
|
||||
file directly, rather than resurrecting a general `:e`.
|
||||
|
||||
## See also
|
||||
|
||||
- [`v0.6-markdown.md`](v0.6-markdown.md) — the editing surfaces, the `$`/`>`
|
||||
palette model, and the setup-recipe snippet catalog.
|
||||
- [`typoena-toml.md`](typoena-toml.md) — the sibling prefs file this is kept
|
||||
separate from, and the `>` command palette snippets share the surface with.
|
||||
- [`macroplan.md`](macroplan.md) — v0.6 scope.
|
||||
201
docs/typoena-toml.md
Normal file
201
docs/typoena-toml.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# `.typoena.toml` — editor preferences
|
||||
|
||||
> The git-tracked file that controls how the editor behaves — auto-save,
|
||||
> format-on-save, the line-number gutter, the boot file, and the panel theme. Hand-editable, or
|
||||
> changed live from the `Cmd-P` palette (booleans flip; the theme and auto-sync
|
||||
> interval rotate through preset options on **Enter**). Landed in **v0.5** (see
|
||||
> [`macroplan.md`](macroplan.md)).
|
||||
>
|
||||
> **Not to be confused with `/sd/typoena.conf`** — that holds the device
|
||||
> _secrets_ (Wi-Fi, PAT, remote URL, commit author), is gitignored, and is never
|
||||
> committed. `.typoena.toml` is _behaviour_, shared across devices; `typoena.conf`
|
||||
> is _secrets_, per-device. See [v0.1 product](v0.1-mvp-product.md).
|
||||
|
||||
## Location
|
||||
|
||||
```
|
||||
/sd/repo/.typoena.toml
|
||||
```
|
||||
|
||||
It lives inside the Tracked repo (`/sd/repo`), so it is **committed and pushed**
|
||||
like any note — which means the preferences **sync to every device** that clones
|
||||
the repo. That is deliberate: your editor behaviour follows you. (A per-device
|
||||
override for the one genuinely device-specific key, `auto_sync`, may layer on top
|
||||
later via `typoena.conf` — worth it only once `auto_sync` actually does something
|
||||
in v0.8. See the [auto_sync](#auto_sync) note.)
|
||||
|
||||
The file is read **once at boot**, before the first screen is drawn (so
|
||||
`line_numbers` shapes the opening frame, and `open_last_on_boot` picks the file
|
||||
that frame shows). A **missing, empty, or partial file is
|
||||
fine** — every absent key falls back to its default below, so a fresh card just
|
||||
works with no config present.
|
||||
|
||||
## Keys
|
||||
|
||||
| Key | Type | Default | Options | Effect |
|
||||
| ------------------- | ------ | --------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `save_on_idle` | bool | `true` | `true` / `false` | Auto-save the current buffer on the idle typing-pause, so `:w` is optional. |
|
||||
| `format_on_save` | bool | `true` | `true` / `false` | Run `:fmt` on the buffer before an explicit `:w`/`:sync`. |
|
||||
| `line_numbers` | bool | `true` | `true` / `false` | Show the absolute line-number gutter. Off reclaims its columns for text. |
|
||||
| `open_last_on_boot` | bool | `true` | `true` / `false` | Boot into the file that was active at power-off, instead of `notes.md`. |
|
||||
| `theme` | string | `"light"` | `light` / `dark` | Panel colour polarity. `dark` inverts the whole frame to white-on-black. |
|
||||
| `auto_sync` | string | `"10m"` | `2m` / `5m` / `10m` / `15m` / `30m` | Max-staleness cap for opportunistic auto-publish. **Value only — no behaviour yet** (rides v0.8, with the sleep work). |
|
||||
|
||||
The **Options** column is what the palette rotates through on **Enter**; a
|
||||
boolean is just the two-option case. Hand-editing a string key can still set any
|
||||
value — the palette only cycles the presets.
|
||||
|
||||
### Example
|
||||
|
||||
```toml
|
||||
# Typoena editor preferences — hand-editable, git-tracked.
|
||||
# Edit here, or change live from the Cmd-P palette (type `>`).
|
||||
save_on_idle = true
|
||||
format_on_save = true
|
||||
line_numbers = true
|
||||
open_last_on_boot = true
|
||||
theme = "light"
|
||||
auto_sync = "10m"
|
||||
```
|
||||
|
||||
### `save_on_idle`
|
||||
|
||||
When on, the firmware quietly persists a dirty, named buffer once typing has
|
||||
paused (~1.5 s), so a power pull can't cost more than the last couple of seconds
|
||||
of writing. It is a **safety net, not an action**:
|
||||
|
||||
- **Silent.** No snackbar, no forced screen refresh. A visible confirmation on
|
||||
every pause would cost a ~630 ms e-ink flash purely to say "saved" — exactly
|
||||
the gratuitous flashing the panel avoids elsewhere. `:w` remains the _loud_
|
||||
save (it posts `saved`).
|
||||
- **Unformatted.** The idle save never runs `:fmt` — see the
|
||||
[format_on_save](#format_on_save) note for why.
|
||||
- Fires **once per typing burst**; a failed save doesn't retry-storm (it's kept
|
||||
in RAM and re-attempted on the next burst, or on `:w`).
|
||||
|
||||
### `format_on_save`
|
||||
|
||||
Runs `:fmt` — table alignment, blank-line collapse, trailing-whitespace strip —
|
||||
on the buffer _before_ it is persisted, so `:sync` is **fmt → save → commit →
|
||||
push** and `:w` saves formatted.
|
||||
|
||||
**Formatting only happens on an explicit `:w`/`:sync`.** The `save_on_idle`
|
||||
auto-save is deliberately left unformatted: if it reformatted on every idle
|
||||
pause, tables would reflow and blank lines collapse _mid-session_, with the caret
|
||||
jumping under you every time you paused to think. Formatting is a deliberate act;
|
||||
the safety-net save is not.
|
||||
|
||||
### `line_numbers`
|
||||
|
||||
Shows the absolute line-number gutter (built always-on in v0.2). Turning it off
|
||||
returns the gutter's columns to the text, so prose gets the full writing width.
|
||||
Applied **live** — toggling it from the palette redraws immediately with (or
|
||||
without) the gutter.
|
||||
|
||||
### `open_last_on_boot`
|
||||
|
||||
Boot into the file that was active when the device powered off, instead of the
|
||||
default `notes.md` — power the typewriter back on and you are where you left
|
||||
off, caret on the last character as always.
|
||||
|
||||
Only the **choice** lives in this file. The last-active _path_ is device state,
|
||||
not shared behaviour: the firmware keeps it in a device-local marker
|
||||
(`/sd/.typoena-last`, beside the dirty journal at the card root), rewritten on
|
||||
every buffer switch. It is deliberately **not** stored here — a tracked key
|
||||
would dirty the repo on every file switch (riding each `:gp` as noise) and make
|
||||
two devices fight over a single "last file".
|
||||
|
||||
The fallback is safe by construction: a missing marker, a garbled one (power
|
||||
pull mid-write), or a marker naming a file that no longer exists (deleted here,
|
||||
or removed by a `:gl` from another device) simply boots the default `notes.md`.
|
||||
Turning the pref **off** doesn't stop the marker being recorded — it only stops
|
||||
boot from reading it — so flipping it back on resumes correctly from the very
|
||||
next boot.
|
||||
|
||||
### `theme`
|
||||
|
||||
Panel colour polarity: `light` (the native black-ink-on-white-paper) or `dark`
|
||||
(white-on-black). On the 1-bit e-paper panel this is not a palette swap but a
|
||||
**whole-frame invert** applied at the very end of the render, so text, selection,
|
||||
caret, side panel and command palette all flip together and each stays legible.
|
||||
Any value other than `dark` reads as light. Applied **live** — cycling it from
|
||||
the palette repaints inverted at once.
|
||||
|
||||
> **On e-paper, `dark` is not free.** Partial refreshes over a mostly-black field
|
||||
> ghost more than over white, and the panel is tuned for white-background reading.
|
||||
> It works, but expect a slightly muddier refresh than `light` — verify on-device.
|
||||
|
||||
### `auto_sync`
|
||||
|
||||
A duration string that will one day cap how stale the published copy is allowed
|
||||
to get — an _opportunistic, rate-limited_ push, not a wall-clock timer. The
|
||||
palette rotates it through the presets `2m` / `5m` / `10m` / `15m` / `30m`
|
||||
(hand-editing can still set any string, e.g. `"0"`/empty to disable). **The value
|
||||
is only stored and displayed in v0.5 — nothing reads it yet:** the periodic push
|
||||
lands with the sleep work in v0.8 (originally pencilled on v0.7's git work;
|
||||
v0.7 closed 2026-07-14 with manual `:gl`/`:gp` only), so
|
||||
cycling the interval today changes what will be honoured _then_, not now.
|
||||
Rationale for the `"10m"` default:
|
||||
[`tradeoff-curves/wifi-auto-sync.md`](tradeoff-curves/wifi-auto-sync.md).
|
||||
|
||||
## Editing it
|
||||
|
||||
Two ways, both landing in the same file:
|
||||
|
||||
1. **By hand** — it's plain text on the card; edit it on your computer and reboot
|
||||
to apply. (The palette hides dotfiles, but you can still open it in-editor with
|
||||
`:e repo/.typoena.toml`.)
|
||||
2. **Live, from the device** — open the settings list either way:
|
||||
- **`:settings`** — drops you straight into it, or
|
||||
- **`Cmd-P`** then type **`>`** — switches the file palette to the command
|
||||
list (VS Code semantics).
|
||||
|
||||
Every pref appears carrying its current state:
|
||||
|
||||
```
|
||||
> save on idle: on
|
||||
format on save: on
|
||||
line numbers: on
|
||||
open last on boot: on
|
||||
theme: light
|
||||
auto sync: 10m
|
||||
```
|
||||
|
||||
`Ctrl-N`/`Ctrl-P` move the selection; **Enter** advances the selected pref to
|
||||
its next value, applies it at once, writes the change back to `.typoena.toml`,
|
||||
and confirms the new state on the snackbar (e.g. `theme: dark - saved`). A
|
||||
boolean flips; the theme and auto-sync interval **rotate through their preset
|
||||
options and wrap** — same key, so the palette is uniformly "press Enter to
|
||||
change". **The list stays open** so you can change several prefs in a row;
|
||||
**Esc** (or `Cmd-P`) closes it. Each change rides the next `:sync` to your
|
||||
other devices.
|
||||
|
||||
`auto_sync` is a value command now, but has no behaviour to drive until v0.8 —
|
||||
cycling it sets the interval that the future periodic push will honour.
|
||||
|
||||
## Parsing
|
||||
|
||||
The reader is a deliberately tiny **line-based** parser, not a general TOML
|
||||
library — the file is flat `key = value` pairs (a bool, or a quoted string) with
|
||||
`#` comments, so a full TOML crate isn't worth pulling onto the firmware build.
|
||||
It lives in the host-testable `editor` crate (`Prefs::parse` / `Prefs::to_toml`).
|
||||
Rules:
|
||||
|
||||
- A `#` starts a comment to end of line (whole-line or trailing).
|
||||
- Blank lines and lines without `=` are ignored.
|
||||
- An **unrecognized key** is ignored; an **unparseable value** (e.g.
|
||||
`save_on_idle = yes`) leaves _that key_ at its default rather than reading as
|
||||
`false`.
|
||||
- Any key not present falls back to its default, so partial files are valid.
|
||||
|
||||
Because `Prefs::to_toml` round-trips with `Prefs::parse`, a palette edit rewrites
|
||||
the whole file in canonical form (with the header comment) — hand-added comments
|
||||
elsewhere in the file are not preserved across a palette toggle.
|
||||
|
||||
## See also
|
||||
|
||||
- [`macroplan.md`](macroplan.md) — v0.5 scope and the decisions behind these keys.
|
||||
- [`v0.1-mvp-product.md`](v0.1-mvp-product.md) — the `typoena.conf` device secrets
|
||||
this file is kept separate from.
|
||||
- [`tradeoff-curves/wifi-auto-sync.md`](tradeoff-curves/wifi-auto-sync.md) — why
|
||||
`auto_sync` defaults to 10 minutes.
|
||||
@@ -8,7 +8,7 @@
|
||||
> [technical design](v0.1-mvp-technical.md) (how it's built) ·
|
||||
> [ADR log](adr.md) (load-bearing decisions) ·
|
||||
> [QFD](qfd.md) (requirements ↔ functions ↔ components) ·
|
||||
> [roadmap](roadmap.md) (where v0.1 sits in the sequence).
|
||||
> [macroplan](macroplan.md) (where v0.1 sits in the sequence).
|
||||
|
||||
## One-line summary
|
||||
|
||||
@@ -52,11 +52,11 @@ at build time:
|
||||
a `.env` file gitignored locally) and embedded as `const &'static str`s in
|
||||
the binary.
|
||||
- The dev clones the git working copy onto the SD card from their laptop
|
||||
(`git clone` directly onto the mounted SD), so `/sd/local/notes.md` is
|
||||
(`git clone` directly onto the mounted SD), so `/sd/repo/notes.md` is
|
||||
present at first boot.
|
||||
- Re-configuring = re-build + re-flash. For a single-dev MVP, that's fine.
|
||||
|
||||
If the firmware boots and finds no `/sd/local` or no `/sd/local/notes.md`, it
|
||||
If the firmware boots and finds no `/sd/repo` or no `/sd/repo/notes.md`, it
|
||||
shows a one-line error on the splash and stops. No recovery flow ships in
|
||||
v0.1 — the dev re-mounts the SD and reboots.
|
||||
|
||||
@@ -75,7 +75,7 @@ Auth model rationale: [ADR-005](adr.md#adr-005-auth--https--github-personal-acce
|
||||
Boot (~3 s): Wi-Fi stays off (brought up on demand by `Ctrl-G`)
|
||||
│
|
||||
▼
|
||||
Open /sd/local/notes.md, place cursor at end of file
|
||||
Open /sd/repo/notes.md, place cursor at end of file
|
||||
│
|
||||
▼
|
||||
[EDITING] ← keypresses, partial refresh per edit
|
||||
@@ -99,49 +99,60 @@ strip aspect (~2.9:1) — biases the UX toward "current line and recent
|
||||
context," not a full page. Hardware rationale: [ADR-003](adr.md#adr-003-display-medium--e-ink-gdey0579t93-panel).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ notes.md ● 14:02:33 │ ← header
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor │
|
||||
│ incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud │
|
||||
│ exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. │
|
||||
│ │
|
||||
│ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu │
|
||||
│ fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in │ ← edit area
|
||||
│ culpa qui officia deserunt mollit anim id est laborum. │ (~11 lines)
|
||||
│ │
|
||||
│ The quick brown fox jumps over the lazy▎ │ (cursor block)
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ INSERT • unsaved • Wi-Fi — │ ← status
|
||||
└─────────────────────────────────────────────────────────────────────────────────────┘
|
||||
┌───────────────────────────────────────────────────────┬─────────────────────┐
|
||||
│ Lorem ipsum dolor sit amet, consectetur adipiscing │ notes.md ● │
|
||||
│ elit. Sed do eiusmod tempor incididunt ut labore et │ │
|
||||
│ dolore magna aliqua. Ut enim ad minim veniam, quis │ │
|
||||
│ nostrud exercitation ullamco laboris nisi ut aliquip │ 1 240 words │
|
||||
│ ex ea commodo consequat. │ │
|
||||
│ │ 12:07 elapsed │
|
||||
│ Duis aute irure dolor in reprehenderit in voluptate │ │
|
||||
│ velit esse cillum dolore eu fugiat nulla pariatur. │ │
|
||||
│ Excepteur sint occaecat cupidatat non proident. │ │
|
||||
│ │ 14:02 │
|
||||
│ The quick brown fox jumps over the lazy▎ │ Wi-Fi — │
|
||||
│ │ -- INSERT -- │
|
||||
└───────────────────────────────────────────────────────┴─────────────────────┘
|
||||
writing column (~60 cols, full height) side panel (~20 ch)
|
||||
```
|
||||
|
||||
- Header: filename, dirty indicator (●/○), wall clock if SNTP has set time.
|
||||
- Status: mode (always `INSERT` in v0.1), save state, Wi-Fi state
|
||||
(`—` = off, `✓` = connected, `✗` = failed; default is `—` because the
|
||||
radio is on-demand). `Ctrl-S` briefly flashes "saved at HH:MM" and
|
||||
returns. `Ctrl-G` transitions through `✓ committed abc1234 · pushing…`
|
||||
at ~0.2 s — the commit-landed moment, when the user's work is durable
|
||||
(see "Durability before delivery" in [`CONTEXT.md`](../CONTEXT.md)) —
|
||||
and ends at `✓ pushed abc1234` (or `publish failed: <reason>`, with the
|
||||
local commit preserved either way).
|
||||
- Cursor: solid block at the insertion point. **No blink.**
|
||||
- **Writing column** (left, ~60 cols, full panel height): only the text and a
|
||||
solid-block cursor at the insertion point. **No blink.** The seam at `x = 396`
|
||||
runs through it invisibly (the driver's split-and-mirror blit handles it).
|
||||
This is the only region that repaints as you type.
|
||||
- **Side panel** (right, ~150 px / ~20 cols, full height): all metadata lives
|
||||
here, so the writing column keeps the full height. Top — **filename** + dirty
|
||||
dot (●/○). Middle — **word count** and **elapsed time**, refreshed on a short
|
||||
typing pause, _not_ per keystroke. Bottom-left — the **mode** indicator
|
||||
(always `INSERT` in v0.1) with any pending count/operator echo. Bottom —
|
||||
ambient state: **clock** (if SNTP has set the time), **Wi-Fi** (`—` =
|
||||
off/on-demand, `✓` = connected, `✗` = failed), a **keyboard-disconnect** flag
|
||||
(rendered `NO KBD` — Latin-9 has no `⌨`/`✗` glyph — shown _only_ while the
|
||||
keyboard is dropped; blank when healthy, per
|
||||
[`CONTEXT.md`](../CONTEXT.md) "No state the user didn't ask for"), and
|
||||
**publish state**. `Ctrl-S` briefly flashes "saved HH:MM";
|
||||
`Ctrl-G` transitions through `✓ committed abc1234 · pushing…` at ~0.2 s — the
|
||||
commit-landed moment, when the user's work is durable (see "Durability before
|
||||
delivery" in [`CONTEXT.md`](../CONTEXT.md)) — and ends at `✓ pushed abc1234`
|
||||
(or `publish failed: <reason>`, with the local commit preserved either way).
|
||||
- **Deliberately not shown:** any cursor-position readout (_line_ or _column_),
|
||||
character count, or reading-time estimate — each repaints a panel row on cursor
|
||||
movement or per keystroke, on a row apart from the cursor, doubling the ghosting
|
||||
rate for little writerly value. v0.1 ships no positional readout at all.
|
||||
|
||||
Fonts: one mono font, embedded in firmware. Target sizing: a 14 px mono
|
||||
glyph at roughly 8 px advance gives ~95 columns × ~11 visible lines of edit
|
||||
area after header (16 px) and status (16 px) bars. Final size tuned during
|
||||
spike 2 against real legibility on the panel.
|
||||
Fonts: one mono font for the writing column, a smaller one for the panel, both
|
||||
embedded in firmware. Sizing target: ~60 columns of writing (down from the
|
||||
full-width ~79 — too wide for a comfortable line length) across the full ~13
|
||||
visible lines, now that metadata has moved off the top/bottom bars into the side
|
||||
panel. Final size tuned during spike 2 against real legibility on the panel.
|
||||
|
||||
**UX consequence of the strip aspect:** the cursor stays near the bottom of
|
||||
the visible area during normal typing; the edit area scrolls one line at a
|
||||
the visible area during normal typing; the writing column scrolls one line at a
|
||||
time. There is no "page" — just a continuously advancing strip. Embrace it,
|
||||
don't fight it.
|
||||
|
||||
**What scroll looks like on e-ink:** every line-scroll is a partial refresh
|
||||
of the full edit area — every line moves, there is no small dirty rect for
|
||||
of the full writing column — every line moves, there is no small dirty rect for
|
||||
scroll — so each scroll costs ~200–300 ms. Per-keystroke partial refreshes
|
||||
accumulate ghosting; the render module clears it by upgrading every 20th
|
||||
refresh to a full refresh, deferred to the next idle pause ≥ 1 s so it
|
||||
@@ -153,18 +164,19 @@ a save.
|
||||
|
||||
## Error UX
|
||||
|
||||
| Failure | What the user sees |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| No SD card on boot | "no SD card — insert one and reboot" |
|
||||
| SD card mounted but `/sd/local` missing | "missing /sd/local — re-provision and reboot" (dev re-clones onto SD) |
|
||||
| Wi-Fi credentials wrong (build-time env vars) | status shows `Wi-Fi ✗`; dev rebuilds with correct env vars and re-flashes |
|
||||
| Wi-Fi won't connect | status shows `Wi-Fi ✗`; editing still works; `Ctrl-G` shows "no network" |
|
||||
| `Ctrl-G` publish fails (auth, network, merge conflict) | status shows reason; local commit is preserved; user can retry — non-fast-forward auto-recovers via pull-then-push and is not surfaced as a failure unless the pull itself conflicts |
|
||||
| File write fails | status flashes "save failed"; buffer stays dirty |
|
||||
| Keyboard disconnects | header shows `⌨ ✗`; editing pauses; reconnects automatically |
|
||||
| Failure | What the user sees |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No SD card on boot | "no SD card — insert one and reboot" |
|
||||
| SD card mounted but `/sd/repo` missing | "missing /sd/repo — re-provision and reboot" (dev re-clones onto SD) |
|
||||
| Wi-Fi credentials wrong (build-time env vars) | panel Wi-Fi field shows `✗`; dev rebuilds with correct env vars and re-flashes |
|
||||
| Wi-Fi won't connect | panel Wi-Fi field shows `✗`; editing still works; `Ctrl-G`'s publish-state field shows "no network" |
|
||||
| `Ctrl-G` publish fails (auth, network, merge conflict) | panel publish-state field shows the reason; local commit is preserved; user can retry — non-fast-forward auto-recovers via pull-then-push and is not surfaced as a failure unless the pull itself conflicts |
|
||||
| File write fails | panel save field flashes "save failed"; buffer stays dirty |
|
||||
| Keyboard disconnects | panel shows `⌨ ✗` (blank once reconnected); editing pauses; reconnects automatically |
|
||||
|
||||
No modal dialogs. Errors live in the status line. The editor is never blocked
|
||||
from accepting keystrokes (except during the partial-refresh frame itself).
|
||||
No modal dialogs. Errors live in the side panel's ambient-state fields. The
|
||||
editor is never blocked from accepting keystrokes (except during the
|
||||
partial-refresh frame itself).
|
||||
|
||||
## Out of scope for v0.1
|
||||
|
||||
@@ -193,7 +205,23 @@ to an engineering function with a measured target in
|
||||
[qfd.md §6](qfd.md#6-critical-performance-budget); that's the
|
||||
place to check before declaring an item done.
|
||||
|
||||
- [ ] After a cold boot with valid pre-flashed config, cursor is ready in ≤ 5 s.
|
||||
> **Status 2026-07-11:** v0.1 was declared **delivered**, with acceptance
|
||||
> criteria run as **post-ship hardening**. The 1-hour soak is attested from
|
||||
> sustained real use, and cold boot is now **verified at 4258 ms** (under the 5 s
|
||||
> gate, after a boot-refresh fix — see below). One criterion remains known **not**
|
||||
> to pass: `Ctrl-G`'s pull-then-retry path is **not yet implemented** (deferred
|
||||
> to v0.9).
|
||||
|
||||
- [x] After a cold boot with valid pre-flashed config, cursor is ready in ≤ 5 s.
|
||||
— **VERIFIED 2026-07-11: 4258 ms power-on → cursor** (`boot: cursor ready`
|
||||
log prefix; 742 ms under the gate). First measured ~5.5 s (over the gate);
|
||||
the fix was to bring the editor up with a full-area partial (~630 ms) instead
|
||||
of a second full refresh (~1.9 s) — panel confirmed clean, no splash
|
||||
ghosting. (Instrument note: `esp_timer_get_time` reads only the ~2.85 s
|
||||
app-side slice — it starts ~1.4 s in, after the bootloader + the ~0.74 s
|
||||
PSRAM memtest; the log prefix / `esp_log_timestamp` is the real
|
||||
from-power-on number.) Remaining boot levers for the v1.0 ≤ 3 s target: the
|
||||
~1.9 s splash full refresh and the ~0.74 s PSRAM memtest.
|
||||
- [ ] Typing a 1000-word paragraph never drops a keystroke and never lags
|
||||
more than 300 ms behind the keyboard.
|
||||
- [ ] `Ctrl-S` durably writes the file (verified by power-cycling immediately
|
||||
@@ -203,8 +231,11 @@ place to check before declaring an item done.
|
||||
has moved on since the last publish).
|
||||
- [ ] Pulling power during typing never corrupts the file; the previous saved
|
||||
state is recoverable.
|
||||
- [ ] One hour of continuous typing without crash, freeze, or memory
|
||||
exhaustion.
|
||||
- [x] One hour of continuous typing without crash, freeze, or memory
|
||||
exhaustion. — attested by the author 2026-07-11 from sustained real-use
|
||||
sessions (no crash, freeze, or memory exhaustion since the editor was wired
|
||||
up). Author attestation, not a controlled instrumented run; a heap-watermark
|
||||
log over a timed session would make it rigorous.
|
||||
|
||||
## Non-goals as success criteria
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
> Decisions referenced inline point at [`adr.md`](adr.md). Tradeoff weights
|
||||
> and the critical-performance budget live in [`qfd.md`](qfd.md). Project
|
||||
> overview: [`../README.md`](../README.md). Release sequence:
|
||||
> [`roadmap.md`](roadmap.md).
|
||||
> [`macroplan.md`](macroplan.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -42,13 +42,13 @@ blocked by I/O.
|
||||
|
||||
### Threads / tasks
|
||||
|
||||
| Task | Core | Stack | Responsibility |
|
||||
| ------------- | ---- | ----- | -------------------------------------------------------- |
|
||||
| `usb_task` | 0 | 8 KB | TinyUSB host loop, decode HID reports, post `KeyEvent`s |
|
||||
| Task | Core | Stack | Responsibility |
|
||||
| ------------- | ---- | ----- | --------------------------------------------------------- |
|
||||
| `usb_task` | 0 | 8 KB | TinyUSB host loop, decode HID reports, post `KeyEvent`s |
|
||||
| `wifi_task` | 0 | 8 KB | On-demand station: off by default, brought up by `Ctrl-G` |
|
||||
| `ui_task` | 1 | 16 KB | Consume `KeyEvent`s, mutate editor state, enqueue render |
|
||||
| `render_task` | 1 | 12 KB | Drain render queue, do partial/full refresh on EPD |
|
||||
| `git_task` | 1 | 32 KB | Triggered by `Ctrl-G`; runs gitoxide commit + push |
|
||||
| `ui_task` | 1 | 16 KB | Consume `KeyEvent`s, mutate editor state, enqueue render |
|
||||
| `render_task` | 1 | 12 KB | Drain render queue, do partial/full refresh on EPD |
|
||||
| `git_task` | 1 | 96 KB | Triggered by `Ctrl-G`; runs libgit2 commit + push |
|
||||
|
||||
All inter-task communication is via `crossbeam-channel` or `std::sync::mpsc`
|
||||
bounded queues. The editor state is `Arc<Mutex<EditorState>>`; the lock is
|
||||
@@ -65,14 +65,17 @@ read snapshot (render diff).
|
||||
4. Mount FAT on SD → verify /sd/repo and /sd/repo/notes.md exist
|
||||
├─ missing → fatal: "missing /sd/repo — re-mount SD and reboot"
|
||||
└─ present → continue
|
||||
5. Init SPI bus (shared: EPD + SD on different CS)
|
||||
5. Init two SPI buses: EPD on SPI2, SD on SPI3 (separate hosts — ADR-012)
|
||||
6. Init EPD, full refresh: splash + boot log
|
||||
7. Start tasks: usb, wifi (spawned in `Off` state — no radio bring-up), ui, render
|
||||
8. ui_task opens /sd/repo/notes.md, places cursor, enqueues full render
|
||||
9. STEADY STATE
|
||||
```
|
||||
|
||||
Target boot time: ≤ 5 s to cursor (v0.1). The 3 s target is v1.0.
|
||||
Target boot time: ≤ 5 s to cursor (v0.1). The 3 s target is v1.0. Measured cold
|
||||
boot is **4258 ms** (2026-07-11) — under the v0.1 gate; the full breakdown and why
|
||||
≤ 3 s is hard (one ~1.9 s full refresh is unavoidable at cold boot) is in
|
||||
[`notes/boot-time-budget.md`](notes/boot-time-budget.md).
|
||||
|
||||
## Hardware bring-up order
|
||||
|
||||
@@ -89,20 +92,31 @@ spike 4 is the gate for
|
||||
— critically — whether `epd-waveshare` already supports the panel's
|
||||
controller (SSD1683-class) or whether we write a thin custom driver
|
||||
against `embedded-hal`. Either way, ~300 LoC; this spike answers which.
|
||||
3. **Spike 3 — SD.** Mount FAT, read/write a file. Validates SPI sharing with
|
||||
EPD (or separate bus if needed).
|
||||
3. **Spike 3 — SD.** Mount FAT, read/write a file. Verified 2026-07-11; the
|
||||
shared-bus question resolved to a separate SPI3 for the SD (ADR-012).
|
||||
4. **Spike 4 — USB host.** Enumerate the Nuphy as a boot-protocol HID
|
||||
keyboard, log keycodes over UART.
|
||||
5. **Spike 5 — Partial refresh.** Type a string letter-by-letter, partial
|
||||
refresh per character, measure end-to-end latency.
|
||||
6. **Spike 6 — Wi-Fi + TLS.** Connect to home Wi-Fi, do an HTTPS GET to
|
||||
`api.github.com`, validate cert chain.
|
||||
7. **Spike 7 — gitoxide push.** Smoke test: from desktop-Rust first, then on
|
||||
device, push a single commit to a test repo over HTTPS+PAT.
|
||||
7. **Spike 7 — git push.** Smoke test: from desktop-Rust first, then on
|
||||
device, push a single commit to a test repo over HTTPS+PAT. **Done** — the
|
||||
gitoxide fall-back to **libgit2** (`git2`) fired here; push is proven on
|
||||
hardware ([postmortem](postmortems/2026-07-05-spike7-gix-https-push.md)).
|
||||
|
||||
Only after spike 7 do we start integration. Any spike that fails forces a
|
||||
stack decision (e.g. fall back to libgit2; switch to a separate SD SPI bus).
|
||||
|
||||
### Rendering spikes (post-bring-up)
|
||||
|
||||
The display/UX spikes (8–14) — panel layout, boot splash, theme, transient
|
||||
panel, scroll indicator, line-number gutter, multi-file navigation — are **not**
|
||||
part of the v0.1 integration gate and span several releases, so they live in
|
||||
their own log: [`spikes.md`](spikes.md). Spikes **8 (layout)** and **9 (splash)**
|
||||
feed v0.1; the rest feed later releases. Run Spike 8 first — it partitions the
|
||||
panel into the writing column and side panel the others draw within.
|
||||
|
||||
## Module breakdown
|
||||
|
||||
### `editor` — text buffer + cursor
|
||||
@@ -135,9 +149,11 @@ Custom widget layer on `embedded-graphics` —
|
||||
Owns the top-ranked engineering functions (latency, refresh-region area)
|
||||
in [qfd.md §3](qfd.md#3-house-of-quality--whats--hows).
|
||||
|
||||
- A render request is a `RenderOp { Lines(range) | Status(text) | FullRefresh }`.
|
||||
- A render request is a `RenderOp { Lines(range) | Panel(field, text) | FullRefresh }`.
|
||||
The **side panel** is multi-field (Spike 8), so the old single `Status(text)`
|
||||
op becomes a per-field write; the exact field enum is what Spike 8 decides.
|
||||
- The render thread maintains a shadow of the current screen contents per
|
||||
region (header / edit area lines / status).
|
||||
region (**writing-column** lines / **side-panel** fields).
|
||||
- For `Lines(range)`: clear the region's bounding box, draw glyphs, partial
|
||||
refresh that rect.
|
||||
- Glyph cache: rasterised mono font kept in PSRAM; one entry per (codepoint).
|
||||
@@ -150,11 +166,26 @@ in [qfd.md §3](qfd.md#3-house-of-quality--whats--hows).
|
||||
Storage split rationale:
|
||||
[ADR-007](adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config).
|
||||
|
||||
- Atomic save: write to `notes.md.tmp`, fsync, rename. We accept FAT's
|
||||
weakness here; on power loss between rename and dir flush, the user gets
|
||||
the previous version, which is the documented behavior.
|
||||
Implemented in [`firmware::persistence`](../firmware/src/persistence.rs)
|
||||
(`Storage::{mount, load, save, recover}`), graduated from Spike 3 and
|
||||
hardware-verified 2026-07-11.
|
||||
|
||||
- Atomic save: write to `notes.md.tmp`, fsync, unlink the target (FatFS
|
||||
`f_rename` won't overwrite — ADR-007), rename. On power loss the user gets
|
||||
the previous version, the documented behavior. Boot recovery reconciles a
|
||||
leftover `*.tmp`: kept-target-if-both-present, promote-if-target-gone (see
|
||||
ADR-007 for why "just promote" is unsafe).
|
||||
- The file is read fully into the rope at boot. v0.1 caps file size at
|
||||
256 KB; larger files refuse to open with a clear message.
|
||||
256 KB; larger files refuse to open with a clear message. Saving is not
|
||||
capped — the buffer is always persistable.
|
||||
- **Wired into `main.rs`** (2026-07-11): the editor boot-loads the note via
|
||||
`Editor::with_text(Storage::load())` — a missing card / repo / unreadable note
|
||||
halts boot with the reason painted on the panel — and `:w`/`:sync` persist
|
||||
through `Storage::save`, inline on the UI loop (a small-file write is tens of
|
||||
ms). Save errors are logged and the RAM buffer kept, so a card pulled
|
||||
mid-session doesn't lose work. The git-push half of `:sync` is **not** wired
|
||||
yet: it awaits `git_sync` being graduated into a module and run on the
|
||||
dedicated git thread (see `git` below), so `:sync` currently just saves.
|
||||
|
||||
### `wifi` — on-demand station
|
||||
|
||||
@@ -164,8 +195,8 @@ Storage split rationale:
|
||||
radio; on-demand is closer to ~25 mAh/day at 10 Publishes/day. See
|
||||
[qfd.md H13](qfd.md#6-critical-performance-budget).
|
||||
- **Bring-up on `Ctrl-G`.** `wifi_task` transitions `Off → Associating →
|
||||
Connected`. Association timeout: 10 s. If association fails, the local
|
||||
commit is preserved and the status line shows `⌁ ✗`; no background retry.
|
||||
Connected`. Association timeout: 10 s. If association fails, the local
|
||||
commit is preserved and the side panel's Wi-Fi field shows `✗`; no background retry.
|
||||
- **Grace window** of ~90 s after a Publish completes. A `Ctrl-G` inside
|
||||
the window reuses the connection (no second association cost). If no
|
||||
Publish fires before the window expires, the radio tears down.
|
||||
@@ -185,26 +216,46 @@ to v0.9. v0.1 reads all config from the binary at build time — see
|
||||
|
||||
### `git` — commit + push
|
||||
|
||||
`gitoxide` choice and kill-switch:
|
||||
[ADR-004](adr.md#adr-004-git-implementation--gitoxide-gix).
|
||||
Auth model: [ADR-005](adr.md#adr-005-auth--https--github-personal-access-token).
|
||||
Library + kill-switch:
|
||||
[ADR-004](adr.md#adr-004-git-implementation--gitoxide-gix) — the gitoxide bet was
|
||||
**retired in spike 7** (gix has no HTTP(S) push at all), so the module is built on
|
||||
**libgit2 via the `git2` crate**.
|
||||
Auth + transport are settled: **HTTPS + PAT over esp-idf's mbedTLS**
|
||||
([ADR-005](adr.md#adr-005-auth--https--github-personal-access-token)) — no custom
|
||||
transport and no `reqwest`/`rustls` layer are needed; libgit2's own smart-HTTP
|
||||
client speaks TLS through the mbedTLS stream it is compiled against. The full
|
||||
`init → commit → push` path is **proven on hardware** — see the
|
||||
[spike 7 postmortem](postmortems/2026-07-05-spike7-gix-https-push.md).
|
||||
PSRAM heap during push is a top-3 watched metric — see
|
||||
[qfd.md §6](qfd.md#6-critical-performance-budget).
|
||||
|
||||
- `gix` with the smart-HTTP transport backed by `esp-idf` mbedtls (via a
|
||||
custom transport impl, or `gix-transport` with `reqwest`+`rustls-mbedtls`
|
||||
if that path is cleaner — decided in spike 7).
|
||||
- libgit2 **1.9.4** is vendored as an esp-idf component
|
||||
(`firmware/components/libgit2`) built with `GIT_HTTPS + GIT_MBEDTLS`; `git2`
|
||||
binds it in system mode. A short `esp_stubs.c` supplies the POSIX calls
|
||||
picolibc/FATFS lack (`utimes` existence-gate, `p_rename` replace, uid/symlink).
|
||||
- The server cert chain is verified against an embedded GitHub root bundle
|
||||
(`GIT_OPT_SET_SSL_CERT_LOCATIONS`); the `certificate_check` callback returns
|
||||
PASSTHROUGH so libgit2's own verdict stands → **fail-closed** on an untrusted /
|
||||
MITM cert. A product should refresh those roots (or reuse esp-idf's bundle) at
|
||||
provisioning.
|
||||
- Runs on a **dedicated ~96 KB thread**, not the shared UI/main stack: libgit2's
|
||||
init→push chain nests ~10 `GIT_PATH_MAX` (4 KB) buffers (~67 KB measured).
|
||||
- Operations needed in v0.1 (the [`gct` shell function](../CONTEXT.md#user-facing-actions) is the reference):
|
||||
- `gix::open` the existing working copy at `/sd/repo`
|
||||
- stage everything under the working copy (`git add .` equivalent)
|
||||
- open a **persistent clone** at `/sd/repo` (spike 7 proved the flow on a
|
||||
fresh per-boot init pushing a throwaway `device/<unix>` branch, to isolate
|
||||
the transport from history reconciliation; the product keeps one clone and
|
||||
fast-forwards — storage is [ADR-007](adr.md), still SD-vs-flash-FAT while SD
|
||||
is blocked)
|
||||
- stage everything under the working copy (`git add --all`, incl. deletions)
|
||||
- short-circuit return if nothing is staged — status: "nothing to publish"
|
||||
- commit with author from config, message `"<ISO-8601 timestamp>"` (no `wip`
|
||||
prefix; the timestamp _is_ the message)
|
||||
- push HEAD to `origin/<current branch>`
|
||||
- push HEAD to `origin/<current branch>` (normally a clean fast-forward)
|
||||
- on push failure: `git pull --no-edit` (merge), then retry the push once.
|
||||
Only surface failure to the status line if the pull conflicts or the
|
||||
second push also fails.
|
||||
- The PAT is loaded into the Authorization header per request; never logged.
|
||||
Only surface failure to the side panel's publish-state field if the pull
|
||||
conflicts or the second push also fails.
|
||||
- The PAT is handed to libgit2's credential callback (`USER_PASS_PLAINTEXT`);
|
||||
never logged, never persisted to the working copy.
|
||||
- The whole sequence is atomic from the user's view — see
|
||||
[`CONTEXT.md` → Publish](../CONTEXT.md#user-facing-actions).
|
||||
|
||||
@@ -243,7 +294,7 @@ ESP32-S3-N16R8: 512 KB SRAM + 8 MB PSRAM. We budget conservatively.
|
||||
| Internal SRAM | ~80 KB | mbedtls runtime (TLS handshake working set) |
|
||||
| PSRAM | ~128 KB | EPD framebuffer (792×272×1 ≈ 27 KB) + shadow + glyph cache |
|
||||
| PSRAM | ~512 KB | rope buffer + edit history headroom |
|
||||
| PSRAM | ~1.5 MB | gitoxide working set during push (pack delta, etc.) |
|
||||
| PSRAM | ~1.5 MB | libgit2 working set during push (pack delta, etc.) |
|
||||
| PSRAM | rest | heap headroom |
|
||||
|
||||
PSRAM is the default for `Box::new` via a custom allocator wrapper; DMA-able
|
||||
@@ -303,13 +354,13 @@ does not implement key rotation.
|
||||
Mirrored as live conflicts in
|
||||
[qfd.md §7 "Conflicts left explicitly unresolved by v0.1"](qfd.md#7-tradeoffs-and-their-why-linked-to-adrs).
|
||||
|
||||
| Risk | Symptom we'd see | Fallback |
|
||||
| ---------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `gix` smart-HTTP push doesn't work on `esp-idf-rs` mbedtls | spike 7 fails | switch to `libgit2-sys` (C, well-trodden) for v0.1 only |
|
||||
| TinyUSB host drops HID reports under load | dropped keystrokes during fast typing | enable larger USB rx buffer; if still bad, fall back to BLE-HID for v0.1 |
|
||||
| EPD partial refresh slower than 200 ms | typing feels laggy | reduce font size to shrink dirty area; or render multi-char bursts |
|
||||
| TLS heap pressure on PSRAM | OOM during push | tune mbedtls to smaller cipher suites; force GC of glyph cache before push |
|
||||
| SD + EPD on same SPI bus collide | corruption on save during render | move SD to a separate SPI peripheral (ESP32-S3 has two) |
|
||||
| Risk | Symptom we'd see | Fallback |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ~~`gix` smart-HTTP push doesn't work on `esp-idf-rs` mbedtls~~ **fired 2026-07-05**: gix has no HTTP(S) push at all (only `file://`/`ssh://`) | spike 7 (desktop) | **switched to `libgit2` (`git2`)** — mechanics proven on desktop; on-device libgit2/mbedtls cross-compile is the new gate. See [postmortem](postmortems/2026-07-05-spike7-gix-https-push.md). |
|
||||
| TinyUSB host drops HID reports under load | dropped keystrokes during fast typing | enable larger USB rx buffer; if still bad, fall back to BLE-HID for v0.1 |
|
||||
| EPD partial refresh slower than 200 ms | typing feels laggy | reduce font size to shrink dirty area; or render multi-char bursts |
|
||||
| TLS heap pressure on PSRAM | OOM during push | tune mbedtls to smaller cipher suites; force GC of glyph cache before push |
|
||||
| SD + EPD on same SPI bus collide | corruption on save during render | **ADOPTED (ADR-012):** SD on its own SPI3 host |
|
||||
|
||||
Every one of these is detected by a spike before integration starts — we are
|
||||
not finding them at the end.
|
||||
|
||||
39
docs/v0.2-navigation.md
Normal file
39
docs/v0.2-navigation.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# v0.2 — Vim navigation
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status:** COMPLETE 2026-07-11. Navigation done in core; the **UTF-8-correct
|
||||
buffer** and **`Ctrl-d/u` half-page scroll** landed and are hardware-verified,
|
||||
and the **absolute line-number gutter** is built, host-tested, and **confirmed
|
||||
on the panel (Spike 13) 2026-07-11** — a single-line edit repaints only the rows
|
||||
at/below the change and forces no extra full refresh. Shipped early beyond scope:
|
||||
a read-only **View** mode and the full `d`/`c` operator + text-object grammar
|
||||
(see [v0.3](v0.3-editing.md) / [v0.4](v0.4-visual-and-ex.md)).
|
||||
|
||||
- [x] Mode state machine (Normal / Insert / View), mode indicator in the status strip
|
||||
- [x] Movement: `h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d Ctrl-u`. `Ctrl-d/u`
|
||||
step **display** (soft-wrapped) rows, not logical lines — half a page is
|
||||
half the visible window however prose wraps; decoded as `HalfPageDown/Up`
|
||||
intents in the keymap, caret moves and the viewport follows.
|
||||
- [x] `i a o O A` to enter Insert
|
||||
- [x] `Esc` returns to Normal
|
||||
- [x] Line numbers in the left gutter: **absolute**, built + host-tested
|
||||
2026-07-11, **confirmed on the panel (Spike 13) 2026-07-11** — numbered on a
|
||||
logical line's first display row, blank on wrapped continuation rows; the
|
||||
gutter width tracks the buffer's line count (2 digits + separator, widening
|
||||
past 99 lines) and steals its columns from the soft-wrap. **Always on** in
|
||||
v0.2; the on/off toggle rides the [v0.5](v0.5-palette-and-multi-file.md)
|
||||
`.typoena.toml` prefs.
|
||||
Relative numbering was dropped (2026-07-11): renumbering the whole gutter on
|
||||
every `j`/`k` burns the e-ink ghosting budget for no proportionate gain,
|
||||
whereas absolute renumbers only the rows below an edit — the on-panel check
|
||||
confirmed a single-line edit repaints only rows at/below it with no extra
|
||||
full refresh.
|
||||
- [x] Groundwork — UTF-8-correct buffer: caret motions and edits step by
|
||||
character, not byte (dropped the ASCII == byte-offset assumption), so every
|
||||
motion stays correct with accented input. **Done 2026-07-11** alongside
|
||||
extracting the editor into a host-testable crate — char-step
|
||||
motions/deletes, byte-vs-char split in `layout`/`caret_rc`, `word_end`/`de`
|
||||
fixed; 15 host tests. Render font is ISO-8859-15 (Latin-9), so accented
|
||||
glyphs display.
|
||||
25
docs/v0.2.5-international-input.md
Normal file
25
docs/v0.2.5-international-input.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# v0.2.5 — International input
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status:** DONE in core, **hardware-verified 2026-07-11** (typed ç é è ñ on the
|
||||
bench, no crash). US-International dead-key accent composition lives in the
|
||||
`keymap` crate — a `Composer` downstream of the decoder — wired into
|
||||
`usb_kbd.rs` so the editor still receives a single `Key::Char`. Builds on the
|
||||
[v0.2](v0.2-navigation.md) UTF-8-correct buffer and the ISO-8859-15 render font.
|
||||
Host-tested.
|
||||
|
||||
- [x] Dead keys — grave, acute, circumflex, diaeresis, tilde — compose with
|
||||
the next letter: à é ê ë ñ, ç (via `'`+c), both cases
|
||||
- [x] `'`+space emits a literal apostrophe (the everyday apostrophe path); a
|
||||
dead key followed by a non-composing letter emits the accent then the
|
||||
letter
|
||||
- [x] A non-character event (Enter, Backspace, arrows) flushes any pending
|
||||
accent as its literal first
|
||||
- [ ] ~~Pending-accent indicator in the side-panel status strip~~ — **DROPPED
|
||||
(2026-07-11 decision):** at typing speed it would be stale before the
|
||||
~630 ms panel repaint, so it conveys nothing. Left unbuilt on purpose.
|
||||
- [x] Bonus (2026-07-11): the physical **Esc key** (HID 0x29) now types
|
||||
`` ` ``/`~` — Esc comes from the Caps tap — so grave/tilde accents and
|
||||
Markdown code fences are reachable on a 60% board without a Fn layer.
|
||||
30
docs/v0.3-editing.md
Normal file
30
docs/v0.3-editing.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# v0.3 — Vim editing
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status:** COMPLETE in core 2026-07-11, host-tested (65 editor + 28 keymap
|
||||
tests) and **partially smoke-tested on the panel 2026-07-11**. The three
|
||||
remaining pieces landed together: a single unnamed **register** with
|
||||
`y`/`yy`/`p`/`P` (and `x`/`d`/`c` filling it, so `dd`…`p` moves a line),
|
||||
**undo/redo** (`u`/`Ctrl-r`, snapshot-based, bounded to 100 groups in PSRAM — a
|
||||
whole Insert session undoes as one group), and **`.` repeat** (keystroke-recorded,
|
||||
so it replays an insert session like `ciwfoo<Esc>`). The `d`/`c` operator grammar
|
||||
and text objects had already landed ahead of schedule. On device, `dd`, `yy`, and
|
||||
`Ctrl-r` confirmed good; the one issue found was that a **multi-line paste near
|
||||
the bottom left its later lines below the fold** — `adjust_scroll` only kept the
|
||||
caret's (first) pasted line visible. Fixed by a `reveal()` that scrolls the end of
|
||||
the pasted block into view while the caret stays on its first line (reflash to
|
||||
re-confirm on panel).
|
||||
|
||||
- [x] `x dd`, `dw dd d$` (✓); `yy p P` (✓) and `.` repeat (✓) — register + a
|
||||
keystroke-recorded last-change both landed 2026-07-11
|
||||
- [x] Undo / redo (`u`, `Ctrl-r`) — snapshot history bounded to 100 groups in
|
||||
PSRAM; one Insert session = one undo group
|
||||
- [x] Numeric prefixes (`3dd`, `5j`)
|
||||
- [x] Ahead of schedule: `c` change operator + text objects
|
||||
(`ciw`, `di(`, `ca"`, … — inner/around, nesting-aware)
|
||||
|
||||
Known limits (deferred): `.` drops a _leading_ count (`3x` then `.` deletes one;
|
||||
a count inside an operator like `d2w` is kept); no named registers; `.` after an
|
||||
aborted operator (`d<Esc>`) is a no-op.
|
||||
34
docs/v0.4-visual-and-ex.md
Normal file
34
docs/v0.4-visual-and-ex.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# v0.4 — Visual mode + ex commands
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status:** COMPLETE in core 2026-07-11, host-tested (83 editor tests), on-device
|
||||
smoke-test pending. Charwise **Visual** (`v`) and linewise **VisualLine** (`V`)
|
||||
selection landed with `y`/`d`/`c` on the span: charwise is vim-inclusive of the
|
||||
char under the further caret, linewise spans whole logical lines and fills the
|
||||
register linewise (so `Vy`…`p` copies a line, `Vd` deletes it like `dd`). Motions
|
||||
(`h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d/u`) and counts extend the selection;
|
||||
`v`/`V` toggle/switch submode, `Esc` cancels. The selection renders as
|
||||
reverse-video cells (black fill, glyphs redrawn white) — the only selection
|
||||
affordance on a 1-bit panel — with the caret cell punched back to _normal_ video
|
||||
so the active end stands out. The Normal-mode motions were factored into a shared
|
||||
`move_by` helper so Normal and Visual can't drift.
|
||||
|
||||
**DECISION (2026-07-07, resolved 2026-07-11):** `v`/`V` = **Visual** selection
|
||||
(vim-standard). The read-only **View** (reading/scroll) mode that used to sit on
|
||||
`v`/`V` moved to **`gr`** (go-read) — a `g`-prefixed gesture reusing the existing
|
||||
pending-`g` machinery, no vim clash. View mode stays; `v`/`V` are now Visual.
|
||||
|
||||
- [x] Visual char (`v`) and line (`V`) modes, `y d c` on selections — landed
|
||||
2026-07-11 (18 new tests). Known limits (deferred): no `o` swap-ends, no
|
||||
`x`/`s` operator aliases, no Visual `.` repeat, no `:'<,'>` range commands.
|
||||
- [~] `:` command line (mechanism ✓; `:w`/`:wq`/`:x` save, `:fmt`/`:sync`/`:gl`
|
||||
wired; `:q` deliberately dropped — nothing to quit to). Command-line
|
||||
editing added 2026-07-11: Ctrl-W deletes the previous word, Cmd-Backspace
|
||||
clears the line. **`:e <path>` deferred to [v0.5](v0.5-palette-and-multi-file.md)** — opening another file
|
||||
needs host file-IO + buffer switching, which is v0.5's multi-file work
|
||||
(gated behind Spikes 11/14); half-building it here ahead of its
|
||||
dirty-buffer handling wasn't worth it.
|
||||
- [x] Ahead of schedule / unscheduled: `:fmt` Markdown formatter
|
||||
(table alignment, blank-line collapse, trailing-whitespace strip)
|
||||
288
docs/v0.5-palette-and-multi-file.md
Normal file
288
docs/v0.5-palette-and-multi-file.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# v0.5 — File palette + multi-file
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md). Prefs reference:
|
||||
> [typoena-toml.md](typoena-toml.md).
|
||||
|
||||
**Status:** buffer **foundation** landed in core 2026-07-11 (slice 1 of 4),
|
||||
host-tested; the palette + transient panel (Spike 11) and delete → git-staging
|
||||
(Spike 14) remain the on-device gates. The single-file `Effect` return became a
|
||||
drained **effect queue** (`Save{path,contents}` / `Load{path}` / `Publish` /
|
||||
`Pull`), so one action can ask the host for several steps in order — opening a
|
||||
non-resident file queues a `Save` of the outgoing dirty buffer _then_ a `Load` of
|
||||
the target. The multi-buffer state deliberately avoids a rope-per-buffer rewrite:
|
||||
the active buffer keeps its fields inline on `Editor`, inactive buffers park in a
|
||||
small LRU `Vec<Buffer>` (≤ 3 resident = active + 2), and a switch marshals fields
|
||||
in/out so the ~3k-line editing engine is untouched. A dirty parked buffer is
|
||||
saved before it is evicted (nothing leaves RAM unsaved); `:e <path>` opens by
|
||||
prefix (`/sd/repo` → Tracked, `/sd/local` → Local); `:sync` is refused in-core in
|
||||
a Local buffer. Firmware drains the queue **to empty** each batch (a `Load` can
|
||||
cascade an eviction `Save`), and `persistence::{load_path,save_path}` generalise
|
||||
the atomic save off the hard-coded `notes.md`.
|
||||
|
||||
**Slice 2 of 4 landed in core 2026-07-11**, host-tested: the `Cmd-P` file
|
||||
**palette** — a modal transient panel over the writing column with a bare
|
||||
fuzzy-search input (no `>` prefix: `>` is reserved for the command palette,
|
||||
slice 4 — VS Code semantics), the ranked list, and the selected row in reverse
|
||||
video. A pure host-testable fuzzy matcher (`fuzzy_score`: subsequence match,
|
||||
boundary + consecutive-run bonuses, no penalties) ranks results; an in-core MRU
|
||||
floats recently-opened files to the top on an empty query and is **shared with
|
||||
`:e`** (both flow through `open_path`). The host feeds the file list once at boot
|
||||
(`set_file_list`, enumerating `/sd/repo` + `/sd/local`, dotfiles skipped);
|
||||
`Ctrl-n`/`Ctrl-p` (fzf-style; `Ctrl-d`/`Ctrl-u` too) move the selection — the
|
||||
60 % board has no arrow keys — Enter opens via the same park/evict path as `:e`,
|
||||
Esc (or `Cmd-P` again) closes. Same slice: **`Ctrl-n`/`Ctrl-p` also work as
|
||||
down/up line motions in Normal mode** (vim `CTRL-N`≡`j`, `CTRL-P`≡`k`,
|
||||
count-aware), which is why the palette opener moved to `Cmd-P` alone. Scope
|
||||
shows as the inline `repo/…` vs `local/…` label rather than the planned
|
||||
`[git]`/`[local]` badge — it also disambiguates subpaths, not just scope. 111
|
||||
editor tests + 28 keymap tests pass; the no-git firmware binary builds clean.
|
||||
The transient-panel refresh (**Spike 11**) is **CONFIRMED ON DEVICE 2026-07-12 —
|
||||
no ghosting** (user flashed it and eyeballed the full-area partial the palette
|
||||
forces); Cmd-P opens it on-device too. Remaining v0.5 slice: 4 prefs +
|
||||
palette command mode.
|
||||
|
||||
**Slice 3 (`:enew` + delete) COMPLETE + CONFIRMED ON DEVICE 2026-07-12**
|
||||
(committed `c9c0716`). `:enew <name>` creates a new file: empty, active, marked **dirty**
|
||||
so eviction/`:w` persists it, and added to the in-core file list so the palette
|
||||
finds it without a disk re-enumeration — no card IO until it is saved. `:delete`
|
||||
unlinks the **current** file (a new `Effect::Delete` the host services), then
|
||||
switches to the most-recently-parked buffer or an empty scratch; the discarded
|
||||
buffer is never saved even when dirty. **Scope for a new file is read from the
|
||||
path, not a modal prompt** — `local/x` / `repo/x` (the palette label form) select
|
||||
the scope, a bare name uses the current buffer's scope. Same change made the
|
||||
**`/sd` prefix optional everywhere** in `resolve_path`: `/sd/repo/x`, `/repo/x`,
|
||||
and `repo/x` all name one file and nothing resolves outside `/sd` (the writer
|
||||
can't reach anything else). **Spike 14 (delete → git-staging) DID need a firmware
|
||||
fix.** The first on-device test found `add_all(["*"])` alone does **not** stage a
|
||||
deletion on this libgit2 build (the tree came back unchanged, so the second push
|
||||
was a silent "up to date" no-op — the "delete didn't work" report). Fix:
|
||||
`stage_and_commit` now runs `add_all` **then `update_all(["*"])`** (`git add -u`),
|
||||
which removes index entries whose working-tree file is gone — together they are
|
||||
`git add -A`. Also, `:delete` gave no clear feedback, so the snackbar now names
|
||||
the scoped file and, for a Tracked file, that it is local until `:sync`
|
||||
(`deleted repo/notes.md - :sync to publish`). Deferred to later: greying the
|
||||
Publish affordance for a Local buffer, and the multi-file publish count. 123
|
||||
editor tests + 28 keymap tests pass; the no-git firmware binary builds clean. The
|
||||
`update_all` fix (behind `--features git`, unbuildable locally) was **verified on
|
||||
device 2026-07-12** — `:enew test.txt` → `:sync` → `:delete` → `:sync` removed
|
||||
test.txt from origin.
|
||||
|
||||
**Slice 4 (`.typoena.toml` prefs + palette `>` command mode) COMPLETE in core
|
||||
2026-07-12, HOST-TESTED not yet on-device.** A `Prefs` type (host-testable
|
||||
line-based TOML parse/serialize — flat `key = value` bools + one string with `#`
|
||||
comments, no crate pulled onto xtensa) lives on `Editor`; the host reads
|
||||
`/sd/repo/.typoena.toml` at boot and applies it before the first render, and a
|
||||
missing/partial file falls back to per-key defaults. Keys: `save_on_idle`,
|
||||
`format_on_save`, `line_numbers` (all bool, default on) and `auto_sync`
|
||||
(string, default `"10m"`, **schema + default only** — nothing reads it yet).
|
||||
`line_numbers` is live: `gutter_cols()` returns 0 when off, so the text reclaims
|
||||
the gutter's columns (the `gutter - 1` field width made saturating to avoid the
|
||||
underflow). The palette `>` command mode (VS Code semantics — a leading `>` in
|
||||
the query switches file search to the command list) exposes the three booleans
|
||||
as live toggles; Enter flips the pref, applies it at once, queues a new
|
||||
`Effect::SavePrefs` (the editor serializes; the host does the atomic write to
|
||||
`.typoena.toml`, which rides the next `:sync` to other devices), and confirms
|
||||
the new state on the snackbar. **The list stays open after a toggle** so several
|
||||
prefs flip in one visit (Esc/`Cmd-P` closes); **`:settings` opens the palette
|
||||
straight into `>` mode** as a one-command shortcut (both requested by the user
|
||||
2026-07-12, chosen over a separate settings modal — same surface, no duplicate
|
||||
machinery). Committed `c535864`. **Three "decide before build" calls:** (1) the
|
||||
idle auto-save is **unformatted** — `:fmt` runs only on explicit `:w`/`:sync`, so
|
||||
tables/blank-lines are never reflowed mid-session; (2) the per-device `auto_sync`
|
||||
override (card-local `typoena.conf`) is **deferred** — `auto_sync` is inert in
|
||||
v0.5, so there is nothing yet to override; (3) `> auto sync: <dur>` as a palette
|
||||
command is **deferred to v0.7** — a control that changes a value nothing reads
|
||||
would be a dead switch. `save_on_idle` is honoured host-side: a silent idle
|
||||
auto-save (no snackbar, no forced e-ink flash — a safety net, not an action)
|
||||
fires once per typing burst after a 1.5 s pause. 141 editor tests + 28 keymap
|
||||
tests pass; the no-git firmware binary builds clean. Firmware bumped **0.4.0 →
|
||||
0.5.0** (the v0.5 feature set is met). **Boot-read of the prefs file CONFIRMED ON
|
||||
DEVICE 2026-07-12** — a `.typoena.toml` in `typoena-test` with non-default values
|
||||
(`save_on_idle=false`, `line_numbers=false`, `auto_sync="5m"`) logged back
|
||||
`prefs: Prefs { save_on_idle: false, format_on_save: true, line_numbers: false,
|
||||
auto_sync: "5m" }` at boot, a byte-exact parse (comments skipped, bools + quoted
|
||||
string read). **Full gate CLOSED 2026-07-12:** the palette `>` live-toggle
|
||||
round-trip is confirmed — origin's `.typoena.toml` went `line_numbers` false →
|
||||
true via a _device_-authored publish (`3c79f38`), proving toggle → `SavePrefs` →
|
||||
atomic write → `git add -A` → push — and the `save_on_idle` autosave works on
|
||||
device too. **v0.5 slice 4 fully DONE + on-device confirmed.**
|
||||
|
||||
**Amendment 2026-07-12 — non-boolean prefs (`theme`, `auto_sync`).** Two of the
|
||||
"decide before build" calls above are **superseded**: `auto_sync` is now a live
|
||||
palette command, and a new `theme` (`light`/`dark`) key ships. The generalising
|
||||
idea is that a boolean toggle is just the two-option case of _rotate through a
|
||||
preset list on Enter_ — so the palette gains one uniform gesture: **Enter
|
||||
advances the selected pref to its next value and wraps** (a bool flips; a string
|
||||
pref cycles its options). `theme` rotates `light`↔`dark` and is applied by a
|
||||
single whole-frame invert at the end of the render ([`Frame::invert`]), so text,
|
||||
selection, caret, panel and palette all flip together; `auto_sync` rotates
|
||||
`2m`/`5m`/`10m`/`15m`/`30m`. Decision (3) — "a value control that changes nothing
|
||||
readable would be a dead switch" — is knowingly overridden: cycling `auto_sync`
|
||||
persists and displays the interval, but **still drives no behaviour until v0.7**;
|
||||
we accept a set-ahead control so the surface is ready and the value syncs now.
|
||||
Decision (2) (per-device `typoena.conf` override) stays deferred. Kept simple: no
|
||||
enum machinery — both string prefs share a `next_option(current, &OPTIONS)`
|
||||
helper, and hand-editing the TOML can still set any value (the palette only
|
||||
cycles presets; an off-list value snaps to the head on the next Enter). Editor
|
||||
tests cover the rotate/wrap/snap, the live theme invert, and the round-trip.
|
||||
|
||||
**Trailing-newline handling — a saved note ends with a _visible_ blank line
|
||||
(commit `d14d9e7`, 2026-07-12; host-tested, on-device gate open).** Two
|
||||
adjustments to how the buffer meets the file. First, format-on-save no longer
|
||||
strips _every_ trailing blank line — it collapses a run to **at most one** and
|
||||
keeps it, so a writer who presses Enter to open the next line doesn't have that
|
||||
line (and the caret) yanked away on save (the caret used to jump up to the last
|
||||
non-empty line). Second, persistence treats the file's POSIX terminator as
|
||||
content the editor _shows_: `load_path` reads the file **verbatim** and
|
||||
`save_path` writes the buffer, appending a final newline **only if one is
|
||||
missing** (guarded, not unconditional). Because the editor lays out
|
||||
`rows = #\n + 1`, that terminating newline renders as a **visible trailing empty
|
||||
line** the caret can land on — open a note and the blank line the newline stands
|
||||
for is there. The two are an identity round-trip for any device-written file
|
||||
(all end in `\n`); the file stays git-clean (exactly one terminator — vim and
|
||||
GitHub show no phantom blank line); and a trailing blank line the writer leaves
|
||||
is mirrored, never doubled. This replaced an interim model that stripped the
|
||||
terminator on load and hid it (the file was correct, the newline just wasn't
|
||||
shown). On-device check: reflash → open a note (trailing empty line visible) →
|
||||
`:w` (caret stays on it) → reopen (still there). `Prefs::to_toml` ends in a
|
||||
newline for the same reason; the guarded save leaves the prefs file with exactly
|
||||
one.
|
||||
|
||||
**Amendment 2026-07-13 — recursive enumeration + a 2-char search threshold.**
|
||||
Loading a real repo (`jcalixte/notes`) exposed that `enumerate_files` listed
|
||||
only the **top-level** files of `/sd/repo` and `/sd/local` — a nested notes tree
|
||||
showed a single file in the palette (subpaths always _opened_ fine via
|
||||
`:e repo/sub/x.md`; only the listing was flat). The enumeration is now a
|
||||
recursive walk: dot entries are skipped at every level (so `.git` is never
|
||||
descended into), each directory is read fully before recursing (one FatFS dir
|
||||
handle open at a time — the `remove_dir_recursive` pattern, kind to the
|
||||
FD-bounded mount), depth is capped at 8, and the boot-time walk logs its file
|
||||
count and duration (`file walk: N files in Xms`) so the FAT dir-IO cost on a
|
||||
big repo is measurable, not assumed. With the list now card-sized, the palette
|
||||
gained a **search threshold**: below 2 typed chars the result list is the
|
||||
**recents (MRU) only** — quick-switch (`Cmd-P`, `Enter`) stays one keystroke
|
||||
away — and the full fuzzy-ranked list appears from 2 chars on
|
||||
(`PALETTE_MIN_QUERY`). A fresh boot with no opens yet shows `(type to search)`.
|
||||
`>` commands and `$` snippets are short curated lists; the threshold does not
|
||||
apply to them.
|
||||
|
||||
**TODO (on-device, next time the device is on the bench)** — two measurements
|
||||
from the same boot log, both already instrumented:
|
||||
|
||||
- [x] **Re-measure the walk time** after the d_type fix (`2660a3e` — dirent
|
||||
`file_type()` instead of a per-entry `metadata()` stat, which cost
|
||||
~32 ms/file and made run 1 take 35 s for 1098 files). **Measured
|
||||
2026-07-13: 1098 files in 4.3 s** (~4 ms/file, readdir-over-SPI bound —
|
||||
8× under the stat-per-entry cost, but boot cursor-ready is now ~8.7 s,
|
||||
so the async-walk question is back open; it now lives with the
|
||||
[v1.0 ≤ 3 s boot item](v1.0-polish.md)).
|
||||
- [ ] **Read the file-list DRAM cost** from the new
|
||||
`file list: internal heap <before> -> <after> (<N> KB consumed)` line
|
||||
(the build is bracketed with `MALLOC_CAP_INTERNAL` readings in
|
||||
`main.rs`). The 1098 path Strings are each below the 16 KB SPIRAM
|
||||
malloc threshold, so they all land in internal DRAM — estimated
|
||||
60–70 KB, competing with Wi-Fi/TLS. Decision rule: **~60–70 KB
|
||||
confirms** interning the paths into one shared buffer (a single >16 KB alloc goes to PSRAM; only a ~9 KB offset index stays in DRAM);
|
||||
**well under that (≤~30 KB)** kills the idea — the next DRAM suspect
|
||||
is then parked-buffer text.
|
||||
|
||||
- [x] `Cmd-P` opens fuzzy file palette over **both** `/sd/repo/` and
|
||||
`/sd/local/` — **landed and CONFIRMED ON DEVICE 2026-07-12** (Spike 11: no
|
||||
ghosting on the transient panel); scope shows as the inline
|
||||
`repo/…` / `local/…` label instead of a `[git]`/`[local]` badge.
|
||||
- [~] Open, switch, close buffers (keep ≤ 3 in memory) — **open + switch + the
|
||||
≤ 3 LRU-resident model with dirty-aware save-before-evict done in core**
|
||||
(host-tested); `:e <path>` **and the palette** drive it today. Explicit
|
||||
**close** still to come.
|
||||
- [x] `:e` and palette share the same recent-files list — both open via
|
||||
`open_path`, which pushes to the in-core MRU that orders the palette.
|
||||
- [x] `:enew` creates a new file — **done in core (host-tested) 2026-07-12.**
|
||||
Scope is read from the path (`local/x` / `repo/x` select it, the palette
|
||||
label form; a bare name uses the current scope) rather than a modal
|
||||
prompt — the resolved scope is echoed in the snackbar. The `/sd` prefix is
|
||||
optional throughout (`/sd/repo/x` = `/repo/x` = `repo/x`).
|
||||
- [x] Delete a file — **core done (host-tested) 2026-07-12;** `:delete` unlinks
|
||||
the current file via `Effect::Delete`. For a Tracked file the removal reaches
|
||||
the next `:sync` Publish's staged set. **Spike 14 (on-device) found the
|
||||
staging incomplete:** `add_all(["*"])` alone did not stage the deletion, so
|
||||
`stage_and_commit` now also runs `update_all(["*"])` (`git add -u`) — the
|
||||
two together are `git add -A`. A Local file is just unlinked. The snackbar
|
||||
now confirms the delete and flags that a Tracked file needs `:sync`.
|
||||
**Verified on device 2026-07-12** — the `:enew`→`:sync`→`:delete`→`:sync`
|
||||
cycle removed test.txt from origin.
|
||||
- [~] `Ctrl-G` is disabled / hidden when the current buffer is local-scope —
|
||||
**`:sync` / Publish is blocked in-core for a Local buffer** (posts "Publish
|
||||
unavailable (Local)"); the side-panel affordance that hides/greys the
|
||||
gesture is the remaining half.
|
||||
- [ ] The side panel briefly shows file count on `Ctrl-G` when the publish bundles
|
||||
more than one dirty Tracked file (e.g. `"publishing 3 files: abc1234"`),
|
||||
so workspace-scoped behaviour stays visible to the user
|
||||
- [x] **Preferences file** `/sd/repo/.typoena.toml` — a git-tracked,
|
||||
hand-editable TOML file for editor behaviour, deliberately **distinct from
|
||||
the `/sd/typoena.conf` card secrets** (Wi-Fi / PAT / remote / author,
|
||||
gitignored, never committed — see [v0.1](v0.1-mvp-product.md)). Read at boot; a missing file or
|
||||
key falls back to the defaults below. **Core done 2026-07-12** (a `Prefs`
|
||||
type on `Editor`, host-testable parse/serialize, applied via
|
||||
`Editor::set_prefs` before the first render); full reference:
|
||||
[`typoena-toml.md`](typoena-toml.md). Keys:
|
||||
- [x] `save_on_idle` (bool, default `true`) — auto-save the current buffer on
|
||||
the idle typing-pause, so `:w` becomes optional rather than required.
|
||||
**Honoured host-side** as a _silent_ save (no snackbar, no forced e-ink
|
||||
flash — a safety net, not an action), unformatted, once per typing burst
|
||||
after a 1.5 s pause.
|
||||
- [x] `format_on_save` (bool, default `true`) — run `:fmt` (table alignment,
|
||||
blank-line collapse, trailing-whitespace strip) on the buffer before it
|
||||
is persisted, so `:sync` is **fmt → save → commit → push** and `:w`
|
||||
saves formatted. Implemented in-core 2026-07-11 (`Editor`), now **driven
|
||||
by this key**. **Open question RESOLVED (2026-07-12):** fmt runs only on
|
||||
an explicit `:w`/`:sync`; the `save_on_idle` auto-save is deliberately
|
||||
**unformatted**, so tables/blank lines are never reflowed mid-session
|
||||
(the caret would jump under you on every thinking pause).
|
||||
- [x] `line_numbers` (bool, default `true`) — show the absolute line-number
|
||||
gutter (built always-on in v0.2). Off reclaims the gutter's columns for
|
||||
text (`gutter_cols()` → 0); the palette `> line numbers: on/off` command
|
||||
toggles it live. **Done 2026-07-12.**
|
||||
- [x] `theme` (string, default `"light"`; options `light`/`dark`) — panel
|
||||
colour polarity. `dark` is a single whole-frame invert at the end of the
|
||||
render (`Frame::invert`), so everything flips together and stays legible.
|
||||
Palette `> theme` rotates `light`↔`dark` live. **Added 2026-07-12
|
||||
(amendment); host-tested, on-device check pending.** Caveat: `dark`
|
||||
partial-refreshes ghost more on e-paper than `light` — verify on panel.
|
||||
- [ ] `auto_sync` (duration string, default `"10m"`; `"0"` / omitted
|
||||
disables; **min clamp ~`"2m"`** so a palette typo can't drain the
|
||||
battery) — a _max-staleness cap_, not a wall-clock timer:
|
||||
**opportunistic, rate-limited** Publish. Push when already awake + dirty
|
||||
(coalesced into the idle-pause, ≤ once per `auto_sync`) and once on the
|
||||
way into sleep if dirty; **never wake from deep sleep purely to sync**.
|
||||
Wi-Fi energy is a `1/T` curve whose knee sits at 5–10 min, and
|
||||
`save_on_idle` already owns local data safety — so 10 min halves the
|
||||
sync energy of a 5-min default for no real risk. Full derivation:
|
||||
[`tradeoff-curves/wifi-auto-sync.md`](tradeoff-curves/wifi-auto-sync.md).
|
||||
The **schema + default (`"10m"`) live here in v0.5** and round-trip
|
||||
through `Prefs`; **nothing reads the value yet** — the periodic side
|
||||
lands with the light / deep sleep work (v0.8; originally pencilled on
|
||||
v0.7's git work, re-homed 2026-07-14 when v0.7 closed with manual
|
||||
`:gl`/`:gp` only). Marked `[~]`: parsed and preserved, no behaviour.
|
||||
**Amended 2026-07-12:** now a palette preset command — Enter rotates it
|
||||
through `2m`/`5m`/`10m`/`15m`/`30m` (the `~2m` min is baked into the
|
||||
preset list). Set-ahead only: still read by nothing until v0.8.
|
||||
- [x] Open question RESOLVED (2026-07-12): the per-device sync cadence override
|
||||
(a card-local `typoena.conf` layer over the committed prefs) is
|
||||
**deferred** — `auto_sync` is inert in v0.5, so there is nothing yet to
|
||||
override; revisit when v0.8 makes the periodic push real.
|
||||
- [x] **Palette command mode** — typing `>` at the `Cmd-P` palette switches it
|
||||
from file search to a command list (VS Code-style). **Done in core
|
||||
2026-07-12.** The v0.5 commands toggle the three boolean `.typoena.toml`
|
||||
prefs — `> save on idle`, `> format on save`, `> line numbers` — each label
|
||||
carrying its live state; Enter flips the pref, applies it at once, queues
|
||||
`Effect::SavePrefs` (persist to the file), and confirms on the snackbar.
|
||||
**The list stays open after a toggle** (flip several, Esc/`Cmd-P` closes),
|
||||
and **`:settings` opens it directly** — both added 2026-07-12 as the "change
|
||||
config from the device" surface (chosen over a separate settings modal).
|
||||
This command list is the discoverable surface later actions (`:fmt`, font)
|
||||
also register into. **Amended 2026-07-12:** the palette now also carries the
|
||||
non-boolean prefs `> theme` (`light`/`dark`, live whole-frame invert) and
|
||||
`> auto sync` (`2m`..`30m`), both cycled by the same Enter-rotates-to-next
|
||||
gesture. `auto_sync` is exposed **set-ahead** (no behaviour until v0.8),
|
||||
knowingly overriding the earlier "dead switch" call.
|
||||
115
docs/v0.6-markdown.md
Normal file
115
docs/v0.6-markdown.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# v0.6 — Markdown affordances
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status:** render affordances done early. The snippet engine (tab-stop core,
|
||||
inline Tab-expansion, hint-on-pause, `$` palette) and the `>` command-palette
|
||||
generalisation are **done in core, host-tested** (187 editor tests); the
|
||||
remaining work is the **firmware boot-read + `just init` catalog** (slice 5, the
|
||||
on-device gate). Snippets are net-new scope, added 2026-07-08; reshaped
|
||||
2026-07-12 from a hard-coded table into a git-synced, Zed-compatible library with
|
||||
a `$` palette launcher — see [`typoena-snippets.md`](typoena-snippets.md) for the
|
||||
file format.
|
||||
|
||||
- [x] Heading lines bolded in render (faux-bold double-strike)
|
||||
- [x] List continuation on Enter inside `- ` / `1. ` (with empty-item exit)
|
||||
- [x] Blockquote continuation on Enter inside `> ` (nested depth preserved,
|
||||
empty-quote exit); a nested list inside a quote degrades to `> `
|
||||
- [x] Soft-wrap at word boundaries
|
||||
|
||||
## Snippets
|
||||
|
||||
Trigger-driven text expansion for Markdown authoring (Zed-inspired, but **no
|
||||
completion popup**: e-ink's ~630 ms refresh rules out a live filtering menu, and
|
||||
it fights the distraction-free premise). The library is a git-synced,
|
||||
Zed-compatible JSON file — full file-format reference in
|
||||
[`typoena-snippets.md`](typoena-snippets.md). This section is the _editor_
|
||||
behaviour.
|
||||
|
||||
- [x] **The tab-stop engine** — the shared core both surfaces drive. A body is
|
||||
literal text plus numbered stops `$1 … $n` and a final `$0`; on insertion
|
||||
the caret lands on `$1` (or the end), in Insert. Tab advances to the next
|
||||
stop, **forward only** (no Shift-Tab); pending stops sit after the caret
|
||||
and shift with edits there. The session auto-aborts on Esc, a mode change,
|
||||
or a motion that leaves the stops. `${n:label}` parses to a bare `$n` (the
|
||||
label is stripped — no selection model to fill); no dynamic values (no RTC,
|
||||
so no `date`).
|
||||
- [x] **Inline Tab-expansion (Insert mode).** If the word immediately before the
|
||||
caret matches a snippet prefix, Tab expands it and starts the tab-stop
|
||||
session; otherwise Tab inserts spaces as today. A check in the Insert
|
||||
handler alongside the existing `list_marker` transform
|
||||
(`expand_snippet(word) -> Option<Snippet>`). Tab already arrives as
|
||||
`Key::Char('\t')`, so no new key event.
|
||||
- [x] **Hint-on-pause.** On the typing pause (same throttle as the word-count /
|
||||
cursor refresh — never a per-keystroke repaint), if the word before the
|
||||
caret is a prefix, the right side panel shows a quiet hint (`» name`, on the
|
||||
row above the mode line, sharing the slot with the `NO KBD` flag — the two
|
||||
never co-occur since the hint means you're typing). The panel is ~15 cols,
|
||||
so the hint is the snippet **name**, not the whole body — the full preview is
|
||||
the `$` palette's job. Snapshotted in `refresh_stats` so it rides the pause,
|
||||
and the firmware's Insert-pause repaint already carries it (no firmware
|
||||
change). Latin-9 has no `↹` glyph, hence the `»` marker.
|
||||
- [x] **`$` palette (browse + insert).** `Cmd-P` then `$` switches the palette to
|
||||
the snippet list (the same sigil mechanism as `>`). Fuzzy-matches name /
|
||||
prefix / description; Enter inserts the body at the caret and starts the
|
||||
tab-stop session. Lists **all** snippets — the fuzzy filter handles clutter,
|
||||
so there's no hidden "inline-only vs palette-only" split. Rows read
|
||||
`Name [prefix]`, so browsing also teaches the inline trigger.
|
||||
- [x] **Boot wiring.** The host reads `/sd/repo/.typoena.snippets.json` at boot
|
||||
and calls `Editor::set_snippets` (mirroring `set_prefs`); a missing or
|
||||
malformed file is non-fatal (no snippets, editor runs). Parse lives in the
|
||||
host-testable `editor` crate via `serde_json` — the one new dependency,
|
||||
confirmed to build for xtensa (`cargo check`, firmware 0.6.0). On-device
|
||||
smoke-test still pending.
|
||||
|
||||
## The palette, generalised (`Cmd-P` · `>` · `$`) — done in core
|
||||
|
||||
v0.5 shipped `Cmd-P` = files and `>` = a five-entry settings list (`save_on_idle`,
|
||||
`format_on_save`, `line_numbers` toggles + `theme`/`auto_sync` rotations). v0.6
|
||||
makes the sigils a clean split by verb, and the empty-palette placeholder legends
|
||||
them: `Go to file · > settings · $ snippets`.
|
||||
|
||||
- **bare `Cmd-P`** → _navigate_: go to file (unchanged).
|
||||
- **`>`** → _act on the editor_ — the command palette, a real action registry (the
|
||||
`PALETTE_CMDS` list, actions first then settings). The pref toggles are just its
|
||||
stateful entries, not a special section. Dispatch is by `PaletteCmd::kind`: a
|
||||
**toggle** flips and the list **stays open**; a **one-shot** (`format`,
|
||||
`publish` — the latter shares `run_publish` with `:sync`) runs and **closes**; a
|
||||
**parameterised** command (`new file...`) morphs the palette into a second
|
||||
**input step** (the box becomes a filename prompt → Enter creates it, scope read
|
||||
from a `repo/`/`local/` prefix as `:enew` does; backspacing past the start steps
|
||||
back to the `>` list). This **retired `:e`** — bare `Cmd-P` covers file-opening;
|
||||
dotfiles can get a dedicated `> edit ...` command if/when wanted.
|
||||
- **`$`** → _insert content_ — the snippet launcher above.
|
||||
|
||||
Labels avoid `…`/`↹` — the palette/panel fonts are ISO-8859-15, which has neither
|
||||
glyph — so `new file...` uses ASCII dots and the pause hint leads with `»`.
|
||||
|
||||
## First-time setup — snippet catalog (`just init`) — done
|
||||
|
||||
[`just init`](../firmware/README.md#provisioning-an-sd-card) seeds the two
|
||||
git-tracked config files into `repo/` (so they commit + sync on the device's
|
||||
first `:sync`), **writing only a file that is absent** so a re-`init` never
|
||||
clobbers a synced library: a starter [`.typoena.toml`](typoena-toml.md) (the five
|
||||
keys at their defaults) and a [`.typoena.snippets.json`](typoena-snippets.md)
|
||||
assembled from a **curated catalog** (`firmware/snippets-catalog/`, `jq`-merged).
|
||||
The catalog is grouped and opt-in — you pick the groups (each `[Y/n]`, all-yes on
|
||||
a non-interactive run) rather than getting one writer's whole personal set. Not
|
||||
every Zed snippet is worth proposing: the Slidev/blog-pipeline ones (`@[youtube]`,
|
||||
`<v-clicks>`, frontmatter, mermaid) and hyper-specific personal ones are left out,
|
||||
since a distraction-free prose appliance can't render them and you'd never miss
|
||||
them. Prefixes are **English** (except `edanso`, the mnemonic for `œ`), bodies
|
||||
translated from the source Zed snippets. **Three groups, 17 snippets:**
|
||||
|
||||
- [x] **Symbols** (inline, the keyboard can't type them): `arrow`→`→`,
|
||||
`neq`→`≠`, `times`→`×`, `middot`→`·`, `deg`→`°`, `euro`→`€`, `edanso`→`œ`
|
||||
(dead keys in v0.2.5 cover accents, _not_ these). **Caveat:** `→` (U+2192)
|
||||
and `≠` (U+2260) are outside ISO-8859-15, so they store correctly and sync
|
||||
but render as a missing-glyph box on the device panel until the font is
|
||||
extended; the other five are Latin-9 and draw fine. Kept deliberately.
|
||||
- [x] **Structure**: `todo`→`- [ ] `, `link`→`[$1]($2)$0`, `img`→`$0`,
|
||||
`table` (2-col), `code` (fenced block). `img`/`code` are net-new.
|
||||
- [x] **Prose / PKM templates** (`${n:label}` stripped to `$n`): `booknotes`,
|
||||
`reference` (the English reference block — `refangl` folded in), `bias`,
|
||||
`capture`, `standard`. (`5w1h` dropped.)
|
||||
154
docs/v0.7-search-and-git.md
Normal file
154
docs/v0.7-search-and-git.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# v0.7 — Search + better git
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status: CLOSED 2026-07-14.** `/` search is done and panel-verified
|
||||
(smartcase + accent-folded; `n`/`N` confirmed on-device). `:gl` is verified
|
||||
on-device in all four shapes: the **fast-forward (Pulled) closing gate passed
|
||||
on run 3** — `main 17840720 → e738a6a1`, `apply_tree_diff` wrote the one
|
||||
changed file in 1271 ms, internal DRAM never dropped below ~48 KB, and the
|
||||
palette re-walk picked the new file up in the background (0 KB internal
|
||||
consumed — interning confirmed). Run 2 had crashed there in libgit2's
|
||||
`checkout_tree` (O(tree) workdir walk + internal-DRAM exhaustion + an esp-idf
|
||||
spi_master NULL-deref on its own failed-alloc path); the rebuild as an
|
||||
O(changed) tree-diff apply plus three memory fixes (file-list interning,
|
||||
64 KB DMA reserve, TLS session reuse) is what run 3 verified. Run-1 bonus:
|
||||
the first on-device rejected-push → reconcile → replay → push success; run 3
|
||||
repeated that cycle at 24 s total (was 59 s — the reconcile fetch fell from
|
||||
~30 s to ~5 s with session resumption). Residual watch item from run 3:
|
||||
boot loaded 1 byte from notes.md but `:w` saved 0 — possible trailing-newline
|
||||
round-trip asymmetry on an empty-note buffer (host-test it; harmless here,
|
||||
the tree was unchanged).
|
||||
|
||||
- [x] `/` forward search, `n N` — core 2026-07-13, panel-verified 2026-07-14
|
||||
(including `n`/`N`). Literal substring (no regex on-device), matched
|
||||
**smartcase + accent-folded** (2026-07-14, user decision — supersedes
|
||||
the same-day plain-insensitive version): an all-lowercase pattern is
|
||||
case-insensitive, one capital makes it exact (vim smartcase), and
|
||||
diacritics always fold both ways — `/ete` finds `été`, `/été` finds
|
||||
`ete` (Latin-1 repertoire; ligatures `œ`/`æ` excluded — they fold to
|
||||
two chars). `n`/`N` recompute smartcase from the remembered pattern, so
|
||||
repeats behave like the original search. `/` reuses the `:` command line's
|
||||
editing with a `/` prompt (one command-line mode, as in vim), and the
|
||||
jump happens on **Enter only** — no incremental caret-chasing, for the
|
||||
same e-ink-refresh reason snippets have no completion popup. Wraps
|
||||
around with a "wrapped" notice; "not found: pat" leaves the caret alone;
|
||||
a bare `/`+Enter repeats the last pattern, which is editor-global
|
||||
(survives buffer switches, like vim's search register). `n`/`N` are
|
||||
motions (counts work, Visual extends over them); deliberately **not**
|
||||
operator targets (`dn` out of scope).
|
||||
- [x] `:gl` — pull: fetch + **fast-forward only**, refuse on divergence and
|
||||
surface it (renamed from the planned `:Gpull`). On-device 2026-07-14:
|
||||
UpToDate shape, dirty-journal gate, and the reconcile/replay path
|
||||
verified (runs 1 and 3); **Pulled (fast-forward) verified run 3**
|
||||
(ls-refs 2792 ms, download 5136 ms, apply 1271 ms / 1 file, `:gl` total
|
||||
16.0 s — ~3 s of the total is still unaccounted connect/negotiation,
|
||||
tracked with the publish gap below). How it works:
|
||||
- The git thread takes a `GitRequest::Pull` on the same channel as publish
|
||||
(shared `ensure_online` Wi-Fi/clock/TLS preamble). `pull_once` does
|
||||
**ls-refs first**: the ref advertisement alone answers "anything new?", so
|
||||
up to date and LocalAhead (stranded commit — `:sync`'s job) return without
|
||||
entering pack negotiation (run 1 paid a 9.7 s fetch to learn "up to
|
||||
date"); a needed download rides the same open connection. Divergence is
|
||||
refused — no merge on the device. Tracking-ref updates keep publish's
|
||||
radio-free up-to-date check honest.
|
||||
- The fast-forward is an **O(changed) tree-diff apply, not `checkout_tree`**
|
||||
(`apply_tree_diff`, the splice philosophy on the read side):
|
||||
`diff_tree_to_tree` skips identical subtree OIDs in memory, then exactly
|
||||
the changed paths are written (unlink+tmp+rename — FAT `f_rename` won't
|
||||
overwrite) or unlinked, and the ref moves last. libgit2's SAFE checkout
|
||||
instead iterates the whole working directory (readdir over SPI on all
|
||||
~1100 files) — run 2 died there: internal DRAM was exhausted mid-walk and
|
||||
esp-idf's spi_master NULL-derefs on its own failed-DMA-alloc path
|
||||
(LoadProhibited in setup/uninstall_priv_desc). Belt kept O(changed): any
|
||||
to-be-clobbered file whose content no longer hashes to the old tree's blob
|
||||
aborts the pull before the first write (edits made behind git's back —
|
||||
e.g. desktop edits directly on the card). A half-applied working copy with
|
||||
the ref unmoved self-heals: the next `:gl` re-applies idempotently.
|
||||
- UI gate: `:gl` is refused while the dirty journal is non-empty
|
||||
("unsynced changes - :sync first", also logged) — an unpublished save
|
||||
would fight the apply, and sync-then-pull is the single-writer appliance's
|
||||
natural order. A RAM-dirty (never-saved) buffer does _not_ gate: its edits
|
||||
simply win over the pulled state (last-writer-wins, like the reconcile).
|
||||
- After a `Pulled` outcome the UI drops clean parked buffers (next switch
|
||||
re-reads the disk), re-reads the clean active buffer in place
|
||||
(`Editor::refresh_active` — boot posture, undo cleared), keeps a
|
||||
RAM-dirty active buffer untouched, and re-walks the palette file list in
|
||||
the background. Snackbar: `pulled <oid>` / `up to date` /
|
||||
`ahead - :sync to publish` / `diverged - resolve on a computer` /
|
||||
`pull: <reason>`.
|
||||
|
||||
Memory + connection work landed here (2026-07-14, forced by the run-2 crash;
|
||||
all three compose):
|
||||
|
||||
- [x] **File-list interning** — 1099 palette paths as individual `String`s
|
||||
measured **182 KB of internal DRAM** (each alloc under the 16 KB
|
||||
SPIRAM-malloc threshold), a main contributor to the DMA-pool exhaustion.
|
||||
The walk now emits one newline-joined blob (seeded at 64 KB → PSRAM);
|
||||
the editor stores blob + sorted spans (`set_file_list_joined`) and the
|
||||
palette reads slices. **Confirmed run 3**: the post-pull re-walk logged
|
||||
`0 KB consumed, blob 51 KB`. (The boot walk's "125 KB consumed" is a
|
||||
measurement artifact — that window runs concurrently with USB attach and
|
||||
other boot allocations; the quiescent re-walk is the clean reading.)
|
||||
- [x] **DMA reserve 32 → 64 KB** (`CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL`) —
|
||||
the esp-idf SPI driver crashes rather than errors on a failed DMA alloc,
|
||||
so the floor must be generous enough that it never sees one.
|
||||
- [x] **TLS session resumption + one-connection pull** — every git op paid a
|
||||
full multi-second handshake (a rejected-push `:sync` runs three).
|
||||
`esp_mbedtls_stream.c` (second vendor delta) caches the mbedTLS session
|
||||
at stream close and offers it on the next connect to the same host.
|
||||
First same-session evidence (run 3): the reconcile fetch — always the
|
||||
second connection of a rejected-push `:sync` — fell from ~30 s (run 1)
|
||||
to ~5 s; a controlled first-op vs second-op `ls-refs` comparison is
|
||||
still worth logging when convenient.
|
||||
|
||||
Sync performance — inherited from the
|
||||
[real-repo-sync kaizen](kaizen/real-repo-sync.md) (closed 2026-07-13 at
|
||||
24.1 s end-to-end; target ≤ ~10 s):
|
||||
|
||||
- [~] Close the publish gap: 19.8 s at the kaizen close — the splice's 5.0 s
|
||||
and the ~7 s of repo-open/negotiation overhead are the next curves
|
||||
([tradeoff analysis](tradeoff-curves/sync-commit-staging.md)). TLS
|
||||
session resumption (above) attacks the negotiation half; re-measure a
|
||||
warm `:sync` before hunting further. Run-3 data point: a rejected-push
|
||||
`:sync` (worst case, three connections) totalled 24.0 s vs 59 s in
|
||||
run 1. **Warm clean publish measured 2026-07-14 (run 4, as `:gp` after
|
||||
the rename): 19.1 s** — but the mix shifted entirely. The push leg
|
||||
collapsed to **5.9 s** (6 objects; TLS-resumed connect 2.4 s vs 4.0 s
|
||||
for the session's first; post-repack marking cost invisible), and the
|
||||
splice grew to **10.3 s** because the committed file sat four
|
||||
directories deep: O(depth) means ~5 loose tree writes instead of 1
|
||||
(× the ~0.4 s dir-scan cost above) plus the session's first pack touch
|
||||
(5 mmaps / 1808 KB — the 1740 KB .idx map dominates). Same run:
|
||||
up-to-date `:gl` is now **4.7 s** git-side via the ls-refs fast path
|
||||
("no fetch" — was 9.7 s in run 1). So the residual curve is now
|
||||
**splice depth × loose-write cost**, which is the same FAT-dir-scan
|
||||
story as above and shares its levers (pack-not-loose foremost) — a
|
||||
future perf pass; a root-level file's warm `:gp` should sit ≈ 12–13 s.
|
||||
- [x] Instrument the residual ~360 ms/loose-write — **measured 2026-07-14,
|
||||
CONVICTED: FAT linear directory scans** (full analysis in the
|
||||
[tradeoff doc](tradeoff-curves/sync-commit-staging.md), run 6).
|
||||
`sd_bench`'s dir-entry-scaling section shows stat-miss cost linear in
|
||||
sibling count (~0.1 ms/entry/scan) and the loose-object composite
|
||||
reproducing the field number exactly: p50 402.9 ms at 256 siblings —
|
||||
the `.git/objects/` fan-out is ~256 dirs, and every path resolution
|
||||
under it scans them. Composes with the run-1 splice counters (**0
|
||||
mmaps / 0 KB read** — no pack I/O in the residual). Bounded (≤ ~2 s per
|
||||
typical commit) and accepted; the levers — fewer path resolutions in
|
||||
libgit2, or committing straight into a small pack instead of loose
|
||||
objects — are a future perf pass, not v0.x work. (The bench run also
|
||||
caught a cleanup bug: std's `remove_dir_all` mis-types directories via
|
||||
the generic-unix DT constants and dies on FatFS `FR_DENIED`; `sd_bench`
|
||||
now removes its scratch tree itself, same decoding as the palette
|
||||
walk. The aborted run left `/sd/sdbench` on the card — the next bench
|
||||
run's setup sweep removes it.)
|
||||
- [x] The images-off-card lever — **decided + landed 2026-07-14
|
||||
(skip-media-in-apply)**: the repo stays untouched (its images are
|
||||
remanso's blob-served CDN), and the device-side half shipped as media
|
||||
paths invisible to `apply_tree_diff` (never written/deleted/belt-hashed
|
||||
— a pulled 16 MB image was `:gl`'s last OOM path). Accepted trade: the
|
||||
card's media go stale vs HEAD, so a computer sees phantom
|
||||
modified/deleted images in `git status` — device-owned card, refresh
|
||||
with `just load`, never hand-commit from it. Full write-up in
|
||||
[notes/git-sync-images-and-repo-size.md](notes/git-sync-images-and-repo-size.md).
|
||||
25
docs/v0.8-battery-and-sleep.md
Normal file
25
docs/v0.8-battery-and-sleep.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# v0.8 — Power: battery + sleep
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
- [ ] Measure idle / typing / push current draw on bench
|
||||
- [ ] 18650 + IP5306 charge board, soft power switch
|
||||
- [ ] Per-sync Wi-Fi teardown — refactor `git_sync`'s modem ownership so the
|
||||
radio de-inits after each git op. Today it comes up lazily on the first
|
||||
op and stays associated for the rest of the session (~15–20 mAh/hr);
|
||||
TLS session resumption (v0.7) already makes the fresh-connect path cheap
|
||||
(2.4 s vs 4.0 s). Prerequisite for both sleep modes. Analysis:
|
||||
[tradeoff-curves/wifi-auto-sync.md](tradeoff-curves/wifi-auto-sync.md).
|
||||
- [ ] Light sleep on idle > 30 s (keyboard interrupt wakes)
|
||||
- [ ] Deep sleep on lid close (reed switch); restore cursor + buffer
|
||||
- [ ] `auto_sync` runtime — the opportunistic, rate-limited push behind the
|
||||
v0.5 `.typoena.toml` key (re-homed here 2026-07-14; v0.7 closed with
|
||||
manual `:gl`/`:gp` only). Push when already awake + dirty (coalesced
|
||||
into the idle-pause, ≤ once per `auto_sync`), push once on the way into
|
||||
sleep if dirty, **never wake from deep sleep purely to sync**; min clamp
|
||||
~2 min. A clean tick is radio-free via the dirty journal. Policy + curve:
|
||||
[tradeoff-curves/wifi-auto-sync.md](tradeoff-curves/wifi-auto-sync.md).
|
||||
- [ ] Battery indicator in the side panel
|
||||
28
docs/v0.9-robustness.md
Normal file
28
docs/v0.9-robustness.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# v0.9 — Robustness
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
- [ ] Crash-safe writes (write to `.tmp`, fsync, rename) — NB FatFS `f_rename`
|
||||
refuses to overwrite, so it's unlink-then-rename + `*.tmp` boot-recovery
|
||||
(found in [Spike 3](postmortems/2026-07-05-spike3-sd-cmd59.md), still
|
||||
open there)
|
||||
- [ ] Recover from interrupted push (re-attempt on next save) — the splice
|
||||
journal + stranded-commit recovery (2026-07-13) already survive a
|
||||
power-pull mid-push; this item is the _retry_ half
|
||||
- [ ] Reconnect-on-stale-connection in the libgit2 http/stream layer — the
|
||||
push keep-alive race is **avoided, not fixed** (repack keeps the marking
|
||||
phase at ~3.5 s ≪ the ~30 s idle window; a multi-pack or cold-cache
|
||||
state could still lose it — see the
|
||||
[real-repo-sync kaizen](kaizen/real-repo-sync.md))
|
||||
- [ ] Eradicate the paint-during-sync DMA allocation failure: persistent
|
||||
internal DMA scratch in `Epd` (safety net + allocation-free repaints
|
||||
shipped; see the
|
||||
[editor-freeze postmortem](postmortems/2026-07-11-editor-freeze-spi-dma-oom-during-sync.md))
|
||||
- [ ] SD card removal / reinsert handling
|
||||
- [ ] Wi-Fi reconnect with backoff
|
||||
- [ ] On-device provisioning + settings screen: SSID, PAT rotation, default
|
||||
remote, commit author (replaces the v0.1 dev-only NVS-flashing path —
|
||||
first release usable by someone who is not the firmware author)
|
||||
20
docs/v1.0-polish.md
Normal file
20
docs/v1.0-polish.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# v1.0 — Polish
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status:** not started.
|
||||
|
||||
- [ ] Boot time ≤ 3 s to usable cursor — 4.26 s at v0.1 ship; **regressed to
|
||||
~8.7 s on a real 1098-file card** once the v0.5 palette walk joined the
|
||||
boot path (4.3 s of readdir-over-SPI, already d_type-optimised — see the
|
||||
[v0.5 walk amendment](v0.5-palette-and-multi-file.md)), so the walk must
|
||||
go async/deferred, and the ~1.9 s cold-boot full refresh + ~0.74 s PSRAM
|
||||
memtest are the remaining levers (see
|
||||
[`notes/boot-time-budget.md`](notes/boot-time-budget.md))
|
||||
- [ ] Font selection (at least one serif + one mono) with adjustable font
|
||||
size, switchable at runtime and persisted across reboots
|
||||
- [ ] Theme: light / dark (inverted e-ink), switchable at runtime and
|
||||
persisted across reboots
|
||||
- [ ] Enclosure design files in `hardware/`
|
||||
- [ ] User guide
|
||||
11
docs/v1.x-stretch.md
Normal file
11
docs/v1.x-stretch.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# v1.x — Stretch / nice-to-have
|
||||
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
Post-1.0 ideas, not committed to any release:
|
||||
|
||||
- 10.3" panel upgrade via IT8951
|
||||
- Multiple remotes / repos
|
||||
- Stats: words today, streak
|
||||
- BLE-HID fallback for wireless keyboards
|
||||
2
editor/.gitignore
vendored
Normal file
2
editor/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
15
editor/Cargo.toml
Normal file
15
editor/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "editor"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Modal (vim-style) text-editor core: the buffer, motions, edits, and e-paper layout/render. Depends only on embedded-graphics plus the display and keymap crates, so it builds and is tested on the host off the xtensa target (unlike the firmware crate it used to live in)."
|
||||
|
||||
[dependencies]
|
||||
embedded-graphics = "0.8"
|
||||
display = { path = "../display" }
|
||||
keymap = { path = "../keymap" }
|
||||
# Snippet library parse (v0.6): the `.typoena.snippets.json` file uses Zed's JSON
|
||||
# shape. serde_json handles the string-escape corner cases a hand-rolled reader
|
||||
# would get wrong; the crate is `std` (esp-idf provides std on xtensa).
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
372
editor/src/buffers.rs
Normal file
372
editor/src/buffers.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
//! Multi-buffer management: the active/parked buffer set, the file
|
||||
//! registry and MRU list, and path resolution between the repo and local scopes.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Tracked files live here (the git working copy).
|
||||
pub const REPO_DIR: &str = "/sd/repo";
|
||||
/// Local files live here (never published).
|
||||
pub const LOCAL_DIR: &str = "/sd/local";
|
||||
|
||||
/// Resolve a `:e`/`:enew` argument (or palette pick) to an absolute path +
|
||||
/// [`Scope`]. Everything the writer can reach lives on the card under `/sd`, so
|
||||
/// the `/sd` prefix is **optional**: `/sd/repo/x`, `/repo/x`, and `repo/x` all
|
||||
/// name the same file, and nothing resolves outside `/sd`. The arg is normalized
|
||||
/// to a scope-relative form (peel an optional `/sd`, then an optional leading
|
||||
/// `/`), then:
|
||||
/// - a leading `local/` or `repo/` segment **selects the scope** and names the
|
||||
/// file in it — the same labels the palette shows (`local/journal.md`,
|
||||
/// `repo/notes.md`), so a name read off the palette is typeable verbatim. Safe
|
||||
/// because scopes are flat: there are no real `local/`/`repo/` subdirectories;
|
||||
/// - otherwise a bare name joins the **current** buffer's scope directory, so
|
||||
/// `:e draft.md` opens a sibling of the file you're in.
|
||||
pub(crate) fn resolve_path(arg: &str, current: Scope) -> (String, Scope) {
|
||||
// Peel the optional `/sd` prefix, then an optional leading `/`, leaving a
|
||||
// scope-relative remainder (`repo/…`, `local/…`, or a bare name).
|
||||
let rel = arg
|
||||
.strip_prefix("/sd/")
|
||||
.or_else(|| arg.strip_prefix('/'))
|
||||
.unwrap_or(arg);
|
||||
if let Some(name) = rel.strip_prefix("local/") {
|
||||
(format!("{LOCAL_DIR}/{name}"), Scope::Local)
|
||||
} else if let Some(name) = rel.strip_prefix("repo/") {
|
||||
(format!("{REPO_DIR}/{name}"), Scope::Tracked)
|
||||
} else {
|
||||
let dir = match current {
|
||||
Scope::Tracked => REPO_DIR,
|
||||
Scope::Local => LOCAL_DIR,
|
||||
};
|
||||
(format!("{dir}/{rel}"), current)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// A resident-but-inactive buffer: everything needed to restore a file's editing
|
||||
/// state when the user switches back, without re-reading the disk. The active
|
||||
/// buffer holds these same fields inline on [`Editor`]; parking marshals them
|
||||
/// out to here, activation marshals them back.
|
||||
pub(crate) struct Buffer {
|
||||
pub(crate) path: String,
|
||||
scope: Scope,
|
||||
text: String,
|
||||
caret: usize,
|
||||
scroll_top: usize,
|
||||
dirty: bool,
|
||||
undo: Vec<(String, usize)>,
|
||||
redo: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
/// Buffers kept resident at once — the active one plus [`MAX_RESIDENT`] − 1
|
||||
/// parked (v0.5 keeps ≤ 3). Beyond this the least-recently-used parked buffer is
|
||||
/// evicted; it is saved first if dirty, so an evicted buffer is never lost.
|
||||
pub(crate) const MAX_RESIDENT: usize = 3;
|
||||
|
||||
/// Recent-files (MRU) list length — how many opens the palette remembers; they
|
||||
/// are the whole result list below [`PALETTE_MIN_QUERY`] chars and float to the
|
||||
/// top above it. Far more than [`MAX_RESIDENT`] (recency
|
||||
/// outlives residency: a file evicted from memory is still recently *used*), but
|
||||
/// bounded so the list can't grow without limit over a long session.
|
||||
pub(crate) const MRU_MAX: usize = 16;
|
||||
|
||||
|
||||
impl Editor {
|
||||
/// The host confirms `path` was persisted; clear its dirty flag wherever that
|
||||
/// buffer is resident (active or parked). A no-op for a path that is no longer
|
||||
/// in memory (already-evicted buffers were saved on the way out).
|
||||
pub fn mark_saved(&mut self, path: &str) {
|
||||
if self.path == path {
|
||||
self.dirty = false;
|
||||
}
|
||||
if let Some(b) = self.parked.iter_mut().find(|b| b.path == path) {
|
||||
b.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a file the host read from disk in response to an [`Effect::Load`]:
|
||||
/// park the current buffer and make the loaded one active. If the target
|
||||
/// turned resident in the meantime, switch to that copy instead (its in-memory
|
||||
/// edits win over a stale disk read).
|
||||
pub fn install_loaded(&mut self, path: String, scope: Scope, contents: String) {
|
||||
if path == self.path {
|
||||
return;
|
||||
}
|
||||
if self.parked.iter().any(|b| b.path == path) {
|
||||
self.open_path(path, scope);
|
||||
return;
|
||||
}
|
||||
self.park_active();
|
||||
self.set_active(path, scope, contents);
|
||||
}
|
||||
|
||||
/// Replace the active buffer's contents after the file changed on disk
|
||||
/// underneath us — a `:gl` pull fast-forwarded the working copy. Same boot
|
||||
/// posture as a fresh load (Normal, caret on the last char, clean, no undo
|
||||
/// history — the old snapshots reference the replaced text). The host only
|
||||
/// calls this when the buffer is clean; a dirty buffer's RAM edits win
|
||||
/// (last-writer-wins, like the reconcile path).
|
||||
pub fn refresh_active(&mut self, contents: String) {
|
||||
let (path, scope) = (self.path.clone(), self.scope);
|
||||
self.set_active(path, scope, contents);
|
||||
}
|
||||
|
||||
/// Drop every *clean* parked buffer, so the next switch to one re-reads the
|
||||
/// disk ([`Effect::Load`]) instead of resurrecting a stale resident copy —
|
||||
/// a `:gl` pull may have rewritten any tracked file. Dirty parked buffers
|
||||
/// are kept: their unsaved edits win over the pulled state, exactly like
|
||||
/// the active buffer's.
|
||||
pub fn drop_clean_parked(&mut self) {
|
||||
self.parked.retain(|b| b.dirty);
|
||||
}
|
||||
|
||||
/// Queue an [`Effect::Save`] of the active buffer. Posts "no file name" for an
|
||||
/// unnamed scratch buffer (nothing to save to) rather than writing to `""`.
|
||||
pub(crate) fn request_save_active(&mut self) {
|
||||
if self.path.is_empty() {
|
||||
self.set_notice("no file name");
|
||||
return;
|
||||
}
|
||||
self.requests.push(Effect::Save {
|
||||
path: self.path.clone(),
|
||||
scope: self.scope,
|
||||
contents: self.text.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Switch the active buffer to `path`. If it is already resident (parked),
|
||||
/// restore that copy with its caret/scroll/undo intact — no disk read. If it
|
||||
/// is not resident, queue an [`Effect::Load`]; the host reads the file and
|
||||
/// calls [`install_loaded`](Self::install_loaded), which does the park + swap.
|
||||
/// A dirty outgoing buffer is preserved in RAM (parked) and persisted only
|
||||
/// when it is later evicted, so switching itself never blocks on IO.
|
||||
pub(crate) fn open_path(&mut self, path: String, scope: Scope) {
|
||||
if path == self.path {
|
||||
return; // already the active buffer
|
||||
}
|
||||
self.note_recent(&path); // float it to the top of the palette's MRU
|
||||
match self.parked.iter().position(|b| b.path == path) {
|
||||
Some(i) => {
|
||||
let target = self.parked.remove(i);
|
||||
self.park_active();
|
||||
self.activate(target);
|
||||
}
|
||||
None => self.requests.push(Effect::Load { path, scope }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the active buffer's editing state into a parked [`Buffer`], leaving
|
||||
/// the active fields empty for a subsequent [`activate`](Self::activate) or
|
||||
/// [`set_active`](Self::set_active). Evicts the least-recently-used parked
|
||||
/// buffer if that pushes residency over [`MAX_RESIDENT`]; an evicted dirty
|
||||
/// buffer queues a [`Effect::Save`] so no unsaved work leaves memory.
|
||||
pub(crate) fn park_active(&mut self) {
|
||||
let buf = Buffer {
|
||||
path: core::mem::take(&mut self.path),
|
||||
scope: self.scope,
|
||||
text: core::mem::take(&mut self.text),
|
||||
caret: self.caret,
|
||||
scroll_top: self.scroll_top,
|
||||
dirty: self.dirty,
|
||||
undo: core::mem::take(&mut self.undo),
|
||||
redo: core::mem::take(&mut self.redo),
|
||||
};
|
||||
self.parked.push(buf);
|
||||
// Active is currently empty, so residency == parked.len(); keep it under
|
||||
// MAX_RESIDENT so the buffer about to become active fits.
|
||||
while self.parked.len() >= MAX_RESIDENT {
|
||||
let evicted = self.parked.remove(0);
|
||||
if evicted.dirty {
|
||||
self.requests.push(Effect::Save {
|
||||
path: evicted.path,
|
||||
scope: evicted.scope,
|
||||
contents: evicted.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a parked buffer into the active fields (its caret, scroll, undo,
|
||||
/// and dirty flag come back with it). Lands in Normal with input state reset.
|
||||
pub(crate) fn activate(&mut self, b: Buffer) {
|
||||
self.path = b.path;
|
||||
self.scope = b.scope;
|
||||
self.text = b.text;
|
||||
self.caret = b.caret;
|
||||
self.scroll_top = b.scroll_top;
|
||||
self.dirty = b.dirty;
|
||||
self.undo = b.undo;
|
||||
self.redo = b.redo;
|
||||
self.reset_active_input();
|
||||
}
|
||||
|
||||
/// Make a freshly-loaded file the active buffer: same boot posture as
|
||||
/// [`with_file`](Self::with_file) (Normal, caret on the last char) with empty
|
||||
/// undo history and a clean dirty flag.
|
||||
pub(crate) fn set_active(&mut self, path: String, scope: Scope, text: String) {
|
||||
self.path = path;
|
||||
self.scope = scope;
|
||||
self.text = text;
|
||||
self.caret = self.text.len();
|
||||
if self.caret > self.line_start(self.caret) {
|
||||
self.caret = self.prev_char(self.caret);
|
||||
}
|
||||
self.scroll_top = 0;
|
||||
self.dirty = false;
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.reset_active_input();
|
||||
}
|
||||
|
||||
/// Reset the transient per-keystroke input state (mode, pending operator,
|
||||
/// visual anchor, command line) on a buffer swap, so nothing leaks across.
|
||||
/// The register and `.` history are deliberately left alone — they are global
|
||||
/// (vim-like), so a yank in one file pastes in another.
|
||||
pub(crate) fn reset_active_input(&mut self) {
|
||||
self.mode = Mode::Normal;
|
||||
self.visual_anchor = None;
|
||||
self.cmdline.clear();
|
||||
self.reset_pending();
|
||||
}
|
||||
|
||||
|
||||
/// `:enew <arg>` — create a new file and make it the active buffer. Scope is
|
||||
/// read from the path exactly like `:e` (`local/…` → Local, else Tracked;
|
||||
/// a bare name lands in the current buffer's scope), so no scope prompt is
|
||||
/// needed — the resolved scope is echoed in the snackbar instead. If the name
|
||||
/// already resolves to the active or a parked buffer, this just switches to it
|
||||
/// (no clobber); otherwise the buffer starts empty and **dirty**, so it is
|
||||
/// durable (a later eviction or `:w` persists it) and shows in the palette at
|
||||
/// once. The file is not written to disk until then — `:enew` alone allocates
|
||||
/// no card IO.
|
||||
pub(crate) fn new_file(&mut self, arg: &str) {
|
||||
let arg = arg.trim();
|
||||
if arg.is_empty() {
|
||||
self.set_notice("usage: :enew <file>");
|
||||
return;
|
||||
}
|
||||
let (path, scope) = resolve_path(arg, self.scope);
|
||||
// Already open (active or parked) — treat `:enew` of an existing name as a
|
||||
// switch rather than replacing its contents with an empty buffer.
|
||||
if path == self.path || self.parked.iter().any(|b| b.path == path) {
|
||||
self.open_path(path, scope);
|
||||
return;
|
||||
}
|
||||
self.note_recent(&path);
|
||||
self.add_to_file_list(&path);
|
||||
self.park_active();
|
||||
self.set_active(path.clone(), scope, String::new());
|
||||
// A fresh file is unsaved: mark it dirty so eviction/`:w` persists it and
|
||||
// it never silently vanishes (unlike an `:e` of a missing name).
|
||||
self.dirty = true;
|
||||
self.set_notice(format!("new {}", palette_label(&path)));
|
||||
}
|
||||
|
||||
/// `:delete` — unlink the **current** file from the card and leave it. Queues
|
||||
/// an [`Effect::Delete`] (the host does the removal + reports the outcome) and
|
||||
/// updates the in-core model now: the path is dropped from the file list and
|
||||
/// MRU, and the active buffer switches to the most-recently-parked buffer, or
|
||||
/// an empty unnamed scratch if none is resident. An unnamed scratch buffer has
|
||||
/// nothing on disk, so it is a no-op with a notice. Deleting an arbitrary
|
||||
/// (non-current) file is deferred — this is the file you are looking at.
|
||||
pub(crate) fn delete_current(&mut self) {
|
||||
if self.path.is_empty() {
|
||||
self.set_notice("no file to delete");
|
||||
return;
|
||||
}
|
||||
let path = core::mem::take(&mut self.path);
|
||||
let scope = self.scope;
|
||||
self.requests.push(Effect::Delete { path: path.clone(), scope });
|
||||
self.remove_from_file_list(&path);
|
||||
self.recent.retain(|p| p != &path);
|
||||
// The current buffer is being discarded, not parked: restore the most
|
||||
// recently parked buffer if one is resident, else fall back to scratch.
|
||||
match self.parked.pop() {
|
||||
Some(b) => {
|
||||
self.note_recent(&b.path);
|
||||
self.activate(b);
|
||||
}
|
||||
None => self.set_active(String::new(), Scope::Tracked, String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `i`-th file path in the palette's sorted base order (a slice into
|
||||
/// [`file_blob`](Self::file_blob)).
|
||||
pub(crate) fn file_at(&self, i: usize) -> &str {
|
||||
let (s, e) = self.file_spans[i];
|
||||
&self.file_blob[s as usize..e as usize]
|
||||
}
|
||||
|
||||
/// How many files the palette knows about.
|
||||
pub(crate) fn file_count(&self) -> usize {
|
||||
self.file_spans.len()
|
||||
}
|
||||
|
||||
/// Insert `path` into the palette's file list, keeping the spans sorted and
|
||||
/// unique (matches [`set_file_list_joined`](Self::set_file_list_joined)'s
|
||||
/// invariant). Used by `:enew` so a just-created file is findable without a
|
||||
/// disk re-enumeration. Appends to the blob; a `String` realloc only moves
|
||||
/// bytes, the spans are indices and stay valid.
|
||||
pub(crate) fn add_to_file_list(&mut self, path: &str) {
|
||||
match self
|
||||
.file_spans
|
||||
.binary_search_by(|&(s, e)| self.file_blob[s as usize..e as usize].cmp(path))
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(i) => {
|
||||
let start = self.file_blob.len() as u32;
|
||||
self.file_blob.push_str(path);
|
||||
self.file_spans.insert(i, (start, start + path.len() as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop `path` from the palette's file list (used by `:delete`). Only the
|
||||
/// span goes; its bytes stay in the blob as dead weight until the next
|
||||
/// host re-walk replaces the whole thing — a few dozen bytes at most.
|
||||
pub(crate) fn remove_from_file_list(&mut self, path: &str) {
|
||||
let blob = &self.file_blob;
|
||||
self.file_spans
|
||||
.retain(|&(s, e)| &blob[s as usize..e as usize] != path);
|
||||
}
|
||||
|
||||
// --- File palette (Ctrl-P) ---------------------------------------------
|
||||
|
||||
/// Feed the palette its file list as **one newline-joined blob** of
|
||||
/// absolute paths, enumerated by the host from `/sd/repo` and `/sd/local`.
|
||||
/// This is the device's entry point: a single large `String` lands in
|
||||
/// PSRAM (allocations ≥ 16 KB cross the SPIRAM-malloc threshold), where
|
||||
/// the same list as 1099 individual `String`s measured 182 KB of internal
|
||||
/// DRAM. Spans are sorted + deduped for a stable base order; the MRU
|
||||
/// floats recents above it. The palette is a pure view over this — nothing
|
||||
/// is read from disk until a file is actually opened.
|
||||
pub fn set_file_list_joined(&mut self, blob: String) {
|
||||
let mut spans: Vec<(u32, u32)> = Vec::new();
|
||||
let mut start = 0u32;
|
||||
for line in blob.split('\n') {
|
||||
let end = start + line.len() as u32;
|
||||
if !line.is_empty() {
|
||||
spans.push((start, end));
|
||||
}
|
||||
start = end + 1; // past the '\n'
|
||||
}
|
||||
spans.sort_by(|&(a, b), &(c, d)| blob[a as usize..b as usize].cmp(&blob[c as usize..d as usize]));
|
||||
spans.dedup_by(|&mut (a, b), &mut (c, d)| blob[a as usize..b as usize] == blob[c as usize..d as usize]);
|
||||
self.file_blob = blob;
|
||||
self.file_spans = spans;
|
||||
}
|
||||
|
||||
/// [`set_file_list_joined`](Self::set_file_list_joined) from a `Vec` —
|
||||
/// convenience for hosts/tests that already hold separate strings.
|
||||
pub fn set_file_list(&mut self, files: Vec<String>) {
|
||||
self.set_file_list_joined(files.join("\n"));
|
||||
}
|
||||
|
||||
/// Push `path` to the front of the recent-files MRU (dropping any earlier
|
||||
/// occurrence), bounded to [`MRU_MAX`]. Drives the palette's empty-query
|
||||
/// order, so the file you were just in sits at the top.
|
||||
pub(crate) fn note_recent(&mut self, path: &str) {
|
||||
self.recent.retain(|p| p != path);
|
||||
self.recent.insert(0, path.to_string());
|
||||
self.recent.truncate(MRU_MAX);
|
||||
}
|
||||
|
||||
}
|
||||
435
editor/src/editing.rs
Normal file
435
editor/src/editing.rs
Normal file
@@ -0,0 +1,435 @@
|
||||
//! Text mutation: insert/delete/paste, registers, snippets expansion at the
|
||||
//! caret, operators and text objects.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange /
|
||||
/// `y`ank).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Op {
|
||||
Delete,
|
||||
Change,
|
||||
Yank,
|
||||
}
|
||||
|
||||
|
||||
impl Editor {
|
||||
pub(crate) fn insert_char(&mut self, c: char) {
|
||||
self.text.insert(self.caret, c);
|
||||
self.caret += c.len_utf8();
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, s: &str) {
|
||||
self.text.insert_str(self.caret, s);
|
||||
self.caret += s.len();
|
||||
}
|
||||
|
||||
// --- Snippets ----------------------------------------------------------
|
||||
|
||||
/// Expand a snippet `body` at the caret and enter its tab-stop session. Splits
|
||||
/// the body into literal text (stops removed) and their [`parse_snippet_body`]
|
||||
/// visit order, inserts the literal, lands the caret on `$1` (or the body end
|
||||
/// if there are no stops), and enters Insert. The remaining stops queue in
|
||||
/// [`snippet_stops`](Self::snippet_stops) for Tab. Shared by inline
|
||||
/// Tab-expansion and the `$` palette. **The caller owns the undo boundary** —
|
||||
/// it must [`checkpoint`](Self::checkpoint) *before* any related mutation
|
||||
/// (the inline path deletes the trigger word first), so one undo group covers
|
||||
/// the whole expansion; `insert_snippet` deliberately does not checkpoint.
|
||||
pub(crate) fn insert_snippet(&mut self, body: &str) {
|
||||
let (literal, stops) = parse_snippet_body(body);
|
||||
let base = self.caret;
|
||||
self.text.insert_str(base, &literal);
|
||||
let mut abs = stops.into_iter().map(|o| base + o);
|
||||
match abs.next() {
|
||||
Some(first) => {
|
||||
self.caret = first;
|
||||
self.snippet_stops = abs.collect();
|
||||
}
|
||||
None => {
|
||||
self.caret = base + literal.len();
|
||||
self.snippet_stops.clear();
|
||||
}
|
||||
}
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// Tab inside a live session: jump the caret to the next pending stop. The
|
||||
/// final stop (`$0` / the body end) empties the queue, ending the session with
|
||||
/// the caret resting there. Offsets were kept current by the edit-shift in
|
||||
/// [`insert_key`](Self::insert_key), so this is a plain move.
|
||||
pub(crate) fn snippet_advance(&mut self) {
|
||||
if self.snippet_stops.is_empty() {
|
||||
return;
|
||||
}
|
||||
let next = self.snippet_stops.remove(0);
|
||||
self.caret = next.min(self.text.len());
|
||||
}
|
||||
|
||||
/// The maximal run of non-whitespace ending exactly at the caret — the
|
||||
/// candidate inline snippet trigger — or `None` if the caret is at a line/word
|
||||
/// start (the char before it is whitespace). Whitespace is ASCII, so scanning
|
||||
/// bytes is UTF-8-safe: `start` lands just past an ASCII space/newline (a char
|
||||
/// boundary) or at 0.
|
||||
pub(crate) fn word_before_caret(&self) -> Option<(usize, &str)> {
|
||||
let b = self.text.as_bytes();
|
||||
if self.caret == 0 || b[self.caret - 1].is_ascii_whitespace() {
|
||||
return None;
|
||||
}
|
||||
let mut start = self.caret;
|
||||
while start > 0 && !b[start - 1].is_ascii_whitespace() {
|
||||
start -= 1;
|
||||
}
|
||||
Some((start, &self.text[start..self.caret]))
|
||||
}
|
||||
|
||||
/// Inline Tab-expansion: if the word immediately before the caret is exactly a
|
||||
/// snippet prefix, replace it with the expansion (as one undo group) and start
|
||||
/// the tab-stop session, returning `true`. Otherwise leave the buffer untouched
|
||||
/// and return `false`, so Tab falls back to inserting spaces.
|
||||
pub(crate) fn try_expand_snippet(&mut self) -> bool {
|
||||
let Some((start, word)) = self.word_before_caret() else {
|
||||
return false;
|
||||
};
|
||||
let Some(body) = self.snippets.iter().find(|s| s.prefix == word).map(|s| s.body.clone())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
self.checkpoint(); // baseline includes the trigger word — undo restores it
|
||||
self.text.replace_range(start..self.caret, "");
|
||||
self.caret = start;
|
||||
self.insert_snippet(&body);
|
||||
true
|
||||
}
|
||||
|
||||
/// Enter in Insert mode, with Markdown list and blockquote continuation. At the
|
||||
/// END of a list line (`- `/`* `/`+ ` or `N. `) or a blockquote (`> `, nested
|
||||
/// depth preserved), start the next line automatically — same bullet, the next
|
||||
/// number, or the same quote depth — preserving indentation. Enter on an
|
||||
/// otherwise-empty item/quote strips the marker instead (exits it). Anywhere
|
||||
/// else (mid-line, or a plain line) it's a plain newline.
|
||||
pub(crate) fn insert_newline(&mut self) {
|
||||
let le = self.line_end(self.caret);
|
||||
if self.caret == le {
|
||||
let ls = self.line_start(self.caret);
|
||||
if let Some((next, cur_len, content_empty)) = continuation_marker(&self.text[ls..le]) {
|
||||
if content_empty {
|
||||
// Empty item/quote: drop the marker, leaving a blank line.
|
||||
self.text.replace_range(ls..ls + cur_len, "");
|
||||
self.caret = ls;
|
||||
} else {
|
||||
self.insert_str(&format!("\n{next}"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.insert_char('\n');
|
||||
}
|
||||
|
||||
pub(crate) fn backspace(&mut self) {
|
||||
if self.caret > 0 {
|
||||
self.caret = self.prev_char(self.caret);
|
||||
self.text.remove(self.caret); // removes the whole char at the caret
|
||||
}
|
||||
}
|
||||
|
||||
/// `x` — delete the char under the caret (never a newline).
|
||||
pub(crate) fn delete_at_caret(&mut self) {
|
||||
let b = self.text.as_bytes();
|
||||
if self.caret < b.len() && b[self.caret] != b'\n' {
|
||||
self.text.remove(self.caret);
|
||||
// Keep the caret on a char: if it fell off the line end, step back.
|
||||
if self.caret >= self.line_end(self.caret) && self.caret > self.line_start(self.caret) {
|
||||
self.caret = self.prev_char(self.caret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `dd` — delete the current logical line, including its newline (or the
|
||||
/// preceding one for the last line, so no blank line is left behind).
|
||||
pub(crate) fn delete_current_line(&mut self) {
|
||||
let ls = self.line_start(self.caret);
|
||||
let le = self.line_end(self.caret);
|
||||
let (start, end) = if le < self.text.len() {
|
||||
(ls, le + 1) // eat the trailing newline
|
||||
} else if ls > 0 {
|
||||
(ls - 1, le) // last line: eat the preceding newline instead
|
||||
} else {
|
||||
(ls, le) // whole buffer
|
||||
};
|
||||
self.text.replace_range(start..end, "");
|
||||
self.caret = self.line_start(start.min(self.text.len()));
|
||||
}
|
||||
|
||||
/// `cc` — clear the current line's text and drop into insert.
|
||||
pub(crate) fn change_current_line(&mut self) {
|
||||
self.checkpoint();
|
||||
let ls = self.line_start(self.caret);
|
||||
let le = self.line_end(self.caret);
|
||||
self.register = format!("{}\n", &self.text[ls..le]); // linewise, like dd
|
||||
self.register_linewise = true;
|
||||
self.text.replace_range(ls..le, "");
|
||||
self.caret = ls;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// Yank `n` logical lines from the caret's line into the register, linewise
|
||||
/// (each line carries its trailing `\n`, synthesised for a final line that
|
||||
/// lacks one). Backs both `yy`/`nyy` and `dd`/`ndd`'s register capture; does
|
||||
/// not move the caret or change the buffer.
|
||||
pub(crate) fn register_lines(&mut self, n: usize) {
|
||||
let ls = self.line_start(self.caret);
|
||||
let mut e = ls;
|
||||
for _ in 0..n {
|
||||
let le = self.line_end(e);
|
||||
e = if le < self.text.len() { le + 1 } else { le };
|
||||
}
|
||||
let mut block = self.text[ls..e].to_string();
|
||||
if !block.ends_with('\n') {
|
||||
block.push('\n'); // the last line has no trailing newline; add one
|
||||
}
|
||||
self.register = block;
|
||||
self.register_linewise = true;
|
||||
}
|
||||
|
||||
/// `p` — paste the register `n` times after the caret. Linewise content
|
||||
/// opens new line(s) below the current line (caret to the first pasted
|
||||
/// line); charwise content goes in just after the caret char (caret on the
|
||||
/// last pasted char). No-op on an empty register.
|
||||
pub(crate) fn paste_after(&mut self, n: usize) {
|
||||
if self.register.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.checkpoint();
|
||||
let content = self.register.repeat(n);
|
||||
// `end`: byte offset of the last pasted char, so the viewport can reveal
|
||||
// the whole block even when the caret stays on its first line.
|
||||
let end = if self.register_linewise {
|
||||
let le = self.line_end(self.caret);
|
||||
if le < self.text.len() {
|
||||
let at = le + 1; // start of the following line
|
||||
self.text.insert_str(at, &content);
|
||||
self.caret = at;
|
||||
at + content.len() - 1
|
||||
} else {
|
||||
// Last line has no trailing newline: prefix one, drop the
|
||||
// block's trailing newline so we don't leave a blank line.
|
||||
let block = content.strip_suffix('\n').unwrap_or(&content);
|
||||
let inserted = format!("\n{block}");
|
||||
let end = le + inserted.len() - 1;
|
||||
self.text.insert_str(le, &inserted);
|
||||
self.caret = le + 1;
|
||||
end
|
||||
}
|
||||
} else {
|
||||
let at = if self.text.is_empty() { 0 } else { self.next_char(self.caret) };
|
||||
self.text.insert_str(at, &content);
|
||||
self.caret = self.prev_char(at + content.len()); // onto the last char
|
||||
self.caret
|
||||
};
|
||||
self.reveal(end);
|
||||
}
|
||||
|
||||
/// `P` — paste the register `n` times before the caret. Linewise content
|
||||
/// opens new line(s) above the current line; charwise content goes in at the
|
||||
/// caret (caret on the last pasted char). No-op on an empty register.
|
||||
pub(crate) fn paste_before(&mut self, n: usize) {
|
||||
if self.register.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.checkpoint();
|
||||
let content = self.register.repeat(n);
|
||||
let end = if self.register_linewise {
|
||||
let ls = self.line_start(self.caret);
|
||||
self.text.insert_str(ls, &content);
|
||||
self.caret = ls;
|
||||
ls + content.len() - 1
|
||||
} else {
|
||||
let at = self.caret;
|
||||
self.text.insert_str(at, &content);
|
||||
self.caret = self.prev_char(at + content.len()); // onto the last char
|
||||
self.caret
|
||||
};
|
||||
self.reveal(end);
|
||||
}
|
||||
|
||||
/// Apply a pending operator over the buffer range `[start, end)` (order
|
||||
/// independent). All three fill the unnamed register (charwise) with the
|
||||
/// range. Yank copies and leaves the text; Delete removes it; Change removes
|
||||
/// it and enters insert. Yank leaves the caret at the range start (vim `yw`),
|
||||
/// the others land it there because the text collapses to that point.
|
||||
pub(crate) fn apply_op(&mut self, op: Op, start: usize, end: usize) {
|
||||
let s = start.min(end);
|
||||
let e = start.max(end).min(self.text.len());
|
||||
self.register = self.text[s..e].to_string();
|
||||
self.register_linewise = false;
|
||||
if op == Op::Yank {
|
||||
self.caret = s;
|
||||
return;
|
||||
}
|
||||
self.checkpoint(); // Delete/Change mutate — snapshot for undo
|
||||
self.text.replace_range(s..e, "");
|
||||
self.caret = s.min(self.text.len());
|
||||
if op == Op::Change {
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a text object to a buffer range. `around` selects `a` (include
|
||||
/// delimiters / trailing space) vs `i` (inner). Returns `None` if there's
|
||||
/// no matching object under the caret.
|
||||
pub(crate) fn text_object(&self, obj: char, around: bool) -> Option<(usize, usize)> {
|
||||
match obj {
|
||||
'w' => Some(self.word_object(around)),
|
||||
'(' | ')' | 'b' => self.pair_object(b'(', b')', around),
|
||||
'{' | '}' | 'B' => self.pair_object(b'{', b'}', around),
|
||||
'[' | ']' => self.pair_object(b'[', b']', around),
|
||||
'<' | '>' => self.pair_object(b'<', b'>', around),
|
||||
'"' => self.quote_object(b'"', around),
|
||||
'\'' => self.quote_object(b'\'', around),
|
||||
'`' => self.quote_object(b'`', around),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `iw`/`aw`: the run of same-class chars (word vs space, never crossing a
|
||||
/// newline) under the caret. `aw` also takes the trailing run of spaces, or
|
||||
/// the leading one if there is no trailing space. Word class is
|
||||
/// whitespace-delimited (so this behaves like vim's `iW`/`aW`).
|
||||
pub(crate) fn word_object(&self, around: bool) -> (usize, usize) {
|
||||
let b = self.text.as_bytes();
|
||||
let n = b.len();
|
||||
if n == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let pos = self.caret.min(n - 1);
|
||||
let ws = |c: u8| c == b' ' || c == b'\t';
|
||||
let target_ws = ws(b[pos]);
|
||||
let same = |c: u8| ws(c) == target_ws && c != b'\n';
|
||||
let mut s = pos;
|
||||
while s > 0 && same(b[s - 1]) {
|
||||
s -= 1;
|
||||
}
|
||||
let mut e = pos + 1;
|
||||
while e < n && same(b[e]) {
|
||||
e += 1;
|
||||
}
|
||||
if around && !target_ws {
|
||||
let mut a = e;
|
||||
while a < n && ws(b[a]) {
|
||||
a += 1;
|
||||
}
|
||||
if a > e {
|
||||
return (s, a);
|
||||
}
|
||||
let mut ls = s;
|
||||
while ls > 0 && ws(b[ls - 1]) {
|
||||
ls -= 1;
|
||||
}
|
||||
return (ls, e);
|
||||
}
|
||||
(s, e)
|
||||
}
|
||||
|
||||
/// `i(`/`a(` and friends: the range between the bracket pair enclosing the
|
||||
/// caret, nesting-aware. `around` includes the brackets themselves.
|
||||
pub(crate) fn pair_object(&self, open: u8, close: u8, around: bool) -> Option<(usize, usize)> {
|
||||
let b = self.text.as_bytes();
|
||||
let n = b.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let start = self.caret.min(n - 1);
|
||||
// Scan left for the enclosing open bracket.
|
||||
let mut depth = 0i32;
|
||||
let mut i = start;
|
||||
let open_idx = loop {
|
||||
let ch = b[i];
|
||||
if ch == close && i != start {
|
||||
depth += 1;
|
||||
} else if ch == open {
|
||||
if depth == 0 {
|
||||
break Some(i);
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
if i == 0 {
|
||||
break None;
|
||||
}
|
||||
i -= 1;
|
||||
}?;
|
||||
// Scan right for its matching close.
|
||||
let mut depth = 0i32;
|
||||
let mut j = open_idx + 1;
|
||||
let close_idx = loop {
|
||||
if j >= n {
|
||||
break None;
|
||||
}
|
||||
let ch = b[j];
|
||||
if ch == open {
|
||||
depth += 1;
|
||||
} else if ch == close {
|
||||
if depth == 0 {
|
||||
break Some(j);
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
j += 1;
|
||||
}?;
|
||||
Some(if around {
|
||||
(open_idx, close_idx + 1)
|
||||
} else {
|
||||
(open_idx + 1, close_idx)
|
||||
})
|
||||
}
|
||||
|
||||
/// `i"`/`a"` and friends: the range between a matching quote pair on the
|
||||
/// current line. `around` includes the quotes.
|
||||
pub(crate) fn quote_object(&self, q: u8, around: bool) -> Option<(usize, usize)> {
|
||||
let b = self.text.as_bytes();
|
||||
let ls = self.line_start(self.caret);
|
||||
let le = self.line_end(self.caret);
|
||||
let quotes: Vec<usize> = (ls..le).filter(|&i| b[i] == q).collect();
|
||||
// Pair them left-to-right; take the first pair closing at/after the caret.
|
||||
let mut k = 0;
|
||||
while k + 1 < quotes.len() {
|
||||
let (a, z) = (quotes[k], quotes[k + 1]);
|
||||
if self.caret <= z {
|
||||
return Some(if around { (a, z + 1) } else { (a + 1, z) });
|
||||
}
|
||||
k += 2;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert-mode Ctrl+W / Ctrl+Backspace: delete the word before the caret.
|
||||
pub(crate) fn delete_word_before(&mut self) {
|
||||
let b = self.text.as_bytes();
|
||||
let mut i = self.caret;
|
||||
while i > 0 && (b[i - 1] == b' ' || b[i - 1] == b'\t') {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !b[i - 1].is_ascii_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
self.text.replace_range(i..self.caret, "");
|
||||
self.caret = i;
|
||||
}
|
||||
|
||||
/// Insert-mode Cmd+Backspace: delete back to the start of the line, or the
|
||||
/// preceding newline if already there.
|
||||
pub(crate) fn delete_to_line_start(&mut self) {
|
||||
let ls = self.line_start(self.caret);
|
||||
if ls == self.caret {
|
||||
if self.caret > 0 {
|
||||
self.caret -= 1;
|
||||
self.text.remove(self.caret);
|
||||
}
|
||||
} else {
|
||||
self.text.replace_range(ls..self.caret, "");
|
||||
self.caret = ls;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rendering ---------------------------------------------------------
|
||||
|
||||
}
|
||||
121
editor/src/fuzzy.rs
Normal file
121
editor/src/fuzzy.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! Match primitives: the palette's fuzzy scorer and the case/diacritic
|
||||
//! folding helpers shared with `/` search.
|
||||
|
||||
|
||||
/// Fuzzy-match `query` against `text` (the file palette's matcher). Returns a
|
||||
/// relevance score if every `query` character appears in `text` in order
|
||||
/// (a subsequence match, case-insensitive over ASCII), else `None`. Higher is
|
||||
/// better; a higher score is a "tighter" match.
|
||||
///
|
||||
/// Scoring rewards two things prose filenames make meaningful: a match at a
|
||||
/// **word boundary** (start of string, or just after `/ _ - . space`) scores far
|
||||
/// above one mid-word, and a **run** of consecutive matches scores extra per
|
||||
/// char. So typing `notes` ranks `repo/notes.md` above `promo-tests.md` even
|
||||
/// though both contain the letters. There are no penalties, so a score is always
|
||||
/// ≥ the query length; ties are broken by the caller (recency, then list order).
|
||||
/// An empty query matches everything with score 0.
|
||||
///
|
||||
/// A **space** in the query matches any separator (`/ _ - . space`), so typing
|
||||
/// `la conv` finds `la-convergence.md` — filenames use `-` where the typist
|
||||
/// thinks "space".
|
||||
pub(crate) fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
|
||||
let q: Vec<char> = query.chars().collect();
|
||||
if q.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
let mut qi = 0;
|
||||
let mut score = 0i32;
|
||||
let mut prev_matched = false;
|
||||
let mut prev: Option<char> = None;
|
||||
for (i, tc) in text.chars().enumerate() {
|
||||
let hit = qi < q.len()
|
||||
&& (tc.eq_ignore_ascii_case(&q[qi])
|
||||
|| (q[qi] == ' ' && matches!(tc, '/' | '_' | '-' | '.' | ' ')));
|
||||
if hit {
|
||||
score += 1;
|
||||
let boundary =
|
||||
i == 0 || matches!(prev, Some('/') | Some('_') | Some('-') | Some('.') | Some(' '));
|
||||
if boundary {
|
||||
score += 10;
|
||||
}
|
||||
if prev_matched {
|
||||
score += 5;
|
||||
}
|
||||
qi += 1;
|
||||
prev_matched = true;
|
||||
} else {
|
||||
prev_matched = false;
|
||||
}
|
||||
prev = Some(tc);
|
||||
}
|
||||
(qi == q.len()).then_some(score)
|
||||
}
|
||||
|
||||
|
||||
/// Byte offset of the first folded match of `pat` in `hay`, or `None`.
|
||||
/// Char-by-char comparison through [`fold`] at every char boundary — no
|
||||
/// folded copy of the buffer (folding can change byte lengths, which would
|
||||
/// break the returned offsets). `ci` is the smartcase verdict, computed once
|
||||
/// per search from the pattern. O(n·m), fine at note sizes for an
|
||||
/// Enter-triggered jump.
|
||||
pub(crate) fn find_fold(hay: &str, pat: &str, ci: bool) -> Option<usize> {
|
||||
hay.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.find(|&i| starts_with_fold(&hay[i..], pat, ci))
|
||||
}
|
||||
|
||||
/// [`find_fold`], but the *last* match — the backward (`N`) direction.
|
||||
pub(crate) fn rfind_fold(hay: &str, pat: &str, ci: bool) -> Option<usize> {
|
||||
hay.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.rev()
|
||||
.find(|&i| starts_with_fold(&hay[i..], pat, ci))
|
||||
}
|
||||
|
||||
/// Whether `s` begins with `pat` under [`fold`].
|
||||
pub(crate) fn starts_with_fold(s: &str, pat: &str, ci: bool) -> bool {
|
||||
let mut sc = s.chars();
|
||||
pat.chars()
|
||||
.all(|p| sc.next().is_some_and(|c| fold(c, ci) == fold(p, ci)))
|
||||
}
|
||||
|
||||
/// A char's search identity: diacritics are always stripped (`é` = `e`, so
|
||||
/// `/ete` finds `été` and vice versa — accents are how the word is *spelled*,
|
||||
/// not what you're *searching for*), and case is dropped only when `ci`
|
||||
/// (the smartcase rule: an all-lowercase pattern searches insensitively; one
|
||||
/// capital in it makes the search exact).
|
||||
pub(crate) fn fold(c: char, ci: bool) -> char {
|
||||
let c = if ci {
|
||||
// First char of the lowercase expansion — 1:1 for all of Latin,
|
||||
// which is what this appliance types.
|
||||
c.to_lowercase().next().unwrap_or(c)
|
||||
} else {
|
||||
c
|
||||
};
|
||||
strip_diacritic(c)
|
||||
}
|
||||
|
||||
/// Map accented Latin letters to their base letter, both cases (the Latin-1
|
||||
/// Supplement set — the French/Western repertoire the keymap can produce).
|
||||
/// Ligatures (`œ`, `æ`) fold to more than one char and are left alone.
|
||||
pub(crate) fn strip_diacritic(c: char) -> char {
|
||||
match c {
|
||||
'à'..='å' => 'a',
|
||||
'ç' => 'c',
|
||||
'è'..='ë' => 'e',
|
||||
'ì'..='ï' => 'i',
|
||||
'ñ' => 'n',
|
||||
'ò'..='ö' => 'o',
|
||||
'ù'..='ü' => 'u',
|
||||
'ý' | 'ÿ' => 'y',
|
||||
'À'..='Å' => 'A',
|
||||
'Ç' => 'C',
|
||||
'È'..='Ë' => 'E',
|
||||
'Ì'..='Ï' => 'I',
|
||||
'Ñ' => 'N',
|
||||
'Ò'..='Ö' => 'O',
|
||||
'Ù'..='Ü' => 'U',
|
||||
'Ý' => 'Y',
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
993
editor/src/lib.rs
Normal file
993
editor/src/lib.rs
Normal file
@@ -0,0 +1,993 @@
|
||||
//! Modal text editor core: a vim-style buffer with Normal / Insert (edit) /
|
||||
//! Visual (selection) / View (read-only) modes, rendered onto the e-paper
|
||||
//! [`Frame`].
|
||||
//!
|
||||
//! The buffer is a UTF-8 `String` (the keyboard's dead-key composer feeds it
|
||||
//! accented Latin-9 characters). `caret` is a byte offset that always sits on a
|
||||
//! char boundary: motions and edits step whole characters via `next_char` /
|
||||
//! `prev_char`, and display columns are character counts, so a two-byte `é`
|
||||
//! never traps the caret mid-character. Motions and edits work on the logical
|
||||
//! (`\n`-delimited) buffer; word-wrapping and scrolling are a render-time
|
||||
//! concern handled by [`Editor::draw`].
|
||||
|
||||
// ISO-8859-15 (Latin-9) rather than the ascii subset: same glyph cells, but it
|
||||
// carries the accented Latin glyphs (à é ê ç … plus œ €) that international
|
||||
// input will emit. ASCII rendering is byte-for-byte unchanged.
|
||||
use embedded_graphics::mono_font::iso_8859_15::{FONT_9X15, FONT_10X20};
|
||||
use embedded_graphics::mono_font::MonoTextStyle;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
use embedded_graphics::primitives::{PrimitiveStyle, Rectangle};
|
||||
use embedded_graphics::text::{Baseline, Text};
|
||||
|
||||
use display::{blit_glyph, extra_glyph, Frame, HEIGHT, WIDTH};
|
||||
use keymap::Key;
|
||||
|
||||
|
||||
mod buffers;
|
||||
mod editing;
|
||||
mod fuzzy;
|
||||
mod markdown;
|
||||
mod motions;
|
||||
mod palette;
|
||||
mod prefs;
|
||||
mod render;
|
||||
mod search;
|
||||
mod snippets;
|
||||
mod undo;
|
||||
mod visual;
|
||||
|
||||
pub(crate) use buffers::*;
|
||||
pub(crate) use editing::*;
|
||||
pub(crate) use fuzzy::*;
|
||||
pub(crate) use markdown::*;
|
||||
pub(crate) use palette::*;
|
||||
pub(crate) use prefs::*;
|
||||
pub(crate) use render::*;
|
||||
pub(crate) use snippets::*;
|
||||
|
||||
pub use buffers::{LOCAL_DIR, REPO_DIR};
|
||||
pub use prefs::{Prefs, PREFS_PATH};
|
||||
pub use render::{CH, CW};
|
||||
pub use snippets::{Snippet, Snippets, SNIPPETS_PATH};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// Navigation and commands (hjkl, w/b/e, dd, x, …).
|
||||
Normal,
|
||||
/// Text entry — keys insert at the caret.
|
||||
Insert,
|
||||
/// Charwise selection: an anchor is dropped at the caret (`visual_anchor`)
|
||||
/// and motions extend the span; `y`/`d`/`c` act on it, `Esc`/`v` leave.
|
||||
Visual,
|
||||
/// Linewise selection (`V`): the span always covers whole logical lines
|
||||
/// from the anchor's line to the caret's, whatever the columns.
|
||||
VisualLine,
|
||||
/// Read-only reading (entered with `gr`): keys scroll the viewport, edits
|
||||
/// are locked out.
|
||||
View,
|
||||
/// `:` command line — keys accumulate a command shown in the status strip;
|
||||
/// Enter runs it, Esc cancels. Handles `:fmt` (in-core) plus `:w`/`:gp`
|
||||
/// (which ask the host to persist/publish via an [`Effect`]).
|
||||
Command,
|
||||
/// File palette (`Cmd-P`, reachable from every mode) — a modal transient
|
||||
/// panel over the writing column. Typing fuzzy-filters the file list
|
||||
/// ([`Editor::set_file_list`]); `Ctrl-n`/`Ctrl-p` move the selection, Enter
|
||||
/// opens it, Esc (or `Cmd-P` again) cancels back to Normal. See
|
||||
/// [`Editor::palette_key`].
|
||||
Palette,
|
||||
}
|
||||
|
||||
/// Which of the two file scopes ([`CONTEXT.md`]) a buffer belongs to. Fixed at
|
||||
/// creation — there is no move-between-scopes operation. **Tracked** files live
|
||||
/// under [`REPO_DIR`] and can be Published (`:gp`); **Local** files live under
|
||||
/// [`LOCAL_DIR`] and never leave the device, so `:gp` is refused in-core.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Scope {
|
||||
Tracked,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// A side effect the host (firmware) must carry out. The editor core is pure and
|
||||
/// does no IO, so persistence, publishing, and file reads can't happen here —
|
||||
/// they are queued and drained by [`Editor::take_effects`] after a key batch,
|
||||
/// then actioned by the main loop. `:fmt` is pure text work and stays in-core,
|
||||
/// so it queues nothing.
|
||||
///
|
||||
/// A single key can queue more than one effect: opening a file that isn't
|
||||
/// resident queues a [`Save`](Effect::Save) of the outgoing dirty buffer *and* a
|
||||
/// [`Load`](Effect::Load) of the target. Effects are serviced in order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// Persist `contents` to `path` (an atomic save on the host). Queued by `:w`
|
||||
/// (and the `:wq`/`:x` aliases), by save-before-switch, and by
|
||||
/// save-before-evict. The contents ride along because the buffer being saved
|
||||
/// is not always the active one — an evicted buffer's text is no longer
|
||||
/// reachable through [`Editor::text`]. On success the host calls
|
||||
/// [`Editor::mark_saved`].
|
||||
Save { path: String, scope: Scope, contents: String },
|
||||
/// Read `path` from disk; on success the host installs it with
|
||||
/// [`Editor::install_loaded`]. Queued when switching to a file that is not
|
||||
/// resident in memory (`:e`, palette pick).
|
||||
Load { path: String, scope: Scope },
|
||||
/// `:gp` — publish the Tracked working copy (git push). Preceded by a
|
||||
/// [`Save`](Effect::Save) of the current buffer in the same batch. Never
|
||||
/// queued from a Local buffer (blocked in-core).
|
||||
Publish,
|
||||
/// `:gl` — pull from the remote: fetch, then **fast-forward only**. The host
|
||||
/// refuses (and surfaces) a divergence rather than merging, and never
|
||||
/// touches local commits. Complements `:gp` (push) as the download half.
|
||||
Pull,
|
||||
/// `:delete` — unlink `path` from the card. For a **Tracked** file the removal
|
||||
/// lands in the git working copy, so the next [`Publish`](Effect::Publish)'s
|
||||
/// `add --all` stages the deletion (no eager `git rm` needed); a **Local** file
|
||||
/// is just unlinked. The editor has already dropped the file from its model and
|
||||
/// switched away by the time this drains, so `scope` is informational; the host
|
||||
/// reports the outcome on the snackbar (mirrors [`Save`](Effect::Save)).
|
||||
Delete { path: String, scope: Scope },
|
||||
/// Persist the preferences file ([`PREFS_PATH`]) after a palette `>` command
|
||||
/// changed a pref. Carries the already-serialized TOML ([`Prefs::to_toml`]),
|
||||
/// so the host only does the atomic write — no re-serialization or buffer
|
||||
/// bookkeeping. Separate from [`Save`](Effect::Save): prefs are not a text
|
||||
/// buffer and live at a fixed path outside the multi-buffer model.
|
||||
SavePrefs { contents: String },
|
||||
}
|
||||
|
||||
|
||||
/// The editor state: buffer, caret, mode, viewport, and pending command state.
|
||||
pub struct Editor {
|
||||
text: String,
|
||||
/// Byte offset of the caret, always on a UTF-8 char boundary. Ranges over
|
||||
/// `0..=text.len()`; step it only via `next_char`/`prev_char`.
|
||||
caret: usize,
|
||||
mode: Mode,
|
||||
/// Index of the first visible display line.
|
||||
scroll_top: usize,
|
||||
/// Pending numeric count prefix (`0` = none), e.g. the `3` in `3j`.
|
||||
count: usize,
|
||||
/// Operator awaiting a motion/text object (`dd`, `dw`, `ciw`, `di(`, …).
|
||||
pending_op: Option<Op>,
|
||||
/// After an operator, an `i`/`a` text-object prefix awaiting the object
|
||||
/// char. `Some(false)` = inner (`i`), `Some(true)` = around (`a`).
|
||||
pending_obj: Option<bool>,
|
||||
/// First `g` of a `gg`/`gr` awaiting the second.
|
||||
pending_g: bool,
|
||||
/// The fixed end of a Visual selection (byte offset), dropped when `v`/`V`
|
||||
/// enters Visual and cleared on leaving. The selection spans from here to
|
||||
/// the caret; `None` outside Visual/VisualLine.
|
||||
visual_anchor: Option<usize>,
|
||||
/// The `:` command line being typed (valid only in `Mode::Command`).
|
||||
cmdline: String,
|
||||
/// Which prompt opened the command line — `':'` (ex command) or `'/'`
|
||||
/// (search). Both share `Mode::Command`'s line editing (vim models them as
|
||||
/// one command-line mode); Enter dispatches on this.
|
||||
cmd_prompt: char,
|
||||
/// The last `/` pattern, kept for `n`/`N` and a bare `/`+Enter repeat.
|
||||
/// Editor-global (not per-buffer), like vim's search register.
|
||||
last_search: String,
|
||||
/// Word count as of the last stats refresh. The panel shows this snapshot,
|
||||
/// not a live count, so ordinary typing never repaints the panel row — it is
|
||||
/// refreshed on a typing pause / non-Insert action via `refresh_stats`.
|
||||
shown_words: usize,
|
||||
/// Whether a USB keyboard is attached; drives the panel disconnect flag.
|
||||
/// Fed from `usb_kbd::keyboard_present()` by the main loop.
|
||||
keyboard_present: bool,
|
||||
/// Transient side-panel message ("snackbar") — the last host event
|
||||
/// (save/publish result). Shown until the next keystroke dismisses it
|
||||
/// (cleared in [`Editor::handle`]); `None` means nothing to show.
|
||||
notice: Option<String>,
|
||||
/// Editor preferences (mirrors [`PREFS_PATH`]). Held here so the palette `>`
|
||||
/// command mode can toggle them live; the host reads the file at boot and
|
||||
/// applies it via [`set_prefs`](Self::set_prefs), and reads it back for the
|
||||
/// keys it honours (`save_on_idle`). `format_on_save` and `line_numbers` are
|
||||
/// consulted in-core (`:w`/`:gp` and the gutter).
|
||||
prefs: Prefs,
|
||||
/// The unnamed register: the last yanked or deleted text, replayed by
|
||||
/// `p`/`P`. `y`, `d`, `c`, and `x` all fill it (vim's unnamed register), so
|
||||
/// `dd`…`p` moves a line. There is one register — no named registers yet.
|
||||
register: String,
|
||||
/// Whether [`register`](Self::register) holds whole **lines** (from `yy`/`dd`,
|
||||
/// stored with a trailing `\n`) rather than a character span (`yw`/`x`). It
|
||||
/// decides how `p`/`P` reinsert: linewise pastes open a new line, charwise
|
||||
/// paste inline next to the caret.
|
||||
register_linewise: bool,
|
||||
/// Undo history: `(text, caret)` snapshots, one per change-group, oldest
|
||||
/// first. We snapshot the whole buffer rather than journal diffs — prose
|
||||
/// notes are small and PSRAM is ample (8 MB), so a full copy per edit is
|
||||
/// cheap and far simpler to reason about. Bounded to [`UNDO_DEPTH`] groups.
|
||||
undo: Vec<(String, usize)>,
|
||||
/// Redo history: states popped by `u`, replayable with `Ctrl-r`. Cleared the
|
||||
/// moment a fresh edit records a new undo baseline (a new branch of history).
|
||||
redo: Vec<(String, usize)>,
|
||||
/// The last completed change, as the exact key sequence that produced it —
|
||||
/// replayed verbatim by `.`. Recording keystrokes (rather than a structured
|
||||
/// op) is what lets `.` repeat an insert session like `ciwfoo<Esc>`.
|
||||
dot: Vec<Key>,
|
||||
/// The change currently being recorded, if one is in progress (from the
|
||||
/// initiating key through the key that completes it). Committed to [`dot`] on
|
||||
/// completion. `None` between changes.
|
||||
dot_recording: Option<Vec<Key>>,
|
||||
/// True while `.` is replaying [`dot`], so the replayed keys are neither
|
||||
/// re-recorded nor able to re-trigger `.`.
|
||||
replaying: bool,
|
||||
/// Absolute path of the active buffer on the SD card (e.g.
|
||||
/// `/sd/repo/notes.md`). Empty for an unnamed scratch buffer (the boot-message
|
||||
/// layout use); `:w` on an empty path posts "no file name" rather than saving.
|
||||
path: String,
|
||||
/// The active buffer's scope. Gates Publish — `:gp` is refused in Local.
|
||||
scope: Scope,
|
||||
/// Whether the active buffer has unsaved edits. Set at each change-group
|
||||
/// ([`checkpoint`](Self::checkpoint)) and cleared when the host confirms a
|
||||
/// save ([`mark_saved`](Self::mark_saved)). Decides whether a switch/evict
|
||||
/// persists the buffer first. Deliberately over-eager: entering Insert and
|
||||
/// leaving without typing marks it dirty, costing at most one redundant
|
||||
/// (idempotent) save — cheaper than tracking every mutation site.
|
||||
dirty: bool,
|
||||
/// Inactive-but-resident buffers, least-recently-used first. The active
|
||||
/// buffer plus these is capped at [`MAX_RESIDENT`]; switching away parks the
|
||||
/// active buffer here (with its caret, scroll, and undo), switching back
|
||||
/// restores it without touching the disk. A parked buffer pushed over the cap
|
||||
/// is evicted — saved first (via an [`Effect::Save`]) if it is dirty.
|
||||
parked: Vec<Buffer>,
|
||||
/// Host-effect queue, drained by [`take_effects`](Self::take_effects) after a
|
||||
/// key batch. See [`Effect`].
|
||||
requests: Vec<Effect>,
|
||||
/// Every openable file, as absolute paths, fed by the host at boot (a
|
||||
/// recursive walk of `/sd/repo` and `/sd/local`). The palette fuzzy-filters
|
||||
/// this once the query reaches [`PALETTE_MIN_QUERY`] chars; empty until the
|
||||
/// host feeds it.
|
||||
///
|
||||
/// **Interned**: one newline-joined blob plus byte spans, not a
|
||||
/// `Vec<String>`. On the device 1099 paths as individual `String`s cost
|
||||
/// 182 KB of *internal* DRAM (small allocs stay under the 16 KB
|
||||
/// PSRAM-malloc threshold, and per-alloc overhead dwarfs the ~50-byte
|
||||
/// payloads) — which starved the SD DMA pool during the first on-device
|
||||
/// pull (2026-07-14). A single large blob lands in PSRAM; the spans are
|
||||
/// one small `Vec`. Access via [`file_at`](Self::file_at).
|
||||
file_blob: String,
|
||||
/// Byte ranges of each path in [`file_blob`](Self::file_blob), sorted by
|
||||
/// the paths they point at (the palette's stable base order).
|
||||
file_spans: Vec<(u32, u32)>,
|
||||
/// Recently-opened files, most-recent-first (an MRU), deduped and bounded to
|
||||
/// [`MRU_MAX`]. Every `:e`/palette open pushes to the front
|
||||
/// ([`note_recent`](Self::note_recent)); it orders the palette when the query
|
||||
/// is empty, so the file you were just in is one keystroke away.
|
||||
recent: Vec<String>,
|
||||
/// The palette's fuzzy query (valid only in [`Mode::Palette`]).
|
||||
palette_query: String,
|
||||
/// The selected row in the palette's *filtered* result list (index into
|
||||
/// [`palette_matches`](Self::palette_matches), not into [`files`](Self::files)).
|
||||
/// Reset to 0 whenever the query changes.
|
||||
palette_sel: usize,
|
||||
/// Which step the palette is in ([`List`](PaletteStep::List) filter vs the
|
||||
/// `New file` filename input). `List` whenever the palette is closed.
|
||||
palette_step: PaletteStep,
|
||||
/// The snippet library, fed by the host at boot via
|
||||
/// [`set_snippets`](Self::set_snippets) from `.typoena.snippets.json`. Empty
|
||||
/// until fed (and after a missing/malformed file). Drives inline
|
||||
/// Tab-expansion and the `$` palette.
|
||||
snippets: Vec<Snippet>,
|
||||
/// Active snippet tab-stop session: the byte offsets of the **remaining**
|
||||
/// stops to visit, in order, with the caret sitting on the current one. Empty
|
||||
/// when no session is running. On each Insert-mode edit the pending offsets
|
||||
/// shift by the edit's length delta (they are all at/after the caret), so they
|
||||
/// track the text; Tab pops the next one, and leaving Insert clears them.
|
||||
snippet_stops: Vec<usize>,
|
||||
/// Snapshot of the snippet name inline Tab would expand right now (the word
|
||||
/// before the caret is a prefix), or `None`. Refreshed by
|
||||
/// [`refresh_stats`](Self::refresh_stats) on the typing pause — the same
|
||||
/// throttle as the word count — so the panel hint never repaints per keystroke.
|
||||
snippet_hint: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
impl Editor {
|
||||
pub fn new() -> Self {
|
||||
Editor {
|
||||
text: String::new(),
|
||||
caret: 0,
|
||||
mode: Mode::Normal, // power-on = Normal (vim-style); `with_text` boots the same
|
||||
scroll_top: 0,
|
||||
count: 0,
|
||||
pending_op: None,
|
||||
pending_obj: None,
|
||||
pending_g: false,
|
||||
visual_anchor: None,
|
||||
cmdline: String::new(),
|
||||
cmd_prompt: ':',
|
||||
last_search: String::new(),
|
||||
shown_words: 0,
|
||||
keyboard_present: false,
|
||||
notice: None,
|
||||
prefs: Prefs::default(),
|
||||
register: String::new(),
|
||||
register_linewise: false,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
dot: Vec::new(),
|
||||
dot_recording: None,
|
||||
replaying: false,
|
||||
path: String::new(),
|
||||
scope: Scope::Tracked,
|
||||
dirty: false,
|
||||
parked: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
file_blob: String::new(),
|
||||
file_spans: Vec::new(),
|
||||
recent: Vec::new(),
|
||||
palette_query: String::new(),
|
||||
palette_sel: 0,
|
||||
palette_step: PaletteStep::List,
|
||||
snippets: Vec::new(),
|
||||
snippet_stops: Vec::new(),
|
||||
snippet_hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed a fresh editor from previously saved text — the boot-load path
|
||||
/// (`storage.load()` → `Editor`). Boots in **Normal** mode (vim opens a file
|
||||
/// in Normal, not Insert) with the caret on the *last* character — the
|
||||
/// resume point — matching the Esc→Normal convention rather than sitting one
|
||||
/// cell past the end. The first [`Editor::draw`] scrolls it into view. An
|
||||
/// empty string is equivalent to [`Editor::new`].
|
||||
pub fn with_text(text: String) -> Self {
|
||||
Self::with_file(String::new(), Scope::Tracked, text)
|
||||
}
|
||||
|
||||
/// Seed a fresh editor from a named file's saved text — the boot-load and
|
||||
/// file-open path. Same boot posture as [`with_text`](Self::with_text)
|
||||
/// (Normal mode, caret on the last character) but records the file's `path`
|
||||
/// and `scope` so `:w` knows where to persist and `:gp` knows whether
|
||||
/// Publish is offered.
|
||||
pub fn with_file(path: String, scope: Scope, text: String) -> Self {
|
||||
let mut ed = Editor { text, path, scope, ..Editor::new() };
|
||||
ed.caret = ed.text.len();
|
||||
if ed.caret > ed.line_start(ed.caret) {
|
||||
ed.caret = ed.prev_char(ed.caret);
|
||||
}
|
||||
ed
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> Mode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// The full buffer contents, for the host to persist on `:w`/`:gp`.
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Absolute path of the active buffer (empty for an unnamed scratch buffer).
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The active buffer's [`Scope`]. The host hides/greys `Ctrl-G` in Local.
|
||||
pub fn scope(&self) -> Scope {
|
||||
self.scope
|
||||
}
|
||||
|
||||
/// Whether the active buffer has unsaved edits.
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
/// Drain the queued host effects (save/load/publish/pull). The main loop
|
||||
/// calls this after applying a key batch and services them in order.
|
||||
pub fn take_effects(&mut self) -> Vec<Effect> {
|
||||
core::mem::take(&mut self.requests)
|
||||
}
|
||||
|
||||
pub fn scroll_top(&self) -> usize {
|
||||
self.scroll_top
|
||||
}
|
||||
|
||||
/// Recompute the throttled panel snapshots from the buffer: the word count and
|
||||
/// the inline-snippet hint. The main loop calls this on a typing pause and on
|
||||
/// non-Insert actions, so the panel stays current without repainting on every
|
||||
/// keystroke.
|
||||
pub fn refresh_stats(&mut self) {
|
||||
self.shown_words = self.word_count();
|
||||
self.snippet_hint = self.current_snippet_hint();
|
||||
}
|
||||
|
||||
/// The snippet name inline Tab would expand at the caret right now, or `None`.
|
||||
/// Only in Insert mode outside a live tab-stop session (mid-session Tab
|
||||
/// advances stops, it doesn't expand), and only when the word immediately
|
||||
/// before the caret is exactly a snippet prefix — the same test
|
||||
/// [`try_expand_snippet`](Self::try_expand_snippet) uses. Snapshotted into
|
||||
/// [`snippet_hint`](Self::snippet_hint) by [`refresh_stats`](Self::refresh_stats).
|
||||
fn current_snippet_hint(&self) -> Option<String> {
|
||||
if self.mode != Mode::Insert || !self.snippet_stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (_, word) = self.word_before_caret()?;
|
||||
self.snippets
|
||||
.iter()
|
||||
.find(|s| s.prefix == word)
|
||||
.map(|s| s.name.clone())
|
||||
}
|
||||
|
||||
/// Tell the editor whether a keyboard is attached (for the panel flag).
|
||||
pub fn set_keyboard_present(&mut self, present: bool) {
|
||||
self.keyboard_present = present;
|
||||
}
|
||||
|
||||
/// Post a transient side-panel notice ("snackbar") — e.g. the result of a
|
||||
/// save or publish. Shown from the next [`Editor::draw`] until the next
|
||||
/// keystroke dismisses it (see [`Editor::handle`]). The host calls this from
|
||||
/// its `:` command effect handlers.
|
||||
pub fn set_notice(&mut self, msg: impl Into<String>) {
|
||||
self.notice = Some(msg.into());
|
||||
}
|
||||
|
||||
/// The current preferences. The host reads this for the keys it honours
|
||||
/// (`save_on_idle` in the idle loop); `format_on_save` and `line_numbers`
|
||||
/// are consulted in-core.
|
||||
pub fn prefs(&self) -> &Prefs {
|
||||
&self.prefs
|
||||
}
|
||||
|
||||
/// Apply the preferences the host read from [`PREFS_PATH`] at boot. Called
|
||||
/// before the first render so `line_numbers` shapes the first frame. A live
|
||||
/// change later comes from the palette `>` commands, not this.
|
||||
pub fn set_prefs(&mut self, prefs: Prefs) {
|
||||
self.prefs = prefs;
|
||||
}
|
||||
|
||||
/// Install the snippet library the host parsed from [`SNIPPETS_PATH`] at boot
|
||||
/// (via [`Snippets::parse`]). Mirrors [`set_prefs`](Self::set_prefs): a
|
||||
/// missing or malformed file simply yields an empty library and no snippets.
|
||||
pub fn set_snippets(&mut self, snippets: Snippets) {
|
||||
self.snippets = snippets.0;
|
||||
}
|
||||
|
||||
/// Whitespace-delimited word count of the whole buffer.
|
||||
fn word_count(&self) -> usize {
|
||||
self.text.split_whitespace().count()
|
||||
}
|
||||
|
||||
/// Dispatch one decoded key event according to the current mode. Any host
|
||||
/// effect a `:` command (or a buffer switch) triggers is pushed to the queue
|
||||
/// drained by [`take_effects`](Self::take_effects); ordinary keys queue
|
||||
/// nothing.
|
||||
pub fn handle(&mut self, key: Key) {
|
||||
// Any keystroke dismisses the transient notice ("snackbar"). The host
|
||||
// sets a fresh one *after* the key batch (on a `:` command's effect), so
|
||||
// a save/publish message survives to the next draw, then clears the
|
||||
// moment you move on — no timed repaint (which on e-ink would cost a
|
||||
// full ~630 ms flash just to erase text).
|
||||
self.notice = None;
|
||||
|
||||
// `.` repeats the last change — intercepted before dispatch (in Normal,
|
||||
// not mid-command, not already replaying) so the '.' keystroke itself is
|
||||
// never inserted or recorded. In Insert mode '.' falls through as a
|
||||
// literal character.
|
||||
if !self.replaying
|
||||
&& self.mode == Mode::Normal
|
||||
&& self.pending_op.is_none()
|
||||
&& self.pending_obj.is_none()
|
||||
&& self.dot_recording.is_none()
|
||||
&& key == Key::Char('.')
|
||||
{
|
||||
self.repeat_last_change();
|
||||
return;
|
||||
}
|
||||
|
||||
// State before dispatch, so `record_dot` can read the transition a key
|
||||
// caused (entered Insert, started an operator, …).
|
||||
let before_mode = self.mode;
|
||||
let before_pending = self.pending_op.is_some() || self.pending_obj.is_some();
|
||||
|
||||
match self.mode {
|
||||
Mode::Insert => self.insert_key(key),
|
||||
Mode::Normal => self.normal_key(key),
|
||||
Mode::Visual | Mode::VisualLine => self.visual_key(key),
|
||||
Mode::View => self.view_key(key),
|
||||
Mode::Command => self.command_key(key),
|
||||
Mode::Palette => self.palette_key(key),
|
||||
}
|
||||
|
||||
// A snippet tab-stop session lives only in Insert. Leaving Insert — Esc,
|
||||
// or any mode change — ends it (the buffer is then just text, so Tab
|
||||
// inserts a tab again). The natural end (Tab past the last stop) already
|
||||
// empties `snippet_stops` while still in Insert.
|
||||
if !self.snippet_stops.is_empty() && self.mode != Mode::Insert {
|
||||
self.snippet_stops.clear();
|
||||
}
|
||||
|
||||
// Cmd-p mid-change (e.g. during an insert session) aborts the `.`
|
||||
// recording — a palette round-trip (file switch, `>` command) is not a
|
||||
// repeatable edit, and replaying it from `.` would reopen the palette.
|
||||
if key == Key::Palette {
|
||||
self.dot_recording = None;
|
||||
}
|
||||
|
||||
if !self.replaying {
|
||||
self.record_dot(key, before_mode, before_pending);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record `key` into the in-progress change for `.`. Called after dispatch
|
||||
/// with the mode/operator state as it was *before*. A change is recorded
|
||||
/// from its initiating key (an edit `x`/`p`/`P`, an operator `d`/`c`, or any
|
||||
/// key that enters Insert) through the key that completes it — an operator
|
||||
/// resolving back to Normal, or `Esc` ending an insert session. Yank (`y`)
|
||||
/// and pure motions never start a recording, so `.` ignores them. The leading
|
||||
/// count is not captured (so `3x` then `.` deletes one), but a count *inside*
|
||||
/// an operator is (`d2w` records in full).
|
||||
fn record_dot(&mut self, key: Key, before_mode: Mode, before_pending: bool) {
|
||||
if self.dot_recording.is_some() {
|
||||
self.dot_recording.as_mut().unwrap().push(key);
|
||||
if self.change_complete() {
|
||||
self.dot = self.dot_recording.take().unwrap();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Not yet recording: does this key begin a change? Only from a clean
|
||||
// Normal state (no operator already pending — that key belongs to an
|
||||
// in-progress command we'd have been recording already).
|
||||
if before_mode == Mode::Normal && !before_pending {
|
||||
let starts = matches!(key, Key::Char('x') | Key::Char('p') | Key::Char('P'))
|
||||
|| self.mode == Mode::Insert
|
||||
|| matches!(self.pending_op, Some(Op::Delete) | Some(Op::Change));
|
||||
if starts {
|
||||
self.dot_recording = Some(vec![key]);
|
||||
if self.change_complete() {
|
||||
self.dot = self.dot_recording.take().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A recorded change is complete once we're back in Normal with no operator
|
||||
/// still pending (an immediate edit, a resolved operator, or a finished
|
||||
/// insert session).
|
||||
fn change_complete(&self) -> bool {
|
||||
self.mode == Mode::Normal && self.pending_op.is_none() && self.pending_obj.is_none()
|
||||
}
|
||||
|
||||
/// `.` — replay the last recorded change. Sets [`replaying`](Self::replaying)
|
||||
/// so the replayed keys are not themselves recorded and cannot recurse.
|
||||
fn repeat_last_change(&mut self) {
|
||||
if self.dot.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.replaying = true;
|
||||
for k in self.dot.clone() {
|
||||
self.handle(k);
|
||||
}
|
||||
self.replaying = false;
|
||||
}
|
||||
|
||||
// --- Insert mode -------------------------------------------------------
|
||||
|
||||
fn insert_key(&mut self, key: Key) {
|
||||
// A live snippet session (non-empty `snippet_stops`) makes Tab advance to
|
||||
// the next stop instead of inserting a tab, and needs its pending offsets
|
||||
// kept in step with any edit at the caret (below).
|
||||
let session = !self.snippet_stops.is_empty();
|
||||
let len_before = self.text.len();
|
||||
match key {
|
||||
Key::Char('\t') if session => self.snippet_advance(),
|
||||
// Tab expands a snippet if the word before the caret is a prefix;
|
||||
// otherwise it inserts spaces as before.
|
||||
Key::Char('\t') => {
|
||||
if !self.try_expand_snippet() {
|
||||
self.insert_str(TAB);
|
||||
}
|
||||
}
|
||||
Key::Char(c) => self.insert_char(c),
|
||||
Key::Enter => self.insert_newline(),
|
||||
Key::Backspace => self.backspace(),
|
||||
Key::DeleteWord => self.delete_word_before(),
|
||||
Key::DeleteLine => self.delete_to_line_start(),
|
||||
// Half-page scroll and the Ctrl-n/Ctrl-p line motions are navigation
|
||||
// gestures — Normal/View only. In Insert they're a no-op rather than
|
||||
// yanking the caret off the text you're typing. Redo (Ctrl-r) is
|
||||
// likewise ignored here.
|
||||
Key::HalfPageDown | Key::HalfPageUp | Key::Redo | Key::Down | Key::Up => {}
|
||||
// Cmd-p works from every mode: acts like Esc (ending the insert
|
||||
// session, caret onto the last inserted char), then opens the
|
||||
// palette. Closing it lands in Normal, as Esc would have.
|
||||
Key::Palette => {
|
||||
self.mode = Mode::Normal;
|
||||
if self.caret > self.line_start(self.caret) {
|
||||
self.caret = self.prev_char(self.caret);
|
||||
}
|
||||
self.open_palette();
|
||||
}
|
||||
Key::Escape => {
|
||||
self.mode = Mode::Normal;
|
||||
// vim drops the caret onto the last inserted char.
|
||||
if self.caret > self.line_start(self.caret) {
|
||||
self.caret = self.prev_char(self.caret);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every pending stop sits at/after the caret, so an edit at the caret
|
||||
// shifts them all by its signed length delta — keeping `$2 … $0` correct
|
||||
// while you type at `$1`. (Tab-advance and Esc don't change the length.)
|
||||
if session && !self.snippet_stops.is_empty() {
|
||||
let delta = self.text.len() as isize - len_before as isize;
|
||||
if delta != 0 {
|
||||
for s in &mut self.snippet_stops {
|
||||
*s = s.saturating_add_signed(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Normal mode -------------------------------------------------------
|
||||
|
||||
fn normal_key(&mut self, key: Key) {
|
||||
let c = match key {
|
||||
Key::Char(c) => c,
|
||||
// Ctrl-d/u: scroll half a screen by *display* rows (see
|
||||
// `move_display_rows`). Like any non-motion key, they abandon a
|
||||
// pending count/operator first.
|
||||
Key::HalfPageDown => {
|
||||
self.reset_pending();
|
||||
self.move_display_rows(HALF_PAGE as isize);
|
||||
return;
|
||||
}
|
||||
Key::HalfPageUp => {
|
||||
self.reset_pending();
|
||||
self.move_display_rows(-(HALF_PAGE as isize));
|
||||
return;
|
||||
}
|
||||
// Ctrl-n/Ctrl-p: move down/up a line (vim CTRL-N ≡ j, CTRL-P ≡ k),
|
||||
// honouring a leading count (`3<C-n>`) then abandoning the rest of any
|
||||
// pending command like a plain motion.
|
||||
Key::Down => {
|
||||
let n = self.count.max(1);
|
||||
self.reset_pending();
|
||||
self.move_by('j', n);
|
||||
return;
|
||||
}
|
||||
Key::Up => {
|
||||
let n = self.count.max(1);
|
||||
self.reset_pending();
|
||||
self.move_by('k', n);
|
||||
return;
|
||||
}
|
||||
// Ctrl-r redo: like any non-motion key it abandons a pending command.
|
||||
Key::Redo => {
|
||||
self.reset_pending();
|
||||
self.redo();
|
||||
return;
|
||||
}
|
||||
// Cmd-p: open the file palette (abandoning any pending command).
|
||||
Key::Palette => {
|
||||
self.reset_pending();
|
||||
self.open_palette();
|
||||
return;
|
||||
}
|
||||
// Esc and other non-character events cancel any pending command.
|
||||
_ => {
|
||||
self.reset_pending();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Operator pending (d/c): expect a text object, motion, or doubled op.
|
||||
if let Some(op) = self.pending_op {
|
||||
// After an i/a prefix, `c` is the text-object selector.
|
||||
if let Some(around) = self.pending_obj {
|
||||
self.pending_obj = None;
|
||||
self.pending_op = None;
|
||||
if let Some((s, e)) = self.text_object(c, around) {
|
||||
self.apply_op(op, s, e);
|
||||
}
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
// A count between the operator and its motion (e.g. `d2w`).
|
||||
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
|
||||
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
|
||||
return;
|
||||
}
|
||||
let n = self.count.max(1);
|
||||
match c {
|
||||
'i' => {
|
||||
self.pending_obj = Some(false);
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
'a' => {
|
||||
self.pending_obj = Some(true);
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
'd' if op == Op::Delete => {
|
||||
self.checkpoint(); // one snapshot for the whole `ndd`
|
||||
self.register_lines(n); // yank the lines before removing them
|
||||
(0..n).for_each(|_| self.delete_current_line());
|
||||
}
|
||||
'c' if op == Op::Change => self.change_current_line(),
|
||||
'y' if op == Op::Yank => self.register_lines(n), // `yy` — caret stays put
|
||||
'w' => {
|
||||
let mut t = self.caret;
|
||||
(0..n).for_each(|_| t = self.word_forward_pos(t));
|
||||
self.apply_op(op, self.caret, t);
|
||||
}
|
||||
'b' => {
|
||||
let mut t = self.caret;
|
||||
(0..n).for_each(|_| t = self.word_back_pos(t));
|
||||
self.apply_op(op, self.caret, t);
|
||||
}
|
||||
'e' => {
|
||||
let mut t = self.caret;
|
||||
(0..n).for_each(|_| t = self.word_end_pos(t));
|
||||
// Inclusive of the last char: end the range past it.
|
||||
self.apply_op(op, self.caret, self.next_char(t));
|
||||
}
|
||||
'0' => self.apply_op(op, self.line_start(self.caret), self.caret),
|
||||
'$' => self.apply_op(op, self.caret, self.line_end(self.caret)),
|
||||
_ => {}
|
||||
}
|
||||
self.pending_op = None;
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.pending_g {
|
||||
self.pending_g = false;
|
||||
match c {
|
||||
'g' => self.caret = 0,
|
||||
// `gr` (go-read): enter the read-only View/scroll mode. `v`/`V`
|
||||
// used to trigger it but now belong to Visual selection.
|
||||
'r' => self.mode = Mode::View,
|
||||
_ => {}
|
||||
}
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Count prefix: a leading `0` is the line-start motion, not a digit.
|
||||
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
|
||||
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
|
||||
return;
|
||||
}
|
||||
let n = self.count.max(1);
|
||||
|
||||
match c {
|
||||
'g' => {
|
||||
self.pending_g = true;
|
||||
return;
|
||||
}
|
||||
'x' => {
|
||||
self.checkpoint();
|
||||
// Yank the chars we're about to delete (charwise), so `x`…`p`
|
||||
// works. `x` never crosses the line end.
|
||||
let s = self.caret;
|
||||
let le = self.line_end(s);
|
||||
let mut e = s;
|
||||
for _ in 0..n {
|
||||
if e >= le {
|
||||
break;
|
||||
}
|
||||
e = self.next_char(e);
|
||||
}
|
||||
self.register = self.text[s..e].to_string();
|
||||
self.register_linewise = false;
|
||||
(0..n).for_each(|_| self.delete_at_caret());
|
||||
}
|
||||
'u' => self.undo(),
|
||||
'd' => {
|
||||
self.pending_op = Some(Op::Delete);
|
||||
return;
|
||||
}
|
||||
'c' => {
|
||||
self.pending_op = Some(Op::Change);
|
||||
return;
|
||||
}
|
||||
'y' => {
|
||||
self.pending_op = Some(Op::Yank);
|
||||
return;
|
||||
}
|
||||
'p' => self.paste_after(n),
|
||||
'P' => self.paste_before(n),
|
||||
// Entering Insert snapshots once here; the whole session (up to Esc)
|
||||
// is one undo group, so `u` reverts an entire typed run at a time.
|
||||
'i' => {
|
||||
self.checkpoint();
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'a' => {
|
||||
self.checkpoint();
|
||||
self.move_right_append();
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'A' => {
|
||||
self.checkpoint();
|
||||
self.caret = self.line_end(self.caret);
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'I' => {
|
||||
self.checkpoint();
|
||||
self.caret = self.line_start(self.caret);
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'o' => {
|
||||
self.checkpoint();
|
||||
self.caret = self.line_end(self.caret);
|
||||
self.insert_char('\n');
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'O' => {
|
||||
self.checkpoint();
|
||||
let p = self.line_start(self.caret);
|
||||
self.text.insert(p, '\n');
|
||||
self.caret = p;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
// Drop an anchor at the caret and enter Visual (charwise `v`) /
|
||||
// VisualLine (`V`); motions then extend the selection.
|
||||
'v' => {
|
||||
self.visual_anchor = Some(self.caret);
|
||||
self.mode = Mode::Visual;
|
||||
}
|
||||
'V' => {
|
||||
self.visual_anchor = Some(self.caret);
|
||||
self.mode = Mode::VisualLine;
|
||||
}
|
||||
':' => {
|
||||
self.reset_pending();
|
||||
self.cmdline.clear();
|
||||
self.cmd_prompt = ':';
|
||||
self.mode = Mode::Command;
|
||||
return;
|
||||
}
|
||||
// `/` opens the same command line with a search prompt. The jump
|
||||
// happens on Enter only — no incremental caret-chasing; the e-ink
|
||||
// refresh cost rules that out (same call as the no-completion-popup
|
||||
// snippet decision).
|
||||
'/' => {
|
||||
self.reset_pending();
|
||||
self.cmdline.clear();
|
||||
self.cmd_prompt = '/';
|
||||
self.mode = Mode::Command;
|
||||
return;
|
||||
}
|
||||
// Any remaining char is either a shared motion (h/l/j/k/w/b/e/0/$/G)
|
||||
// or unknown; `move_by` applies the former and ignores the latter.
|
||||
_ => {
|
||||
self.move_by(c, n);
|
||||
}
|
||||
}
|
||||
self.count = 0;
|
||||
}
|
||||
|
||||
fn command_key(&mut self, key: Key) {
|
||||
match key {
|
||||
Key::Char(c) => self.cmdline.push(c),
|
||||
Key::Backspace => {
|
||||
// Backspace on the empty command line cancels back to Normal.
|
||||
if self.cmdline.pop().is_none() {
|
||||
self.mode = Mode::Normal;
|
||||
}
|
||||
}
|
||||
Key::Enter => {
|
||||
if self.cmd_prompt == '/' {
|
||||
self.execute_search();
|
||||
} else {
|
||||
self.execute_command();
|
||||
}
|
||||
self.cmdline.clear();
|
||||
// Most commands return to Normal; one that opened another mode
|
||||
// (`:settings` → the palette) set it during `execute_command`, so
|
||||
// only fall back to Normal if we're still in Command.
|
||||
if self.mode == Mode::Command {
|
||||
self.mode = Mode::Normal;
|
||||
}
|
||||
}
|
||||
Key::Escape => {
|
||||
self.cmdline.clear();
|
||||
self.mode = Mode::Normal;
|
||||
}
|
||||
// Cmd-p works from every mode: abandon the half-typed `:`/`/` line
|
||||
// (as Esc would) and open the palette.
|
||||
Key::Palette => {
|
||||
self.cmdline.clear();
|
||||
self.open_palette();
|
||||
}
|
||||
Key::DeleteWord => {
|
||||
// Readline Ctrl-W: drop trailing spaces, then the word before the
|
||||
// caret — editing the `:` command line while typing it. Unlike
|
||||
// Backspace, emptying the line does not cancel back to Normal.
|
||||
while self.cmdline.ends_with(' ') {
|
||||
self.cmdline.pop();
|
||||
}
|
||||
while !self.cmdline.is_empty() && !self.cmdline.ends_with(' ') {
|
||||
self.cmdline.pop();
|
||||
}
|
||||
}
|
||||
// Cmd+Backspace: clear the whole command line, staying in Command.
|
||||
Key::DeleteLine => self.cmdline.clear(),
|
||||
// Tab isn't meaningful on a short command line.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the typed `:` command, queuing any [`Effect`] the host must carry out.
|
||||
/// Unknown commands are silently ignored. The `:q` quit family is deliberately
|
||||
/// absent — an always-on writing appliance has nothing to quit to; `:wq`/`:x`
|
||||
/// therefore just save (the "quit" half is dropped).
|
||||
fn execute_command(&mut self) {
|
||||
let cmd = self.cmdline.trim().to_string();
|
||||
// `:enew <path>` — create a new file. (`:e` was retired in v0.6: bare
|
||||
// `Cmd-P` opens files, and `> new file` creates them.)
|
||||
if let Some(arg) = cmd.strip_prefix("enew ") {
|
||||
self.new_file(arg);
|
||||
return;
|
||||
}
|
||||
match cmd.as_str() {
|
||||
"enew" => self.set_notice("usage: :enew <file>"),
|
||||
"delete" => self.delete_current(),
|
||||
"settings" => self.open_settings(),
|
||||
"fmt" => self.format_buffer(),
|
||||
"w" | "wq" | "x" => {
|
||||
if self.prefs.format_on_save {
|
||||
self.format_buffer();
|
||||
}
|
||||
self.request_save_active();
|
||||
}
|
||||
// fmt → save → push, shared with the `>` publish command.
|
||||
"gp" => self.run_publish(),
|
||||
"gl" => self.requests.push(Effect::Pull),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_pending(&mut self) {
|
||||
self.count = 0;
|
||||
self.pending_op = None;
|
||||
self.pending_obj = None;
|
||||
self.pending_g = false;
|
||||
}
|
||||
|
||||
// --- Undo / redo -------------------------------------------------------
|
||||
|
||||
fn view_key(&mut self, key: Key) {
|
||||
match key {
|
||||
// j/k and Ctrl-n/Ctrl-p both step one row (View is a pure viewport).
|
||||
Key::Char('j') | Key::Down => self.scroll_top += 1, // clamped in draw()
|
||||
Key::Char('k') | Key::Up => self.scroll_top = self.scroll_top.saturating_sub(1),
|
||||
Key::Char(' ') => self.scroll_top += ROWS,
|
||||
// Half-page scroll, mirroring Normal mode — here it's a pure
|
||||
// viewport move (View has no caret to chase). Clamped in draw().
|
||||
Key::HalfPageDown => self.scroll_top += HALF_PAGE,
|
||||
Key::HalfPageUp => self.scroll_top = self.scroll_top.saturating_sub(HALF_PAGE),
|
||||
Key::Char('G') => {
|
||||
let total = self.layout().len();
|
||||
self.scroll_top = total.saturating_sub(ROWS);
|
||||
}
|
||||
Key::Char('g') => {
|
||||
if self.pending_g {
|
||||
self.scroll_top = 0;
|
||||
self.pending_g = false;
|
||||
} else {
|
||||
self.pending_g = true;
|
||||
}
|
||||
}
|
||||
Key::Escape => {
|
||||
self.mode = Mode::Normal;
|
||||
self.pending_g = false;
|
||||
}
|
||||
// Cmd-p works from every mode; leaving View for the palette (and
|
||||
// then Normal on close), exactly as Esc-then-Cmd-p would.
|
||||
Key::Palette => {
|
||||
self.pending_g = false;
|
||||
self.open_palette();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Motions (all on the logical buffer) -------------------------------
|
||||
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
241
editor/src/markdown.rs
Normal file
241
editor/src/markdown.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
//! Markdown formatting: `:fmt` reflow, list markers, and table alignment.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Parse an auto-continued Markdown line prefix at the start of `line` — a list
|
||||
/// item (`- `/`* `/`+ ` or `N. `) or a blockquote (`> `, possibly nested). Returns
|
||||
/// `(next_marker, current_marker_len, content_empty)` where `next_marker` is what
|
||||
/// the following line should start with (same bullet, the incremented number, or
|
||||
/// the same quote depth, preserving indentation), `current_marker_len` is the byte
|
||||
/// length of this line's marker prefix, and `content_empty` is whether anything
|
||||
/// follows it. Returns `None` when the line has no such prefix. ASCII throughout
|
||||
/// (leading spaces, bullets, digits, `. `, `> ` are all single-byte).
|
||||
pub(crate) fn continuation_marker(line: &str) -> Option<(String, usize, bool)> {
|
||||
let indent = line.len() - line.trim_start_matches(' ').len();
|
||||
let rest = &line[indent..];
|
||||
for bullet in ["- ", "* ", "+ "] {
|
||||
if rest.starts_with(bullet) {
|
||||
let cur_len = indent + bullet.len();
|
||||
let content_empty = line[cur_len..].trim().is_empty();
|
||||
return Some((format!("{}{bullet}", &line[..indent]), cur_len, content_empty));
|
||||
}
|
||||
}
|
||||
// Ordered: <digits>`. ` → continue as the next number.
|
||||
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
if digits > 0 && rest[digits..].starts_with(". ") {
|
||||
let cur_len = indent + digits + 2;
|
||||
let content_empty = line[cur_len..].trim().is_empty();
|
||||
let n: usize = rest[..digits].parse().unwrap_or(0);
|
||||
return Some((format!("{}{}. ", &line[..indent], n + 1), cur_len, content_empty));
|
||||
}
|
||||
// Blockquote: a run of `>` markers, each with an optional trailing space,
|
||||
// continued at the same depth (`> > text` → `> > `). A bare `>` normalizes to
|
||||
// `> `. A nested list inside the quote isn't preserved — it degrades to `> `.
|
||||
if rest.starts_with('>') {
|
||||
let bytes = rest.as_bytes();
|
||||
let mut j = 0;
|
||||
let mut depth = 0;
|
||||
while j < bytes.len() && bytes[j] == b'>' {
|
||||
depth += 1;
|
||||
j += 1;
|
||||
if j < bytes.len() && bytes[j] == b' ' {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
let cur_len = indent + j;
|
||||
let content_empty = line[cur_len..].trim().is_empty();
|
||||
let next = format!("{}{}", &line[..indent], "> ".repeat(depth));
|
||||
return Some((next, cur_len, content_empty));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// --- `:fmt` Markdown normalizer ----------------------------------------------
|
||||
|
||||
/// Column alignment parsed from a table's `|:--:|` separator row.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum Align {
|
||||
Left,
|
||||
Right,
|
||||
Center,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Normalize a Markdown buffer for `:fmt`: strip trailing whitespace, align
|
||||
/// pipe tables, and collapse runs of blank lines to a single blank (dropping
|
||||
/// trailing blanks). Deliberately does NOT reflow paragraphs — the buffer's
|
||||
/// logical line breaks are the writer's, and display wrapping is soft (see
|
||||
/// `layout`). ASCII throughout (widths are char counts).
|
||||
pub(crate) fn format_markdown(text: &str) -> String {
|
||||
// 1. Trailing-whitespace strip, per line.
|
||||
let stripped: Vec<String> = text.split('\n').map(|l| l.trim_end().to_string()).collect();
|
||||
|
||||
// 2. Reformat pipe-table blocks in place; pass everything else through.
|
||||
let mut piped: Vec<String> = Vec::with_capacity(stripped.len());
|
||||
let mut i = 0;
|
||||
while i < stripped.len() {
|
||||
if let Some(len) = table_block_len(&stripped[i..]) {
|
||||
piped.extend(format_table(&stripped[i..i + len]));
|
||||
i += len;
|
||||
} else {
|
||||
piped.push(stripped[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Collapse 2+ consecutive blank lines to one. A trailing blank run
|
||||
// collapses the same way, so at most one trailing blank line survives — and
|
||||
// we deliberately keep that one rather than dropping it. A writer often
|
||||
// presses Enter to open the next line before pausing; yanking that line
|
||||
// (and the caret) out from under them on every format-on-save is jarring.
|
||||
// The file's POSIX terminator is `save_path`'s job, not this pass's, so
|
||||
// keeping the blank line here is purely about not disturbing the buffer.
|
||||
let mut out: Vec<String> = Vec::with_capacity(piped.len());
|
||||
let mut blank_run = 0;
|
||||
for line in piped {
|
||||
if line.is_empty() {
|
||||
blank_run += 1;
|
||||
if blank_run == 1 {
|
||||
out.push(String::new());
|
||||
}
|
||||
} else {
|
||||
blank_run = 0;
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
out.join("\n")
|
||||
}
|
||||
|
||||
/// Split a table row into trimmed cells, dropping the empty cells that leading /
|
||||
/// trailing `|` produce (`| a | b |` → `["a", "b"]`).
|
||||
pub(crate) fn table_cells(line: &str) -> Vec<String> {
|
||||
let t = line.trim();
|
||||
let t = t.strip_prefix('|').unwrap_or(t);
|
||||
let t = t.strip_suffix('|').unwrap_or(t);
|
||||
t.split('|').map(|c| c.trim().to_string()).collect()
|
||||
}
|
||||
|
||||
/// A separator row: every cell is dashes with optional edge colons (`:--`, `-:`,
|
||||
/// `:-:`, `---`) and at least one dash.
|
||||
pub(crate) fn is_separator_row(line: &str) -> bool {
|
||||
if !line.contains('|') {
|
||||
return false;
|
||||
}
|
||||
let cells = table_cells(line);
|
||||
!cells.is_empty()
|
||||
&& cells.iter().all(|c| {
|
||||
!c.is_empty() && c.contains('-') && c.chars().all(|ch| ch == '-' || ch == ':')
|
||||
})
|
||||
}
|
||||
|
||||
/// If `lines[0..]` starts a pipe table (header row + separator row + data rows),
|
||||
/// return its length in lines; else `None`.
|
||||
pub(crate) fn table_block_len(lines: &[String]) -> Option<usize> {
|
||||
if lines.len() < 2 || !lines[0].contains('|') || !is_separator_row(&lines[1]) {
|
||||
return None;
|
||||
}
|
||||
let mut n = 2;
|
||||
while n < lines.len() && !lines[n].is_empty() && lines[n].contains('|') {
|
||||
n += 1;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// Reformat one detected table block: pad every cell to its column's width and
|
||||
/// rebuild the separator row, honoring per-column alignment colons.
|
||||
pub(crate) fn format_table(block: &[String]) -> Vec<String> {
|
||||
let rows: Vec<Vec<String>> = block.iter().map(|l| table_cells(l)).collect();
|
||||
let aligns: Vec<Align> = rows[1]
|
||||
.iter()
|
||||
.map(|c| match (c.starts_with(':'), c.ends_with(':')) {
|
||||
(true, true) => Align::Center,
|
||||
(true, false) => Align::Left,
|
||||
(false, true) => Align::Right,
|
||||
(false, false) => Align::None,
|
||||
})
|
||||
.collect();
|
||||
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(aligns.len());
|
||||
|
||||
// Column widths from content rows (min 3 so the separator stays readable).
|
||||
let mut width = vec![3usize; ncols];
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
if ri == 1 {
|
||||
continue; // the separator's own width doesn't constrain the column
|
||||
}
|
||||
for (ci, cell) in row.iter().enumerate() {
|
||||
width[ci] = width[ci].max(cell.chars().count());
|
||||
}
|
||||
}
|
||||
let align_of = |ci: usize| aligns.get(ci).copied().unwrap_or(Align::None);
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
let cells: Vec<String> = (0..ncols)
|
||||
.map(|ci| {
|
||||
let w = width[ci];
|
||||
if ri == 1 {
|
||||
match align_of(ci) {
|
||||
Align::Left => format!(":{}", "-".repeat(w - 1)),
|
||||
Align::Right => format!("{}:", "-".repeat(w - 1)),
|
||||
Align::Center => format!(":{}:", "-".repeat(w - 2)),
|
||||
Align::None => "-".repeat(w),
|
||||
}
|
||||
} else {
|
||||
pad_cell(row.get(ci).map(String::as_str).unwrap_or(""), w, align_of(ci))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.push(format!("| {} |", cells.join(" | ")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pad `cell` to `w` columns per `align` (left/none pad right, right pads left,
|
||||
/// center splits). Over-wide cells are returned unchanged.
|
||||
pub(crate) fn pad_cell(cell: &str, w: usize, align: Align) -> String {
|
||||
let len = cell.chars().count();
|
||||
if len >= w {
|
||||
return cell.to_string();
|
||||
}
|
||||
let pad = w - len;
|
||||
match align {
|
||||
Align::Right => format!("{}{cell}", " ".repeat(pad)),
|
||||
Align::Center => {
|
||||
let l = pad / 2;
|
||||
format!("{}{cell}{}", " ".repeat(l), " ".repeat(pad - l))
|
||||
}
|
||||
_ => format!("{cell}{}", " ".repeat(pad)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Editor {
|
||||
/// `:fmt` — normalize the buffer (align tables, collapse duplicate blank
|
||||
/// lines, strip trailing whitespace) and keep the caret on roughly the same
|
||||
/// line (buffer length changes, so exact restoration isn't possible).
|
||||
pub(crate) fn format_buffer(&mut self) {
|
||||
self.checkpoint(); // `:fmt` (and format-on-save) is undoable
|
||||
let row = self.text[..self.caret].bytes().filter(|&b| b == b'\n').count();
|
||||
self.text = format_markdown(&self.text);
|
||||
// Land the caret at the start of the same logical line, clamped.
|
||||
let total = self.text.bytes().filter(|&b| b == b'\n').count() + 1;
|
||||
let target = row.min(total - 1);
|
||||
self.caret = if target == 0 {
|
||||
0
|
||||
} else {
|
||||
let mut seen = 0;
|
||||
let mut off = self.text.len();
|
||||
for (i, b) in self.text.bytes().enumerate() {
|
||||
if b == b'\n' {
|
||||
seen += 1;
|
||||
if seen == target {
|
||||
off = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
off
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
191
editor/src/motions.rs
Normal file
191
editor/src/motions.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
//! Caret motions: char/word/line stepping shared by Normal, Visual and View.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Editor {
|
||||
/// Apply a plain caret motion shared by Normal and Visual — `h l j k`,
|
||||
/// `w b e`, `0 $`, `G` — `n` times, returning whether `c` was a motion (and
|
||||
/// so consumed). `gg`/`gr` are handled by their callers' pending-`g` state,
|
||||
/// not here.
|
||||
pub(crate) fn move_by(&mut self, c: char, n: usize) -> bool {
|
||||
match c {
|
||||
'h' => (0..n).for_each(|_| self.move_left()),
|
||||
'l' => (0..n).for_each(|_| self.move_right()),
|
||||
'j' => (0..n).for_each(|_| self.move_down()),
|
||||
'k' => (0..n).for_each(|_| self.move_up()),
|
||||
'w' => (0..n).for_each(|_| self.caret = self.word_forward_pos(self.caret)),
|
||||
'b' => (0..n).for_each(|_| self.caret = self.word_back_pos(self.caret)),
|
||||
'e' => (0..n).for_each(|_| self.caret = self.word_end_pos(self.caret)),
|
||||
'0' => self.caret = self.line_start(self.caret),
|
||||
'$' => self.caret = self.line_end(self.caret),
|
||||
'G' => self.caret = self.line_start(self.text.len()),
|
||||
// Repeat the last `/` search; a motion here so Visual extends over
|
||||
// it for free. Deliberately not an operator target (`dn` is not in
|
||||
// scope) — operators resolve their own motion table in `normal_key`.
|
||||
'n' => self.search_repeat(n, true),
|
||||
'N' => self.search_repeat(n, false),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// --- Command mode (`:`) ------------------------------------------------
|
||||
|
||||
/// Offset of the start of the line containing `pos`.
|
||||
pub(crate) fn line_start(&self, pos: usize) -> usize {
|
||||
let b = self.text.as_bytes();
|
||||
let mut i = pos;
|
||||
while i > 0 && b[i - 1] != b'\n' {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Offset of the end of the line containing `pos` (the `\n`, or buffer end).
|
||||
pub(crate) fn line_end(&self, pos: usize) -> usize {
|
||||
let b = self.text.as_bytes();
|
||||
let mut i = pos;
|
||||
while i < b.len() && b[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Byte offset one character right of `i`, clamped to the buffer end. `i`
|
||||
/// must be a char boundary (every caret position is one).
|
||||
pub(crate) fn next_char(&self, i: usize) -> usize {
|
||||
self.text[i..].chars().next().map_or(i, |c| i + c.len_utf8())
|
||||
}
|
||||
|
||||
/// Byte offset one character left of `i`, clamped to 0.
|
||||
pub(crate) fn prev_char(&self, i: usize) -> usize {
|
||||
self.text[..i].chars().next_back().map_or(i, |c| i - c.len_utf8())
|
||||
}
|
||||
|
||||
/// Byte offset `col` characters into the text starting at `start`, clamped
|
||||
/// to `end` (so a shorter target line lands the caret at its end).
|
||||
pub(crate) fn advance_chars(&self, start: usize, col: usize, end: usize) -> usize {
|
||||
let mut pos = start;
|
||||
for _ in 0..col {
|
||||
if pos >= end {
|
||||
break;
|
||||
}
|
||||
pos = self.next_char(pos);
|
||||
}
|
||||
pos.min(end)
|
||||
}
|
||||
|
||||
pub(crate) fn move_left(&mut self) {
|
||||
if self.caret > self.line_start(self.caret) {
|
||||
self.caret = self.prev_char(self.caret);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn move_right(&mut self) {
|
||||
if self.caret < self.line_end(self.caret) {
|
||||
self.caret = self.next_char(self.caret);
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `l` but allowed to land one past the last char (for `a`).
|
||||
pub(crate) fn move_right_append(&mut self) {
|
||||
if self.caret < self.line_end(self.caret) {
|
||||
self.caret = self.next_char(self.caret);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn move_down(&mut self) {
|
||||
let ls = self.line_start(self.caret);
|
||||
let col = self.text[ls..self.caret].chars().count();
|
||||
let le = self.line_end(self.caret);
|
||||
if le >= self.text.len() {
|
||||
return; // already on the last line
|
||||
}
|
||||
let next_start = le + 1;
|
||||
let next_end = self.line_end(next_start);
|
||||
self.caret = self.advance_chars(next_start, col, next_end);
|
||||
}
|
||||
|
||||
pub(crate) fn move_up(&mut self) {
|
||||
let ls = self.line_start(self.caret);
|
||||
if ls == 0 {
|
||||
return; // already on the first line
|
||||
}
|
||||
let col = self.text[ls..self.caret].chars().count();
|
||||
let prev_start = self.line_start(ls - 1);
|
||||
let prev_end = ls - 1; // the '\n' that ends the previous line
|
||||
self.caret = self.advance_chars(prev_start, col, prev_end);
|
||||
}
|
||||
|
||||
/// Move the caret by `delta` **display** (soft-wrapped) rows, keeping the
|
||||
/// column where the target row is long enough. This is the `Ctrl-d`/`Ctrl-u`
|
||||
/// step: unlike `j`/`k` (which move by *logical* line and so jump over
|
||||
/// wrapped continuation rows), it walks the rendered layout, so half a page
|
||||
/// is half the visible window no matter how the prose wraps. In Normal mode
|
||||
/// the caret is always kept on-screen, so moving it *is* the scroll — the
|
||||
/// viewport follows via `adjust_scroll` at draw time.
|
||||
pub(crate) fn move_display_rows(&mut self, delta: isize) {
|
||||
let lay = self.layout();
|
||||
if lay.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (row, col) = self.caret_rc(&lay);
|
||||
let target = (row as isize + delta).clamp(0, lay.len() as isize - 1) as usize;
|
||||
let line = &lay[target];
|
||||
let row_end = line.start + line.text.len();
|
||||
self.caret = self.advance_chars(line.start, col, row_end);
|
||||
}
|
||||
|
||||
/// Start of the next whitespace-delimited word after `from`.
|
||||
pub(crate) fn word_forward_pos(&self, from: usize) -> usize {
|
||||
let b = self.text.as_bytes();
|
||||
let n = b.len();
|
||||
let mut i = from;
|
||||
while i < n && !b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
while i < n && b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Start of the word at or before `from`.
|
||||
pub(crate) fn word_back_pos(&self, from: usize) -> usize {
|
||||
let b = self.text.as_bytes();
|
||||
let mut i = from;
|
||||
while i > 0 && b[i - 1].is_ascii_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !b[i - 1].is_ascii_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Byte offset of the last character of the current/next word — vim `e`
|
||||
/// lands the caret on that char. Skips any leading whitespace, then runs to
|
||||
/// the word's end; whitespace includes `\n`, so it can cross lines.
|
||||
pub(crate) fn word_end_pos(&self, from: usize) -> usize {
|
||||
let start = self.next_char(from);
|
||||
if start >= self.text.len() {
|
||||
return from;
|
||||
}
|
||||
let mut last = from;
|
||||
let mut in_word = false;
|
||||
for (off, c) in self.text[start..].char_indices() {
|
||||
if c.is_ascii_whitespace() {
|
||||
if in_word {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
in_word = true;
|
||||
last = start + off;
|
||||
}
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
// --- Edits -------------------------------------------------------------
|
||||
|
||||
}
|
||||
506
editor/src/palette.rs
Normal file
506
editor/src/palette.rs
Normal file
@@ -0,0 +1,506 @@
|
||||
//! The Cmd-P palette: file switching, the `>` command registry, and the `$`
|
||||
//! snippet picker.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The palette's display label for an absolute path: `/sd/` stripped, so
|
||||
/// `/sd/repo/notes.md` shows as `repo/notes.md` and `/sd/local/journal.md` as
|
||||
/// `local/journal.md`. The scope dir (`repo`/`local`) stays, which both
|
||||
/// disambiguates same-named files across scopes and reads as a scope tag. A path
|
||||
/// not under `/sd/` is shown verbatim. Matching (`fuzzy_score`) runs on this
|
||||
/// label, so you can filter by scope (`local`) or subpath, not just basename.
|
||||
pub(crate) fn palette_label(path: &str) -> &str {
|
||||
path.strip_prefix("/sd/").unwrap_or(path)
|
||||
}
|
||||
|
||||
/// A `>` palette command — a real action registry, not a settings box (v0.6).
|
||||
/// Three dispatch shapes, distinguished by [`PaletteCmd::kind`]:
|
||||
/// - a **[one-shot](CmdKind::OneShot)** ([`Format`](PaletteCmd::Format),
|
||||
/// [`Publish`](PaletteCmd::Publish)) runs and closes the palette;
|
||||
/// - a **[parameterised](CmdKind::Param)** command ([`NewFile`](PaletteCmd::NewFile))
|
||||
/// morphs the palette into a filename input step;
|
||||
/// - a **[toggle](CmdKind::Toggle)** — the boolean prefs and the
|
||||
/// [`Theme`](PaletteCmd::Theme)/[`AutoSync`](PaletteCmd::AutoSync) rotations —
|
||||
/// applies live and keeps the list open, so several settings flip in a row. Each
|
||||
/// toggle's *label* carries the pref's current state ([`Editor::command_label`]),
|
||||
/// so the list still doubles as a settings readout. `auto_sync` has no behaviour
|
||||
/// yet (v0.7); cycling it only changes the stored/displayed value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PaletteCmd {
|
||||
NewFile,
|
||||
Format,
|
||||
Publish,
|
||||
SaveOnIdle,
|
||||
FormatOnSave,
|
||||
LineNumbers,
|
||||
OpenLastOnBoot,
|
||||
Theme,
|
||||
AutoSync,
|
||||
}
|
||||
|
||||
/// How a [`PaletteCmd`] behaves on Enter — see [`PaletteCmd::kind`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CmdKind {
|
||||
/// Applies live and keeps the palette open (the pref toggles/rotations).
|
||||
Toggle,
|
||||
/// Runs once and closes the palette (`format`, `publish`).
|
||||
OneShot,
|
||||
/// Opens a second input step in the palette (`new file`).
|
||||
Param,
|
||||
}
|
||||
|
||||
impl PaletteCmd {
|
||||
/// The command's dispatch shape, which decides what Enter does in
|
||||
/// [`Editor::palette_run_command`].
|
||||
fn kind(self) -> CmdKind {
|
||||
match self {
|
||||
PaletteCmd::NewFile => CmdKind::Param,
|
||||
PaletteCmd::Format | PaletteCmd::Publish => CmdKind::OneShot,
|
||||
_ => CmdKind::Toggle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The palette command list, in display order (empty `>` query shows them all):
|
||||
/// the actions first, the settings after.
|
||||
pub(crate) const PALETTE_CMDS: [PaletteCmd; 9] = [
|
||||
PaletteCmd::NewFile,
|
||||
PaletteCmd::Format,
|
||||
PaletteCmd::Publish,
|
||||
PaletteCmd::SaveOnIdle,
|
||||
PaletteCmd::FormatOnSave,
|
||||
PaletteCmd::LineNumbers,
|
||||
PaletteCmd::OpenLastOnBoot,
|
||||
PaletteCmd::Theme,
|
||||
PaletteCmd::AutoSync,
|
||||
];
|
||||
|
||||
/// Which step the palette is showing. Most of its life it is a
|
||||
/// [`List`](PaletteStep::List) — files, `>` commands, or `$` snippets, chosen by
|
||||
/// the query's leading sigil. Selecting a [parameterised](CmdKind::Param) `>`
|
||||
/// command switches it to an input step ([`NewFile`](PaletteStep::NewFile)), where
|
||||
/// the query is a value (a filename) rather than a filter, and Enter commits it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PaletteStep {
|
||||
List,
|
||||
NewFile,
|
||||
}
|
||||
|
||||
|
||||
/// Query length (chars) at which the file palette searches the full file list.
|
||||
/// Shorter queries show only the recents ([`MRU_MAX`]) — the list is a
|
||||
/// recursive walk of the card, and one char can't rank hundreds of paths
|
||||
/// usefully. `>` commands and `$` snippets are short curated lists, so the
|
||||
/// threshold does not apply to them.
|
||||
pub(crate) const PALETTE_MIN_QUERY: usize = 2;
|
||||
|
||||
|
||||
impl Editor {
|
||||
/// `Ctrl-P` — open the file palette: empty query (full list, recents first),
|
||||
/// selection on the first row.
|
||||
pub(crate) fn open_palette(&mut self) {
|
||||
self.mode = Mode::Palette;
|
||||
self.palette_query.clear();
|
||||
self.palette_sel = 0;
|
||||
self.palette_step = PaletteStep::List;
|
||||
}
|
||||
|
||||
/// `:settings` — open the palette straight into `>` command mode (the
|
||||
/// settings list), so the prefs are reachable in one command instead of
|
||||
/// `Cmd-P` then `>`. Same surface, same stay-open toggle behaviour.
|
||||
pub(crate) fn open_settings(&mut self) {
|
||||
self.mode = Mode::Palette;
|
||||
self.palette_query = ">".to_string();
|
||||
self.palette_sel = 0;
|
||||
self.palette_step = PaletteStep::List;
|
||||
}
|
||||
|
||||
/// Leave the palette back to Normal, clearing its query, selection, and step.
|
||||
pub(crate) fn close_palette(&mut self) {
|
||||
self.mode = Mode::Normal;
|
||||
self.palette_query.clear();
|
||||
self.palette_sel = 0;
|
||||
self.palette_step = PaletteStep::List;
|
||||
}
|
||||
|
||||
/// Dispatch a key in [`Mode::Palette`]. In the `New file` input step the keys
|
||||
/// build a filename ([`new_file_step_key`](Self::new_file_step_key)); otherwise
|
||||
/// typing fuzzy-filters, `Ctrl-n`/`Ctrl-p` (or `Ctrl-d`/`Ctrl-u`) move the
|
||||
/// selection, and Enter acts on it per the leading sigil (open a file, run a
|
||||
/// `>` command, or insert a `$` snippet). Esc or `Cmd-P` closes; Backspace on
|
||||
/// an empty query also closes (mirrors the `:` line). Any query edit resets the
|
||||
/// selection to the top.
|
||||
pub(crate) fn palette_key(&mut self, key: Key) {
|
||||
if self.palette_step == PaletteStep::NewFile {
|
||||
return self.new_file_step_key(key);
|
||||
}
|
||||
match key {
|
||||
Key::Char(c) => {
|
||||
self.palette_query.push(c);
|
||||
self.palette_sel = 0;
|
||||
}
|
||||
Key::Backspace => {
|
||||
if self.palette_query.pop().is_none() {
|
||||
self.close_palette();
|
||||
} else {
|
||||
self.palette_sel = 0;
|
||||
}
|
||||
}
|
||||
// Readline Ctrl-W: drop trailing spaces then the last word.
|
||||
Key::DeleteWord => {
|
||||
while self.palette_query.ends_with(' ') {
|
||||
self.palette_query.pop();
|
||||
}
|
||||
while !self.palette_query.is_empty() && !self.palette_query.ends_with(' ') {
|
||||
self.palette_query.pop();
|
||||
}
|
||||
self.palette_sel = 0;
|
||||
}
|
||||
Key::DeleteLine => {
|
||||
self.palette_query.clear();
|
||||
self.palette_sel = 0;
|
||||
}
|
||||
// Ctrl-n/Ctrl-p move the selection (fzf-style); Ctrl-d/Ctrl-u do too.
|
||||
// Wraps around the current result list (files, `>` commands, `$` snippets).
|
||||
Key::Down | Key::HalfPageDown => {
|
||||
let n = self.palette_len();
|
||||
if n > 0 {
|
||||
self.palette_sel = (self.palette_sel + 1) % n;
|
||||
}
|
||||
}
|
||||
Key::Up | Key::HalfPageUp => {
|
||||
let n = self.palette_len();
|
||||
if n > 0 {
|
||||
self.palette_sel = self.palette_sel.checked_sub(1).unwrap_or(n - 1);
|
||||
}
|
||||
}
|
||||
// Enter acts on the selection by mode: insert a `$` snippet, run a `>`
|
||||
// command, or open the selected file.
|
||||
Key::Enter => {
|
||||
if self.palette_snippet_mode() {
|
||||
self.palette_insert_selected();
|
||||
} else if self.palette_command_mode() {
|
||||
self.palette_run_command();
|
||||
} else {
|
||||
self.palette_open_selected();
|
||||
}
|
||||
}
|
||||
// Esc, or Cmd-P again, closes the palette.
|
||||
Key::Escape | Key::Palette => self.close_palette(),
|
||||
Key::Redo => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys in the `> new file` input step: the query is a filename, not a filter.
|
||||
/// Enter creates it (scope resolved from a `repo/`/`local/` prefix, exactly as
|
||||
/// `:enew` did) and closes; an empty name is a no-op that stays in the step.
|
||||
/// Backspacing past the start steps **back** to the `>` command list rather
|
||||
/// than closing, so the step is escapable without losing the palette. Esc or
|
||||
/// `Cmd-P` closes outright.
|
||||
pub(crate) fn new_file_step_key(&mut self, key: Key) {
|
||||
match key {
|
||||
Key::Char(c) => self.palette_query.push(c),
|
||||
Key::Backspace => {
|
||||
if self.palette_query.pop().is_none() {
|
||||
// Nothing left to erase — return to the command list.
|
||||
self.palette_step = PaletteStep::List;
|
||||
self.palette_query = ">".to_string();
|
||||
self.palette_sel = 0;
|
||||
}
|
||||
}
|
||||
Key::DeleteWord => {
|
||||
while self.palette_query.ends_with(' ') {
|
||||
self.palette_query.pop();
|
||||
}
|
||||
while !self.palette_query.is_empty() && !self.palette_query.ends_with(' ') {
|
||||
self.palette_query.pop();
|
||||
}
|
||||
}
|
||||
Key::DeleteLine => self.palette_query.clear(),
|
||||
Key::Enter => {
|
||||
let name = self.palette_query.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return; // nothing typed yet — stay in the step
|
||||
}
|
||||
self.close_palette();
|
||||
self.new_file(&name);
|
||||
}
|
||||
Key::Escape | Key::Palette => self.close_palette(),
|
||||
// No list to move over in this step.
|
||||
Key::Up | Key::Down | Key::HalfPageUp | Key::HalfPageDown | Key::Redo => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the palette's selected file (Enter). A no-op on an empty result set.
|
||||
/// Closes the palette first, then routes through [`open_path`](Self::open_path)
|
||||
/// exactly like `:e`, so the switch/park/evict/MRU path is shared.
|
||||
pub(crate) fn palette_open_selected(&mut self) {
|
||||
let idx = self.palette_matches().get(self.palette_sel).copied();
|
||||
self.close_palette();
|
||||
let Some(idx) = idx else { return };
|
||||
let (path, scope) = resolve_path(self.file_at(idx), self.scope);
|
||||
self.open_path(path, scope);
|
||||
}
|
||||
|
||||
/// The palette's filtered, ranked result as indices into [`files`](Self::files).
|
||||
/// Base order is MRU-first (recents in use order, then the rest as sorted). A
|
||||
/// non-empty query keeps only fuzzy matches and stable-sorts them by score, so
|
||||
/// equal scores keep their MRU/base position. See [`fuzzy_score`].
|
||||
///
|
||||
/// Below [`PALETTE_MIN_QUERY`] chars the candidate set is the recents only:
|
||||
/// the file list is a recursive walk of the whole card, too long to page
|
||||
/// through unranked, but the MRU keeps quick-switch (`Cmd-P`, `Enter`) one
|
||||
/// keystroke away. Two typed chars reveal the full list.
|
||||
pub(crate) fn palette_matches(&self) -> Vec<usize> {
|
||||
let mut order: Vec<usize> = Vec::with_capacity(self.file_count());
|
||||
for r in &self.recent {
|
||||
if let Some(i) = (0..self.file_count()).find(|&i| self.file_at(i) == r) {
|
||||
order.push(i);
|
||||
}
|
||||
}
|
||||
if self.palette_query.chars().count() >= PALETTE_MIN_QUERY {
|
||||
for i in 0..self.file_count() {
|
||||
if !order.contains(&i) {
|
||||
order.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.palette_query.is_empty() {
|
||||
return order;
|
||||
}
|
||||
let mut scored: Vec<(usize, i32)> = order
|
||||
.into_iter()
|
||||
.filter_map(|i| {
|
||||
fuzzy_score(&self.palette_query, palette_label(self.file_at(i))).map(|s| (i, s))
|
||||
})
|
||||
.collect();
|
||||
// Stable sort by descending score — ties keep their MRU/base position.
|
||||
scored.sort_by_key(|&(_, s)| core::cmp::Reverse(s));
|
||||
scored.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
// --- Palette command mode (`>`) ----------------------------------------
|
||||
|
||||
/// Whether the palette is in `>` command mode. VS Code semantics: a leading
|
||||
/// `>` in the query switches the file search to the command list. The `>` is
|
||||
/// part of [`palette_query`](Self::palette_query), so backspacing it off
|
||||
/// returns to file mode with no extra state.
|
||||
pub(crate) fn palette_command_mode(&self) -> bool {
|
||||
self.palette_query.starts_with('>')
|
||||
}
|
||||
|
||||
/// The command filter: everything after the leading `>`, trimmed. `>` alone
|
||||
/// (or with only spaces) is an empty filter, which matches every command.
|
||||
pub(crate) fn command_filter(&self) -> &str {
|
||||
self.palette_query.strip_prefix('>').unwrap_or("").trim()
|
||||
}
|
||||
|
||||
/// A command's display label. An action's label is a plain verb (with a
|
||||
/// trailing `...` on the parameterised `new file`, VS-Code-style, to flag the
|
||||
/// second step — ASCII dots, since Latin-9 has no `…` glyph); a toggle's label
|
||||
/// carries its pref's current state, so the list reads as a live settings panel
|
||||
/// and the effect is legible before and after. This is also the text
|
||||
/// [`fuzzy_score`] matches against.
|
||||
pub(crate) fn command_label(&self, cmd: PaletteCmd) -> String {
|
||||
let on = |b| if b { "on" } else { "off" };
|
||||
match cmd {
|
||||
PaletteCmd::NewFile => "new file...".to_string(),
|
||||
PaletteCmd::Format => "format".to_string(),
|
||||
PaletteCmd::Publish => "publish".to_string(),
|
||||
PaletteCmd::SaveOnIdle => format!("save on idle: {}", on(self.prefs.save_on_idle)),
|
||||
PaletteCmd::FormatOnSave => format!("format on save: {}", on(self.prefs.format_on_save)),
|
||||
PaletteCmd::LineNumbers => format!("line numbers: {}", on(self.prefs.line_numbers)),
|
||||
PaletteCmd::OpenLastOnBoot => {
|
||||
format!("open last on boot: {}", on(self.prefs.open_last_on_boot))
|
||||
}
|
||||
PaletteCmd::Theme => format!("theme: {}", self.prefs.theme),
|
||||
PaletteCmd::AutoSync => format!("auto sync: {}", self.prefs.auto_sync),
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered, ranked command indices into [`PALETTE_CMDS`]. An empty filter
|
||||
/// keeps registry order; a non-empty one fuzzy-ranks by label, same matcher
|
||||
/// and stable-sort as the file list.
|
||||
pub(crate) fn palette_command_matches(&self) -> Vec<usize> {
|
||||
let filter = self.command_filter();
|
||||
let mut scored: Vec<(usize, i32)> = PALETTE_CMDS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &cmd)| fuzzy_score(filter, &self.command_label(cmd)).map(|s| (i, s)))
|
||||
.collect();
|
||||
scored.sort_by_key(|&(_, s)| core::cmp::Reverse(s));
|
||||
scored.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Enter in `>` command mode, dispatched by the selected command's
|
||||
/// [`kind`](PaletteCmd::kind):
|
||||
/// - a **[toggle](CmdKind::Toggle)** flips its pref and the palette **stays
|
||||
/// open** (flip several in a row; the label updates in place);
|
||||
/// - a **[one-shot](CmdKind::OneShot)** (`format`/`publish`) runs and **closes**
|
||||
/// — an action switches you back to writing, a toggle does not;
|
||||
/// - a **[parameterised](CmdKind::Param)** command (`new file`) opens the
|
||||
/// filename input step ([`begin_new_file_step`](Self::begin_new_file_step)).
|
||||
///
|
||||
/// A no-op on an empty result set (nothing selected), staying open so the
|
||||
/// query can be fixed.
|
||||
pub(crate) fn palette_run_command(&mut self) {
|
||||
let Some(&ci) = self.palette_command_matches().get(self.palette_sel) else {
|
||||
return;
|
||||
};
|
||||
let cmd = PALETTE_CMDS[ci];
|
||||
match cmd.kind() {
|
||||
CmdKind::Toggle => self.cycle_pref(cmd),
|
||||
CmdKind::OneShot => {
|
||||
self.close_palette();
|
||||
match cmd {
|
||||
PaletteCmd::Format => {
|
||||
self.format_buffer();
|
||||
self.set_notice("formatted");
|
||||
}
|
||||
PaletteCmd::Publish => self.run_publish(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
CmdKind::Param => self.begin_new_file_step(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the open palette into its `new file` filename input step: the list
|
||||
/// gives way to a prompt, and the next Enter creates the typed file. Reached
|
||||
/// only from [`palette_run_command`](Self::palette_run_command), so the palette
|
||||
/// is already open.
|
||||
pub(crate) fn begin_new_file_step(&mut self) {
|
||||
self.palette_step = PaletteStep::NewFile;
|
||||
self.palette_query.clear();
|
||||
self.palette_sel = 0;
|
||||
}
|
||||
|
||||
/// The publish path shared by `:gp` and the `>` `publish` command: format on
|
||||
/// save (if enabled), queue the buffer save, then the git push — the host
|
||||
/// services them in order. Tracked-only: a Local buffer never reaches the
|
||||
/// remote, so it is a no-op with a notice.
|
||||
pub(crate) fn run_publish(&mut self) {
|
||||
if self.scope == Scope::Local {
|
||||
self.set_notice("Publish unavailable (Local)");
|
||||
return;
|
||||
}
|
||||
if self.prefs.format_on_save {
|
||||
self.format_buffer();
|
||||
}
|
||||
self.request_save_active();
|
||||
self.requests.push(Effect::Publish);
|
||||
}
|
||||
|
||||
/// Advance the pref a command targets to its next value, apply it live (the
|
||||
/// next [`draw`](Self::draw) reflects it — line numbers appear/vanish, the
|
||||
/// theme flips at once), queue the prefs-file write ([`Effect::SavePrefs`]),
|
||||
/// and confirm the new state on the snackbar. A boolean flips; a preset
|
||||
/// string ([`Theme`](PaletteCmd::Theme), [`AutoSync`](PaletteCmd::AutoSync))
|
||||
/// rotates to its next option and wraps — so from the palette every setting
|
||||
/// is "press Enter to change". The queued `SavePrefs` is what makes the
|
||||
/// change durable and lets it ride the next `:gp` to other devices.
|
||||
pub(crate) fn cycle_pref(&mut self, cmd: PaletteCmd) {
|
||||
match cmd {
|
||||
PaletteCmd::SaveOnIdle => self.prefs.save_on_idle = !self.prefs.save_on_idle,
|
||||
PaletteCmd::FormatOnSave => self.prefs.format_on_save = !self.prefs.format_on_save,
|
||||
PaletteCmd::LineNumbers => self.prefs.line_numbers = !self.prefs.line_numbers,
|
||||
PaletteCmd::OpenLastOnBoot => {
|
||||
self.prefs.open_last_on_boot = !self.prefs.open_last_on_boot
|
||||
}
|
||||
PaletteCmd::Theme => {
|
||||
self.prefs.theme = next_option(&self.prefs.theme, &THEME_OPTIONS).to_string()
|
||||
}
|
||||
PaletteCmd::AutoSync => {
|
||||
self.prefs.auto_sync = next_option(&self.prefs.auto_sync, &AUTO_SYNC_OPTIONS).to_string()
|
||||
}
|
||||
// Actions, not prefs: palette_run_command routes them away, so we never
|
||||
// arrive here. Return before the SavePrefs/notice below rather than
|
||||
// panicking the firmware on a would-be routing bug.
|
||||
PaletteCmd::NewFile | PaletteCmd::Format | PaletteCmd::Publish => {
|
||||
debug_assert!(false, "cycle_pref called with a non-toggle command");
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.requests.push(Effect::SavePrefs {
|
||||
contents: self.prefs.to_toml(),
|
||||
});
|
||||
// The label already reflects the just-changed state (e.g. "theme: dark").
|
||||
self.set_notice(format!("{} - saved", self.command_label(cmd)));
|
||||
}
|
||||
|
||||
// --- Palette snippet mode (`$`) ----------------------------------------
|
||||
|
||||
/// Whether the palette is in `$` snippet mode. Same sigil mechanism as `>`: a
|
||||
/// leading `$` in the query switches the file search to the snippet launcher,
|
||||
/// and backspacing it off returns to file mode with no extra state. `$` and `>`
|
||||
/// are mutually exclusive (a query starts with at most one).
|
||||
pub(crate) fn palette_snippet_mode(&self) -> bool {
|
||||
self.palette_query.starts_with('$')
|
||||
}
|
||||
|
||||
/// The snippet filter: everything after the leading `$`, trimmed. `$` alone is
|
||||
/// an empty filter, which lists every snippet.
|
||||
pub(crate) fn snippet_filter(&self) -> &str {
|
||||
self.palette_query.strip_prefix('$').unwrap_or("").trim()
|
||||
}
|
||||
|
||||
/// The text a snippet is fuzzy-matched against: name, prefix, and description
|
||||
/// together, so you find a snippet by whichever you remember. Matching runs on
|
||||
/// this joined haystack (see [`fuzzy_score`]); [`snippet_label`] is the shorter
|
||||
/// string actually drawn.
|
||||
pub(crate) fn snippet_haystack(s: &Snippet) -> String {
|
||||
format!("{} {} {}", s.name, s.prefix, s.description)
|
||||
}
|
||||
|
||||
/// A snippet's palette row: the display name with its inline trigger in
|
||||
/// brackets (`Markdown link [link]`), so browsing also teaches the prefix you'd
|
||||
/// type for the fast inline path. Truncated to the column width by the caller.
|
||||
pub(crate) fn snippet_label(s: &Snippet) -> String {
|
||||
format!("{} [{}]", s.name, s.prefix)
|
||||
}
|
||||
|
||||
/// Filtered, ranked snippet indices into [`snippets`](Self::snippets). An empty
|
||||
/// filter keeps the parsed order (sorted by name); a non-empty one fuzzy-ranks
|
||||
/// over [`snippet_haystack`], same matcher and stable-sort as the file list.
|
||||
pub(crate) fn palette_snippet_matches(&self) -> Vec<usize> {
|
||||
let filter = self.snippet_filter();
|
||||
let mut scored: Vec<(usize, i32)> = self
|
||||
.snippets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, s)| fuzzy_score(filter, &Self::snippet_haystack(s)).map(|score| (i, score)))
|
||||
.collect();
|
||||
scored.sort_by_key(|&(_, s)| core::cmp::Reverse(s));
|
||||
scored.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Enter in `$` snippet mode: insert the selected snippet at the caret and start
|
||||
/// its tab-stop session. Unlike a `>` toggle (which stays open), this **closes**
|
||||
/// the palette — inserting content returns you to the buffer, in Insert on `$1`.
|
||||
/// Checkpoints so the whole insertion is one undo group. A no-op on an empty
|
||||
/// result set (nothing selected), which stays open so the query can be fixed.
|
||||
pub(crate) fn palette_insert_selected(&mut self) {
|
||||
let Some(&i) = self.palette_snippet_matches().get(self.palette_sel) else {
|
||||
return;
|
||||
};
|
||||
let body = self.snippets[i].body.clone();
|
||||
self.close_palette();
|
||||
self.checkpoint(); // baseline is the buffer before insertion — undo removes it whole
|
||||
self.insert_snippet(&body);
|
||||
}
|
||||
|
||||
/// Row count of the palette's current result list, whichever sigil is active —
|
||||
/// the single source the selection clamps against.
|
||||
pub(crate) fn palette_len(&self) -> usize {
|
||||
if self.palette_snippet_mode() {
|
||||
self.palette_snippet_matches().len()
|
||||
} else if self.palette_command_mode() {
|
||||
self.palette_command_matches().len()
|
||||
} else {
|
||||
self.palette_matches().len()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Visual mode -------------------------------------------------------
|
||||
|
||||
}
|
||||
161
editor/src/prefs.rs
Normal file
161
editor/src/prefs.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
//! `.typoena.toml` preferences: parsing, serialisation, and option cycling.
|
||||
|
||||
|
||||
/// The git-tracked preferences file. Read at boot and rewritten when a palette
|
||||
/// `>` command changes a pref, so the setting survives a reboot and rides the
|
||||
/// next `:gp` to every device that clones the repo. Deliberately **distinct**
|
||||
/// from the gitignored `/sd/typoena.conf` device secrets (Wi-Fi / PAT / remote /
|
||||
/// author, never committed — see v0.1): behaviour is shared, secrets are not.
|
||||
pub const PREFS_PATH: &str = "/sd/repo/.typoena.toml";
|
||||
|
||||
/// Editor preferences, mirroring the git-tracked [`PREFS_PATH`] TOML. The host
|
||||
/// reads the file at boot and applies it with [`Editor::set_prefs`]; the palette
|
||||
/// `>` command mode toggles a pref live and queues an [`Effect::SavePrefs`] to
|
||||
/// write the change back. Every key falls back to the [`Default`] below, so a
|
||||
/// missing, empty, or partial file still yields a full, usable `Prefs`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Prefs {
|
||||
/// Auto-save the active buffer on the idle typing-pause, so `:w` becomes
|
||||
/// optional. The idle save is **unformatted** — a safety net against power
|
||||
/// loss, not a formatting pass; `:fmt` only runs on an explicit `:w`/`:gp`
|
||||
/// (see [`format_on_save`](Prefs::format_on_save)) so text is never reflowed
|
||||
/// mid-session. Honoured by the host loop, not the core.
|
||||
pub save_on_idle: bool,
|
||||
/// Run `:fmt` (table alignment, blank-line collapse, trailing-whitespace
|
||||
/// strip) on the buffer before an explicit `:w`/`:gp` persist.
|
||||
pub format_on_save: bool,
|
||||
/// Show the absolute line-number gutter (built always-on in v0.2). Off
|
||||
/// reclaims the gutter's columns for text — applied live by [`gutter_cols`].
|
||||
pub line_numbers: bool,
|
||||
/// Boot into the file that was active when the device powered off, instead
|
||||
/// of the default note. Only the *choice* lives here; the last-active path
|
||||
/// itself is device state, kept in a device-local marker beside the dirty
|
||||
/// journal — never in this git-tracked file, where every buffer switch
|
||||
/// would dirty the repo and devices would fight over one "last file".
|
||||
/// Honoured by the host at boot (falling back to the default note when the
|
||||
/// marker is missing or stale), not the core.
|
||||
pub open_last_on_boot: bool,
|
||||
/// Panel colour polarity: `"light"` (native black-ink-on-white-paper) or
|
||||
/// `"dark"` (white-on-black). On the 1-bit panel this is a whole-frame invert
|
||||
/// applied at the end of [`draw`](Editor::draw) via [`Frame::invert`], so any
|
||||
/// value other than `"dark"` reads as light. The palette rotates it through
|
||||
/// [`THEME_OPTIONS`]; a hand-typed value still round-trips.
|
||||
pub theme: String,
|
||||
/// Max-staleness cap for opportunistic auto-publish, as a duration string.
|
||||
/// The palette rotates it through [`AUTO_SYNC_OPTIONS`] (`"2m"`..`"30m"`);
|
||||
/// hand-editing can still set any string. **Persisted-but-inert in v0.5** —
|
||||
/// the periodic push that reads it rides v0.7/v0.8, so cycling it changes the
|
||||
/// stored/displayed value but triggers nothing yet.
|
||||
pub auto_sync: String,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
save_on_idle: true,
|
||||
format_on_save: true,
|
||||
line_numbers: true,
|
||||
open_last_on_boot: true,
|
||||
theme: "light".into(),
|
||||
auto_sync: "10m".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// Parse a [`PREFS_PATH`] file, falling back to [`Default`] for any missing or
|
||||
/// unrecognized key (so a partial or empty file still yields a full `Prefs`).
|
||||
/// A deliberately tiny line-based reader: these are flat `key = value` pairs
|
||||
/// (bool, or a quoted string) with `#` comments — not a general TOML parser,
|
||||
/// so it pulls no crate onto the xtensa build and stays host-testable here. An
|
||||
/// unparseable value for a key leaves that key at its default.
|
||||
pub fn parse(src: &str) -> Self {
|
||||
let mut p = Self::default();
|
||||
for line in src.lines() {
|
||||
// Strip a trailing/whole-line `#` comment, then split `key = value`.
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
let Some((key, val)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let (key, val) = (key.trim(), val.trim());
|
||||
match key {
|
||||
"save_on_idle" => {
|
||||
if let Some(b) = parse_bool(val) {
|
||||
p.save_on_idle = b;
|
||||
}
|
||||
}
|
||||
"format_on_save" => {
|
||||
if let Some(b) = parse_bool(val) {
|
||||
p.format_on_save = b;
|
||||
}
|
||||
}
|
||||
"line_numbers" => {
|
||||
if let Some(b) = parse_bool(val) {
|
||||
p.line_numbers = b;
|
||||
}
|
||||
}
|
||||
"open_last_on_boot" => {
|
||||
if let Some(b) = parse_bool(val) {
|
||||
p.open_last_on_boot = b;
|
||||
}
|
||||
}
|
||||
"theme" => p.theme = val.trim_matches('"').to_string(),
|
||||
"auto_sync" => p.auto_sync = val.trim_matches('"').to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Serialize back to the [`PREFS_PATH`] form, with a header comment pointing at
|
||||
/// both edit paths. Round-trips with [`parse`](Prefs::parse). Ends in a newline
|
||||
/// like a normal text file; `save_path`'s guarded final-newline write leaves it
|
||||
/// as exactly one.
|
||||
pub fn to_toml(&self) -> String {
|
||||
format!(
|
||||
"# Typoena editor preferences — hand-editable, git-tracked.\n\
|
||||
# Edit here, or change live from the Cmd-P palette (type `>`).\n\
|
||||
save_on_idle = {}\n\
|
||||
format_on_save = {}\n\
|
||||
line_numbers = {}\n\
|
||||
open_last_on_boot = {}\n\
|
||||
theme = \"{}\"\n\
|
||||
auto_sync = \"{}\"\n",
|
||||
self.save_on_idle,
|
||||
self.format_on_save,
|
||||
self.line_numbers,
|
||||
self.open_last_on_boot,
|
||||
self.theme,
|
||||
self.auto_sync,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a TOML boolean literal, or `None` for anything else (so a typo leaves
|
||||
/// the key at its default rather than silently reading as `false`).
|
||||
pub(crate) fn parse_bool(v: &str) -> Option<bool> {
|
||||
match v {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel-polarity presets the palette rotates [`Prefs::theme`] through.
|
||||
pub(crate) const THEME_OPTIONS: [&str; 2] = ["light", "dark"];
|
||||
|
||||
/// The auto-publish intervals the palette rotates [`Prefs::auto_sync`] through.
|
||||
/// Hand-editing the TOML can still set any duration string; these are just the
|
||||
/// values the `>` palette cycles.
|
||||
pub(crate) const AUTO_SYNC_OPTIONS: [&str; 5] = ["2m", "5m", "10m", "15m", "30m"];
|
||||
|
||||
/// The option after `current` in `options`, wrapping past the end — the
|
||||
/// rotate-on-Enter for a preset string pref. A `current` that isn't in the list
|
||||
/// (e.g. hand-typed into the TOML) snaps to the first option, so one Enter
|
||||
/// always lands on a known value.
|
||||
pub(crate) fn next_option<'a>(current: &str, options: &[&'a str]) -> &'a str {
|
||||
match options.iter().position(|&o| o == current) {
|
||||
Some(i) => options[(i + 1) % options.len()],
|
||||
None => options[0],
|
||||
}
|
||||
}
|
||||
769
editor/src/render.rs
Normal file
769
editor/src/render.rs
Normal file
@@ -0,0 +1,769 @@
|
||||
//! Rendering: the e-paper grid geometry, display-line layout, and all
|
||||
//! `draw_*` painting onto the [`Frame`].
|
||||
|
||||
use super::*;
|
||||
|
||||
/// FONT_10X20 cell size (writing column) and the grid it tiles into.
|
||||
pub const CW: i32 = 10;
|
||||
pub const CH: i32 = 20;
|
||||
/// Writing-region width, in characters: the left region holding the
|
||||
/// line-number gutter **and** the text column (the gutter steals from it — see
|
||||
/// [`Editor::text_cols`]). The right **side panel** holds metadata (see
|
||||
/// CONTEXT.md § Screen regions). 63 cols × 10 px = 630 px; the driver's
|
||||
/// `x = 396` seam runs through it invisibly. The remaining 162 px (right of the
|
||||
/// divider) hold the ~17-col side panel (at its FONT_9X15 metadata font — see
|
||||
/// [`PANEL_COLS`]). Widened from 60 so the gutter doesn't narrow the text: a
|
||||
/// ≤ 99-line file keeps a full 60-col text column.
|
||||
pub(crate) const WRITE_COLS: usize = 63;
|
||||
/// Minimum digit columns in the line-number gutter (before the 1-col separator).
|
||||
/// Files up to 99 lines still get a 2-wide gutter so short notes don't jitter.
|
||||
pub(crate) const GUTTER_MIN_DIGITS: usize = 2;
|
||||
/// Visible writing rows. 13 × 20 px = 260 px. The transient `:` command line is
|
||||
/// drawn at body size over the **bottom** writing row (see [`Editor::draw_cmdline`]),
|
||||
/// so no rows are permanently reserved for it.
|
||||
pub(crate) const ROWS: usize = (HEIGHT / 20) as usize; // 13
|
||||
/// Half-page scroll distance for `Ctrl-d`/`Ctrl-u`, in **display rows** — vim's
|
||||
/// `'scroll'` default (half the visible window). Fixed, not configurable: a
|
||||
/// resizable `'scroll'` is meaningless on a fixed 13-row panel.
|
||||
pub(crate) const HALF_PAGE: usize = ROWS / 2; // 6
|
||||
/// x of the 1 px rule dividing writing column from side panel, and the left edge
|
||||
/// of panel text (a small gutter past the rule).
|
||||
pub(crate) const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 630
|
||||
pub(crate) const PANEL_X: i32 = DIVIDER_X + 8; // 638
|
||||
/// Side-panel font cell: **FONT_9X15** — a middle size between the old squint-y
|
||||
/// 6×10 and the body 10×20. Legible metadata without eating as many columns as
|
||||
/// the body font would (the `:` command line, being text you type, stays at the
|
||||
/// body 10×20 — see [`Editor::draw_cmdline`]). Kept as its own pair (not reusing
|
||||
/// `CW`/`CH`) so the panel font tunes independently of the writing font; change
|
||||
/// these **and** the `MonoTextStyle` font in `draw_panel` together.
|
||||
pub(crate) const PANEL_CW: i32 = 9;
|
||||
pub(crate) const PANEL_CH: i32 = 15;
|
||||
/// Side-panel text width in [`PANEL_CW`]-px columns, for clamping panel strings —
|
||||
/// the snackbar notice, word count — so they never draw past the right edge of
|
||||
/// the panel.
|
||||
pub(crate) const PANEL_COLS: usize = (WIDTH as usize - PANEL_X as usize) / PANEL_CW as usize; // 15
|
||||
/// Max wrapped lines the snackbar draws under the word count, so a long notice
|
||||
/// can't run down into the bottom mode strip. Four PANEL_CH rows ≈ 60 chars,
|
||||
/// enough for any current message.
|
||||
pub(crate) const NOTICE_MAX_LINES: usize = 4;
|
||||
/// Tab stop, in spaces. Tabs never enter the buffer — they expand on insert so
|
||||
/// the buffer stays 1 char = 1 column.
|
||||
pub(crate) const TAB: &str = " ";
|
||||
|
||||
|
||||
/// Word-wrap `text` to lines of at most `width` characters, for the side-panel
|
||||
/// snackbar. Packs whole words greedily; a word longer than `width` is hard-split
|
||||
/// across lines (so a long path or oid still shows in full rather than being
|
||||
/// truncated). Empty input yields no lines.
|
||||
pub(crate) fn wrap_text(text: &str, width: usize) -> Vec<String> {
|
||||
let width = width.max(1);
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if word.chars().count() > width {
|
||||
if !cur.is_empty() {
|
||||
lines.push(core::mem::take(&mut cur));
|
||||
}
|
||||
let mut chars = word.chars().peekable();
|
||||
while chars.peek().is_some() {
|
||||
lines.push(chars.by_ref().take(width).collect());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let sep = usize::from(!cur.is_empty());
|
||||
if cur.chars().count() + sep + word.chars().count() > width {
|
||||
lines.push(core::mem::take(&mut cur));
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
cur.push(' ');
|
||||
}
|
||||
cur.push_str(word);
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
lines.push(cur);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
|
||||
/// One wrapped display line: its text and the buffer offset of its first char.
|
||||
pub(crate) struct Line {
|
||||
pub(crate) start: usize,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
|
||||
impl Editor {
|
||||
/// Number of logical lines in the buffer (1 + newline count). Used to size
|
||||
/// the line-number gutter.
|
||||
pub(crate) fn logical_lines(&self) -> usize {
|
||||
self.text.bytes().filter(|&b| b == b'\n').count() + 1
|
||||
}
|
||||
|
||||
/// Width of the absolute line-number gutter, in display columns: enough
|
||||
/// digits for the buffer's largest line number (min [`GUTTER_MIN_DIGITS`])
|
||||
/// plus a 1-column separator before the text. Sized from the *total* line
|
||||
/// count, not the visible range, so it stays fixed while scrolling — only
|
||||
/// crossing a power of ten (100, 1000, …) reflows the wrap, which is rare.
|
||||
pub(crate) fn gutter_cols(&self) -> usize {
|
||||
if !self.prefs.line_numbers {
|
||||
return 0; // gutter off: text reclaims the full writing width
|
||||
}
|
||||
let digits = self.logical_lines().to_string().len().max(GUTTER_MIN_DIGITS);
|
||||
digits + 1
|
||||
}
|
||||
|
||||
/// Character columns left for text once the gutter is reserved. The writing
|
||||
/// region is fixed at [`WRITE_COLS`]; the gutter steals from it, so text
|
||||
/// soft-wraps narrower.
|
||||
pub(crate) fn text_cols(&self) -> usize {
|
||||
WRITE_COLS - self.gutter_cols()
|
||||
}
|
||||
|
||||
/// Wrap the buffer into display lines, tracking each line's buffer offset.
|
||||
/// Soft-wrap at word boundaries: a logical line too long for [`text_cols`]
|
||||
/// (the writing width left after the line-number gutter) breaks at the last
|
||||
/// space that fits, so words are never split — except a single word wider
|
||||
/// than the column, hard-broken at [`text_cols`].
|
||||
/// Wrapping counts characters (one per display cell), while `Line.start` is
|
||||
/// a byte offset into the buffer, so caret math stays correct for multi-byte
|
||||
/// (accented) characters.
|
||||
pub(crate) fn layout(&self) -> Vec<Line> {
|
||||
let cols = self.text_cols(); // writing width after the gutter is reserved
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut base = 0usize; // byte offset of the current logical line's start
|
||||
for logical in self.text.split('\n') {
|
||||
let chars: Vec<char> = logical.chars().collect();
|
||||
if chars.is_empty() {
|
||||
lines.push(Line { start: base, text: String::new() });
|
||||
} else {
|
||||
let mut c = 0usize; // char index within `logical`
|
||||
let mut byte = 0usize; // byte offset of chars[c] within `logical`
|
||||
while c < chars.len() {
|
||||
let remaining = chars.len() - c;
|
||||
let take = if remaining <= cols {
|
||||
remaining
|
||||
} else {
|
||||
// Break at the last space within the COLS-wide window;
|
||||
// include that space on this line. No space → hard break.
|
||||
let window = c + cols;
|
||||
let mut brk = None;
|
||||
let mut p = window;
|
||||
while p > c {
|
||||
p -= 1;
|
||||
if chars[p] == ' ' {
|
||||
brk = Some(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match brk {
|
||||
Some(sp) if sp > c => sp + 1 - c,
|
||||
_ => cols,
|
||||
}
|
||||
};
|
||||
lines.push(Line {
|
||||
start: base + byte,
|
||||
text: chars[c..c + take].iter().collect(),
|
||||
});
|
||||
byte += chars[c..c + take].iter().map(|ch| ch.len_utf8()).sum::<usize>();
|
||||
c += take;
|
||||
}
|
||||
}
|
||||
base += logical.len() + 1; // bytes + the '\n' that `split` consumed
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Display (row, col) of the caret within `lay`. `col` is a character count
|
||||
/// (display cells) from the row's start, not a byte offset, so it is correct
|
||||
/// for multi-byte characters and indexes `Line.text` via `chars().nth`.
|
||||
pub(crate) fn caret_rc(&self, lay: &[Line]) -> (usize, usize) {
|
||||
let mut row = 0;
|
||||
for (i, l) in lay.iter().enumerate() {
|
||||
if l.start <= self.caret {
|
||||
row = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let col = self.text[lay[row].start..self.caret].chars().count();
|
||||
(row, col)
|
||||
}
|
||||
|
||||
/// Scroll so the display row holding byte offset `pos` is visible,
|
||||
/// bottom-aligning it when it sits below the viewport (never scrolls up).
|
||||
/// Called after an insert that can run past the fold — chiefly a multi-line
|
||||
/// paste — so the *whole* pasted block is revealed, not just the caret's
|
||||
/// first line. The caret is left where the edit put it; `draw`'s
|
||||
/// `adjust_scroll` won't override this as long as the caret stays within the
|
||||
/// resulting window (true for any block up to a screen tall).
|
||||
pub(crate) fn reveal(&mut self, pos: usize) {
|
||||
let lay = self.layout();
|
||||
if lay.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pos = pos.min(self.text.len());
|
||||
let mut row = 0;
|
||||
for (i, l) in lay.iter().enumerate() {
|
||||
if l.start <= pos {
|
||||
row = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if row >= self.scroll_top + ROWS {
|
||||
self.scroll_top = row + 1 - ROWS;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the viewport so the caret stays visible (Normal/Insert), or just
|
||||
/// clamp it to the content (View).
|
||||
pub(crate) fn adjust_scroll(&mut self, caret_row: usize, total: usize) {
|
||||
match self.mode {
|
||||
Mode::View => {
|
||||
let max = total.saturating_sub(ROWS);
|
||||
if self.scroll_top > max {
|
||||
self.scroll_top = max;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if caret_row < self.scroll_top {
|
||||
self.scroll_top = caret_row;
|
||||
} else if caret_row >= self.scroll_top + ROWS {
|
||||
self.scroll_top = caret_row + 1 - ROWS;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the logical line starting at `ls` a Markdown ATX heading — 1–6 `#`
|
||||
/// followed by a space? (Used to render heading lines bold.)
|
||||
pub(crate) fn is_heading_at(&self, ls: usize) -> bool {
|
||||
let b = self.text.as_bytes();
|
||||
let mut i = ls;
|
||||
while i < b.len() && b[i] == b'#' {
|
||||
i += 1;
|
||||
}
|
||||
let hashes = i - ls;
|
||||
(1..=6).contains(&hashes) && b.get(i) == Some(&b' ')
|
||||
}
|
||||
|
||||
/// Paint the non-Latin-9 glyphs (`→ ≠ Σ … – — ' ' " " •`) that the base font
|
||||
/// can't draw, overlaying them onto the cells `embedded-graphics` just filled
|
||||
/// with fallback boxes. `line` is a laid-out display line; `x0` is its
|
||||
/// x-origin (`gx`, or `gx + 1` for the heading double-strike). Only the
|
||||
/// half-open display-column range `[col_start, col_end)` is painted, so the
|
||||
/// visual-selection pass can invert just the selected span. `ink = On` draws
|
||||
/// black-on-white; `ink = Off` draws white-on-black for reverse-video cells.
|
||||
/// Because every extra glyph is one cell wide (like the font), cell x is
|
||||
/// `x0 + col * CW` — the same grid the layout and caret math already use.
|
||||
pub(crate) fn overlay_extras(
|
||||
f: &mut Frame,
|
||||
line: &str,
|
||||
x0: i32,
|
||||
y: i32,
|
||||
col_start: usize,
|
||||
col_end: usize,
|
||||
ink: BinaryColor,
|
||||
) {
|
||||
for (col, ch) in line.chars().enumerate() {
|
||||
if col < col_start || col >= col_end {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = extra_glyph(ch) {
|
||||
blit_glyph(f, x0 + col as i32 * CW, y, g, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the current state into a frame. `cursor_on` gates the caret: the
|
||||
/// Insert bar caret is suppressed while typing and shown after a pause, and
|
||||
/// `false` also suppresses the Normal block caret so callers can render pure
|
||||
/// text (e.g. a boot message). View never draws a caret. In the main loop
|
||||
/// Normal always passes `true`, so its block caret is unaffected.
|
||||
pub fn draw(&mut self, cursor_on: bool) -> Frame {
|
||||
let mut f = Frame::empty();
|
||||
self.draw_into(&mut f, cursor_on);
|
||||
f
|
||||
}
|
||||
|
||||
/// [`draw`](Self::draw) into a caller-owned frame, reusing its allocation.
|
||||
/// Firmware keeps two boot-time frames and round-trips them through here so
|
||||
/// a repaint never allocates: the editor must stay drawable even when a
|
||||
/// background `:gp` push has taken the heap to the floor — a failed
|
||||
/// framebuffer alloc aborts the whole app (the 2026-07-13 OOM).
|
||||
pub fn draw_into(&mut self, out: &mut Frame, cursor_on: bool) {
|
||||
let lay = self.layout();
|
||||
let (crow, ccol) = self.caret_rc(&lay);
|
||||
self.adjust_scroll(crow, lay.len());
|
||||
|
||||
// Take the caller's buffer (no copy, no alloc), render into it as an
|
||||
// owned frame — the body predates this signature — and hand it back.
|
||||
let mut f = std::mem::replace(out, Frame::empty());
|
||||
f.clear_white();
|
||||
let text_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
|
||||
let gutter = self.gutter_cols();
|
||||
let cols = WRITE_COLS - gutter; // text columns after the gutter
|
||||
let gx = gutter as i32 * CW; // text (and cursor) x-origin, past the gutter
|
||||
// Number field width (the last gutter col is the separator). Saturating so
|
||||
// a disabled gutter (`gutter == 0`, line_numbers off) can't underflow; the
|
||||
// number draw below is skipped in that case anyway.
|
||||
let digits = gutter.saturating_sub(1);
|
||||
let end = (self.scroll_top + ROWS).min(lay.len());
|
||||
// Absolute line number of the first visible row's logical line, then
|
||||
// bumped as later logical lines scroll into view.
|
||||
let mut line_no = self.text.as_bytes()[..lay[self.scroll_top.min(lay.len() - 1)].start]
|
||||
.iter()
|
||||
.filter(|&&b| b == b'\n')
|
||||
.count()
|
||||
+ 1;
|
||||
for (vis, li) in (self.scroll_top..end).enumerate() {
|
||||
let y = vis as i32 * CH;
|
||||
// Number a logical line only on its first display row; wrapped
|
||||
// continuation rows leave the gutter blank.
|
||||
let first_row = lay[li].start == self.line_start(lay[li].start);
|
||||
if li > self.scroll_top && first_row {
|
||||
line_no += 1;
|
||||
}
|
||||
if gutter > 0 && first_row {
|
||||
let label = format!("{line_no:>digits$}");
|
||||
Text::with_baseline(&label, Point::new(0, y), text_style, Baseline::Top)
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
}
|
||||
Text::with_baseline(&lay[li].text, Point::new(gx, y), text_style, Baseline::Top)
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
// Markdown heading (`#`..`######` + space): faux-bold by double-
|
||||
// striking the whole display line 1px to the right (no bold Latin-9
|
||||
// font exists). Checks the logical line so wrapped headings stay bold.
|
||||
let heading = self.is_heading_at(self.line_start(lay[li].start));
|
||||
if heading {
|
||||
Text::with_baseline(&lay[li].text, Point::new(gx + 1, y), text_style, Baseline::Top)
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
}
|
||||
// Repaint any non-Latin-9 glyphs over the fallback boxes the font
|
||||
// left. Double-struck at gx+1 too on headings, to stay faux-bold.
|
||||
Self::overlay_extras(&mut f, &lay[li].text, gx, y, 0, usize::MAX, BinaryColor::On);
|
||||
if heading {
|
||||
Self::overlay_extras(&mut f, &lay[li].text, gx + 1, y, 0, usize::MAX, BinaryColor::On);
|
||||
}
|
||||
}
|
||||
|
||||
// Visual selection: reverse-video the selected cells (black fill, glyphs
|
||||
// redrawn white). A second pass so the text loop above stays untouched;
|
||||
// on a 1-bit panel this inversion is the only selection affordance.
|
||||
if self.in_visual() {
|
||||
let (ss, se, lw) = self.visual_span();
|
||||
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
|
||||
for (vis, li) in (self.scroll_top..end).enumerate() {
|
||||
let y = vis as i32 * CH;
|
||||
let rs = lay[li].start;
|
||||
let re = rs + lay[li].text.len();
|
||||
let (col_a, col_b) = if rs.max(ss) < re.min(se) {
|
||||
let a = rs.max(ss);
|
||||
let b = re.min(se);
|
||||
(self.text[rs..a].chars().count(), self.text[rs..b].chars().count())
|
||||
} else if lw && lay[li].text.is_empty() && rs >= ss && rs <= se {
|
||||
// A blank line inside a linewise selection: a 1-cell mark so
|
||||
// the empty row still reads as selected.
|
||||
(0, 1)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let x = gx + col_a as i32 * CW;
|
||||
let w = (col_b - col_a).max(1) as u32 * CW as u32;
|
||||
Rectangle::new(Point::new(x, y), Size::new(w, CH as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
let seg: String = lay[li].text.chars().skip(col_a).take(col_b - col_a).collect();
|
||||
if !seg.is_empty() {
|
||||
Text::with_baseline(&seg, Point::new(x, y), inv, Baseline::Top)
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
}
|
||||
// Any extra glyphs in the selected span: repaint white-on-black.
|
||||
Self::overlay_extras(&mut f, &lay[li].text, gx, y, col_a, col_b, BinaryColor::Off);
|
||||
}
|
||||
}
|
||||
|
||||
if crow >= self.scroll_top && crow < self.scroll_top + ROWS {
|
||||
let x = gx + ccol.min(cols - 1) as i32 * CW;
|
||||
let y = (crow - self.scroll_top) as i32 * CH;
|
||||
match self.mode {
|
||||
Mode::Normal if cursor_on => {
|
||||
// Block caret: fill the cell, redraw the glyph in white.
|
||||
Rectangle::new(Point::new(x, y), Size::new(CW as u32, CH as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
if let Some(ch) = lay[crow].text.chars().nth(ccol) {
|
||||
if let Some(g) = extra_glyph(ch) {
|
||||
blit_glyph(&mut f, x, y, g, BinaryColor::Off);
|
||||
} else {
|
||||
let mut buf = [0u8; 4];
|
||||
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
|
||||
Text::with_baseline(
|
||||
ch.encode_utf8(&mut buf),
|
||||
Point::new(x, y),
|
||||
inv,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::Insert if cursor_on => {
|
||||
// Bar caret at the left edge of the cell.
|
||||
Rectangle::new(Point::new(x, y), Size::new(2, CH as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
}
|
||||
Mode::Visual | Mode::VisualLine if cursor_on => {
|
||||
// The selection painted this cell inverted; punch the caret
|
||||
// back to normal video (white cell, black glyph) so the
|
||||
// active end stands out from the rest of the selection.
|
||||
Rectangle::new(Point::new(x, y), Size::new(CW as u32, CH as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
if let Some(ch) = lay[crow].text.chars().nth(ccol) {
|
||||
if let Some(g) = extra_glyph(ch) {
|
||||
blit_glyph(&mut f, x, y, g, BinaryColor::On);
|
||||
} else {
|
||||
let mut buf = [0u8; 4];
|
||||
Text::with_baseline(
|
||||
ch.encode_utf8(&mut buf),
|
||||
Point::new(x, y),
|
||||
text_style,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.draw_panel(&mut f);
|
||||
self.draw_cmdline(&mut f);
|
||||
// The file palette is a modal transient panel: it paints over the whole
|
||||
// writing column (the side panel stays, showing the PALETTE mode). Drawn
|
||||
// last so it covers the buffer text/caret rendered above.
|
||||
if self.mode == Mode::Palette {
|
||||
self.draw_palette(&mut f);
|
||||
}
|
||||
// Dark theme: flip the whole frame in one pass, after everything else is
|
||||
// painted, so text, selection, caret, panel and palette all invert
|
||||
// together. Any value but "dark" stays native black-on-white.
|
||||
if self.prefs.theme == "dark" {
|
||||
f.invert();
|
||||
}
|
||||
*out = f;
|
||||
}
|
||||
|
||||
/// Draw the side panel: a full-height rule, word count at the top, and the
|
||||
/// mode indicator + pending-command echo at the bottom-left, with a
|
||||
/// keyboard-disconnect flag just above the mode while the keyboard is
|
||||
/// dropped. Small 6×10 font. This is the surface every later field
|
||||
/// (filename, clock, Wi-Fi, publish state) will add to. Word count is a
|
||||
/// throttled snapshot and the rest is event-driven, so the panel never
|
||||
/// repaints per keystroke.
|
||||
pub(crate) fn draw_panel(&self, f: &mut Frame) {
|
||||
// The rule dividing writing column from panel, full panel height.
|
||||
Rectangle::new(Point::new(DIVIDER_X, 0), Size::new(1, HEIGHT as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
|
||||
let style = MonoTextStyle::new(&FONT_9X15, BinaryColor::On);
|
||||
|
||||
// Word count, from the throttled snapshot (never per keystroke).
|
||||
let words = format!("{} words", self.shown_words);
|
||||
Text::with_baseline(&words, Point::new(PANEL_X, 2), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
|
||||
// Transient notice ("snackbar"), just under the word count: the last
|
||||
// save/publish result. Word-wrapped to the panel width (so a message like
|
||||
// "save FAILED - retry :w" keeps its actionable tail instead of clipping
|
||||
// mid-word) and capped at a few lines so it can't reach the bottom mode
|
||||
// strip; cleared on the next keystroke.
|
||||
if let Some(msg) = &self.notice {
|
||||
for (i, line) in wrap_text(msg, PANEL_COLS)
|
||||
.into_iter()
|
||||
.take(NOTICE_MAX_LINES)
|
||||
.enumerate()
|
||||
{
|
||||
let y = 2 + PANEL_CH + 2 + i as i32 * PANEL_CH;
|
||||
Text::with_baseline(&line, Point::new(PANEL_X, y), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Just above the mode line: the keyboard-disconnect flag, or — while
|
||||
// typing a recognised prefix — a quiet snippet hint. Mutually exclusive:
|
||||
// the hint means you're typing, which needs the keyboard. Latin-9 has no
|
||||
// ⌨/✗ or ↹ glyph, so the flag is plain text and the hint leads with `»`.
|
||||
if !self.keyboard_present {
|
||||
Text::with_baseline(
|
||||
"NO KBD",
|
||||
Point::new(PANEL_X, HEIGHT as i32 - 2 * PANEL_CH),
|
||||
style,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
} else if let Some(name) = &self.snippet_hint {
|
||||
let hint: String = format!("» {name}").chars().take(PANEL_COLS).collect();
|
||||
Text::with_baseline(
|
||||
&hint,
|
||||
Point::new(PANEL_X, HEIGHT as i32 - 2 * PANEL_CH),
|
||||
style,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Mode indicator + pending count/operator echo at the panel's bottom-
|
||||
// left. In Command mode the ':' line (bottom strip) takes over instead.
|
||||
// All event-driven — never repaints per keystroke.
|
||||
if self.mode != Mode::Command {
|
||||
let name = match self.mode {
|
||||
Mode::Normal => "NORMAL",
|
||||
Mode::Insert => "INSERT",
|
||||
Mode::Visual => "VISUAL",
|
||||
Mode::VisualLine => "V-LINE",
|
||||
Mode::View => "VIEW",
|
||||
Mode::Palette => "PALETTE",
|
||||
Mode::Command => unreachable!(),
|
||||
};
|
||||
let mut s = format!("-- {name} --");
|
||||
if self.count > 0 {
|
||||
s.push_str(&format!(" {}", self.count));
|
||||
}
|
||||
match self.pending_op {
|
||||
Some(Op::Delete) => s.push('d'),
|
||||
Some(Op::Change) => s.push('c'),
|
||||
Some(Op::Yank) => s.push('y'),
|
||||
None => {}
|
||||
}
|
||||
match self.pending_obj {
|
||||
Some(false) => s.push('i'),
|
||||
Some(true) => s.push('a'),
|
||||
None => {}
|
||||
}
|
||||
if self.pending_g {
|
||||
s.push('g');
|
||||
}
|
||||
Text::with_baseline(
|
||||
&s,
|
||||
Point::new(PANEL_X, HEIGHT as i32 - PANEL_CH),
|
||||
style,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The transient `:` command line, drawn at body size (FONT_10X20) along the
|
||||
/// bottom of the writing column (vim-style). Shown only while composing a
|
||||
/// command. At this size it no longer fits the old 12 px sliver, so it
|
||||
/// overlays the bottom writing row: blank that row to white first, then draw
|
||||
/// `:cmd` over it. The row's text reappears on the next render once the
|
||||
/// command finishes (Enter/Esc) — you never read the last line while typing a
|
||||
/// command. Blanks only the writing column (left of the divider), so the
|
||||
/// panel is untouched (and in Command mode its mode line isn't drawn anyway).
|
||||
pub(crate) fn draw_cmdline(&self, f: &mut Frame) {
|
||||
if self.mode != Mode::Command {
|
||||
return;
|
||||
}
|
||||
let band_top = (ROWS as i32 - 1) * CH; // start of the last writing row
|
||||
Rectangle::new(
|
||||
Point::new(0, band_top),
|
||||
Size::new(DIVIDER_X as u32, (HEIGHT as i32 - band_top) as u32),
|
||||
)
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
|
||||
let s = format!("{}{}", self.cmd_prompt, self.cmdline);
|
||||
let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
|
||||
Text::with_baseline(&s, Point::new(2, HEIGHT as i32 - CH), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Draw the file palette (the modal transient panel, [`Mode::Palette`]) over
|
||||
/// the writing column — the side panel stays put. Top row: a `> query`
|
||||
/// prompt with a block caret. Then a rule, the fuzzy-ranked file list (the
|
||||
/// selected row in reverse video), and a key hint on the bottom row. The list
|
||||
/// scrolls to keep the selection visible. Body font (FONT_10X20) throughout.
|
||||
/// The whole column repaints, so the host renders this as one full-area
|
||||
/// partial — the Spike 11 transient-panel refresh worth eyeballing for
|
||||
/// e-ink ghosting.
|
||||
pub(crate) fn draw_palette(&self, f: &mut Frame) {
|
||||
// Cover the writing column with white (left of the divider only).
|
||||
Rectangle::new(Point::new(0, 0), Size::new(DIVIDER_X as u32, HEIGHT as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
|
||||
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
|
||||
|
||||
// The `> new file` input step: a labelled filename prompt with a block
|
||||
// caret and a create hint — no list. The query is the filename being
|
||||
// typed; an empty one shows a placeholder naming the scope-prefix rule.
|
||||
if self.palette_step == PaletteStep::NewFile {
|
||||
Text::with_baseline("New file:", Point::new(2, 0), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
let y = CH + 3;
|
||||
if self.palette_query.is_empty() {
|
||||
Text::with_baseline(
|
||||
"name (repo/ or local/ prefix)",
|
||||
Point::new(2 + CW, y),
|
||||
style,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
} else {
|
||||
Text::with_baseline(&self.palette_query, Point::new(2, y), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
let cx = (2 + self.palette_query.chars().count() as i32 * CW).min(DIVIDER_X - 2);
|
||||
Rectangle::new(Point::new(cx, y), Size::new(2, CH as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
Text::with_baseline(
|
||||
"Enter create Esc cancel",
|
||||
Point::new(2, HEIGHT as i32 - CH),
|
||||
style,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
// The query on the top row with a block caret at the end. A bare input is
|
||||
// "go to file" (VS Code Cmd-P); a leading `>` switches to the command list
|
||||
// (`command_mode`). An empty query is just the caret over a `Go to file`
|
||||
// placeholder that clears on the first keystroke — type `>` for commands.
|
||||
let command_mode = self.palette_command_mode();
|
||||
let snippet_mode = self.palette_snippet_mode();
|
||||
if self.palette_query.is_empty() {
|
||||
Text::with_baseline(
|
||||
"Go to file · > settings · $ snippets",
|
||||
Point::new(2 + CW, 0),
|
||||
style,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
} else {
|
||||
Text::with_baseline(&self.palette_query, Point::new(2, 0), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
let cx = (2 + self.palette_query.chars().count() as i32 * CW).min(DIVIDER_X - 2);
|
||||
Rectangle::new(Point::new(cx, 0), Size::new(2, CH as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
|
||||
// Rule under the prompt.
|
||||
Rectangle::new(Point::new(0, CH), Size::new(DIVIDER_X as u32, 1))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
|
||||
// The list is the fuzzy-ranked files, the `>` command registry, or the `$`
|
||||
// snippet library, per the active sigil.
|
||||
let matches = if snippet_mode {
|
||||
self.palette_snippet_matches()
|
||||
} else if command_mode {
|
||||
self.palette_command_matches()
|
||||
} else {
|
||||
self.palette_matches()
|
||||
};
|
||||
let max_chars = WRITE_COLS - 1; // leave a right margin
|
||||
let list_top = CH + 3;
|
||||
let hint_y = HEIGHT as i32 - CH; // bottom row holds the key hint
|
||||
let visible = ((hint_y - list_top) / CH).max(1) as usize;
|
||||
|
||||
if matches.is_empty() {
|
||||
let msg = if snippet_mode {
|
||||
if self.snippets.is_empty() {
|
||||
"(no snippets)"
|
||||
} else {
|
||||
"(no match)"
|
||||
}
|
||||
} else if command_mode {
|
||||
"(no command)"
|
||||
} else if self.file_spans.is_empty() {
|
||||
"(no files on card)"
|
||||
} else if self.palette_query.chars().count() < PALETTE_MIN_QUERY {
|
||||
// No recents yet and the query is below the search threshold —
|
||||
// the full list needs 2+ chars.
|
||||
"(type to search)"
|
||||
} else {
|
||||
"(no match)"
|
||||
};
|
||||
Text::with_baseline(msg, Point::new(2, list_top), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
} else {
|
||||
let sel = self.palette_sel.min(matches.len() - 1);
|
||||
// Scroll the window so the selection stays visible.
|
||||
let start = if sel >= visible { sel - visible + 1 } else { 0 };
|
||||
for (row, &idx) in matches.iter().enumerate().skip(start).take(visible) {
|
||||
let y = list_top + (row - start) as i32 * CH;
|
||||
let label: String = if snippet_mode {
|
||||
Self::snippet_label(&self.snippets[idx]).chars().take(max_chars).collect()
|
||||
} else if command_mode {
|
||||
self.command_label(PALETTE_CMDS[idx])
|
||||
} else {
|
||||
palette_label(self.file_at(idx)).chars().take(max_chars).collect()
|
||||
};
|
||||
if row == sel {
|
||||
// Reverse video: black fill across the column, white glyphs.
|
||||
Rectangle::new(Point::new(0, y), Size::new(DIVIDER_X as u32, CH as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
Text::with_baseline(&label, Point::new(2, y), inv, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
} else {
|
||||
Text::with_baseline(&label, Point::new(2, y), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hint = if snippet_mode {
|
||||
"^N/^P move Enter insert Esc close"
|
||||
} else if command_mode {
|
||||
"^N/^P move Enter change Esc close"
|
||||
} else {
|
||||
"^N/^P move Enter open Esc close"
|
||||
};
|
||||
Text::with_baseline(hint, Point::new(2, hint_y), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
66
editor/src/search.rs
Normal file
66
editor/src/search.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! `/` search execution and `n`/`N` repeats (smartcase + accent folding).
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Editor {
|
||||
/// Run the typed `/` search: remember the pattern (a bare `/`+Enter repeats
|
||||
/// the last one, like vim) and jump forward once. Literal substring — no
|
||||
/// regex on a writing appliance — matched **smartcase** (an all-lowercase
|
||||
/// pattern is case-insensitive; one capital makes it exact, vim-style) and
|
||||
/// always **accent-folded** (`/ete` finds `été`; see [`fold`]).
|
||||
pub(crate) fn execute_search(&mut self) {
|
||||
if !self.cmdline.is_empty() {
|
||||
self.last_search = self.cmdline.clone();
|
||||
}
|
||||
self.search_repeat(1, true);
|
||||
}
|
||||
|
||||
/// Jump `n` matches of [`last_search`](Self::last_search) forward
|
||||
/// (`fwd`, the `/`-Enter and `n` step) or backward (`N`), wrapping around
|
||||
/// the buffer with a "wrapped" notice. A missing pattern or an absent
|
||||
/// match posts a notice and leaves the caret alone.
|
||||
pub(crate) fn search_repeat(&mut self, n: usize, fwd: bool) {
|
||||
if self.last_search.is_empty() {
|
||||
self.set_notice("no previous search");
|
||||
return;
|
||||
}
|
||||
let pat = self.last_search.clone();
|
||||
// Smartcase: any capital in the pattern makes the search exact
|
||||
// (`n`/`N` recompute from the remembered pattern, so a repeat behaves
|
||||
// like the original search).
|
||||
let ci = !pat.chars().any(char::is_uppercase);
|
||||
for _ in 0..n {
|
||||
// Start strictly past (or before) the caret so a caret already on a
|
||||
// match moves to the next one, per vim.
|
||||
let hit = if fwd {
|
||||
let start = if self.caret >= self.text.len() {
|
||||
self.text.len()
|
||||
} else {
|
||||
self.next_char(self.caret)
|
||||
};
|
||||
match find_fold(&self.text[start..], &pat, ci) {
|
||||
Some(i) => Some((start + i, false)),
|
||||
None => find_fold(&self.text, &pat, ci).map(|i| (i, true)),
|
||||
}
|
||||
} else {
|
||||
match rfind_fold(&self.text[..self.caret], &pat, ci) {
|
||||
Some(i) => Some((i, false)),
|
||||
None => rfind_fold(&self.text, &pat, ci).map(|i| (i, true)),
|
||||
}
|
||||
};
|
||||
match hit {
|
||||
Some((pos, wrapped)) => {
|
||||
self.caret = pos;
|
||||
if wrapped {
|
||||
self.set_notice("wrapped");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.set_notice(format!("not found: {pat}"));
|
||||
return; // absent now, absent on every repeat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
161
editor/src/snippets.rs
Normal file
161
editor/src/snippets.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
//! `.typoena.snippets.json`: snippet parsing and tab-stop extraction.
|
||||
|
||||
|
||||
/// The git-tracked snippet library, read at boot like [`PREFS_PATH`]. The host
|
||||
/// reads this file and hands the parsed list to [`Editor::set_snippets`]; a
|
||||
/// missing or malformed file is non-fatal (no snippets, editor runs). It lives in
|
||||
/// the Tracked repo so the library syncs across devices. Full format reference:
|
||||
/// `docs/typoena-snippets.md`.
|
||||
pub const SNIPPETS_PATH: &str = "/sd/repo/.typoena.snippets.json";
|
||||
|
||||
/// One snippet: an inline trigger [`prefix`](Snippet::prefix), a
|
||||
/// [`body`](Snippet::body) carrying `$1..$n`/`$0` tab stops, and a
|
||||
/// [`name`](Snippet::name)/[`description`](Snippet::description) the `$` palette
|
||||
/// shows. Parsed from the Zed-compatible JSON by [`Snippets::parse`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Snippet {
|
||||
/// Display name — the top-level JSON key. What the `$` palette lists.
|
||||
pub name: String,
|
||||
/// The word that triggers inline Tab-expansion.
|
||||
pub prefix: String,
|
||||
/// Literal body text with `$1..$n`/`$0` stops. `${n:label}` placeholders are
|
||||
/// stripped to bare `$n` at parse time (no completion popup to show a label,
|
||||
/// no overtype model to fill it) — see [`strip_stop_labels`].
|
||||
pub body: String,
|
||||
/// Human description; the `$` palette fuzzy-matches and shows it. Empty if the
|
||||
/// JSON entry omits it.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// A parsed snippet library. [`parse`](Snippets::parse) reads the Zed JSON shape;
|
||||
/// [`Editor::set_snippets`] installs it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Snippets(pub Vec<Snippet>);
|
||||
|
||||
/// The Zed JSON value for one snippet: `body` is a string or an array of lines.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct RawSnippet {
|
||||
prefix: String,
|
||||
body: RawBody,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum RawBody {
|
||||
/// `"body": ["line", "line"]` — Zed's multi-line form (joined with `\n`).
|
||||
Lines(Vec<String>),
|
||||
/// `"body": "one line"`.
|
||||
Text(String),
|
||||
}
|
||||
|
||||
impl Snippets {
|
||||
/// Parse the Zed-compatible `.typoena.snippets.json`: an object keyed by
|
||||
/// display name, each value `{ prefix, body, description? }` where `body` is a
|
||||
/// string or an array of lines. Labels in `${n:label}` stops are stripped to
|
||||
/// `$n`. Entries come back sorted by name (a `BTreeMap` parse — deterministic,
|
||||
/// and the empty-`$` palette order; a query re-ranks by fuzzy score anyway). An
|
||||
/// empty (or whitespace-only) file means "no snippets", same as a missing one —
|
||||
/// only a malformed file is an `Err` the host logs before booting with none.
|
||||
pub fn parse(src: &str) -> Result<Self, serde_json::Error> {
|
||||
if src.trim().is_empty() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let raw: std::collections::BTreeMap<String, RawSnippet> = serde_json::from_str(src)?;
|
||||
let snippets = raw
|
||||
.into_iter()
|
||||
.map(|(name, r)| {
|
||||
let body = match r.body {
|
||||
RawBody::Text(s) => s,
|
||||
RawBody::Lines(v) => v.join("\n"),
|
||||
};
|
||||
Snippet {
|
||||
name,
|
||||
prefix: r.prefix,
|
||||
body: strip_stop_labels(&body),
|
||||
description: r.description,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Self(snippets))
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite `${n:label}` (and `${n}`) tab stops to a bare `$n`, leaving plain
|
||||
/// `$n`/`$0` and every other `$` untouched. The editor has no completion popup to
|
||||
/// surface a label and no selection/overtype model to fill one, so the label is
|
||||
/// only noise to delete — dropping it is what lets a Zed snippet file with
|
||||
/// `${1:Titre}` load unchanged. Byte-indexed but UTF-8-safe: it only ever indexes
|
||||
/// the ASCII `$ { } :` and digits; any multi-byte char is copied whole.
|
||||
pub(crate) fn strip_stop_labels(body: &str) -> String {
|
||||
let b = body.as_bytes();
|
||||
let mut out = String::with_capacity(body.len());
|
||||
let mut i = 0;
|
||||
while i < body.len() {
|
||||
if b[i] == b'$' && i + 1 < body.len() && b[i + 1] == b'{' {
|
||||
// Read the digits right after "${".
|
||||
let mut j = i + 2;
|
||||
while j < body.len() && b[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
// A numbered placeholder `${<digits>…}` → `$<digits>`, dropping the rest.
|
||||
if j > i + 2 {
|
||||
if let Some(close) = body[j..].find('}') {
|
||||
out.push('$');
|
||||
out.push_str(&body[i + 2..j]);
|
||||
i = j + close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Not a numbered placeholder (or unclosed) — emit the `$` literally.
|
||||
out.push('$');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let c = body[i..].chars().next().unwrap();
|
||||
out.push(c);
|
||||
i += c.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a snippet body into its literal text (tab-stop markers removed) and the
|
||||
/// caret **visit order** of those stops as byte offsets into that literal: `$1 …
|
||||
/// $n` ascending, then `$0` last. If the body has numbered stops but no explicit
|
||||
/// `$0`, a final stop at the body end is appended (so the last Tab lands past the
|
||||
/// text). A body with no stops returns an empty stop list (the caret just lands at
|
||||
/// the end — no session). `$` not followed by a digit is literal.
|
||||
pub(crate) fn parse_snippet_body(body: &str) -> (String, Vec<usize>) {
|
||||
let b = body.as_bytes();
|
||||
let mut literal = String::with_capacity(body.len());
|
||||
let mut stops: Vec<(u32, usize)> = Vec::new(); // (stop number, offset in `literal`)
|
||||
let mut i = 0;
|
||||
while i < body.len() {
|
||||
if b[i] == b'$' {
|
||||
let mut j = i + 1;
|
||||
while j < body.len() && b[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
if j > i + 1 {
|
||||
let num: u32 = body[i + 1..j].parse().unwrap_or(0);
|
||||
stops.push((num, literal.len()));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
literal.push('$'); // a lone `$` (e.g. a price) is literal text
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let c = body[i..].chars().next().unwrap();
|
||||
literal.push(c);
|
||||
i += c.len_utf8();
|
||||
}
|
||||
// Ensure a final resting stop: `$0` if present, else an implicit one at the end.
|
||||
if !stops.is_empty() && !stops.iter().any(|&(n, _)| n == 0) {
|
||||
stops.push((0, literal.len()));
|
||||
}
|
||||
// Visit order: $1..$n ascending, $0 (the final rest) last.
|
||||
stops.sort_by_key(|&(n, _)| if n == 0 { u32::MAX } else { n });
|
||||
(literal, stops.into_iter().map(|(_, off)| off).collect())
|
||||
}
|
||||
2708
editor/src/tests.rs
Normal file
2708
editor/src/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
66
editor/src/undo.rs
Normal file
66
editor/src/undo.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! Snapshot undo/redo, bounded to [`UNDO_DEPTH`] checkpoints.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Maximum undo depth (change-groups). A full-buffer snapshot per group means
|
||||
/// worst-case memory is `UNDO_DEPTH × buffer size`; for note-sized files on the
|
||||
/// 8 MB PSRAM this is negligible, and prose editing rarely nears 100 groups
|
||||
/// between saves anyway.
|
||||
pub(crate) const UNDO_DEPTH: usize = 100;
|
||||
|
||||
|
||||
impl Editor {
|
||||
/// Record the current `(text, caret)` as an undo baseline, at the *start* of
|
||||
/// a change-group, and drop the redo history (a new edit forks the timeline).
|
||||
/// Called once per change: on entering Insert (the whole session undoes
|
||||
/// together), and before each Normal-mode edit (`x`, `dd`, operators, paste,
|
||||
/// `:fmt`). If the buffer is unchanged since the last baseline it is a no-op,
|
||||
/// so calling it more than once before a mutation records only one group.
|
||||
pub(crate) fn checkpoint(&mut self) {
|
||||
// A change-group is about to begin, so the buffer is (or is about to be)
|
||||
// modified relative to the last save. See the `dirty` field note on why
|
||||
// this is deliberately slightly over-eager.
|
||||
self.dirty = true;
|
||||
if self.undo.last().is_some_and(|(t, _)| t == &self.text) {
|
||||
return; // nothing changed since the last baseline
|
||||
}
|
||||
self.undo.push((self.text.clone(), self.caret));
|
||||
if self.undo.len() > UNDO_DEPTH {
|
||||
self.undo.remove(0); // drop the oldest group
|
||||
}
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
/// `u` — restore the most recent undo baseline, pushing the current state to
|
||||
/// the redo stack. Lands in Normal mode with the caret clamped onto a char
|
||||
/// boundary. No-op with nothing to undo.
|
||||
pub(crate) fn undo(&mut self) {
|
||||
if let Some((text, caret)) = self.undo.pop() {
|
||||
self.redo.push((self.text.clone(), self.caret));
|
||||
self.restore(text, caret);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ctrl-r` — reapply the most recently undone state. No-op with nothing to
|
||||
/// redo.
|
||||
pub(crate) fn redo(&mut self) {
|
||||
if let Some((text, caret)) = self.redo.pop() {
|
||||
self.undo.push((self.text.clone(), self.caret));
|
||||
self.restore(text, caret);
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap in a snapshot's buffer + caret, landing in Normal on a char boundary.
|
||||
pub(crate) fn restore(&mut self, text: String, caret: usize) {
|
||||
self.text = text;
|
||||
self.caret = caret.min(self.text.len());
|
||||
while self.caret > 0 && !self.text.is_char_boundary(self.caret) {
|
||||
self.caret -= 1;
|
||||
}
|
||||
self.mode = Mode::Normal;
|
||||
self.reset_pending();
|
||||
}
|
||||
|
||||
// --- Buffers (multi-file) ----------------------------------------------
|
||||
|
||||
}
|
||||
194
editor/src/visual.rs
Normal file
194
editor/src/visual.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! Visual mode: charwise/linewise selection with y/d/c over the span.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Editor {
|
||||
/// True while a Visual selection is active (charwise or linewise).
|
||||
pub(crate) fn in_visual(&self) -> bool {
|
||||
matches!(self.mode, Mode::Visual | Mode::VisualLine)
|
||||
}
|
||||
|
||||
/// Dispatch a key in Visual/VisualLine. Motions extend the selection (the
|
||||
/// anchor stays put, the caret moves); `y`/`d`/`c` act on the span and
|
||||
/// leave Visual; `v`/`V` switch submode or toggle back to Normal; `Esc`
|
||||
/// cancels. Counts and `gg`/`G` work as in Normal.
|
||||
pub(crate) fn visual_key(&mut self, key: Key) {
|
||||
let c = match key {
|
||||
Key::Char(c) => c,
|
||||
Key::HalfPageDown => {
|
||||
self.count = 0;
|
||||
self.move_display_rows(HALF_PAGE as isize);
|
||||
return;
|
||||
}
|
||||
Key::HalfPageUp => {
|
||||
self.count = 0;
|
||||
self.move_display_rows(-(HALF_PAGE as isize));
|
||||
return;
|
||||
}
|
||||
Key::Escape => {
|
||||
self.exit_visual();
|
||||
return;
|
||||
}
|
||||
// Cmd-p works from every mode: drop the selection (as Esc would)
|
||||
// and open the palette.
|
||||
Key::Palette => {
|
||||
self.exit_visual();
|
||||
self.open_palette();
|
||||
return;
|
||||
}
|
||||
// Enter/Backspace/etc. carry no Visual meaning; drop any count.
|
||||
_ => {
|
||||
self.count = 0;
|
||||
self.pending_g = false;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// `gg` — jump to the top, extending the selection.
|
||||
if self.pending_g {
|
||||
self.pending_g = false;
|
||||
if c == 'g' {
|
||||
self.caret = 0;
|
||||
}
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Count prefix, exactly as in Normal (a leading `0` is the motion).
|
||||
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
|
||||
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
|
||||
return;
|
||||
}
|
||||
let n = self.count.max(1);
|
||||
|
||||
match c {
|
||||
'g' => {
|
||||
self.pending_g = true;
|
||||
return;
|
||||
}
|
||||
// `v` toggles charwise off (or switches VisualLine → charwise);
|
||||
// `V` toggles linewise off (or switches charwise → linewise).
|
||||
'v' => {
|
||||
if self.mode == Mode::Visual {
|
||||
self.exit_visual();
|
||||
} else {
|
||||
self.mode = Mode::Visual;
|
||||
}
|
||||
}
|
||||
'V' => {
|
||||
if self.mode == Mode::VisualLine {
|
||||
self.exit_visual();
|
||||
} else {
|
||||
self.mode = Mode::VisualLine;
|
||||
}
|
||||
}
|
||||
'y' => self.visual_yank(),
|
||||
'd' => self.visual_delete(),
|
||||
'c' => self.visual_change(),
|
||||
// Any other char: a shared motion extends the selection, or is a
|
||||
// no-op. Unlike Normal, edit keys (`x`, `p`, …) aren't bound here.
|
||||
_ => {
|
||||
self.move_by(c, n);
|
||||
}
|
||||
}
|
||||
self.count = 0;
|
||||
}
|
||||
|
||||
/// The current selection as `(start, end, linewise)` byte offsets, `start <
|
||||
/// end` (or equal on an empty buffer). Charwise is vim-inclusive of the char
|
||||
/// under the further caret; linewise always spans whole logical lines from
|
||||
/// the anchor's line through the caret's.
|
||||
pub(crate) fn visual_span(&self) -> (usize, usize, bool) {
|
||||
let anchor = self.visual_anchor.unwrap_or(self.caret);
|
||||
let lo = anchor.min(self.caret);
|
||||
let hi = anchor.max(self.caret);
|
||||
if self.mode == Mode::VisualLine {
|
||||
(self.line_start(lo), self.line_end(hi), true)
|
||||
} else {
|
||||
(lo, self.next_char(hi).min(self.text.len()), false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the selection into the unnamed register (linewise from `V`, charwise
|
||||
/// otherwise), leave the caret at the selection start, and return to Normal.
|
||||
pub(crate) fn visual_yank(&mut self) {
|
||||
let (s, e, line) = self.visual_span();
|
||||
self.register = self.selection_text(s, e, line);
|
||||
self.register_linewise = line;
|
||||
self.caret = s;
|
||||
self.exit_visual();
|
||||
}
|
||||
|
||||
/// Delete the selection (filling the register like `visual_yank`), leaving
|
||||
/// the caret at the span start, and return to Normal. Linewise removes whole
|
||||
/// lines including a bounding newline, mirroring `dd`.
|
||||
pub(crate) fn visual_delete(&mut self) {
|
||||
let (s, e, line) = self.visual_span();
|
||||
self.register = self.selection_text(s, e, line);
|
||||
self.register_linewise = line;
|
||||
self.checkpoint();
|
||||
let (ds, de) = self.delete_bounds(s, e, line);
|
||||
self.text.replace_range(ds..de, "");
|
||||
self.caret = if line {
|
||||
self.line_start(ds.min(self.text.len()))
|
||||
} else {
|
||||
ds.min(self.text.len())
|
||||
};
|
||||
self.exit_visual();
|
||||
}
|
||||
|
||||
/// Change the selection: delete it (filling the register) and drop into
|
||||
/// Insert at the start. A linewise change clears the lines' text but leaves
|
||||
/// one empty line to type on (like `cc`), rather than removing the line.
|
||||
pub(crate) fn visual_change(&mut self) {
|
||||
let (s, e, line) = self.visual_span();
|
||||
self.register = self.selection_text(s, e, line);
|
||||
self.register_linewise = line;
|
||||
self.checkpoint();
|
||||
// Linewise: replace only `s..e` (the text, keeping the final newline) so
|
||||
// one blank line remains. Charwise: remove the exact span.
|
||||
self.text.replace_range(s..e, "");
|
||||
self.caret = s.min(self.text.len());
|
||||
self.visual_anchor = None;
|
||||
self.pending_g = false;
|
||||
self.count = 0;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// The register contents for a selection span: charwise is the raw slice;
|
||||
/// linewise gets a synthesised trailing newline (as `yy`/`dd` store lines).
|
||||
pub(crate) fn selection_text(&self, s: usize, e: usize, line: bool) -> String {
|
||||
let mut block = self.text[s..e].to_string();
|
||||
if line && !block.ends_with('\n') {
|
||||
block.push('\n');
|
||||
}
|
||||
block
|
||||
}
|
||||
|
||||
/// Byte range to actually remove for a delete. Charwise is the span as-is;
|
||||
/// linewise also eats the trailing newline (or, on the last line, the
|
||||
/// preceding one) so no blank line is left behind — matching `dd`.
|
||||
pub(crate) fn delete_bounds(&self, s: usize, e: usize, line: bool) -> (usize, usize) {
|
||||
if !line {
|
||||
return (s, e);
|
||||
}
|
||||
if e < self.text.len() {
|
||||
(s, self.next_char(e)) // eat the trailing '\n' at `e`
|
||||
} else if s > 0 {
|
||||
(self.prev_char(s), e) // last line: eat the preceding '\n' instead
|
||||
} else {
|
||||
(s, e) // whole buffer
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave Visual for Normal, clearing the anchor and any pending state.
|
||||
pub(crate) fn exit_visual(&mut self) {
|
||||
self.mode = Mode::Normal;
|
||||
self.visual_anchor = None;
|
||||
self.pending_g = false;
|
||||
self.count = 0;
|
||||
}
|
||||
|
||||
// --- View mode ---------------------------------------------------------
|
||||
|
||||
}
|
||||
@@ -3,8 +3,8 @@ target = "xtensa-esp32s3-espidf"
|
||||
|
||||
[target.'cfg(target_os = "espidf")']
|
||||
linker = "ldproxy"
|
||||
runner = "espflash flash --monitor"
|
||||
rustflags = [ "--cfg", "espidf_time64"]
|
||||
runner = "espflash flash --monitor --baud 921600"
|
||||
rustflags = ["--cfg", "espidf_time64"]
|
||||
|
||||
[unstable]
|
||||
build-std = ["std", "panic_abort"]
|
||||
|
||||
@@ -13,5 +13,13 @@ TW_WIFI_PASS=your-password
|
||||
# Note: the spike assumes WPA2-Personal. Open network → leave TW_WIFI_PASS empty.
|
||||
# A WPA3-only AP needs AuthMethod::WPA3Personal in src/bin/wifi_tls.rs.
|
||||
|
||||
# Spike 7 (gitoxide push) will add: TW_REMOTE_URL, TW_GH_USER, TW_PAT,
|
||||
# TW_AUTHOR_NAME, TW_AUTHOR_EMAIL.
|
||||
# Spike 7 — on-device git push (`just flash-git-push`, src/bin/git_push.rs).
|
||||
# HTTPS + PAT auth (ADR-005); baked into the git_push image at build time and
|
||||
# never logged. Point TW_REMOTE_URL at a THROWAWAY test repo — the spike pushes
|
||||
# a fresh `device/<unix>` branch on every boot.
|
||||
TW_REMOTE_URL=https://github.com/you/typoena-test.git
|
||||
TW_GH_USER=your-github-username
|
||||
TW_PAT=ghp_xxxxxxxxxxxxxxxxxxxx
|
||||
# Commit author (message is a timestamp).
|
||||
TW_AUTHOR_NAME=Typoena
|
||||
TW_AUTHOR_EMAIL=typoena@example.com
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firmware"
|
||||
version = "0.1.0"
|
||||
version = "0.7.0"
|
||||
authors = ["Julien Calixte <juliencalixte@gmail.com>"]
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
@@ -10,6 +10,13 @@ rust-version = "1.85"
|
||||
# rust-analyzer — see README "harness = false" note).
|
||||
autobins = false
|
||||
|
||||
# Shared library surface (currently just resilient Wi-Fi bring-up in
|
||||
# `firmware::net`), reused by the editor bin and the spike bins so the connect
|
||||
# retry logic lives in exactly one place rather than being copied per binary.
|
||||
[lib]
|
||||
name = "firmware"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "firmware"
|
||||
path = "src/main.rs"
|
||||
@@ -22,21 +29,111 @@ name = "wifi_tls"
|
||||
path = "src/bin/wifi_tls.rs"
|
||||
harness = false
|
||||
|
||||
# Spike 3 — SD card (FAT) over the shared SPI2 bus. Standalone bench program.
|
||||
# Flash with `just flash-sd`.
|
||||
[[bin]]
|
||||
name = "sd_fat"
|
||||
path = "src/bin/sd_fat.rs"
|
||||
harness = false
|
||||
|
||||
# SD/FAT primitive-op micro-benchmark — attributes the ~700 ms/loose-object write
|
||||
# floor (see docs/tradeoff-curves/sync-commit-staging.md). Pure SD, no git feature.
|
||||
# Flash with `just flash-bench`.
|
||||
[[bin]]
|
||||
name = "sd_bench"
|
||||
path = "src/bin/sd_bench.rs"
|
||||
harness = false
|
||||
|
||||
# git-level micro-benchmark — localizes the ~700 ms/object libgit2 overhead (see
|
||||
# docs/tradeoff-curves/sync-commit-staging.md). Needs the `git` feature.
|
||||
[[bin]]
|
||||
name = "git_bench"
|
||||
path = "src/bin/git_bench.rs"
|
||||
harness = false
|
||||
required-features = ["git"]
|
||||
|
||||
# Spike 9 — boot splash. Standalone bench program that paints the Typoena
|
||||
# wordmark-in-a-circle on the EPD. No git, no SD, no Wi-Fi. Flash with
|
||||
# `just flash-splash`.
|
||||
[[bin]]
|
||||
name = "splash"
|
||||
path = "src/bin/splash.rs"
|
||||
harness = false
|
||||
|
||||
# Spike 7 Path 2 — libgit2 link/run smoke via the git2 safe API. Gated behind
|
||||
# the `git` feature so the editor build never pulls libgit2-sys/pkg-config.
|
||||
# Build: cargo build --release --bin git_smoke --features git (env in justfile).
|
||||
[[bin]]
|
||||
name = "git_smoke"
|
||||
path = "src/bin/git_smoke.rs"
|
||||
harness = false
|
||||
required-features = ["git"]
|
||||
|
||||
# Spike 7 Path 2 finish — on-device init/commit/push over mbedTLS HTTPS. Wi-Fi +
|
||||
# SNTP + flash-FAT + libgit2 in one bench binary. Gated behind `git` like
|
||||
# git_smoke. Build/flash with `just flash-git-push` (needs the git TW_* vars in
|
||||
# .env; flashes partitions.csv so the `storage` FAT partition exists).
|
||||
[[bin]]
|
||||
name = "git_push"
|
||||
path = "src/bin/git_push.rs"
|
||||
harness = false
|
||||
required-features = ["git"]
|
||||
|
||||
# Milestone #2A — persistent-clone publish cycle (clone + open + fast-forward
|
||||
# push), the product's real git flow. Same `git` gate + TW_* env + partition
|
||||
# table as git_push. Build/flash with `just flash-git-sync`.
|
||||
[[bin]]
|
||||
name = "git_sync"
|
||||
path = "src/bin/git_sync.rs"
|
||||
harness = false
|
||||
required-features = ["git"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
|
||||
[profile.dev]
|
||||
debug = true # Symbols are nice, and they don't increase the size on Flash
|
||||
debug = true # Symbols are nice, and they don't increase the size on Flash
|
||||
opt-level = "z"
|
||||
[patch.crates-io]
|
||||
esp-idf-sys = { git = "https://github.com/esp-rs/esp-idf-sys.git" }
|
||||
esp-idf-hal = { git = "https://github.com/esp-rs/esp-idf-hal.git" }
|
||||
esp-idf-svc = { git = "https://github.com/esp-rs/esp-idf-svc.git" }
|
||||
|
||||
[features]
|
||||
# A Cargo feature, OFF by default — so a bare `cargo build` (and `just
|
||||
# build-light`) is a LIGHT editor build. The justfile's nominal `build`/`flash`
|
||||
# opt in via `--features git`. Enabling it (a) pulls the git2 safe API and (b)
|
||||
# turns on the `#[cfg(feature = "git")]` publish path in main.rs (see
|
||||
# `publish()`), so git code is only ever compiled with `--features git`. libgit2
|
||||
# itself is built by the esp-idf component (firmware/components/libgit2/) with
|
||||
# mbedTLS — but ONLY when the justfile also sets LIBGIT2_SRC (the full recipes
|
||||
# do; `build-light` doesn't, so the component is an empty no-op). git2/libgit2-sys
|
||||
# run in system mode (LIBGIT2_NO_VENDOR=1) purely for the Rust bindings, with
|
||||
# default features off to avoid dragging in openssl-sys/libssh2-sys.
|
||||
git = ["dep:git2", "dep:libgit2-sys"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
log = "0.4"
|
||||
esp-idf-svc = { version = "0.52.1", features = ["critical-section", "embassy-time-driver", "embassy-sync"] }
|
||||
# Pure HID decode (Key type + edge-detecting Decoder), split out so it is
|
||||
# host-testable off the xtensa target. See ../keymap and MEMORY_AUDIT.md.
|
||||
keymap = { path = "../keymap" }
|
||||
# Editor core (buffer, motions, edits, layout/render) and the panel framebuffer,
|
||||
# extracted from src/editor.rs and src/epd.rs so `cargo test` can exercise them
|
||||
# off-device. The firmware links them; the host tests live in each crate.
|
||||
editor = { path = "../editor" }
|
||||
display = { path = "../display" }
|
||||
git2 = { version = "0.20", default-features = false, optional = true }
|
||||
# Already in the tree under git2 (same system-mode build); named directly for
|
||||
# the raw `git_libgit2_opts` calls git2 doesn't wrap — the odb-cache cap
|
||||
# (GIT_OPT_SET_CACHE_MAX_SIZE) and cache telemetry (GIT_OPT_GET_CACHED_MEMORY)
|
||||
# that git_sync sets against the run-4 push OOM (2026-07-13).
|
||||
libgit2-sys = { version = "0.18", default-features = false, optional = true }
|
||||
esp-idf-svc = { version = "0.52.1", features = [
|
||||
"critical-section",
|
||||
"embassy-time-driver",
|
||||
"embassy-sync",
|
||||
] }
|
||||
# Remove `generic-queue-8` if you plan to use `embassy-time` WITH `embassy-executor`
|
||||
embassy-time = { version = "0.5", features = ["generic-queue-8"] }
|
||||
embedded-graphics = "0.8"
|
||||
@@ -46,3 +143,10 @@ embedded-svc = "0.29"
|
||||
|
||||
[build-dependencies]
|
||||
embuild = "0.33"
|
||||
|
||||
# TEMPORARY — Spike 7 Path 2 Gate A probe. Builds libgit2 as an esp-idf
|
||||
# component (see firmware/components/libgit2/). Compiled against on-disk source
|
||||
# via LIBGIT2_SRC. Revert if the probe fails; promote (vendor the source) if it
|
||||
# passes.
|
||||
[[package.metadata.esp-idf-sys.extra_components]]
|
||||
component_dirs = ["components/libgit2"]
|
||||
|
||||
@@ -61,11 +61,43 @@ need `CONFIG_SPIRAM` turned on first.
|
||||
|
||||
Credentials are build-time: copy [`.env.example`](.env.example) to `.env`, set
|
||||
`TW_WIFI_SSID` / `TW_WIFI_PASS`, and `just` loads them (dotenv) so `build.rs`
|
||||
bakes them in. `.env` is gitignored; the editor build (`just flash`) needs none
|
||||
of it. `sdkconfig.defaults` gains the full certificate bundle and a bigger main
|
||||
bakes them in. `.env` is gitignored; the light editor build (`just flash-light`)
|
||||
needs none of it. `sdkconfig.defaults` gains the full certificate bundle and a bigger main
|
||||
task stack for the mbedtls handshake — a one-time esp-idf reconfigure on the
|
||||
next build.
|
||||
|
||||
**Spike 3 — SD card (FAT) on dedicated SPI3: verified 2026-07-11.** A separate
|
||||
binary — [`src/bin/sd_fat.rs`](src/bin/sd_fat.rs), flashed with `just flash-sd` —
|
||||
is now a thin on-device harness over the real
|
||||
[`firmware::persistence`](src/persistence.rs) module: it mounts the card, reports
|
||||
FAT usage, and round-trips an atomic save/load (write `*.tmp` → fsync → unlink →
|
||||
rename → read-back). Per ADR-012 the SD runs on its **own SPI3 host** —
|
||||
**SCK 14 · MOSI 15 · MISO 13 · SD CS 10** — leaving the EPD alone on SPI2.
|
||||
Verified on the dedicated SPI3 bus 2026-07-11 (same mount + round-trip result as
|
||||
the initial shared-SPI2 bring-up).
|
||||
|
||||
Bench result (genuine 32 GB SDHC card): mounts at 10 MHz, `29806 MiB total`,
|
||||
atomic round-trip byte-identical. Two findings baked into the code:
|
||||
|
||||
- **Card compatibility.** A 133 GB SDXC card failed init at `CMD59` (SPI-mode
|
||||
CRC); a genuine ≤32 GB card works. We keep CRC required and reject bad cards
|
||||
with a swap-the-card message rather than run over an unchecked bus. See the
|
||||
[Spike 3 postmortem](../docs/postmortems/2026-07-05-spike3-sd-cmd59.md).
|
||||
- **FatFS rename ≠ POSIX rename.** `f_rename` won't overwrite an existing
|
||||
target (returns `FR_EXIST`), so the atomic save unlinks the destination first.
|
||||
`firmware::persistence` pairs this with `*.tmp` boot-recovery
|
||||
(`Storage::recover`): if a `*.tmp` is found _alongside_ the target the crash
|
||||
may have been mid-write, so it keeps the committed file and discards the tmp;
|
||||
it only promotes the tmp when the target was already unlinked. Long filenames
|
||||
(`CONFIG_FATFS_LFN_HEAP`) are required for the two-dot `*.md.tmp` name.
|
||||
|
||||
**Arbitration resolved (ADR-012):** the EPD driver holds an exclusive SPI2 lock
|
||||
for its whole lifetime, and persistence runs on its own thread, so a shared bus
|
||||
would need an EPD rewrite plus a cross-thread mutex on the save path. Instead the
|
||||
SD gets its own SPI3 — the EPD stays untouched, no arbitration. Remaining before
|
||||
persistence lands in `main.rs`: wire the atomic save (unlink-then-rename +
|
||||
`*.tmp` boot-recovery) into a `persistence` module.
|
||||
|
||||
**Spike 5 — partial refresh + typing: verified 2026-07-04.** `main.rs` wires
|
||||
the keyboard to the panel: [`src/usb_kbd.rs`](src/usb_kbd.rs) feeds decoded
|
||||
key-downs (US layout, edge-detected) into a queue, and the main loop keeps a
|
||||
@@ -118,8 +150,8 @@ reseat the jumpers (CS first) before debugging code.
|
||||
|
||||
Next up per
|
||||
[`docs/v0.1-mvp-technical.md`](../docs/v0.1-mvp-technical.md#hardware-bring-up-order):
|
||||
Wi-Fi/TLS (Spike 6, implemented above), then gitoxide push (Spike 7); SD is
|
||||
deferred.
|
||||
Wi-Fi/TLS (Spike 6, implemented above), then git push (Spike 7), then SD
|
||||
(Spike 3) — all verified.
|
||||
|
||||
**Spike 1 — Blink: verified 2026-07-04.** GPIO 2 + on-board WS2812 toggled
|
||||
at 1 Hz with `blink N` on USB-serial, proving toolchain, esp-idf link, and
|
||||
@@ -141,14 +173,48 @@ the Xtensa GCC to `PATH`):
|
||||
. ~/export-esp.sh
|
||||
```
|
||||
|
||||
Then from this directory:
|
||||
Then from this directory, `just build` (the nominal product build) or, for fast
|
||||
iteration without git, `just build-light`:
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
just build # full: firmware + git publishing (libgit2 + git2)
|
||||
just build-light # light: editor only, no git — much faster
|
||||
```
|
||||
|
||||
(A bare `cargo build --release` with no env is equivalent to `build-light` — the
|
||||
`git` feature is off by default.)
|
||||
|
||||
The first build is slow (the esp-idf C sources are checked out and built
|
||||
under `.embuild/`). Subsequent builds are incremental.
|
||||
under `.embuild/`; the full build also compiles libgit2 + mbedTLS). Subsequent
|
||||
builds are incremental.
|
||||
|
||||
### Build modes — git (default) vs light
|
||||
|
||||
Publishing (`:gp` → git push) is expensive to build: it drags in libgit2 +
|
||||
mbedTLS (compiled as an esp-idf component) and the `git2` crate. It sits behind
|
||||
a switch. The nominal build turns it on (it's the product); a **light** build
|
||||
leaves it off — ideal for iterating on the editor, EPD, USB, or SD without
|
||||
paying for libgit2:
|
||||
|
||||
| Build | Command | libgit2 component | `git2` crate | `:gp` |
|
||||
| ------------------------ | --------------------------------------- | -------------------------- | ------------ | ------------------------- |
|
||||
| **Full / git** (default) | `just build` / `just flash` | compiled | linked | save → push |
|
||||
| **Light** | `just build-light` / `just flash-light` | not compiled (empty no-op) | not linked | saves locally, skips push |
|
||||
|
||||
Two independent switches make this work, and the justfile flips them together:
|
||||
|
||||
1. **`git` Cargo feature** (`--features git`) — pulls the `git2` crate and
|
||||
turns on the `#[cfg(feature = "git")]` publish path in
|
||||
[`src/main.rs`](src/main.rs) (`publish()`). Off by default; the full recipes
|
||||
pass it.
|
||||
2. **`LIBGIT2_SRC` env** — the [libgit2 component](components/libgit2/CMakeLists.txt)
|
||||
only compiles its sources when this points at the vendored tree; unset, it
|
||||
registers an _empty_ component. Only the full recipes set it.
|
||||
|
||||
Because git code in the firmware binary is only ever compiled under
|
||||
`--features git`, `just build-light` can never drag libgit2 in. (Git isn't wired
|
||||
into `main.rs` yet, so the full `just build` currently just builds slower and
|
||||
behaves like the light build — the seam is in place ahead of the integration.)
|
||||
|
||||
## Flash (when hardware is on the bench)
|
||||
|
||||
@@ -166,6 +232,58 @@ over USB you should see:
|
||||
at 1 Hz on the serial monitor, and — if an LED is wired from GPIO 2 → 330 Ω
|
||||
→ GND — the LED blinks in lockstep.
|
||||
|
||||
## Provisioning an SD card
|
||||
|
||||
Typoena reads its config and its notes repo from the SD card — it never
|
||||
cold-clones the ~566 MB repo over Wi-Fi + mbedTLS (the
|
||||
[git-sync sizing decision](../docs/notes/git-sync-images-and-repo-size.md)).
|
||||
Instead a Mac prepares the card over a reader, and the device only ever takes
|
||||
the `open` + fast-forward path. The [`justfile`](justfile) has three entry
|
||||
points, each ejecting the card when done:
|
||||
|
||||
```sh
|
||||
just init ~/code/notes # full prep of a fresh card: notes repo + config
|
||||
just load ~/code/notes # (re)copy just the notes repo → /sd/repo
|
||||
just provision # (re)write just the config (rotate PAT, switch Wi-Fi)
|
||||
```
|
||||
|
||||
`init` is the once-per-card command; `load` and `provision` each refresh one
|
||||
half without touching the other. Add a `/Volumes/<name>` as the last argument if
|
||||
more than one removable card is mounted — auto-detect refuses on ambiguity,
|
||||
since a wrong guess would let `rsync --delete` wipe the wrong disk's `repo/`.
|
||||
|
||||
### Config with little to type
|
||||
|
||||
`typoena.conf` (Wi-Fi + PAT + git identity) needs **no `.env`**. Each value runs
|
||||
a ladder — `.env` if present, else derived from tools already on the machine,
|
||||
else an interactive prompt with the derived value as the default:
|
||||
|
||||
| Value | Derived from |
|
||||
| ------------------------------------ | --------------------------------------------------------- |
|
||||
| `TW_REMOTE_URL` | the source repo's `origin` (or the card's existing clone) |
|
||||
| `TW_AUTHOR_NAME` / `TW_AUTHOR_EMAIL` | `git config user.name` / `user.email` |
|
||||
| `TW_GH_USER` | `gh api user` |
|
||||
| `TW_WIFI_SSID` | the Mac's active Wi-Fi network |
|
||||
| `TW_WIFI_PASS` | the System keychain for that SSID (else prompt) |
|
||||
| `TW_PAT` | **never derived** — always typed by hand |
|
||||
|
||||
So a first run is usually: `just init ~/code/notes`, press Enter through the
|
||||
auto-filled defaults, approve the macOS Keychain dialog for the Wi-Fi password
|
||||
(or type it), and paste a fine-grained PAT once. Reading a saved Wi-Fi password
|
||||
triggers a macOS authorization dialog (login password / Touch ID → Allow) —
|
||||
that's macOS guarding a System-keychain secret, not something the recipe can
|
||||
suppress. Keeping [`.env`](.env.example) populated stays a valid override and
|
||||
skips all prompts.
|
||||
|
||||
### Secrets on the card
|
||||
|
||||
FAT has no file permissions, so **physical custody of the card is the only
|
||||
control** over the plaintext `TW_PAT`. Scope it to a fine-grained token with
|
||||
`contents:write` on just the notes repo, so a lost card is a one-token revoke.
|
||||
The PAT is never derived from `gh auth token` (a broad token on removable media
|
||||
would defeat the point) and never echoed — the recipes report each value only as
|
||||
`set` / `MISSING`.
|
||||
|
||||
## Pin choice
|
||||
|
||||
GPIO 2 is a safe general-purpose pin on the ESP32-S3-DevKitC-1: it's not
|
||||
|
||||
@@ -27,12 +27,50 @@ fn main() {
|
||||
// them in with env!(). Empty when unset: the network spike checks at
|
||||
// runtime and prints a clear message, so the *editor* build never has to
|
||||
// carry Wi-Fi creds. Source them from firmware/.env (loaded by `just`).
|
||||
for var in ["TW_WIFI_SSID", "TW_WIFI_PASS"] {
|
||||
//
|
||||
// The TW_REMOTE_URL / TW_GH_USER / TW_PAT / TW_AUTHOR_* vars back Spike 7's
|
||||
// on-device push (src/bin/git_push.rs). env!() embeds a value only in a
|
||||
// binary that references it, so the editor binary carries none of these.
|
||||
// NOTE: TW_PAT ends up in the git_push image — fine for the bench spike, but
|
||||
// a product must not bake the PAT into flash (ADR-005).
|
||||
for var in [
|
||||
"TW_WIFI_SSID",
|
||||
"TW_WIFI_PASS",
|
||||
"TW_REMOTE_URL",
|
||||
"TW_GH_USER",
|
||||
"TW_PAT",
|
||||
"TW_AUTHOR_NAME",
|
||||
"TW_AUTHOR_EMAIL",
|
||||
] {
|
||||
let val = std::env::var(var).unwrap_or_default();
|
||||
println!("cargo:rustc-env={var}={val}");
|
||||
println!("cargo:rerun-if-env-changed={var}");
|
||||
}
|
||||
|
||||
// A git-feature build with an empty publish config can only ever fail at
|
||||
// runtime (git_sync's publish_cycle guard), so refuse it here instead.
|
||||
// env!() bakes the values at compile time and only `just` dotenv-loads
|
||||
// firmware/.env — a plain `cargo build --features git` in a bare shell
|
||||
// silently produced a firmware whose `:sync` could never work (bit the
|
||||
// 2026-07-13 flash). TW_WIFI_PASS may be legitimately empty (open network)
|
||||
// and TW_AUTHOR_* have runtime defaults, so only the four required vars
|
||||
// are checked.
|
||||
if std::env::var("CARGO_FEATURE_GIT").is_ok() {
|
||||
let missing: Vec<&str> = ["TW_WIFI_SSID", "TW_REMOTE_URL", "TW_GH_USER", "TW_PAT"]
|
||||
.into_iter()
|
||||
.filter(|v| std::env::var(v).map_or(true, |val| val.is_empty()))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
panic!(
|
||||
"git-feature build without publish config: {} unset/empty. \
|
||||
Build through `just build` (dotenv-loads firmware/.env), or \
|
||||
source firmware/.env into this shell. For a no-git editor \
|
||||
build use `just build-light` (drops --features git).",
|
||||
missing.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pointing rerun-if-changed at a file that never exists forces this
|
||||
// script to rerun on every build, keeping BUILD_TIME fresh.
|
||||
println!("cargo:rerun-if-changed=.force-build-stamp");
|
||||
|
||||
131
firmware/components/libgit2/CMakeLists.txt
Normal file
131
firmware/components/libgit2/CMakeLists.txt
Normal file
@@ -0,0 +1,131 @@
|
||||
# libgit2 as an ESP-IDF component (Spike 7, Path 2 — Gate A probe).
|
||||
#
|
||||
# Builds libgit2 1.9.4 (the exact version libgit2-sys 0.18.5 expects, so git2's
|
||||
# safe Rust API can bind it in system mode later) from an on-disk source tree
|
||||
# pointed to by $ENV{LIBGIT2_SRC}, configured for mbedTLS.
|
||||
#
|
||||
# Why a component and not CFLAGS injection (Path 1): registering as a component
|
||||
# makes libgit2 inherit the lwip / mbedtls / pthread / vfs / newlib include and
|
||||
# link graph. Path 1 died because manually injecting one component's includes
|
||||
# cascades into the next (arpa/inet.h -> sys/ioctl.h -> ...). The component
|
||||
# model resolves that graph for us.
|
||||
#
|
||||
# GATE A SCOPE: prove libgit2's C compiles under the esp-idf/xtensa toolchain.
|
||||
# Not wired to any Rust caller yet — esp-idf builds all components (COMPONENTS
|
||||
# is empty in esp-idf-sys's project template), so this compiles regardless.
|
||||
|
||||
set(LG2 $ENV{LIBGIT2_SRC})
|
||||
if(NOT LG2 OR NOT EXISTS "${LG2}/include/git2.h")
|
||||
# No source provided → register an empty component and bail. This keeps the
|
||||
# editor build (`just build`, no env) working: esp-idf builds every
|
||||
# component, but with nothing to compile this one is a no-op. Only `git`
|
||||
# builds set LIBGIT2_SRC. (Once libgit2 is vendored into the repo this
|
||||
# branch becomes dead — LIBGIT2_SRC will always resolve.)
|
||||
message(STATUS "libgit2 component: LIBGIT2_SRC unset/invalid — empty component (git feature off)")
|
||||
idf_component_register()
|
||||
return()
|
||||
endif()
|
||||
|
||||
# Source list mirrors libgit2-sys's build.rs, with two deltas:
|
||||
# - hashing/TLS = mbedtls (one hash file) instead of sha1dc + openssl
|
||||
# - bundled zlib compiled in (deps/zlib) instead of linking libz-sys
|
||||
# The transports/ and streams/ dirs are compiled wholesale; each file guards
|
||||
# itself on the feature macros in git2_features.h.
|
||||
file(GLOB LG2_SRCS
|
||||
"${LG2}/src/libgit2/*.c"
|
||||
"${LG2}/src/libgit2/transports/*.c"
|
||||
"${LG2}/src/libgit2/streams/*.c"
|
||||
"${LG2}/src/util/*.c"
|
||||
"${LG2}/deps/llhttp/*.c"
|
||||
"${LG2}/deps/xdiff/*.c"
|
||||
"${LG2}/deps/pcre/*.c"
|
||||
"${LG2}/deps/zlib/*.c"
|
||||
)
|
||||
# streams/mbedtls.c is replaced by our patched copy: its wrap() error path
|
||||
# double-freed the socket stream (tlsf abort on device when ssl_setup failed
|
||||
# under memory pressure). See esp_mbedtls_stream.c's header for the one-hunk
|
||||
# delta; keep the copy in lockstep on submodule bumps.
|
||||
list(REMOVE_ITEM LG2_SRCS "${LG2}/src/libgit2/streams/mbedtls.c")
|
||||
|
||||
list(APPEND LG2_SRCS
|
||||
"${LG2}/src/util/allocators/failalloc.c"
|
||||
"${LG2}/src/util/allocators/stdalloc.c"
|
||||
"${LG2}/src/util/unix/realpath.c"
|
||||
"${LG2}/src/util/hash/mbedtls.c" # SHA1 + SHA256 via mbedtls
|
||||
"${CMAKE_CURRENT_LIST_DIR}/esp_map.c" # p_mmap via malloc+read (no <sys/mman.h>)
|
||||
"${CMAKE_CURRENT_LIST_DIR}/esp_stubs.c" # getuid/readlink/utimes/... stubs
|
||||
"${CMAKE_CURRENT_LIST_DIR}/esp_mbedtls_stream.c" # streams/mbedtls.c + double-free fix
|
||||
# NOTE: unix/map.c replaced by esp_map.c — picolibc has no <sys/mman.h>.
|
||||
# NOTE: unix/process.c deliberately excluded — needs fork()/sys/wait.h,
|
||||
# only used by the SSH-exec transport we don't enable.
|
||||
)
|
||||
|
||||
list(LENGTH LG2_SRCS LG2_NSRC)
|
||||
if(LG2_NSRC LESS 50)
|
||||
message(FATAL_ERROR "libgit2 glob returned only ${LG2_NSRC} files — LIBGIT2_SRC likely wrong")
|
||||
endif()
|
||||
message(STATUS "libgit2 component: ${LG2_NSRC} source files from ${LG2}")
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${LG2_SRCS}
|
||||
INCLUDE_DIRS "${LG2}/include"
|
||||
PRIV_INCLUDE_DIRS
|
||||
"${CMAKE_CURRENT_LIST_DIR}" # our hand-written git2_features.h
|
||||
"${LG2}/src/libgit2"
|
||||
"${LG2}/src/util"
|
||||
"${LG2}/src/util/hash"
|
||||
"${LG2}/deps/llhttp"
|
||||
"${LG2}/deps/xdiff"
|
||||
"${LG2}/deps/pcre"
|
||||
"${LG2}/deps/zlib"
|
||||
REQUIRES mbedtls lwip pthread vfs newlib
|
||||
)
|
||||
|
||||
# PCRE (bundled regex backend) compile-time config — copied from libgit2-sys's
|
||||
# build.rs. Global -D because libgit2 shares one config.h across the tree.
|
||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||
GIT_REGEX_BUILTIN=1
|
||||
HAVE_STDINT_H=1
|
||||
HAVE_MEMMOVE=1
|
||||
NO_RECURSE=1
|
||||
NEWLINE=10
|
||||
POSIX_MALLOC_THRESHOLD=10
|
||||
LINK_SIZE=2
|
||||
PARENS_NEST_LIMIT=250
|
||||
MATCH_LIMIT=10000000
|
||||
MATCH_LIMIT_RECURSION=MATCH_LIMIT
|
||||
MAX_NAME_SIZE=32
|
||||
MAX_NAME_COUNT=10000
|
||||
)
|
||||
|
||||
# Force-include our platform shims into every libgit2 TU (lstat==stat, etc.).
|
||||
target_compile_options(${COMPONENT_LIB} PRIVATE
|
||||
"-include" "${CMAKE_CURRENT_LIST_DIR}/esp_port.h"
|
||||
)
|
||||
|
||||
# gcc 14 promoted these from warnings to hard errors; this vendored C (last
|
||||
# tested on gcc <14) trips them benignly (int32_t/long pointer aliasing on a
|
||||
# 32-bit target; internal iterator callback casts). Downgrade to warnings.
|
||||
# Missing POSIX functions surface as implicit-declaration warnings, then as
|
||||
# unresolved symbols at link — that's how we enumerate the shims still needed.
|
||||
target_compile_options(${COMPONENT_LIB} PRIVATE
|
||||
-Wno-error=implicit-function-declaration
|
||||
-Wno-error=incompatible-pointer-types
|
||||
-Wno-error=implicit-int
|
||||
)
|
||||
|
||||
# posix.c holds three low-level fs primitives we must replace for FATFS; compile
|
||||
# the originals under throwaway names — scoped to this ONE file so no other TU is
|
||||
# touched — and provide FATFS-correct versions in esp_stubs.c:
|
||||
# p_rename — FATFS f_rename can't replace an existing target and FAT has no
|
||||
# hardlinks, so libgit2's link-then-rename can't overwrite config/refs/HEAD/
|
||||
# index during its lock→commit. Ours does remove-then-rename.
|
||||
# p_open / p_creat — libgit2 creates objects/packs mode 0444; FATFS turns that
|
||||
# into AM_RDO and then won't f_unlink them (and chmod can't clear it), so
|
||||
# objects become undeletable (breaks re-clone/fetch/repack). Ours force
|
||||
# owner-write so nothing is ever created read-only.
|
||||
set_source_files_properties(
|
||||
"${LG2}/src/util/posix.c"
|
||||
PROPERTIES COMPILE_DEFINITIONS
|
||||
"p_rename=libgit2_unused_p_rename;p_open=libgit2_unused_p_open;p_creat=libgit2_unused_p_creat"
|
||||
)
|
||||
123
firmware/components/libgit2/esp_map.c
Normal file
123
firmware/components/libgit2/esp_map.c
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* esp_map.c — p_mmap/p_munmap for libgit2 on esp-idf (Spike 7, Path 2).
|
||||
*
|
||||
* Replaces src/util/unix/map.c, which needs <sys/mman.h> (absent on
|
||||
* picolibc/esp-idf). libgit2 uses p_mmap read-only, to view pack files and the
|
||||
* index. We emulate it by allocating a buffer and reading the range into it.
|
||||
* Allocations go through git__malloc, so with PSRAM in the heap they land in
|
||||
* the 8 MB external RAM rather than the ~340 KB internal DRAM.
|
||||
*
|
||||
* There is deliberately NO cache here. A window cache was built and removed
|
||||
* on 2026-07-12: across four instrumented real-repo bench runs it scored
|
||||
* exactly 0 hits, because libgit2's mwindow layer reuses its open windows and
|
||||
* only genuinely new (offset, len) ranges ever reach p_mmap — and the one
|
||||
* memory bug it "fixed" (7.4 MB resident starving zlib) was caused by the
|
||||
* cache itself holding buffers past p_munmap. Free-at-munmap keeps
|
||||
* GIT_OPT_SET_MWINDOW_MAPPED_LIMIT honest by construction: when libgit2
|
||||
* releases a window, the memory really is back in git__malloc's pool. Full
|
||||
* trail: docs/tradeoff-curves/sync-commit-staging.md (final-bench section).
|
||||
* The stats counters below are kept so a future workload that *does* repeat
|
||||
* ranges (push? reconcile?) can be spotted before anyone rebuilds a cache.
|
||||
*
|
||||
* Limitation: writable/shared mappings are not written back.
|
||||
*/
|
||||
|
||||
#include "git2_util.h"
|
||||
#include "map.h"
|
||||
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <stdint.h>
|
||||
|
||||
int git__page_size(size_t *page_size)
|
||||
{
|
||||
*page_size = 4096;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int git__mmap_alignment(size_t *alignment)
|
||||
{
|
||||
*alignment = 4096;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Diagnostics, read from the bench via esp_map_stats(). The signature predates
|
||||
* the cache removal: `hits` is always 0, `misses` counts every mapping, and
|
||||
* `cached_kb` now reports the LIVE mapped bytes (the mwindow working set). */
|
||||
static uint32_t g_maps;
|
||||
static uint64_t g_read_bytes;
|
||||
static size_t g_live_bytes;
|
||||
|
||||
void esp_map_stats(uint32_t *hits, uint32_t *misses, uint32_t *read_kb, uint32_t *cached_kb)
|
||||
{
|
||||
if (hits) *hits = 0;
|
||||
if (misses) *misses = g_maps;
|
||||
if (read_kb) *read_kb = (uint32_t)(g_read_bytes / 1024);
|
||||
if (cached_kb) *cached_kb = (uint32_t)(g_live_bytes / 1024);
|
||||
}
|
||||
|
||||
static int read_range(int fd, off64_t offset, size_t len, unsigned char *data)
|
||||
{
|
||||
size_t got = 0;
|
||||
|
||||
if (lseek(fd, offset, SEEK_SET) < 0) {
|
||||
git_error_set(GIT_ERROR_OS, "failed to seek for mmap emulation");
|
||||
return -1;
|
||||
}
|
||||
while (got < len) {
|
||||
ssize_t n = read(fd, data + got, len - got);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
git_error_set(GIT_ERROR_OS, "failed to read for mmap emulation");
|
||||
return -1;
|
||||
}
|
||||
if (n == 0)
|
||||
break; /* short file: zero-fill the tail, like a real mapping */
|
||||
got += (size_t)n;
|
||||
}
|
||||
if (got < len)
|
||||
memset(data + got, 0, len - got);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int p_mmap(git_map *out, size_t len, int prot, int flags, int fd, off64_t offset)
|
||||
{
|
||||
unsigned char *data;
|
||||
|
||||
GIT_UNUSED(prot);
|
||||
GIT_UNUSED(flags);
|
||||
GIT_MMAP_VALIDATE(out, len, prot, flags);
|
||||
|
||||
out->data = NULL;
|
||||
out->len = 0;
|
||||
|
||||
data = git__malloc(len);
|
||||
GIT_ERROR_CHECK_ALLOC(data);
|
||||
if (read_range(fd, offset, len, data) < 0) {
|
||||
git__free(data);
|
||||
return -1;
|
||||
}
|
||||
g_maps++;
|
||||
g_read_bytes += len;
|
||||
g_live_bytes += len;
|
||||
|
||||
out->data = data;
|
||||
out->len = len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int p_munmap(git_map *map)
|
||||
{
|
||||
GIT_ASSERT_ARG(map);
|
||||
|
||||
git__free(map->data);
|
||||
if (g_live_bytes >= map->len)
|
||||
g_live_bytes -= map->len;
|
||||
else
|
||||
g_live_bytes = 0;
|
||||
map->data = NULL;
|
||||
map->len = 0;
|
||||
return 0;
|
||||
}
|
||||
551
firmware/components/libgit2/esp_mbedtls_stream.c
Normal file
551
firmware/components/libgit2/esp_mbedtls_stream.c
Normal file
@@ -0,0 +1,551 @@
|
||||
/*
|
||||
* Copyright (C) the libgit2 contributors. All rights reserved.
|
||||
*
|
||||
* This file is part of libgit2, distributed under the GNU GPL v2 with
|
||||
* a Linking Exception. For full terms see the included COPYING file.
|
||||
*/
|
||||
|
||||
/*
|
||||
* esp_mbedtls_stream.c — verbatim copy of the vendored
|
||||
* src/libgit2/streams/mbedtls.c (v1.9.4) with ONE fix; it replaces the
|
||||
* vendored file in CMakeLists.txt (same pattern as esp_map.c).
|
||||
*
|
||||
* THE FIX (2026-07-13): mbedtls_stream_wrap()'s `out_err` path closed and
|
||||
* freed `st->io` — the caller's socket stream — but every caller frees that
|
||||
* stream on error too (git_mbedtls_stream_new does close+free right after),
|
||||
* and wrap's OTHER error paths (calloc/strdup failures) do NOT free it, so
|
||||
* the caller cannot compensate either way. When mbedtls_ssl_setup failed on
|
||||
* the device (internal-RAM exhaustion during the first real-repo push), the
|
||||
* double git__free tripped tlsf ("block already marked as free") and reset
|
||||
* the chip instead of surfacing a clean error. Delta from vendor: the
|
||||
* `out_err` label no longer touches st->io — on error, ownership of `in`
|
||||
* stays with the caller, consistently — and it frees the git__malloc'd
|
||||
* st->ssl struct the vendored path leaked.
|
||||
*
|
||||
* SECOND DELTA (2026-07-14): TLS session resumption. Every git operation
|
||||
* (push, fetch, ls-refs — and a rejected push's reconcile+retry runs three)
|
||||
* opens a fresh HTTPS connection, and a full handshake on this 160 MHz core
|
||||
* costs seconds each time. libgit2 gives the stream no connection reuse, but
|
||||
* mbedTLS can resume: cache the session after a successful handshake
|
||||
* (mbedtls_ssl_get_session at stream close) and offer it on the next connect
|
||||
* to the same host (mbedtls_ssl_set_session before the handshake) — the
|
||||
* server then skips the certificate exchange and most of the key exchange.
|
||||
* Single git thread, so plain statics like the rest of this file. Best
|
||||
* effort: any failure just falls back to a full handshake.
|
||||
*
|
||||
* Keep this file in lockstep with the vendored one on submodule bumps (diff
|
||||
* against it; the deltas must stay these two hunks).
|
||||
*/
|
||||
|
||||
#include "streams/mbedtls.h"
|
||||
|
||||
#ifdef GIT_MBEDTLS
|
||||
|
||||
#include <ctype.h>
|
||||
|
||||
#include "runtime.h"
|
||||
#include "stream.h"
|
||||
#include "streams/socket.h"
|
||||
#include "git2/transport.h"
|
||||
#include "util.h"
|
||||
|
||||
#ifndef GIT_DEFAULT_CERT_LOCATION
|
||||
#define GIT_DEFAULT_CERT_LOCATION NULL
|
||||
#endif
|
||||
|
||||
/* Work around C90-conformance issues */
|
||||
#if !defined(__STDC_VERSION__) || (__STDC_VERSION__ < 199901L)
|
||||
# if defined(_MSC_VER)
|
||||
# define inline __inline
|
||||
# elif defined(__GNUC__)
|
||||
# define inline __inline__
|
||||
# else
|
||||
# define inline
|
||||
# endif
|
||||
#endif
|
||||
|
||||
#include <mbedtls/ssl.h>
|
||||
#include <mbedtls/error.h>
|
||||
#include <mbedtls/entropy.h>
|
||||
#include <mbedtls/ctr_drbg.h>
|
||||
|
||||
#undef inline
|
||||
|
||||
#define GIT_SSL_DEFAULT_CIPHERS "TLS1-3-AES-128-GCM-SHA256:TLS1-3-AES-256-GCM-SHA384:TLS1-3-CHACHA20-POLY1305-SHA256:TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256:TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256:TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256:TLS-ECDHE-RSA-WITH-CHACHA20-POLY1305-SHA256:TLS-DHE-RSA-WITH-AES-128-GCM-SHA256:TLS-DHE-RSA-WITH-AES-256-GCM-SHA384:TLS-DHE-RSA-WITH-CHACHA20-POLY1305-SHA256:TLS-ECDHE-ECDSA-WITH-AES-128-CBC-SHA256:TLS-ECDHE-RSA-WITH-AES-128-CBC-SHA256:TLS-ECDHE-ECDSA-WITH-AES-128-CBC-SHA:TLS-ECDHE-RSA-WITH-AES-128-CBC-SHA:TLS-ECDHE-ECDSA-WITH-AES-256-CBC-SHA384:TLS-ECDHE-RSA-WITH-AES-256-CBC-SHA384:TLS-ECDHE-ECDSA-WITH-AES-256-CBC-SHA:TLS-ECDHE-RSA-WITH-AES-256-CBC-SHA:TLS-DHE-RSA-WITH-AES-128-CBC-SHA256:TLS-DHE-RSA-WITH-AES-256-CBC-SHA256:TLS-RSA-WITH-AES-128-GCM-SHA256:TLS-RSA-WITH-AES-256-GCM-SHA384:TLS-RSA-WITH-AES-128-CBC-SHA256:TLS-RSA-WITH-AES-256-CBC-SHA256:TLS-RSA-WITH-AES-128-CBC-SHA:TLS-RSA-WITH-AES-256-CBC-SHA"
|
||||
#define GIT_SSL_DEFAULT_CIPHERS_COUNT 28
|
||||
|
||||
static int ciphers_list[GIT_SSL_DEFAULT_CIPHERS_COUNT];
|
||||
|
||||
static bool initialized = false;
|
||||
static mbedtls_ssl_config mbedtls_config;
|
||||
static mbedtls_ctr_drbg_context mbedtls_rng;
|
||||
static mbedtls_entropy_context mbedtls_entropy;
|
||||
|
||||
static bool has_ca_chain = false;
|
||||
static mbedtls_x509_crt mbedtls_ca_chain;
|
||||
|
||||
/* TLS session cache for resumption (see SECOND DELTA in the header).
|
||||
* Valid only on the git thread; keyed by host so a config change can't
|
||||
* resume against the wrong server. */
|
||||
static bool session_valid = false;
|
||||
static char session_host[64];
|
||||
static mbedtls_ssl_session saved_session;
|
||||
|
||||
/**
|
||||
* This function aims to clean-up the SSL context which
|
||||
* we allocated.
|
||||
*/
|
||||
static void shutdown_ssl(void)
|
||||
{
|
||||
if (session_valid) {
|
||||
mbedtls_ssl_session_free(&saved_session);
|
||||
session_valid = false;
|
||||
}
|
||||
|
||||
if (has_ca_chain) {
|
||||
mbedtls_x509_crt_free(&mbedtls_ca_chain);
|
||||
has_ca_chain = false;
|
||||
}
|
||||
|
||||
if (initialized) {
|
||||
mbedtls_ctr_drbg_free(&mbedtls_rng);
|
||||
mbedtls_ssl_config_free(&mbedtls_config);
|
||||
mbedtls_entropy_free(&mbedtls_entropy);
|
||||
initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
int git_mbedtls_stream_global_init(void)
|
||||
{
|
||||
int loaded = 0;
|
||||
char *crtpath = GIT_DEFAULT_CERT_LOCATION;
|
||||
struct stat statbuf;
|
||||
|
||||
size_t ciphers_known = 0;
|
||||
char *cipher_name = NULL;
|
||||
char *cipher_string = NULL;
|
||||
char *cipher_string_tmp = NULL;
|
||||
|
||||
mbedtls_ssl_config_init(&mbedtls_config);
|
||||
mbedtls_entropy_init(&mbedtls_entropy);
|
||||
mbedtls_ctr_drbg_init(&mbedtls_rng);
|
||||
|
||||
if (mbedtls_ssl_config_defaults(&mbedtls_config,
|
||||
MBEDTLS_SSL_IS_CLIENT,
|
||||
MBEDTLS_SSL_TRANSPORT_STREAM,
|
||||
MBEDTLS_SSL_PRESET_DEFAULT) != 0) {
|
||||
git_error_set(GIT_ERROR_SSL, "failed to initialize mbedTLS");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
/* configure TLSv1.1 or better */
|
||||
#ifdef MBEDTLS_SSL_MINOR_VERSION_2
|
||||
mbedtls_ssl_conf_min_version(&mbedtls_config, MBEDTLS_SSL_MAJOR_VERSION_3, MBEDTLS_SSL_MINOR_VERSION_2);
|
||||
#endif
|
||||
|
||||
/* verify_server_cert is responsible for making the check.
|
||||
* OPTIONAL because REQUIRED drops the certificate as soon as the check
|
||||
* is made, so we can never see the certificate and override it. */
|
||||
mbedtls_ssl_conf_authmode(&mbedtls_config, MBEDTLS_SSL_VERIFY_OPTIONAL);
|
||||
|
||||
/* set the list of allowed ciphersuites */
|
||||
ciphers_known = 0;
|
||||
cipher_string = cipher_string_tmp = git__strdup(GIT_SSL_DEFAULT_CIPHERS);
|
||||
GIT_ERROR_CHECK_ALLOC(cipher_string);
|
||||
|
||||
while ((cipher_name = git__strtok(&cipher_string_tmp, ":")) != NULL) {
|
||||
int cipherid = mbedtls_ssl_get_ciphersuite_id(cipher_name);
|
||||
if (cipherid == 0) continue;
|
||||
|
||||
if (ciphers_known >= ARRAY_SIZE(ciphers_list)) {
|
||||
git_error_set(GIT_ERROR_SSL, "out of cipher list space");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
ciphers_list[ciphers_known++] = cipherid;
|
||||
}
|
||||
git__free(cipher_string);
|
||||
|
||||
if (!ciphers_known) {
|
||||
git_error_set(GIT_ERROR_SSL, "no cipher could be enabled");
|
||||
goto cleanup;
|
||||
}
|
||||
mbedtls_ssl_conf_ciphersuites(&mbedtls_config, ciphers_list);
|
||||
|
||||
/* Seeding the random number generator */
|
||||
|
||||
if (mbedtls_ctr_drbg_seed(&mbedtls_rng, mbedtls_entropy_func,
|
||||
&mbedtls_entropy, NULL, 0) != 0) {
|
||||
git_error_set(GIT_ERROR_SSL, "failed to initialize mbedTLS entropy pool");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
mbedtls_ssl_conf_rng(&mbedtls_config, mbedtls_ctr_drbg_random, &mbedtls_rng);
|
||||
|
||||
/* load default certificates */
|
||||
if (crtpath != NULL && stat(crtpath, &statbuf) == 0 && S_ISREG(statbuf.st_mode))
|
||||
loaded = (git_mbedtls__set_cert_location(crtpath, NULL) == 0);
|
||||
|
||||
if (!loaded && crtpath != NULL && stat(crtpath, &statbuf) == 0 && S_ISDIR(statbuf.st_mode))
|
||||
loaded = (git_mbedtls__set_cert_location(NULL, crtpath) == 0);
|
||||
|
||||
initialized = true;
|
||||
|
||||
return git_runtime_shutdown_register(shutdown_ssl);
|
||||
|
||||
cleanup:
|
||||
mbedtls_ctr_drbg_free(&mbedtls_rng);
|
||||
mbedtls_ssl_config_free(&mbedtls_config);
|
||||
mbedtls_entropy_free(&mbedtls_entropy);
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int bio_read(void *b, unsigned char *buf, size_t len)
|
||||
{
|
||||
git_stream *io = (git_stream *) b;
|
||||
return (int) git_stream_read(io, buf, min(len, INT_MAX));
|
||||
}
|
||||
|
||||
static int bio_write(void *b, const unsigned char *buf, size_t len)
|
||||
{
|
||||
git_stream *io = (git_stream *) b;
|
||||
return (int) git_stream_write(io, (const char *)buf, min(len, INT_MAX), 0);
|
||||
}
|
||||
|
||||
static int ssl_set_error(mbedtls_ssl_context *ssl, int error)
|
||||
{
|
||||
char errbuf[512];
|
||||
int ret = -1;
|
||||
|
||||
GIT_ASSERT(error != MBEDTLS_ERR_SSL_WANT_READ);
|
||||
GIT_ASSERT(error != MBEDTLS_ERR_SSL_WANT_WRITE);
|
||||
|
||||
if (error != 0)
|
||||
mbedtls_strerror( error, errbuf, 512 );
|
||||
|
||||
switch(error) {
|
||||
case 0:
|
||||
git_error_set(GIT_ERROR_SSL, "SSL error: unknown error");
|
||||
break;
|
||||
|
||||
case MBEDTLS_ERR_X509_CERT_VERIFY_FAILED:
|
||||
git_error_set(GIT_ERROR_SSL, "SSL error: %#04x [%x] - %s", error, mbedtls_ssl_get_verify_result(ssl), errbuf);
|
||||
ret = GIT_ECERTIFICATE;
|
||||
break;
|
||||
|
||||
default:
|
||||
git_error_set(GIT_ERROR_SSL, "SSL error: %#04x - %s", error, errbuf);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int ssl_teardown(mbedtls_ssl_context *ssl)
|
||||
{
|
||||
int ret = 0;
|
||||
|
||||
ret = mbedtls_ssl_close_notify(ssl);
|
||||
if (ret < 0)
|
||||
ret = ssl_set_error(ssl, ret);
|
||||
|
||||
mbedtls_ssl_free(ssl);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int verify_server_cert(mbedtls_ssl_context *ssl)
|
||||
{
|
||||
int ret = -1;
|
||||
|
||||
if ((ret = mbedtls_ssl_get_verify_result(ssl)) != 0) {
|
||||
char vrfy_buf[512];
|
||||
int len = mbedtls_x509_crt_verify_info(vrfy_buf, sizeof(vrfy_buf), "", ret);
|
||||
if (len >= 1) vrfy_buf[len - 1] = '\0'; /* Remove trailing \n */
|
||||
git_error_set(GIT_ERROR_SSL, "the SSL certificate is invalid: %#04x - %s", ret, vrfy_buf);
|
||||
return GIT_ECERTIFICATE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
git_stream parent;
|
||||
git_stream *io;
|
||||
int owned;
|
||||
bool connected;
|
||||
char *host;
|
||||
mbedtls_ssl_context *ssl;
|
||||
git_cert_x509 cert_info;
|
||||
} mbedtls_stream;
|
||||
|
||||
|
||||
static int mbedtls_connect(git_stream *stream)
|
||||
{
|
||||
int ret;
|
||||
mbedtls_stream *st = (mbedtls_stream *) stream;
|
||||
|
||||
if (st->owned && (ret = git_stream_connect(st->io)) < 0)
|
||||
return ret;
|
||||
|
||||
st->connected = true;
|
||||
|
||||
mbedtls_ssl_set_hostname(st->ssl, st->host);
|
||||
|
||||
mbedtls_ssl_set_bio(st->ssl, st->io, bio_write, bio_read, NULL);
|
||||
|
||||
/* Offer the cached session for an abbreviated handshake (best effort —
|
||||
* a refusal by either side just runs the full handshake). */
|
||||
if (session_valid && st->host && strcmp(session_host, st->host) == 0)
|
||||
(void)mbedtls_ssl_set_session(st->ssl, &saved_session);
|
||||
|
||||
if ((ret = mbedtls_ssl_handshake(st->ssl)) != 0)
|
||||
return ssl_set_error(st->ssl, ret);
|
||||
|
||||
return verify_server_cert(st->ssl);
|
||||
}
|
||||
|
||||
/* Snapshot the (possibly ticket-refreshed) session for the next connect.
|
||||
* Called at stream close — by then any TLS 1.3 NewSessionTicket sent after
|
||||
* the handshake has been processed by the reads. */
|
||||
static void save_session(mbedtls_stream *st)
|
||||
{
|
||||
mbedtls_ssl_session fresh;
|
||||
|
||||
if (!st->connected || !st->host || strlen(st->host) >= sizeof(session_host))
|
||||
return;
|
||||
|
||||
mbedtls_ssl_session_init(&fresh);
|
||||
if (mbedtls_ssl_get_session(st->ssl, &fresh) != 0) {
|
||||
mbedtls_ssl_session_free(&fresh);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session_valid)
|
||||
mbedtls_ssl_session_free(&saved_session);
|
||||
saved_session = fresh; /* shallow move — ownership transfers */
|
||||
session_valid = true;
|
||||
strcpy(session_host, st->host);
|
||||
}
|
||||
|
||||
static int mbedtls_certificate(git_cert **out, git_stream *stream)
|
||||
{
|
||||
unsigned char *encoded_cert;
|
||||
mbedtls_stream *st = (mbedtls_stream *) stream;
|
||||
|
||||
const mbedtls_x509_crt *cert = mbedtls_ssl_get_peer_cert(st->ssl);
|
||||
if (!cert) {
|
||||
git_error_set(GIT_ERROR_SSL, "the server did not provide a certificate");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Retrieve the length of the certificate first */
|
||||
if (cert->raw.len == 0) {
|
||||
git_error_set(GIT_ERROR_NET, "failed to retrieve certificate information");
|
||||
return -1;
|
||||
}
|
||||
|
||||
encoded_cert = git__malloc(cert->raw.len);
|
||||
GIT_ERROR_CHECK_ALLOC(encoded_cert);
|
||||
memcpy(encoded_cert, cert->raw.p, cert->raw.len);
|
||||
|
||||
st->cert_info.parent.cert_type = GIT_CERT_X509;
|
||||
st->cert_info.data = encoded_cert;
|
||||
st->cert_info.len = cert->raw.len;
|
||||
|
||||
*out = &st->cert_info.parent;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int mbedtls_set_proxy(git_stream *stream, const git_proxy_options *proxy_options)
|
||||
{
|
||||
mbedtls_stream *st = (mbedtls_stream *) stream;
|
||||
|
||||
return git_stream_set_proxy(st->io, proxy_options);
|
||||
}
|
||||
|
||||
static ssize_t mbedtls_stream_write(git_stream *stream, const char *data, size_t len, int flags)
|
||||
{
|
||||
mbedtls_stream *st = (mbedtls_stream *) stream;
|
||||
int written;
|
||||
|
||||
GIT_UNUSED(flags);
|
||||
|
||||
/*
|
||||
* `mbedtls_ssl_write` can only represent INT_MAX bytes
|
||||
* written via its return value. We thus need to clamp
|
||||
* the maximum number of bytes written.
|
||||
*/
|
||||
len = min(len, INT_MAX);
|
||||
|
||||
if ((written = mbedtls_ssl_write(st->ssl, (const unsigned char *)data, len)) <= 0)
|
||||
return ssl_set_error(st->ssl, written);
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
static ssize_t mbedtls_stream_read(git_stream *stream, void *data, size_t len)
|
||||
{
|
||||
mbedtls_stream *st = (mbedtls_stream *) stream;
|
||||
int ret;
|
||||
|
||||
if ((ret = mbedtls_ssl_read(st->ssl, (unsigned char *)data, len)) <= 0)
|
||||
ssl_set_error(st->ssl, ret);
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int mbedtls_stream_close(git_stream *stream)
|
||||
{
|
||||
mbedtls_stream *st = (mbedtls_stream *) stream;
|
||||
int ret = 0;
|
||||
|
||||
save_session(st);
|
||||
|
||||
if (st->connected && (ret = ssl_teardown(st->ssl)) != 0)
|
||||
return -1;
|
||||
|
||||
st->connected = false;
|
||||
|
||||
return st->owned ? git_stream_close(st->io) : 0;
|
||||
}
|
||||
|
||||
static void mbedtls_stream_free(git_stream *stream)
|
||||
{
|
||||
mbedtls_stream *st = (mbedtls_stream *) stream;
|
||||
|
||||
if (st->owned)
|
||||
git_stream_free(st->io);
|
||||
|
||||
git__free(st->host);
|
||||
git__free(st->cert_info.data);
|
||||
mbedtls_ssl_free(st->ssl);
|
||||
git__free(st->ssl);
|
||||
git__free(st);
|
||||
}
|
||||
|
||||
static int mbedtls_stream_wrap(
|
||||
git_stream **out,
|
||||
git_stream *in,
|
||||
const char *host,
|
||||
int owned)
|
||||
{
|
||||
mbedtls_stream *st;
|
||||
int error;
|
||||
|
||||
st = git__calloc(1, sizeof(mbedtls_stream));
|
||||
GIT_ERROR_CHECK_ALLOC(st);
|
||||
|
||||
st->io = in;
|
||||
st->owned = owned;
|
||||
|
||||
st->ssl = git__malloc(sizeof(mbedtls_ssl_context));
|
||||
GIT_ERROR_CHECK_ALLOC(st->ssl);
|
||||
mbedtls_ssl_init(st->ssl);
|
||||
if (mbedtls_ssl_setup(st->ssl, &mbedtls_config)) {
|
||||
git_error_set(GIT_ERROR_SSL, "failed to create ssl object");
|
||||
error = -1;
|
||||
goto out_err;
|
||||
}
|
||||
|
||||
st->host = git__strdup(host);
|
||||
GIT_ERROR_CHECK_ALLOC(st->host);
|
||||
|
||||
st->parent.version = GIT_STREAM_VERSION;
|
||||
st->parent.encrypted = 1;
|
||||
st->parent.proxy_support = git_stream_supports_proxy(st->io);
|
||||
st->parent.connect = mbedtls_connect;
|
||||
st->parent.certificate = mbedtls_certificate;
|
||||
st->parent.set_proxy = mbedtls_set_proxy;
|
||||
st->parent.read = mbedtls_stream_read;
|
||||
st->parent.write = mbedtls_stream_write;
|
||||
st->parent.close = mbedtls_stream_close;
|
||||
st->parent.free = mbedtls_stream_free;
|
||||
|
||||
*out = (git_stream *) st;
|
||||
return 0;
|
||||
|
||||
out_err:
|
||||
/* ESP FIX: do NOT close/free st->io here — on error the caller keeps
|
||||
* ownership of `in` (git_mbedtls_stream_new closes+frees it right after;
|
||||
* the vendored code freed it here too → double free → tlsf abort). */
|
||||
mbedtls_ssl_free(st->ssl);
|
||||
git__free(st->ssl);
|
||||
git__free(st);
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
int git_mbedtls_stream_wrap(
|
||||
git_stream **out,
|
||||
git_stream *in,
|
||||
const char *host)
|
||||
{
|
||||
return mbedtls_stream_wrap(out, in, host, 0);
|
||||
}
|
||||
|
||||
int git_mbedtls_stream_new(
|
||||
git_stream **out,
|
||||
const char *host,
|
||||
const char *port)
|
||||
{
|
||||
git_stream *stream;
|
||||
int error;
|
||||
|
||||
GIT_ASSERT_ARG(out);
|
||||
GIT_ASSERT_ARG(host);
|
||||
GIT_ASSERT_ARG(port);
|
||||
|
||||
if ((error = git_socket_stream_new(&stream, host, port)) < 0)
|
||||
return error;
|
||||
|
||||
if ((error = mbedtls_stream_wrap(out, stream, host, 1)) < 0) {
|
||||
git_stream_close(stream);
|
||||
git_stream_free(stream);
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
int git_mbedtls__set_cert_location(const char *file, const char *path)
|
||||
{
|
||||
int ret = 0;
|
||||
char errbuf[512];
|
||||
|
||||
GIT_ASSERT_ARG(file || path);
|
||||
|
||||
if (has_ca_chain)
|
||||
mbedtls_x509_crt_free(&mbedtls_ca_chain);
|
||||
|
||||
mbedtls_x509_crt_init(&mbedtls_ca_chain);
|
||||
|
||||
if (file)
|
||||
ret = mbedtls_x509_crt_parse_file(&mbedtls_ca_chain, file);
|
||||
|
||||
if (ret >= 0 && path)
|
||||
ret = mbedtls_x509_crt_parse_path(&mbedtls_ca_chain, path);
|
||||
|
||||
/* mbedtls_x509_crt_parse_path returns the number of invalid certs on success */
|
||||
if (ret < 0) {
|
||||
mbedtls_x509_crt_free(&mbedtls_ca_chain);
|
||||
mbedtls_strerror( ret, errbuf, 512 );
|
||||
git_error_set(GIT_ERROR_SSL, "failed to load CA certificates: %#04x - %s", ret, errbuf);
|
||||
return -1;
|
||||
}
|
||||
|
||||
mbedtls_ssl_conf_ca_chain(&mbedtls_config, &mbedtls_ca_chain, NULL);
|
||||
has_ca_chain = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
#include "stream.h"
|
||||
|
||||
int git_mbedtls_stream_global_init(void)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
20
firmware/components/libgit2/esp_port.h
Normal file
20
firmware/components/libgit2/esp_port.h
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* esp_port.h — platform shims for building libgit2 on esp-idf, force-included
|
||||
* into every libgit2 TU via `-include` (Spike 7, Path 2).
|
||||
*
|
||||
* esp-idf's C library is picolibc and its filesystem layer is the VFS, which
|
||||
* has no symlink concept. So POSIX calls libgit2 assumes on "unix" are either
|
||||
* absent or degenerate here. Shimming them in a force-included header keeps
|
||||
* libgit2's own sources untouched (important: we don't want to fork 1.9.4).
|
||||
*/
|
||||
#ifndef LIBGIT2_ESPIDF_PORT_H
|
||||
#define LIBGIT2_ESPIDF_PORT_H
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
/* No symlinks on FAT/LittleFS: lstat is just stat. */
|
||||
#ifndef lstat
|
||||
#define lstat stat
|
||||
#endif
|
||||
|
||||
#endif
|
||||
146
firmware/components/libgit2/esp_stubs.c
Normal file
146
firmware/components/libgit2/esp_stubs.c
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* esp_stubs.c — POSIX identity/symlink stubs for libgit2 on esp-idf
|
||||
* (Spike 7, Path 2).
|
||||
*
|
||||
* picolibc *declares* these in <unistd.h>/<pwd.h> but does not implement them:
|
||||
* esp-idf has no users, groups, processes, or symlinks. libgit2 calls them
|
||||
* while resolving config paths, ownership checks, and temp names. We provide
|
||||
* definitions that model "a single root user, no user database, no symlinks",
|
||||
* which is the truthful shape of the device's flat filesystem.
|
||||
*/
|
||||
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
#include <pwd.h>
|
||||
#include <errno.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/stat.h> /* stat() + S_IWUSR for utimes()/p_open() below */
|
||||
#include <stdio.h> /* remove(), rename() for the p_rename replacement */
|
||||
#include <fcntl.h> /* open(), O_* flags for the p_open()/p_creat() shims */
|
||||
#include <stdarg.h> /* va_list for the variadic p_open() */
|
||||
|
||||
#ifndef O_BINARY
|
||||
#define O_BINARY 0 /* no text/binary distinction on esp-idf */
|
||||
#endif
|
||||
#ifndef O_CLOEXEC
|
||||
#define O_CLOEXEC 0 /* no exec() on esp-idf, so close-on-exec is a no-op */
|
||||
#endif
|
||||
|
||||
/* One implicit root user/group. */
|
||||
uid_t getuid(void) { return 0; }
|
||||
uid_t geteuid(void) { return 0; }
|
||||
gid_t getgid(void) { return 0; }
|
||||
gid_t getegid(void) { return 0; }
|
||||
|
||||
/* No process hierarchy. */
|
||||
pid_t getppid(void) { return 0; }
|
||||
pid_t getpgid(pid_t pid) { (void)pid; return 0; }
|
||||
pid_t getsid(pid_t pid) { (void)pid; return 0; }
|
||||
|
||||
/* No user database: report "no such user" so libgit2 falls back to $HOME. */
|
||||
int getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen,
|
||||
struct passwd **result)
|
||||
{
|
||||
(void)uid;
|
||||
(void)pwd;
|
||||
(void)buf;
|
||||
(void)buflen;
|
||||
*result = NULL;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* No symlinks on FAT/LittleFS: nothing is ever a symbolic link. */
|
||||
ssize_t readlink(const char *path, char *buf, size_t bufsiz)
|
||||
{
|
||||
(void)path;
|
||||
(void)buf;
|
||||
(void)bufsiz;
|
||||
errno = EINVAL; /* POSIX: EINVAL == "named file is not a symbolic link" */
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* No symlinks on FAT: creating one is unsupported. libgit2 calls this only in
|
||||
* its "does this filesystem support symlinks?" probe (fs_path.c) and while
|
||||
* copying trees (futils.c) — a clean failure is the honest answer, and libgit2
|
||||
* treats the target as a normal file. (git_push referenced it; git_smoke, which
|
||||
* only touched the ODB, did not — hence it surfaces now.) */
|
||||
int symlink(const char *target, const char *linkpath)
|
||||
{
|
||||
(void)target;
|
||||
(void)linkpath;
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* FATFS/VFS can't set file times — but utimes() MUST still fail for a path that
|
||||
* doesn't exist. libgit2's git_futils_touch() is p_utimes(), and that is how the
|
||||
* loose ODB's `freshen` probe answers "does this object already exist?":
|
||||
* git_odb_write() SKIPS the write entirely when freshen succeeds (odb.c:1629).
|
||||
* A blanket `return 0` made every freshen succeed, so libgit2 believed every
|
||||
* object was already on disk and silently dropped ALL loose-object writes —
|
||||
* blobs/trees/commits never persisted, and write_tree then failed with
|
||||
* "invalid object specified". Gate success on existence: present → 0 (actually
|
||||
* setting the time is a cosmetic no-op we skip), absent → -1/ENOENT so freshen
|
||||
* reports "not found" and the real write proceeds. */
|
||||
int utimes(const char *path, const struct timeval times[2])
|
||||
{
|
||||
struct stat st;
|
||||
(void)times;
|
||||
if (stat(path, &st) != 0)
|
||||
return -1; /* stat set errno (ENOENT for a missing object) */
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* lwip implements getaddrinfo() but not gai_strerror(); libgit2's socket stream
|
||||
* (streams/socket.c) uses it only to format a connect-error message, so a
|
||||
* constant string is sufficient. Deliberately no <netdb.h> include: the symbol
|
||||
* is undefined at link (no macro/decl shadows it), so a plain definition is
|
||||
* safe, and the ABI (pointer vs. implicit-int return) matches on xtensa. */
|
||||
const char *gai_strerror(int ecode)
|
||||
{
|
||||
(void)ecode;
|
||||
return "getaddrinfo failure";
|
||||
}
|
||||
|
||||
/* POSIX rename() atomically replaces an existing target; FATFS f_rename does
|
||||
* NOT (it fails with EEXIST) and FAT has no hardlinks, so libgit2's own
|
||||
* p_rename (link-then-rename, in posix.c) can't overwrite config/refs/HEAD/index
|
||||
* during their lock→commit. Provide replace semantics: drop the target, then
|
||||
* rename. Not crash-atomic (a crash between the two loses `to`), but FAT offers
|
||||
* no atomic replace — acceptable for the working copy. posix.c's original is
|
||||
* compiled as libgit2_unused_p_rename (see the component CMakeLists), so this is
|
||||
* the p_rename every caller links against. */
|
||||
int p_rename(const char *from, const char *to)
|
||||
{
|
||||
(void)remove(to); /* ignore ENOENT when `to` doesn't exist yet */
|
||||
return rename(from, to) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
/* libgit2 creates loose objects and packfiles with mode 0444 (read-only) — the
|
||||
* git convention that objects are immutable. FATFS honours that as the AM_RDO
|
||||
* attribute and then refuses to f_unlink the file (EACCES), and esp-idf's FATFS
|
||||
* VFS chmod() can't clear AM_RDO — so a written object can NEVER be deleted,
|
||||
* which breaks re-clone recovery and (later) fetch/repack. We force owner-write
|
||||
* into every create mode so libgit2's files stay writable and therefore
|
||||
* deletable. Immutability is only a safety hint on an appliance where nothing
|
||||
* but libgit2 touches these files. posix.c's originals are compiled as
|
||||
* libgit2_unused_p_open/p_creat (see the component CMakeLists), so these are the
|
||||
* definitions every other TU links against. Mirrors posix.c's p_open/p_creat. */
|
||||
int p_open(const char *path, int flags, ...)
|
||||
{
|
||||
mode_t mode = 0;
|
||||
if (flags & O_CREAT) {
|
||||
va_list arg_list;
|
||||
va_start(arg_list, flags);
|
||||
mode = (mode_t)va_arg(arg_list, int);
|
||||
va_end(arg_list);
|
||||
mode |= S_IWUSR; /* never create read-only → FATFS won't set AM_RDO */
|
||||
}
|
||||
return open(path, flags | O_BINARY | O_CLOEXEC, mode);
|
||||
}
|
||||
|
||||
int p_creat(const char *path, mode_t mode)
|
||||
{
|
||||
return open(path, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY | O_CLOEXEC,
|
||||
mode | S_IWUSR);
|
||||
}
|
||||
37
firmware/components/libgit2/git2_features.h
Normal file
37
firmware/components/libgit2/git2_features.h
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* git2_features.h — hand-written feature config for building libgit2 as an
|
||||
* ESP-IDF component (Spike 7, Path 2).
|
||||
*
|
||||
* libgit2's CMake normally generates this from git2_features.h.in. We write it
|
||||
* by hand so the build is driven by ESP-IDF's component CMake instead. The one
|
||||
* substantive choice vs. the libgit2-sys vendored build: TLS + hashing use
|
||||
* *mbedTLS* (which ESP-IDF already ships and uses for its own TLS), not
|
||||
* OpenSSL/SecureTransport. That backend exists in the C library but the
|
||||
* libgit2-sys Rust wrapper never exposes it — the reason for Path 2.
|
||||
*/
|
||||
#ifndef INCLUDE_features_h__
|
||||
#define INCLUDE_features_h__
|
||||
|
||||
#define GIT_THREADS 1
|
||||
#define GIT_TRACE 1
|
||||
#define GIT_ARCH_32 1
|
||||
|
||||
/* Bundled backends: no system deps to hunt for on the device. */
|
||||
#define GIT_REGEX_BUILTIN 1
|
||||
#define GIT_HTTPPARSER_BUILTIN 1
|
||||
#define GIT_COMPRESSION_BUILTIN 1
|
||||
|
||||
/* TLS via ESP-IDF's mbedTLS (streams/mbedtls.c). */
|
||||
#define GIT_HTTPS 1
|
||||
#define GIT_MBEDTLS 1
|
||||
|
||||
/* Hashing via mbedTLS too (util/hash/mbedtls.c) — one crypto backend, and it
|
||||
* lets us skip the sha1dc collision-detection sources. */
|
||||
#define GIT_SHA1_MBEDTLS 1
|
||||
#define GIT_SHA256_MBEDTLS 1
|
||||
|
||||
/* Socket readiness via poll()/select() (both provided by lwip + vfs). */
|
||||
#define GIT_IO_POLL 1
|
||||
#define GIT_IO_SELECT 1
|
||||
|
||||
#endif
|
||||
12
firmware/components/libgit2/poll.h
Normal file
12
firmware/components/libgit2/poll.h
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* poll.h shim for the libgit2 esp-idf component (Spike 7, Path 2).
|
||||
*
|
||||
* libgit2's posix.h does `#include <poll.h>` when GIT_IO_POLL is set, but
|
||||
* esp-idf's newlib ships poll() under <sys/poll.h> only — there is no
|
||||
* top-level <poll.h>. This forwarding header (on the component's private
|
||||
* include path) bridges the gap without touching libgit2's sources.
|
||||
*/
|
||||
#ifndef LIBGIT2_ESPIDF_POLL_SHIM_H
|
||||
#define LIBGIT2_ESPIDF_POLL_SHIM_H
|
||||
#include <sys/poll.h>
|
||||
#endif
|
||||
1
firmware/components/libgit2/vendor
Submodule
1
firmware/components/libgit2/vendor
Submodule
Submodule firmware/components/libgit2/vendor added at f7164261c9
BIN
firmware/docs/sd-card.jpg
Normal file
BIN
firmware/docs/sd-card.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -9,19 +9,55 @@ set dotenv-load := true
|
||||
esp_env := ". ~/export-esp.sh &&"
|
||||
elf := "target/xtensa-esp32s3-espidf/release/firmware"
|
||||
elf_wifi := "target/xtensa-esp32s3-espidf/release/wifi_tls"
|
||||
elf_sd := "target/xtensa-esp32s3-espidf/release/sd_fat"
|
||||
elf_splash := "target/xtensa-esp32s3-espidf/release/splash"
|
||||
elf_git := "target/xtensa-esp32s3-espidf/release/git_smoke"
|
||||
elf_git_push := "target/xtensa-esp32s3-espidf/release/git_push"
|
||||
elf_git_sync := "target/xtensa-esp32s3-espidf/release/git_sync"
|
||||
|
||||
# Custom partition table (adds the `storage` FAT partition for the git working
|
||||
# copy). Only the git-push flash applies it — the editor flash keeps the default
|
||||
# single-app layout, so this change is scoped to Spike 7's finish.
|
||||
partition_table := justfile_directory() + "/partitions.csv"
|
||||
|
||||
# Spike 7 Path 2 — env for the git2/libgit2 build. LIBGIT2_SRC points at the
|
||||
# vendored libgit2 submodule (v1.9.4, matches libgit2-sys 0.18.5). pkgconfig/
|
||||
# holds fake .pc files so libgit2-sys + libz-sys run in system mode and the
|
||||
# symbols come from the esp-idf libgit2 component. Only the git recipes set this,
|
||||
# so the editor build never compiles libgit2.
|
||||
libgit2_src := justfile_directory() + "/components/libgit2/vendor"
|
||||
git_env := "LIBGIT2_SRC=" + libgit2_src + " LIBGIT2_NO_VENDOR=1 PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_LIBDIR=" + justfile_directory() + "/pkgconfig"
|
||||
|
||||
# list recipes
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# compile (release)
|
||||
# compile (release) — the nominal product build: full firmware WITH git
|
||||
# publishing (compiles libgit2 + mbedTLS, links git2). For fast iteration on the
|
||||
# editor / EPD / USB / SD without paying for libgit2, use `build-light`.
|
||||
build:
|
||||
{{esp_env}} {{git_env}} cargo build --release --bin firmware --features git
|
||||
|
||||
# build + flash + open serial monitor (full firmware, see `build`)
|
||||
flash:
|
||||
{{esp_env}} {{git_env}} cargo run --release --bin firmware --features git
|
||||
|
||||
# LIGHT editor build — no git: no libgit2 component (LIBGIT2_SRC unset) and no
|
||||
# git2 crate (`git` feature off), so it builds much faster. `:sync` saves locally
|
||||
# and skips the push. Use for anything but exercising publish.
|
||||
build-light:
|
||||
{{esp_env}} cargo build --release --bin firmware
|
||||
|
||||
# build + flash + open serial monitor
|
||||
flash:
|
||||
# light build + flash + open serial monitor (see `build-light`)
|
||||
flash-light:
|
||||
{{esp_env}} cargo run --release --bin firmware
|
||||
|
||||
# flash + monitor the ALREADY-BUILT firmware — no cargo, no rebuild. Flashes
|
||||
# whatever `build`/`build-light` last produced (both write the same ELF).
|
||||
# Same espflash flags as the cargo runner in .cargo/config.toml.
|
||||
flash-only:
|
||||
espflash flash --monitor --baud 921600 {{elf}}
|
||||
|
||||
# serial monitor only, with decoded backtraces
|
||||
monitor:
|
||||
espflash monitor --elf {{elf}}
|
||||
@@ -38,6 +74,95 @@ flash-wifi:
|
||||
monitor-wifi:
|
||||
espflash monitor --elf {{elf_wifi}}
|
||||
|
||||
# Spike 3 — build the SD/FAT spike (no .env needed)
|
||||
build-sd:
|
||||
{{esp_env}} cargo build --release --bin sd_fat
|
||||
|
||||
# Spike 3 — flash + monitor the SD/FAT spike
|
||||
flash-sd:
|
||||
{{esp_env}} cargo run --release --bin sd_fat
|
||||
|
||||
# serial monitor for the SD spike, with decoded backtraces
|
||||
monitor-sd:
|
||||
espflash monitor --elf {{elf_sd}}
|
||||
|
||||
# SD primitive-op micro-benchmark — attributes the ~700 ms/loose-object write
|
||||
# floor (docs/tradeoff-curves/sync-commit-staging.md). Writes only to /sd/sdbench.
|
||||
build-bench:
|
||||
{{esp_env}} cargo build --release --bin sd_bench
|
||||
|
||||
# build + flash + monitor the SD bench
|
||||
flash-bench:
|
||||
{{esp_env}} cargo run --release --bin sd_bench
|
||||
|
||||
# serial monitor for the SD bench, with decoded backtraces
|
||||
monitor-bench:
|
||||
espflash monitor --elf target/xtensa-esp32s3-espidf/release/sd_bench
|
||||
|
||||
# git-level micro-benchmark — localizes the ~700 ms/object libgit2 overhead
|
||||
# (docs/tradeoff-curves/sync-commit-staging.md). Read-mostly on /sd/repo.
|
||||
build-gitbench:
|
||||
{{esp_env}} {{git_env}} cargo build --release --bin git_bench --features git
|
||||
|
||||
# build + flash + monitor the git-level bench
|
||||
flash-gitbench:
|
||||
{{esp_env}} {{git_env}} cargo run --release --bin git_bench --features git
|
||||
|
||||
# serial monitor for the git-level bench, with decoded backtraces
|
||||
monitor-gitbench:
|
||||
espflash monitor --elf target/xtensa-esp32s3-espidf/release/git_bench
|
||||
|
||||
# Spike 9 — build the boot splash spike (no .env needed)
|
||||
build-splash:
|
||||
{{esp_env}} cargo build --release --bin splash
|
||||
|
||||
# Spike 9 — flash + monitor the boot splash spike
|
||||
flash-splash:
|
||||
{{esp_env}} cargo run --release --bin splash
|
||||
|
||||
# serial monitor for the splash spike, with decoded backtraces
|
||||
monitor-splash:
|
||||
espflash monitor --elf {{elf_splash}}
|
||||
|
||||
# Spike 7 Path 2 — build the git2/libgit2 smoke (git2 safe API on device)
|
||||
build-git:
|
||||
{{esp_env}} {{git_env}} cargo build --release --bin git_smoke --features git
|
||||
|
||||
# Spike 7 Path 2 — flash + monitor the git2/libgit2 smoke
|
||||
flash-git:
|
||||
{{esp_env}} {{git_env}} cargo run --release --bin git_smoke --features git
|
||||
|
||||
# serial monitor for the git smoke, with decoded backtraces
|
||||
monitor-git:
|
||||
espflash monitor --elf {{elf_git}}
|
||||
|
||||
# Spike 7 finish — build the on-device git push (Wi-Fi + SNTP + flash-FAT + libgit2)
|
||||
build-git-push:
|
||||
{{esp_env}} {{git_env}} cargo build --release --bin git_push --features git
|
||||
|
||||
# Spike 7 finish — flash + monitor. espflash applies the custom partition table
|
||||
# (so the `storage` FAT partition exists) and sets 16 MB flash. Uses espflash
|
||||
# directly, not `cargo run`, so the table is applied only to this binary.
|
||||
flash-git-push: build-git-push
|
||||
espflash flash --monitor --baud 921600 --partition-table {{partition_table}} --flash-size 16mb {{elf_git_push}}
|
||||
|
||||
# serial monitor for the git push, with decoded backtraces
|
||||
monitor-git-push:
|
||||
espflash monitor --elf {{elf_git_push}}
|
||||
|
||||
# Milestone #2A — build the persistent-clone publish cycle (clone + fast-forward push)
|
||||
build-git-sync:
|
||||
{{esp_env}} {{git_env}} cargo build --release --bin git_sync --features git
|
||||
|
||||
# Milestone #2A — flash + monitor. Applies the same custom partition table as
|
||||
# git-push (the `storage` FAT partition holds the persistent clone).
|
||||
flash-git-sync: build-git-sync
|
||||
espflash flash --monitor --baud 921600 --partition-table {{partition_table}} --flash-size 16mb {{elf_git_sync}}
|
||||
|
||||
# serial monitor for the git sync, with decoded backtraces
|
||||
monitor-git-sync:
|
||||
espflash monitor --elf {{elf_git_sync}}
|
||||
|
||||
# detect board, print chip/MAC/flash size
|
||||
info:
|
||||
espflash board-info
|
||||
@@ -45,3 +170,468 @@ info:
|
||||
# list USB serial devices
|
||||
ports:
|
||||
@ls /dev/cu.usb* 2>/dev/null || echo "no USB serial device found"
|
||||
|
||||
# ─── SD card provisioning ────────────────────────────────────────────────────
|
||||
# Prepare an SD card on a computer so it can go straight into Typoena (the
|
||||
# decision in docs/notes/git-sync-images-and-repo-size.md): the device never
|
||||
# cold-clones 566 MB over Wi-Fi + mbedTLS; a laptop copies the clone onto the
|
||||
# card via a reader, and the device only ever takes the `open` + fast-forward
|
||||
# path. Three entry points, each ejecting the card when done:
|
||||
#
|
||||
# just init ~/code/notes # full prep of a fresh card: repo + config
|
||||
# just load ~/code/notes # (re)copy just the notes repo → /sd/repo
|
||||
# just provision # (re)write just the config (derive + prompt)
|
||||
#
|
||||
# Config (Wi-Fi + PAT + git identity) needs no firmware/.env: each value is
|
||||
# resolved from firmware/.env if present, else derived from tools the machine
|
||||
# already has (git config, gh, the active Wi-Fi network + its Keychain
|
||||
# password), else asked for at an interactive prompt with the derived value as
|
||||
# the default. The PAT is always typed by hand — never derived (a broad
|
||||
# `gh auth token` on a plaintext card would defeat the scoped-token model).
|
||||
#
|
||||
# Add a /Volumes/<name> as the last arg if more than one card is mounted.
|
||||
# The `_`-prefixed recipes are shared internals (hidden from `just --list`).
|
||||
sd_repo_dir := "repo"
|
||||
|
||||
# Full prep of a fresh card: copy the notes repo, seed the git-tracked config
|
||||
# files (first-time only), write the runtime config, then eject. Run this once
|
||||
# per card. <repo-src> is a clone made on this computer.
|
||||
init repo_src sd_volume="":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
vol="$(just _card "{{sd_volume}}" | tail -n1)"
|
||||
just _load-repo "{{repo_src}}" "$vol"
|
||||
just _seed-configs "$vol"
|
||||
just _write-conf "$vol" "{{repo_src}}"
|
||||
just _eject "$vol"
|
||||
|
||||
# (Re)copy just the notes repo to /sd/repo: fast-forward the source clone from
|
||||
# its remote first, repack it to a single pack if it has grown several (the
|
||||
# device's push needs the one-pack layout — see _load-repo), then rsync it
|
||||
# across (full clone, gitignored paths excluded) and eject — e.g. to refresh
|
||||
# the card after the device pushed new notes.
|
||||
# Refuses if the card's .typoena-dirty journal lists unpublished device edits
|
||||
# (:sync from the device first, or TW_DISCARD_UNPUBLISHED=1 to back up+discard).
|
||||
# --force wipes the card's repo/ + dirty journal first for a from-scratch copy
|
||||
# (recovers a corrupt or half-written card clone; journaled files still get the
|
||||
# rescue backup). Scoped to repo/: typoena.conf and /sd/local notes survive.
|
||||
# just load ~/code/notes --force
|
||||
# just load ~/code/notes SDCARD --force
|
||||
[positional-arguments]
|
||||
load repo_src *rest:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
repo_src="$1"; shift
|
||||
force=""; volname=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--force) force=1 ;;
|
||||
*) volname="$a" ;;
|
||||
esac
|
||||
done
|
||||
vol="$(just _card "$volname" | tail -n1)"
|
||||
just _load-repo "$repo_src" "$vol" "$force"
|
||||
just _eject "$vol"
|
||||
|
||||
# (Re)write just the config (Wi-Fi + PAT + git identity), then eject — e.g. to
|
||||
# rotate the PAT or switch networks without touching repo/. Values are derived +
|
||||
# prompted (see the section header); firmware/.env is an optional override. With
|
||||
# no repo arg, the git remote is derived from the card's existing clone.
|
||||
provision sd_volume="":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
vol="$(just _card "{{sd_volume}}" | tail -n1)"
|
||||
just _write-conf "$vol"
|
||||
just _eject "$vol"
|
||||
|
||||
# Resolve the target card volume. Prints the /Volumes/<name> path as the last
|
||||
# stdout line (callers capture it); all diagnostics go to stderr. Prefers an
|
||||
# explicit name; else auto-detects exactly one removable/SD volume (covers cards
|
||||
# in a built-in Mac reader, which report an "Internal" bus), and refuses on 0 or
|
||||
# >1 — a wrong guess here means rsync --delete wipes the wrong disk's repo/.
|
||||
_card sd_volume="":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
want="{{sd_volume}}"
|
||||
if [ -n "$want" ]; then
|
||||
vol="/Volumes/$want"
|
||||
[ -d "$vol" ] || { echo "error: '$vol' is not mounted" >&2; exit 1; }
|
||||
else
|
||||
cands=()
|
||||
for v in /Volumes/*; do
|
||||
[ -d "$v" ] || continue
|
||||
info="$(diskutil info "$v" 2>/dev/null || true)"
|
||||
# Treat a volume as a candidate card if ANY removable/SD signal is
|
||||
# present. A card in a Mac's *built-in* reader shows Protocol "Secure
|
||||
# Digital" + Removable Media "Removable", but sits on the "Internal"
|
||||
# bus with no whole-disk "Ejectable" line — so the old
|
||||
# "Ejectable: Yes AND external" test missed it entirely. None of these
|
||||
# match the fixed internal APFS disk; a spurious extra match just
|
||||
# trips the ">1 — name one" refusal below, which is safe.
|
||||
if echo "$info" | grep -qiE 'Protocol:[[:space:]]+Secure Digital' \
|
||||
|| echo "$info" | grep -qiE 'Removable Media:[[:space:]]+(Removable|Yes)' \
|
||||
|| echo "$info" | grep -qiE 'Ejectable:[[:space:]]+Yes' \
|
||||
|| echo "$info" | grep -qiE 'Device Location:[[:space:]]+External'; then
|
||||
cands+=("$v")
|
||||
fi
|
||||
done
|
||||
case "${#cands[@]}" in
|
||||
0) echo "error: no removable card detected under /Volumes — insert an SD card (FAT32)" >&2; exit 1 ;;
|
||||
1) vol="${cands[0]}" ;;
|
||||
*) echo "error: multiple removable volumes — name one as the volume arg:" >&2
|
||||
printf ' %s\n' "${cands[@]#/Volumes/}" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
# The device won't mount a non-FAT card (esp_vfs_fat, format_if_mount_failed=false).
|
||||
fs="$(diskutil info "$vol" 2>/dev/null | grep -iE 'File System Personality' | sed 's/.*:[[:space:]]*//' || true)"
|
||||
case "$fs" in
|
||||
*FAT32*|*MS-DOS*) : ;;
|
||||
*) echo "warning: '$vol' is '$fs', not FAT32 — the device may fail to mount it" >&2 ;;
|
||||
esac
|
||||
echo "$vol"
|
||||
|
||||
# Copy a full clone of the notes repo to <vol>/repo. Everything the repo's
|
||||
# .gitignore ignores is excluded (node_modules is 3.9 GB, gitignored, never in
|
||||
# .git) — the device needs .git + the checkout (~720 MB), not the JS deps or
|
||||
# secrets like firmware/.env. Copying (not a fresh `git clone` of the local
|
||||
# path) preserves origin → GitHub so on-device fetch/push still work — except
|
||||
# the URL scheme: the card copy's origin is rewritten to HTTPS at the end,
|
||||
# because the device's libgit2 has no SSH transport (HTTPS+PAT over mbedTLS
|
||||
# only). The source clone is never touched.
|
||||
_load-repo repo_src vol wipe="":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
src="{{repo_src}}"; src="${src%/}" # strip trailing slash
|
||||
wipe="{{wipe}}"
|
||||
[ -d "$src/.git" ] || { echo "error: '$src' is not a git repo (no .git/)"; exit 1; }
|
||||
|
||||
# ── unpublished-work guard ───────────────────────────────────────────────
|
||||
# /sd/.typoena-dirty (card root — outside the repo/ rsync scope) lists the
|
||||
# repo-relative paths the device saved or `:delete`d but never confirmed
|
||||
# published; the device clears it only on a confirmed push, so it also
|
||||
# covers any stranded splice commit in repo/.git. Mirroring repo/ over
|
||||
# those edits while the journal survives is worse than losing them: the
|
||||
# device's next :sync splices the journaled paths from the mirrored tree,
|
||||
# committing Mac-state — or a *deletion* for a path the Mac clone lacks.
|
||||
# So refuse by default; TW_DISCARD_UNPUBLISHED=1 backs the files up to a
|
||||
# temp dir, clears the journal, then mirrors. Either way a completed load
|
||||
# leaves no stale journal behind.
|
||||
journal="{{vol}}/.typoena-dirty"
|
||||
if [ -s "$journal" ]; then
|
||||
echo "card carries unpublished device edits (.typoena-dirty):" >&2
|
||||
sed 's/^/ /' "$journal" >&2
|
||||
if [ -z "${TW_DISCARD_UNPUBLISHED:-}" ] && [ -z "$wipe" ]; then
|
||||
echo "error: loading would overwrite these with the Mac clone and strand the journal." >&2
|
||||
echo " Put the card back in Typoena and :sync first (the normal path), or re-run" >&2
|
||||
echo " with TW_DISCARD_UNPUBLISHED=1 to back them up to a temp dir and discard." >&2
|
||||
exit 1
|
||||
fi
|
||||
rescue="$(mktemp -d)/typoena-rescue"
|
||||
while IFS= read -r p; do
|
||||
p="$(echo "$p" | xargs)"; [ -n "$p" ] || continue
|
||||
if [ -f "{{vol}}/{{sd_repo_dir}}/$p" ]; then
|
||||
mkdir -p "$rescue/$(dirname "$p")"
|
||||
cp "{{vol}}/{{sd_repo_dir}}/$p" "$rescue/$p"
|
||||
else
|
||||
echo " (no file on card for '$p' — was a device :delete, nothing to back up)" >&2
|
||||
fi
|
||||
done < "$journal"
|
||||
rm -f "$journal"
|
||||
echo "journal cleared; unpublished files backed up to $rescue" >&2
|
||||
fi
|
||||
|
||||
origin="$(git -C "$src" remote get-url origin 2>/dev/null || true)"
|
||||
case "$origin" in
|
||||
https://*|git@*|ssh://*|git://*) echo "source origin: $origin" ;;
|
||||
"") echo "warning: '$src' has no 'origin' remote — the device can't fetch/push after loading" ;;
|
||||
*) echo "warning: origin is '$origin' (looks like a local path, not a remote) —"
|
||||
echo " the device fetch/push will fail; set origin to the GitHub URL first" ;;
|
||||
esac
|
||||
if [ -n "${TW_REMOTE_URL:-}" ] && [ -n "$origin" ] && [ "$origin" != "${TW_REMOTE_URL}" ]; then
|
||||
echo "warning: origin ($origin) != TW_REMOTE_URL (${TW_REMOTE_URL})"
|
||||
fi
|
||||
|
||||
# Refresh the source clone from its remote before mirroring, so the card
|
||||
# carries the latest — e.g. notes the device pushed since the last load.
|
||||
# Fast-forward-only and best-effort: a dirty tree, diverged history, detached
|
||||
# HEAD, or being offline just warns and copies the current on-disk state
|
||||
# rather than aborting the card prep. Set TW_NO_PULL=1 to skip the pull.
|
||||
case "$origin" in
|
||||
https://*|git@*|ssh://*|git://*)
|
||||
if [ -n "${TW_NO_PULL:-}" ]; then
|
||||
echo "TW_NO_PULL set — skipping pull, copying current on-disk state"
|
||||
else
|
||||
branch="$(git -C "$src" symbolic-ref --quiet --short HEAD || true)"
|
||||
if [ -z "$branch" ]; then
|
||||
echo "warning: '$src' is in detached HEAD — skipping pull, copying current state" >&2
|
||||
elif git -C "$src" pull --ff-only origin "$branch"; then
|
||||
:
|
||||
else
|
||||
echo "warning: 'git pull --ff-only origin $branch' failed (dirty, diverged, or offline) —" >&2
|
||||
echo " copying the current on-disk state instead" >&2
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Single-pack card prep (kaizen: real-repo-sync). Each pack's whole .idx —
|
||||
# plus any multi-pack-index — is mmap'd OUTSIDE libgit2's window budget on
|
||||
# the device, and a multi-pack layout scatters the push's boundary-marking
|
||||
# walk across packs: run 8 (2026-07-13) spent 31 s marking and lost the
|
||||
# HTTP keep-alive; the same repo repacked to one pack marked in 3.5 s.
|
||||
# Repack the SOURCE clone (the card copy just mirrors it) only when the
|
||||
# layout needs it; best-effort like the pull — a failure warns, because a
|
||||
# multi-pack card usually still syncs, just slower and riskier.
|
||||
packs=$(find "$src/.git/objects/pack" -name '*.pack' 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$packs" -gt 1 ] || [ -e "$src/.git/objects/pack/multi-pack-index" ]; then
|
||||
echo "source clone has $packs packs — repacking to one for the device (git repack -ad)"
|
||||
git -C "$src" repack -ad \
|
||||
|| echo "warning: repack failed — card will carry $packs packs; on-device push may hit the keep-alive race" >&2
|
||||
fi
|
||||
|
||||
dest="{{vol}}/{{sd_repo_dir}}"
|
||||
# --force: delete the card's repo/ outright so rsync copies from scratch —
|
||||
# unlike the mirror, this also clears paths the exclude list protects from
|
||||
# `--delete` (a stray .env, build junk) and any corrupt .git state. Scoped
|
||||
# to repo/ + the journal (already cleared above if present): typoena.conf,
|
||||
# ca.pem and /sd/local never touched. Sanity-check vol first — with an
|
||||
# empty vol this rm would aim at "/repo".
|
||||
if [ -n "$wipe" ]; then
|
||||
[ -n "{{vol}}" ] && [ -d "{{vol}}" ] || { echo "error: bad volume '{{vol}}'" >&2; exit 1; }
|
||||
echo "--force: wiping $dest for a from-scratch copy"
|
||||
rm -rf "$dest"
|
||||
rm -f "$journal"
|
||||
fi
|
||||
echo "copying repo: $src -> $dest"
|
||||
mkdir -p "$dest"
|
||||
|
||||
# Build the exclude list from git's own ignore resolution (.gitignore +
|
||||
# .git/info/exclude + global). --directory collapses a fully-ignored dir
|
||||
# (node_modules/, 3.9 GB) to one line. Driving off git — not rsync's own
|
||||
# dir-merge — means we never list a tracked file or anything under .git/, so
|
||||
# a .gitignore pattern like `logs/` or `*.pack` can't corrupt the clone.
|
||||
ignore_list="$(mktemp)"
|
||||
trap 'rm -f "$ignore_list"' EXIT
|
||||
git -C "$src" -c core.quotePath=false ls-files \
|
||||
--others --ignored --exclude-standard --directory --no-empty-directory \
|
||||
> "$ignore_list"
|
||||
echo "excluding $(wc -l < "$ignore_list" | tr -d ' ') gitignored path(s) (incl. node_modules, .env)"
|
||||
|
||||
# -rt (no perms/owner/symlinks — meaningless on FAT), --modify-window=1 for
|
||||
# FAT's 2 s timestamp granularity, --delete to mirror (re-runs stay clean),
|
||||
# -P for progress on the ~700 MB copy. Scoped to repo/, never the card root.
|
||||
rsync -rtP --delete --modify-window=1 --exclude-from="$ignore_list" "$src/" "$dest/"
|
||||
|
||||
# The device's libgit2 speaks HTTPS+PAT only (mbedTLS — no SSH transport),
|
||||
# so an SSH origin copied verbatim fails every on-device push/fetch with
|
||||
# "unsupported URL protocol" (found the hard way, 2026-07-13). Rewrite the
|
||||
# CARD copy's origin to the HTTPS equivalent; the source clone keeps its
|
||||
# own URL. Its embedded trust store carries GitHub's roots, hence the
|
||||
# non-github warning.
|
||||
case "$origin" in
|
||||
git@*:*)
|
||||
host="${origin#git@}"; host="${host%%:*}"
|
||||
card_origin="https://$host/${origin#git@*:}"
|
||||
;;
|
||||
ssh://git@*)
|
||||
rest="${origin#ssh://git@}"
|
||||
card_origin="https://${rest%%[:/]*}/${rest#*/}"
|
||||
;;
|
||||
https://*) card_origin="$origin" ;;
|
||||
*) card_origin="" ;;
|
||||
esac
|
||||
if [ -n "$card_origin" ]; then
|
||||
if [ "$card_origin" != "$origin" ]; then
|
||||
echo "rewriting card origin for the device: $origin -> $card_origin"
|
||||
fi
|
||||
git -C "$dest" remote set-url origin "$card_origin"
|
||||
case "$card_origin" in
|
||||
https://github.com/*) ;;
|
||||
*) echo "warning: origin host isn't github.com — the device's embedded trust store" \
|
||||
"only carries GitHub roots, so on-device TLS will fail without its CA" ;;
|
||||
esac
|
||||
elif [ -n "$origin" ]; then
|
||||
echo "warning: origin '$origin' has no HTTPS equivalent I can derive —"
|
||||
echo " on-device push/fetch will fail; set it by hand:"
|
||||
echo " git -C '$dest' remote set-url origin https://github.com/<owner>/<repo>.git"
|
||||
fi
|
||||
|
||||
# First-time setup: seed the two git-tracked config files into the card's repo/
|
||||
# so they commit + sync on the device's first `:sync`. Writes ONLY a file that is
|
||||
# absent — a card whose clone already carries `.typoena.toml` /
|
||||
# `.typoena.snippets.json` keeps its own, so a re-`init` never clobbers a synced
|
||||
# library. The starter `.typoena.toml` mirrors the editor defaults; the snippet
|
||||
# library is assembled from the curated catalog (snippets-catalog/, opt-in per
|
||||
# group) with `jq`. Non-interactive runs take every default (all groups).
|
||||
_seed-configs vol:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
repo="{{vol}}/{{sd_repo_dir}}"
|
||||
catalog="{{justfile_directory()}}/snippets-catalog"
|
||||
interactive=1; [ -t 0 ] || interactive=0
|
||||
|
||||
# ask_yn "label" → 0 (yes) / 1 (no); default yes, non-interactive → yes.
|
||||
ask_yn() {
|
||||
local ans
|
||||
if [ "$interactive" = 0 ]; then return 0; fi
|
||||
read -r -p " $1 [Y/n]: " ans || ans=""
|
||||
[[ "${ans:-Y}" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
# ── starter prefs (.typoena.toml) ────────────────────────────────────────
|
||||
prefs="$repo/.typoena.toml"
|
||||
if [ -e "$prefs" ]; then
|
||||
echo "keeping existing .typoena.toml"
|
||||
elif ask_yn "seed a starter .typoena.toml (editor defaults)?"; then
|
||||
# printf per line (matching _write-conf), so no heredoc indentation trap.
|
||||
{
|
||||
printf '# Typoena editor preferences — hand-editable, git-tracked.\n'
|
||||
printf '# Edit here, or change live from the Cmd-P palette (type `>`).\n'
|
||||
printf 'save_on_idle = true\n'
|
||||
printf 'format_on_save = true\n'
|
||||
printf 'line_numbers = true\n'
|
||||
printf 'theme = "light"\n'
|
||||
printf 'auto_sync = "10m"\n'
|
||||
} > "$prefs"
|
||||
echo "wrote .typoena.toml"
|
||||
fi
|
||||
|
||||
# ── snippet library (.typoena.snippets.json) from the catalog ────────────
|
||||
snips="$repo/.typoena.snippets.json"
|
||||
if [ -e "$snips" ]; then
|
||||
echo "keeping existing .typoena.snippets.json"
|
||||
elif ! command -v jq >/dev/null 2>&1; then
|
||||
echo "warning: jq not found — skipping the snippet catalog (install jq, or" >&2
|
||||
echo " hand-write $snips later)" >&2
|
||||
else
|
||||
echo "snippet catalog — choose groups to include:" >&2
|
||||
picked=()
|
||||
ask_yn "Symbols (→ ≠ × · ° € œ)" && picked+=("$catalog/symbols.json")
|
||||
ask_yn "Structure (todo, link, img, table, code)" && picked+=("$catalog/structure.json")
|
||||
ask_yn "Prose (booknotes, reference, bias, capture, standard)" && picked+=("$catalog/prose.json")
|
||||
if [ "${#picked[@]}" -gt 0 ]; then
|
||||
jq -s 'add' "${picked[@]}" > "$snips"
|
||||
echo "wrote .typoena.snippets.json ($(jq 'length' "$snips") snippets, ${#picked[@]} group(s))"
|
||||
else
|
||||
echo "no groups chosen — skipping .typoena.snippets.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Resolve + write <vol>/typoena.conf (Wi-Fi + PAT + git identity). Each value
|
||||
# runs a ladder: firmware/.env (loaded via `set dotenv-load`) → derived from
|
||||
# tools already on the machine (git config, gh, the active Wi-Fi network + its
|
||||
# System-keychain password) → interactive prompt, with the derived value as the
|
||||
# default. So a newcomer needs no .env: they Enter through the defaults and paste
|
||||
# a PAT once. The PAT is never derived (a broad `gh auth token` on plaintext
|
||||
# removable media would defeat the scoped-token model) and never echoed. FAT has
|
||||
# no file permissions, so physical custody of the card is the control: use a
|
||||
# fine-grained PAT (contents:write on just the notes repo) so a lost card is a
|
||||
# one-token revoke. <repo_src> (optional) seeds the git remote; without it the
|
||||
# remote is derived from the card's existing clone.
|
||||
_write-conf vol repo_src="":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
conf="{{vol}}/typoena.conf"
|
||||
repo_src="{{repo_src}}"; repo_src="${repo_src%/}"
|
||||
interactive=1; [ -t 0 ] || interactive=0
|
||||
|
||||
# ask "label" "default" → chosen value on stdout; prompt shown on stderr (so
|
||||
# it survives the $(...) capture). Non-interactive → returns the default.
|
||||
ask() {
|
||||
local label="$1" def="${2:-}" ans
|
||||
if [ "$interactive" = 0 ]; then printf '%s' "$def"; return; fi
|
||||
read -r -p " $label${def:+ [$def]}: " ans || ans=""
|
||||
printf '%s' "${ans:-$def}"
|
||||
}
|
||||
# like ask but hides input (secrets). A present default reads as "keep current".
|
||||
ask_secret() {
|
||||
local label="$1" def="${2:-}" ans
|
||||
if [ "$interactive" = 0 ]; then printf '%s' "$def"; return; fi
|
||||
read -r -s -p " $label${def:+ [keep current]}: " ans || ans=""; printf '\n' >&2
|
||||
printf '%s' "${ans:-$def}"
|
||||
}
|
||||
|
||||
# ── derive defaults (silent; empty when a tool is missing/unauthed) ──────────
|
||||
# remote: .env → source repo's origin → the card's existing clone.
|
||||
remote="${TW_REMOTE_URL:-}"
|
||||
if [ -z "$remote" ] && [ -n "$repo_src" ] && [ -d "$repo_src/.git" ]; then
|
||||
remote="$(git -C "$repo_src" remote get-url origin 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$remote" ] && [ -d "{{vol}}/{{sd_repo_dir}}/.git" ]; then
|
||||
remote="$(git -C "{{vol}}/{{sd_repo_dir}}" remote get-url origin 2>/dev/null || true)"
|
||||
fi
|
||||
a_name="${TW_AUTHOR_NAME:-$(git config user.name 2>/dev/null || true)}"
|
||||
a_email="${TW_AUTHOR_EMAIL:-$(git config user.email 2>/dev/null || true)}"
|
||||
gh_user="${TW_GH_USER:-}"
|
||||
[ -z "$gh_user" ] && gh_user="$(gh api user --jq .login 2>/dev/null || true)"
|
||||
# ssid: .env → the Mac's active Wi-Fi network.
|
||||
wifi_if="$(networksetup -listallhardwareports 2>/dev/null | awk '/Wi-Fi/{getline; print $2; exit}')"
|
||||
ssid="${TW_WIFI_SSID:-}"
|
||||
[ -z "$ssid" ] && ssid="$(networksetup -getairportnetwork "${wifi_if:-en0}" 2>/dev/null | sed -n 's/^Current Wi-Fi Network: //p')"
|
||||
wifi_pass="${TW_WIFI_PASS:-}"
|
||||
pat="${TW_PAT:-}"
|
||||
|
||||
# ── confirm / fill via prompts ──────────────────────────────────────────────
|
||||
[ "$interactive" = 1 ] && echo "configuring $conf — press Enter to accept each [default]:" >&2
|
||||
ssid="$(ask "Wi-Fi SSID" "$ssid")"
|
||||
# Keychain read happens after the SSID is final (the user may have edited it).
|
||||
# Reading a System-keychain Wi-Fi password can pop a macOS auth dialog — only
|
||||
# attempt it interactively so scripted runs never trigger a surprise prompt.
|
||||
if [ -z "$wifi_pass" ] && [ -n "$ssid" ] && [ "$interactive" = 1 ]; then
|
||||
echo " looking up Wi-Fi password for '$ssid' in Keychain (approve the macOS dialog if it appears)…" >&2
|
||||
wifi_pass="$(security find-generic-password -wa "$ssid" 2>/dev/null || true)"
|
||||
fi
|
||||
wifi_pass="$(ask_secret "Wi-Fi password" "$wifi_pass")"
|
||||
remote="$(ask "Git remote URL" "$remote")"
|
||||
gh_user="$(ask "GitHub username" "$gh_user")"
|
||||
pat="$(ask_secret "GitHub PAT (fine-grained, contents:write)" "$pat")"
|
||||
a_name="$(ask "Commit author name" "$a_name")"
|
||||
a_email="$(ask "Commit author email" "$a_email")"
|
||||
|
||||
# ── require the essentials (a blank config ships a dead device) ──────────────
|
||||
missing=""
|
||||
[ -n "$ssid" ] || missing="$missing Wi-Fi-SSID"
|
||||
[ -n "$remote" ] || missing="$missing git-remote-URL"
|
||||
[ -n "$pat" ] || missing="$missing GitHub-PAT"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "error: missing required config:$missing" >&2
|
||||
[ "$interactive" = 0 ] && echo " (no TTY — set these in firmware/.env or run interactively)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── write ───────────────────────────────────────────────────────────────────
|
||||
{
|
||||
printf '# Typoena runtime config — generated by `just init`/`provision`.\n'
|
||||
printf '# Plaintext secrets on removable media: keep the card safe; scope TW_PAT\n'
|
||||
printf '# to contents:write on just the notes repo. `key=value`, `#` = comment.\n'
|
||||
printf 'TW_WIFI_SSID=%s\n' "$ssid"
|
||||
printf 'TW_WIFI_PASS=%s\n' "$wifi_pass"
|
||||
printf 'TW_REMOTE_URL=%s\n' "$remote"
|
||||
printf 'TW_GH_USER=%s\n' "$gh_user"
|
||||
printf 'TW_PAT=%s\n' "$pat"
|
||||
printf 'TW_AUTHOR_NAME=%s\n' "$a_name"
|
||||
printf 'TW_AUTHOR_EMAIL=%s\n' "$a_email"
|
||||
} > "$conf"
|
||||
mask() { if [ -n "${1:-}" ]; then printf 'set'; else printf 'MISSING'; fi; }
|
||||
echo "wrote $conf (secrets never printed):"
|
||||
echo " wifi: ssid=$(mask "$ssid") pass=$(mask "$wifi_pass")"
|
||||
echo " git: remote=$(mask "$remote") gh_user=$(mask "$gh_user") pat=$(mask "$pat")"
|
||||
echo " author: name=$(mask "$a_name") email=$(mask "$a_email")"
|
||||
|
||||
# Flush + eject so the card can go straight into Typoena. Also strips the
|
||||
# AppleDouble `._*` companions macOS writes on FAT for any file it decorates
|
||||
# with xattrs: on a real card they reached .git/objects/pack/._pack-*.idx,
|
||||
# which git's pack scan (Mac git AND the device's libgit2) picks up via its
|
||||
# *.idx glob and tries to parse ("non-monotonic index" spam; 2045 of them
|
||||
# found 2026-07-13). Best-effort — a missing dot_clean never blocks the eject.
|
||||
_eject vol:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dot_clean -m "{{vol}}" 2>/dev/null || true
|
||||
sync
|
||||
echo "ejecting {{vol}}"
|
||||
if diskutil eject "{{vol}}" >/dev/null 2>&1; then
|
||||
echo "✅ card ejected — remove it and insert into Typoena"
|
||||
else
|
||||
echo "⚠️ eject failed (a file may still be open) — eject '{{vol}}' from Finder before removing"
|
||||
fi
|
||||
|
||||
19
firmware/partitions.csv
Normal file
19
firmware/partitions.csv
Normal file
@@ -0,0 +1,19 @@
|
||||
# Typoena partition table — 16 MB flash (ESP32-S3-WROOM-1-N16R8).
|
||||
#
|
||||
# Adds a `storage` data partition of subtype `fat` so the on-device git working
|
||||
# copy (Spike 7) lives on flash-FAT: `esp_vfs_fat_spiflash_mount_rw_wl` finds it
|
||||
# by the label below and mounts it at /spiflash.
|
||||
#
|
||||
# esp-idf-rs does NOT compile this table (it builds `libespidf` as a static lib,
|
||||
# not a full app project) — espflash applies it at flash time via
|
||||
# `--partition-table partitions.csv`. Only `just flash-git-push` passes that flag,
|
||||
# so the editor flash is unaffected and keeps its default single-app layout.
|
||||
#
|
||||
# factory is 3 MB — generous headroom for the libgit2-linked binary (the editor
|
||||
# is ~1 MB; git_push adds Wi-Fi + FAT + libgit2). storage is 4 MB. Total ~7.4 MB.
|
||||
#
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
phy_init, data, phy, 0xf000, 0x1000,
|
||||
factory, app, factory, 0x10000, 0x300000,
|
||||
storage, data, fat, 0x310000, 0x400000,
|
||||
|
14
firmware/pkgconfig/libgit2.pc
Normal file
14
firmware/pkgconfig/libgit2.pc
Normal file
@@ -0,0 +1,14 @@
|
||||
# Fake pkg-config file for the libgit2 esp-idf component (Spike 7, Path 2).
|
||||
#
|
||||
# libgit2-sys in system mode (LIBGIT2_NO_VENDOR=1) probes for a system libgit2
|
||||
# via pkg-config; without one it aborts, with one it emits link flags. We don't
|
||||
# want it to build OR link anything — esp-idf already builds liblibgit2.a and
|
||||
# puts it in the final link group (that's how git_smoke's 538 symbols resolved).
|
||||
# So this .pc makes the probe *succeed* (right version, in range [1.9.4,1.10.0))
|
||||
# while emitting NOTHING (empty Libs/Cflags). The symbols come from the
|
||||
# component; libgit2-sys supplies only its hand-written Rust bindings.
|
||||
Name: libgit2
|
||||
Description: libgit2 built as an esp-idf component (mbedTLS)
|
||||
Version: 1.9.4
|
||||
Libs:
|
||||
Cflags:
|
||||
16
firmware/pkgconfig/zlib.pc
Normal file
16
firmware/pkgconfig/zlib.pc
Normal file
@@ -0,0 +1,16 @@
|
||||
# Fake pkg-config file for zlib (Spike 7, Path 2).
|
||||
#
|
||||
# libz-sys is a non-optional dependency of libgit2-sys. Left to itself it would
|
||||
# either grab the host (macOS) zlib or vendor-build its own libz.a — and either
|
||||
# way its zlib symbols would collide with the zlib we bundle *inside* the
|
||||
# libgit2 component (deps/zlib, which lives in esp-idf's link group where the
|
||||
# ordering is safe). This .pc makes libz-sys's probe succeed while emitting
|
||||
# NOTHING, so it contributes no symbols and the component's bundled zlib wins.
|
||||
#
|
||||
# PKG_CONFIG_LIBDIR is pointed at this directory only, so neither probe can fall
|
||||
# through to the host's real zlib.pc.
|
||||
Name: zlib
|
||||
Description: zlib bundled into the libgit2 esp-idf component
|
||||
Version: 1.3.1
|
||||
Libs:
|
||||
Cflags:
|
||||
@@ -1,7 +1,20 @@
|
||||
# Rust often needs a bit of an extra main task stack size compared to C (the default is 3K)
|
||||
# You might have to increase this further if you allocate large stack variables in the main task.
|
||||
# Bumped for the TLS handshake (Spike 6): mbedtls runs on the calling task and
|
||||
# wants several KB of stack on top of the app's own usage.
|
||||
# wants several KB of stack on top of the app's own usage. 12 KB ran the editor
|
||||
# AND a full TLS-on-main handshake on hardware — the last-known-good value for
|
||||
# this shared stack.
|
||||
#
|
||||
# Spike 7 briefly bumped this to 96 KB because libgit2's push chain is far deeper
|
||||
# (~67 KB measured for a trivial config write: nearly every function puts a 4 KB
|
||||
# `char path[GIT_PATH_MAX]` on the stack and init→config→FATFS→wear-leveling
|
||||
# nests ~10 deep). But this sdkconfig is SHARED with the editor build, so that
|
||||
# forced the editor to over-reserve ~80 KB for nothing. Git now runs on its own
|
||||
# large-stack thread instead (git_push.rs — GIT_STACK via
|
||||
# std::thread::Builder::stack_size), so this drops back to the Spike-6 value.
|
||||
# (The "time() only works on the main task" theory was a misdiagnosis: that
|
||||
# iteration ran git on a std::thread with the DEFAULT 4 KB stack and just
|
||||
# overflowed sooner — it was always stack depth, never thread-vs-main.)
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288
|
||||
|
||||
# Increase a bit these stack sizes as they are also a bit too small by default
|
||||
@@ -13,9 +26,68 @@ CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=4096
|
||||
# `std::thread::Builder::new().stack_size(XXX)` for spawning
|
||||
CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=4096
|
||||
|
||||
# TLS trust store (Spike 6 — Wi-Fi + TLS, the gate for Spike 7 gitoxide push).
|
||||
# FatFS long filenames (Spike 3 — SD). Default is 8.3-only, which rejects the
|
||||
# persistence module's atomic-save temp name (`notes.md.tmp` has two dots).
|
||||
# LFN on the heap keeps the working buffer off the (small) task stack.
|
||||
CONFIG_FATFS_LFN_HEAP=y
|
||||
|
||||
# PSRAM (Spike 7 — the git working set needs it; the docs memory plan budgets
|
||||
# ~1.5 MB PSRAM for pack/delta work, and the rope buffer lives there too). This
|
||||
# board is an ESP32-S3-WROOM-1-N16R8 → 8 MB *octal* PSRAM, so OCT mode is
|
||||
# required (quad would fail to init). USE_MALLOC adds PSRAM to the heap
|
||||
# allocator, so large allocations (Box, Rope, libgit2 buffers) land there
|
||||
# instead of the ~339 KB internal DRAM Spike 6 measured. Octal PSRAM occupies
|
||||
# GPIO 33–37; the EPD/SD pins (4–13) deliberately avoid that range, so no wiring
|
||||
# conflict. Speed left at the 40 MHz default (safe first-enable; 80 MHz needs
|
||||
# matching flash-freq config and is a later tuning step).
|
||||
CONFIG_SPIRAM=y
|
||||
CONFIG_SPIRAM_MODE_OCT=y
|
||||
CONFIG_SPIRAM_USE_MALLOC=y
|
||||
# Internal reserve for DMA/internal-only allocations (default 32 KB). The
|
||||
# first on-device `:gl` fast-forward (2026-07-14) exhausted internal DRAM
|
||||
# mid-checkout and an SD-SPI DMA bounce-buffer alloc failed — esp-idf's
|
||||
# spi_master then NULL-derefs on its own error path (LoadProhibited in
|
||||
# setup/uninstall_priv_desc) instead of returning, so the DMA floor must be
|
||||
# generous enough that the driver never sees a failed alloc. 64 KB, paired
|
||||
# with the file-list interning that returns ~180 KB to internal.
|
||||
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=65536
|
||||
|
||||
# FatFS fast seek (sync-latency root cause, 2026-07-12). Without it, lseek
|
||||
# resolves by walking the file's FAT cluster chain over SPI — forward from the
|
||||
# current position, from the CHAIN HEAD on any backward seek. sd_bench measured
|
||||
# ~190 ms per long seek into the 263 MB packfile (5.8 ms at offset 0), and
|
||||
# libgit2 pays ~8 such seeks per loose-object write via p_mmap's lseek+read →
|
||||
# the ~1.5 s/object commit cost (docs/tradeoff-curves/sync-commit-staging.md).
|
||||
# Fast seek builds an in-memory cluster-link map (CLMT) per READ-mode file,
|
||||
# making lseek O(1); write-mode files transparently fall back to the walk. The
|
||||
# buffer is per-open-file, 4 B × size: 256 words = 1 KB, covering ~127 fragments
|
||||
# (the default 64 covers ~31 — a freshly-provisioned pack is near-contiguous,
|
||||
# but don't let card aging silently disable the fix; vfs_fat falls back to slow
|
||||
# seeks when the map doesn't fit).
|
||||
CONFIG_FATFS_USE_FASTSEEK=y
|
||||
CONFIG_FATFS_FAST_SEEK_BUFFER_SIZE=256
|
||||
|
||||
# Silence the legacy-I2C boot warning. esp-idf-hal's i2c.rs is always compiled
|
||||
# and binds the old `driver/i2c.h` API, so the legacy driver's TU (and its
|
||||
# `__attribute__((constructor))` deprecation warning) gets linked into every
|
||||
# image — even though Typoena uses no I2C at all (EPD/SD are SPI, keyboard is
|
||||
# USB). This flag wraps that constructor out. Safe here: we link neither the
|
||||
# new i2c_master driver nor call the old one, so the conflict-abort check it
|
||||
# also removes can never fire.
|
||||
CONFIG_I2C_SKIP_LEGACY_CONFLICT_CHECK=y
|
||||
|
||||
# TLS trust store (Spike 6 — Wi-Fi + TLS, the gate for Spike 7 git push).
|
||||
# The certificate bundle backs esp_crt_bundle_attach so an HTTPS GET to
|
||||
# api.github.com validates against real roots. FULL rather than the common
|
||||
# subset so a less common CA in the chain can't surprise us on the bench.
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y
|
||||
|
||||
# mbedTLS allocations go to PSRAM. The default (internal-only) needs ~33 KB of
|
||||
# contiguous internal RAM per TLS connection (two ~17 KB I/O buffers + contexts)
|
||||
# at the moment `:sync` pushes — with Wi-Fi, USB host, the editor and libgit2
|
||||
# all resident, mbedtls_ssl_setup failed exactly there on the first real-repo
|
||||
# push (2026-07-13; the failure then tripped the vendored stream double-free —
|
||||
# see components/libgit2/esp_mbedtls_stream.c). TLS buffers are CPU-only data,
|
||||
# so PSRAM is safe; the handshake is network-bound, not memory-bandwidth-bound.
|
||||
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
|
||||
|
||||
58
firmware/snippets-catalog/prose.json
Normal file
58
firmware/snippets-catalog/prose.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"Book notes": {
|
||||
"prefix": "booknotes",
|
||||
"body": ["# $1", "", "## $2 - $3", "", "## What the book is about", ""],
|
||||
"description": "Reading notes (title / author - year)"
|
||||
},
|
||||
"Reference block": {
|
||||
"prefix": "reference",
|
||||
"body": ["___", "", "## References", ""],
|
||||
"description": "Reference block"
|
||||
},
|
||||
"Cognitive bias": {
|
||||
"prefix": "bias",
|
||||
"body": [
|
||||
"# $1",
|
||||
"",
|
||||
"## Misconception",
|
||||
"",
|
||||
"$3",
|
||||
"",
|
||||
"## The reality",
|
||||
"",
|
||||
"$4",
|
||||
"",
|
||||
"___",
|
||||
"",
|
||||
"## References",
|
||||
"",
|
||||
"- You are not so smart - p. $2",
|
||||
""
|
||||
],
|
||||
"description": "Cognitive-bias note"
|
||||
},
|
||||
"Capture": {
|
||||
"prefix": "capture",
|
||||
"body": ["# $1", "", "## Captured inspiration", "", "$2", ""],
|
||||
"description": "Inspiration capture"
|
||||
},
|
||||
"Standard": {
|
||||
"prefix": "standard",
|
||||
"body": [
|
||||
"# $1",
|
||||
"",
|
||||
"## Key points",
|
||||
"",
|
||||
"## Mistakes to avoid",
|
||||
"",
|
||||
"___",
|
||||
"",
|
||||
"## Examples",
|
||||
"",
|
||||
"___",
|
||||
"",
|
||||
"## References"
|
||||
],
|
||||
"description": "Standard / playbook"
|
||||
}
|
||||
}
|
||||
27
firmware/snippets-catalog/structure.json
Normal file
27
firmware/snippets-catalog/structure.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"Todo item": {
|
||||
"prefix": "todo",
|
||||
"body": "- [ ] ",
|
||||
"description": "Task-list item"
|
||||
},
|
||||
"Link": {
|
||||
"prefix": "link",
|
||||
"body": "[$1]($2)$0",
|
||||
"description": "Inline link"
|
||||
},
|
||||
"Image": {
|
||||
"prefix": "img",
|
||||
"body": "$0",
|
||||
"description": "Image"
|
||||
},
|
||||
"Table": {
|
||||
"prefix": "table",
|
||||
"body": ["| $1 | $2 |", "|-|-|", "| $3 | $4 |"],
|
||||
"description": "Two-column table"
|
||||
},
|
||||
"Code block": {
|
||||
"prefix": "code",
|
||||
"body": ["```$1", "$2", "```"],
|
||||
"description": "Fenced code block"
|
||||
}
|
||||
}
|
||||
37
firmware/snippets-catalog/symbols.json
Normal file
37
firmware/snippets-catalog/symbols.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Arrow": {
|
||||
"prefix": "arrow",
|
||||
"body": "→",
|
||||
"description": "Rightwards arrow (→)"
|
||||
},
|
||||
"Not equal": {
|
||||
"prefix": "neq",
|
||||
"body": "≠",
|
||||
"description": "Not-equal sign (≠)"
|
||||
},
|
||||
"Times": {
|
||||
"prefix": "times",
|
||||
"body": "×",
|
||||
"description": "Multiplication sign (×)"
|
||||
},
|
||||
"Middle dot": {
|
||||
"prefix": "middot",
|
||||
"body": "·",
|
||||
"description": "Middle dot (·), inclusive writing"
|
||||
},
|
||||
"Degree": {
|
||||
"prefix": "deg",
|
||||
"body": "°",
|
||||
"description": "Degree sign (°)"
|
||||
},
|
||||
"Euro": {
|
||||
"prefix": "euro",
|
||||
"body": "€",
|
||||
"description": "Euro sign (€)"
|
||||
},
|
||||
"OE ligature": {
|
||||
"prefix": "edanso",
|
||||
"body": "œ",
|
||||
"description": "Ligature œ (e dans l'o)"
|
||||
}
|
||||
}
|
||||
371
firmware/src/bin/git_bench.rs
Normal file
371
firmware/src/bin/git_bench.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
//! git-level micro-benchmark — localizes the ~700 ms/object libgit2 overhead the
|
||||
//! `:sync` commit split showed (2026-07-12), now that `sd_bench` proved the raw
|
||||
//! card does a *full* loose-object write (stat+create+write+rename) in ~86 ms.
|
||||
//! The ~8× gap between that and `write_tree`'s 710 ms lives inside libgit2, not
|
||||
//! FAT — this bench times the git2 ODB/index primitives in isolation to find it.
|
||||
//!
|
||||
//! HEADLINE OP (since the 2026-07-12 real-repo run): `splice stage→tree` — the
|
||||
//! O(depth) TreeBuilder walk that replaces the index-based commit entirely
|
||||
//! (docs/tradeoff-curves/sync-commit-staging.md). It runs FIRST so its first iteration is
|
||||
//! the cold number; acceptance bar: **sub-second cold on the real 570 MB-pack
|
||||
//! clone, heap staying healthy**. The index paths it supersedes run LAST, for
|
||||
//! regression tracking — they previously OOM'd, and a late crash can't cost the
|
||||
//! splice data.
|
||||
//!
|
||||
//! Read-mostly on `/sd/repo`: the only writes are unreferenced ("orphan") loose
|
||||
//! blobs/trees/commits — never reachable from a ref, so never pushed, and
|
||||
//! gc-able. Safe on the test card.
|
||||
//!
|
||||
//! Flash with `just flash-gitbench` (needs the `git` feature; env in the recipe).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use esp_idf_svc::hal::delay::FreeRtos;
|
||||
use git2::{IndexEntry, IndexTime, ObjectType, Oid, Repository, Signature, Tree};
|
||||
|
||||
use firmware::git_sync::GIT_STACK;
|
||||
use firmware::persistence::{Storage, REPO_DIR};
|
||||
|
||||
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
||||
|
||||
/// Iterations per op. Small — some ops write to the card, and the first vs rest
|
||||
/// spread (min vs max) is itself the signal (e.g. cold vs warm, write vs
|
||||
/// freshen-skip). Kept low (3) on the real 570 MB-pack clone so a slow op still
|
||||
/// finishes in seconds.
|
||||
const N: usize = 3;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
log::info!("Typoena — git-level bench, {BUILD_TAG}");
|
||||
|
||||
// libgit2 nests ~67 KB of GIT_PATH_MAX stack buffers (postmortem #3), so the
|
||||
// git work must run on the same 96 KB stack the real git service uses. On the
|
||||
// small main-task stack `index.write()` overflows → nested panic → boot loop.
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("git_bench".into())
|
||||
.stack_size(GIT_STACK)
|
||||
.spawn(run)
|
||||
.expect("spawn git_bench thread");
|
||||
match handle.join() {
|
||||
Ok(Ok(())) => log::info!("git_bench: done"),
|
||||
Ok(Err(e)) => log::error!("git_bench failed: {e:?}"),
|
||||
Err(_) => log::error!("git_bench thread panicked"),
|
||||
}
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
// libgit2 holds the pack + idx (+ commit-graph) fds open and reads loose
|
||||
// objects on top; the editor's default 4-FD budget can't cover read_tree.
|
||||
let _sd = Storage::mount_for_git().context("mounting SD")?;
|
||||
|
||||
// A 32 MB default mwindow window (mwindow.c) would git__malloc > PSRAM on the
|
||||
// real 570 MB pack; small windows keep each p_mmap read cheap, and the
|
||||
// esp_map cache keeps them from being re-read on every freshen→refresh.
|
||||
// SAFETY: process-global libgit2 options, set once before any repo work.
|
||||
unsafe {
|
||||
git2::opts::set_mwindow_size(256 * 1024).ok();
|
||||
git2::opts::set_mwindow_mapped_limit(4 * 1024 * 1024).ok();
|
||||
}
|
||||
|
||||
// Repository open — one-time, but shows the cost of scanning .git (config,
|
||||
// refs, ODB backends/packs) which every later op may implicitly refresh.
|
||||
let t = Instant::now();
|
||||
let repo = Repository::open(REPO_DIR)
|
||||
.with_context(|| format!("opening git repo at {REPO_DIR}"))?;
|
||||
log::info!("Repository::open {:.1} ms", t.elapsed().as_micros() as f64 / 1000.0);
|
||||
log_map_stats("open");
|
||||
|
||||
// 1) THE FIX — `splice stage→tree`, the O(depth) TreeBuilder walk
|
||||
// (docs/tradeoff-curves/sync-commit-staging.md): patch the edited file's ancestor
|
||||
// subtree chain onto HEAD's tree; never materialise the 1179-entry index,
|
||||
// never index.write(), never read_tree the whole tree. Runs FIRST so
|
||||
// iteration #1 is genuinely cold (only `open` has touched the pack).
|
||||
let head_tree = repo
|
||||
.head()?
|
||||
.peel_to_commit()
|
||||
.context("HEAD → commit")?
|
||||
.tree()
|
||||
.context("HEAD tree")?;
|
||||
// A nested path that already exists in HEAD's tree, found by an O(depth)
|
||||
// descent — NOT read_tree, which is itself the 77 s op — so the splice
|
||||
// REPLACES a real file and rebuilds a real ancestor chain, not just the root.
|
||||
let edit_path = find_edit_path(&repo, &head_tree)?;
|
||||
log::info!(
|
||||
"splice: editing {} (depth {})",
|
||||
edit_path.join("/"),
|
||||
edit_path.len()
|
||||
);
|
||||
|
||||
// The blob write is inside the timing: the real commit pays blob + trees, and
|
||||
// it keeps the number comparable to `index-free stage→tree` below. Not
|
||||
// measured here: the ref/reflog update (commit(Some("HEAD"))) — flat FAT
|
||||
// writes, ~350 ms on the toy repo.
|
||||
bench("splice stage→tree", |i| {
|
||||
let data = format!("typoena splice bench edit #{i}\n");
|
||||
let oid = repo.blob(data.as_bytes()).context("write blob")?;
|
||||
let parts: Vec<&str> = edit_path.iter().map(String::as_str).collect();
|
||||
splice(&repo, Some(&head_tree), &parts, Some(oid)).map(|_| ())
|
||||
})?;
|
||||
log_map_stats("splice");
|
||||
|
||||
// 2) commit(None, …) — create a commit OBJECT without moving HEAD or writing a
|
||||
// reflog (update_ref = None → an orphan commit, gc-able). Isolates commit-
|
||||
// object creation from the ref-update + reflog cost; splice + this projects
|
||||
// the full real-repo commit. Reuses the parent's tree (no new tree needed);
|
||||
// unique message each iter forces a real write.
|
||||
let parent = repo.head()?.peel_to_commit().context("HEAD → commit")?;
|
||||
let sig = Signature::now("typoena-bench", "bench@typoena.local").context("sig")?;
|
||||
bench("commit(None) orphan obj", |i| {
|
||||
let msg = format!("typoena git_bench orphan commit #{i}");
|
||||
repo.commit(None, &sig, &sig, &msg, &head_tree, &[&parent])
|
||||
.map(|_| ())
|
||||
.context("commit(None)")
|
||||
})?;
|
||||
log_map_stats("commit");
|
||||
|
||||
// 3) odb.write(blob) in isolation — unique content each iter forces a real
|
||||
// write (no freshen-skip). If ~100 ms the ODB write path is fine and any
|
||||
// slow op above is in the tree/ref layer; if ~1 s the cost is inside the
|
||||
// ODB write itself (deflate/sha/freshen) and the mmap cache regressed.
|
||||
let odb = repo.odb().context("opening odb")?;
|
||||
bench("odb.write(blob)", |i| {
|
||||
let data = format!("typoena git_bench orphan blob #{i} — unique so the write is real\n");
|
||||
odb.write(ObjectType::Blob, data.as_bytes())
|
||||
.map(|_| ())
|
||||
.context("odb.write")
|
||||
})?;
|
||||
log_map_stats("odb.write");
|
||||
|
||||
// 3b) LOCALIZE the commit-vs-blob gap. The fast-seek A/B (2026-07-12) left
|
||||
// `commit(None)` at 1.7 s while `odb.write` dropped to ~0.4 s — commit
|
||||
// additionally VALIDATES its parent + tree OIDs against the odb (strict
|
||||
// object creation → pack header resolves) and freshens the packed tree.
|
||||
// Price the two suspects, then re-bench commit + splice with strict
|
||||
// creation OFF. If commit collapses toward odb.write, validation was the
|
||||
// gap — and git_sync can ship with strict off (every OID it inserts
|
||||
// comes from HEAD's tree or a blob it just wrote).
|
||||
let parent_id = parent.id();
|
||||
let tree_id = head_tree.id();
|
||||
bench("odb.read_header(packed)", |i| {
|
||||
let id = if i % 2 == 0 { tree_id } else { parent_id };
|
||||
odb.read_header(id).map(|_| ()).context("read_header")
|
||||
})?;
|
||||
bench("odb.exists(missing)", |i| {
|
||||
let id = Oid::from_str(&format!("{:040x}", 0xdead_beef_u64 + i as u64))?;
|
||||
let _ = odb.exists(id); // miss → freshen fails → git_odb_refresh path
|
||||
Ok(())
|
||||
})?;
|
||||
log_map_stats("probes");
|
||||
|
||||
// Process-global libgit2 flag; this bench owns the process.
|
||||
git2::opts::strict_object_creation(false);
|
||||
bench("commit(None) [strict off]", |i| {
|
||||
let msg = format!("typoena git_bench strict-off commit #{i}");
|
||||
repo.commit(None, &sig, &sig, &msg, &head_tree, &[&parent])
|
||||
.map(|_| ())
|
||||
.context("commit strict-off")
|
||||
})?;
|
||||
bench("splice [strict off]", |i| {
|
||||
let data = format!("typoena splice strict-off edit #{i}\n");
|
||||
let oid = repo.blob(data.as_bytes()).context("write blob")?;
|
||||
let parts: Vec<&str> = edit_path.iter().map(String::as_str).collect();
|
||||
splice(&repo, Some(&head_tree), &parts, Some(oid)).map(|_| ())
|
||||
})?;
|
||||
git2::opts::strict_object_creation(true);
|
||||
log_map_stats("strict-off");
|
||||
|
||||
// 4) on-disk index LOAD (no write). Times loading all ~1179 entries from the
|
||||
// card and prints the count. We deliberately do NOT bench index.write():
|
||||
// it calls truncate_racily_clean, which diffs the whole working tree
|
||||
// against the index and — because a fresh FAT clone makes every entry look
|
||||
// "racy" (2 s mtime granularity) — re-hashes ~170 MB over SPI, up to ~10 min
|
||||
// on this repo (proven 2026-07-12, index.write max 611 s). The splice
|
||||
// never touches the on-disk index, so that path never runs.
|
||||
bench("repo.index() load", |_| {
|
||||
repo.index().map(|_| ()).context("index open")
|
||||
})?;
|
||||
let n_entries = repo.index().map(|i| i.len()).unwrap_or(0);
|
||||
log::info!("on-disk index has {n_entries} entries");
|
||||
log_map_stats("index load");
|
||||
|
||||
// 5) REFUTED ALTERNATIVE — the index-free in-memory-index commit
|
||||
// (read_tree(HEAD) + add + write_tree_to). It dodges truncate_racily_clean
|
||||
// but is still O(N_tree): the 2026-07-12 real-repo run measured ~77 s for
|
||||
// the cold read_tree and drove the mmap cache to 7.4 MB (zlib OOM). Kept
|
||||
// for regression tracking, run LAST so a crash here can't cost the splice
|
||||
// data above. The cold read_tree is now timed explicitly (the 77 s was
|
||||
// previously visible only via log timestamps); the ops above warmed only
|
||||
// ~depth of the ~158 tree windows, so this is still ~cold.
|
||||
let t = Instant::now();
|
||||
{
|
||||
let mut idx = git2::Index::new().context("Index::new")?;
|
||||
idx.read_tree(&head_tree).context("seed read_tree")?;
|
||||
}
|
||||
log::info!(
|
||||
"seed read_tree(HEAD) cold {:.1} ms",
|
||||
t.elapsed().as_micros() as f64 / 1000.0
|
||||
);
|
||||
log_map_stats("read_tree");
|
||||
|
||||
// Warm repeats: windows resident → pure CPU + cache lookups.
|
||||
bench("Index::new + read_tree", |_| {
|
||||
let mut idx = git2::Index::new().context("Index::new")?;
|
||||
idx.read_tree(&head_tree).context("read_tree")?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let edit_path_bytes = edit_path.join("/").into_bytes();
|
||||
bench("index-free stage→tree", |i| {
|
||||
let mut idx = git2::Index::new().context("Index::new")?;
|
||||
idx.read_tree(&head_tree).context("read_tree")?;
|
||||
let data = format!("typoena index-free bench edit #{i}\n");
|
||||
let oid = repo.blob(data.as_bytes()).context("write blob")?;
|
||||
idx.add(&blob_entry(&edit_path_bytes, oid)).context("index.add")?;
|
||||
idx.write_tree_to(&repo).map(|_| ()).context("write_tree_to")
|
||||
})?;
|
||||
log_map_stats("index-free");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PROTOTYPE of the real fix (destined for `git_sync::stage_and_commit`): return
|
||||
/// a new tree OID equal to `base` with `path` set to `new` — `Some(blob)` to
|
||||
/// add/replace, `None` to delete. Reads ~depth subtree objects, writes ~depth
|
||||
/// trees; every other entry (all 1179 files, the 150 MB of images) is carried
|
||||
/// forward by OID without ever being read. `base = None` builds a fresh subtree
|
||||
/// chain (new file in a new directory). The git_sync version must additionally
|
||||
/// drop a directory entry when a delete empties its subtree; the bench only
|
||||
/// exercises replace.
|
||||
fn splice(repo: &Repository, base: Option<&Tree>, path: &[&str], new: Option<Oid>) -> Result<Oid> {
|
||||
let (head, rest) = path.split_first().context("splice: empty path")?;
|
||||
let mut tb = repo.treebuilder(base).context("treebuilder")?;
|
||||
if rest.is_empty() {
|
||||
match new {
|
||||
Some(oid) => {
|
||||
tb.insert(*head, oid, 0o100644).context("insert blob")?;
|
||||
}
|
||||
None => {
|
||||
let _ = tb.remove(*head); // already absent ⇒ nothing to delete
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let sub = match base.and_then(|b| b.get_name(head)) {
|
||||
Some(e) if e.kind() == Some(ObjectType::Tree) => {
|
||||
Some(repo.find_tree(e.id()).context("loading subtree")?)
|
||||
}
|
||||
_ => None, // no such dir yet (or a blob in the way): build from empty
|
||||
};
|
||||
let new_sub = splice(repo, sub.as_ref(), rest, new)?;
|
||||
tb.insert(*head, new_sub, 0o040000).context("insert subtree")?;
|
||||
}
|
||||
tb.write().context("treebuilder write")
|
||||
}
|
||||
|
||||
/// Find a real file to "edit": descend the first subtree at each level (capped),
|
||||
/// then take the first blob of the deepest tree reached. Reads O(depth) tree
|
||||
/// objects — never `read_tree`/materialise the whole tree (that's the 77 s op
|
||||
/// this bench exists to retire).
|
||||
fn find_edit_path(repo: &Repository, root: &Tree) -> Result<Vec<String>> {
|
||||
let mut path = Vec::new();
|
||||
let mut cur_id = root.id();
|
||||
for _ in 0..6 {
|
||||
let cur = repo.find_tree(cur_id).context("descending tree")?;
|
||||
match cur.iter().find(|e| e.kind() == Some(ObjectType::Tree)) {
|
||||
Some(sub) => {
|
||||
path.push(sub.name().context("non-utf8 tree name")?.to_string());
|
||||
cur_id = sub.id();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let cur = repo.find_tree(cur_id).context("leaf tree")?;
|
||||
let blob = cur
|
||||
.iter()
|
||||
.find(|e| e.kind() == Some(ObjectType::Blob))
|
||||
.context("no blob along the first-subtree chain — pick an edit path manually")?;
|
||||
path.push(blob.name().context("non-utf8 blob name")?.to_string());
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
/// Counters from the p_mmap emulation in `components/libgit2/esp_map.c`.
|
||||
/// Post cache-removal: `hits` is always 0, `misses` counts every mapping,
|
||||
/// `cached_kb` reports the LIVE mapped bytes (the mwindow working set).
|
||||
fn esp_map_stats(hits: *mut u32, misses: *mut u32, read_kb: *mut u32, cached_kb: *mut u32);
|
||||
}
|
||||
|
||||
/// Log the p_mmap counters — mappings performed, total KB read from the card,
|
||||
/// and KB currently live-mapped (should track mwindow's open windows and stay
|
||||
/// well under MWINDOW_MAPPED_LIMIT now that munmap frees immediately).
|
||||
fn log_map_stats(label: &str) {
|
||||
let (mut hits, mut misses, mut read_kb, mut cached_kb) = (0u32, 0u32, 0u32, 0u32);
|
||||
unsafe { esp_map_stats(&mut hits, &mut misses, &mut read_kb, &mut cached_kb) };
|
||||
let _ = hits; // always 0 since the cache removal; slot kept for ABI stability
|
||||
// Free heap spans PSRAM here; a drop toward 0 during write_tree/commit on the
|
||||
// real repo would point at mwindow/idx allocation pressure (or thrash) as the
|
||||
// cause of an apparent hang, not CPU.
|
||||
let free_kb = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() } / 1024;
|
||||
log::info!(
|
||||
"mmap @ {label:<11} {misses} maps, {read_kb} KB read, {cached_kb} KB live, {free_kb} KB heap free"
|
||||
);
|
||||
}
|
||||
|
||||
/// Announce, time, and summarize an op. The `→ label …` line prints BEFORE the op
|
||||
/// runs, so if an op hangs on the real 570 MB-pack repo we can see which one it
|
||||
/// entered — a bare `summarize` prints only after all N iters, hiding the culprit.
|
||||
fn bench<F: FnMut(usize) -> Result<()>>(label: &str, op: F) -> Result<()> {
|
||||
log::info!("→ {label} …");
|
||||
summarize(label, time_each(op)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A minimal index entry pointing at an already-written blob — for `index.add`,
|
||||
/// which (unlike `add_frombuffer`) needs no repo owner, so it works on a bare
|
||||
/// in-memory index. Only `id`, `path` and `mode` feed the tree write.
|
||||
fn blob_entry(path: &[u8], oid: Oid) -> IndexEntry {
|
||||
IndexEntry {
|
||||
ctime: IndexTime::new(0, 0),
|
||||
mtime: IndexTime::new(0, 0),
|
||||
dev: 0,
|
||||
ino: 0,
|
||||
mode: 0o100644,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
file_size: 0,
|
||||
id: oid,
|
||||
flags: 0,
|
||||
flags_extended: 0,
|
||||
path: path.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `op(i)` for `i in 0..N`, returning each call's wall time in microseconds.
|
||||
fn time_each<F: FnMut(usize) -> Result<()>>(mut op: F) -> Result<Vec<u64>> {
|
||||
let mut times = Vec::with_capacity(N);
|
||||
for i in 0..N {
|
||||
let t = Instant::now();
|
||||
op(i)?;
|
||||
times.push(t.elapsed().as_micros() as u64);
|
||||
}
|
||||
Ok(times)
|
||||
}
|
||||
|
||||
/// Log min / p50 / mean / max in ms for a set of per-call microsecond timings.
|
||||
fn summarize(label: &str, mut times: Vec<u64>) {
|
||||
times.sort_unstable();
|
||||
let n = times.len();
|
||||
let mean = times.iter().sum::<u64>() / n as u64;
|
||||
let ms = |us: u64| us as f64 / 1000.0;
|
||||
log::info!(
|
||||
"{label:<26} min {:>6.1} p50 {:>6.1} mean {:>6.1} max {:>6.1} ms",
|
||||
ms(times[0]),
|
||||
ms(times[n / 2]),
|
||||
ms(mean),
|
||||
ms(times[n - 1]),
|
||||
);
|
||||
}
|
||||
389
firmware/src/bin/git_push.rs
Normal file
389
firmware/src/bin/git_push.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
//! Spike 7 — Path 2 finish: on-device `init → commit → push` over mbedTLS HTTPS.
|
||||
//!
|
||||
//! Gate D proved the `git2` safe API links and runs on device (SHA1 via
|
||||
//! mbedTLS). This is the real prize: the full publish path the editor's `git`
|
||||
//! module will run, end to end on hardware —
|
||||
//!
|
||||
//! 1. Wi-Fi assoc + SNTP. A valid wall clock is needed twice: mbedTLS checks
|
||||
//! the server cert's validity window, and the commit signature is stamped
|
||||
//! with the current time.
|
||||
//! 2. Mount flash-FAT at /spiflash (partition `storage`, see partitions.csv).
|
||||
//! The working copy lives here — the ADR-007 storage question is settled
|
||||
//! *for the spike* as flash-FAT (sidesteps the still-unresolved SD card);
|
||||
//! SD stays the product plan of record.
|
||||
//! 3. `git init` a fresh working copy, write a file, stage it, commit with the
|
||||
//! configured author (message = a timestamp), and push HEAD to a fresh
|
||||
//! per-boot branch `device/<unix>` on the HTTPS remote, PAT in the
|
||||
//! credential callback (never logged).
|
||||
//!
|
||||
//! Why push to a *fresh* branch and not `add` onto a clone: a fresh `init` each
|
||||
//! boot has an unrelated history, so pushing onto an existing branch would be a
|
||||
//! non-fast-forward and drag in merge-unrelated-histories handling. A unique
|
||||
//! `device/<unix>` branch is always a clean create — it isolates the actual
|
||||
//! unknown (does the push transport work on device) from history reconciliation.
|
||||
//! The product will hold a *persistent clone* so real publishes fast-forward;
|
||||
//! proving clone/fetch on device is a clean follow-up.
|
||||
//!
|
||||
//! ## Cert verification
|
||||
//!
|
||||
//! libgit2's mbedTLS stream verifies the server chain against whatever CA is set
|
||||
//! via `GIT_OPT_SET_SSL_CERT_LOCATIONS`: the handshake is `VERIFY_OPTIONAL`, then
|
||||
//! `verify_server_cert` turns a bad/untrusted chain into `GIT_ECERTIFICATE`. We
|
||||
//! embed GitHub's root CAs (`github_roots.pem`), write them to `/spiflash/ca.pem`
|
||||
//! and point libgit2 there (`install_tls_trust_store`). The `certificate_check`
|
||||
//! callback then returns PASSTHROUGH — "honor libgit2's own result" — which the
|
||||
//! http transport maps to `is_valid ? 0 : -1` (httpclient.c:805), so an
|
||||
//! untrusted / MITM cert FAILS the push. Fail-closed, no blanket-accept.
|
||||
//!
|
||||
//! Scope note: esp-idf ships a full CA bundle (Spike 6), but it's attached to
|
||||
//! esp-idf's own mbedtls config, not libgit2's private one — bridging it would
|
||||
//! mean touching libgit2 sources. Embedding GitHub's roots is the minimal,
|
||||
//! source-clean trust store; refresh it if GitHub rotates CAs (a product would
|
||||
//! prefer esp-idf's bundle via a custom subtransport, or fetch roots at
|
||||
//! provisioning). See ADR-005 / the Spike 7 postmortem.
|
||||
//!
|
||||
//! Build/flash with `just flash-git-push` (needs the git TW_* vars in .env).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::CStr;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use esp_idf_svc::eventloop::EspSystemEventLoop;
|
||||
use esp_idf_svc::hal::delay::FreeRtos;
|
||||
use esp_idf_svc::hal::peripherals::Peripherals;
|
||||
use esp_idf_svc::nvs::EspDefaultNvsPartition;
|
||||
use esp_idf_svc::sntp::{EspSntp, SyncStatus};
|
||||
use esp_idf_svc::sys::{self, esp};
|
||||
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
|
||||
use firmware::net::connect_wifi;
|
||||
use git2::{
|
||||
CertificateCheckStatus, Cred, CredentialType, IndexAddOption, PushOptions, RemoteCallbacks,
|
||||
Repository, Signature,
|
||||
};
|
||||
|
||||
/// Injected by build.rs so serial output identifies the exact build.
|
||||
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
||||
|
||||
// Baked in at build time from firmware/.env (see build.rs). Empty when unset;
|
||||
// checked at runtime so the editor build never depends on them.
|
||||
const WIFI_SSID: &str = env!("TW_WIFI_SSID");
|
||||
const WIFI_PASS: &str = env!("TW_WIFI_PASS");
|
||||
const REMOTE_URL: &str = env!("TW_REMOTE_URL");
|
||||
const GH_USER: &str = env!("TW_GH_USER");
|
||||
const PAT: &str = env!("TW_PAT");
|
||||
const AUTHOR_NAME: &str = env!("TW_AUTHOR_NAME");
|
||||
const AUTHOR_EMAIL: &str = env!("TW_AUTHOR_EMAIL");
|
||||
|
||||
/// flash-FAT partition (partitions.csv) and its VFS mount point.
|
||||
const FAT_LABEL: &CStr = c"storage";
|
||||
const MOUNT: &CStr = c"/spiflash";
|
||||
const MOUNT_STR: &str = "/spiflash";
|
||||
|
||||
/// GitHub's root CAs, embedded so the push can verify the server's TLS chain
|
||||
/// (see the "Cert verification" module docs). Written to FAT at runtime and
|
||||
/// handed to libgit2 via GIT_OPT_SET_SSL_CERT_LOCATIONS.
|
||||
const GITHUB_ROOTS_PEM: &str = include_str!("github_roots.pem");
|
||||
/// Where install_tls_trust_store drops the bundle for libgit2's mbedTLS stream
|
||||
/// to parse (needs a real path — CONFIG_MBEDTLS_FS_IO is on).
|
||||
const CA_BUNDLE_PATH: &str = "/spiflash/ca.pem";
|
||||
|
||||
/// SNTP first-sync budget (same as Spike 6).
|
||||
const SNTP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Stack for the dedicated git thread (see run()). libgit2's init→push chain
|
||||
/// measured ~67 KB on hardware and the push is deeper; 96 KB is the value proven
|
||||
/// on the main task before git moved off it. Sizing the thread — not the shared
|
||||
/// main-task stack — is the whole point of this task: the editor build no longer
|
||||
/// reserves it. Allocated from internal DRAM only while a push is running.
|
||||
const GIT_STACK: usize = 96 * 1024;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Required once before any esp-idf-svc call; some runtime patches only link
|
||||
// if this symbol is referenced. See esp-idf-template#71.
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
log::info!("Typoena — Spike 7 Path 2 finish (on-device git push), {BUILD_TAG}");
|
||||
|
||||
if let Err(e) = run() {
|
||||
log::error!("❌ Spike 7 setup failed: {e:?}");
|
||||
}
|
||||
|
||||
// Reached only on a setup error (run() idles forever on the happy path).
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
if WIFI_SSID.is_empty() {
|
||||
bail!("TW_WIFI_SSID is empty — set the network + git TW_* vars in firmware/.env");
|
||||
}
|
||||
if REMOTE_URL.is_empty() || GH_USER.is_empty() || PAT.is_empty() {
|
||||
bail!("TW_REMOTE_URL / TW_GH_USER / TW_PAT must all be set in firmware/.env");
|
||||
}
|
||||
|
||||
let peripherals = Peripherals::take()?;
|
||||
let sys_loop = EspSystemEventLoop::take()?;
|
||||
let nvs = EspDefaultNvsPartition::take()?;
|
||||
|
||||
// Wi-Fi is bound here and, on the happy path, NEVER dropped: run() idles at
|
||||
// the end rather than returning. Dropping EspWifi runs wifi_deinit, whose
|
||||
// free() asserts if the git work left the heap in a bad state — an earlier
|
||||
// revision crashed exactly there (tlsf_free in wifi_deinit), which masked
|
||||
// the git-thread logs. Keeping the radio up surfaces the real result.
|
||||
let _wifi = {
|
||||
let mut wifi = BlockingWifi::wrap(
|
||||
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
|
||||
sys_loop,
|
||||
)?;
|
||||
connect_wifi(&mut wifi, WIFI_SSID, WIFI_PASS)?;
|
||||
let ip = wifi.wifi().sta_netif().get_ip_info()?;
|
||||
log::info!("Wi-Fi up — IP {}", ip.ip);
|
||||
wifi
|
||||
};
|
||||
|
||||
sync_clock()?;
|
||||
mount_fat().context("mounting flash-FAT")?;
|
||||
install_tls_trust_store().context("installing TLS trust store")?;
|
||||
|
||||
// Git runs on a DEDICATED large-stack thread, not the main task. libgit2's
|
||||
// call chain is deeply stack-hungry (see GIT_STACK), and sizing the *shared*
|
||||
// main-task stack for it made the editor build over-reserve ~80 KB. Its own
|
||||
// thread lets the main task stay small (sdkconfig.defaults, back to 12 KB).
|
||||
//
|
||||
// NB: an earlier iteration ran git on a std::thread and appeared to fail,
|
||||
// which we wrongly blamed on a "newlib time() lock that only works on main".
|
||||
// The real cause was the default 4 KB pthread stack overflowing — the same
|
||||
// chain just smashed the stack sooner. An explicit stack_size fixes it; there
|
||||
// is no thread-vs-main limitation. (Verified: the push below runs mbedTLS +
|
||||
// FATFS timestamping off-main.)
|
||||
//
|
||||
// Errors are LOGGED, not propagated, so the radio stays up (_wifi is held on
|
||||
// this task) and the monitor shows the result. join() blocks main until git
|
||||
// finishes; a panic almost certainly means GIT_STACK is too small.
|
||||
let git = std::thread::Builder::new()
|
||||
.name("git".into())
|
||||
.stack_size(GIT_STACK)
|
||||
.spawn(git_publish)
|
||||
.context("spawning git thread")?;
|
||||
match git.join() {
|
||||
Ok(Ok(summary)) => log::info!("✅ Spike 7 complete — {summary}"),
|
||||
Ok(Err(e)) => log::error!("❌ git_publish failed: {e:?}"),
|
||||
Err(_) => log::error!("❌ git thread panicked — likely stack overflow, raise GIT_STACK"),
|
||||
}
|
||||
|
||||
log::info!("idling with Wi-Fi up — press reset to re-run");
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off SNTP and block until first sync. Required before TLS (cert validity)
|
||||
/// and before committing (signature timestamp). Mirrors Spike 6.
|
||||
fn sync_clock() -> Result<()> {
|
||||
let sntp = EspSntp::new_default()?;
|
||||
log::info!("SNTP started, waiting for first sync…");
|
||||
let start = Instant::now();
|
||||
while sntp.get_sync_status() != SyncStatus::Completed {
|
||||
if start.elapsed() >= SNTP_TIMEOUT {
|
||||
bail!("SNTP did not sync within {SNTP_TIMEOUT:?} — TLS + commit time would be wrong");
|
||||
}
|
||||
FreeRtos::delay_ms(100);
|
||||
}
|
||||
let unix = now_unix();
|
||||
if unix < 1_700_000_000 {
|
||||
bail!("clock still at {unix} after SNTP — refusing TLS/commit with a bad wall clock");
|
||||
}
|
||||
log::info!("clock synced — unix {unix}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mount the flash-FAT `storage` partition at /spiflash, formatting on first
|
||||
/// boot. Unlike the SD spike (which must never reformat a user's card),
|
||||
/// `format_if_mount_failed = true` is correct here: `storage` is our own blank
|
||||
/// partition, and a fresh flash needs an initial FAT.
|
||||
fn mount_fat() -> Result<()> {
|
||||
let cfg = sys::esp_vfs_fat_mount_config_t {
|
||||
format_if_mount_failed: true,
|
||||
max_files: 16, // libgit2 opens several files at once (index, refs, objects)
|
||||
allocation_unit_size: 4096,
|
||||
disk_status_check_enable: false,
|
||||
use_one_fat: false,
|
||||
};
|
||||
// SAFETY: valid C strings + config; the driver fills `wl` on success.
|
||||
let mut wl: sys::wl_handle_t = 0;
|
||||
esp!(unsafe {
|
||||
sys::esp_vfs_fat_spiflash_mount_rw_wl(MOUNT.as_ptr(), FAT_LABEL.as_ptr(), &cfg, &mut wl)
|
||||
})
|
||||
.context("esp_vfs_fat_spiflash_mount_rw_wl (is the `storage` partition flashed?)")?;
|
||||
|
||||
let (mut total, mut free) = (0u64, 0u64);
|
||||
// Best-effort usage report.
|
||||
if unsafe { sys::esp_vfs_fat_info(MOUNT.as_ptr(), &mut total, &mut free) } == sys::ESP_OK {
|
||||
log::info!(
|
||||
"flash-FAT mounted at {MOUNT_STR} — {} KiB total, {} KiB free",
|
||||
total / 1024,
|
||||
free / 1024
|
||||
);
|
||||
} else {
|
||||
log::info!("flash-FAT mounted at {MOUNT_STR}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the embedded GitHub root CAs to FAT and point libgit2's mbedTLS stream
|
||||
/// at them (GIT_OPT_SET_SSL_CERT_LOCATIONS). Must run after the FAT mount and
|
||||
/// before the push. Once set, libgit2 verifies the server chain itself, so the
|
||||
/// push fails closed on an untrusted cert (the push callback returns PASSTHROUGH
|
||||
/// to honor that result — see the module docs).
|
||||
fn install_tls_trust_store() -> Result<()> {
|
||||
fs::write(CA_BUNDLE_PATH, GITHUB_ROOTS_PEM)
|
||||
.with_context(|| format!("writing CA bundle to {CA_BUNDLE_PATH}"))?;
|
||||
// SAFETY: sets a process-global libgit2 option once, before any TLS work.
|
||||
unsafe { git2::opts::set_ssl_cert_file(CA_BUNDLE_PATH) }
|
||||
.context("git2::opts::set_ssl_cert_file")?;
|
||||
log::info!(
|
||||
"TLS trust store installed — {} B of GitHub roots at {CA_BUNDLE_PATH}",
|
||||
GITHUB_ROOTS_PEM.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The whole publish (runs on the dedicated git thread — see run()): init a fresh
|
||||
/// working copy, write a file, commit, and push to a fresh remote branch. Returns
|
||||
/// a one-line summary.
|
||||
fn git_publish() -> Result<String> {
|
||||
log::info!("git_publish started — free heap {}", free_heap());
|
||||
let unix = now_unix();
|
||||
|
||||
// Fresh working copy per boot in a UNIQUE dir — never deleted. libgit2
|
||||
// writes loose objects read-only, and FATFS refuses to f_unlink a read-only
|
||||
// file (→ EACCES), so a wipe-and-reinit strategy can't clean a prior repo.
|
||||
// Unique dirs sidestep that; the 4 MB partition holds many tiny repos, and
|
||||
// each boot pushes to its own branch anyway. (Cleanup of old dirs is a
|
||||
// product concern, not a spike one.)
|
||||
let repo_dir = format!("{MOUNT_STR}/wc-{unix}");
|
||||
let repo = Repository::init(&repo_dir).context("git init working copy")?;
|
||||
log::info!("init OK at {repo_dir} — free heap {}", free_heap());
|
||||
|
||||
// One tracked file. Content is disposable; it just makes a non-empty tree.
|
||||
let path = format!("{repo_dir}/device.md");
|
||||
let body = format!("# Typoena on-device publish\n\nunix: {unix}\n{BUILD_TAG}\n");
|
||||
fs::File::create(&path)
|
||||
.and_then(|mut f| f.write_all(body.as_bytes()))
|
||||
.context("writing device.md")?;
|
||||
log::info!("wrote device.md");
|
||||
|
||||
// Stage (add --all semantics) and commit with the configured author. Message
|
||||
// is the timestamp — the product's `git` module will use a proper ISO-8601
|
||||
// string (desktop spike uses chrono); unix seconds keep this bench binary
|
||||
// dependency-free.
|
||||
let mut index = repo.index().context("opening index")?;
|
||||
index
|
||||
.add_all(["*"], IndexAddOption::DEFAULT, None)
|
||||
.context("staging (add --all)")?;
|
||||
index.write().context("writing index")?;
|
||||
let tree = repo.find_tree(index.write_tree().context("writing tree")?)?;
|
||||
log::info!("staged + tree written — free heap {}", free_heap());
|
||||
|
||||
let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?;
|
||||
let message = format!("Typoena on-device publish — unix {unix}");
|
||||
repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[])
|
||||
.context("creating commit")?;
|
||||
let local = repo
|
||||
.head()?
|
||||
.shorthand()
|
||||
.context("HEAD has no branch shorthand")?
|
||||
.to_string();
|
||||
log::info!("committed to {local} — free heap {}", free_heap());
|
||||
|
||||
// Point origin at the HTTPS remote and push to a fresh per-boot branch.
|
||||
let remote_branch = format!("device/{unix}");
|
||||
let refspec = format!("refs/heads/{local}:refs/heads/{remote_branch}");
|
||||
repo.remote("origin", REMOTE_URL)
|
||||
.context("creating origin remote")?;
|
||||
log::info!("origin set; pushing {refspec} — free heap {}", free_heap());
|
||||
|
||||
push(&repo, &refspec).with_context(|| format!("pushing {refspec}"))?;
|
||||
|
||||
log::info!(
|
||||
"push returned — free heap {}, min-ever {}",
|
||||
free_heap(),
|
||||
min_free_heap()
|
||||
);
|
||||
Ok(format!("pushed {local} → origin/{remote_branch} over mbedTLS HTTPS"))
|
||||
}
|
||||
|
||||
/// Push `refspec` to origin over HTTPS. Binds the PAT credential + the (spike)
|
||||
/// cert-accept callback, and surfaces a server-side ref rejection as an error.
|
||||
fn push(repo: &Repository, refspec: &str) -> Result<()> {
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
|
||||
// Server-side per-ref status arrives via a callback, NOT as a push() error.
|
||||
// An Rc<RefCell<…>> lets the callback own a handle while we read the result
|
||||
// after push() returns (the desktop spike uses the same shape).
|
||||
let rejection: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||
let mut cbs = RemoteCallbacks::new();
|
||||
|
||||
cbs.credentials(|_url, _user_from_url, allowed| {
|
||||
// GitHub over HTTPS asks for USER_PASS_PLAINTEXT: the PAT is the
|
||||
// password. The PAT is handed to libgit2 here and never logged.
|
||||
if allowed.contains(CredentialType::USER_PASS_PLAINTEXT) {
|
||||
return Cred::userpass_plaintext(GH_USER, PAT);
|
||||
}
|
||||
Err(git2::Error::from_str(
|
||||
"server did not offer USER_PASS_PLAINTEXT — cannot authenticate with a PAT",
|
||||
))
|
||||
});
|
||||
|
||||
// Real verification: libgit2's mbedTLS stream checks the server chain
|
||||
// against the CA bundle install_tls_trust_store() loaded. PASSTHROUGH tells
|
||||
// libgit2 to honor that result — the http transport maps it to
|
||||
// `is_valid ? 0 : -1` (httpclient.c:805), so an untrusted cert FAILS the
|
||||
// push (fail-closed). No blanket-accept.
|
||||
cbs.certificate_check(|_cert, host| {
|
||||
log::info!("verifying {host} TLS chain against embedded GitHub CA bundle");
|
||||
Ok(CertificateCheckStatus::CertificatePassthrough)
|
||||
});
|
||||
|
||||
{
|
||||
let rejection = rejection.clone();
|
||||
cbs.push_update_reference(move |refname, status| {
|
||||
if let Some(msg) = status {
|
||||
*rejection.borrow_mut() = Some(format!("{refname}: {msg}"));
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
let mut opts = PushOptions::new();
|
||||
opts.remote_callbacks(cbs);
|
||||
remote.push(&[refspec], Some(&mut opts)).context("push transport")?;
|
||||
|
||||
if let Some(msg) = rejection.borrow().clone() {
|
||||
bail!("remote rejected ref: {msg}");
|
||||
}
|
||||
log::info!("push accepted by remote");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current wall-clock seconds since the Unix epoch (valid after SNTP).
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn free_heap() -> u32 {
|
||||
unsafe { sys::esp_get_free_heap_size() }
|
||||
}
|
||||
|
||||
fn min_free_heap() -> u32 {
|
||||
unsafe { sys::esp_get_minimum_free_heap_size() }
|
||||
}
|
||||
45
firmware/src/bin/git_smoke.rs
Normal file
45
firmware/src/bin/git_smoke.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Spike 7 — Path 2 Gate D: the `git2` safe API on device.
|
||||
//!
|
||||
//! Gates A/B/C proved libgit2 compiles, links, and runs via hand externs. This
|
||||
//! replaces those with the real integration path: the `git2` crate's safe Rust
|
||||
//! API, bound to our esp-idf-built libgit2 through `libgit2-sys` in system mode
|
||||
//! (LIBGIT2_NO_VENDOR=1 + the fake pkg-config in firmware/pkgconfig/). If this
|
||||
//! links and runs, the desktop spike's add/commit/push code transfers to device.
|
||||
//!
|
||||
//! Two checks, both in-memory (no filesystem or network needed yet):
|
||||
//! 1. git2::Version — proves the safe wrapper reaches the linked library.
|
||||
//! 2. Oid::hash_object — computes a blob SHA1 through libgit2's ODB, which
|
||||
//! runs the mbedTLS hash backend. The expected hash is known, so a correct
|
||||
//! value proves the whole path (git2 -> libgit2 -> mbedtls) end to end.
|
||||
|
||||
use esp_idf_svc::sys;
|
||||
use git2::{ObjectType, Oid};
|
||||
|
||||
fn main() {
|
||||
sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
log::info!("Typoena — Spike 7 Path 2 Gate D (git2 safe API on device)");
|
||||
|
||||
let (major, minor, patch) = git2::Version::get().libgit2_version();
|
||||
log::info!("git2 crate is talking to libgit2 {major}.{minor}.{patch}");
|
||||
|
||||
// `git hash-object` of the 5 bytes "hello" is a fixed, well-known value.
|
||||
match Oid::hash_object(ObjectType::Blob, b"hello") {
|
||||
Ok(oid) => {
|
||||
log::info!("sha1(blob \"hello\") = {oid}");
|
||||
if oid.to_string() == "b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0" {
|
||||
log::info!("hash matches `git hash-object` — mbedTLS SHA1 backend correct");
|
||||
} else {
|
||||
log::warn!("hash MISMATCH — mbedTLS SHA1 backend produced the wrong digest");
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("Oid::hash_object failed: {e}"),
|
||||
}
|
||||
|
||||
log::info!("✅ git2 safe API linked and ran on device");
|
||||
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
487
firmware/src/bin/git_sync.rs
Normal file
487
firmware/src/bin/git_sync.rs
Normal file
@@ -0,0 +1,487 @@
|
||||
//! Milestone #2, increment A — the PERSISTENT-CLONE publish cycle.
|
||||
//!
|
||||
//! `git_push.rs` proved the transport by doing a *fresh* `git init` each boot and
|
||||
//! pushing an unrelated per-boot branch `device/<unix>` — deliberately sidestepping
|
||||
//! history reconciliation. That is NOT how the editor publishes. The product keeps
|
||||
//! ONE clone of the user's repo on the device and fast-forwards it on every
|
||||
//! `Ctrl-G`. This binary proves the piece `git_push` never exercised on hardware:
|
||||
//! `clone` + persistent `open` + a real fast-forward `push`, over mbedTLS HTTPS+PAT.
|
||||
//!
|
||||
//! Flow: Wi-Fi + SNTP + flash-FAT + TLS trust store (identical to `git_push`) →
|
||||
//! `open` `/spiflash/repo` if it exists, else `clone` the remote → append a line
|
||||
//! to a tracked `notes.md` (a real content change, unlike `git_push`'s throwaway
|
||||
//! file) → stage + commit (parent = current HEAD, or none on an empty remote) →
|
||||
//! push HEAD to its own branch as a fast-forward, with a fetch-and-reconcile on a
|
||||
//! rejected push.
|
||||
//!
|
||||
//! ## Scope (increment A)
|
||||
//!
|
||||
//! The reconcile handles *up-to-date* and *fast-forward*. A true **divergence**
|
||||
//! (a second writer pushed in between) needs a real merge commit written to
|
||||
//! FATFS — the riskiest untested path — and is deferred to increment B; here it
|
||||
//! bails with a clear message. The single-writer appliance case (one device
|
||||
//! publishing) is always a fast-forward, so the happy path never hits it.
|
||||
//!
|
||||
//! Functions are kept standalone (config via the baked `TW_*` consts, no editor
|
||||
//! coupling) so increment C can lift `open_or_clone`/`publish`/`push_with_retry`
|
||||
//! into a reusable `git` module the editor's `Ctrl-G` calls.
|
||||
//!
|
||||
//! Build/flash with `just flash-git-sync` (same TW_* `.env` + partition table as
|
||||
//! `git_push`). Git runs on a dedicated 96 KB thread (see `git_push.rs` / #3).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::CStr;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use esp_idf_svc::eventloop::EspSystemEventLoop;
|
||||
use esp_idf_svc::hal::delay::FreeRtos;
|
||||
use esp_idf_svc::hal::peripherals::Peripherals;
|
||||
use esp_idf_svc::nvs::EspDefaultNvsPartition;
|
||||
use esp_idf_svc::sntp::{EspSntp, SyncStatus};
|
||||
use esp_idf_svc::sys::{self, esp};
|
||||
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
|
||||
use firmware::net::connect_wifi;
|
||||
use git2::build::{CheckoutBuilder, RepoBuilder};
|
||||
use git2::{
|
||||
CertificateCheckStatus, Commit, Cred, CredentialType, FetchOptions, IndexAddOption,
|
||||
PushOptions, RemoteCallbacks, Repository, Signature,
|
||||
};
|
||||
|
||||
/// Injected by build.rs so serial output identifies the exact build.
|
||||
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
||||
|
||||
// Baked in at build time from firmware/.env (see build.rs). Empty when unset;
|
||||
// checked at runtime so the editor build never depends on them.
|
||||
const WIFI_SSID: &str = env!("TW_WIFI_SSID");
|
||||
const WIFI_PASS: &str = env!("TW_WIFI_PASS");
|
||||
const REMOTE_URL: &str = env!("TW_REMOTE_URL");
|
||||
const GH_USER: &str = env!("TW_GH_USER");
|
||||
const PAT: &str = env!("TW_PAT");
|
||||
const AUTHOR_NAME: &str = env!("TW_AUTHOR_NAME");
|
||||
const AUTHOR_EMAIL: &str = env!("TW_AUTHOR_EMAIL");
|
||||
|
||||
/// flash-FAT partition (partitions.csv) and its VFS mount point.
|
||||
const FAT_LABEL: &CStr = c"storage";
|
||||
const MOUNT: &CStr = c"/spiflash";
|
||||
const MOUNT_STR: &str = "/spiflash";
|
||||
|
||||
/// The persistent clone. Unlike `git_push`'s per-boot `wc-<unix>` dirs, this one
|
||||
/// dir survives reboots (flash-FAT is only formatted on a failed mount): boot 1
|
||||
/// clones it, every later boot opens it and fast-forwards — the product model.
|
||||
const REPO_DIR: &str = "/spiflash/repo";
|
||||
/// The tracked file we append to. Stands in for the editor's note file(s).
|
||||
const NOTES_FILE: &str = "notes.md";
|
||||
|
||||
/// Debug/recovery + the read-only-delete test knob: when true, wipe REPO_DIR and
|
||||
/// re-clone from scratch every boot instead of opening the existing clone.
|
||||
/// Deleting the existing clone's objects only succeeds with the esp_stubs
|
||||
/// p_open/p_creat fix (libgit2 objects are otherwise mode 0444 → AM_RDO →
|
||||
/// un-deletable on FAT). Ships **false** — the product opens the persistent
|
||||
/// clone and fast-forwards; flip true to validate the RO-delete fix or to force
|
||||
/// a clean re-clone.
|
||||
const RECLONE_EACH_BOOT: bool = false;
|
||||
|
||||
/// GitHub's root CAs, embedded so the push can verify the server's TLS chain.
|
||||
/// Shared with `git_push` (same file). Written to FAT and handed to libgit2 via
|
||||
/// GIT_OPT_SET_SSL_CERT_LOCATIONS.
|
||||
const GITHUB_ROOTS_PEM: &str = include_str!("github_roots.pem");
|
||||
const CA_BUNDLE_PATH: &str = "/spiflash/ca.pem";
|
||||
|
||||
/// SNTP first-sync budget (same as Spike 6).
|
||||
const SNTP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Stack for the dedicated git thread — clone/checkout is at least as deep as the
|
||||
/// init→push chain `git_push` measured (~67 KB), so keep the proven 96 KB. See #3.
|
||||
const GIT_STACK: usize = 96 * 1024;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
log::info!("Typoena — persistent-clone publish cycle (milestone #2A), {BUILD_TAG}");
|
||||
|
||||
if let Err(e) = run() {
|
||||
log::error!("❌ git_sync setup failed: {e:?}");
|
||||
}
|
||||
|
||||
// Reached only on a setup error (run() idles forever on the happy path).
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
if WIFI_SSID.is_empty() {
|
||||
bail!("TW_WIFI_SSID is empty — set the network + git TW_* vars in firmware/.env");
|
||||
}
|
||||
if REMOTE_URL.is_empty() || GH_USER.is_empty() || PAT.is_empty() {
|
||||
bail!("TW_REMOTE_URL / TW_GH_USER / TW_PAT must all be set in firmware/.env");
|
||||
}
|
||||
|
||||
let peripherals = Peripherals::take()?;
|
||||
let sys_loop = EspSystemEventLoop::take()?;
|
||||
let nvs = EspDefaultNvsPartition::take()?;
|
||||
|
||||
// Wi-Fi is bound here and never dropped on the happy path (run() idles at the
|
||||
// end): dropping EspWifi runs wifi_deinit, which can assert if git left the
|
||||
// heap in a bad state and mask the real logs. Keeping the radio up surfaces
|
||||
// the true result. (Same rationale as git_push.rs.)
|
||||
let _wifi = {
|
||||
let mut wifi = BlockingWifi::wrap(
|
||||
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
|
||||
sys_loop,
|
||||
)?;
|
||||
connect_wifi(&mut wifi, WIFI_SSID, WIFI_PASS)?;
|
||||
let ip = wifi.wifi().sta_netif().get_ip_info()?;
|
||||
log::info!("Wi-Fi up — IP {}", ip.ip);
|
||||
wifi
|
||||
};
|
||||
|
||||
sync_clock()?;
|
||||
mount_fat().context("mounting flash-FAT")?;
|
||||
install_tls_trust_store().context("installing TLS trust store")?;
|
||||
|
||||
// Git runs on a dedicated 96 KB thread, not the shared main task (see #3 /
|
||||
// git_push.rs). Errors are LOGGED, not propagated, so the radio stays up and
|
||||
// the monitor shows the result.
|
||||
let git = std::thread::Builder::new()
|
||||
.name("git".into())
|
||||
.stack_size(GIT_STACK)
|
||||
.spawn(publish)
|
||||
.context("spawning git thread")?;
|
||||
match git.join() {
|
||||
Ok(Ok(summary)) => log::info!("✅ publish cycle complete — {summary}"),
|
||||
Ok(Err(e)) => log::error!("❌ publish failed: {e:?}"),
|
||||
Err(_) => log::error!("❌ git thread panicked — likely stack overflow, raise GIT_STACK"),
|
||||
}
|
||||
|
||||
log::info!("idling with Wi-Fi up — press reset to publish again");
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/// The publish cycle (on the dedicated git thread): open-or-clone the persistent
|
||||
/// repo, record a change, commit on top of the current branch, and fast-forward
|
||||
/// push it. Returns a one-line summary.
|
||||
fn publish() -> Result<String> {
|
||||
log::info!("publish started — free heap {}", free_heap());
|
||||
|
||||
let (repo, cloned) = open_or_clone()?;
|
||||
log::info!(
|
||||
"repo ready ({}) — free heap {}",
|
||||
if cloned { "cloned" } else { "opened" },
|
||||
free_heap()
|
||||
);
|
||||
|
||||
// Append a line to a tracked file: a real content change (the editor will
|
||||
// write the user's notes here). Create-or-append so it works on the first
|
||||
// publish and every one after.
|
||||
let unix = now_unix();
|
||||
let path = format!("{REPO_DIR}/{NOTES_FILE}");
|
||||
let line = format!("- publish @unix {unix} ({BUILD_TAG})\n");
|
||||
{
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("opening {path}"))?;
|
||||
f.write_all(line.as_bytes()).context("appending note")?;
|
||||
}
|
||||
log::info!("appended a line to {NOTES_FILE}");
|
||||
|
||||
// Stage everything (add --all: also stages deletions, which v0.x note-delete
|
||||
// needs) and build the tree.
|
||||
let mut index = repo.index().context("opening index")?;
|
||||
index
|
||||
.add_all(["*"], IndexAddOption::DEFAULT, None)
|
||||
.context("staging (add --all)")?;
|
||||
index.write().context("writing index")?;
|
||||
let tree = repo.find_tree(index.write_tree().context("writing tree")?)?;
|
||||
|
||||
// Commit on top of the current branch tip (None on an empty/unborn remote).
|
||||
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
|
||||
|
||||
// Nothing-to-publish short-circuit (product spec): if the staged tree matches
|
||||
// the parent's, there is nothing new to push.
|
||||
if let Some(p) = &parent {
|
||||
if p.tree_id() == tree.id() {
|
||||
return Ok(format!("nothing to publish (tree unchanged @ {})", short(p.id())));
|
||||
}
|
||||
}
|
||||
|
||||
let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?;
|
||||
let message = format!("Typoena publish — unix {unix}");
|
||||
let parents: Vec<&Commit> = parent.iter().collect();
|
||||
let oid = repo
|
||||
.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)
|
||||
.context("creating commit")?;
|
||||
let branch = repo
|
||||
.head()?
|
||||
.shorthand()
|
||||
.context("HEAD has no branch shorthand")?
|
||||
.to_string();
|
||||
log::info!("committed {} to {branch} — free heap {}", short(oid), free_heap());
|
||||
|
||||
push_with_retry(&repo, &branch)?;
|
||||
|
||||
log::info!(
|
||||
"push done — free heap {}, min-ever {}",
|
||||
free_heap(),
|
||||
min_free_heap()
|
||||
);
|
||||
Ok(format!(
|
||||
"published {} → origin/{branch} (persistent clone at {REPO_DIR})",
|
||||
short(oid)
|
||||
))
|
||||
}
|
||||
|
||||
/// Open the persistent clone, or clone it on first boot. Returns the repo and
|
||||
/// whether a clone happened. Clone carries the auth + cert callbacks (the remote
|
||||
/// may be private, and the TLS chain is verified either way).
|
||||
fn open_or_clone() -> Result<(Repository, bool)> {
|
||||
if RECLONE_EACH_BOOT && Path::new(REPO_DIR).exists() {
|
||||
log::warn!("RECLONE_EACH_BOOT — removing {REPO_DIR} to re-clone from scratch");
|
||||
remove_tree(Path::new(REPO_DIR)).context("RECLONE_EACH_BOOT wipe")?;
|
||||
}
|
||||
match Repository::open(REPO_DIR) {
|
||||
Ok(repo) => {
|
||||
log::info!("opened existing clone at {REPO_DIR}");
|
||||
Ok((repo, false))
|
||||
}
|
||||
Err(_) => {
|
||||
// A leftover at REPO_DIR is a partial clone (libgit2 refuses to clone
|
||||
// into a non-empty dir, code=Exists). Remove it and re-clone via
|
||||
// remove_tree (path-based) — std::fs::remove_dir_all EACCESes on
|
||||
// esp-idf's FATFS VFS (see remove_tree). Hardware-verified.
|
||||
if Path::new(REPO_DIR).exists() {
|
||||
log::warn!("{REPO_DIR} exists but is not a valid repo — removing stale clone");
|
||||
remove_tree(Path::new(REPO_DIR)).context("removing stale clone")?;
|
||||
}
|
||||
log::info!("cloning {REMOTE_URL} → {REPO_DIR}");
|
||||
let mut fo = FetchOptions::new();
|
||||
fo.remote_callbacks(auth_callbacks());
|
||||
let repo = RepoBuilder::new()
|
||||
.fetch_options(fo)
|
||||
.clone(REMOTE_URL, Path::new(REPO_DIR))
|
||||
.context("clone")?;
|
||||
Ok((repo, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push `branch` to origin; on a rejected push, fetch origin and reconcile, then
|
||||
/// retry once. A true divergence (two writers) needs a merge commit and is
|
||||
/// deferred to increment B — `fetch_and_integrate` bails there.
|
||||
fn push_with_retry(repo: &Repository, branch: &str) -> Result<()> {
|
||||
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
|
||||
match try_push(repo, &refspec) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first) => {
|
||||
log::warn!("push rejected ({first}); fetching origin to reconcile");
|
||||
fetch_and_integrate(repo, branch).context("reconciling after a rejected push")?;
|
||||
log::info!("reconciled with origin; retrying push");
|
||||
try_push(repo, &refspec).context("push after reconcile")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One push attempt over HTTPS. Binds the PAT credential + the cert-verify
|
||||
/// callback, and surfaces a server-side ref rejection (e.g. non-fast-forward) as
|
||||
/// an error (it arrives via `push_update_reference`, not as a `push()` error).
|
||||
fn try_push(repo: &Repository, refspec: &str) -> Result<()> {
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
let rejection: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
let mut cbs = auth_callbacks();
|
||||
{
|
||||
let rejection = rejection.clone();
|
||||
cbs.push_update_reference(move |refname, status| {
|
||||
if let Some(msg) = status {
|
||||
*rejection.borrow_mut() = Some(format!("{refname}: {msg}"));
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
let mut opts = PushOptions::new();
|
||||
opts.remote_callbacks(cbs);
|
||||
remote
|
||||
.push(&[refspec], Some(&mut opts))
|
||||
.context("push transport")?;
|
||||
|
||||
if let Some(msg) = rejection.borrow().clone() {
|
||||
bail!("remote rejected ref: {msg}");
|
||||
}
|
||||
log::info!("push accepted by remote");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch origin and integrate `branch` into the local branch. Handles up-to-date
|
||||
/// and fast-forward (the common single-writer cases). A real divergence needs a
|
||||
/// merge commit written to FATFS — deferred to increment B — so it bails.
|
||||
fn fetch_and_integrate(repo: &Repository, branch: &str) -> Result<()> {
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
let mut fo = FetchOptions::new();
|
||||
fo.remote_callbacks(auth_callbacks());
|
||||
remote
|
||||
.fetch(&[branch], Some(&mut fo), None)
|
||||
.context("fetch origin")?;
|
||||
|
||||
let fetch_head = repo.find_reference("FETCH_HEAD")?;
|
||||
let theirs = repo.reference_to_annotated_commit(&fetch_head)?;
|
||||
let (analysis, _) = repo.merge_analysis(&[&theirs])?;
|
||||
|
||||
if analysis.is_up_to_date() {
|
||||
log::info!("already up to date with origin/{branch}");
|
||||
return Ok(());
|
||||
}
|
||||
if analysis.is_fast_forward() {
|
||||
log::info!("fast-forwarding local {branch} to origin");
|
||||
let refname = format!("refs/heads/{branch}");
|
||||
repo.find_reference(&refname)?
|
||||
.set_target(theirs.id(), "fast-forward to origin")?;
|
||||
repo.set_head(&refname)?;
|
||||
repo.checkout_head(Some(CheckoutBuilder::new().force()))?;
|
||||
return Ok(());
|
||||
}
|
||||
bail!("origin/{branch} diverged from local — a real merge commit is needed (increment B, deferred)")
|
||||
}
|
||||
|
||||
/// Recursively remove a directory tree by PATH — deliberately NOT
|
||||
/// `std::fs::remove_dir_all`. std's version deletes race-free via the
|
||||
/// openat/unlinkat/fdopendir family, which esp-idf's path-based FATFS VFS does
|
||||
/// not implement → it fails with EACCES on the first entry. A plain
|
||||
/// read_dir → remove_file/remove_dir walk uses only what FATFS supports.
|
||||
/// (Hardware-diagnosed: std::remove_dir_all EACCES'd where this succeeds, and
|
||||
/// every entry reported writable — so read-only was never the blocker; the
|
||||
/// esp_stubs p_open/p_creat shim keeps objects writable so this — and libgit2's
|
||||
/// own gc/repack/fetch-prune deletes — never hit a read-only file on FAT.)
|
||||
/// Reads each directory fully before recursing so no readdir handle is open
|
||||
/// during removal.
|
||||
fn remove_tree(path: &Path) -> Result<()> {
|
||||
let meta = fs::symlink_metadata(path).with_context(|| format!("stat {}", path.display()))?;
|
||||
if meta.is_dir() {
|
||||
let children: Vec<_> = fs::read_dir(path)
|
||||
.with_context(|| format!("read_dir {}", path.display()))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
for child in children {
|
||||
remove_tree(&child)?;
|
||||
}
|
||||
fs::remove_dir(path).with_context(|| format!("rmdir {}", path.display()))
|
||||
} else {
|
||||
fs::remove_file(path).with_context(|| format!("unlink {}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth + cert callbacks shared by clone, fetch, and push. Captures only the
|
||||
/// baked consts, so a fresh set can be built per operation (RemoteCallbacks is
|
||||
/// consumed by each). The PAT is handed to libgit2 here and never logged.
|
||||
fn auth_callbacks<'a>() -> RemoteCallbacks<'a> {
|
||||
let mut cbs = RemoteCallbacks::new();
|
||||
cbs.credentials(|_url, _user_from_url, allowed| {
|
||||
if allowed.contains(CredentialType::USER_PASS_PLAINTEXT) {
|
||||
return Cred::userpass_plaintext(GH_USER, PAT);
|
||||
}
|
||||
Err(git2::Error::from_str(
|
||||
"server did not offer USER_PASS_PLAINTEXT — cannot authenticate with a PAT",
|
||||
))
|
||||
});
|
||||
cbs.certificate_check(|_cert, host| {
|
||||
log::info!("verifying {host} TLS chain against embedded GitHub CA bundle");
|
||||
Ok(CertificateCheckStatus::CertificatePassthrough)
|
||||
});
|
||||
cbs
|
||||
}
|
||||
|
||||
/// First 8 hex chars of an OID, for readable logs.
|
||||
fn short(oid: git2::Oid) -> String {
|
||||
oid.to_string()[..8].to_string()
|
||||
}
|
||||
|
||||
/// Kick off SNTP and block until first sync. Required before TLS (cert validity)
|
||||
/// and before committing (signature timestamp). Mirrors Spike 6 / git_push.
|
||||
fn sync_clock() -> Result<()> {
|
||||
let sntp = EspSntp::new_default()?;
|
||||
log::info!("SNTP started, waiting for first sync…");
|
||||
let start = Instant::now();
|
||||
while sntp.get_sync_status() != SyncStatus::Completed {
|
||||
if start.elapsed() >= SNTP_TIMEOUT {
|
||||
bail!("SNTP did not sync within {SNTP_TIMEOUT:?} — TLS + commit time would be wrong");
|
||||
}
|
||||
FreeRtos::delay_ms(100);
|
||||
}
|
||||
let unix = now_unix();
|
||||
if unix < 1_700_000_000 {
|
||||
bail!("clock still at {unix} after SNTP — refusing TLS/commit with a bad wall clock");
|
||||
}
|
||||
log::info!("clock synced — unix {unix}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mount the flash-FAT `storage` partition at /spiflash, formatting only on a
|
||||
/// failed mount (so the persistent clone survives reboots). Mirrors git_push.
|
||||
fn mount_fat() -> Result<()> {
|
||||
let cfg = sys::esp_vfs_fat_mount_config_t {
|
||||
format_if_mount_failed: true,
|
||||
max_files: 16, // libgit2 opens several files at once (index, refs, objects)
|
||||
allocation_unit_size: 4096,
|
||||
disk_status_check_enable: false,
|
||||
use_one_fat: false,
|
||||
};
|
||||
let mut wl: sys::wl_handle_t = 0;
|
||||
esp!(unsafe {
|
||||
sys::esp_vfs_fat_spiflash_mount_rw_wl(MOUNT.as_ptr(), FAT_LABEL.as_ptr(), &cfg, &mut wl)
|
||||
})
|
||||
.context("esp_vfs_fat_spiflash_mount_rw_wl (is the `storage` partition flashed?)")?;
|
||||
|
||||
let (mut total, mut free) = (0u64, 0u64);
|
||||
if unsafe { sys::esp_vfs_fat_info(MOUNT.as_ptr(), &mut total, &mut free) } == sys::ESP_OK {
|
||||
log::info!(
|
||||
"flash-FAT mounted at {MOUNT_STR} — {} KiB total, {} KiB free",
|
||||
total / 1024,
|
||||
free / 1024
|
||||
);
|
||||
} else {
|
||||
log::info!("flash-FAT mounted at {MOUNT_STR}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the embedded GitHub root CAs to FAT and point libgit2's mbedTLS stream
|
||||
/// at them. Must run after the FAT mount and before any TLS. Mirrors git_push.
|
||||
fn install_tls_trust_store() -> Result<()> {
|
||||
fs::write(CA_BUNDLE_PATH, GITHUB_ROOTS_PEM)
|
||||
.with_context(|| format!("writing CA bundle to {CA_BUNDLE_PATH}"))?;
|
||||
// SAFETY: sets a process-global libgit2 option once, before any TLS work.
|
||||
unsafe { git2::opts::set_ssl_cert_file(CA_BUNDLE_PATH) }
|
||||
.context("git2::opts::set_ssl_cert_file")?;
|
||||
log::info!(
|
||||
"TLS trust store installed — {} B of GitHub roots at {CA_BUNDLE_PATH}",
|
||||
GITHUB_ROOTS_PEM.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current wall-clock seconds since the Unix epoch (valid after SNTP).
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn free_heap() -> u32 {
|
||||
unsafe { sys::esp_get_free_heap_size() }
|
||||
}
|
||||
|
||||
fn min_free_heap() -> u32 {
|
||||
unsafe { sys::esp_get_minimum_free_heap_size() }
|
||||
}
|
||||
110
firmware/src/bin/github_roots.pem
Normal file
110
firmware/src/bin/github_roots.pem
Normal file
@@ -0,0 +1,110 @@
|
||||
# GitHub HTTPS trust anchors for the on-device git push (Spike 7).
|
||||
# Extracted from the macOS system root store 2026-07-06.
|
||||
# github.com currently chains: leaf -> Sectigo E36 -> Sectigo E46 ->
|
||||
# USERTrust ECC. The others cover GitHub's known RSA / DigiCert chains.
|
||||
# Roots only: the server sends the intermediates during the handshake.
|
||||
#
|
||||
# Regenerate: security find-certificate -a -c <CN> -p \
|
||||
# /System/Library/Keychains/SystemRootCertificates.keychain
|
||||
|
||||
# USERTrust ECC Certification Authority
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDEL
|
||||
MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl
|
||||
eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT
|
||||
JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAx
|
||||
MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgT
|
||||
Ck5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUg
|
||||
VVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlm
|
||||
aWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqflo
|
||||
I+d61SRvU8Za2EurxtW20eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinng
|
||||
o4N+LZfQYcTxmdwlkWOrfzCjtHDix6EznPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0G
|
||||
A1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNVHQ8BAf8EBAMCAQYwDwYD
|
||||
VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBBHU6+4WMB
|
||||
zzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbW
|
||||
RNZu9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# USERTrust RSA Certification Authority
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB
|
||||
iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl
|
||||
cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV
|
||||
BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw
|
||||
MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV
|
||||
BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU
|
||||
aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy
|
||||
dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK
|
||||
AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B
|
||||
3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY
|
||||
tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/
|
||||
Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2
|
||||
VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT
|
||||
79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6
|
||||
c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT
|
||||
Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l
|
||||
c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee
|
||||
UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE
|
||||
Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd
|
||||
BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G
|
||||
A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF
|
||||
Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO
|
||||
VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3
|
||||
ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs
|
||||
8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR
|
||||
iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze
|
||||
Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ
|
||||
XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/
|
||||
qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB
|
||||
VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB
|
||||
L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG
|
||||
jjxDah2nGN59PRbxYvnKkKj9
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# DigiCert Global Root G2
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh
|
||||
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
|
||||
MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT
|
||||
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
|
||||
b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI
|
||||
2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx
|
||||
1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ
|
||||
q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz
|
||||
tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ
|
||||
vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP
|
||||
BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV
|
||||
5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY
|
||||
1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4
|
||||
NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG
|
||||
Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91
|
||||
8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe
|
||||
pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl
|
||||
MrY=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# DigiCert Global Root CA
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh
|
||||
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD
|
||||
QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT
|
||||
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
|
||||
b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB
|
||||
CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97
|
||||
nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt
|
||||
43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P
|
||||
T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4
|
||||
gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO
|
||||
BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR
|
||||
TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw
|
||||
DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr
|
||||
hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg
|
||||
06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF
|
||||
PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls
|
||||
YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
|
||||
CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
|
||||
-----END CERTIFICATE-----
|
||||
321
firmware/src/bin/sd_bench.rs
Normal file
321
firmware/src/bin/sd_bench.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! SD/FAT primitive-op micro-benchmark — investigating the ~700 ms-per-loose-
|
||||
//! object write floor found in the `:sync` commit split (2026-07-12, see
|
||||
//! `docs/tradeoff-curves/sync-commit-staging.md`).
|
||||
//!
|
||||
//! The split showed a single small git loose object (`write_tree` = one tree
|
||||
//! object) takes ~710 ms to land on the card, and it is **not** fsync
|
||||
//! (`GIT_OPT_ENABLE_FSYNC_GITDIR` is off). libgit2's loose-object write
|
||||
//! (`odb_loose.c` `loose_backend__write` → `git_filebuf_commit_at`) is, per object:
|
||||
//!
|
||||
//! stat(final) — freshen probe, misses (our `utimes` stub → `stat`)
|
||||
//! open+write+close — a temp file (`GIT_FILEBUF_TEMPORARY`)
|
||||
//! [mkdir objects/xx once per fan-out]
|
||||
//! p_rename — our stub: remove(final) [ENOENT] + rename(temp → final)
|
||||
//!
|
||||
//! i.e. **two directory-mutating writes** (temp create + rename) per object. This
|
||||
//! bench times each FAT primitive in isolation, then a composite that mirrors the
|
||||
//! sequence above, so we can attribute the ~700 ms to specific ops and get a
|
||||
//! baseline to compare an A1/A2 card or a 20 MHz bus against. All writes go to
|
||||
//! `/sd/sdbench` (cleaned up at the end); the pack-seek op additionally opens
|
||||
//! `/sd/repo`'s packfile READ-ONLY — it never writes there.
|
||||
//!
|
||||
//! Flash with `just flash-bench`. Needs no `.env`, no `git` feature (pure SD).
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use esp_idf_svc::hal::delay::FreeRtos;
|
||||
|
||||
use firmware::persistence::Storage;
|
||||
|
||||
/// Injected by build.rs so serial output identifies the exact build.
|
||||
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
||||
|
||||
/// Scratch dir on the card ROOT — outside `/sd/repo`, so a later `:sync` never
|
||||
/// stages it and the user's notes are never touched.
|
||||
const BENCH_DIR: &str = "/sd/sdbench";
|
||||
/// Iterations per op: enough to read min/p50/mean past controller jitter, few
|
||||
/// enough that total write volume stays tiny.
|
||||
const N: usize = 20;
|
||||
/// ~ the size of a small deflated git loose object (blob/tree/commit).
|
||||
const PAYLOAD: [u8; 200] = [b'x'; 200];
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Required once before any esp-idf-svc call (see esp-idf-template#71).
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
log::info!("Typoena — SD primitive bench, {BUILD_TAG}");
|
||||
match run() {
|
||||
Ok(()) => log::info!("sd_bench: done"),
|
||||
Err(e) => log::error!("sd_bench failed: {e:?}"),
|
||||
}
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let sd = Storage::mount().context("mounting SD")?;
|
||||
let (max_khz, real_khz) = sd.negotiated_khz();
|
||||
log::info!(
|
||||
"bus: max {max_khz} kHz, negotiated {real_khz} kHz — {N} iters, {}-byte payload",
|
||||
PAYLOAD.len()
|
||||
);
|
||||
|
||||
// Fresh scratch dir.
|
||||
let _ = remove_tree(std::path::Path::new(BENCH_DIR));
|
||||
fs::create_dir_all(BENCH_DIR).with_context(|| format!("creating {BENCH_DIR}"))?;
|
||||
|
||||
// Warm-up: the first write after mount pays one-time settling — don't measure it.
|
||||
{
|
||||
let mut f = File::create(format!("{BENCH_DIR}/warmup"))?;
|
||||
f.write_all(&PAYLOAD)?;
|
||||
}
|
||||
|
||||
// 1) create + write(200B) + close, a fresh unique file each time. The drop at
|
||||
// the block's end is the close (FatFS f_close flushes dir entry + data).
|
||||
summarize(
|
||||
"create+write(200B)+close",
|
||||
time_each(|i| {
|
||||
let mut f = File::create(format!("{BENCH_DIR}/c{i}"))?;
|
||||
f.write_all(&PAYLOAD)?;
|
||||
Ok(())
|
||||
})?,
|
||||
);
|
||||
|
||||
// 2) rename c{i} -> o{i}. Sources exist from step 1 (untimed setup).
|
||||
summarize(
|
||||
"rename",
|
||||
time_each(|i| {
|
||||
fs::rename(format!("{BENCH_DIR}/c{i}"), format!("{BENCH_DIR}/o{i}")).map_err(Into::into)
|
||||
})?,
|
||||
);
|
||||
|
||||
// 3) stat, hit.
|
||||
summarize(
|
||||
"stat (hit)",
|
||||
time_each(|i| {
|
||||
fs::metadata(format!("{BENCH_DIR}/o{i}"))
|
||||
.map(|_| ())
|
||||
.map_err(Into::into)
|
||||
})?,
|
||||
);
|
||||
|
||||
// 4) stat, miss (ENOENT) — the freshen-probe analogue. A read, expected cheap.
|
||||
summarize(
|
||||
"stat (miss/ENOENT)",
|
||||
time_each(|i| {
|
||||
let _ = fs::metadata(format!("{BENCH_DIR}/nope{i}"));
|
||||
Ok(())
|
||||
})?,
|
||||
);
|
||||
|
||||
// 5) remove o{i}.
|
||||
summarize(
|
||||
"remove",
|
||||
time_each(|i| fs::remove_file(format!("{BENCH_DIR}/o{i}")).map_err(Into::into))?,
|
||||
);
|
||||
|
||||
// 6) Composite: the exact loose-object write sequence libgit2 performs, with a
|
||||
// git-length (38-hex) final name so LFN directory-entry cost is included.
|
||||
// If the model is right this lands near the ~700 ms/object from the split.
|
||||
summarize(
|
||||
"loose-object composite",
|
||||
time_each(|i| {
|
||||
let tmp = format!("{BENCH_DIR}/tmp_obj{i}");
|
||||
let fin = format!("{BENCH_DIR}/{i:038x}");
|
||||
let _ = fs::metadata(&fin); // freshen probe, misses
|
||||
{
|
||||
let mut f = File::create(&tmp)?; // temp create + write + close
|
||||
f.write_all(&PAYLOAD)?;
|
||||
}
|
||||
let _ = fs::remove_file(&fin); // p_rename's remove(to) — ENOENT
|
||||
fs::rename(&tmp, &fin)?; // temp -> final
|
||||
Ok(())
|
||||
})?,
|
||||
);
|
||||
|
||||
// 6b) Directory-entry scaling — the ~360 ms/loose-write residual suspect
|
||||
// (2026-07-13, post-FASTSEEK; see sync-commit-staging.md). FAT has no
|
||||
// directory index: every path resolution scans the parent's entries
|
||||
// linearly over SPI, so op cost should grow with sibling count. A repo
|
||||
// `.git/objects/` accumulates up to 256 fan-out dirs, and every loose
|
||||
// write resolves multi-component paths under it several times (freshen
|
||||
// stat, temp create, remove, rename). If stat/create cost climbs with N
|
||||
// here, the residual is directory scans, not data I/O — and the miss
|
||||
// case (full scan, no early exit) is the worst-case bound.
|
||||
for n in [8usize, 64, 256] {
|
||||
let dir = format!("{BENCH_DIR}/fan{n}");
|
||||
fs::create_dir_all(&dir)?;
|
||||
for j in 0..n {
|
||||
File::create(format!("{dir}/e{j:04}"))?; // sibling entries (untimed setup)
|
||||
}
|
||||
summarize(
|
||||
&format!("stat hit, {n:>3} siblings"),
|
||||
time_each(|i| {
|
||||
fs::metadata(format!("{dir}/e{:04}", i % n))
|
||||
.map(|_| ())
|
||||
.map_err(Into::into)
|
||||
})?,
|
||||
);
|
||||
summarize(
|
||||
&format!("stat miss, {n:>3} siblings"),
|
||||
time_each(|i| {
|
||||
let _ = fs::metadata(format!("{dir}/nope{i}"));
|
||||
Ok(())
|
||||
})?,
|
||||
);
|
||||
summarize(
|
||||
&format!("loose composite, {n:>3} sib"),
|
||||
time_each(|i| {
|
||||
let tmp = format!("{dir}/tmp_obj{i}");
|
||||
let fin = format!("{dir}/{i:038x}");
|
||||
let _ = fs::metadata(&fin); // freshen probe, misses
|
||||
{
|
||||
let mut f = File::create(&tmp)?;
|
||||
f.write_all(&PAYLOAD)?;
|
||||
}
|
||||
let _ = fs::remove_file(&fin); // p_rename's remove(to) — ENOENT
|
||||
fs::rename(&tmp, &fin)?;
|
||||
Ok(())
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up so the card is left as we found it.
|
||||
remove_tree(std::path::Path::new(BENCH_DIR))
|
||||
.with_context(|| format!("removing {BENCH_DIR}"))?;
|
||||
|
||||
// 7) THE ~1.5 s LOOSE-WRITE SUSPECT (git_bench, 2026-07-12 second real-repo
|
||||
// run): lseek inside a huge file. Without CONFIG_FATFS_USE_FASTSEEK,
|
||||
// FatFS resolves lseek by walking the file's FAT cluster chain — forward
|
||||
// from the current position, from the CHAIN HEAD on any backward seek.
|
||||
// The 570 MB pack is ~36k clusters ≈ ~146 KB of FAT reads over SPI per
|
||||
// long walk. `p_mmap` (esp_map.c) does lseek+read per window, and
|
||||
// libgit2's freshen path probes the pack TRAILER (near the end) while
|
||||
// tree windows sit at low offsets — so each loose write pays ~one full
|
||||
// walk. Prediction: "@start" stays ~ms; "@end" costs ~1.5 s per iter.
|
||||
// If so, the fix is CONFIG_FATFS_USE_FASTSEEK=y (fast-seek applies to
|
||||
// read-mode files only — exactly how the pack is opened).
|
||||
match find_pack()? {
|
||||
Some(pack) => {
|
||||
let len = fs::metadata(&pack)?.len();
|
||||
log::info!("pack seek bench: {pack} ({} MB)", len / (1024 * 1024));
|
||||
if len < 1024 * 1024 {
|
||||
log::info!("pack too small to show chain-walk cost — skipping (toy card?)");
|
||||
} else {
|
||||
let mut f = File::open(&pack).with_context(|| format!("opening {pack}"))?;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
// Baseline: rewind + read at the chain head — no walk to resolve.
|
||||
summarize(
|
||||
"pack seek+read 4KB @start",
|
||||
time_each(|_| {
|
||||
f.seek(SeekFrom::Start(0))?;
|
||||
f.read_exact(&mut buf)?;
|
||||
Ok(())
|
||||
})?,
|
||||
);
|
||||
// Rewind (cheap, measured above), then seek near the end — pays
|
||||
// one full cluster-chain walk per iteration if fast-seek is off.
|
||||
let high = len - 4096;
|
||||
summarize(
|
||||
"pack seek+read 4KB @end",
|
||||
time_each(|_| {
|
||||
f.seek(SeekFrom::Start(0))?;
|
||||
f.read_exact(&mut buf)?;
|
||||
f.seek(SeekFrom::Start(high))?;
|
||||
f.read_exact(&mut buf)?;
|
||||
Ok(())
|
||||
})?,
|
||||
);
|
||||
}
|
||||
}
|
||||
None => log::info!("no packfile under /sd/repo/.git/objects/pack — skipping seek bench"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Largest `*.pack` under the repo's pack dir, if the card carries a clone.
|
||||
/// Skips macOS AppleDouble sidecars (`._pack-*.pack`, 4 KB of Finder metadata) —
|
||||
/// the Spike-14 cruft in its latest disguise.
|
||||
fn find_pack() -> Result<Option<String>> {
|
||||
let Ok(entries) = fs::read_dir("/sd/repo/.git/objects/pack") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut best: Option<(u64, String)> = None;
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if name.starts_with("._") || !name.ends_with(".pack") {
|
||||
continue;
|
||||
}
|
||||
let len = fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
|
||||
if best.as_ref().is_none_or(|(l, _)| len > *l) {
|
||||
best = Some((len, p.to_string_lossy().into_owned()));
|
||||
}
|
||||
}
|
||||
Ok(best.map(|(_, p)| p))
|
||||
}
|
||||
|
||||
/// `fs::remove_dir_all` replacement: std's version trusts the dirent file
|
||||
/// type, and the prebuilt std decodes esp-idf's DT constants with the generic
|
||||
/// unix table (files read as fifos, directories as char devices — same story
|
||||
/// as the palette walk in main.rs). It therefore `unlink`s subdirectories,
|
||||
/// which FatFS refuses with FR_DENIED (EACCES) when they're non-empty. Decode
|
||||
/// the type the same both-tables way and recurse ourselves.
|
||||
fn remove_tree(dir: &std::path::Path) -> Result<()> {
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let children: Vec<_> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| e.file_type().ok().map(|t| (e.path(), t)))
|
||||
.collect();
|
||||
for (path, ftype) in children {
|
||||
let is_dir = if ftype.is_dir() || ftype.is_char_device() {
|
||||
true
|
||||
} else if ftype.is_file() || ftype.is_fifo() {
|
||||
false
|
||||
} else {
|
||||
fs::metadata(&path)?.is_dir()
|
||||
};
|
||||
if is_dir {
|
||||
remove_tree(&path)?;
|
||||
} else {
|
||||
fs::remove_file(&path)?;
|
||||
}
|
||||
}
|
||||
fs::remove_dir(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `op(i)` for `i in 0..N`, returning each call's wall time in microseconds.
|
||||
fn time_each<F: FnMut(usize) -> Result<()>>(mut op: F) -> Result<Vec<u64>> {
|
||||
let mut times = Vec::with_capacity(N);
|
||||
for i in 0..N {
|
||||
let t = Instant::now();
|
||||
op(i)?;
|
||||
times.push(t.elapsed().as_micros() as u64);
|
||||
}
|
||||
Ok(times)
|
||||
}
|
||||
|
||||
/// Log min / p50 / mean / max in ms for a set of per-call microsecond timings.
|
||||
fn summarize(label: &str, mut times: Vec<u64>) {
|
||||
times.sort_unstable();
|
||||
let n = times.len();
|
||||
let mean = times.iter().sum::<u64>() / n as u64;
|
||||
let ms = |us: u64| us as f64 / 1000.0;
|
||||
log::info!(
|
||||
"{label:<26} min {:>6.1} p50 {:>6.1} mean {:>6.1} max {:>6.1} ms",
|
||||
ms(times[0]),
|
||||
ms(times[n / 2]),
|
||||
ms(mean),
|
||||
ms(times[n - 1]),
|
||||
);
|
||||
}
|
||||
117
firmware/src/bin/sd_fat.rs
Normal file
117
firmware/src/bin/sd_fat.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Spike 3 — SD card (FAT) on its own SPI3 host, now a thin on-device harness
|
||||
//! over the real [`firmware::persistence`] module.
|
||||
//!
|
||||
//! The raw storage stack (SPI3 bring-up, hand-rolled SDSPI descriptors, the
|
||||
//! atomic write/fsync/unlink/rename dance) was proven here first and has since
|
||||
//! graduated into `firmware::persistence` so the editor and this spike share one
|
||||
//! implementation. This binary now just drives that module on hardware:
|
||||
//!
|
||||
//! 1. [`Storage::mount`] — SPI3 (SCK 14, MOSI 15, MISO 13, CS 10; ADR-012) +
|
||||
//! FAT mount at `/sd` + boot crash-recovery. `format_if_mount_failed` is
|
||||
//! false in the module, so the card must already be FAT-formatted (it is —
|
||||
//! Spike 3 formatted it 2026-07-11).
|
||||
//! 2. Report the card's negotiated clock and FAT usage.
|
||||
//! 3. Load `/sd/repo/notes.md` (non-destructive) and report its size.
|
||||
//! 4. Only if there is no notes.md yet (a blank bench card, nothing to lose)
|
||||
//! exercise the real [`Storage::save`] → [`Storage::load`] round-trip and
|
||||
//! byte-compare. On a provisioned card the write test is skipped so the
|
||||
//! user's writing is never clobbered by the bench tool.
|
||||
//!
|
||||
//! Flash with `just flash-sd`. Needs no `.env`.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use esp_idf_svc::hal::delay::FreeRtos;
|
||||
|
||||
use firmware::persistence::{Storage, MAX_FILE_BYTES, NOTES, REPO_DIR};
|
||||
|
||||
/// Injected by build.rs so serial output identifies the exact build.
|
||||
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Required once before any esp-idf-svc call; some runtime patches only link
|
||||
// if this symbol is referenced. See esp-idf-template#71.
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
log::info!("Typoena — Spike 3 (SD/FAT via firmware::persistence), {BUILD_TAG}");
|
||||
|
||||
match run() {
|
||||
Ok(()) => log::info!("✅ Spike 3 complete — persistence::Storage mounts and round-trips"),
|
||||
Err(e) => log::error!("❌ Spike 3 failed: {e:?}"),
|
||||
}
|
||||
|
||||
// Idle instead of returning, so the result stays on the monitor.
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let storage = Storage::mount().context("mounting SD via persistence module")?;
|
||||
|
||||
let (max_khz, real_khz) = storage.negotiated_khz();
|
||||
log::info!("card clock — max {max_khz} kHz, negotiated {real_khz} kHz");
|
||||
|
||||
let (total, free) = storage.usage().context("reading FAT usage")?;
|
||||
log::info!(
|
||||
"FAT usage — {} MiB total, {} MiB free",
|
||||
total / (1024 * 1024),
|
||||
free / (1024 * 1024)
|
||||
);
|
||||
|
||||
if storage.repo_present() {
|
||||
log::info!("{REPO_DIR} present (card is provisioned)");
|
||||
} else {
|
||||
log::warn!("{REPO_DIR} missing — card not provisioned; run `just init` on the host");
|
||||
}
|
||||
|
||||
// Read-back is always safe. An empty string means "no notes.md yet".
|
||||
let existing = storage.load().context("loading notes.md")?;
|
||||
log::info!("notes.md load OK — {} bytes", existing.len());
|
||||
|
||||
if existing.is_empty() {
|
||||
write_test(&storage).context("save/load round-trip")?;
|
||||
} else {
|
||||
log::info!(
|
||||
"notes.md already has content — skipping the destructive write test to protect it \
|
||||
(v0.1 caps notes at {} KiB)",
|
||||
MAX_FILE_BYTES / 1024
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exercise the module's real atomic save + load, then confirm the bytes match.
|
||||
/// Only called when notes.md is empty, so nothing of the user's is at risk.
|
||||
fn write_test(storage: &Storage) -> Result<()> {
|
||||
// The module deliberately never creates the repo dir (a missing one means an
|
||||
// unprovisioned card, which the editor treats as fatal). On a blank bench
|
||||
// card there's nothing to protect, so create it here as explicit bench setup
|
||||
// to give `Storage::save` a directory to write into.
|
||||
if !storage.repo_present() {
|
||||
log::info!("{REPO_DIR} missing — creating it (bench setup) so the write test can run");
|
||||
fs::create_dir_all(REPO_DIR).with_context(|| format!("create {REPO_DIR}"))?;
|
||||
}
|
||||
// End the payload with '\n', like a real editor buffer (whose visible trailing
|
||||
// blank line is exactly that terminating newline). `save` inserts a final
|
||||
// newline only when one is missing and `load` reads verbatim, so a payload that
|
||||
// already ends in '\n' round-trips byte-for-byte — which this exact-equality
|
||||
// check relies on.
|
||||
let payload = format!("typoena spike 3\n{BUILD_TAG}\ndedicated SPI3: SCK14 MOSI15 MISO13 CS10\n");
|
||||
storage.save(&payload).context("Storage::save")?;
|
||||
let back = storage.load().context("Storage::load after save")?;
|
||||
if back != payload {
|
||||
bail!(
|
||||
"read-back mismatch: wrote {} bytes, read {} bytes",
|
||||
payload.len(),
|
||||
back.len()
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"round-trip OK — {} bytes: save {NOTES} (tmp→fsync→unlink→rename) → load identical",
|
||||
payload.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
71
firmware/src/bin/splash.rs
Normal file
71
firmware/src/bin/splash.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Spike 9 — boot splash.
|
||||
//!
|
||||
//! Paints the Typoena wordmark inside a circle, centred on the 792×272 panel,
|
||||
//! with one clean full refresh — the image the appliance shows at boot before
|
||||
//! the editor opens (v0.1's "e-ink shows Typoena splash").
|
||||
//!
|
||||
//! The frame itself is [`display::Frame::splash`], a pure `embedded-graphics`
|
||||
//! drawing shared with `main.rs`'s boot path (so this bench binary and the real
|
||||
//! boot show the identical mark). It draws **vectors** — a stroked circle + a
|
||||
//! centred `FONT_10X20` string — rather than the embedded 1-bit *bitmap* asset
|
||||
//! sketched in `docs/spikes.md`: a deliberate trade. Spike 2 already proved
|
||||
//! vector + font rendering, so the splash carries no new stack risk and needs
|
||||
//! no asset-embed step, but it also does NOT retire the image-blit pipeline the
|
||||
//! doc named as Spike 9's only risk. A raster logo is deferred to v1.0 polish.
|
||||
//!
|
||||
//! EPD bring-up mirrors `main.rs` (SPI2, SCK 12 · DIN/MOSI 11 · CS 7 · DC 6 ·
|
||||
//! RST 5 · BUSY 4), driving the shared [`firmware::epd`] driver. Flash with
|
||||
//! `just flash-splash`. Needs no `.env`.
|
||||
|
||||
use esp_idf_svc::hal::delay::FreeRtos;
|
||||
use esp_idf_svc::hal::gpio::{AnyIOPin, PinDriver, Pull};
|
||||
use esp_idf_svc::hal::peripherals::Peripherals;
|
||||
use esp_idf_svc::hal::spi::config::{Config, DriverConfig};
|
||||
use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver};
|
||||
use esp_idf_svc::hal::units::FromValueType;
|
||||
|
||||
use display::Frame;
|
||||
use firmware::epd::Epd;
|
||||
|
||||
/// Injected by build.rs so serial output identifies the exact build.
|
||||
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// Required once before any esp-idf-svc call; some runtime patches only link
|
||||
// if this symbol is referenced. See esp-idf-template#71.
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
log::info!("Typoena — Spike 9 (boot splash), {BUILD_TAG}");
|
||||
|
||||
let peripherals = Peripherals::take()?;
|
||||
let pins = peripherals.pins;
|
||||
|
||||
// GDEY0579T93 on the same S3-safe GPIOs main.rs uses (Spike 2 wiring):
|
||||
// SCK 12 · DIN/MOSI 11 · CS 7 · DC 6 · RST 5 · BUSY 4
|
||||
let spi = SpiDriver::new(
|
||||
peripherals.spi2,
|
||||
pins.gpio12,
|
||||
pins.gpio11,
|
||||
None::<AnyIOPin>,
|
||||
&DriverConfig::new().dma(Dma::Auto(4096)),
|
||||
)?;
|
||||
let bus = SpiBusDriver::new(spi, &Config::new().baudrate(4.MHz().into()))?;
|
||||
let cs = PinDriver::output(pins.gpio7)?;
|
||||
let dc = PinDriver::output(pins.gpio6)?;
|
||||
let rst = PinDriver::output(pins.gpio5)?;
|
||||
let busy = PinDriver::input(pins.gpio4, Pull::Down)?;
|
||||
let mut epd = Epd::new(bus, dc, rst, cs, busy);
|
||||
|
||||
log::info!("EPD reset + init…");
|
||||
epd.reset()?;
|
||||
epd.init()?;
|
||||
|
||||
log::info!("painting splash…");
|
||||
epd.display_frame(Frame::splash().bytes())?;
|
||||
|
||||
// Idle so the splash stays up and the result stays on the monitor.
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ use esp_idf_svc::hal::peripherals::Peripherals;
|
||||
use esp_idf_svc::http::client::{Configuration as HttpConfig, EspHttpConnection};
|
||||
use esp_idf_svc::nvs::EspDefaultNvsPartition;
|
||||
use esp_idf_svc::sntp::{EspSntp, SyncStatus};
|
||||
use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi};
|
||||
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
|
||||
use firmware::net::connect_wifi;
|
||||
|
||||
/// Injected by build.rs so serial output identifies the exact build.
|
||||
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
||||
@@ -81,7 +82,7 @@ fn run() -> Result<()> {
|
||||
sys_loop,
|
||||
)?;
|
||||
|
||||
connect_wifi(&mut wifi)?;
|
||||
connect_wifi(&mut wifi, WIFI_SSID, WIFI_PASS)?;
|
||||
let ip = wifi.wifi().sta_netif().get_ip_info()?;
|
||||
log::info!("Wi-Fi up — IP {}, GW {}", ip.ip, ip.subnet.gateway);
|
||||
|
||||
@@ -91,35 +92,6 @@ fn run() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Associate with the configured AP and wait for the netif (DHCP) to come up.
|
||||
fn connect_wifi(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
|
||||
// Open network → no auth; otherwise WPA2-Personal (see .env.example for WPA3).
|
||||
let auth_method = if WIFI_PASS.is_empty() {
|
||||
AuthMethod::None
|
||||
} else {
|
||||
AuthMethod::WPA2Personal
|
||||
};
|
||||
|
||||
wifi.set_configuration(&Configuration::Client(ClientConfiguration {
|
||||
ssid: WIFI_SSID
|
||||
.try_into()
|
||||
.ok()
|
||||
.context("SSID longer than 32 bytes")?,
|
||||
password: WIFI_PASS
|
||||
.try_into()
|
||||
.ok()
|
||||
.context("password longer than 64 bytes")?,
|
||||
auth_method,
|
||||
..Default::default()
|
||||
}))?;
|
||||
|
||||
wifi.start()?;
|
||||
log::info!("associating with \"{WIFI_SSID}\"…");
|
||||
wifi.connect().context("Wi-Fi association failed")?;
|
||||
wifi.wait_netif_up().context("DHCP / netif never came up")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kick off SNTP and block until the first sync (or time out). Required before
|
||||
/// TLS: cert validity is checked against wall time.
|
||||
fn sync_clock() -> Result<()> {
|
||||
|
||||
@@ -1,778 +0,0 @@
|
||||
//! Modal text editor core: a vim-style buffer with Normal / Insert (edit) /
|
||||
//! View (read-only) modes, rendered onto the e-paper [`Frame`].
|
||||
//!
|
||||
//! The buffer is plain ASCII — the US-QWERTY decoder only ever produces ASCII
|
||||
//! and Tab expands to spaces on insert — so a byte offset into the `String` is
|
||||
//! also a character index, and `caret` is that offset. Motions and edits work
|
||||
//! on the logical (`\n`-delimited) buffer; word-wrapping and scrolling are a
|
||||
//! render-time concern handled by [`Editor::draw`].
|
||||
|
||||
use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_10X20};
|
||||
use embedded_graphics::mono_font::MonoTextStyle;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
use embedded_graphics::primitives::{PrimitiveStyle, Rectangle};
|
||||
use embedded_graphics::text::{Baseline, Text};
|
||||
|
||||
use crate::epd::{self, Frame};
|
||||
use crate::usb_kbd::Key;
|
||||
|
||||
/// FONT_10X20 cell size and the grid it tiles the panel into.
|
||||
pub const CW: i32 = 10;
|
||||
pub const CH: i32 = 20;
|
||||
const COLS: usize = (epd::WIDTH / 10) as usize; // 79 characters per line
|
||||
const ROWS: usize = (epd::HEIGHT / 20) as usize; // 13 text rows; bottom 12 px = status
|
||||
/// Tab stop, in spaces. Tabs never enter the buffer — they expand on insert so
|
||||
/// the buffer stays 1 char = 1 column.
|
||||
const TAB: &str = " ";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// Navigation and commands (hjkl, w/b/e, dd, x, …).
|
||||
Normal,
|
||||
/// Text entry — keys insert at the caret.
|
||||
Insert,
|
||||
/// Read-only reading: keys scroll the viewport, edits are locked out.
|
||||
View,
|
||||
}
|
||||
|
||||
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Op {
|
||||
Delete,
|
||||
Change,
|
||||
}
|
||||
|
||||
/// The editor state: buffer, caret, mode, viewport, and pending command state.
|
||||
pub struct Editor {
|
||||
text: String,
|
||||
/// Byte offset of the caret (== char index; the buffer is ASCII). Ranges
|
||||
/// over `0..=text.len()`.
|
||||
caret: usize,
|
||||
mode: Mode,
|
||||
/// Index of the first visible display line.
|
||||
scroll_top: usize,
|
||||
/// Pending numeric count prefix (`0` = none), e.g. the `3` in `3j`.
|
||||
count: usize,
|
||||
/// Operator awaiting a motion/text object (`dd`, `dw`, `ciw`, `di(`, …).
|
||||
pending_op: Option<Op>,
|
||||
/// After an operator, an `i`/`a` text-object prefix awaiting the object
|
||||
/// char. `Some(false)` = inner (`i`), `Some(true)` = around (`a`).
|
||||
pending_obj: Option<bool>,
|
||||
/// First `g` of a `gg` awaiting the second.
|
||||
pending_g: bool,
|
||||
}
|
||||
|
||||
/// One wrapped display line: its text and the buffer offset of its first char.
|
||||
struct Line {
|
||||
start: usize,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn new() -> Self {
|
||||
Editor {
|
||||
text: String::new(),
|
||||
caret: 0,
|
||||
mode: Mode::Insert, // writing appliance: power-on = ready to type
|
||||
scroll_top: 0,
|
||||
count: 0,
|
||||
pending_op: None,
|
||||
pending_obj: None,
|
||||
pending_g: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> Mode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
pub fn scroll_top(&self) -> usize {
|
||||
self.scroll_top
|
||||
}
|
||||
|
||||
/// Dispatch one decoded key event according to the current mode.
|
||||
pub fn handle(&mut self, key: Key) {
|
||||
match self.mode {
|
||||
Mode::Insert => self.insert_key(key),
|
||||
Mode::Normal => self.normal_key(key),
|
||||
Mode::View => self.view_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Insert mode -------------------------------------------------------
|
||||
|
||||
fn insert_key(&mut self, key: Key) {
|
||||
match key {
|
||||
Key::Char('\t') => self.insert_str(TAB),
|
||||
Key::Char(c) => self.insert_char(c),
|
||||
Key::Enter => self.insert_char('\n'),
|
||||
Key::Backspace => self.backspace(),
|
||||
Key::DeleteWord => self.delete_word_before(),
|
||||
Key::DeleteLine => self.delete_to_line_start(),
|
||||
Key::Escape => {
|
||||
self.mode = Mode::Normal;
|
||||
// vim drops the caret onto the last inserted char.
|
||||
if self.caret > self.line_start(self.caret) {
|
||||
self.caret -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Normal mode -------------------------------------------------------
|
||||
|
||||
fn normal_key(&mut self, key: Key) {
|
||||
let c = match key {
|
||||
Key::Char(c) => c,
|
||||
// Esc and non-character events cancel any pending command.
|
||||
_ => {
|
||||
self.reset_pending();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Operator pending (d/c): expect a text object, motion, or doubled op.
|
||||
if let Some(op) = self.pending_op {
|
||||
// After an i/a prefix, `c` is the text-object selector.
|
||||
if let Some(around) = self.pending_obj {
|
||||
self.pending_obj = None;
|
||||
self.pending_op = None;
|
||||
if let Some((s, e)) = self.text_object(c, around) {
|
||||
self.apply_op(op, s, e);
|
||||
}
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
// A count between the operator and its motion (e.g. `d2w`).
|
||||
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
|
||||
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
|
||||
return;
|
||||
}
|
||||
let n = self.count.max(1);
|
||||
match c {
|
||||
'i' => {
|
||||
self.pending_obj = Some(false);
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
'a' => {
|
||||
self.pending_obj = Some(true);
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
'd' if op == Op::Delete => (0..n).for_each(|_| self.delete_current_line()),
|
||||
'c' if op == Op::Change => self.change_current_line(),
|
||||
'w' => {
|
||||
let mut t = self.caret;
|
||||
(0..n).for_each(|_| t = self.word_forward_pos(t));
|
||||
self.apply_op(op, self.caret, t);
|
||||
}
|
||||
'b' => {
|
||||
let mut t = self.caret;
|
||||
(0..n).for_each(|_| t = self.word_back_pos(t));
|
||||
self.apply_op(op, self.caret, t);
|
||||
}
|
||||
'e' => {
|
||||
let mut t = self.caret;
|
||||
(0..n).for_each(|_| t = self.word_end_pos(t));
|
||||
self.apply_op(op, self.caret, t + 1);
|
||||
}
|
||||
'0' => self.apply_op(op, self.line_start(self.caret), self.caret),
|
||||
'$' => self.apply_op(op, self.caret, self.line_end(self.caret)),
|
||||
_ => {}
|
||||
}
|
||||
self.pending_op = None;
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.pending_g {
|
||||
self.pending_g = false;
|
||||
if c == 'g' {
|
||||
self.caret = 0;
|
||||
}
|
||||
self.count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Count prefix: a leading `0` is the line-start motion, not a digit.
|
||||
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
|
||||
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
|
||||
return;
|
||||
}
|
||||
let n = self.count.max(1);
|
||||
|
||||
match c {
|
||||
'h' => (0..n).for_each(|_| self.move_left()),
|
||||
'l' => (0..n).for_each(|_| self.move_right()),
|
||||
'j' => (0..n).for_each(|_| self.move_down()),
|
||||
'k' => (0..n).for_each(|_| self.move_up()),
|
||||
'w' => (0..n).for_each(|_| self.caret = self.word_forward_pos(self.caret)),
|
||||
'b' => (0..n).for_each(|_| self.caret = self.word_back_pos(self.caret)),
|
||||
'e' => (0..n).for_each(|_| self.caret = self.word_end_pos(self.caret)),
|
||||
'0' => self.caret = self.line_start(self.caret),
|
||||
'$' => self.caret = self.line_end(self.caret),
|
||||
'G' => self.caret = self.line_start(self.text.len()),
|
||||
'g' => {
|
||||
self.pending_g = true;
|
||||
return;
|
||||
}
|
||||
'x' => (0..n).for_each(|_| self.delete_at_caret()),
|
||||
'd' => {
|
||||
self.pending_op = Some(Op::Delete);
|
||||
return;
|
||||
}
|
||||
'c' => {
|
||||
self.pending_op = Some(Op::Change);
|
||||
return;
|
||||
}
|
||||
'i' => self.mode = Mode::Insert,
|
||||
'a' => {
|
||||
self.move_right_append();
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'A' => {
|
||||
self.caret = self.line_end(self.caret);
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'I' => {
|
||||
self.caret = self.line_start(self.caret);
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'o' => {
|
||||
self.caret = self.line_end(self.caret);
|
||||
self.insert_char('\n');
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'O' => {
|
||||
let p = self.line_start(self.caret);
|
||||
self.text.insert(p, '\n');
|
||||
self.caret = p;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
'v' | 'V' => self.mode = Mode::View,
|
||||
_ => {}
|
||||
}
|
||||
self.count = 0;
|
||||
}
|
||||
|
||||
fn reset_pending(&mut self) {
|
||||
self.count = 0;
|
||||
self.pending_op = None;
|
||||
self.pending_obj = None;
|
||||
self.pending_g = false;
|
||||
}
|
||||
|
||||
// --- View mode ---------------------------------------------------------
|
||||
|
||||
fn view_key(&mut self, key: Key) {
|
||||
match key {
|
||||
Key::Char('j') => self.scroll_top += 1, // clamped in draw()
|
||||
Key::Char('k') => self.scroll_top = self.scroll_top.saturating_sub(1),
|
||||
Key::Char(' ') => self.scroll_top += ROWS,
|
||||
Key::Char('G') => {
|
||||
let total = self.layout().len();
|
||||
self.scroll_top = total.saturating_sub(ROWS);
|
||||
}
|
||||
Key::Char('g') => {
|
||||
if self.pending_g {
|
||||
self.scroll_top = 0;
|
||||
self.pending_g = false;
|
||||
} else {
|
||||
self.pending_g = true;
|
||||
}
|
||||
}
|
||||
Key::Escape => {
|
||||
self.mode = Mode::Normal;
|
||||
self.pending_g = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Motions (all on the logical buffer) -------------------------------
|
||||
|
||||
/// Offset of the start of the line containing `pos`.
|
||||
fn line_start(&self, pos: usize) -> usize {
|
||||
let b = self.text.as_bytes();
|
||||
let mut i = pos;
|
||||
while i > 0 && b[i - 1] != b'\n' {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Offset of the end of the line containing `pos` (the `\n`, or buffer end).
|
||||
fn line_end(&self, pos: usize) -> usize {
|
||||
let b = self.text.as_bytes();
|
||||
let mut i = pos;
|
||||
while i < b.len() && b[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn move_left(&mut self) {
|
||||
if self.caret > self.line_start(self.caret) {
|
||||
self.caret -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
if self.caret < self.line_end(self.caret) {
|
||||
self.caret += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `l` but allowed to land one past the last char (for `a`).
|
||||
fn move_right_append(&mut self) {
|
||||
if self.caret < self.line_end(self.caret) {
|
||||
self.caret += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let col = self.caret - self.line_start(self.caret);
|
||||
let le = self.line_end(self.caret);
|
||||
if le >= self.text.len() {
|
||||
return; // already on the last line
|
||||
}
|
||||
let next_start = le + 1;
|
||||
let next_end = self.line_end(next_start);
|
||||
self.caret = (next_start + col).min(next_end);
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let ls = self.line_start(self.caret);
|
||||
if ls == 0 {
|
||||
return; // already on the first line
|
||||
}
|
||||
let col = self.caret - ls;
|
||||
let prev_start = self.line_start(ls - 1);
|
||||
let prev_end = ls - 1; // the '\n' that ends the previous line
|
||||
self.caret = (prev_start + col).min(prev_end);
|
||||
}
|
||||
|
||||
/// Start of the next whitespace-delimited word after `from`.
|
||||
fn word_forward_pos(&self, from: usize) -> usize {
|
||||
let b = self.text.as_bytes();
|
||||
let n = b.len();
|
||||
let mut i = from;
|
||||
while i < n && !b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
while i < n && b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Start of the word at or before `from`.
|
||||
fn word_back_pos(&self, from: usize) -> usize {
|
||||
let b = self.text.as_bytes();
|
||||
let mut i = from;
|
||||
while i > 0 && b[i - 1].is_ascii_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !b[i - 1].is_ascii_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// End of the current/next word (lands on its last char).
|
||||
fn word_end_pos(&self, from: usize) -> usize {
|
||||
let b = self.text.as_bytes();
|
||||
let n = b.len();
|
||||
let mut i = from + 1;
|
||||
if i >= n {
|
||||
return from;
|
||||
}
|
||||
while i < n && b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
while i < n && !b[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
i.saturating_sub(1)
|
||||
}
|
||||
|
||||
// --- Edits -------------------------------------------------------------
|
||||
|
||||
fn insert_char(&mut self, c: char) {
|
||||
self.text.insert(self.caret, c);
|
||||
self.caret += c.len_utf8();
|
||||
}
|
||||
|
||||
fn insert_str(&mut self, s: &str) {
|
||||
self.text.insert_str(self.caret, s);
|
||||
self.caret += s.len();
|
||||
}
|
||||
|
||||
fn backspace(&mut self) {
|
||||
if self.caret > 0 {
|
||||
self.caret -= 1;
|
||||
self.text.remove(self.caret);
|
||||
}
|
||||
}
|
||||
|
||||
/// `x` — delete the char under the caret (never a newline).
|
||||
fn delete_at_caret(&mut self) {
|
||||
let b = self.text.as_bytes();
|
||||
if self.caret < b.len() && b[self.caret] != b'\n' {
|
||||
self.text.remove(self.caret);
|
||||
// Keep the caret on a char: if it fell off the line end, step back.
|
||||
if self.caret >= self.line_end(self.caret) && self.caret > self.line_start(self.caret) {
|
||||
self.caret -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `dd` — delete the current logical line, including its newline (or the
|
||||
/// preceding one for the last line, so no blank line is left behind).
|
||||
fn delete_current_line(&mut self) {
|
||||
let ls = self.line_start(self.caret);
|
||||
let le = self.line_end(self.caret);
|
||||
let (start, end) = if le < self.text.len() {
|
||||
(ls, le + 1) // eat the trailing newline
|
||||
} else if ls > 0 {
|
||||
(ls - 1, le) // last line: eat the preceding newline instead
|
||||
} else {
|
||||
(ls, le) // whole buffer
|
||||
};
|
||||
self.text.replace_range(start..end, "");
|
||||
self.caret = self.line_start(start.min(self.text.len()));
|
||||
}
|
||||
|
||||
/// `cc` — clear the current line's text and drop into insert.
|
||||
fn change_current_line(&mut self) {
|
||||
let ls = self.line_start(self.caret);
|
||||
let le = self.line_end(self.caret);
|
||||
self.text.replace_range(ls..le, "");
|
||||
self.caret = ls;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// Apply a pending operator over the buffer range `[start, end)` (order
|
||||
/// independent). Delete removes it; Change removes it and enters insert.
|
||||
fn apply_op(&mut self, op: Op, start: usize, end: usize) {
|
||||
let s = start.min(end);
|
||||
let e = start.max(end).min(self.text.len());
|
||||
self.text.replace_range(s..e, "");
|
||||
self.caret = s.min(self.text.len());
|
||||
if op == Op::Change {
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a text object to a buffer range. `around` selects `a` (include
|
||||
/// delimiters / trailing space) vs `i` (inner). Returns `None` if there's
|
||||
/// no matching object under the caret.
|
||||
fn text_object(&self, obj: char, around: bool) -> Option<(usize, usize)> {
|
||||
match obj {
|
||||
'w' => Some(self.word_object(around)),
|
||||
'(' | ')' | 'b' => self.pair_object(b'(', b')', around),
|
||||
'{' | '}' | 'B' => self.pair_object(b'{', b'}', around),
|
||||
'[' | ']' => self.pair_object(b'[', b']', around),
|
||||
'<' | '>' => self.pair_object(b'<', b'>', around),
|
||||
'"' => self.quote_object(b'"', around),
|
||||
'\'' => self.quote_object(b'\'', around),
|
||||
'`' => self.quote_object(b'`', around),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `iw`/`aw`: the run of same-class chars (word vs space, never crossing a
|
||||
/// newline) under the caret. `aw` also takes the trailing run of spaces, or
|
||||
/// the leading one if there is no trailing space. Word class is
|
||||
/// whitespace-delimited (so this behaves like vim's `iW`/`aW`).
|
||||
fn word_object(&self, around: bool) -> (usize, usize) {
|
||||
let b = self.text.as_bytes();
|
||||
let n = b.len();
|
||||
if n == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let pos = self.caret.min(n - 1);
|
||||
let ws = |c: u8| c == b' ' || c == b'\t';
|
||||
let target_ws = ws(b[pos]);
|
||||
let same = |c: u8| ws(c) == target_ws && c != b'\n';
|
||||
let mut s = pos;
|
||||
while s > 0 && same(b[s - 1]) {
|
||||
s -= 1;
|
||||
}
|
||||
let mut e = pos + 1;
|
||||
while e < n && same(b[e]) {
|
||||
e += 1;
|
||||
}
|
||||
if around && !target_ws {
|
||||
let mut a = e;
|
||||
while a < n && ws(b[a]) {
|
||||
a += 1;
|
||||
}
|
||||
if a > e {
|
||||
return (s, a);
|
||||
}
|
||||
let mut ls = s;
|
||||
while ls > 0 && ws(b[ls - 1]) {
|
||||
ls -= 1;
|
||||
}
|
||||
return (ls, e);
|
||||
}
|
||||
(s, e)
|
||||
}
|
||||
|
||||
/// `i(`/`a(` and friends: the range between the bracket pair enclosing the
|
||||
/// caret, nesting-aware. `around` includes the brackets themselves.
|
||||
fn pair_object(&self, open: u8, close: u8, around: bool) -> Option<(usize, usize)> {
|
||||
let b = self.text.as_bytes();
|
||||
let n = b.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let start = self.caret.min(n - 1);
|
||||
// Scan left for the enclosing open bracket.
|
||||
let mut depth = 0i32;
|
||||
let mut i = start;
|
||||
let open_idx = loop {
|
||||
let ch = b[i];
|
||||
if ch == close && i != start {
|
||||
depth += 1;
|
||||
} else if ch == open {
|
||||
if depth == 0 {
|
||||
break Some(i);
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
if i == 0 {
|
||||
break None;
|
||||
}
|
||||
i -= 1;
|
||||
}?;
|
||||
// Scan right for its matching close.
|
||||
let mut depth = 0i32;
|
||||
let mut j = open_idx + 1;
|
||||
let close_idx = loop {
|
||||
if j >= n {
|
||||
break None;
|
||||
}
|
||||
let ch = b[j];
|
||||
if ch == open {
|
||||
depth += 1;
|
||||
} else if ch == close {
|
||||
if depth == 0 {
|
||||
break Some(j);
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
j += 1;
|
||||
}?;
|
||||
Some(if around {
|
||||
(open_idx, close_idx + 1)
|
||||
} else {
|
||||
(open_idx + 1, close_idx)
|
||||
})
|
||||
}
|
||||
|
||||
/// `i"`/`a"` and friends: the range between a matching quote pair on the
|
||||
/// current line. `around` includes the quotes.
|
||||
fn quote_object(&self, q: u8, around: bool) -> Option<(usize, usize)> {
|
||||
let b = self.text.as_bytes();
|
||||
let ls = self.line_start(self.caret);
|
||||
let le = self.line_end(self.caret);
|
||||
let quotes: Vec<usize> = (ls..le).filter(|&i| b[i] == q).collect();
|
||||
// Pair them left-to-right; take the first pair closing at/after the caret.
|
||||
let mut k = 0;
|
||||
while k + 1 < quotes.len() {
|
||||
let (a, z) = (quotes[k], quotes[k + 1]);
|
||||
if self.caret <= z {
|
||||
return Some(if around { (a, z + 1) } else { (a + 1, z) });
|
||||
}
|
||||
k += 2;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert-mode Ctrl+W / Ctrl+Backspace: delete the word before the caret.
|
||||
fn delete_word_before(&mut self) {
|
||||
let b = self.text.as_bytes();
|
||||
let mut i = self.caret;
|
||||
while i > 0 && (b[i - 1] == b' ' || b[i - 1] == b'\t') {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !b[i - 1].is_ascii_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
self.text.replace_range(i..self.caret, "");
|
||||
self.caret = i;
|
||||
}
|
||||
|
||||
/// Insert-mode Cmd+Backspace: delete back to the start of the line, or the
|
||||
/// preceding newline if already there.
|
||||
fn delete_to_line_start(&mut self) {
|
||||
let ls = self.line_start(self.caret);
|
||||
if ls == self.caret {
|
||||
if self.caret > 0 {
|
||||
self.caret -= 1;
|
||||
self.text.remove(self.caret);
|
||||
}
|
||||
} else {
|
||||
self.text.replace_range(ls..self.caret, "");
|
||||
self.caret = ls;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rendering ---------------------------------------------------------
|
||||
|
||||
/// Wrap the buffer into display lines, tracking each line's buffer offset.
|
||||
fn layout(&self) -> Vec<Line> {
|
||||
let mut lines = vec![Line {
|
||||
start: 0,
|
||||
text: String::new(),
|
||||
}];
|
||||
let mut idx = 0usize;
|
||||
for ch in self.text.chars() {
|
||||
if ch == '\n' {
|
||||
idx += 1;
|
||||
lines.push(Line {
|
||||
start: idx,
|
||||
text: String::new(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if lines.last().unwrap().text.chars().count() >= COLS {
|
||||
lines.push(Line {
|
||||
start: idx,
|
||||
text: String::new(),
|
||||
});
|
||||
}
|
||||
lines.last_mut().unwrap().text.push(ch);
|
||||
idx += 1;
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Display (row, col) of the caret within `lay`.
|
||||
fn caret_rc(&self, lay: &[Line]) -> (usize, usize) {
|
||||
let mut row = 0;
|
||||
for (i, l) in lay.iter().enumerate() {
|
||||
if l.start <= self.caret {
|
||||
row = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(row, self.caret - lay[row].start)
|
||||
}
|
||||
|
||||
/// Move the viewport so the caret stays visible (Normal/Insert), or just
|
||||
/// clamp it to the content (View).
|
||||
fn adjust_scroll(&mut self, caret_row: usize, total: usize) {
|
||||
match self.mode {
|
||||
Mode::View => {
|
||||
let max = total.saturating_sub(ROWS);
|
||||
if self.scroll_top > max {
|
||||
self.scroll_top = max;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if caret_row < self.scroll_top {
|
||||
self.scroll_top = caret_row;
|
||||
} else if caret_row >= self.scroll_top + ROWS {
|
||||
self.scroll_top = caret_row + 1 - ROWS;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the current state into a frame. `insert_cursor_on` gates the
|
||||
/// Insert-mode bar caret (suppressed while typing, shown after a pause);
|
||||
/// Normal draws a block caret and View draws none, regardless.
|
||||
pub fn draw(&mut self, insert_cursor_on: bool) -> Frame {
|
||||
let lay = self.layout();
|
||||
let (crow, ccol) = self.caret_rc(&lay);
|
||||
self.adjust_scroll(crow, lay.len());
|
||||
|
||||
let mut f = Frame::new_white();
|
||||
let text_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
|
||||
let end = (self.scroll_top + ROWS).min(lay.len());
|
||||
for (vis, li) in (self.scroll_top..end).enumerate() {
|
||||
Text::with_baseline(
|
||||
&lay[li].text,
|
||||
Point::new(0, vis as i32 * CH),
|
||||
text_style,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
if crow >= self.scroll_top && crow < self.scroll_top + ROWS {
|
||||
let x = ccol.min(COLS - 1) as i32 * CW;
|
||||
let y = (crow - self.scroll_top) as i32 * CH;
|
||||
match self.mode {
|
||||
Mode::Normal => {
|
||||
// Block caret: fill the cell, redraw the glyph in white.
|
||||
Rectangle::new(Point::new(x, y), Size::new(CW as u32, CH as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
if let Some(ch) = lay[crow].text.chars().nth(ccol) {
|
||||
let mut buf = [0u8; 4];
|
||||
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
|
||||
Text::with_baseline(
|
||||
ch.encode_utf8(&mut buf),
|
||||
Point::new(x, y),
|
||||
inv,
|
||||
Baseline::Top,
|
||||
)
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Mode::Insert if insert_cursor_on => {
|
||||
// Bar caret at the left edge of the cell.
|
||||
Rectangle::new(Point::new(x, y), Size::new(2, CH as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
|
||||
.draw(&mut f)
|
||||
.unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.draw_status(&mut f);
|
||||
f
|
||||
}
|
||||
|
||||
/// Draw the mode indicator (and any pending count/operator) in the bottom
|
||||
/// strip, in the small 6×10 font so it fits below the 13 text rows.
|
||||
fn draw_status(&self, f: &mut Frame) {
|
||||
let name = match self.mode {
|
||||
Mode::Normal => "NORMAL",
|
||||
Mode::Insert => "INSERT",
|
||||
Mode::View => "VIEW",
|
||||
};
|
||||
let mut s = format!(" -- {name} --");
|
||||
if self.count > 0 {
|
||||
s.push_str(&format!(" {}", self.count));
|
||||
}
|
||||
match self.pending_op {
|
||||
Some(Op::Delete) => s.push('d'),
|
||||
Some(Op::Change) => s.push('c'),
|
||||
None => {}
|
||||
}
|
||||
match self.pending_obj {
|
||||
Some(false) => s.push('i'),
|
||||
Some(true) => s.push('a'),
|
||||
None => {}
|
||||
}
|
||||
if self.pending_g {
|
||||
s.push('g');
|
||||
}
|
||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||
Text::with_baseline(&s, Point::new(2, ROWS as i32 * CH + 1), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -12,75 +12,22 @@
|
||||
//! `embedded-graphics` `DrawTarget` (`Frame`), full refresh (`display_frame`),
|
||||
//! and partial refresh (`display_frame_partial`) — Spikes 2 and 5.
|
||||
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
use esp_idf_svc::hal::delay::FreeRtos;
|
||||
use esp_idf_svc::hal::gpio::{Input, Output, PinDriver};
|
||||
use esp_idf_svc::hal::spi::{SpiBusDriver, SpiDriver};
|
||||
use esp_idf_svc::sys::EspError;
|
||||
|
||||
pub const WIDTH: u16 = 792;
|
||||
pub const HEIGHT: u16 = 272;
|
||||
// Panel geometry and the drawable `Frame` now live in the `display` crate, so
|
||||
// the editor can render onto them off the xtensa target. Re-exported here so
|
||||
// `epd::HEIGHT`, `epd::FB_BYTES`, etc. keep resolving for main.rs and the driver
|
||||
// code below, and so the driver need not know they were relocated.
|
||||
pub use display::{FB_BYTES, FB_BYTES_W, HEIGHT, WIDTH};
|
||||
|
||||
/// Each controller drives one half. SSD1683 X is byte-addressed; 396 px
|
||||
/// rounds up to 50 bytes (400 px) of RAM width, full panel height (272 rows).
|
||||
const CTRL_BYTES_W: usize = 50;
|
||||
const CTRL_BYTES: usize = CTRL_BYTES_W * HEIGHT as usize; // 50 * 272 = 13600
|
||||
|
||||
/// Full-frame 1-bit framebuffer: 792 px = 99 bytes per row, MSB-first,
|
||||
/// 1 = white, 0 = black (SSD16xx convention).
|
||||
pub const FB_BYTES_W: usize = (WIDTH / 8) as usize; // 99
|
||||
pub const FB_BYTES: usize = FB_BYTES_W * HEIGHT as usize; // 26928
|
||||
|
||||
/// In-memory 792×272 1-bit frame, drawable via `embedded-graphics`.
|
||||
/// `BinaryColor::On` = black ink, `Off` = white paper.
|
||||
pub struct Frame {
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new_white() -> Self {
|
||||
Self { buf: vec![0xFF; FB_BYTES] }
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // symmetric with new_white; kept as part of the API
|
||||
pub fn new_black() -> Self {
|
||||
Self { buf: vec![0x00; FB_BYTES] }
|
||||
}
|
||||
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.buf
|
||||
}
|
||||
}
|
||||
|
||||
impl OriginDimensions for Frame {
|
||||
fn size(&self) -> Size {
|
||||
Size::new(WIDTH as u32, HEIGHT as u32)
|
||||
}
|
||||
}
|
||||
|
||||
impl DrawTarget for Frame {
|
||||
type Color = BinaryColor;
|
||||
type Error = core::convert::Infallible;
|
||||
|
||||
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
|
||||
where
|
||||
I: IntoIterator<Item = Pixel<Self::Color>>,
|
||||
{
|
||||
for Pixel(p, color) in pixels {
|
||||
if (0..WIDTH as i32).contains(&p.x) && (0..HEIGHT as i32).contains(&p.y) {
|
||||
let idx = p.y as usize * FB_BYTES_W + p.x as usize / 8;
|
||||
let bit = 0x80u8 >> (p.x % 8);
|
||||
match color {
|
||||
BinaryColor::On => self.buf[idx] &= !bit, // black ink
|
||||
BinaryColor::Off => self.buf[idx] |= bit, // white paper
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Max bytes per SPI transfer; matches the DMA size configured in `main`.
|
||||
const SPI_CHUNK: usize = 4096;
|
||||
|
||||
@@ -90,6 +37,10 @@ pub struct Epd<'d> {
|
||||
rst: PinDriver<'d, Output>,
|
||||
cs: PinDriver<'d, Output>,
|
||||
busy: PinDriver<'d, Input>,
|
||||
/// A refresh kicked off by `display_frame_async` whose waveform may still
|
||||
/// be running. Every public display call waits it out (`wait_ready`)
|
||||
/// before sending further controller traffic.
|
||||
refresh_pending: bool,
|
||||
}
|
||||
|
||||
impl<'d> Epd<'d> {
|
||||
@@ -100,7 +51,17 @@ impl<'d> Epd<'d> {
|
||||
cs: PinDriver<'d, Output>,
|
||||
busy: PinDriver<'d, Input>,
|
||||
) -> Self {
|
||||
Self { spi, dc, rst, cs, busy }
|
||||
Self { spi, dc, rst, cs, busy, refresh_pending: false }
|
||||
}
|
||||
|
||||
/// Wait out a refresh started by `display_frame_async`, if one is still
|
||||
/// running. Safe to call anytime; a no-op when nothing is pending.
|
||||
pub fn wait_ready(&mut self) -> Result<(), EspError> {
|
||||
if self.refresh_pending {
|
||||
self.wait_while_busy(2500)?; // full_refresh_time ≈ 2200 ms
|
||||
self.refresh_pending = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- low-level SPI framing (DC low = command, DC high = data) ----
|
||||
@@ -258,6 +219,15 @@ impl<'d> Epd<'d> {
|
||||
|
||||
/// Port of GxEPD2 `refresh(false)` → `_Update_Full` (fast full update).
|
||||
fn update_full(&mut self) -> Result<(), EspError> {
|
||||
self.kick_update_full()?;
|
||||
self.wait_while_busy(2500)?; // full_refresh_time ≈ 2200 ms
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The command half of `update_full`: starts the full-refresh waveform
|
||||
/// (~2.2 s) and returns while it runs. The caller owns the eventual BUSY
|
||||
/// wait before any further controller traffic.
|
||||
fn kick_update_full(&mut self) -> Result<(), EspError> {
|
||||
self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x03, 0x80)?; // slave
|
||||
self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x03, 0x00)?; // master
|
||||
self.cmd(0x21)?; // display update control 1
|
||||
@@ -267,7 +237,6 @@ impl<'d> Epd<'d> {
|
||||
self.cmd(0x22)?;
|
||||
self.data(&[0xD7])?; // fast full update
|
||||
self.cmd(0x20)?; // master activation
|
||||
self.wait_while_busy(2500)?; // full_refresh_time ≈ 2200 ms
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -300,6 +269,7 @@ impl<'d> Epd<'d> {
|
||||
/// Fill the whole panel with one value and full-refresh.
|
||||
/// `0xFF` = white, `0x00` = black. Port of GxEPD2 `clearScreen`.
|
||||
pub fn clear_screen(&mut self, value: u8) -> Result<(), EspError> {
|
||||
self.wait_ready()?;
|
||||
self.write_buffer(0x26, value)?; // previous
|
||||
self.write_buffer(0x24, value)?; // current
|
||||
self.update_full()?;
|
||||
@@ -341,12 +311,28 @@ impl<'d> Epd<'d> {
|
||||
/// consistent "previous" image.
|
||||
pub fn display_frame(&mut self, fb: &[u8]) -> Result<(), EspError> {
|
||||
assert_eq!(fb.len(), FB_BYTES, "framebuffer must be 99 x 272 bytes");
|
||||
self.wait_ready()?;
|
||||
self.write_frame_bank(0x26, fb, 0, HEIGHT)?; // previous
|
||||
self.write_frame_bank(0x24, fb, 0, HEIGHT)?; // current
|
||||
self.update_full()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `display_frame` minus the wait: writes both RAM banks, starts the
|
||||
/// full-refresh waveform (~2.2 s), and returns immediately so the caller
|
||||
/// can do other work (SD mount, note load) while the panel paints itself.
|
||||
/// Every public display call waits out the pending refresh (`wait_ready`)
|
||||
/// before its own controller traffic, so nothing can collide with it.
|
||||
pub fn display_frame_async(&mut self, fb: &[u8]) -> Result<(), EspError> {
|
||||
assert_eq!(fb.len(), FB_BYTES, "framebuffer must be 99 x 272 bytes");
|
||||
self.wait_ready()?;
|
||||
self.write_frame_bank(0x26, fb, 0, HEIGHT)?; // previous
|
||||
self.write_frame_bank(0x24, fb, 0, HEIGHT)?; // current
|
||||
self.kick_update_full()?;
|
||||
self.refresh_pending = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Partial-refresh only rows `y0..y0+h` of the panel from a full
|
||||
/// framebuffer — the fast per-keystroke path (pass `(0, HEIGHT)` for the
|
||||
/// whole panel). Requires the `0x26` (previous) bank to already hold the
|
||||
@@ -363,6 +349,7 @@ impl<'d> Epd<'d> {
|
||||
) -> Result<(), EspError> {
|
||||
assert_eq!(fb.len(), FB_BYTES, "framebuffer must be 99 x 272 bytes");
|
||||
assert!(h > 0 && y0 + h <= HEIGHT, "row window out of range");
|
||||
self.wait_ready()?;
|
||||
self.write_frame_bank(0x24, fb, y0, h)?; // current = new
|
||||
self.update_part(y0, h)?; // transition previous (0x26) -> current (0x24)
|
||||
self.write_frame_bank(0x26, fb, y0, h)?; // previous = new, for next time
|
||||
|
||||
1183
firmware/src/git_sync.rs
Normal file
1183
firmware/src/git_sync.rs
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user