Compare commits

..

3 Commits

Author SHA1 Message Date
Julien Calixte
81d5f818cc docs(persistence): note boot-load and :w/:sync save are wired 2026-07-11 12:17:58 +02:00
Julien Calixte
637fe4a4f4 feat(firmware): boot-load and save the note via persistence
Seed the editor from Storage::load at boot and persist on :w/:sync
through Storage::save. A missing card/repo/unreadable note halts boot
with the reason on the panel rather than starting empty and clobbering
the note on the next save. Save errors are logged, buffer kept in RAM.

The git-push half of :sync stays a TODO — it needs git_sync graduated
into a module on the dedicated git thread; :sync saves for now.
2026-07-11 12:17:58 +02:00
Julien Calixte
a61e0a2196 feat(editor): add text() getter and with_text seed constructor
The host had no way to read the buffer out or seed it from a loaded
file. with_text lands the caret at the end so boot-load resumes writing
where the user left off; text() exposes the buffer for :w/:sync.
2026-07-11 12:17:51 +02:00
3 changed files with 127 additions and 9 deletions

View File

@@ -175,6 +175,14 @@ hardware-verified 2026-07-11.
- The file is read fully into the rope at boot. v0.1 caps file size at
256 KB; larger files refuse to open with a clear message. Saving is not
capped — the buffer is always persistable.
- **Wired into `main.rs`** (2026-07-11): the editor boot-loads the note via
`Editor::with_text(Storage::load())` — a missing card / repo / unreadable note
halts boot with the reason painted on the panel — and `:w`/`:sync` persist
through `Storage::save`, inline on the UI loop (a small-file write is tens of
ms). Save errors are logged and the RAM buffer kept, so a card pulled
mid-session doesn't lose work. The git-push half of `:sync` is **not** wired
yet: it awaits `git_sync` being graduated into a module and run on the
dedicated git thread (see `git` below), so `:sync` currently just saves.
### `wifi` — on-demand station

View File

@@ -131,10 +131,29 @@ impl Editor {
}
}
/// Seed a fresh editor from previously saved text — the boot-load path
/// (`storage.load()` → `Editor`). The caret lands at the end so the user
/// resumes writing exactly where they left off, in the same power-on Insert
/// mode as [`Editor::new`]; the first [`Editor::draw`] scrolls it into view.
/// An empty string is equivalent to [`Editor::new`].
pub fn with_text(text: String) -> Self {
let caret = text.len();
Editor {
text,
caret,
..Editor::new()
}
}
pub fn mode(&self) -> Mode {
self.mode
}
/// The full buffer contents, for the host to persist on `:w`/`:sync`.
pub fn text(&self) -> &str {
&self.text
}
pub fn scroll_top(&self) -> usize {
self.scroll_top
}
@@ -1466,4 +1485,26 @@ mod tests {
assert_eq!(eff, Effect::None);
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn with_text_seeds_buffer_caret_at_end_ready_to_type() {
let e = Editor::with_text("resumed draft".to_string());
assert_eq!(e.text(), "resumed draft");
assert_eq!(e.caret, 13); // caret at end, continuing the last sentence
assert_eq!(e.mode(), Mode::Insert); // power-on = ready to write
}
#[test]
fn with_text_empty_matches_new() {
let e = Editor::with_text(String::new());
assert_eq!(e.text(), "");
assert_eq!(e.caret, 0);
assert_eq!(e.mode(), Mode::Insert);
}
#[test]
fn text_getter_reflects_edits() {
let e = typed("hello");
assert_eq!(e.text(), "hello");
}
}

View File

