Compare commits

...

3 Commits

Author SHA1 Message Date
Julien Calixte
18ed6aa599 docs(roadmap): record the panel snackbar and fix panel status
The side-panel split (writing column + panel) is already built in the
editor crate; correct the stale "not built yet" note and add the
snackbar under v0.1.
2026-07-11 14:01:02 +02:00
Julien Calixte
8e3e6e25a4 feat(firmware): post loaded/saved snackbars to the panel
Boot posts "loaded <name>" (the note's filename without suffix); :w and
:sync post "saved" / "save FAILED - retry :w" via Editor::set_notice, so
save state is visible on-device, where there is no serial console.
2026-07-11 14:00:55 +02:00
Julien Calixte
e797381da6 feat(editor): boot in Normal mode and add a panel snackbar
Two boot/panel UX changes that share the editor's constructor, so they
land in one commit:

- Power-on mode is now Normal (was Insert). with_text() opens a loaded
  note in Normal with the caret on the last char (the resume point);
  draw(false) now suppresses the caret in every mode, so the boot-error
  screen renders as pure text rather than sprouting a block caret.
- notice/set_notice(): a transient side-panel message ("snackbar")
  drawn under the word count and cleared on the next keystroke — no
  timed repaint, which on e-ink would cost a ~630 ms flash to erase.
2026-07-11 14:00:45 +02:00
3 changed files with 112 additions and 35 deletions

View File

@@ -133,11 +133,23 @@ lives in the `git_sync` / `sd_fat` spike bins, not `main.rs`.
`git push`; on push failure, `git pull --no-edit` then retry the push
(no-op short-circuit when nothing is staged). Proven on device in the
`git_sync` spike (✓); not yet wired to the editor.
- [ ] Split the display into **writing column** (~60 cols) + **side panel**
(~20 cols) for all metadata — the surface every later panel feature
writes to. Not built yet: `editor.rs` still renders full-width 79 cols.
Defined in [`CONTEXT.md` § Screen regions](../CONTEXT.md#screen-regions)
and [product § Screen layout](v0.1-mvp-product.md#screen-layout).
- [x] Split the display into a **writing column** (60 cols) + a **side panel**
(~30 cols at FONT_6X10) for metadata — the surface every later panel
feature writes to. **Built** in the `editor` crate (`draw_panel`): a
full-height divider at x=600, with the panel currently showing the word
count, the mode indicator, a NO-KBD flag, and a transient save/publish
**snackbar** (below). Later fields (filename, clock, Wi-Fi, battery) add to
the same surface. Defined in
[`CONTEXT.md` § Screen regions](../CONTEXT.md#screen-regions) and
[product § Screen layout](v0.1-mvp-product.md#screen-layout).
- [x] **Snackbar** — a transient side-panel notice for host events (added
2026-07-11). On-device there is no serial log, so boot posts `loaded
<name>` (the note's filename without suffix) and `:w`/`:sync` post `saved`
/ `save FAILED - retry :w`; when git publish is wired it will show the
push result. Set via `Editor::set_notice`; cleared on the next keystroke
rather than a timer — a timed auto-dismiss would cost a ~630 ms full-area
e-ink flash purely to erase text, which the panel deliberately avoids (cf.
the dropped pending-accent marker in v0.2.5).
- [~] Partial refresh on edits (✓ Spike 5); full refresh on save — save not wired yet
Out of scope: Vim, palette, multiple files, branches, conflict handling.

View File

@@ -19,7 +19,7 @@ use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{PrimitiveStyle, Rectangle};
use embedded_graphics::text::{Baseline, Text};
use display::{Frame, HEIGHT};
use display::{Frame, HEIGHT, WIDTH};
use keymap::Key;
/// FONT_10X20 cell size (writing column) and the grid it tiles into.
@@ -38,6 +38,10 @@ const ROWS: usize = (HEIGHT / 20) as usize; // 13
/// of panel text (a small gutter past the rule).
const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 600
const PANEL_X: i32 = DIVIDER_X + 8; // 608
/// Side-panel text width in 6 px (`FONT_6X10`) columns, for clamping panel
/// strings — the snackbar notice, word count — so they never draw past the
/// right edge of the panel.
const PANEL_COLS: usize = (WIDTH as usize - PANEL_X as usize) / 6; // 30
/// Tab stop, in spaces. Tabs never enter the buffer — they expand on insert so
/// the buffer stays 1 char = 1 column.
const TAB: &str = " ";
@@ -106,6 +110,10 @@ pub struct Editor {
/// Whether a USB keyboard is attached; drives the panel disconnect flag.
/// Fed from `usb_kbd::keyboard_present()` by the main loop.
keyboard_present: bool,
/// Transient side-panel message ("snackbar") — the last host event
/// (save/publish result). Shown until the next keystroke dismisses it
/// (cleared in [`Editor::handle`]); `None` means nothing to show.
notice: Option<String>,
}
/// One wrapped display line: its text and the buffer offset of its first char.
@@ -119,7 +127,7 @@ impl Editor {
Editor {
text: String::new(),
caret: 0,
mode: Mode::Insert, // writing appliance: power-on = ready to type
mode: Mode::Normal, // power-on = Normal (vim-style); `with_text` boots the same
scroll_top: 0,
count: 0,
pending_op: None,
@@ -128,21 +136,23 @@ impl Editor {
cmdline: String::new(),
shown_words: 0,
keyboard_present: false,
notice: None,
}
}
/// 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`].
/// (`storage.load()` → `Editor`). Boots in **Normal** mode (vim opens a file
/// in Normal, not Insert) with the caret on the *last* character — the
/// resume point — matching the Esc→Normal convention rather than sitting one
/// cell past the end. 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()
let mut ed = Editor { text, ..Editor::new() };
ed.caret = ed.text.len();
if ed.caret > ed.line_start(ed.caret) {
ed.caret = ed.prev_char(ed.caret);
}
ed
}
pub fn mode(&self) -> Mode {
@@ -170,6 +180,14 @@ impl Editor {
self.keyboard_present = present;
}
/// Post a transient side-panel notice ("snackbar") — e.g. the result of a
/// save or publish. Shown from the next [`Editor::draw`] until the next
/// keystroke dismisses it (see [`Editor::handle`]). The host calls this from
/// its `:` command effect handlers.
pub fn set_notice(&mut self, msg: impl Into<String>) {
self.notice = Some(msg.into());
}
/// Whitespace-delimited word count of the whole buffer.
fn word_count(&self) -> usize {
self.text.split_whitespace().count()
@@ -179,6 +197,12 @@ impl Editor {
/// any [`Effect`] the host must carry out (only `:` commands produce one;
/// every other key yields [`Effect::None`]).
pub fn handle(&mut self, key: Key) -> Effect {
// Any keystroke dismisses the transient notice ("snackbar"). The host
// sets a fresh one *after* handle() returns (on a `:` command's effect),
// so a save/publish message survives to the next draw, then clears the
// moment you move on — no timed repaint (which on e-ink would cost a
// full ~630 ms flash just to erase text).
self.notice = None;
match self.mode {
Mode::Insert => {
self.insert_key(key);
@@ -951,10 +975,12 @@ impl Editor {
(1..=6).contains(&hashes) && b.get(i) == Some(&b' ')
}
/// Render the current state into a frame. `insert_cursor_on` gates the
/// Insert-mode bar caret (suppressed while typing, shown after a pause);
/// Normal draws a block caret and View draws none, regardless.
pub fn draw(&mut self, insert_cursor_on: bool) -> Frame {
/// Render the current state into a frame. `cursor_on` gates the caret: the
/// Insert bar caret is suppressed while typing and shown after a pause, and
/// `false` also suppresses the Normal block caret so callers can render pure
/// text (e.g. a boot message). View never draws a caret. In the main loop
/// Normal always passes `true`, so its block caret is unaffected.
pub fn draw(&mut self, cursor_on: bool) -> Frame {
let lay = self.layout();
let (crow, ccol) = self.caret_rc(&lay);
self.adjust_scroll(crow, lay.len());
@@ -981,7 +1007,7 @@ impl Editor {
let x = ccol.min(WRITE_COLS - 1) as i32 * CW;
let y = (crow - self.scroll_top) as i32 * CH;
match self.mode {
Mode::Normal => {
Mode::Normal if cursor_on => {
// Block caret: fill the cell, redraw the glyph in white.
Rectangle::new(Point::new(x, y), Size::new(CW as u32, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
@@ -1000,7 +1026,7 @@ impl Editor {
.unwrap();
}
}
Mode::Insert if insert_cursor_on => {
Mode::Insert if cursor_on => {
// Bar caret at the left edge of the cell.
Rectangle::new(Point::new(x, y), Size::new(2, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
@@ -1038,6 +1064,16 @@ impl Editor {
.draw(f)
.unwrap();
// Transient notice ("snackbar"), just under the word count: the last
// save/publish result. Clamped to the panel width so a long message
// can't spill past the right edge; cleared on the next keystroke.
if let Some(msg) = &self.notice {
let shown: String = msg.chars().take(PANEL_COLS).collect();
Text::with_baseline(&shown, Point::new(PANEL_X, 16), style, Baseline::Top)
.draw(f)
.unwrap();
}
// Keyboard-disconnect flag, just above the mode line, shown only while
// the keyboard is dropped. Latin-9 has no ⌨/✗ glyph, so plain text.
if !self.keyboard_present {
@@ -1290,9 +1326,11 @@ fn pad_cell(cell: &str, w: usize, align: Align) -> String {
mod tests {
use super::*;
/// Type a run of characters in Insert mode (the power-on mode).
/// Type a run of characters in Insert mode, entered with `i` from the
/// power-on Normal mode.
fn typed(s: &str) -> Editor {
let mut e = Editor::new();
e.handle(Key::Char('i')); // Normal -> Insert
for c in s.chars() {
e.handle(Key::Char(c));
}
@@ -1303,7 +1341,7 @@ mod tests {
/// [`Effect`] the Enter produced.
fn command(cmd: &str) -> (Editor, Effect) {
let mut e = Editor::new();
e.handle(Key::Escape); // Insert -> Normal
e.handle(Key::Escape); // ensure Normal (power-on already is)
e.handle(Key::Char(':')); // Normal -> Command
for c in cmd.chars() {
e.handle(Key::Char(c));
@@ -1487,11 +1525,11 @@ mod tests {
}
#[test]
fn with_text_seeds_buffer_caret_at_end_ready_to_type() {
fn with_text_boots_normal_with_caret_on_last_char() {
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
assert_eq!(e.caret, 12); // on the last char ('t'), the resume point
assert_eq!(e.mode(), Mode::Normal); // vim-style: open a file in Normal
}
#[test]
@@ -1499,7 +1537,7 @@ mod tests {
let e = Editor::with_text(String::new());
assert_eq!(e.text(), "");
assert_eq!(e.caret, 0);
assert_eq!(e.mode(), Mode::Insert);
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
@@ -1507,4 +1545,13 @@ mod tests {
let e = typed("hello");
assert_eq!(e.text(), "hello");
}
#[test]
fn a_notice_shows_until_the_next_key_dismisses_it() {
let mut e = Editor::new();
e.set_notice("saved");
assert_eq!(e.notice.as_deref(), Some("saved"));
e.handle(Key::Char('j')); // any key dismisses the snackbar
assert_eq!(e.notice, None);
}
}

View File

@@ -68,8 +68,17 @@ fn main() -> anyhow::Result<()> {
// Bring up the USB keyboard in the background; keys arrive via next_key().
usb_kbd::start()?;
// Seed the editor from the saved note (caret at the end, ready to type on).
// Seed the editor from the saved note. Boots in Normal mode with the caret
// on the last character (the resume point) — press `i`/`a`/`o` to write.
let mut ed = Editor::with_text(saved);
// Confirm the boot-load on the panel (no serial console in normal use):
// "loaded <name>" using the note's filename without its suffix (notes.md ->
// notes). Cleared by the first keystroke, like any snackbar.
let name = std::path::Path::new(NOTES)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("notes");
ed.set_notice(format!("loaded {name}"));
let mut updates: u32 = 0;
let mut cursor_shown = true; // the initial render includes the caret
let mut last_activity = Instant::now();
@@ -104,8 +113,8 @@ fn main() -> anyhow::Result<()> {
// no libgit2 / git2 at all — see the note on `publish`.
match effect {
Effect::None => {}
Effect::Save => save_note(&storage, &ed),
Effect::Publish => publish(&storage, &ed),
Effect::Save => save_note(&storage, &mut ed),
Effect::Publish => publish(&storage, &mut ed),
}
// Keyboard attach/detach feeds the panel's disconnect flag.
@@ -248,10 +257,17 @@ fn show_message(epd: &mut Epd, msg: &str) -> anyhow::Result<()> {
/// 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) {
fn save_note(storage: &Storage, ed: &mut Editor) {
let n = ed.text().len();
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"),
Ok(()) => {
log::info!(":w — saved {n} bytes to {NOTES}");
ed.set_notice("saved");
}
Err(e) => {
log::error!(":w — save FAILED ({e:#}); buffer kept in RAM, retry :w");
ed.set_notice("save FAILED - retry :w");
}
}
}
@@ -265,8 +281,10 @@ fn save_note(storage: &Storage, ed: &Editor) {
/// This `#[cfg]` is the seam that keeps the light build light: the git-only code
/// path is only ever compiled under `--features git`, so wiring publish in later
/// can never drag libgit2 into a light build.
fn publish(storage: &Storage, ed: &Editor) {
fn publish(storage: &Storage, ed: &mut Editor) {
// Publishing an unsaved buffer is meaningless, so save first in both builds.
// (`save_note` posts the "saved N B" snackbar; the git path will overwrite it
// with a push result once wired.)
save_note(storage, ed);
#[cfg(feature = "git")]