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:
@@ -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"
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"))?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user