feat: add multi-file buffer foundation (v0.5 slice 1)
Rework the single Effect return into a drained effect queue
(Save{path,scope,contents} / Load / Publish / Pull) so one action can
ask the host for several ordered steps: opening a non-resident file
queues a Save of the outgoing dirty buffer then a Load of the target.
Keep the active buffer's fields inline on Editor and park inactive
buffers in a small LRU (<=3 resident = active + 2); switching back to a
resident buffer restores its caret/scroll/undo without touching the SD.
A dirty parked buffer is saved before eviction, so nothing leaves RAM
unsaved. `:e <path>` opens by prefix (/sd/repo -> Tracked, /sd/local ->
Local); `:sync` is refused in-core for a Local buffer.
Firmware drains the queue to empty each batch (a Load can cascade an
eviction Save) and persistence generalises the atomic save off the
hard-coded notes.md into load_path/save_path.
Also bump the side panel to FONT_9X15 and the `:` command line to
FONT_10X20 for legibility, word-wrapping the snackbar so a long notice
keeps its actionable tail.
This commit is contained in:
22
CONTEXT.md
22
CONTEXT.md
@@ -34,6 +34,25 @@ stay Local for their lifetime. Lives under `/sd/local/`.
|
||||
_Avoid_: draft, private, untracked, scratch (these all imply impermanence or
|
||||
promotability, which is not the model).
|
||||
|
||||
### Editing model
|
||||
|
||||
**Buffer**:
|
||||
A **File** loaded into memory for editing, with its own caret, scroll position,
|
||||
and undo history. Opening a file makes it the **active buffer** — the one the
|
||||
**Writing column** shows. Up to three buffers stay **resident** at once (the
|
||||
active one plus two parked in the background); switching back to a resident
|
||||
buffer restores its caret and undo without re-reading the card. A fourth open
|
||||
**evicts** the least-recently-used resident buffer — saved first if it has
|
||||
unsaved edits, so nothing is lost.
|
||||
_Avoid_: tab, window, document (a buffer is not a UI chrome element); "the file"
|
||||
when you mean the in-memory copy rather than the bytes on the card.
|
||||
|
||||
**Open**:
|
||||
Bringing a **File** into the **active buffer**, via `Ctrl-P` (the file palette)
|
||||
or `:e`. Scope is read from where the file lives (`/sd/repo` → **Tracked**,
|
||||
`/sd/local` → **Local**), never chosen at open time.
|
||||
_Avoid_: load (implementation talk for the disk read behind an Open).
|
||||
|
||||
### User-facing actions
|
||||
|
||||
**Save**:
|
||||
@@ -68,7 +87,8 @@ _Avoid_: edit area, text area, main pane (superseded — they named the old
|
||||
full-width text region before the side panel carved out its right edge).
|
||||
|
||||
**Side panel**:
|
||||
The right region (~160 px / ~25 cols, full height) holding all metadata:
|
||||
The right region (~160 px / ~17 cols at its FONT_9X15 metadata font, full
|
||||
height) holding all metadata:
|
||||
filename + dirty dot, word count, elapsed time, clock, Wi-Fi,
|
||||
keyboard-disconnect flag, publish state, and the mode indicator at its
|
||||
bottom-left. Sits entirely in the master half
|
||||
|
||||
@@ -316,20 +316,42 @@ pending-`g` machinery, no vim clash. View mode stays; `v`/`V` are now Visual.
|
||||
- [x] Ahead of schedule / unscheduled: `:fmt` Markdown formatter
|
||||
(table alignment, blank-line collapse, trailing-whitespace strip)
|
||||
|
||||
## v0.5 — File palette + multi-file — [ ]
|
||||
## v0.5 — File palette + multi-file — [~]
|
||||
|
||||
**Status:** not started (Spikes 11 + 14 retire the panel-mechanism and
|
||||
buffer-lifecycle risk first).
|
||||
**Status:** buffer **foundation** landed in core 2026-07-11 (slice 1 of 4),
|
||||
host-tested; the palette + transient panel (Spike 11) and delete → git-staging
|
||||
(Spike 14) remain the on-device gates. The single-file `Effect` return became a
|
||||
drained **effect queue** (`Save{path,contents}` / `Load{path}` / `Publish` /
|
||||
`Pull`), so one action can ask the host for several steps in order — opening a
|
||||
non-resident file queues a `Save` of the outgoing dirty buffer *then* a `Load` of
|
||||
the target. The multi-buffer state deliberately avoids a rope-per-buffer rewrite:
|
||||
the active buffer keeps its fields inline on `Editor`, inactive buffers park in a
|
||||
small LRU `Vec<Buffer>` (≤ 3 resident = active + 2), and a switch marshals fields
|
||||
in/out so the ~3k-line editing engine is untouched. A dirty parked buffer is
|
||||
saved before it is evicted (nothing leaves RAM unsaved); `:e <path>` opens by
|
||||
prefix (`/sd/repo` → Tracked, `/sd/local` → Local); `:sync` is refused in-core in
|
||||
a Local buffer. Firmware drains the queue **to empty** each batch (a `Load` can
|
||||
cascade an eviction `Save`), and `persistence::{load_path,save_path}` generalise
|
||||
the atomic save off the hard-coded `notes.md`. 93 editor tests pass; the default
|
||||
(no-git) firmware binary builds clean. Remaining v0.5 slices: 2 palette + fuzzy +
|
||||
transient panel, 3 `:enew` + delete, 4 prefs + palette command mode.
|
||||
|
||||
- [ ] `Ctrl-P` opens fuzzy file palette over **both** `/sd/repo/` and
|
||||
`/sd/local/`, with a scope marker (e.g. `[git]` / `[local]`) per result
|
||||
- [ ] Open, switch, close buffers (keep ≤ 3 in memory)
|
||||
- [ ] `:e` and palette share the same recent-files list
|
||||
- [~] Open, switch, close buffers (keep ≤ 3 in memory) — **open + switch + the
|
||||
≤ 3 LRU-resident model with dirty-aware save-before-evict done in core**
|
||||
(host-tested); `:e <path>` drives it today. Explicit **close** (and the
|
||||
palette that surfaces switching) still to come.
|
||||
- [~] `:e` and palette share the same recent-files list — **`:e <path>` landed**
|
||||
(prefix → scope); the shared recent-files list arrives with the palette.
|
||||
- [ ] `:enew` creates a new file — prompts for scope (tracked vs local)
|
||||
- [ ] Delete a file — removes it from the SD card; for a Tracked file the
|
||||
removal reaches the next `Ctrl-G` Publish's staged set (`git rm` / `add -A`
|
||||
semantics, not plain `git add .`); a Local file is just unlinked
|
||||
- [ ] `Ctrl-G` is disabled / hidden when the current buffer is local-scope
|
||||
- [~] `Ctrl-G` is disabled / hidden when the current buffer is local-scope —
|
||||
**`:sync` / Publish is blocked in-core for a Local buffer** (posts "Publish
|
||||
unavailable (Local)"); the side-panel affordance that hides/greys the
|
||||
gesture is the remaining half.
|
||||
- [ ] The side panel briefly shows file count on `Ctrl-G` when the publish bundles
|
||||
more than one dirty Tracked file (e.g. `"publishing 3 files: abc1234"`),
|
||||
so workspace-scoped behaviour stays visible to the user
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// ISO-8859-15 (Latin-9) rather than the ascii subset: same glyph cells, but it
|
||||
// carries the accented Latin glyphs (à é ê ç … plus œ €) that international
|
||||
// input will emit. ASCII rendering is byte-for-byte unchanged.
|
||||
use embedded_graphics::mono_font::iso_8859_15::{FONT_6X10, FONT_10X20};
|
||||
use embedded_graphics::mono_font::iso_8859_15::{FONT_9X15, FONT_10X20};
|
||||
use embedded_graphics::mono_font::MonoTextStyle;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
@@ -31,14 +31,16 @@ pub const CH: i32 = 20;
|
||||
/// [`Editor::text_cols`]). The right **side panel** holds metadata (see
|
||||
/// CONTEXT.md § Screen regions). 63 cols × 10 px = 630 px; the driver's
|
||||
/// `x = 396` seam runs through it invisibly. The remaining 162 px (right of the
|
||||
/// divider) hold the ~25-col side panel. Widened from 60 so the gutter doesn't
|
||||
/// narrow the text: a ≤ 99-line file keeps a full 60-col text column.
|
||||
/// divider) hold the ~17-col side panel (at its FONT_9X15 metadata font — see
|
||||
/// [`PANEL_COLS`]). Widened from 60 so the gutter doesn't narrow the text: a
|
||||
/// ≤ 99-line file keeps a full 60-col text column.
|
||||
const WRITE_COLS: usize = 63;
|
||||
/// Minimum digit columns in the line-number gutter (before the 1-col separator).
|
||||
/// Files up to 99 lines still get a 2-wide gutter so short notes don't jitter.
|
||||
const GUTTER_MIN_DIGITS: usize = 2;
|
||||
/// Visible writing rows. 13 × 20 px = 260 px; the bottom 12 px is the transient
|
||||
/// `:` command line (the only thing left of the old status band).
|
||||
/// Visible writing rows. 13 × 20 px = 260 px. The transient `:` command line is
|
||||
/// drawn at body size over the **bottom** writing row (see [`Editor::draw_cmdline`]),
|
||||
/// so no rows are permanently reserved for it.
|
||||
const ROWS: usize = (HEIGHT / 20) as usize; // 13
|
||||
/// Half-page scroll distance for `Ctrl-d`/`Ctrl-u`, in **display rows** — vim's
|
||||
/// `'scroll'` default (half the visible window). Fixed, not configurable: a
|
||||
@@ -48,10 +50,22 @@ const HALF_PAGE: usize = ROWS / 2; // 6
|
||||
/// of panel text (a small gutter past the rule).
|
||||
const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 630
|
||||
const PANEL_X: i32 = DIVIDER_X + 8; // 638
|
||||
/// 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; // 25
|
||||
/// Side-panel font cell: **FONT_9X15** — a middle size between the old squint-y
|
||||
/// 6×10 and the body 10×20. Legible metadata without eating as many columns as
|
||||
/// the body font would (the `:` command line, being text you type, stays at the
|
||||
/// body 10×20 — see [`Editor::draw_cmdline`]). Kept as its own pair (not reusing
|
||||
/// `CW`/`CH`) so the panel font tunes independently of the writing font; change
|
||||
/// these **and** the `MonoTextStyle` font in `draw_panel` together.
|
||||
const PANEL_CW: i32 = 9;
|
||||
const PANEL_CH: i32 = 15;
|
||||
/// Side-panel text width in [`PANEL_CW`]-px 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) / PANEL_CW as usize; // 15
|
||||
/// Max wrapped lines the snackbar draws under the word count, so a long notice
|
||||
/// can't run down into the bottom mode strip. Four PANEL_CH rows ≈ 60 chars,
|
||||
/// enough for any current message.
|
||||
const NOTICE_MAX_LINES: usize = 4;
|
||||
/// Tab stop, in spaces. Tabs never enter the buffer — they expand on insert so
|
||||
/// the buffer stays 1 char = 1 column.
|
||||
const TAB: &str = " ";
|
||||
@@ -77,19 +91,41 @@ pub enum Mode {
|
||||
Command,
|
||||
}
|
||||
|
||||
/// A side effect the host (firmware) must carry out after a `:` command. The
|
||||
/// editor core is pure and does no IO, so persistence and publishing can't
|
||||
/// happen here — they're signalled out through [`Editor::handle`]'s return
|
||||
/// value and actioned by the main loop. `:fmt` is pure text work and stays
|
||||
/// in-core, so it yields [`Effect::None`].
|
||||
/// Which of the two file scopes ([`CONTEXT.md`]) a buffer belongs to. Fixed at
|
||||
/// creation — there is no move-between-scopes operation. **Tracked** files live
|
||||
/// under [`REPO_DIR`] and can be Published (`:sync`); **Local** files live under
|
||||
/// [`LOCAL_DIR`] and never leave the device, so `:sync` is refused in-core.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Scope {
|
||||
Tracked,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// A side effect the host (firmware) must carry out. The editor core is pure and
|
||||
/// does no IO, so persistence, publishing, and file reads can't happen here —
|
||||
/// they are queued and drained by [`Editor::take_effects`] after a key batch,
|
||||
/// then actioned by the main loop. `:fmt` is pure text work and stays in-core,
|
||||
/// so it queues nothing.
|
||||
///
|
||||
/// A single key can queue more than one effect: opening a file that isn't
|
||||
/// resident queues a [`Save`](Effect::Save) of the outgoing dirty buffer *and* a
|
||||
/// [`Load`](Effect::Load) of the target. Effects are serviced in order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// Nothing for the host to do — ordinary keys, `:fmt`, unknown commands.
|
||||
None,
|
||||
/// `:w` (and the `:wq`/`:x` aliases) — persist the buffer to storage.
|
||||
Save,
|
||||
/// `:sync` — publish the buffer (save, then git push). The host saves
|
||||
/// first: publishing an unsaved buffer is meaningless.
|
||||
/// Persist `contents` to `path` (an atomic save on the host). Queued by `:w`
|
||||
/// (and the `:wq`/`:x` aliases), by save-before-switch, and by
|
||||
/// save-before-evict. The contents ride along because the buffer being saved
|
||||
/// is not always the active one — an evicted buffer's text is no longer
|
||||
/// reachable through [`Editor::text`]. On success the host calls
|
||||
/// [`Editor::mark_saved`].
|
||||
Save { path: String, scope: Scope, contents: String },
|
||||
/// Read `path` from disk; on success the host installs it with
|
||||
/// [`Editor::install_loaded`]. Queued when switching to a file that is not
|
||||
/// resident in memory (`:e`, palette pick).
|
||||
Load { path: String, scope: Scope },
|
||||
/// `:sync` — publish the Tracked working copy (git push). Preceded by a
|
||||
/// [`Save`](Effect::Save) of the current buffer in the same batch. Never
|
||||
/// queued from a Local buffer (blocked in-core).
|
||||
Publish,
|
||||
/// `:gl` — pull from the remote: fetch, then **fast-forward only**. The host
|
||||
/// refuses (and surfaces) a divergence rather than merging, and never
|
||||
@@ -97,6 +133,64 @@ pub enum Effect {
|
||||
Pull,
|
||||
}
|
||||
|
||||
/// Tracked files live here (the git working copy).
|
||||
pub const REPO_DIR: &str = "/sd/repo";
|
||||
/// Local files live here (never published).
|
||||
pub const LOCAL_DIR: &str = "/sd/local";
|
||||
|
||||
/// Resolve a `:e` argument (or palette pick) to an absolute path + [`Scope`]. An
|
||||
/// absolute path under [`LOCAL_DIR`] is Local; any other absolute path (including
|
||||
/// under [`REPO_DIR`]) is Tracked. A bare name (no `/`) is joined onto the current
|
||||
/// buffer's scope directory, so `:e draft.md` opens a sibling of the file you're
|
||||
/// in.
|
||||
fn resolve_path(arg: &str, current: Scope) -> (String, Scope) {
|
||||
if arg.starts_with(&format!("{LOCAL_DIR}/")) {
|
||||
(arg.to_string(), Scope::Local)
|
||||
} else if arg.starts_with('/') {
|
||||
(arg.to_string(), Scope::Tracked)
|
||||
} else {
|
||||
let dir = match current {
|
||||
Scope::Tracked => REPO_DIR,
|
||||
Scope::Local => LOCAL_DIR,
|
||||
};
|
||||
(format!("{dir}/{arg}"), current)
|
||||
}
|
||||
}
|
||||
|
||||
/// Word-wrap `text` to lines of at most `width` characters, for the side-panel
|
||||
/// snackbar. Packs whole words greedily; a word longer than `width` is hard-split
|
||||
/// across lines (so a long path or oid still shows in full rather than being
|
||||
/// truncated). Empty input yields no lines.
|
||||
fn wrap_text(text: &str, width: usize) -> Vec<String> {
|
||||
let width = width.max(1);
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if word.chars().count() > width {
|
||||
if !cur.is_empty() {
|
||||
lines.push(core::mem::take(&mut cur));
|
||||
}
|
||||
let mut chars = word.chars().peekable();
|
||||
while chars.peek().is_some() {
|
||||
lines.push(chars.by_ref().take(width).collect());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let sep = usize::from(!cur.is_empty());
|
||||
if cur.chars().count() + sep + word.chars().count() > width {
|
||||
lines.push(core::mem::take(&mut cur));
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
cur.push(' ');
|
||||
}
|
||||
cur.push_str(word);
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
lines.push(cur);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange /
|
||||
/// `y`ank).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -173,8 +267,50 @@ pub struct Editor {
|
||||
/// True while `.` is replaying [`dot`], so the replayed keys are neither
|
||||
/// re-recorded nor able to re-trigger `.`.
|
||||
replaying: bool,
|
||||
/// Absolute path of the active buffer on the SD card (e.g.
|
||||
/// `/sd/repo/notes.md`). Empty for an unnamed scratch buffer (the boot-message
|
||||
/// layout use); `:w` on an empty path posts "no file name" rather than saving.
|
||||
path: String,
|
||||
/// The active buffer's scope. Gates Publish — `:sync` is refused in Local.
|
||||
scope: Scope,
|
||||
/// Whether the active buffer has unsaved edits. Set at each change-group
|
||||
/// ([`checkpoint`](Self::checkpoint)) and cleared when the host confirms a
|
||||
/// save ([`mark_saved`](Self::mark_saved)). Decides whether a switch/evict
|
||||
/// persists the buffer first. Deliberately over-eager: entering Insert and
|
||||
/// leaving without typing marks it dirty, costing at most one redundant
|
||||
/// (idempotent) save — cheaper than tracking every mutation site.
|
||||
dirty: bool,
|
||||
/// Inactive-but-resident buffers, least-recently-used first. The active
|
||||
/// buffer plus these is capped at [`MAX_RESIDENT`]; switching away parks the
|
||||
/// active buffer here (with its caret, scroll, and undo), switching back
|
||||
/// restores it without touching the disk. A parked buffer pushed over the cap
|
||||
/// is evicted — saved first (via an [`Effect::Save`]) if it is dirty.
|
||||
parked: Vec<Buffer>,
|
||||
/// Host-effect queue, drained by [`take_effects`](Self::take_effects) after a
|
||||
/// key batch. See [`Effect`].
|
||||
requests: Vec<Effect>,
|
||||
}
|
||||
|
||||
/// A resident-but-inactive buffer: everything needed to restore a file's editing
|
||||
/// state when the user switches back, without re-reading the disk. The active
|
||||
/// buffer holds these same fields inline on [`Editor`]; parking marshals them
|
||||
/// out to here, activation marshals them back.
|
||||
struct Buffer {
|
||||
path: String,
|
||||
scope: Scope,
|
||||
text: String,
|
||||
caret: usize,
|
||||
scroll_top: usize,
|
||||
dirty: bool,
|
||||
undo: Vec<(String, usize)>,
|
||||
redo: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
/// Buffers kept resident at once — the active one plus [`MAX_RESIDENT`] − 1
|
||||
/// parked (v0.5 keeps ≤ 3). Beyond this the least-recently-used parked buffer is
|
||||
/// evicted; it is saved first if dirty, so an evicted buffer is never lost.
|
||||
const MAX_RESIDENT: usize = 3;
|
||||
|
||||
/// Maximum undo depth (change-groups). A full-buffer snapshot per group means
|
||||
/// worst-case memory is `UNDO_DEPTH × buffer size`; for note-sized files on the
|
||||
/// 8 MB PSRAM this is negligible, and prose editing rarely nears 100 groups
|
||||
@@ -211,6 +347,11 @@ impl Editor {
|
||||
dot: Vec::new(),
|
||||
dot_recording: None,
|
||||
replaying: false,
|
||||
path: String::new(),
|
||||
scope: Scope::Tracked,
|
||||
dirty: false,
|
||||
parked: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +362,16 @@ impl Editor {
|
||||
/// 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 mut ed = Editor { text, ..Editor::new() };
|
||||
Self::with_file(String::new(), Scope::Tracked, text)
|
||||
}
|
||||
|
||||
/// Seed a fresh editor from a named file's saved text — the boot-load and
|
||||
/// file-open path. Same boot posture as [`with_text`](Self::with_text)
|
||||
/// (Normal mode, caret on the last character) but records the file's `path`
|
||||
/// and `scope` so `:w` knows where to persist and `:sync` knows whether
|
||||
/// Publish is offered.
|
||||
pub fn with_file(path: String, scope: Scope, text: String) -> Self {
|
||||
let mut ed = Editor { text, path, scope, ..Editor::new() };
|
||||
ed.caret = ed.text.len();
|
||||
if ed.caret > ed.line_start(ed.caret) {
|
||||
ed.caret = ed.prev_char(ed.caret);
|
||||
@@ -238,6 +388,55 @@ impl Editor {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Absolute path of the active buffer (empty for an unnamed scratch buffer).
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The active buffer's [`Scope`]. The host hides/greys `Ctrl-G` in Local.
|
||||
pub fn scope(&self) -> Scope {
|
||||
self.scope
|
||||
}
|
||||
|
||||
/// Whether the active buffer has unsaved edits.
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
/// Drain the queued host effects (save/load/publish/pull). The main loop
|
||||
/// calls this after applying a key batch and services them in order.
|
||||
pub fn take_effects(&mut self) -> Vec<Effect> {
|
||||
core::mem::take(&mut self.requests)
|
||||
}
|
||||
|
||||
/// The host confirms `path` was persisted; clear its dirty flag wherever that
|
||||
/// buffer is resident (active or parked). A no-op for a path that is no longer
|
||||
/// in memory (already-evicted buffers were saved on the way out).
|
||||
pub fn mark_saved(&mut self, path: &str) {
|
||||
if self.path == path {
|
||||
self.dirty = false;
|
||||
}
|
||||
if let Some(b) = self.parked.iter_mut().find(|b| b.path == path) {
|
||||
b.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a file the host read from disk in response to an [`Effect::Load`]:
|
||||
/// park the current buffer and make the loaded one active. If the target
|
||||
/// turned resident in the meantime, switch to that copy instead (its in-memory
|
||||
/// edits win over a stale disk read).
|
||||
pub fn install_loaded(&mut self, path: String, scope: Scope, contents: String) {
|
||||
if path == self.path {
|
||||
return;
|
||||
}
|
||||
if self.parked.iter().any(|b| b.path == path) {
|
||||
self.open_path(path, scope);
|
||||
return;
|
||||
}
|
||||
self.park_active();
|
||||
self.set_active(path, scope, contents);
|
||||
}
|
||||
|
||||
pub fn scroll_top(&self) -> usize {
|
||||
self.scroll_top
|
||||
}
|
||||
@@ -267,13 +466,14 @@ impl Editor {
|
||||
self.text.split_whitespace().count()
|
||||
}
|
||||
|
||||
/// Dispatch one decoded key event according to the current mode, returning
|
||||
/// 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 {
|
||||
/// Dispatch one decoded key event according to the current mode. Any host
|
||||
/// effect a `:` command (or a buffer switch) triggers is pushed to the queue
|
||||
/// drained by [`take_effects`](Self::take_effects); ordinary keys queue
|
||||
/// nothing.
|
||||
pub fn handle(&mut self, key: Key) {
|
||||
// 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
|
||||
// sets a fresh one *after* the key batch (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;
|
||||
@@ -290,7 +490,7 @@ impl Editor {
|
||||
&& key == Key::Char('.')
|
||||
{
|
||||
self.repeat_last_change();
|
||||
return Effect::None;
|
||||
return;
|
||||
}
|
||||
|
||||
// State before dispatch, so `record_dot` can read the transition a key
|
||||
@@ -298,30 +498,17 @@ impl Editor {
|
||||
let before_mode = self.mode;
|
||||
let before_pending = self.pending_op.is_some() || self.pending_obj.is_some();
|
||||
|
||||
let effect = match self.mode {
|
||||
Mode::Insert => {
|
||||
self.insert_key(key);
|
||||
Effect::None
|
||||
}
|
||||
Mode::Normal => {
|
||||
self.normal_key(key);
|
||||
Effect::None
|
||||
}
|
||||
Mode::Visual | Mode::VisualLine => {
|
||||
self.visual_key(key);
|
||||
Effect::None
|
||||
}
|
||||
Mode::View => {
|
||||
self.view_key(key);
|
||||
Effect::None
|
||||
}
|
||||
match self.mode {
|
||||
Mode::Insert => self.insert_key(key),
|
||||
Mode::Normal => self.normal_key(key),
|
||||
Mode::Visual | Mode::VisualLine => self.visual_key(key),
|
||||
Mode::View => self.view_key(key),
|
||||
Mode::Command => self.command_key(key),
|
||||
};
|
||||
}
|
||||
|
||||
if !self.replaying {
|
||||
self.record_dot(key, before_mode, before_pending);
|
||||
}
|
||||
effect
|
||||
}
|
||||
|
||||
/// Record `key` into the in-progress change for `.`. Called after dispatch
|
||||
@@ -631,7 +818,7 @@ impl Editor {
|
||||
|
||||
// --- Command mode (`:`) ------------------------------------------------
|
||||
|
||||
fn command_key(&mut self, key: Key) -> Effect {
|
||||
fn command_key(&mut self, key: Key) {
|
||||
match key {
|
||||
Key::Char(c) => self.cmdline.push(c),
|
||||
Key::Backspace => {
|
||||
@@ -641,10 +828,9 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
Key::Enter => {
|
||||
let effect = self.execute_command();
|
||||
self.execute_command();
|
||||
self.cmdline.clear();
|
||||
self.mode = Mode::Normal;
|
||||
return effect;
|
||||
}
|
||||
Key::Escape => {
|
||||
self.cmdline.clear();
|
||||
@@ -666,38 +852,61 @@ impl Editor {
|
||||
// Tab isn't meaningful on a short command line.
|
||||
_ => {}
|
||||
}
|
||||
Effect::None
|
||||
}
|
||||
|
||||
/// Run the typed `:` command, returning any [`Effect`] the host must carry
|
||||
/// out. Unknown commands are silently ignored. The `:q` quit family is
|
||||
/// deliberately absent — an always-on writing appliance has nothing to
|
||||
/// quit to; `:wq`/`:x` therefore just save (the "quit" half is dropped).
|
||||
fn execute_command(&mut self) -> Effect {
|
||||
match self.cmdline.trim() {
|
||||
"fmt" => {
|
||||
self.format_buffer();
|
||||
Effect::None
|
||||
}
|
||||
/// Run the typed `:` command, queuing any [`Effect`] the host must carry out.
|
||||
/// Unknown commands are silently ignored. The `:q` quit family is deliberately
|
||||
/// absent — an always-on writing appliance has nothing to quit to; `:wq`/`:x`
|
||||
/// therefore just save (the "quit" half is dropped).
|
||||
fn execute_command(&mut self) {
|
||||
let cmd = self.cmdline.trim().to_string();
|
||||
// `:e <path>` — open another file (multi-file, v0.5).
|
||||
if let Some(arg) = cmd.strip_prefix("e ") {
|
||||
self.edit_file(arg);
|
||||
return;
|
||||
}
|
||||
match cmd.as_str() {
|
||||
"fmt" => self.format_buffer(),
|
||||
"w" | "wq" | "x" => {
|
||||
if self.format_on_save {
|
||||
self.format_buffer();
|
||||
}
|
||||
Effect::Save
|
||||
self.request_save_active();
|
||||
}
|
||||
"sync" => {
|
||||
// fmt → save → commit → push: format in-core here, then the host
|
||||
// persists and publishes the already-formatted buffer.
|
||||
// Publish is Tracked-only (CONTEXT.md): a Local buffer never
|
||||
// reaches the remote, so `:sync` there is a no-op with a notice.
|
||||
if self.scope == Scope::Local {
|
||||
self.set_notice("Publish unavailable (Local)");
|
||||
return;
|
||||
}
|
||||
// fmt → save → push: format in-core, queue the save of the current
|
||||
// buffer, then the git publish. The host services them in order.
|
||||
if self.format_on_save {
|
||||
self.format_buffer();
|
||||
}
|
||||
Effect::Publish
|
||||
self.request_save_active();
|
||||
self.requests.push(Effect::Publish);
|
||||
}
|
||||
"gl" => Effect::Pull,
|
||||
_ => Effect::None,
|
||||
"gl" => self.requests.push(Effect::Pull),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue an [`Effect::Save`] of the active buffer. Posts "no file name" for an
|
||||
/// unnamed scratch buffer (nothing to save to) rather than writing to `""`.
|
||||
fn request_save_active(&mut self) {
|
||||
if self.path.is_empty() {
|
||||
self.set_notice("no file name");
|
||||
return;
|
||||
}
|
||||
self.requests.push(Effect::Save {
|
||||
path: self.path.clone(),
|
||||
scope: self.scope,
|
||||
contents: self.text.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// `:fmt` — normalize the buffer (align tables, collapse duplicate blank
|
||||
/// lines, strip trailing whitespace) and keep the caret on roughly the same
|
||||
/// line (buffer length changes, so exact restoration isn't possible).
|
||||
@@ -742,6 +951,10 @@ impl Editor {
|
||||
/// `:fmt`). If the buffer is unchanged since the last baseline it is a no-op,
|
||||
/// so calling it more than once before a mutation records only one group.
|
||||
fn checkpoint(&mut self) {
|
||||
// A change-group is about to begin, so the buffer is (or is about to be)
|
||||
// modified relative to the last save. See the `dirty` field note on why
|
||||
// this is deliberately slightly over-eager.
|
||||
self.dirty = true;
|
||||
if self.undo.last().is_some_and(|(t, _)| t == &self.text) {
|
||||
return; // nothing changed since the last baseline
|
||||
}
|
||||
@@ -782,6 +995,115 @@ impl Editor {
|
||||
self.reset_pending();
|
||||
}
|
||||
|
||||
// --- Buffers (multi-file) ----------------------------------------------
|
||||
|
||||
/// Switch the active buffer to `path`. If it is already resident (parked),
|
||||
/// restore that copy with its caret/scroll/undo intact — no disk read. If it
|
||||
/// is not resident, queue an [`Effect::Load`]; the host reads the file and
|
||||
/// calls [`install_loaded`](Self::install_loaded), which does the park + swap.
|
||||
/// A dirty outgoing buffer is preserved in RAM (parked) and persisted only
|
||||
/// when it is later evicted, so switching itself never blocks on IO.
|
||||
fn open_path(&mut self, path: String, scope: Scope) {
|
||||
if path == self.path {
|
||||
return; // already the active buffer
|
||||
}
|
||||
match self.parked.iter().position(|b| b.path == path) {
|
||||
Some(i) => {
|
||||
let target = self.parked.remove(i);
|
||||
self.park_active();
|
||||
self.activate(target);
|
||||
}
|
||||
None => self.requests.push(Effect::Load { path, scope }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the active buffer's editing state into a parked [`Buffer`], leaving
|
||||
/// the active fields empty for a subsequent [`activate`](Self::activate) or
|
||||
/// [`set_active`](Self::set_active). Evicts the least-recently-used parked
|
||||
/// buffer if that pushes residency over [`MAX_RESIDENT`]; an evicted dirty
|
||||
/// buffer queues a [`Effect::Save`] so no unsaved work leaves memory.
|
||||
fn park_active(&mut self) {
|
||||
let buf = Buffer {
|
||||
path: core::mem::take(&mut self.path),
|
||||
scope: self.scope,
|
||||
text: core::mem::take(&mut self.text),
|
||||
caret: self.caret,
|
||||
scroll_top: self.scroll_top,
|
||||
dirty: self.dirty,
|
||||
undo: core::mem::take(&mut self.undo),
|
||||
redo: core::mem::take(&mut self.redo),
|
||||
};
|
||||
self.parked.push(buf);
|
||||
// Active is currently empty, so residency == parked.len(); keep it under
|
||||
// MAX_RESIDENT so the buffer about to become active fits.
|
||||
while self.parked.len() >= MAX_RESIDENT {
|
||||
let evicted = self.parked.remove(0);
|
||||
if evicted.dirty {
|
||||
self.requests.push(Effect::Save {
|
||||
path: evicted.path,
|
||||
scope: evicted.scope,
|
||||
contents: evicted.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a parked buffer into the active fields (its caret, scroll, undo,
|
||||
/// and dirty flag come back with it). Lands in Normal with input state reset.
|
||||
fn activate(&mut self, b: Buffer) {
|
||||
self.path = b.path;
|
||||
self.scope = b.scope;
|
||||
self.text = b.text;
|
||||
self.caret = b.caret;
|
||||
self.scroll_top = b.scroll_top;
|
||||
self.dirty = b.dirty;
|
||||
self.undo = b.undo;
|
||||
self.redo = b.redo;
|
||||
self.reset_active_input();
|
||||
}
|
||||
|
||||
/// Make a freshly-loaded file the active buffer: same boot posture as
|
||||
/// [`with_file`](Self::with_file) (Normal, caret on the last char) with empty
|
||||
/// undo history and a clean dirty flag.
|
||||
fn set_active(&mut self, path: String, scope: Scope, text: String) {
|
||||
self.path = path;
|
||||
self.scope = scope;
|
||||
self.text = text;
|
||||
self.caret = self.text.len();
|
||||
if self.caret > self.line_start(self.caret) {
|
||||
self.caret = self.prev_char(self.caret);
|
||||
}
|
||||
self.scroll_top = 0;
|
||||
self.dirty = false;
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.reset_active_input();
|
||||
}
|
||||
|
||||
/// Reset the transient per-keystroke input state (mode, pending operator,
|
||||
/// visual anchor, command line) on a buffer swap, so nothing leaks across.
|
||||
/// The register and `.` history are deliberately left alone — they are global
|
||||
/// (vim-like), so a yank in one file pastes in another.
|
||||
fn reset_active_input(&mut self) {
|
||||
self.mode = Mode::Normal;
|
||||
self.visual_anchor = None;
|
||||
self.cmdline.clear();
|
||||
self.reset_pending();
|
||||
}
|
||||
|
||||
/// `:e <arg>` — resolve `arg` to an absolute path + scope and open it. A path
|
||||
/// under `/sd/local/` is Local, one under `/sd/repo/` is Tracked; a bare name
|
||||
/// (no slash) lands in the current buffer's scope directory.
|
||||
fn edit_file(&mut self, arg: &str) {
|
||||
let arg = arg.trim();
|
||||
if arg.is_empty() {
|
||||
self.set_notice("usage: :e <file>");
|
||||
return;
|
||||
}
|
||||
let (path, scope) = resolve_path(arg, self.scope);
|
||||
self.open_path(path, scope);
|
||||
}
|
||||
|
||||
// --- Visual mode -------------------------------------------------------
|
||||
|
||||
/// True while a Visual selection is active (charwise or linewise).
|
||||
@@ -1804,7 +2126,7 @@ impl Editor {
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
|
||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||
let style = MonoTextStyle::new(&FONT_9X15, BinaryColor::On);
|
||||
|
||||
// Word count, from the throttled snapshot (never per keystroke).
|
||||
let words = format!("{} words", self.shown_words);
|
||||
@@ -1813,13 +2135,21 @@ impl Editor {
|
||||
.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.
|
||||
// save/publish result. Word-wrapped to the panel width (so a message like
|
||||
// "save FAILED - retry :w" keeps its actionable tail instead of clipping
|
||||
// mid-word) and capped at a few lines so it can't reach the bottom mode
|
||||
// strip; 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();
|
||||
for (i, line) in wrap_text(msg, PANEL_COLS)
|
||||
.into_iter()
|
||||
.take(NOTICE_MAX_LINES)
|
||||
.enumerate()
|
||||
{
|
||||
let y = 2 + PANEL_CH + 2 + i as i32 * PANEL_CH;
|
||||
Text::with_baseline(&line, Point::new(PANEL_X, y), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard-disconnect flag, just above the mode line, shown only while
|
||||
@@ -1827,7 +2157,7 @@ impl Editor {
|
||||
if !self.keyboard_present {
|
||||
Text::with_baseline(
|
||||
"NO KBD",
|
||||
Point::new(PANEL_X, HEIGHT as i32 - 24),
|
||||
Point::new(PANEL_X, HEIGHT as i32 - 2 * PANEL_CH),
|
||||
style,
|
||||
Baseline::Top,
|
||||
)
|
||||
@@ -1867,7 +2197,7 @@ impl Editor {
|
||||
}
|
||||
Text::with_baseline(
|
||||
&s,
|
||||
Point::new(PANEL_X, HEIGHT as i32 - 12),
|
||||
Point::new(PANEL_X, HEIGHT as i32 - PANEL_CH),
|
||||
style,
|
||||
Baseline::Top,
|
||||
)
|
||||
@@ -1876,15 +2206,30 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
/// The transient `:` command line, in the bottom strip below the writing
|
||||
/// column (vim-style). Shown only while composing a command.
|
||||
/// The transient `:` command line, drawn at body size (FONT_10X20) along the
|
||||
/// bottom of the writing column (vim-style). Shown only while composing a
|
||||
/// command. At this size it no longer fits the old 12 px sliver, so it
|
||||
/// overlays the bottom writing row: blank that row to white first, then draw
|
||||
/// `:cmd` over it. The row's text reappears on the next render once the
|
||||
/// command finishes (Enter/Esc) — you never read the last line while typing a
|
||||
/// command. Blanks only the writing column (left of the divider), so the
|
||||
/// panel is untouched (and in Command mode its mode line isn't drawn anyway).
|
||||
fn draw_cmdline(&self, f: &mut Frame) {
|
||||
if self.mode != Mode::Command {
|
||||
return;
|
||||
}
|
||||
let band_top = (ROWS as i32 - 1) * CH; // start of the last writing row
|
||||
Rectangle::new(
|
||||
Point::new(0, band_top),
|
||||
Size::new(DIVIDER_X as u32, (HEIGHT as i32 - band_top) as u32),
|
||||
)
|
||||
.into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
|
||||
let s = format!(":{}", self.cmdline);
|
||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||
Text::with_baseline(&s, Point::new(2, ROWS as i32 * CH + 1), style, Baseline::Top)
|
||||
let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
|
||||
Text::with_baseline(&s, Point::new(2, HEIGHT as i32 - CH), style, Baseline::Top)
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -2088,17 +2433,39 @@ mod tests {
|
||||
e
|
||||
}
|
||||
|
||||
/// From a fresh editor, run `:{cmd}<Enter>`, returning the editor and the
|
||||
/// [`Effect`] the Enter produced.
|
||||
fn command(cmd: &str) -> (Editor, Effect) {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Escape); // ensure Normal (power-on already is)
|
||||
/// From a fresh editor over a named Tracked file, run `:{cmd}<Enter>`,
|
||||
/// returning the editor and the drained [`Effect`]s the command queued.
|
||||
fn command(cmd: &str) -> (Editor, Vec<Effect>) {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, String::new());
|
||||
e.handle(Key::Char(':')); // Normal -> Command
|
||||
for c in cmd.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
let effect = e.handle(Key::Enter);
|
||||
(e, effect)
|
||||
e.handle(Key::Enter);
|
||||
let effects = e.take_effects();
|
||||
(e, effects)
|
||||
}
|
||||
|
||||
/// Coarse kind of an [`Effect`], ignoring `Save`/`Load` payloads, so the
|
||||
/// command tests can assert intent without pinning path/scope/contents.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Save,
|
||||
Load,
|
||||
Publish,
|
||||
Pull,
|
||||
}
|
||||
|
||||
fn kinds(effects: &[Effect]) -> Vec<Kind> {
|
||||
effects
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Effect::Save { .. } => Kind::Save,
|
||||
Effect::Load { .. } => Kind::Load,
|
||||
Effect::Publish => Kind::Publish,
|
||||
Effect::Pull => Kind::Pull,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2247,63 +2614,105 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn w_command_signals_save_and_returns_to_normal() {
|
||||
let (e, eff) = command("w");
|
||||
assert_eq!(eff, Effect::Save);
|
||||
let (e, effs) = command("w");
|
||||
assert_eq!(
|
||||
effs,
|
||||
vec![Effect::Save {
|
||||
path: "/sd/repo/notes.md".into(),
|
||||
scope: Scope::Tracked,
|
||||
contents: String::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_command_signals_publish() {
|
||||
assert_eq!(command("sync").1, Effect::Publish);
|
||||
fn sync_command_saves_then_publishes() {
|
||||
// `:sync` queues a save of the current buffer, then the git publish.
|
||||
assert_eq!(kinds(&command("sync").1), vec![Kind::Save, Kind::Publish]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gl_command_signals_pull() {
|
||||
assert_eq!(command("gl").1, Effect::Pull);
|
||||
assert_eq!(kinds(&command("gl").1), vec![Kind::Pull]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_formats_the_buffer_before_publishing() {
|
||||
// fmt → save → commit → push: `:sync` runs :fmt in-core first (default on).
|
||||
let mut e = Editor::with_text("hello \nworld".to_string()); // trailing spaces
|
||||
let mut e = Editor::with_file(
|
||||
"/sd/repo/notes.md".into(),
|
||||
Scope::Tracked,
|
||||
"hello \nworld".to_string(), // trailing spaces
|
||||
);
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "sync".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
let eff = e.handle(Key::Enter);
|
||||
assert_eq!(eff, Effect::Publish);
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::Save, Kind::Publish]);
|
||||
assert_eq!(e.text(), "hello\nworld"); // :fmt stripped the trailing whitespace
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_is_refused_in_a_local_buffer() {
|
||||
// Publish is Tracked-only; `:sync` in Local queues nothing and warns.
|
||||
let mut e = Editor::with_file(
|
||||
"/sd/local/journal.md".into(),
|
||||
Scope::Local,
|
||||
"dear diary".to_string(),
|
||||
);
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "sync".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
assert!(e.take_effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_on_save_off_leaves_the_buffer_untouched() {
|
||||
let mut e = Editor::with_text("hello \nworld".to_string());
|
||||
let mut e = Editor::with_file(
|
||||
"/sd/repo/notes.md".into(),
|
||||
Scope::Tracked,
|
||||
"hello \nworld".to_string(),
|
||||
);
|
||||
e.format_on_save = false;
|
||||
e.handle(Key::Char(':'));
|
||||
e.handle(Key::Char('w'));
|
||||
let eff = e.handle(Key::Enter);
|
||||
assert_eq!(eff, Effect::Save);
|
||||
e.handle(Key::Enter);
|
||||
assert_eq!(kinds(&e.take_effects()), vec![Kind::Save]);
|
||||
assert_eq!(e.text(), "hello \nworld"); // unchanged when the pref is off
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wq_and_x_alias_save_dropping_the_quit() {
|
||||
assert_eq!(command("wq").1, Effect::Save);
|
||||
assert_eq!(command("x").1, Effect::Save);
|
||||
assert_eq!(kinds(&command("wq").1), vec![Kind::Save]);
|
||||
assert_eq!(kinds(&command("x").1), vec![Kind::Save]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_stays_in_core_and_asks_the_host_for_nothing() {
|
||||
assert_eq!(command("fmt").1, Effect::None);
|
||||
assert!(command("fmt").1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_is_ignored() {
|
||||
let (e, eff) = command("q"); // quit is deliberately unimplemented
|
||||
assert_eq!(eff, Effect::None);
|
||||
let (e, effs) = command("q"); // quit is deliberately unimplemented
|
||||
assert!(effs.is_empty());
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn w_on_an_unnamed_buffer_posts_no_file_name() {
|
||||
// A scratch buffer (empty path) has nowhere to save to.
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Char(':'));
|
||||
e.handle(Key::Char('w'));
|
||||
e.handle(Key::Enter);
|
||||
assert!(e.take_effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_text_boots_normal_with_caret_on_last_char() {
|
||||
let e = Editor::with_text("resumed draft".to_string());
|
||||
@@ -2967,4 +3376,142 @@ mod tests {
|
||||
let _ = e.draw(true); // must not panic on the empty-row highlight path
|
||||
assert_eq!(e.mode(), Mode::VisualLine);
|
||||
}
|
||||
|
||||
// ---- Multi-file buffers (v0.5) ----
|
||||
|
||||
/// Drive `:e {arg}<Enter>` from Normal.
|
||||
fn edit(e: &mut Editor, arg: &str) {
|
||||
e.handle(Key::Char(':'));
|
||||
for c in format!("e {arg}").chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_text_packs_words_and_splits_overlong_tokens() {
|
||||
// Short message: one line.
|
||||
assert_eq!(wrap_text("saved", 15), vec!["saved"]);
|
||||
// Word-wraps on the space, keeping the actionable tail.
|
||||
assert_eq!(
|
||||
wrap_text("save FAILED - retry :w", 15),
|
||||
vec!["save FAILED -", "retry :w"]
|
||||
);
|
||||
// A token longer than the width is hard-split rather than truncated.
|
||||
assert_eq!(
|
||||
wrap_text("supercalifragilistic", 8),
|
||||
vec!["supercal", "ifragili", "stic"]
|
||||
);
|
||||
assert!(wrap_text("", 15).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_maps_prefixes_and_bare_names() {
|
||||
assert_eq!(
|
||||
resolve_path("/sd/local/j.md", Scope::Tracked),
|
||||
("/sd/local/j.md".to_string(), Scope::Local)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_path("/sd/repo/n.md", Scope::Local),
|
||||
("/sd/repo/n.md".to_string(), Scope::Tracked)
|
||||
);
|
||||
// A bare name lands in the current buffer's scope directory.
|
||||
assert_eq!(
|
||||
resolve_path("draft.md", Scope::Local),
|
||||
("/sd/local/draft.md".to_string(), Scope::Local)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_path("draft.md", Scope::Tracked),
|
||||
("/sd/repo/draft.md".to_string(), Scope::Tracked)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_marks_dirty_and_mark_saved_clears_it() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "hi".into());
|
||||
assert!(!e.dirty()); // a freshly loaded buffer is clean
|
||||
e.handle(Key::Char('x')); // delete a char
|
||||
assert!(e.dirty());
|
||||
e.mark_saved("/sd/repo/a.md");
|
||||
assert!(!e.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_command_queues_a_load_for_a_nonresident_file() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
|
||||
edit(&mut e, "/sd/local/j.md");
|
||||
assert_eq!(
|
||||
e.take_effects(),
|
||||
vec![Effect::Load {
|
||||
path: "/sd/local/j.md".into(),
|
||||
scope: Scope::Local,
|
||||
}]
|
||||
);
|
||||
// The active buffer does not change until the host loads and installs it.
|
||||
assert_eq!(e.path(), "/sd/repo/a.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_loaded_parks_current_and_activates_the_target() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "hello B".into());
|
||||
assert_eq!(e.path(), "/sd/repo/b.md");
|
||||
assert_eq!(e.text(), "hello B");
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_back_to_a_resident_buffer_needs_no_load() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "AAA".into());
|
||||
assert_eq!(e.caret, 2); // caret on A's last char
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "BBBBB".into());
|
||||
// A is parked (resident) — switching back reads memory, not disk.
|
||||
edit(&mut e, "/sd/repo/a.md");
|
||||
assert!(e.take_effects().is_empty());
|
||||
assert_eq!(e.path(), "/sd/repo/a.md");
|
||||
assert_eq!(e.text(), "AAA");
|
||||
assert_eq!(e.caret, 2); // its caret came back with it
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_register_is_global_across_buffers() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "word".into());
|
||||
e.handle(Key::Char('y')); // yy — yank the line
|
||||
e.handle(Key::Char('y'));
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, String::new());
|
||||
e.handle(Key::Char('p')); // paste it into the other buffer
|
||||
assert!(e.text().contains("word"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dirty_parked_buffer_is_saved_when_evicted() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
|
||||
// Dirty the active buffer, then push it out of the ≤3 resident window.
|
||||
e.handle(Key::Char('i'));
|
||||
e.handle(Key::Char('!'));
|
||||
e.handle(Key::Escape);
|
||||
assert!(e.dirty());
|
||||
e.take_effects(); // discard anything queued so far
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "B".into()); // parks A(dirty)
|
||||
e.install_loaded("/sd/repo/c.md".into(), Scope::Tracked, "C".into()); // parked: [A,B]
|
||||
assert!(e.take_effects().is_empty()); // nothing evicted yet
|
||||
e.install_loaded("/sd/repo/d.md".into(), Scope::Tracked, "D".into()); // evicts A
|
||||
let effs = e.take_effects();
|
||||
assert_eq!(effs.len(), 1, "the evicted dirty buffer must be saved");
|
||||
match &effs[0] {
|
||||
Effect::Save { path, .. } => assert_eq!(path, "/sd/repo/a.md"),
|
||||
other => panic!("expected a Save of A, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_parked_buffer_is_dropped_silently_on_eviction() {
|
||||
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
|
||||
// A is never edited (clean); filling past ≤3 must evict it without a Save.
|
||||
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "B".into());
|
||||
e.install_loaded("/sd/repo/c.md".into(), Scope::Tracked, "C".into());
|
||||
e.take_effects();
|
||||
e.install_loaded("/sd/repo/d.md".into(), Scope::Tracked, "D".into());
|
||||
assert!(e.take_effects().is_empty()); // clean buffer: no save on evict
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, CH};
|
||||
use editor::{Editor, Effect, Mode, Scope, CH};
|
||||
use firmware::epd::{self, Epd};
|
||||
use firmware::persistence::{Storage, NOTES};
|
||||
|
||||
@@ -101,7 +101,9 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
// 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);
|
||||
// The boot note is Tracked (`/sd/repo/notes.md`); `:e` / the palette (v0.5)
|
||||
// open others, Tracked or Local.
|
||||
let mut ed = Editor::with_file(NOTES.to_string(), Scope::Tracked, 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.
|
||||
@@ -149,41 +151,56 @@ fn main() -> anyhow::Result<()> {
|
||||
// Drain all queued keystrokes (type-ahead absorbed during a refresh),
|
||||
// apply them, then do a single refresh for the batch.
|
||||
let mut keys = 0;
|
||||
let mut effect = Effect::None;
|
||||
while let Some(k) = usb_kbd::next_key() {
|
||||
// A `:` command (only) yields an Effect; keep the last one in the batch.
|
||||
match ed.handle(k) {
|
||||
Effect::None => {}
|
||||
e => effect = e,
|
||||
}
|
||||
ed.handle(k);
|
||||
keys += 1;
|
||||
}
|
||||
|
||||
// Carry out any host-side effect a `:` command asked for. Save is inline
|
||||
// (fast). `:sync` persists, then hands off to the git thread — the push
|
||||
// is behind the `git` feature, so a light build carries no libgit2/git2.
|
||||
match effect {
|
||||
Effect::None => {}
|
||||
Effect::Save => save_note(&storage, &mut ed),
|
||||
Effect::Publish => {
|
||||
// Publishing an unsaved buffer is meaningless, so persist first.
|
||||
save_note(&storage, &mut ed);
|
||||
// Then signal the git thread — non-blocking, so the ~10 s push
|
||||
// never stalls the editor. The outcome returns on `git_rx` and
|
||||
// updates the snackbar (see the idle branch below).
|
||||
#[cfg(feature = "git")]
|
||||
match git_tx.send(firmware::git_sync::PublishRequest) {
|
||||
Ok(()) => ed.set_notice("syncing..."),
|
||||
Err(_) => ed.set_notice("sync: git thread down"),
|
||||
}
|
||||
#[cfg(not(feature = "git"))]
|
||||
log::info!(":sync — saved; light build (no `git` feature) — push skipped");
|
||||
// Service the host-side effects the batch queued, in order. A file open
|
||||
// queues a Save of the outgoing dirty buffer *then* a Load of the target;
|
||||
// `:sync` queues a Save of the current buffer *then* Publish. Save/Load
|
||||
// are inline (fast SD IO); Publish hands off to the git thread — behind
|
||||
// the `git` feature, so a light build carries no libgit2/git2.
|
||||
//
|
||||
// Drain to empty rather than once: servicing a Load can itself queue an
|
||||
// eviction Save (when the swap pushes a dirty parked buffer out of the
|
||||
// ≤3 window), and that must be persisted now, not deferred to the next
|
||||
// keystroke where a power-off could lose it. The queue strictly shrinks
|
||||
// (a Save/Publish/Pull queues nothing; a Load queues at most one Save),
|
||||
// so this terminates.
|
||||
loop {
|
||||
let effects = ed.take_effects();
|
||||
if effects.is_empty() {
|
||||
break;
|
||||
}
|
||||
Effect::Pull => {
|
||||
// `:gl` — fetch + fast-forward from the remote. The on-device
|
||||
// fetch/fast-forward on the git thread is v0.7 work (git_sync
|
||||
// only exposes push today), so acknowledge and no-op for now.
|
||||
ed.set_notice("pull: not wired yet (v0.7)");
|
||||
for effect in effects {
|
||||
match effect {
|
||||
Effect::Save { path, contents, .. } => {
|
||||
save_buffer(&storage, &mut ed, &path, &contents)
|
||||
}
|
||||
Effect::Load { path, scope } => open_buffer(&storage, &mut ed, path, scope),
|
||||
Effect::Publish => {
|
||||
// Non-blocking, so the ~10 s push never stalls the editor.
|
||||
// The outcome returns on `git_rx` and updates the snackbar
|
||||
// (see the idle branch below). The Save that preceded this
|
||||
// in the batch already persisted the buffer, so this is a
|
||||
// pure git push.
|
||||
#[cfg(feature = "git")]
|
||||
match git_tx.send(firmware::git_sync::PublishRequest) {
|
||||
Ok(()) => ed.set_notice("syncing..."),
|
||||
Err(_) => ed.set_notice("sync: git thread down"),
|
||||
}
|
||||
#[cfg(not(feature = "git"))]
|
||||
log::info!(":sync — saved; light build (no `git` feature) — push skipped");
|
||||
}
|
||||
Effect::Pull => {
|
||||
// `:gl` — fetch + fast-forward from the remote. The
|
||||
// on-device fetch/fast-forward on the git thread is v0.7
|
||||
// work (git_sync only exposes push today), so acknowledge
|
||||
// and no-op for now.
|
||||
ed.set_notice("pull: not wired yet (v0.7)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +319,7 @@ fn main() -> anyhow::Result<()> {
|
||||
let ms = t0.elapsed().as_millis();
|
||||
if let Err(e) = result {
|
||||
// Never fatal — the buffer is the source of truth and safe in RAM,
|
||||
// exactly like a failed `save_note`. Drop this frame, leave `shown`
|
||||
// exactly like a failed `save_buffer`. Drop this frame, leave `shown`
|
||||
// untouched so the next paint repaints the same diff, and force a
|
||||
// clean full refresh then. Typical cause: internal DMA-capable RAM
|
||||
// briefly starved by Wi-Fi/TLS during a background `:sync`; it frees
|
||||
@@ -365,23 +382,51 @@ fn show_message(epd: &mut Epd, msg: &str) -> anyhow::Result<()> {
|
||||
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: &mut Editor) {
|
||||
let n = ed.text().len();
|
||||
match storage.save(ed.text()) {
|
||||
/// Persist a buffer to SD at `path`. 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`. On
|
||||
/// success the editor's dirty flag for that path is cleared.
|
||||
fn save_buffer(storage: &Storage, ed: &mut Editor, path: &str, contents: &str) {
|
||||
match storage.save_path(path, contents) {
|
||||
Ok(()) => {
|
||||
log::info!(":w — saved {n} bytes to {NOTES}");
|
||||
log::info!(":w — saved {} bytes to {path}", contents.len());
|
||||
ed.mark_saved(path);
|
||||
ed.set_notice("saved");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(":w — save FAILED ({e:#}); buffer kept in RAM, retry :w");
|
||||
log::error!("save FAILED ({e:#}); buffer kept in RAM, retry :w");
|
||||
ed.set_notice("save FAILED - retry :w");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn open_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) {
|
||||
match storage.load_path(&path) {
|
||||
Ok(text) => {
|
||||
log::info!("opened {path} ({} bytes, {scope:?})", text.len());
|
||||
let name = file_stem(&path);
|
||||
ed.set_notice(format!("loaded {name}"));
|
||||
ed.install_loaded(path, scope, text);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("open {path} FAILED ({e:#})");
|
||||
ed.set_notice(format!("can't open {}", file_stem(&path)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's display name — its basename without extension (`/sd/repo/notes.md`
|
||||
/// → `notes`), for the snackbar. Falls back to the raw path if it has no stem.
|
||||
fn file_stem(path: &str) -> &str {
|
||||
std::path::Path::new(path)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(path)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -250,42 +250,61 @@ impl Storage {
|
||||
Path::new(REPO_DIR).is_dir()
|
||||
}
|
||||
|
||||
/// Read `notes.md` into a `String`. Returns an empty string if the file
|
||||
/// doesn't exist yet (fresh, but provisioned, repo). Refuses a file larger
|
||||
/// than [`MAX_FILE_BYTES`] rather than loading it.
|
||||
/// Read `notes.md` into a `String` — the boot default note. Thin wrapper over
|
||||
/// [`Storage::load_path`].
|
||||
pub fn load(&self) -> Result<String> {
|
||||
match fs::metadata(NOTES) {
|
||||
self.load_path(NOTES)
|
||||
}
|
||||
|
||||
/// Read an arbitrary file under `/sd` into a `String`. Returns an empty string
|
||||
/// if the file doesn't exist yet (a `:e` of a not-yet-created name, or a fresh
|
||||
/// repo). Refuses a file larger than [`MAX_FILE_BYTES`] rather than loading it.
|
||||
///
|
||||
/// The multi-file (v0.5) load path: the editor names the file, the host reads
|
||||
/// it here and hands the text back through `Editor::install_loaded`.
|
||||
pub fn load_path(&self, path: &str) -> Result<String> {
|
||||
match fs::metadata(path) {
|
||||
Ok(m) if m.len() > MAX_FILE_BYTES => bail!(
|
||||
"{NOTES} is {} KiB — over the {} KiB v0.1 limit; open it on a computer to split it",
|
||||
"{path} is {} KiB — over the {} KiB limit; open it on a computer to split it",
|
||||
m.len() / 1024,
|
||||
MAX_FILE_BYTES / 1024
|
||||
),
|
||||
Ok(_) => fs::read_to_string(NOTES).with_context(|| format!("reading {NOTES}")),
|
||||
Ok(_) => fs::read_to_string(path).with_context(|| format!("reading {path}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
|
||||
Err(e) => Err(e).with_context(|| format!("stat {NOTES}")),
|
||||
Err(e) => Err(e).with_context(|| format!("stat {path}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically persist `contents` to `notes.md`: write the tmp, fsync,
|
||||
/// unlink the target, rename over it. See the module docs for why the unlink
|
||||
/// is mandatory on FAT and [`Storage::recover`] for the crash window it opens.
|
||||
/// Atomically persist `contents` to `notes.md`. Thin wrapper over
|
||||
/// [`Storage::save_path`].
|
||||
pub fn save(&self, contents: &str) -> Result<()> {
|
||||
self.save_path(NOTES, contents)
|
||||
}
|
||||
|
||||
/// Atomically persist `contents` to an arbitrary file under `/sd`: write the
|
||||
/// tmp, fsync, unlink the target, rename over it. See the module docs for why
|
||||
/// the unlink is mandatory on FAT. Boot recovery ([`Storage::recover`]) still
|
||||
/// only covers the default `notes.md`; per-file recovery for the other v0.5
|
||||
/// buffers is deferred to the v0.9 crash-safety work — the atomic swap here
|
||||
/// already protects each individual save.
|
||||
pub fn save_path(&self, path: &str, contents: &str) -> Result<()> {
|
||||
let tmp = format!("{path}.tmp");
|
||||
{
|
||||
let mut f = fs::File::create(NOTES_TMP)
|
||||
.with_context(|| format!("create {NOTES_TMP} (is {REPO_DIR} present?)"))?;
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("create {tmp} (does its directory exist?)"))?;
|
||||
f.write_all(contents.as_bytes())
|
||||
.with_context(|| format!("write {NOTES_TMP}"))?;
|
||||
.with_context(|| format!("write {tmp}"))?;
|
||||
// FatFS f_sync — flush the tmp fully before it can replace the target.
|
||||
f.sync_all().with_context(|| format!("fsync {NOTES_TMP}"))?;
|
||||
f.sync_all().with_context(|| format!("fsync {tmp}"))?;
|
||||
}
|
||||
// FatFS f_rename won't overwrite, so unlink the target first (tolerate a
|
||||
// missing target: the first-ever save has nothing to remove).
|
||||
match fs::remove_file(NOTES) {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e).with_context(|| format!("unlink {NOTES} before rename")),
|
||||
Err(e) => return Err(e).with_context(|| format!("unlink {path} before rename")),
|
||||
}
|
||||
fs::rename(NOTES_TMP, NOTES).with_context(|| format!("rename {NOTES_TMP} -> {NOTES}"))?;
|
||||
fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user