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

@@ -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}"))?;
}