From c535864ee70df626c4819bbe7315850885e96dad Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 12 Jul 2026 01:48:10 +0200 Subject: [PATCH] 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). --- docs/README.md | 1 + docs/macroplan.md | 116 +++++-- docs/typoena-toml.md | 149 +++++++++ editor/src/lib.rs | 581 ++++++++++++++++++++++++++++++++++-- firmware/Cargo.toml | 2 +- firmware/src/bin/sd_fat.rs | 7 +- firmware/src/main.rs | 61 +++- firmware/src/persistence.rs | 20 +- 8 files changed, 862 insertions(+), 75 deletions(-) create mode 100644 docs/typoena-toml.md diff --git a/docs/README.md b/docs/README.md index 7c732bf..ffc4070 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/macroplan.md b/docs/macroplan.md index a5126d8..4664640 100644 --- a/docs/macroplan.md +++ b/docs/macroplan.md @@ -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 ` creates a new file: empty, active, marked **dirty** +**Slice 3 (`:enew` + delete) COMPLETE + CONFIRMED ON DEVICE 2026-07-12** +(committed `c9c0716`). `:enew ` 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: ` 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: ` deferred to v0.7** — a + value control that changes nothing readable would be a dead switch. ## v0.6 — Markdown affordances — [~] diff --git a/docs/typoena-toml.md b/docs/typoena-toml.md new file mode 100644 index 0000000..0c674f3 --- /dev/null +++ b/docs/typoena-toml.md @@ -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. diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 3e0e7e5..112c2f6 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -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 { + 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, - /// 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 "), "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 { + 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 = 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 { @@ -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 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); + } } diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index f15d403..b394331 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "firmware" -version = "0.4.0" +version = "0.5.0" authors = ["Julien Calixte "] edition = "2024" resolver = "2" diff --git a/firmware/src/bin/sd_fat.rs b/firmware/src/bin/sd_fat.rs index 84971da..812d00a 100644 --- a/firmware/src/bin/sd_fat.rs +++ b/firmware/src/bin/sd_fat.rs @@ -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")?; diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 600f5db..213711f 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -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. diff --git a/firmware/src/persistence.rs b/firmware/src/persistence.rs index a05036a..66ede14 100644 --- a/firmware/src/persistence.rs +++ b/firmware/src/persistence.rs @@ -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}"))?; }