feat(editor): add .typoena.toml prefs and palette settings (v0.5 slice 4)

Git-tracked editor preferences read at boot and toggled live on-device:

- Prefs type (line-based TOML parse/serialize, no crate on xtensa) held on
  Editor; firmware reads /sd/repo/.typoena.toml before the first render and
  falls back to per-key defaults. Keys: save_on_idle, format_on_save,
  line_numbers (bool) + auto_sync (string, schema/default only until v0.7).
- line_numbers applied live (gutter_cols -> 0 when off).
- Palette > command mode toggles the three bools; the list stays open so
  several flip in one visit, and :settings opens it directly. Each toggle
  applies live and queues Effect::SavePrefs (host atomic-writes the file,
  which rides the next :sync).
- save_on_idle honoured host-side as a silent, unformatted idle auto-save.
- to_toml is newline-free; save_path now appends exactly one terminator
  unconditionally so buffers round-trip byte-for-byte (trailing blanks kept).
- Firmware 0.4.0 -> 0.5.0; new docs/typoena-toml.md reference. Also refreshes
  the slice-3 macroplan status (delete fix was confirmed on device).
This commit is contained in:
Julien Calixte
2026-07-12 01:48:10 +02:00
parent 82f305cea6
commit c535864ee7
8 changed files with 862 additions and 75 deletions

View File

