Compare commits
3 Commits
7a1f25f5d1
...
81d5f818cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d5f818cc | ||
|
|
637fe4a4f4 | ||
|
|
a61e0a2196 |
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user