@@ -12,6 +12,7 @@ use esp_idf_svc::hal::units::FromValueType;
use editor::{Editor, Effect, Mode, CH};
use epd::Epd;
use firmware::persistence::{Storage, NOTES};
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
@@ -57,10 +58,18 @@ fn main() -> anyhow::Result<()> {
epd.init()?;
epd.clear_screen(0xFF)?; // white baseline; establishes the previous bank
// Mount the SD and load the saved note. We bring the SD up *after* the EPD —
// the doc's boot order is SD-first, but a dead panel can't explain a missing
// card — and treat a missing card / repo / unreadable note as fatal: a
// writing appliance that silently started empty would clobber the note on
// the next `:w`. See docs/v0.1-mvp-technical.md, boot sequence.
let (storage, saved) = boot_storage(&mut epd);
// Bring up the USB keyboard in the background; keys arrive via next_key().
usb_kbd::start()?;
let mut ed = Editor::new();
// Seed the editor from the saved note (caret at the end, ready to type on).
let mut ed = Editor::with_text(saved);
let mut updates: u32 = 0;
let mut cursor_shown = true; // the initial render includes the caret
let mut last_activity = Instant::now();
@@ -89,16 +98,22 @@ fn main() -> anyhow::Result<()> {
keys += 1;
}
// Carry out any host-side effect a `:` command asked for. The SD write
// and git-push paths aren't wired into the main loop yet (v0.1 gate —
// see src/bin/git_sync.rs for the persistent-clone push, git_push.rs
// for the TLS trust store). Both block for seconds, so the real version
// must run off this task (dedicated git thread, as the spike does) and
// surface status on the panel; for now we log the intent.
// Carry out any host-side effect a `:` command asked for. The SD save is
// wired (fast, inline). The git-push half of `:sync` is not yet: it must
// run off this task on a dedicated git thread with on-demand Wi-Fi (see
// src/bin/git_sync.rs for the persistent-clone push, git_push.rs for the
// TLS trust store), which is its own integration step — for now `:sync`
// saves and logs that push is pending.
match effect {
Effect::None => {}
Effect::Save => log::info!(":w — save requested (TODO v0.1: write buffer to SD)"),
Effect::Publish => log::info!(":sync — publish requested (TODO v0.1: save + git push)"),
Effect::Save => save_note(&storage, &ed),
Effect::Publish => {
save_note(&storage, &ed);
log::info!(
":sync — note saved; git push not wired yet (needs git_sync \
graduated into a module + on-demand Wi-Fi on the git thread)"
);
}
}
// Keyboard attach/detach feeds the panel's disconnect flag.
@@ -194,6 +209,60 @@ fn main() -> anyhow::Result<()> {
}
}
/// Mount the SD card and load the saved note, or halt with the reason on the
/// panel. Everything here is fatal by design (see the boot-sequence comment in
/// `main`): the note is the whole point of the appliance, so we refuse to run
/// in a state where the next save could destroy it.
fn boot_storage(epd: &mut Epd) -> (Storage, String) {
let storage = match Storage::mount() {
Ok(s) => s,
Err(e) => boot_halt(epd, "SD card not ready", &format!("{e:#}")),
};
if !storage.repo_present() {
boot_halt(
epd,
"No repo on the SD card",
"Provision it on your computer (just init) and reboot.",
);
}
let note = match storage.load() {
Ok(text) => text,
Err(e) => boot_halt(epd, "Could not read your note", &format!("{e:#}")),
};
log::info!("boot: loaded {} bytes from {NOTES}", note.len());
(storage, note)
}
/// Show a terminal boot error on the panel and idle forever. Rebooting into the
/// same missing card would just thrash, so we stop and explain instead.
fn boot_halt(epd: &mut Epd, headline: &str, detail: &str) -> ! {
log::error!("boot halt — {headline}: {detail}");
if let Err(e) = show_message(epd, &format!("{headline}\n\n{detail}\n")) {
log::error!("(could not paint the boot error either: {e:#})");
}
loop {
FreeRtos::delay_ms(1000);
}
}
/// Render a plain full-frame message by borrowing the editor purely as a
/// text-layout engine, so boot failures surface on the panel, not a dead screen.
fn show_message(epd: &mut Epd, msg: &str) -> anyhow::Result<()> {
let frame = Editor::with_text(msg.to_string()).draw(false);
epd.display_frame(frame.bytes())?;
Ok(())
}
/// Persist the buffer to SD. Errors are logged, never propagated: the in-RAM
/// buffer is the source of truth and must survive a failed write (e.g. a card
/// pulled mid-session) so the user can fix the card and retry `:w`.
fn save_note(storage: &Storage, ed: &Editor) {
match storage.save(ed.text()) {
Ok(()) => log::info!(":w — saved {} bytes to {NOTES}", ed.text().len()),
Err(e) => log::error!(":w — save FAILED ({e:#}); buffer kept in RAM, retry :w"),
}
}
/// First and last (inclusive) framebuffer rows that differ between two frames,
/// or `None` if identical. Lets the partial refresh target just the band a
/// keystroke touched instead of all 272 rows.