@@ -15,6 +15,7 @@
| [`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

View File

@@ -356,8 +356,8 @@ 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 in core 2026-07-12, HOST-TESTED not yet
on-device.** `:enew <name>` creates a new file: empty, active, marked **dirty**
**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
@@ -377,9 +377,48 @@ which removes index entries whose working-tree file is gone — together they ar
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 is behind `--features git`, unbuildable locally — on-device
re-test pending).
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). **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). Remaining gate (still pending): the palette `>` live toggle +
`SavePrefs` write-back, and the `save_on_idle` autosave (this test ran with it
off).
- [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
@@ -403,7 +442,8 @@ re-test pending).
`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`.
**On-device re-test of the fix pending** (build is `--features git`).
**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
@@ -411,25 +451,31 @@ re-test pending).
- [ ] 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
- [ ] **Preferences file** `/sd/repo/.typoena.toml` — a git-tracked,
- [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). Read at boot; a missing file or
key falls back to the defaults below. Keys:
- [ ] `save_on_idle` (bool, default `true`) — auto-save the current buffer on
the existing idle pause (the ≥ 1 s typing-pause the panel already uses
for its refresh), so `:w` becomes optional rather than required.
- [ ] `format_on_save` (bool, default `true`) — run `:fmt` (table alignment,
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::format_on_save`,
default on); this key will drive it. **Open question:** with
`save_on_idle` also on, this reformats on every idle pause — reflowing
tables / collapsing blanks mid-session. Consider limiting fmt to
explicit `:w`/`:sync` and leaving the idle auto-save unformatted.
- [ ] `line_numbers` (bool, default `true`) — show the absolute line-number
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; the palette `> line numbers: on/off` command toggles it live.
text (`gutter_cols()` → 0); the palette `> line numbers: on/off` command
toggles it live. **Done 2026-07-12.**
- [ ] `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:
@@ -440,18 +486,26 @@ re-test pending).
`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 + defaults live here in v0.5**; the periodic side rides the
better-git work (v0.7) and must interact with light / deep sleep (v0.8).
- [ ] Open question: because the file is committed, these prefs **sync to
every device** that clones the repo — a per-device sync cadence may
instead want a card-local override (in `typoena.conf`). Decide before
build.
- [ ] **Palette command mode** — typing `>` at the `Ctrl-P` palette switches it
from file search to a command list (VS Code-style). The v0.5 commands edit
the `.typoena.toml` prefs above — e.g. `> save on idle: on/off` and
`> auto sync: 10m` — writing the value back to the file and applying it
live. This command list is the discoverable surface that later actions
(`:fmt`, theme, font) also register into.
The **schema + default (`"10m"`) live here in v0.5** and round-trip
through `Prefs`; **nothing reads the value yet** — the periodic side
rides the better-git work (v0.7) and must interact with light / deep
sleep (v0.8). Marked `[~]`: parsed and preserved, no behaviour.
- [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.7 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`, theme,
font) also register into. **`> auto sync: <dur>` deferred to v0.7** — a
value control that changes nothing readable would be a dead switch.
## v0.6 — Markdown affordances — [~]

149
docs/typoena-toml.md Normal file
View File

@@ -0,0 +1,149 @@
# `.typoena.toml` — editor preferences
> The git-tracked file that controls how the editor behaves — auto-save,
> format-on-save, and the line-number gutter. Hand-editable, or toggled live
> from the `Cmd-P` palette. 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` — deferred until `auto_sync` actually does something in
v0.7. 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). 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 | Effect |
| --- | --- | --- | --- |
| `save_on_idle` | bool | `true` | Auto-save the current buffer on the idle typing-pause, so `:w` is optional. |
| `format_on_save` | bool | `true` | Run `:fmt` on the buffer before an explicit `:w`/`:sync`. |
| `line_numbers` | bool | `true` | Show the absolute line-number gutter. Off reclaims its columns for text. |
| `auto_sync` | string | `"10m"` | Max-staleness cap for opportunistic auto-publish. **Schema only in v0.5 — no behaviour yet.** |
### Example
```toml
# Typoena editor preferences — hand-editable, git-tracked.
# Edit here, or toggle live from the Cmd-P palette (type `>`).
save_on_idle = true
format_on_save = true
line_numbers = true
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.
### `auto_sync`
A duration string (`"10m"`, `"2m"`, `"0"`/empty to disable) that will one day cap
how stale the published copy is allowed to get — an *opportunistic, rate-limited*
push, not a wall-clock timer. **In v0.5 this is schema + default only:** the value
is parsed, preserved through a round-trip, and shown nowhere editable — **nothing
reads it yet.** The periodic push itself rides the better-git work in v0.7 and
must interact with sleep in v0.8. 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).
The three boolean prefs appear as toggles carrying their current state:
```
> save on idle: on
format on save: on
line numbers: on
```
`Ctrl-N`/`Ctrl-P` move the selection; **Enter** flips the selected pref,
applies it at once, writes the change back to `.typoena.toml`, and confirms
the new state on the snackbar (e.g. `line numbers: off - saved`). **The list
stays open** so you can flip several prefs in a row; **Esc** (or `Cmd-P`)
closes it. Each change rides the next `:sync` to your other devices.
`auto_sync` is **not** a palette command in v0.5 (it has no behaviour to
drive yet); it returns as a value command in v0.7.
## 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.

View File

@@ -143,12 +143,128 @@ pub enum Effect {
/// 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 },
}
/// 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";
/// 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 `:sync` 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`/`:sync`
/// (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`/`:sync` 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,
/// Max-staleness cap for opportunistic auto-publish, as a duration string
/// (`"10m"`, `"0"`/empty disables). **Schema + default only in v0.5** — the
/// periodic push itself rides v0.7/v0.8, so nothing reads this yet; it is
/// parsed and preserved through a round-trip but has no behaviour here.
pub auto_sync: String,
}
impl Default for Prefs {
fn default() -> Self {
Self {
save_on_idle: true,
format_on_save: true,
line_numbers: true,
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;
}
}
"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). Emitted *without*
/// a trailing newline: like the editor buffer, this is content without its
/// terminator — the persistence layer appends the single POSIX newline on write
/// (and strips it back on read), so returning one here would double it.
pub fn to_toml(&self) -> String {
format!(
"# Typoena editor preferences — hand-editable, git-tracked.\n\
# Edit here, or toggle live from the Cmd-P palette (type `>`).\n\
save_on_idle = {}\n\
format_on_save = {}\n\
line_numbers = {}\n\
auto_sync = \"{}\"",
self.save_on_idle, self.format_on_save, self.line_numbers, 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`).
fn parse_bool(v: &str) -> Option<bool> {
match v {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
/// 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
@@ -268,6 +384,26 @@ fn palette_label(path: &str) -> &str {
path.strip_prefix("/sd/").unwrap_or(path)
}
/// A palette command (the `>` mode). v0.5 exposes the three boolean prefs as
/// live toggles; each command's *label* carries the pref's current state
/// ([`Editor::command_label`]), so the list doubles as a settings readout. This
/// registry is the discoverable surface later features (`:fmt`, theme, font)
/// grow into. `auto_sync` is deliberately absent until v0.7 gives it behaviour —
/// a value control that changes nothing would be a dead switch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PaletteCmd {
SaveOnIdle,
FormatOnSave,
LineNumbers,
}
/// The palette command list, in display order (empty `>` query shows them all).
const PALETTE_CMDS: [PaletteCmd; 3] = [
PaletteCmd::SaveOnIdle,
PaletteCmd::FormatOnSave,
PaletteCmd::LineNumbers,
];
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange /
/// `y`ank).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -312,10 +448,12 @@ pub struct Editor {
/// (save/publish result). Shown until the next keystroke dismisses it
/// (cleared in [`Editor::handle`]); `None` means nothing to show.
notice: Option<String>,
/// Run `:fmt` on the buffer before persisting on `:w`/`:sync`, so `:sync`
/// is fmt → save → commit → push. Defaults on; the v0.5 `.typoena.toml`
/// `format_on_save` key will drive it.
format_on_save: bool,
/// 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`/`:sync` 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.
@@ -437,7 +575,7 @@ impl Editor {
shown_words: 0,
keyboard_present: false,
notice: None,
format_on_save: true,
prefs: Prefs::default(),
register: String::new(),
register_linewise: false,
undo: Vec::new(),
@@ -563,6 +701,20 @@ impl Editor {
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;
}
/// Whitespace-delimited word count of the whole buffer.
fn word_count(&self) -> usize {
self.text.split_whitespace().count()
@@ -956,7 +1108,12 @@ impl Editor {
Key::Enter => {
self.execute_command();
self.cmdline.clear();
self.mode = Mode::Normal;
// 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();
@@ -1000,9 +1157,10 @@ impl Editor {
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.format_on_save {
if self.prefs.format_on_save {
self.format_buffer();
}
self.request_save_active();
@@ -1016,7 +1174,7 @@ impl Editor {
}
// fmt → save → push: format in-core, queue the save of the current
// buffer, then the git publish. The host services them in order.
if self.format_on_save {
if self.prefs.format_on_save {
self.format_buffer();
}
self.request_save_active();
@@ -1343,6 +1501,15 @@ impl Editor {
self.palette_sel = 0;
}
/// `: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.
fn open_settings(&mut self) {
self.mode = Mode::Palette;
self.palette_query = ">".to_string();
self.palette_sel = 0;
}
/// Leave the palette back to Normal, clearing its query and selection.
fn close_palette(&mut self) {
self.mode = Mode::Normal;
@@ -1382,13 +1549,24 @@ impl Editor {
self.palette_sel = 0;
}
// Ctrl-n/Ctrl-p move the selection (fzf-style); Ctrl-d/Ctrl-u do too.
// Clamped to the result list.
// Clamped to the result list (files, or `>` commands).
Key::Down | Key::HalfPageDown => {
let n = self.palette_matches().len();
let n = if self.palette_command_mode() {
self.palette_command_matches().len()
} else {
self.palette_matches().len()
};
self.palette_sel = (self.palette_sel + 1).min(n.saturating_sub(1));
}
Key::Up | Key::HalfPageUp => self.palette_sel = self.palette_sel.saturating_sub(1),
Key::Enter => self.palette_open_selected(),
// Enter runs the selected `>` command, or opens the selected file.
Key::Enter => {
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 => {}
@@ -1436,6 +1614,78 @@ impl Editor {
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.
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.
fn command_filter(&self) -> &str {
self.palette_query.strip_prefix('>').unwrap_or("").trim()
}
/// A command's display label, carrying its pref's current state (so the list
/// reads as a live settings panel and the toggle's effect is legible before
/// and after). This is also the text [`fuzzy_score`] matches against.
fn command_label(&self, cmd: PaletteCmd) -> String {
let on = |b| if b { "on" } else { "off" };
match cmd {
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)),
}
}
/// 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.
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: run the selected command (toggle its pref) and
/// **stay open**, so several prefs can be flipped in a row — the toggled
/// label updates in place. `Esc` (or `Cmd-P`) closes. A no-op on an empty
/// result set (nothing selected), which also stays open so the query can be
/// fixed. Contrast [`palette_open_selected`](Self::palette_open_selected),
/// which closes: opening a file switches away, toggling a pref does not.
fn palette_run_command(&mut self) {
if let Some(&ci) = self.palette_command_matches().get(self.palette_sel) {
self.toggle_pref(PALETTE_CMDS[ci]);
}
}
/// Flip the boolean pref a command targets, apply it live (the next
/// [`draw`](Self::draw) reflects it — line numbers appear/vanish at once),
/// queue the prefs-file write ([`Effect::SavePrefs`]), and confirm the new
/// state on the snackbar. The queued `SavePrefs` is what makes the change
/// durable and lets it ride the next `:sync` to other devices.
fn toggle_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,
}
self.requests.push(Effect::SavePrefs {
contents: self.prefs.to_toml(),
});
// The label already reflects the just-flipped state (e.g. "line numbers: off").
self.set_notice(format!("{} - saved", self.command_label(cmd)));
}
// --- Visual mode -------------------------------------------------------
/// True while a Visual selection is active (charwise or linewise).
@@ -2160,6 +2410,9 @@ impl Editor {
/// 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.
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
}
@@ -2314,7 +2567,10 @@ impl Editor {
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
let digits = gutter - 1; // number field width; the last col is the separator
// 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.
@@ -2331,7 +2587,7 @@ impl Editor {
if li > self.scroll_top && first_row {
line_no += 1;
}
if first_row {
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)
@@ -2591,14 +2847,20 @@ impl Editor {
let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
// The query on the top row with a block caret at the end. No `>` prefix:
// a bare input is "go to file" (VS Code Cmd-P); the `>` prefix is reserved
// for the command palette (v0.5 slice 4). An empty query is just the caret
// over a `Go to file` placeholder that clears on the first keystroke.
// 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();
if self.palette_query.is_empty() {
Text::with_baseline("Go to file", Point::new(2 + CW, 0), style, Baseline::Top)
.draw(f)
.unwrap();
Text::with_baseline(
"Go to file (type > for commands)",
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)
@@ -2616,14 +2878,21 @@ impl Editor {
.draw(f)
.unwrap();
let matches = self.palette_matches();
// The list is either the fuzzy-ranked files or the `>` command registry.
let matches = 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 self.files.is_empty() {
let msg = if command_mode {
"(no command)"
} else if self.files.is_empty() {
"(no files on card)"
} else {
"(no match)"
@@ -2637,8 +2906,11 @@ impl Editor {
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 =
palette_label(&self.files[idx]).chars().take(max_chars).collect();
let label: String = if command_mode {
self.command_label(PALETTE_CMDS[idx])
} else {
palette_label(&self.files[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))
@@ -2656,7 +2928,11 @@ impl Editor {
}
}
let hint = "^N/^P move Enter open Esc close";
let hint = if command_mode {
"^N/^P move Enter toggle 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();
@@ -2724,7 +3000,13 @@ fn format_markdown(text: &str) -> String {
}
}
// 3. Collapse 2+ consecutive blank lines to one; drop trailing blanks.
// 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 {
@@ -2738,9 +3020,6 @@ fn format_markdown(text: &str) -> String {
out.push(line);
}
}
while out.last().is_some_and(|l| l.is_empty()) {
out.pop();
}
out.join("\n")
}
@@ -2883,6 +3162,7 @@ mod tests {
Publish,
Pull,
Delete,
SavePrefs,
}
fn kinds(effects: &[Effect]) -> Vec<Kind> {
@@ -2894,6 +3174,7 @@ mod tests {
Effect::Publish => Kind::Publish,
Effect::Pull => Kind::Pull,
Effect::Delete { .. } => Kind::Delete,
Effect::SavePrefs { .. } => Kind::SavePrefs,
})
.collect()
}
@@ -3107,7 +3388,7 @@ mod tests {
Scope::Tracked,
"hello \nworld".to_string(),
);
e.format_on_save = false;
e.prefs.format_on_save = false;
e.handle(Key::Char(':'));
e.handle(Key::Char('w'));
e.handle(Key::Enter);
@@ -3115,6 +3396,37 @@ mod tests {
assert_eq!(e.text(), "hello \nworld"); // unchanged when the pref is off
}
#[test]
fn format_keeps_at_most_one_trailing_blank_line() {
// The writer's trailing blank line (pressed Enter to open the next line) is
// kept; a run of them collapses to one; a note with none gains none.
assert_eq!(format_markdown("hello\n"), "hello\n"); // one blank kept
assert_eq!(format_markdown("hello\n\n\n"), "hello\n"); // extras collapsed to one
assert_eq!(format_markdown("hello"), "hello"); // none added
}
#[test]
fn format_on_save_keeps_the_caret_on_a_trailing_blank_line() {
// Regression: `:w` used to drop the trailing blank line and yank the caret
// up onto the last non-empty line. The blank line — and the caret — stay.
let mut e = Editor::with_file(
"/sd/repo/notes.md".into(),
Scope::Tracked,
"hello\n".to_string(), // row 0 "hello", row 1 "" (a fresh empty line)
);
e.caret = e.text().len(); // caret at the very end = on the trailing blank row
let lay = e.layout();
assert_eq!(e.caret_rc(&lay).0, 1, "precondition: caret on the blank row");
e.handle(Key::Char(':'));
e.handle(Key::Char('w'));
e.handle(Key::Enter);
assert_eq!(e.text(), "hello\n", "trailing blank line survived format-on-save");
let lay = e.layout();
assert_eq!(e.caret_rc(&lay).0, 1, "caret stayed on the blank row");
}
#[test]
fn wq_and_x_alias_save_dropping_the_quit() {
assert_eq!(kinds(&command("wq").1), vec![Kind::Save]);
@@ -4326,4 +4638,213 @@ mod tests {
empty.handle(Key::Palette);
let _ = empty.draw(true);
}
// ---- Preferences (.typoena.toml) ----
#[test]
fn prefs_default_matches_the_documented_defaults() {
let p = Prefs::default();
assert!(p.save_on_idle);
assert!(p.format_on_save);
assert!(p.line_numbers);
assert_eq!(p.auto_sync, "10m");
}
#[test]
fn prefs_parse_falls_back_to_defaults_for_missing_keys() {
// Only one key present; the rest stay at their defaults.
let p = Prefs::parse("line_numbers = false\n");
assert!(!p.line_numbers);
assert!(p.save_on_idle); // untouched -> default
assert!(p.format_on_save);
assert_eq!(p.auto_sync, "10m");
}
#[test]
fn prefs_parse_reads_all_keys_and_ignores_comments_and_junk() {
let src = "\
# a header comment\n\
save_on_idle = false # trailing comment\n\
format_on_save = false\n\
line_numbers = false\n\
auto_sync = \"2m\"\n\
bogus_key = whatever\n\
not a pair\n";
let p = Prefs::parse(src);
assert!(!p.save_on_idle);
assert!(!p.format_on_save);
assert!(!p.line_numbers);
assert_eq!(p.auto_sync, "2m");
}
#[test]
fn prefs_parse_keeps_default_on_an_unparseable_bool() {
// A typo in a bool value leaves that key at its default, not `false`.
let p = Prefs::parse("save_on_idle = yes\n");
assert!(p.save_on_idle); // "yes" isn't a TOML bool -> default (true)
}
#[test]
fn prefs_to_toml_round_trips_through_parse() {
let p = Prefs {
save_on_idle: false,
format_on_save: true,
line_numbers: false,
auto_sync: "5m".into(),
};
assert_eq!(Prefs::parse(&p.to_toml()), p);
}
#[test]
fn empty_prefs_file_yields_defaults() {
assert_eq!(Prefs::parse(""), Prefs::default());
}
// ---- line_numbers pref (live gutter toggle) ----
#[test]
fn line_numbers_off_reclaims_the_gutter_columns() {
let mut e = Editor::with_text("one\ntwo\nthree".into());
assert!(e.text_cols() < WRITE_COLS); // gutter present by default
e.prefs.line_numbers = false;
assert_eq!(e.gutter_cols(), 0);
assert_eq!(e.text_cols(), WRITE_COLS); // full width reclaimed
}
#[test]
fn draw_with_line_numbers_off_does_not_panic() {
// The `gutter - 1` field width would underflow if unguarded.
let mut e = Editor::with_text("alpha\nbeta\ngamma".into());
e.prefs.line_numbers = false;
let _ = e.draw(true);
}
// ---- Palette command mode (`>`) ----
/// Open the palette and type `query` (so `>...` enters command mode).
fn palette_type(files: &[&str], query: &str) -> Editor {
let mut e = palette_editor(files);
e.handle(Key::Palette);
for c in query.chars() {
e.handle(Key::Char(c));
}
e
}
#[test]
fn leading_gt_switches_the_palette_to_command_mode() {
let e = palette_type(&["/sd/repo/notes.md"], ">");
assert!(e.palette_command_mode());
// All three prefs commands are offered on a bare `>`.
assert_eq!(e.palette_command_matches().len(), PALETTE_CMDS.len());
}
#[test]
fn backspacing_the_gt_returns_to_file_mode() {
let mut e = palette_type(&["/sd/repo/notes.md"], ">");
assert!(e.palette_command_mode());
e.handle(Key::Backspace);
assert!(!e.palette_command_mode());
assert_eq!(e.mode(), Mode::Palette); // still open, just file mode again
}
#[test]
fn command_filter_fuzzy_matches_the_label() {
let e = palette_type(&["/sd/repo/notes.md"], ">line");
let matches = e.palette_command_matches();
assert_eq!(matches.len(), 1);
assert_eq!(PALETTE_CMDS[matches[0]], PaletteCmd::LineNumbers);
}
#[test]
fn command_label_reflects_current_pref_state() {
let e = palette_editor(&["/sd/repo/notes.md"]);
assert_eq!(e.command_label(PaletteCmd::LineNumbers), "line numbers: on");
}
#[test]
fn running_a_command_toggles_the_pref_live_and_queues_a_save_prefs() {
// >line<Enter> flips line_numbers off, in-core, and asks the host to persist.
let mut e = palette_type(&["/sd/repo/notes.md"], ">line");
assert!(e.prefs().line_numbers);
e.handle(Key::Enter);
assert!(!e.prefs().line_numbers); // applied live
assert_eq!(e.mode(), Mode::Palette); // stays open for more toggles
assert_eq!(kinds(&e.take_effects()), vec![Kind::SavePrefs]);
}
#[test]
fn command_mode_stays_open_across_multiple_toggles() {
// Flip line numbers, then move to save-on-idle and flip that, without the
// palette closing between them; each toggle persists.
let mut e = palette_type(&["/sd/repo/notes.md"], ">");
// Registry order is save_on_idle, format_on_save, line_numbers.
e.handle(Key::Down); // -> format on save
e.handle(Key::Down); // -> line numbers
e.handle(Key::Enter);
assert!(!e.prefs().line_numbers);
assert_eq!(e.mode(), Mode::Palette);
assert_eq!(kinds(&e.take_effects()), vec![Kind::SavePrefs]);
e.handle(Key::Up); // back to format on save
e.handle(Key::Enter);
assert!(!e.prefs().format_on_save);
assert_eq!(e.mode(), Mode::Palette);
assert_eq!(kinds(&e.take_effects()), vec![Kind::SavePrefs]);
}
#[test]
fn save_prefs_carries_the_serialized_prefs() {
let mut e = palette_type(&["/sd/repo/notes.md"], ">line");
e.handle(Key::Enter);
let effects = e.take_effects();
let Effect::SavePrefs { contents } = &effects[0] else {
panic!("expected SavePrefs, got {effects:?}");
};
// The written TOML reflects the toggled state and round-trips.
assert!(!Prefs::parse(contents).line_numbers);
}
#[test]
fn running_a_command_confirms_the_new_state_on_the_snackbar() {
let mut e = palette_type(&["/sd/repo/notes.md"], ">save");
e.handle(Key::Enter);
// save_on_idle default true -> off; the notice names the new state.
assert_eq!(e.notice.as_deref(), Some("save on idle: off - saved"));
}
#[test]
fn a_no_match_command_query_runs_nothing() {
let mut e = palette_type(&["/sd/repo/notes.md"], ">zzzzz");
assert!(e.palette_command_matches().is_empty());
let before = e.prefs().clone();
e.handle(Key::Enter);
assert_eq!(e.prefs(), &before); // nothing toggled
assert!(e.take_effects().is_empty()); // nothing queued
assert_eq!(e.mode(), Mode::Palette); // stays open so the query can be fixed
}
#[test]
fn ctrl_n_moves_the_command_selection_within_bounds() {
let mut e = palette_type(&["/sd/repo/notes.md"], ">");
for _ in 0..10 {
e.handle(Key::Down); // Ctrl-N; clamps at the last command
}
assert_eq!(e.palette_sel, PALETTE_CMDS.len() - 1);
}
#[test]
fn settings_command_opens_the_palette_in_command_mode() {
let (e, _) = command("settings");
assert_eq!(e.mode(), Mode::Palette);
assert!(e.palette_command_mode()); // dropped straight into `>` mode
assert_eq!(e.palette_command_matches().len(), PALETTE_CMDS.len());
}
#[test]
fn draw_in_command_mode_does_not_panic() {
let mut e = palette_type(&["/sd/repo/notes.md"], ">");
let _ = e.draw(true);
let mut none = palette_type(&["/sd/repo/notes.md"], ">zzzzz"); // "(no command)"
let _ = none.draw(true);
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "firmware"
version = "0.4.0"
version = "0.5.0"
authors = ["Julien Calixte <juliencalixte@gmail.com>"]
edition = "2024"
resolver = "2"

View File

@@ -94,9 +94,10 @@ fn write_test(storage: &Storage) -> Result<()> {
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}"))?;
}
// Newline-free, matching a real editor buffer: `save`/`load` normalize the
// trailing terminator (add on write, strip on read), so a payload that ended
// in '\n' would read back one byte shorter. This still round-trips identically.
// Newline-free, matching a real editor buffer: `save` appends one POSIX
// terminator and `load` strips one back, so the round-trip is byte-for-byte
// identical for any payload (one ending in '\n' would too — it's just cleaner
// to keep the fixture terminator-free, mirroring the buffer convention).
let payload = format!("typoena spike 3\n{BUILD_TAG}\ndedicated SPI3: SCK14 MOSI15 MISO13 CS10");
storage.save(&payload).context("Storage::save")?;
let back = storage.load().context("Storage::load after save")?;

View File

@@ -10,7 +10,7 @@ use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver};
use esp_idf_svc::hal::units::FromValueType;
use display::Frame;
use editor::{Editor, Effect, Mode, Scope, CH, LOCAL_DIR, REPO_DIR};
use editor::{Editor, Effect, Mode, Prefs, Scope, CH, LOCAL_DIR, PREFS_PATH, REPO_DIR};
use firmware::epd::{self, Epd};
use firmware::persistence::{Storage, NOTES};
@@ -26,6 +26,12 @@ const FULL_REFRESH_EVERY: u32 = 64;
/// reappears once you settle. Normal/View draw their own caret every action.
const CURSOR_DEBOUNCE_MS: u128 = 750;
/// How long input must pause before `save_on_idle` persists a dirty buffer.
/// Longer than the caret debounce so autosave settles after typing, not during
/// a mid-sentence pause. The save is silent (no snackbar, no forced e-ink
/// flash) — a safety net against power loss, not a user action.
const IDLE_SAVE_MS: u128 = 1500;
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.
@@ -115,9 +121,22 @@ fn main() -> anyhow::Result<()> {
// Feed the file palette (Ctrl-P). Enumerated once at boot — the v0.5 slices
// that create/delete files (`:enew`, delete) will re-feed it then.
ed.set_file_list(enumerate_files());
// Editor preferences (.typoena.toml, git-tracked). Read before the first
// render so `line_numbers` shapes the opening frame. A missing / unreadable /
// partial file falls back to defaults, so a fresh card just works.
let prefs = match storage.load_path(PREFS_PATH) {
Ok(src) => Prefs::parse(&src),
Err(_) => Prefs::default(),
};
log::info!("prefs: {prefs:?}");
ed.set_prefs(prefs);
let mut updates: u32 = 0;
let mut cursor_shown = true; // the initial render includes the caret
let mut last_activity = Instant::now();
// Whether `save_on_idle` already persisted the current idle window, so it
// fires once per typing burst (and doesn't retry-storm if a save fails).
// Reset on the next activity.
let mut idle_saved = false;
// Set when a paint fails (see the refresh block below): the next paint then
// does a full refresh to re-establish both RAM banks, since a partial that
// died mid-transfer may have left them inconsistent.
@@ -204,6 +223,7 @@ fn main() -> anyhow::Result<()> {
ed.set_notice("pull: not wired yet (v0.7)");
}
Effect::Delete { path, scope } => delete_buffer(&storage, &mut ed, path, scope),
Effect::SavePrefs { contents } => save_prefs(&storage, &mut ed, &contents),
}
}
}
@@ -250,6 +270,30 @@ fn main() -> anyhow::Result<()> {
log::info!("keyboard {}", if kbd { "connected" } else { "disconnected" });
continue;
}
// save_on_idle: once input has paused, quietly persist a dirty named
// buffer so a power pull can't cost more than the last couple seconds.
// Silent — no snackbar and no forced e-ink flash (a safety net, not an
// action; `:w` is the loud save). Unformatted: fmt only runs on an
// explicit `:w`/`:sync`, never reflowing text mid-session. Fires once
// per idle window (`idle_saved`), so a failing save can't busy-loop.
if !idle_saved
&& ed.prefs().save_on_idle
&& ed.dirty()
&& !ed.path().is_empty()
&& last_activity.elapsed().as_millis() >= IDLE_SAVE_MS
{
idle_saved = true;
let path = ed.path().to_string();
match storage.save_path(&path, ed.text()) {
Ok(()) => {
log::info!("idle-save: {} bytes to {path}", ed.text().len());
ed.mark_saved(&path);
}
Err(e) => log::warn!("idle-save FAILED ({e:#}); buffer kept in RAM"),
}
// No repaint: `dirty` clearing has no visible effect, and a flash
// here would defeat the point. Fall through to the caret/idle path.
}
// Debounced caret, Insert mode only: once typing pauses, bring the
// bar caret back and refresh the panel word count with a silent
// full-area partial (no flash). Normal/View draw their caret on action.
@@ -274,6 +318,7 @@ fn main() -> anyhow::Result<()> {
}
last_activity = Instant::now();
idle_saved = false; // fresh activity reopens the save_on_idle window
// Non-Insert actions (Normal edits, mode switches) aren't rapid typing,
// so the panel word count can refresh immediately; in Insert the snapshot
// stays frozen until the typing-pause path above refreshes it.
@@ -404,6 +449,20 @@ fn save_buffer(storage: &Storage, ed: &mut Editor, path: &str, contents: &str) {
}
}
/// Persist the preferences file after a palette `>` command changed a pref
/// (`Effect::SavePrefs`). The editor already applied the change live and
/// serialized it; this is a plain atomic write to the fixed `.typoena.toml`
/// path. Under `/sd/repo`, so it rides the next `:sync` to other devices.
fn save_prefs(storage: &Storage, ed: &mut Editor, contents: &str) {
match storage.save_path(PREFS_PATH, contents) {
Ok(()) => log::info!("prefs saved to {PREFS_PATH}"),
Err(e) => {
log::error!("prefs save FAILED ({e:#})");
ed.set_notice("prefs save FAILED");
}
}
}
/// Read `path` from SD and install it as the active buffer (the multi-file open
/// path, from `:e` / the palette). A read failure keeps the current buffer and
/// surfaces the reason on the snackbar rather than swapping to an empty screen.

View File

@@ -308,15 +308,17 @@ impl Storage {
.with_context(|| format!("create {tmp} (does its directory exist?)"))?;
f.write_all(contents.as_bytes())
.with_context(|| format!("write {tmp}"))?;
// End the file with exactly one newline (POSIX text convention; keeps git
// from flagging "No newline at end of file"). The editor buffer is
// newline-free by design, so this is the single place the terminator is
// added; `load_path` strips it back off on the way in. Guarded so an
// already-terminated buffer (e.g. an unformatted external file) isn't
// doubled.
if !contents.ends_with('\n') {
f.write_all(b"\n").with_context(|| format!("write final newline to {tmp}"))?;
}
// Append exactly one POSIX terminator, unconditionally — the symmetric
// inverse of `load_path`, which always strips one back off. This keeps
// git from flagging "No newline at end of file" and makes the buffer
// round-trip byte-for-byte: a buffer that ends in '\n' (a trailing blank
// line the writer left) becomes "…\n\n" on disk, so that blank line
// survives the next load instead of being swallowed. Everything handed
// to `save_path` is content *without* its terminator by convention (the
// editor buffer is newline-free, and `Prefs::to_toml` omits its trailing
// newline for the same reason), so this never double-terminates.
f.write_all(b"\n")
.with_context(|| format!("write final newline to {tmp}"))?;
// FatFS f_sync — flush the tmp fully before it can replace the target.
f.sync_all().with_context(|| format!("fsync {tmp}"))?;
}