refactor(editor): split lib.rs into domain modules

Move-only: tests to tests.rs, then the 6.8k-line lib.rs into 12
concept modules (buffers, editing, fuzzy, markdown, motions, palette,
prefs, render, search, snippets, undo, visual). impl Editor is split
across them; moved private items become pub(crate) and the public API
is preserved via root re-exports. No logic changes; 221 tests and the
firmware build pass unchanged.
This commit is contained in:
Julien Calixte
2026-07-14 10:53:33 +02:00
parent 091688d7c3
commit 459d1a5eb4
14 changed files with 5972 additions and 5866 deletions

372
editor/src/buffers.rs Normal file
View File

@@ -0,0 +1,372 @@
//! Multi-buffer management: the active/parked buffer set, the file
//! registry and MRU list, and path resolution between the repo and local scopes.
use super::*;
/// 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`/`:enew` argument (or palette pick) to an absolute path +
/// [`Scope`]. Everything the writer can reach lives on the card under `/sd`, so
/// the `/sd` prefix is **optional**: `/sd/repo/x`, `/repo/x`, and `repo/x` all
/// name the same file, and nothing resolves outside `/sd`. The arg is normalized
/// to a scope-relative form (peel an optional `/sd`, then an optional leading
/// `/`), then:
/// - a leading `local/` or `repo/` segment **selects the scope** and names the
/// file in it — the same labels the palette shows (`local/journal.md`,
/// `repo/notes.md`), so a name read off the palette is typeable verbatim. Safe
/// because scopes are flat: there are no real `local/`/`repo/` subdirectories;
/// - otherwise a bare name joins the **current** buffer's scope directory, so
/// `:e draft.md` opens a sibling of the file you're in.
pub(crate) fn resolve_path(arg: &str, current: Scope) -> (String, Scope) {
// Peel the optional `/sd` prefix, then an optional leading `/`, leaving a
// scope-relative remainder (`repo/…`, `local/…`, or a bare name).
let rel = arg
.strip_prefix("/sd/")
.or_else(|| arg.strip_prefix('/'))
.unwrap_or(arg);
if let Some(name) = rel.strip_prefix("local/") {
(format!("{LOCAL_DIR}/{name}"), Scope::Local)
} else if let Some(name) = rel.strip_prefix("repo/") {
(format!("{REPO_DIR}/{name}"), Scope::Tracked)
} else {
let dir = match current {
Scope::Tracked => REPO_DIR,
Scope::Local => LOCAL_DIR,
};
(format!("{dir}/{rel}"), current)
}
}
/// 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.
pub(crate) struct Buffer {
pub(crate) 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.
pub(crate) const MAX_RESIDENT: usize = 3;
/// Recent-files (MRU) list length — how many opens the palette remembers; they
/// are the whole result list below [`PALETTE_MIN_QUERY`] chars and float to the
/// top above it. Far more than [`MAX_RESIDENT`] (recency
/// outlives residency: a file evicted from memory is still recently *used*), but
/// bounded so the list can't grow without limit over a long session.
pub(crate) const MRU_MAX: usize = 16;
impl Editor {
/// 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);
}
/// Replace the active buffer's contents after the file changed on disk
/// underneath us — a `:gl` pull fast-forwarded the working copy. Same boot
/// posture as a fresh load (Normal, caret on the last char, clean, no undo
/// history — the old snapshots reference the replaced text). The host only
/// calls this when the buffer is clean; a dirty buffer's RAM edits win
/// (last-writer-wins, like the reconcile path).
pub fn refresh_active(&mut self, contents: String) {
let (path, scope) = (self.path.clone(), self.scope);
self.set_active(path, scope, contents);
}
/// Drop every *clean* parked buffer, so the next switch to one re-reads the
/// disk ([`Effect::Load`]) instead of resurrecting a stale resident copy —
/// a `:gl` pull may have rewritten any tracked file. Dirty parked buffers
/// are kept: their unsaved edits win over the pulled state, exactly like
/// the active buffer's.
pub fn drop_clean_parked(&mut self) {
self.parked.retain(|b| b.dirty);
}
/// 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 `""`.
pub(crate) 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(),
});
}
/// 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.
pub(crate) fn open_path(&mut self, path: String, scope: Scope) {
if path == self.path {
return; // already the active buffer
}
self.note_recent(&path); // float it to the top of the palette's MRU
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.
pub(crate) 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.
pub(crate) 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.
pub(crate) 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.
pub(crate) fn reset_active_input(&mut self) {
self.mode = Mode::Normal;
self.visual_anchor = None;
self.cmdline.clear();
self.reset_pending();
}
/// `:enew <arg>` — create a new file and make it the active buffer. Scope is
/// read from the path exactly like `:e` (`local/…` → Local, else Tracked;
/// a bare name lands in the current buffer's scope), so no scope prompt is
/// needed — the resolved scope is echoed in the snackbar instead. If the name
/// already resolves to the active or a parked buffer, this just switches to it
/// (no clobber); otherwise the buffer starts empty and **dirty**, so it is
/// durable (a later eviction or `:w` persists it) and shows in the palette at
/// once. The file is not written to disk until then — `:enew` alone allocates
/// no card IO.
pub(crate) fn new_file(&mut self, arg: &str) {
let arg = arg.trim();
if arg.is_empty() {
self.set_notice("usage: :enew <file>");
return;
}
let (path, scope) = resolve_path(arg, self.scope);
// Already open (active or parked) — treat `:enew` of an existing name as a
// switch rather than replacing its contents with an empty buffer.
if path == self.path || self.parked.iter().any(|b| b.path == path) {
self.open_path(path, scope);
return;
}
self.note_recent(&path);
self.add_to_file_list(&path);
self.park_active();
self.set_active(path.clone(), scope, String::new());
// A fresh file is unsaved: mark it dirty so eviction/`:w` persists it and
// it never silently vanishes (unlike an `:e` of a missing name).
self.dirty = true;
self.set_notice(format!("new {}", palette_label(&path)));
}
/// `:delete` — unlink the **current** file from the card and leave it. Queues
/// an [`Effect::Delete`] (the host does the removal + reports the outcome) and
/// updates the in-core model now: the path is dropped from the file list and
/// MRU, and the active buffer switches to the most-recently-parked buffer, or
/// an empty unnamed scratch if none is resident. An unnamed scratch buffer has
/// nothing on disk, so it is a no-op with a notice. Deleting an arbitrary
/// (non-current) file is deferred — this is the file you are looking at.
pub(crate) fn delete_current(&mut self) {
if self.path.is_empty() {
self.set_notice("no file to delete");
return;
}
let path = core::mem::take(&mut self.path);
let scope = self.scope;
self.requests.push(Effect::Delete { path: path.clone(), scope });
self.remove_from_file_list(&path);
self.recent.retain(|p| p != &path);
// The current buffer is being discarded, not parked: restore the most
// recently parked buffer if one is resident, else fall back to scratch.
match self.parked.pop() {
Some(b) => {
self.note_recent(&b.path);
self.activate(b);
}
None => self.set_active(String::new(), Scope::Tracked, String::new()),
}
}
/// The `i`-th file path in the palette's sorted base order (a slice into
/// [`file_blob`](Self::file_blob)).
pub(crate) fn file_at(&self, i: usize) -> &str {
let (s, e) = self.file_spans[i];
&self.file_blob[s as usize..e as usize]
}
/// How many files the palette knows about.
pub(crate) fn file_count(&self) -> usize {
self.file_spans.len()
}
/// Insert `path` into the palette's file list, keeping the spans sorted and
/// unique (matches [`set_file_list_joined`](Self::set_file_list_joined)'s
/// invariant). Used by `:enew` so a just-created file is findable without a
/// disk re-enumeration. Appends to the blob; a `String` realloc only moves
/// bytes, the spans are indices and stay valid.
pub(crate) fn add_to_file_list(&mut self, path: &str) {
match self
.file_spans
.binary_search_by(|&(s, e)| self.file_blob[s as usize..e as usize].cmp(path))
{
Ok(_) => {}
Err(i) => {
let start = self.file_blob.len() as u32;
self.file_blob.push_str(path);
self.file_spans.insert(i, (start, start + path.len() as u32));
}
}
}
/// Drop `path` from the palette's file list (used by `:delete`). Only the
/// span goes; its bytes stay in the blob as dead weight until the next
/// host re-walk replaces the whole thing — a few dozen bytes at most.
pub(crate) fn remove_from_file_list(&mut self, path: &str) {
let blob = &self.file_blob;
self.file_spans
.retain(|&(s, e)| &blob[s as usize..e as usize] != path);
}
// --- File palette (Ctrl-P) ---------------------------------------------
/// Feed the palette its file list as **one newline-joined blob** of
/// absolute paths, enumerated by the host from `/sd/repo` and `/sd/local`.
/// This is the device's entry point: a single large `String` lands in
/// PSRAM (allocations ≥ 16 KB cross the SPIRAM-malloc threshold), where
/// the same list as 1099 individual `String`s measured 182 KB of internal
/// DRAM. Spans are sorted + deduped for a stable base order; the MRU
/// floats recents above it. The palette is a pure view over this — nothing
/// is read from disk until a file is actually opened.
pub fn set_file_list_joined(&mut self, blob: String) {
let mut spans: Vec<(u32, u32)> = Vec::new();
let mut start = 0u32;
for line in blob.split('\n') {
let end = start + line.len() as u32;
if !line.is_empty() {
spans.push((start, end));
}
start = end + 1; // past the '\n'
}
spans.sort_by(|&(a, b), &(c, d)| blob[a as usize..b as usize].cmp(&blob[c as usize..d as usize]));
spans.dedup_by(|&mut (a, b), &mut (c, d)| blob[a as usize..b as usize] == blob[c as usize..d as usize]);
self.file_blob = blob;
self.file_spans = spans;
}
/// [`set_file_list_joined`](Self::set_file_list_joined) from a `Vec` —
/// convenience for hosts/tests that already hold separate strings.
pub fn set_file_list(&mut self, files: Vec<String>) {
self.set_file_list_joined(files.join("\n"));
}
/// Push `path` to the front of the recent-files MRU (dropping any earlier
/// occurrence), bounded to [`MRU_MAX`]. Drives the palette's empty-query
/// order, so the file you were just in sits at the top.
pub(crate) fn note_recent(&mut self, path: &str) {
self.recent.retain(|p| p != path);
self.recent.insert(0, path.to_string());
self.recent.truncate(MRU_MAX);
}
}

434
editor/src/editing.rs Normal file
View File

@@ -0,0 +1,434 @@
//! Text mutation: insert/delete/paste, registers, snippets expansion at the
//! caret, operators and text objects.
use super::*;
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange /
/// `y`ank).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Op {
Delete,
Change,
Yank,
}
impl Editor {
pub(crate) fn insert_char(&mut self, c: char) {
self.text.insert(self.caret, c);
self.caret += c.len_utf8();
}
pub(crate) fn insert_str(&mut self, s: &str) {
self.text.insert_str(self.caret, s);
self.caret += s.len();
}
// --- Snippets ----------------------------------------------------------
/// Expand a snippet `body` at the caret and enter its tab-stop session. Splits
/// the body into literal text (stops removed) and their [`parse_snippet_body`]
/// visit order, inserts the literal, lands the caret on `$1` (or the body end
/// if there are no stops), and enters Insert. The remaining stops queue in
/// [`snippet_stops`](Self::snippet_stops) for Tab. Shared by inline
/// Tab-expansion and the `$` palette. **The caller owns the undo boundary** —
/// it must [`checkpoint`](Self::checkpoint) *before* any related mutation
/// (the inline path deletes the trigger word first), so one undo group covers
/// the whole expansion; `insert_snippet` deliberately does not checkpoint.
pub(crate) fn insert_snippet(&mut self, body: &str) {
let (literal, stops) = parse_snippet_body(body);
let base = self.caret;
self.text.insert_str(base, &literal);
let mut abs = stops.into_iter().map(|o| base + o);
match abs.next() {
Some(first) => {
self.caret = first;
self.snippet_stops = abs.collect();
}
None => {
self.caret = base + literal.len();
self.snippet_stops.clear();
}
}
self.mode = Mode::Insert;
}
/// Tab inside a live session: jump the caret to the next pending stop. The
/// final stop (`$0` / the body end) empties the queue, ending the session with
/// the caret resting there. Offsets were kept current by the edit-shift in
/// [`insert_key`](Self::insert_key), so this is a plain move.
pub(crate) fn snippet_advance(&mut self) {
if self.snippet_stops.is_empty() {
return;
}
let next = self.snippet_stops.remove(0);
self.caret = next.min(self.text.len());
}
/// The maximal run of non-whitespace ending exactly at the caret — the
/// candidate inline snippet trigger — or `None` if the caret is at a line/word
/// start (the char before it is whitespace). Whitespace is ASCII, so scanning
/// bytes is UTF-8-safe: `start` lands just past an ASCII space/newline (a char
/// boundary) or at 0.
pub(crate) fn word_before_caret(&self) -> Option<(usize, &str)> {
let b = self.text.as_bytes();
if self.caret == 0 || b[self.caret - 1].is_ascii_whitespace() {
return None;
}
let mut start = self.caret;
while start > 0 && !b[start - 1].is_ascii_whitespace() {
start -= 1;
}
Some((start, &self.text[start..self.caret]))
}
/// Inline Tab-expansion: if the word immediately before the caret is exactly a
/// snippet prefix, replace it with the expansion (as one undo group) and start
/// the tab-stop session, returning `true`. Otherwise leave the buffer untouched
/// and return `false`, so Tab falls back to inserting spaces.
pub(crate) fn try_expand_snippet(&mut self) -> bool {
let Some((start, word)) = self.word_before_caret() else {
return false;
};
let Some(body) = self.snippets.iter().find(|s| s.prefix == word).map(|s| s.body.clone())
else {
return false;
};
self.checkpoint(); // baseline includes the trigger word — undo restores it
self.text.replace_range(start..self.caret, "");
self.caret = start;
self.insert_snippet(&body);
true
}
/// Enter in Insert mode, with Markdown list continuation. At the END of a
/// list line (`- `/`* `/`+ ` or `N. `), start the next item automatically —
/// same bullet, or the next number — preserving indentation. Enter on an
/// otherwise-empty item strips the marker instead (exits the list). Anywhere
/// else (mid-line, or a non-list line) it's a plain newline.
pub(crate) fn insert_newline(&mut self) {
let le = self.line_end(self.caret);
if self.caret == le {
let ls = self.line_start(self.caret);
if let Some((next, cur_len, content_empty)) = list_marker(&self.text[ls..le]) {
if content_empty {
// Empty item: drop the marker, leaving a blank line.
self.text.replace_range(ls..ls + cur_len, "");
self.caret = ls;
} else {
self.insert_str(&format!("\n{next}"));
}
return;
}
}
self.insert_char('\n');
}
pub(crate) fn backspace(&mut self) {
if self.caret > 0 {
self.caret = self.prev_char(self.caret);
self.text.remove(self.caret); // removes the whole char at the caret
}
}
/// `x` — delete the char under the caret (never a newline).
pub(crate) fn delete_at_caret(&mut self) {
let b = self.text.as_bytes();
if self.caret < b.len() && b[self.caret] != b'\n' {
self.text.remove(self.caret);
// Keep the caret on a char: if it fell off the line end, step back.
if self.caret >= self.line_end(self.caret) && self.caret > self.line_start(self.caret) {
self.caret = self.prev_char(self.caret);
}
}
}
/// `dd` — delete the current logical line, including its newline (or the
/// preceding one for the last line, so no blank line is left behind).
pub(crate) fn delete_current_line(&mut self) {
let ls = self.line_start(self.caret);
let le = self.line_end(self.caret);
let (start, end) = if le < self.text.len() {
(ls, le + 1) // eat the trailing newline
} else if ls > 0 {
(ls - 1, le) // last line: eat the preceding newline instead
} else {
(ls, le) // whole buffer
};
self.text.replace_range(start..end, "");
self.caret = self.line_start(start.min(self.text.len()));
}
/// `cc` — clear the current line's text and drop into insert.
pub(crate) fn change_current_line(&mut self) {
self.checkpoint();
let ls = self.line_start(self.caret);
let le = self.line_end(self.caret);
self.register = format!("{}\n", &self.text[ls..le]); // linewise, like dd
self.register_linewise = true;
self.text.replace_range(ls..le, "");
self.caret = ls;
self.mode = Mode::Insert;
}
/// Yank `n` logical lines from the caret's line into the register, linewise
/// (each line carries its trailing `\n`, synthesised for a final line that
/// lacks one). Backs both `yy`/`nyy` and `dd`/`ndd`'s register capture; does
/// not move the caret or change the buffer.
pub(crate) fn register_lines(&mut self, n: usize) {
let ls = self.line_start(self.caret);
let mut e = ls;
for _ in 0..n {
let le = self.line_end(e);
e = if le < self.text.len() { le + 1 } else { le };
}
let mut block = self.text[ls..e].to_string();
if !block.ends_with('\n') {
block.push('\n'); // the last line has no trailing newline; add one
}
self.register = block;
self.register_linewise = true;
}
/// `p` — paste the register `n` times after the caret. Linewise content
/// opens new line(s) below the current line (caret to the first pasted
/// line); charwise content goes in just after the caret char (caret on the
/// last pasted char). No-op on an empty register.
pub(crate) fn paste_after(&mut self, n: usize) {
if self.register.is_empty() {
return;
}
self.checkpoint();
let content = self.register.repeat(n);
// `end`: byte offset of the last pasted char, so the viewport can reveal
// the whole block even when the caret stays on its first line.
let end = if self.register_linewise {
let le = self.line_end(self.caret);
if le < self.text.len() {
let at = le + 1; // start of the following line
self.text.insert_str(at, &content);
self.caret = at;
at + content.len() - 1
} else {
// Last line has no trailing newline: prefix one, drop the
// block's trailing newline so we don't leave a blank line.
let block = content.strip_suffix('\n').unwrap_or(&content);
let inserted = format!("\n{block}");
let end = le + inserted.len() - 1;
self.text.insert_str(le, &inserted);
self.caret = le + 1;
end
}
} else {
let at = if self.text.is_empty() { 0 } else { self.next_char(self.caret) };
self.text.insert_str(at, &content);
self.caret = self.prev_char(at + content.len()); // onto the last char
self.caret
};
self.reveal(end);
}
/// `P` — paste the register `n` times before the caret. Linewise content
/// opens new line(s) above the current line; charwise content goes in at the
/// caret (caret on the last pasted char). No-op on an empty register.
pub(crate) fn paste_before(&mut self, n: usize) {
if self.register.is_empty() {
return;
}
self.checkpoint();
let content = self.register.repeat(n);
let end = if self.register_linewise {
let ls = self.line_start(self.caret);
self.text.insert_str(ls, &content);
self.caret = ls;
ls + content.len() - 1
} else {
let at = self.caret;
self.text.insert_str(at, &content);
self.caret = self.prev_char(at + content.len()); // onto the last char
self.caret
};
self.reveal(end);
}
/// Apply a pending operator over the buffer range `[start, end)` (order
/// independent). All three fill the unnamed register (charwise) with the
/// range. Yank copies and leaves the text; Delete removes it; Change removes
/// it and enters insert. Yank leaves the caret at the range start (vim `yw`),
/// the others land it there because the text collapses to that point.
pub(crate) fn apply_op(&mut self, op: Op, start: usize, end: usize) {
let s = start.min(end);
let e = start.max(end).min(self.text.len());
self.register = self.text[s..e].to_string();
self.register_linewise = false;
if op == Op::Yank {
self.caret = s;
return;
}
self.checkpoint(); // Delete/Change mutate — snapshot for undo
self.text.replace_range(s..e, "");
self.caret = s.min(self.text.len());
if op == Op::Change {
self.mode = Mode::Insert;
}
}
/// Resolve a text object to a buffer range. `around` selects `a` (include
/// delimiters / trailing space) vs `i` (inner). Returns `None` if there's
/// no matching object under the caret.
pub(crate) fn text_object(&self, obj: char, around: bool) -> Option<(usize, usize)> {
match obj {
'w' => Some(self.word_object(around)),
'(' | ')' | 'b' => self.pair_object(b'(', b')', around),
'{' | '}' | 'B' => self.pair_object(b'{', b'}', around),
'[' | ']' => self.pair_object(b'[', b']', around),
'<' | '>' => self.pair_object(b'<', b'>', around),
'"' => self.quote_object(b'"', around),
'\'' => self.quote_object(b'\'', around),
'`' => self.quote_object(b'`', around),
_ => None,
}
}
/// `iw`/`aw`: the run of same-class chars (word vs space, never crossing a
/// newline) under the caret. `aw` also takes the trailing run of spaces, or
/// the leading one if there is no trailing space. Word class is
/// whitespace-delimited (so this behaves like vim's `iW`/`aW`).
pub(crate) fn word_object(&self, around: bool) -> (usize, usize) {
let b = self.text.as_bytes();
let n = b.len();
if n == 0 {
return (0, 0);
}
let pos = self.caret.min(n - 1);
let ws = |c: u8| c == b' ' || c == b'\t';
let target_ws = ws(b[pos]);
let same = |c: u8| ws(c) == target_ws && c != b'\n';
let mut s = pos;
while s > 0 && same(b[s - 1]) {
s -= 1;
}
let mut e = pos + 1;
while e < n && same(b[e]) {
e += 1;
}
if around && !target_ws {
let mut a = e;
while a < n && ws(b[a]) {
a += 1;
}
if a > e {
return (s, a);
}
let mut ls = s;
while ls > 0 && ws(b[ls - 1]) {
ls -= 1;
}
return (ls, e);
}
(s, e)
}
/// `i(`/`a(` and friends: the range between the bracket pair enclosing the
/// caret, nesting-aware. `around` includes the brackets themselves.
pub(crate) fn pair_object(&self, open: u8, close: u8, around: bool) -> Option<(usize, usize)> {
let b = self.text.as_bytes();
let n = b.len();
if n == 0 {
return None;
}
let start = self.caret.min(n - 1);
// Scan left for the enclosing open bracket.
let mut depth = 0i32;
let mut i = start;
let open_idx = loop {
let ch = b[i];
if ch == close && i != start {
depth += 1;
} else if ch == open {
if depth == 0 {
break Some(i);
}
depth -= 1;
}
if i == 0 {
break None;
}
i -= 1;
}?;
// Scan right for its matching close.
let mut depth = 0i32;
let mut j = open_idx + 1;
let close_idx = loop {
if j >= n {
break None;
}
let ch = b[j];
if ch == open {
depth += 1;
} else if ch == close {
if depth == 0 {
break Some(j);
}
depth -= 1;
}
j += 1;
}?;
Some(if around {
(open_idx, close_idx + 1)
} else {
(open_idx + 1, close_idx)
})
}
/// `i"`/`a"` and friends: the range between a matching quote pair on the
/// current line. `around` includes the quotes.
pub(crate) fn quote_object(&self, q: u8, around: bool) -> Option<(usize, usize)> {
let b = self.text.as_bytes();
let ls = self.line_start(self.caret);
let le = self.line_end(self.caret);
let quotes: Vec<usize> = (ls..le).filter(|&i| b[i] == q).collect();
// Pair them left-to-right; take the first pair closing at/after the caret.
let mut k = 0;
while k + 1 < quotes.len() {
let (a, z) = (quotes[k], quotes[k + 1]);
if self.caret <= z {
return Some(if around { (a, z + 1) } else { (a + 1, z) });
}
k += 2;
}
None
}
/// Insert-mode Ctrl+W / Ctrl+Backspace: delete the word before the caret.
pub(crate) fn delete_word_before(&mut self) {
let b = self.text.as_bytes();
let mut i = self.caret;
while i > 0 && (b[i - 1] == b' ' || b[i - 1] == b'\t') {
i -= 1;
}
while i > 0 && !b[i - 1].is_ascii_whitespace() {
i -= 1;
}
self.text.replace_range(i..self.caret, "");
self.caret = i;
}
/// Insert-mode Cmd+Backspace: delete back to the start of the line, or the
/// preceding newline if already there.
pub(crate) fn delete_to_line_start(&mut self) {
let ls = self.line_start(self.caret);
if ls == self.caret {
if self.caret > 0 {
self.caret -= 1;
self.text.remove(self.caret);
}
} else {
self.text.replace_range(ls..self.caret, "");
self.caret = ls;
}
}
// --- Rendering ---------------------------------------------------------
}

121
editor/src/fuzzy.rs Normal file
View File

@@ -0,0 +1,121 @@
//! Match primitives: the palette's fuzzy scorer and the case/diacritic
//! folding helpers shared with `/` search.
/// Fuzzy-match `query` against `text` (the file palette's matcher). Returns a
/// relevance score if every `query` character appears in `text` in order
/// (a subsequence match, case-insensitive over ASCII), else `None`. Higher is
/// better; a higher score is a "tighter" match.
///
/// Scoring rewards two things prose filenames make meaningful: a match at a
/// **word boundary** (start of string, or just after `/ _ - . space`) scores far
/// above one mid-word, and a **run** of consecutive matches scores extra per
/// char. So typing `notes` ranks `repo/notes.md` above `promo-tests.md` even
/// though both contain the letters. There are no penalties, so a score is always
/// ≥ the query length; ties are broken by the caller (recency, then list order).
/// An empty query matches everything with score 0.
///
/// A **space** in the query matches any separator (`/ _ - . space`), so typing
/// `la conv` finds `la-convergence.md` — filenames use `-` where the typist
/// thinks "space".
pub(crate) fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
let q: Vec<char> = query.chars().collect();
if q.is_empty() {
return Some(0);
}
let mut qi = 0;
let mut score = 0i32;
let mut prev_matched = false;
let mut prev: Option<char> = None;
for (i, tc) in text.chars().enumerate() {
let hit = qi < q.len()
&& (tc.eq_ignore_ascii_case(&q[qi])
|| (q[qi] == ' ' && matches!(tc, '/' | '_' | '-' | '.' | ' ')));
if hit {
score += 1;
let boundary =
i == 0 || matches!(prev, Some('/') | Some('_') | Some('-') | Some('.') | Some(' '));
if boundary {
score += 10;
}
if prev_matched {
score += 5;
}
qi += 1;
prev_matched = true;
} else {
prev_matched = false;
}
prev = Some(tc);
}
(qi == q.len()).then_some(score)
}
/// Byte offset of the first folded match of `pat` in `hay`, or `None`.
/// Char-by-char comparison through [`fold`] at every char boundary — no
/// folded copy of the buffer (folding can change byte lengths, which would
/// break the returned offsets). `ci` is the smartcase verdict, computed once
/// per search from the pattern. O(n·m), fine at note sizes for an
/// Enter-triggered jump.
pub(crate) fn find_fold(hay: &str, pat: &str, ci: bool) -> Option<usize> {
hay.char_indices()
.map(|(i, _)| i)
.find(|&i| starts_with_fold(&hay[i..], pat, ci))
}
/// [`find_fold`], but the *last* match — the backward (`N`) direction.
pub(crate) fn rfind_fold(hay: &str, pat: &str, ci: bool) -> Option<usize> {
hay.char_indices()
.map(|(i, _)| i)
.rev()
.find(|&i| starts_with_fold(&hay[i..], pat, ci))
}
/// Whether `s` begins with `pat` under [`fold`].
pub(crate) fn starts_with_fold(s: &str, pat: &str, ci: bool) -> bool {
let mut sc = s.chars();
pat.chars()
.all(|p| sc.next().is_some_and(|c| fold(c, ci) == fold(p, ci)))
}
/// A char's search identity: diacritics are always stripped (`é` = `e`, so
/// `/ete` finds `été` and vice versa — accents are how the word is *spelled*,
/// not what you're *searching for*), and case is dropped only when `ci`
/// (the smartcase rule: an all-lowercase pattern searches insensitively; one
/// capital in it makes the search exact).
pub(crate) fn fold(c: char, ci: bool) -> char {
let c = if ci {
// First char of the lowercase expansion — 1:1 for all of Latin,
// which is what this appliance types.
c.to_lowercase().next().unwrap_or(c)
} else {
c
};
strip_diacritic(c)
}
/// Map accented Latin letters to their base letter, both cases (the Latin-1
/// Supplement set — the French/Western repertoire the keymap can produce).
/// Ligatures (`œ`, `æ`) fold to more than one char and are left alone.
pub(crate) fn strip_diacritic(c: char) -> char {
match c {
'à'..='å' => 'a',
'ç' => 'c',
'è'..='ë' => 'e',
'ì'..='ï' => 'i',
'ñ' => 'n',
'ò'..='ö' => 'o',
'ù'..='ü' => 'u',
'ý' | 'ÿ' => 'y',
'À'..='Å' => 'A',
'Ç' => 'C',
'È'..='Ë' => 'E',
'Ì'..='Ï' => 'I',
'Ñ' => 'N',
'Ò'..='Ö' => 'O',
'Ù'..='Ü' => 'U',
'Ý' => 'Y',
_ => c,
}
}

File diff suppressed because it is too large Load Diff

221
editor/src/markdown.rs Normal file
View File

@@ -0,0 +1,221 @@
//! Markdown formatting: `:fmt` reflow, list markers, and table alignment.
use super::*;
/// Parse a Markdown list marker at the start of `line`. Returns
/// `(next_marker, current_marker_len, content_empty)` where `next_marker` is what
/// the following item should start with (same bullet, or the incremented number,
/// preserving indentation), `current_marker_len` is the byte length of this
/// line's marker prefix, and `content_empty` is whether anything follows it.
/// Returns `None` when the line isn't a list item. ASCII throughout (leading
/// spaces, bullets, digits, `. ` are all single-byte).
pub(crate) fn list_marker(line: &str) -> Option<(String, usize, bool)> {
let indent = line.len() - line.trim_start_matches(' ').len();
let rest = &line[indent..];
for bullet in ["- ", "* ", "+ "] {
if rest.starts_with(bullet) {
let cur_len = indent + bullet.len();
let content_empty = line[cur_len..].trim().is_empty();
return Some((format!("{}{bullet}", &line[..indent]), cur_len, content_empty));
}
}
// Ordered: <digits>`. ` → continue as the next number.
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
if digits > 0 && rest[digits..].starts_with(". ") {
let cur_len = indent + digits + 2;
let content_empty = line[cur_len..].trim().is_empty();
let n: usize = rest[..digits].parse().unwrap_or(0);
return Some((format!("{}{}. ", &line[..indent], n + 1), cur_len, content_empty));
}
None
}
// --- `:fmt` Markdown normalizer ----------------------------------------------
/// Column alignment parsed from a table's `|:--:|` separator row.
#[derive(Clone, Copy)]
pub(crate) enum Align {
Left,
Right,
Center,
None,
}
/// Normalize a Markdown buffer for `:fmt`: strip trailing whitespace, align
/// pipe tables, and collapse runs of blank lines to a single blank (dropping
/// trailing blanks). Deliberately does NOT reflow paragraphs — the buffer's
/// logical line breaks are the writer's, and display wrapping is soft (see
/// `layout`). ASCII throughout (widths are char counts).
pub(crate) fn format_markdown(text: &str) -> String {
// 1. Trailing-whitespace strip, per line.
let stripped: Vec<String> = text.split('\n').map(|l| l.trim_end().to_string()).collect();
// 2. Reformat pipe-table blocks in place; pass everything else through.
let mut piped: Vec<String> = Vec::with_capacity(stripped.len());
let mut i = 0;
while i < stripped.len() {
if let Some(len) = table_block_len(&stripped[i..]) {
piped.extend(format_table(&stripped[i..i + len]));
i += len;
} else {
piped.push(stripped[i].clone());
i += 1;
}
}
// 3. Collapse 2+ consecutive blank lines to one. A trailing blank run
// collapses the same way, so at most one trailing blank line survives — and
// we deliberately keep that one rather than dropping it. A writer often
// presses Enter to open the next line before pausing; yanking that line
// (and the caret) out from under them on every format-on-save is jarring.
// The file's POSIX terminator is `save_path`'s job, not this pass's, so
// keeping the blank line here is purely about not disturbing the buffer.
let mut out: Vec<String> = Vec::with_capacity(piped.len());
let mut blank_run = 0;
for line in piped {
if line.is_empty() {
blank_run += 1;
if blank_run == 1 {
out.push(String::new());
}
} else {
blank_run = 0;
out.push(line);
}
}
out.join("\n")
}
/// Split a table row into trimmed cells, dropping the empty cells that leading /
/// trailing `|` produce (`| a | b |` → `["a", "b"]`).
pub(crate) fn table_cells(line: &str) -> Vec<String> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = t.strip_suffix('|').unwrap_or(t);
t.split('|').map(|c| c.trim().to_string()).collect()
}
/// A separator row: every cell is dashes with optional edge colons (`:--`, `-:`,
/// `:-:`, `---`) and at least one dash.
pub(crate) fn is_separator_row(line: &str) -> bool {
if !line.contains('|') {
return false;
}
let cells = table_cells(line);
!cells.is_empty()
&& cells.iter().all(|c| {
!c.is_empty() && c.contains('-') && c.chars().all(|ch| ch == '-' || ch == ':')
})
}
/// If `lines[0..]` starts a pipe table (header row + separator row + data rows),
/// return its length in lines; else `None`.
pub(crate) fn table_block_len(lines: &[String]) -> Option<usize> {
if lines.len() < 2 || !lines[0].contains('|') || !is_separator_row(&lines[1]) {
return None;
}
let mut n = 2;
while n < lines.len() && !lines[n].is_empty() && lines[n].contains('|') {
n += 1;
}
Some(n)
}
/// Reformat one detected table block: pad every cell to its column's width and
/// rebuild the separator row, honoring per-column alignment colons.
pub(crate) fn format_table(block: &[String]) -> Vec<String> {
let rows: Vec<Vec<String>> = block.iter().map(|l| table_cells(l)).collect();
let aligns: Vec<Align> = rows[1]
.iter()
.map(|c| match (c.starts_with(':'), c.ends_with(':')) {
(true, true) => Align::Center,
(true, false) => Align::Left,
(false, true) => Align::Right,
(false, false) => Align::None,
})
.collect();
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(aligns.len());
// Column widths from content rows (min 3 so the separator stays readable).
let mut width = vec![3usize; ncols];
for (ri, row) in rows.iter().enumerate() {
if ri == 1 {
continue; // the separator's own width doesn't constrain the column
}
for (ci, cell) in row.iter().enumerate() {
width[ci] = width[ci].max(cell.chars().count());
}
}
let align_of = |ci: usize| aligns.get(ci).copied().unwrap_or(Align::None);
let mut out = Vec::with_capacity(rows.len());
for (ri, row) in rows.iter().enumerate() {
let cells: Vec<String> = (0..ncols)
.map(|ci| {
let w = width[ci];
if ri == 1 {
match align_of(ci) {
Align::Left => format!(":{}", "-".repeat(w - 1)),
Align::Right => format!("{}:", "-".repeat(w - 1)),
Align::Center => format!(":{}:", "-".repeat(w - 2)),
Align::None => "-".repeat(w),
}
} else {
pad_cell(row.get(ci).map(String::as_str).unwrap_or(""), w, align_of(ci))
}
})
.collect();
out.push(format!("| {} |", cells.join(" | ")));
}
out
}
/// Pad `cell` to `w` columns per `align` (left/none pad right, right pads left,
/// center splits). Over-wide cells are returned unchanged.
pub(crate) fn pad_cell(cell: &str, w: usize, align: Align) -> String {
let len = cell.chars().count();
if len >= w {
return cell.to_string();
}
let pad = w - len;
match align {
Align::Right => format!("{}{cell}", " ".repeat(pad)),
Align::Center => {
let l = pad / 2;
format!("{}{cell}{}", " ".repeat(l), " ".repeat(pad - l))
}
_ => format!("{cell}{}", " ".repeat(pad)),
}
}
impl Editor {
/// `: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).
pub(crate) fn format_buffer(&mut self) {
self.checkpoint(); // `:fmt` (and format-on-save) is undoable
let row = self.text[..self.caret].bytes().filter(|&b| b == b'\n').count();
self.text = format_markdown(&self.text);
// Land the caret at the start of the same logical line, clamped.
let total = self.text.bytes().filter(|&b| b == b'\n').count() + 1;
let target = row.min(total - 1);
self.caret = if target == 0 {
0
} else {
let mut seen = 0;
let mut off = self.text.len();
for (i, b) in self.text.bytes().enumerate() {
if b == b'\n' {
seen += 1;
if seen == target {
off = i + 1;
break;
}
}
}
off
};
}
}

191
editor/src/motions.rs Normal file
View File

@@ -0,0 +1,191 @@
//! Caret motions: char/word/line stepping shared by Normal, Visual and View.
use super::*;
impl Editor {
/// Apply a plain caret motion shared by Normal and Visual — `h l j k`,
/// `w b e`, `0 $`, `G` — `n` times, returning whether `c` was a motion (and
/// so consumed). `gg`/`gr` are handled by their callers' pending-`g` state,
/// not here.
pub(crate) fn move_by(&mut self, c: char, n: usize) -> bool {
match c {
'h' => (0..n).for_each(|_| self.move_left()),
'l' => (0..n).for_each(|_| self.move_right()),
'j' => (0..n).for_each(|_| self.move_down()),
'k' => (0..n).for_each(|_| self.move_up()),
'w' => (0..n).for_each(|_| self.caret = self.word_forward_pos(self.caret)),
'b' => (0..n).for_each(|_| self.caret = self.word_back_pos(self.caret)),
'e' => (0..n).for_each(|_| self.caret = self.word_end_pos(self.caret)),
'0' => self.caret = self.line_start(self.caret),
'$' => self.caret = self.line_end(self.caret),
'G' => self.caret = self.line_start(self.text.len()),
// Repeat the last `/` search; a motion here so Visual extends over
// it for free. Deliberately not an operator target (`dn` is not in
// scope) — operators resolve their own motion table in `normal_key`.
'n' => self.search_repeat(n, true),
'N' => self.search_repeat(n, false),
_ => return false,
}
true
}
// --- Command mode (`:`) ------------------------------------------------
/// Offset of the start of the line containing `pos`.
pub(crate) fn line_start(&self, pos: usize) -> usize {
let b = self.text.as_bytes();
let mut i = pos;
while i > 0 && b[i - 1] != b'\n' {
i -= 1;
}
i
}
/// Offset of the end of the line containing `pos` (the `\n`, or buffer end).
pub(crate) fn line_end(&self, pos: usize) -> usize {
let b = self.text.as_bytes();
let mut i = pos;
while i < b.len() && b[i] != b'\n' {
i += 1;
}
i
}
/// Byte offset one character right of `i`, clamped to the buffer end. `i`
/// must be a char boundary (every caret position is one).
pub(crate) fn next_char(&self, i: usize) -> usize {
self.text[i..].chars().next().map_or(i, |c| i + c.len_utf8())
}
/// Byte offset one character left of `i`, clamped to 0.
pub(crate) fn prev_char(&self, i: usize) -> usize {
self.text[..i].chars().next_back().map_or(i, |c| i - c.len_utf8())
}
/// Byte offset `col` characters into the text starting at `start`, clamped
/// to `end` (so a shorter target line lands the caret at its end).
pub(crate) fn advance_chars(&self, start: usize, col: usize, end: usize) -> usize {
let mut pos = start;
for _ in 0..col {
if pos >= end {
break;
}
pos = self.next_char(pos);
}
pos.min(end)
}
pub(crate) fn move_left(&mut self) {
if self.caret > self.line_start(self.caret) {
self.caret = self.prev_char(self.caret);
}
}
pub(crate) fn move_right(&mut self) {
if self.caret < self.line_end(self.caret) {
self.caret = self.next_char(self.caret);
}
}
/// Like `l` but allowed to land one past the last char (for `a`).
pub(crate) fn move_right_append(&mut self) {
if self.caret < self.line_end(self.caret) {
self.caret = self.next_char(self.caret);
}
}
pub(crate) fn move_down(&mut self) {
let ls = self.line_start(self.caret);
let col = self.text[ls..self.caret].chars().count();
let le = self.line_end(self.caret);
if le >= self.text.len() {
return; // already on the last line
}
let next_start = le + 1;
let next_end = self.line_end(next_start);
self.caret = self.advance_chars(next_start, col, next_end);
}
pub(crate) fn move_up(&mut self) {
let ls = self.line_start(self.caret);
if ls == 0 {
return; // already on the first line
}
let col = self.text[ls..self.caret].chars().count();
let prev_start = self.line_start(ls - 1);
let prev_end = ls - 1; // the '\n' that ends the previous line
self.caret = self.advance_chars(prev_start, col, prev_end);
}
/// Move the caret by `delta` **display** (soft-wrapped) rows, keeping the
/// column where the target row is long enough. This is the `Ctrl-d`/`Ctrl-u`
/// step: unlike `j`/`k` (which move by *logical* line and so jump over
/// wrapped continuation rows), it walks the rendered layout, so half a page
/// is half the visible window no matter how the prose wraps. In Normal mode
/// the caret is always kept on-screen, so moving it *is* the scroll — the
/// viewport follows via `adjust_scroll` at draw time.
pub(crate) fn move_display_rows(&mut self, delta: isize) {
let lay = self.layout();
if lay.is_empty() {
return;
}
let (row, col) = self.caret_rc(&lay);
let target = (row as isize + delta).clamp(0, lay.len() as isize - 1) as usize;
let line = &lay[target];
let row_end = line.start + line.text.len();
self.caret = self.advance_chars(line.start, col, row_end);
}
/// Start of the next whitespace-delimited word after `from`.
pub(crate) fn word_forward_pos(&self, from: usize) -> usize {
let b = self.text.as_bytes();
let n = b.len();
let mut i = from;
while i < n && !b[i].is_ascii_whitespace() {
i += 1;
}
while i < n && b[i].is_ascii_whitespace() {
i += 1;
}
i
}
/// Start of the word at or before `from`.
pub(crate) fn word_back_pos(&self, from: usize) -> usize {
let b = self.text.as_bytes();
let mut i = from;
while i > 0 && b[i - 1].is_ascii_whitespace() {
i -= 1;
}
while i > 0 && !b[i - 1].is_ascii_whitespace() {
i -= 1;
}
i
}
/// Byte offset of the last character of the current/next word — vim `e`
/// lands the caret on that char. Skips any leading whitespace, then runs to
/// the word's end; whitespace includes `\n`, so it can cross lines.
pub(crate) fn word_end_pos(&self, from: usize) -> usize {
let start = self.next_char(from);
if start >= self.text.len() {
return from;
}
let mut last = from;
let mut in_word = false;
for (off, c) in self.text[start..].char_indices() {
if c.is_ascii_whitespace() {
if in_word {
break;
}
} else {
in_word = true;
last = start + off;
}
}
last
}
// --- Edits -------------------------------------------------------------
}

506
editor/src/palette.rs Normal file
View File

@@ -0,0 +1,506 @@
//! The Cmd-P palette: file switching, the `>` command registry, and the `$`
//! snippet picker.
use super::*;
/// The palette's display label for an absolute path: `/sd/` stripped, so
/// `/sd/repo/notes.md` shows as `repo/notes.md` and `/sd/local/journal.md` as
/// `local/journal.md`. The scope dir (`repo`/`local`) stays, which both
/// disambiguates same-named files across scopes and reads as a scope tag. A path
/// not under `/sd/` is shown verbatim. Matching (`fuzzy_score`) runs on this
/// label, so you can filter by scope (`local`) or subpath, not just basename.
pub(crate) fn palette_label(path: &str) -> &str {
path.strip_prefix("/sd/").unwrap_or(path)
}
/// A `>` palette command — a real action registry, not a settings box (v0.6).
/// Three dispatch shapes, distinguished by [`PaletteCmd::kind`]:
/// - a **[one-shot](CmdKind::OneShot)** ([`Format`](PaletteCmd::Format),
/// [`Publish`](PaletteCmd::Publish)) runs and closes the palette;
/// - a **[parameterised](CmdKind::Param)** command ([`NewFile`](PaletteCmd::NewFile))
/// morphs the palette into a filename input step;
/// - a **[toggle](CmdKind::Toggle)** — the boolean prefs and the
/// [`Theme`](PaletteCmd::Theme)/[`AutoSync`](PaletteCmd::AutoSync) rotations —
/// applies live and keeps the list open, so several settings flip in a row. Each
/// toggle's *label* carries the pref's current state ([`Editor::command_label`]),
/// so the list still doubles as a settings readout. `auto_sync` has no behaviour
/// yet (v0.7); cycling it only changes the stored/displayed value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PaletteCmd {
NewFile,
Format,
Publish,
SaveOnIdle,
FormatOnSave,
LineNumbers,
OpenLastOnBoot,
Theme,
AutoSync,
}
/// How a [`PaletteCmd`] behaves on Enter — see [`PaletteCmd::kind`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CmdKind {
/// Applies live and keeps the palette open (the pref toggles/rotations).
Toggle,
/// Runs once and closes the palette (`format`, `publish`).
OneShot,
/// Opens a second input step in the palette (`new file`).
Param,
}
impl PaletteCmd {
/// The command's dispatch shape, which decides what Enter does in
/// [`Editor::palette_run_command`].
fn kind(self) -> CmdKind {
match self {
PaletteCmd::NewFile => CmdKind::Param,
PaletteCmd::Format | PaletteCmd::Publish => CmdKind::OneShot,
_ => CmdKind::Toggle,
}
}
}
/// The palette command list, in display order (empty `>` query shows them all):
/// the actions first, the settings after.
pub(crate) const PALETTE_CMDS: [PaletteCmd; 9] = [
PaletteCmd::NewFile,
PaletteCmd::Format,
PaletteCmd::Publish,
PaletteCmd::SaveOnIdle,
PaletteCmd::FormatOnSave,
PaletteCmd::LineNumbers,
PaletteCmd::OpenLastOnBoot,
PaletteCmd::Theme,
PaletteCmd::AutoSync,
];
/// Which step the palette is showing. Most of its life it is a
/// [`List`](PaletteStep::List) — files, `>` commands, or `$` snippets, chosen by
/// the query's leading sigil. Selecting a [parameterised](CmdKind::Param) `>`
/// command switches it to an input step ([`NewFile`](PaletteStep::NewFile)), where
/// the query is a value (a filename) rather than a filter, and Enter commits it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PaletteStep {
List,
NewFile,
}
/// Query length (chars) at which the file palette searches the full file list.
/// Shorter queries show only the recents ([`MRU_MAX`]) — the list is a
/// recursive walk of the card, and one char can't rank hundreds of paths
/// usefully. `>` commands and `$` snippets are short curated lists, so the
/// threshold does not apply to them.
pub(crate) const PALETTE_MIN_QUERY: usize = 2;
impl Editor {
/// `Ctrl-P` — open the file palette: empty query (full list, recents first),
/// selection on the first row.
pub(crate) fn open_palette(&mut self) {
self.mode = Mode::Palette;
self.palette_query.clear();
self.palette_sel = 0;
self.palette_step = PaletteStep::List;
}
/// `:settings` — open the palette straight into `>` command mode (the
/// settings list), so the prefs are reachable in one command instead of
/// `Cmd-P` then `>`. Same surface, same stay-open toggle behaviour.
pub(crate) fn open_settings(&mut self) {
self.mode = Mode::Palette;
self.palette_query = ">".to_string();
self.palette_sel = 0;
self.palette_step = PaletteStep::List;
}
/// Leave the palette back to Normal, clearing its query, selection, and step.
pub(crate) fn close_palette(&mut self) {
self.mode = Mode::Normal;
self.palette_query.clear();
self.palette_sel = 0;
self.palette_step = PaletteStep::List;
}
/// Dispatch a key in [`Mode::Palette`]. In the `New file` input step the keys
/// build a filename ([`new_file_step_key`](Self::new_file_step_key)); otherwise
/// typing fuzzy-filters, `Ctrl-n`/`Ctrl-p` (or `Ctrl-d`/`Ctrl-u`) move the
/// selection, and Enter acts on it per the leading sigil (open a file, run a
/// `>` command, or insert a `$` snippet). Esc or `Cmd-P` closes; Backspace on
/// an empty query also closes (mirrors the `:` line). Any query edit resets the
/// selection to the top.
pub(crate) fn palette_key(&mut self, key: Key) {
if self.palette_step == PaletteStep::NewFile {
return self.new_file_step_key(key);
}
match key {
Key::Char(c) => {
self.palette_query.push(c);
self.palette_sel = 0;
}
Key::Backspace => {
if self.palette_query.pop().is_none() {
self.close_palette();
} else {
self.palette_sel = 0;
}
}
// Readline Ctrl-W: drop trailing spaces then the last word.
Key::DeleteWord => {
while self.palette_query.ends_with(' ') {
self.palette_query.pop();
}
while !self.palette_query.is_empty() && !self.palette_query.ends_with(' ') {
self.palette_query.pop();
}
self.palette_sel = 0;
}
Key::DeleteLine => {
self.palette_query.clear();
self.palette_sel = 0;
}
// Ctrl-n/Ctrl-p move the selection (fzf-style); Ctrl-d/Ctrl-u do too.
// Wraps around the current result list (files, `>` commands, `$` snippets).
Key::Down | Key::HalfPageDown => {
let n = self.palette_len();
if n > 0 {
self.palette_sel = (self.palette_sel + 1) % n;
}
}
Key::Up | Key::HalfPageUp => {
let n = self.palette_len();
if n > 0 {
self.palette_sel = self.palette_sel.checked_sub(1).unwrap_or(n - 1);
}
}
// Enter acts on the selection by mode: insert a `$` snippet, run a `>`
// command, or open the selected file.
Key::Enter => {
if self.palette_snippet_mode() {
self.palette_insert_selected();
} else if self.palette_command_mode() {
self.palette_run_command();
} else {
self.palette_open_selected();
}
}
// Esc, or Cmd-P again, closes the palette.
Key::Escape | Key::Palette => self.close_palette(),
Key::Redo => {}
}
}
/// Keys in the `> new file` input step: the query is a filename, not a filter.
/// Enter creates it (scope resolved from a `repo/`/`local/` prefix, exactly as
/// `:enew` did) and closes; an empty name is a no-op that stays in the step.
/// Backspacing past the start steps **back** to the `>` command list rather
/// than closing, so the step is escapable without losing the palette. Esc or
/// `Cmd-P` closes outright.
pub(crate) fn new_file_step_key(&mut self, key: Key) {
match key {
Key::Char(c) => self.palette_query.push(c),
Key::Backspace => {
if self.palette_query.pop().is_none() {
// Nothing left to erase — return to the command list.
self.palette_step = PaletteStep::List;
self.palette_query = ">".to_string();
self.palette_sel = 0;
}
}
Key::DeleteWord => {
while self.palette_query.ends_with(' ') {
self.palette_query.pop();
}
while !self.palette_query.is_empty() && !self.palette_query.ends_with(' ') {
self.palette_query.pop();
}
}
Key::DeleteLine => self.palette_query.clear(),
Key::Enter => {
let name = self.palette_query.trim().to_string();
if name.is_empty() {
return; // nothing typed yet — stay in the step
}
self.close_palette();
self.new_file(&name);
}
Key::Escape | Key::Palette => self.close_palette(),
// No list to move over in this step.
Key::Up | Key::Down | Key::HalfPageUp | Key::HalfPageDown | Key::Redo => {}
}
}
/// Open the palette's selected file (Enter). A no-op on an empty result set.
/// Closes the palette first, then routes through [`open_path`](Self::open_path)
/// exactly like `:e`, so the switch/park/evict/MRU path is shared.
pub(crate) fn palette_open_selected(&mut self) {
let idx = self.palette_matches().get(self.palette_sel).copied();
self.close_palette();
let Some(idx) = idx else { return };
let (path, scope) = resolve_path(self.file_at(idx), self.scope);
self.open_path(path, scope);
}
/// The palette's filtered, ranked result as indices into [`files`](Self::files).
/// Base order is MRU-first (recents in use order, then the rest as sorted). A
/// non-empty query keeps only fuzzy matches and stable-sorts them by score, so
/// equal scores keep their MRU/base position. See [`fuzzy_score`].
///
/// Below [`PALETTE_MIN_QUERY`] chars the candidate set is the recents only:
/// the file list is a recursive walk of the whole card, too long to page
/// through unranked, but the MRU keeps quick-switch (`Cmd-P`, `Enter`) one
/// keystroke away. Two typed chars reveal the full list.
pub(crate) fn palette_matches(&self) -> Vec<usize> {
let mut order: Vec<usize> = Vec::with_capacity(self.file_count());
for r in &self.recent {
if let Some(i) = (0..self.file_count()).find(|&i| self.file_at(i) == r) {
order.push(i);
}
}
if self.palette_query.chars().count() >= PALETTE_MIN_QUERY {
for i in 0..self.file_count() {
if !order.contains(&i) {
order.push(i);
}
}
}
if self.palette_query.is_empty() {
return order;
}
let mut scored: Vec<(usize, i32)> = order
.into_iter()
.filter_map(|i| {
fuzzy_score(&self.palette_query, palette_label(self.file_at(i))).map(|s| (i, s))
})
.collect();
// Stable sort by descending score — ties keep their MRU/base position.
scored.sort_by_key(|&(_, s)| core::cmp::Reverse(s));
scored.into_iter().map(|(i, _)| i).collect()
}
// --- Palette command mode (`>`) ----------------------------------------
/// Whether the palette is in `>` command mode. VS Code semantics: a leading
/// `>` in the query switches the file search to the command list. The `>` is
/// part of [`palette_query`](Self::palette_query), so backspacing it off
/// returns to file mode with no extra state.
pub(crate) fn palette_command_mode(&self) -> bool {
self.palette_query.starts_with('>')
}
/// The command filter: everything after the leading `>`, trimmed. `>` alone
/// (or with only spaces) is an empty filter, which matches every command.
pub(crate) fn command_filter(&self) -> &str {
self.palette_query.strip_prefix('>').unwrap_or("").trim()
}
/// A command's display label. An action's label is a plain verb (with a
/// trailing `...` on the parameterised `new file`, VS-Code-style, to flag the
/// second step — ASCII dots, since Latin-9 has no `…` glyph); a toggle's label
/// carries its pref's current state, so the list reads as a live settings panel
/// and the effect is legible before and after. This is also the text
/// [`fuzzy_score`] matches against.
pub(crate) fn command_label(&self, cmd: PaletteCmd) -> String {
let on = |b| if b { "on" } else { "off" };
match cmd {
PaletteCmd::NewFile => "new file...".to_string(),
PaletteCmd::Format => "format".to_string(),
PaletteCmd::Publish => "publish".to_string(),
PaletteCmd::SaveOnIdle => format!("save on idle: {}", on(self.prefs.save_on_idle)),
PaletteCmd::FormatOnSave => format!("format on save: {}", on(self.prefs.format_on_save)),
PaletteCmd::LineNumbers => format!("line numbers: {}", on(self.prefs.line_numbers)),
PaletteCmd::OpenLastOnBoot => {
format!("open last on boot: {}", on(self.prefs.open_last_on_boot))
}
PaletteCmd::Theme => format!("theme: {}", self.prefs.theme),
PaletteCmd::AutoSync => format!("auto sync: {}", self.prefs.auto_sync),
}
}
/// Filtered, ranked command indices into [`PALETTE_CMDS`]. An empty filter
/// keeps registry order; a non-empty one fuzzy-ranks by label, same matcher
/// and stable-sort as the file list.
pub(crate) fn palette_command_matches(&self) -> Vec<usize> {
let filter = self.command_filter();
let mut scored: Vec<(usize, i32)> = PALETTE_CMDS
.iter()
.enumerate()
.filter_map(|(i, &cmd)| fuzzy_score(filter, &self.command_label(cmd)).map(|s| (i, s)))
.collect();
scored.sort_by_key(|&(_, s)| core::cmp::Reverse(s));
scored.into_iter().map(|(i, _)| i).collect()
}
/// Enter in `>` command mode, dispatched by the selected command's
/// [`kind`](PaletteCmd::kind):
/// - a **[toggle](CmdKind::Toggle)** flips its pref and the palette **stays
/// open** (flip several in a row; the label updates in place);
/// - a **[one-shot](CmdKind::OneShot)** (`format`/`publish`) runs and **closes**
/// — an action switches you back to writing, a toggle does not;
/// - a **[parameterised](CmdKind::Param)** command (`new file`) opens the
/// filename input step ([`begin_new_file_step`](Self::begin_new_file_step)).
///
/// A no-op on an empty result set (nothing selected), staying open so the
/// query can be fixed.
pub(crate) fn palette_run_command(&mut self) {
let Some(&ci) = self.palette_command_matches().get(self.palette_sel) else {
return;
};
let cmd = PALETTE_CMDS[ci];
match cmd.kind() {
CmdKind::Toggle => self.cycle_pref(cmd),
CmdKind::OneShot => {
self.close_palette();
match cmd {
PaletteCmd::Format => {
self.format_buffer();
self.set_notice("formatted");
}
PaletteCmd::Publish => self.run_publish(),
_ => {}
}
}
CmdKind::Param => self.begin_new_file_step(),
}
}
/// Switch the open palette into its `new file` filename input step: the list
/// gives way to a prompt, and the next Enter creates the typed file. Reached
/// only from [`palette_run_command`](Self::palette_run_command), so the palette
/// is already open.
pub(crate) fn begin_new_file_step(&mut self) {
self.palette_step = PaletteStep::NewFile;
self.palette_query.clear();
self.palette_sel = 0;
}
/// The publish path shared by `:gp` and the `>` `publish` command: format on
/// save (if enabled), queue the buffer save, then the git push — the host
/// services them in order. Tracked-only: a Local buffer never reaches the
/// remote, so it is a no-op with a notice.
pub(crate) fn run_publish(&mut self) {
if self.scope == Scope::Local {
self.set_notice("Publish unavailable (Local)");
return;
}
if self.prefs.format_on_save {
self.format_buffer();
}
self.request_save_active();
self.requests.push(Effect::Publish);
}
/// Advance the pref a command targets to its next value, apply it live (the
/// next [`draw`](Self::draw) reflects it — line numbers appear/vanish, the
/// theme flips at once), queue the prefs-file write ([`Effect::SavePrefs`]),
/// and confirm the new state on the snackbar. A boolean flips; a preset
/// string ([`Theme`](PaletteCmd::Theme), [`AutoSync`](PaletteCmd::AutoSync))
/// rotates to its next option and wraps — so from the palette every setting
/// is "press Enter to change". The queued `SavePrefs` is what makes the
/// change durable and lets it ride the next `:gp` to other devices.
pub(crate) fn cycle_pref(&mut self, cmd: PaletteCmd) {
match cmd {
PaletteCmd::SaveOnIdle => self.prefs.save_on_idle = !self.prefs.save_on_idle,
PaletteCmd::FormatOnSave => self.prefs.format_on_save = !self.prefs.format_on_save,
PaletteCmd::LineNumbers => self.prefs.line_numbers = !self.prefs.line_numbers,
PaletteCmd::OpenLastOnBoot => {
self.prefs.open_last_on_boot = !self.prefs.open_last_on_boot
}
PaletteCmd::Theme => {
self.prefs.theme = next_option(&self.prefs.theme, &THEME_OPTIONS).to_string()
}
PaletteCmd::AutoSync => {
self.prefs.auto_sync = next_option(&self.prefs.auto_sync, &AUTO_SYNC_OPTIONS).to_string()
}
// Actions, not prefs: palette_run_command routes them away, so we never
// arrive here. Return before the SavePrefs/notice below rather than
// panicking the firmware on a would-be routing bug.
PaletteCmd::NewFile | PaletteCmd::Format | PaletteCmd::Publish => {
debug_assert!(false, "cycle_pref called with a non-toggle command");
return;
}
}
self.requests.push(Effect::SavePrefs {
contents: self.prefs.to_toml(),
});
// The label already reflects the just-changed state (e.g. "theme: dark").
self.set_notice(format!("{} - saved", self.command_label(cmd)));
}
// --- Palette snippet mode (`$`) ----------------------------------------
/// Whether the palette is in `$` snippet mode. Same sigil mechanism as `>`: a
/// leading `$` in the query switches the file search to the snippet launcher,
/// and backspacing it off returns to file mode with no extra state. `$` and `>`
/// are mutually exclusive (a query starts with at most one).
pub(crate) fn palette_snippet_mode(&self) -> bool {
self.palette_query.starts_with('$')
}
/// The snippet filter: everything after the leading `$`, trimmed. `$` alone is
/// an empty filter, which lists every snippet.
pub(crate) fn snippet_filter(&self) -> &str {
self.palette_query.strip_prefix('$').unwrap_or("").trim()
}
/// The text a snippet is fuzzy-matched against: name, prefix, and description
/// together, so you find a snippet by whichever you remember. Matching runs on
/// this joined haystack (see [`fuzzy_score`]); [`snippet_label`] is the shorter
/// string actually drawn.
pub(crate) fn snippet_haystack(s: &Snippet) -> String {
format!("{} {} {}", s.name, s.prefix, s.description)
}
/// A snippet's palette row: the display name with its inline trigger in
/// brackets (`Markdown link [link]`), so browsing also teaches the prefix you'd
/// type for the fast inline path. Truncated to the column width by the caller.
pub(crate) fn snippet_label(s: &Snippet) -> String {
format!("{} [{}]", s.name, s.prefix)
}
/// Filtered, ranked snippet indices into [`snippets`](Self::snippets). An empty
/// filter keeps the parsed order (sorted by name); a non-empty one fuzzy-ranks
/// over [`snippet_haystack`], same matcher and stable-sort as the file list.
pub(crate) fn palette_snippet_matches(&self) -> Vec<usize> {
let filter = self.snippet_filter();
let mut scored: Vec<(usize, i32)> = self
.snippets
.iter()
.enumerate()
.filter_map(|(i, s)| fuzzy_score(filter, &Self::snippet_haystack(s)).map(|score| (i, score)))
.collect();
scored.sort_by_key(|&(_, s)| core::cmp::Reverse(s));
scored.into_iter().map(|(i, _)| i).collect()
}
/// Enter in `$` snippet mode: insert the selected snippet at the caret and start
/// its tab-stop session. Unlike a `>` toggle (which stays open), this **closes**
/// the palette — inserting content returns you to the buffer, in Insert on `$1`.
/// Checkpoints so the whole insertion is one undo group. A no-op on an empty
/// result set (nothing selected), which stays open so the query can be fixed.
pub(crate) fn palette_insert_selected(&mut self) {
let Some(&i) = self.palette_snippet_matches().get(self.palette_sel) else {
return;
};
let body = self.snippets[i].body.clone();
self.close_palette();
self.checkpoint(); // baseline is the buffer before insertion — undo removes it whole
self.insert_snippet(&body);
}
/// Row count of the palette's current result list, whichever sigil is active —
/// the single source the selection clamps against.
pub(crate) fn palette_len(&self) -> usize {
if self.palette_snippet_mode() {
self.palette_snippet_matches().len()
} else if self.palette_command_mode() {
self.palette_command_matches().len()
} else {
self.palette_matches().len()
}
}
// --- Visual mode -------------------------------------------------------
}

161
editor/src/prefs.rs Normal file
View File

@@ -0,0 +1,161 @@
//! `.typoena.toml` preferences: parsing, serialisation, and option cycling.
/// The git-tracked preferences file. Read at boot and rewritten when a palette
/// `>` command changes a pref, so the setting survives a reboot and rides the
/// next `:gp` to every device that clones the repo. Deliberately **distinct**
/// from the gitignored `/sd/typoena.conf` device secrets (Wi-Fi / PAT / remote /
/// author, never committed — see v0.1): behaviour is shared, secrets are not.
pub const PREFS_PATH: &str = "/sd/repo/.typoena.toml";
/// Editor preferences, mirroring the git-tracked [`PREFS_PATH`] TOML. The host
/// reads the file at boot and applies it with [`Editor::set_prefs`]; the palette
/// `>` command mode toggles a pref live and queues an [`Effect::SavePrefs`] to
/// write the change back. Every key falls back to the [`Default`] below, so a
/// missing, empty, or partial file still yields a full, usable `Prefs`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Prefs {
/// Auto-save the active buffer on the idle typing-pause, so `:w` becomes
/// optional. The idle save is **unformatted** — a safety net against power
/// loss, not a formatting pass; `:fmt` only runs on an explicit `:w`/`:gp`
/// (see [`format_on_save`](Prefs::format_on_save)) so text is never reflowed
/// mid-session. Honoured by the host loop, not the core.
pub save_on_idle: bool,
/// Run `:fmt` (table alignment, blank-line collapse, trailing-whitespace
/// strip) on the buffer before an explicit `:w`/`:gp` persist.
pub format_on_save: bool,
/// Show the absolute line-number gutter (built always-on in v0.2). Off
/// reclaims the gutter's columns for text — applied live by [`gutter_cols`].
pub line_numbers: bool,
/// Boot into the file that was active when the device powered off, instead
/// of the default note. Only the *choice* lives here; the last-active path
/// itself is device state, kept in a device-local marker beside the dirty
/// journal — never in this git-tracked file, where every buffer switch
/// would dirty the repo and devices would fight over one "last file".
/// Honoured by the host at boot (falling back to the default note when the
/// marker is missing or stale), not the core.
pub open_last_on_boot: bool,
/// Panel colour polarity: `"light"` (native black-ink-on-white-paper) or
/// `"dark"` (white-on-black). On the 1-bit panel this is a whole-frame invert
/// applied at the end of [`draw`](Editor::draw) via [`Frame::invert`], so any
/// value other than `"dark"` reads as light. The palette rotates it through
/// [`THEME_OPTIONS`]; a hand-typed value still round-trips.
pub theme: String,
/// Max-staleness cap for opportunistic auto-publish, as a duration string.
/// The palette rotates it through [`AUTO_SYNC_OPTIONS`] (`"2m"`..`"30m"`);
/// hand-editing can still set any string. **Persisted-but-inert in v0.5** —
/// the periodic push that reads it rides v0.7/v0.8, so cycling it changes the
/// stored/displayed value but triggers nothing yet.
pub auto_sync: String,
}
impl Default for Prefs {
fn default() -> Self {
Self {
save_on_idle: true,
format_on_save: true,
line_numbers: true,
open_last_on_boot: true,
theme: "light".into(),
auto_sync: "10m".into(),
}
}
}
impl Prefs {
/// Parse a [`PREFS_PATH`] file, falling back to [`Default`] for any missing or
/// unrecognized key (so a partial or empty file still yields a full `Prefs`).
/// A deliberately tiny line-based reader: these are flat `key = value` pairs
/// (bool, or a quoted string) with `#` comments — not a general TOML parser,
/// so it pulls no crate onto the xtensa build and stays host-testable here. An
/// unparseable value for a key leaves that key at its default.
pub fn parse(src: &str) -> Self {
let mut p = Self::default();
for line in src.lines() {
// Strip a trailing/whole-line `#` comment, then split `key = value`.
let line = line.split('#').next().unwrap_or("").trim();
let Some((key, val)) = line.split_once('=') else {
continue;
};
let (key, val) = (key.trim(), val.trim());
match key {
"save_on_idle" => {
if let Some(b) = parse_bool(val) {
p.save_on_idle = b;
}
}
"format_on_save" => {
if let Some(b) = parse_bool(val) {
p.format_on_save = b;
}
}
"line_numbers" => {
if let Some(b) = parse_bool(val) {
p.line_numbers = b;
}
}
"open_last_on_boot" => {
if let Some(b) = parse_bool(val) {
p.open_last_on_boot = b;
}
}
"theme" => p.theme = val.trim_matches('"').to_string(),
"auto_sync" => p.auto_sync = val.trim_matches('"').to_string(),
_ => {}
}
}
p
}
/// Serialize back to the [`PREFS_PATH`] form, with a header comment pointing at
/// both edit paths. Round-trips with [`parse`](Prefs::parse). Ends in a newline
/// like a normal text file; `save_path`'s guarded final-newline write leaves it
/// as exactly one.
pub fn to_toml(&self) -> String {
format!(
"# Typoena editor preferences — hand-editable, git-tracked.\n\
# Edit here, or change live from the Cmd-P palette (type `>`).\n\
save_on_idle = {}\n\
format_on_save = {}\n\
line_numbers = {}\n\
open_last_on_boot = {}\n\
theme = \"{}\"\n\
auto_sync = \"{}\"\n",
self.save_on_idle,
self.format_on_save,
self.line_numbers,
self.open_last_on_boot,
self.theme,
self.auto_sync,
)
}
}
/// Parse a TOML boolean literal, or `None` for anything else (so a typo leaves
/// the key at its default rather than silently reading as `false`).
pub(crate) fn parse_bool(v: &str) -> Option<bool> {
match v {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
/// The panel-polarity presets the palette rotates [`Prefs::theme`] through.
pub(crate) const THEME_OPTIONS: [&str; 2] = ["light", "dark"];
/// The auto-publish intervals the palette rotates [`Prefs::auto_sync`] through.
/// Hand-editing the TOML can still set any duration string; these are just the
/// values the `>` palette cycles.
pub(crate) const AUTO_SYNC_OPTIONS: [&str; 5] = ["2m", "5m", "10m", "15m", "30m"];
/// The option after `current` in `options`, wrapping past the end — the
/// rotate-on-Enter for a preset string pref. A `current` that isn't in the list
/// (e.g. hand-typed into the TOML) snaps to the first option, so one Enter
/// always lands on a known value.
pub(crate) fn next_option<'a>(current: &str, options: &[&'a str]) -> &'a str {
match options.iter().position(|&o| o == current) {
Some(i) => options[(i + 1) % options.len()],
None => options[0],
}
}

769
editor/src/render.rs Normal file
View File

@@ -0,0 +1,769 @@
//! Rendering: the e-paper grid geometry, display-line layout, and all
//! `draw_*` painting onto the [`Frame`].
use super::*;
/// FONT_10X20 cell size (writing column) and the grid it tiles into.
pub const CW: i32 = 10;
pub const CH: i32 = 20;
/// Writing-region width, in characters: the left region holding the
/// line-number gutter **and** the text column (the gutter steals from it — see
/// [`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 ~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.
pub(crate) 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.
pub(crate) const GUTTER_MIN_DIGITS: usize = 2;
/// 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.
pub(crate) 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
/// resizable `'scroll'` is meaningless on a fixed 13-row panel.
pub(crate) const HALF_PAGE: usize = ROWS / 2; // 6
/// x of the 1 px rule dividing writing column from side panel, and the left edge
/// of panel text (a small gutter past the rule).
pub(crate) const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 630
pub(crate) const PANEL_X: i32 = DIVIDER_X + 8; // 638
/// 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.
pub(crate) const PANEL_CW: i32 = 9;
pub(crate) 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.
pub(crate) 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.
pub(crate) 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.
pub(crate) const TAB: &str = " ";
/// 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.
pub(crate) 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
}
/// One wrapped display line: its text and the buffer offset of its first char.
pub(crate) struct Line {
pub(crate) start: usize,
pub(crate) text: String,
}
impl Editor {
/// Number of logical lines in the buffer (1 + newline count). Used to size
/// the line-number gutter.
pub(crate) fn logical_lines(&self) -> usize {
self.text.bytes().filter(|&b| b == b'\n').count() + 1
}
/// Width of the absolute line-number gutter, in display columns: enough
/// digits for the buffer's largest line number (min [`GUTTER_MIN_DIGITS`])
/// plus a 1-column separator before the text. Sized from the *total* line
/// count, not the visible range, so it stays fixed while scrolling — only
/// crossing a power of ten (100, 1000, …) reflows the wrap, which is rare.
pub(crate) fn gutter_cols(&self) -> usize {
if !self.prefs.line_numbers {
return 0; // gutter off: text reclaims the full writing width
}
let digits = self.logical_lines().to_string().len().max(GUTTER_MIN_DIGITS);
digits + 1
}
/// Character columns left for text once the gutter is reserved. The writing
/// region is fixed at [`WRITE_COLS`]; the gutter steals from it, so text
/// soft-wraps narrower.
pub(crate) fn text_cols(&self) -> usize {
WRITE_COLS - self.gutter_cols()
}
/// Wrap the buffer into display lines, tracking each line's buffer offset.
/// Soft-wrap at word boundaries: a logical line too long for [`text_cols`]
/// (the writing width left after the line-number gutter) breaks at the last
/// space that fits, so words are never split — except a single word wider
/// than the column, hard-broken at [`text_cols`].
/// Wrapping counts characters (one per display cell), while `Line.start` is
/// a byte offset into the buffer, so caret math stays correct for multi-byte
/// (accented) characters.
pub(crate) fn layout(&self) -> Vec<Line> {
let cols = self.text_cols(); // writing width after the gutter is reserved
let mut lines: Vec<Line> = Vec::new();
let mut base = 0usize; // byte offset of the current logical line's start
for logical in self.text.split('\n') {
let chars: Vec<char> = logical.chars().collect();
if chars.is_empty() {
lines.push(Line { start: base, text: String::new() });
} else {
let mut c = 0usize; // char index within `logical`
let mut byte = 0usize; // byte offset of chars[c] within `logical`
while c < chars.len() {
let remaining = chars.len() - c;
let take = if remaining <= cols {
remaining
} else {
// Break at the last space within the COLS-wide window;
// include that space on this line. No space → hard break.
let window = c + cols;
let mut brk = None;
let mut p = window;
while p > c {
p -= 1;
if chars[p] == ' ' {
brk = Some(p);
break;
}
}
match brk {
Some(sp) if sp > c => sp + 1 - c,
_ => cols,
}
};
lines.push(Line {
start: base + byte,
text: chars[c..c + take].iter().collect(),
});
byte += chars[c..c + take].iter().map(|ch| ch.len_utf8()).sum::<usize>();
c += take;
}
}
base += logical.len() + 1; // bytes + the '\n' that `split` consumed
}
lines
}
/// Display (row, col) of the caret within `lay`. `col` is a character count
/// (display cells) from the row's start, not a byte offset, so it is correct
/// for multi-byte characters and indexes `Line.text` via `chars().nth`.
pub(crate) fn caret_rc(&self, lay: &[Line]) -> (usize, usize) {
let mut row = 0;
for (i, l) in lay.iter().enumerate() {
if l.start <= self.caret {
row = i;
} else {
break;
}
}
let col = self.text[lay[row].start..self.caret].chars().count();
(row, col)
}
/// Scroll so the display row holding byte offset `pos` is visible,
/// bottom-aligning it when it sits below the viewport (never scrolls up).
/// Called after an insert that can run past the fold — chiefly a multi-line
/// paste — so the *whole* pasted block is revealed, not just the caret's
/// first line. The caret is left where the edit put it; `draw`'s
/// `adjust_scroll` won't override this as long as the caret stays within the
/// resulting window (true for any block up to a screen tall).
pub(crate) fn reveal(&mut self, pos: usize) {
let lay = self.layout();
if lay.is_empty() {
return;
}
let pos = pos.min(self.text.len());
let mut row = 0;
for (i, l) in lay.iter().enumerate() {
if l.start <= pos {
row = i;
} else {
break;
}
}
if row >= self.scroll_top + ROWS {
self.scroll_top = row + 1 - ROWS;
}
}
/// Move the viewport so the caret stays visible (Normal/Insert), or just
/// clamp it to the content (View).
pub(crate) fn adjust_scroll(&mut self, caret_row: usize, total: usize) {
match self.mode {
Mode::View => {
let max = total.saturating_sub(ROWS);
if self.scroll_top > max {
self.scroll_top = max;
}
}
_ => {
if caret_row < self.scroll_top {
self.scroll_top = caret_row;
} else if caret_row >= self.scroll_top + ROWS {
self.scroll_top = caret_row + 1 - ROWS;
}
}
}
}
/// Is the logical line starting at `ls` a Markdown ATX heading — 16 `#`
/// followed by a space? (Used to render heading lines bold.)
pub(crate) fn is_heading_at(&self, ls: usize) -> bool {
let b = self.text.as_bytes();
let mut i = ls;
while i < b.len() && b[i] == b'#' {
i += 1;
}
let hashes = i - ls;
(1..=6).contains(&hashes) && b.get(i) == Some(&b' ')
}
/// Paint the non-Latin-9 glyphs (`→ ≠ Σ … — ' ' " " •`) that the base font
/// can't draw, overlaying them onto the cells `embedded-graphics` just filled
/// with fallback boxes. `line` is a laid-out display line; `x0` is its
/// x-origin (`gx`, or `gx + 1` for the heading double-strike). Only the
/// half-open display-column range `[col_start, col_end)` is painted, so the
/// visual-selection pass can invert just the selected span. `ink = On` draws
/// black-on-white; `ink = Off` draws white-on-black for reverse-video cells.
/// Because every extra glyph is one cell wide (like the font), cell x is
/// `x0 + col * CW` — the same grid the layout and caret math already use.
pub(crate) fn overlay_extras(
f: &mut Frame,
line: &str,
x0: i32,
y: i32,
col_start: usize,
col_end: usize,
ink: BinaryColor,
) {
for (col, ch) in line.chars().enumerate() {
if col < col_start || col >= col_end {
continue;
}
if let Some(g) = extra_glyph(ch) {
blit_glyph(f, x0 + col as i32 * CW, y, g, ink);
}
}
}
/// 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 mut f = Frame::empty();
self.draw_into(&mut f, cursor_on);
f
}
/// [`draw`](Self::draw) into a caller-owned frame, reusing its allocation.
/// Firmware keeps two boot-time frames and round-trips them through here so
/// a repaint never allocates: the editor must stay drawable even when a
/// background `:gp` push has taken the heap to the floor — a failed
/// framebuffer alloc aborts the whole app (the 2026-07-13 OOM).
pub fn draw_into(&mut self, out: &mut Frame, cursor_on: bool) {
let lay = self.layout();
let (crow, ccol) = self.caret_rc(&lay);
self.adjust_scroll(crow, lay.len());
// Take the caller's buffer (no copy, no alloc), render into it as an
// owned frame — the body predates this signature — and hand it back.
let mut f = std::mem::replace(out, Frame::empty());
f.clear_white();
let text_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
let gutter = self.gutter_cols();
let cols = WRITE_COLS - gutter; // text columns after the gutter
let gx = gutter as i32 * CW; // text (and cursor) x-origin, past the gutter
// Number field width (the last gutter col is the separator). Saturating so
// a disabled gutter (`gutter == 0`, line_numbers off) can't underflow; the
// number draw below is skipped in that case anyway.
let digits = gutter.saturating_sub(1);
let end = (self.scroll_top + ROWS).min(lay.len());
// Absolute line number of the first visible row's logical line, then
// bumped as later logical lines scroll into view.
let mut line_no = self.text.as_bytes()[..lay[self.scroll_top.min(lay.len() - 1)].start]
.iter()
.filter(|&&b| b == b'\n')
.count()
+ 1;
for (vis, li) in (self.scroll_top..end).enumerate() {
let y = vis as i32 * CH;
// Number a logical line only on its first display row; wrapped
// continuation rows leave the gutter blank.
let first_row = lay[li].start == self.line_start(lay[li].start);
if li > self.scroll_top && first_row {
line_no += 1;
}
if gutter > 0 && first_row {
let label = format!("{line_no:>digits$}");
Text::with_baseline(&label, Point::new(0, y), text_style, Baseline::Top)
.draw(&mut f)
.unwrap();
}
Text::with_baseline(&lay[li].text, Point::new(gx, y), text_style, Baseline::Top)
.draw(&mut f)
.unwrap();
// Markdown heading (`#`..`######` + space): faux-bold by double-
// striking the whole display line 1px to the right (no bold Latin-9
// font exists). Checks the logical line so wrapped headings stay bold.
let heading = self.is_heading_at(self.line_start(lay[li].start));
if heading {
Text::with_baseline(&lay[li].text, Point::new(gx + 1, y), text_style, Baseline::Top)
.draw(&mut f)
.unwrap();
}
// Repaint any non-Latin-9 glyphs over the fallback boxes the font
// left. Double-struck at gx+1 too on headings, to stay faux-bold.
Self::overlay_extras(&mut f, &lay[li].text, gx, y, 0, usize::MAX, BinaryColor::On);
if heading {
Self::overlay_extras(&mut f, &lay[li].text, gx + 1, y, 0, usize::MAX, BinaryColor::On);
}
}
// Visual selection: reverse-video the selected cells (black fill, glyphs
// redrawn white). A second pass so the text loop above stays untouched;
// on a 1-bit panel this inversion is the only selection affordance.
if self.in_visual() {
let (ss, se, lw) = self.visual_span();
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
for (vis, li) in (self.scroll_top..end).enumerate() {
let y = vis as i32 * CH;
let rs = lay[li].start;
let re = rs + lay[li].text.len();
let (col_a, col_b) = if rs.max(ss) < re.min(se) {
let a = rs.max(ss);
let b = re.min(se);
(self.text[rs..a].chars().count(), self.text[rs..b].chars().count())
} else if lw && lay[li].text.is_empty() && rs >= ss && rs <= se {
// A blank line inside a linewise selection: a 1-cell mark so
// the empty row still reads as selected.
(0, 1)
} else {
continue;
};
let x = gx + col_a as i32 * CW;
let w = (col_b - col_a).max(1) as u32 * CW as u32;
Rectangle::new(Point::new(x, y), Size::new(w, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(&mut f)
.unwrap();
let seg: String = lay[li].text.chars().skip(col_a).take(col_b - col_a).collect();
if !seg.is_empty() {
Text::with_baseline(&seg, Point::new(x, y), inv, Baseline::Top)
.draw(&mut f)
.unwrap();
}
// Any extra glyphs in the selected span: repaint white-on-black.
Self::overlay_extras(&mut f, &lay[li].text, gx, y, col_a, col_b, BinaryColor::Off);
}
}
if crow >= self.scroll_top && crow < self.scroll_top + ROWS {
let x = gx + ccol.min(cols - 1) as i32 * CW;
let y = (crow - self.scroll_top) as i32 * CH;
match self.mode {
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))
.draw(&mut f)
.unwrap();
if let Some(ch) = lay[crow].text.chars().nth(ccol) {
if let Some(g) = extra_glyph(ch) {
blit_glyph(&mut f, x, y, g, BinaryColor::Off);
} else {
let mut buf = [0u8; 4];
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
Text::with_baseline(
ch.encode_utf8(&mut buf),
Point::new(x, y),
inv,
Baseline::Top,
)
.draw(&mut f)
.unwrap();
}
}
}
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))
.draw(&mut f)
.unwrap();
}
Mode::Visual | Mode::VisualLine if cursor_on => {
// The selection painted this cell inverted; punch the caret
// back to normal video (white cell, black glyph) so the
// active end stands out from the rest of the selection.
Rectangle::new(Point::new(x, y), Size::new(CW as u32, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
.draw(&mut f)
.unwrap();
if let Some(ch) = lay[crow].text.chars().nth(ccol) {
if let Some(g) = extra_glyph(ch) {
blit_glyph(&mut f, x, y, g, BinaryColor::On);
} else {
let mut buf = [0u8; 4];
Text::with_baseline(
ch.encode_utf8(&mut buf),
Point::new(x, y),
text_style,
Baseline::Top,
)
.draw(&mut f)
.unwrap();
}
}
}
_ => {}
}
}
self.draw_panel(&mut f);
self.draw_cmdline(&mut f);
// The file palette is a modal transient panel: it paints over the whole
// writing column (the side panel stays, showing the PALETTE mode). Drawn
// last so it covers the buffer text/caret rendered above.
if self.mode == Mode::Palette {
self.draw_palette(&mut f);
}
// Dark theme: flip the whole frame in one pass, after everything else is
// painted, so text, selection, caret, panel and palette all invert
// together. Any value but "dark" stays native black-on-white.
if self.prefs.theme == "dark" {
f.invert();
}
*out = f;
}
/// Draw the side panel: a full-height rule, word count at the top, and the
/// mode indicator + pending-command echo at the bottom-left, with a
/// keyboard-disconnect flag just above the mode while the keyboard is
/// dropped. Small 6×10 font. This is the surface every later field
/// (filename, clock, Wi-Fi, publish state) will add to. Word count is a
/// throttled snapshot and the rest is event-driven, so the panel never
/// repaints per keystroke.
pub(crate) fn draw_panel(&self, f: &mut Frame) {
// The rule dividing writing column from panel, full panel height.
Rectangle::new(Point::new(DIVIDER_X, 0), Size::new(1, HEIGHT as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(f)
.unwrap();
let style = MonoTextStyle::new(&FONT_9X15, BinaryColor::On);
// Word count, from the throttled snapshot (never per keystroke).
let words = format!("{} words", self.shown_words);
Text::with_baseline(&words, Point::new(PANEL_X, 2), style, Baseline::Top)
.draw(f)
.unwrap();
// Transient notice ("snackbar"), just under the word count: the last
// 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 {
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();
}
}
// Just above the mode line: the keyboard-disconnect flag, or — while
// typing a recognised prefix — a quiet snippet hint. Mutually exclusive:
// the hint means you're typing, which needs the keyboard. Latin-9 has no
// ⌨/✗ or ↹ glyph, so the flag is plain text and the hint leads with `»`.
if !self.keyboard_present {
Text::with_baseline(
"NO KBD",
Point::new(PANEL_X, HEIGHT as i32 - 2 * PANEL_CH),
style,
Baseline::Top,
)
.draw(f)
.unwrap();
} else if let Some(name) = &self.snippet_hint {
let hint: String = format!("» {name}").chars().take(PANEL_COLS).collect();
Text::with_baseline(
&hint,
Point::new(PANEL_X, HEIGHT as i32 - 2 * PANEL_CH),
style,
Baseline::Top,
)
.draw(f)
.unwrap();
}
// Mode indicator + pending count/operator echo at the panel's bottom-
// left. In Command mode the ':' line (bottom strip) takes over instead.
// All event-driven — never repaints per keystroke.
if self.mode != Mode::Command {
let name = match self.mode {
Mode::Normal => "NORMAL",
Mode::Insert => "INSERT",
Mode::Visual => "VISUAL",
Mode::VisualLine => "V-LINE",
Mode::View => "VIEW",
Mode::Palette => "PALETTE",
Mode::Command => unreachable!(),
};
let mut s = format!("-- {name} --");
if self.count > 0 {
s.push_str(&format!(" {}", self.count));
}
match self.pending_op {
Some(Op::Delete) => s.push('d'),
Some(Op::Change) => s.push('c'),
Some(Op::Yank) => s.push('y'),
None => {}
}
match self.pending_obj {
Some(false) => s.push('i'),
Some(true) => s.push('a'),
None => {}
}
if self.pending_g {
s.push('g');
}
Text::with_baseline(
&s,
Point::new(PANEL_X, HEIGHT as i32 - PANEL_CH),
style,
Baseline::Top,
)
.draw(f)
.unwrap();
}
}
/// 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).
pub(crate) 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.cmd_prompt, self.cmdline);
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();
}
/// Draw the file palette (the modal transient panel, [`Mode::Palette`]) over
/// the writing column — the side panel stays put. Top row: a `> query`
/// prompt with a block caret. Then a rule, the fuzzy-ranked file list (the
/// selected row in reverse video), and a key hint on the bottom row. The list
/// scrolls to keep the selection visible. Body font (FONT_10X20) throughout.
/// The whole column repaints, so the host renders this as one full-area
/// partial — the Spike 11 transient-panel refresh worth eyeballing for
/// e-ink ghosting.
pub(crate) fn draw_palette(&self, f: &mut Frame) {
// Cover the writing column with white (left of the divider only).
Rectangle::new(Point::new(0, 0), Size::new(DIVIDER_X as u32, HEIGHT as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
.draw(f)
.unwrap();
let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
// The `> new file` input step: a labelled filename prompt with a block
// caret and a create hint — no list. The query is the filename being
// typed; an empty one shows a placeholder naming the scope-prefix rule.
if self.palette_step == PaletteStep::NewFile {
Text::with_baseline("New file:", Point::new(2, 0), style, Baseline::Top)
.draw(f)
.unwrap();
let y = CH + 3;
if self.palette_query.is_empty() {
Text::with_baseline(
"name (repo/ or local/ prefix)",
Point::new(2 + CW, y),
style,
Baseline::Top,
)
.draw(f)
.unwrap();
} else {
Text::with_baseline(&self.palette_query, Point::new(2, y), style, Baseline::Top)
.draw(f)
.unwrap();
}
let cx = (2 + self.palette_query.chars().count() as i32 * CW).min(DIVIDER_X - 2);
Rectangle::new(Point::new(cx, y), Size::new(2, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(f)
.unwrap();
Text::with_baseline(
"Enter create Esc cancel",
Point::new(2, HEIGHT as i32 - CH),
style,
Baseline::Top,
)
.draw(f)
.unwrap();
return;
}
// The query on the top row with a block caret at the end. A bare input is
// "go to file" (VS Code Cmd-P); a leading `>` switches to the command list
// (`command_mode`). An empty query is just the caret over a `Go to file`
// placeholder that clears on the first keystroke — type `>` for commands.
let command_mode = self.palette_command_mode();
let snippet_mode = self.palette_snippet_mode();
if self.palette_query.is_empty() {
Text::with_baseline(
"Go to file · > settings · $ snippets",
Point::new(2 + CW, 0),
style,
Baseline::Top,
)
.draw(f)
.unwrap();
} else {
Text::with_baseline(&self.palette_query, Point::new(2, 0), style, Baseline::Top)
.draw(f)
.unwrap();
}
let cx = (2 + self.palette_query.chars().count() as i32 * CW).min(DIVIDER_X - 2);
Rectangle::new(Point::new(cx, 0), Size::new(2, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(f)
.unwrap();
// Rule under the prompt.
Rectangle::new(Point::new(0, CH), Size::new(DIVIDER_X as u32, 1))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(f)
.unwrap();
// The list is the fuzzy-ranked files, the `>` command registry, or the `$`
// snippet library, per the active sigil.
let matches = if snippet_mode {
self.palette_snippet_matches()
} else if command_mode {
self.palette_command_matches()
} else {
self.palette_matches()
};
let max_chars = WRITE_COLS - 1; // leave a right margin
let list_top = CH + 3;
let hint_y = HEIGHT as i32 - CH; // bottom row holds the key hint
let visible = ((hint_y - list_top) / CH).max(1) as usize;
if matches.is_empty() {
let msg = if snippet_mode {
if self.snippets.is_empty() {
"(no snippets)"
} else {
"(no match)"
}
} else if command_mode {
"(no command)"
} else if self.file_spans.is_empty() {
"(no files on card)"
} else if self.palette_query.chars().count() < PALETTE_MIN_QUERY {
// No recents yet and the query is below the search threshold —
// the full list needs 2+ chars.
"(type to search)"
} else {
"(no match)"
};
Text::with_baseline(msg, Point::new(2, list_top), style, Baseline::Top)
.draw(f)
.unwrap();
} else {
let sel = self.palette_sel.min(matches.len() - 1);
// Scroll the window so the selection stays visible.
let start = if sel >= visible { sel - visible + 1 } else { 0 };
for (row, &idx) in matches.iter().enumerate().skip(start).take(visible) {
let y = list_top + (row - start) as i32 * CH;
let label: String = if snippet_mode {
Self::snippet_label(&self.snippets[idx]).chars().take(max_chars).collect()
} else if command_mode {
self.command_label(PALETTE_CMDS[idx])
} else {
palette_label(self.file_at(idx)).chars().take(max_chars).collect()
};
if row == sel {
// Reverse video: black fill across the column, white glyphs.
Rectangle::new(Point::new(0, y), Size::new(DIVIDER_X as u32, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(f)
.unwrap();
Text::with_baseline(&label, Point::new(2, y), inv, Baseline::Top)
.draw(f)
.unwrap();
} else {
Text::with_baseline(&label, Point::new(2, y), style, Baseline::Top)
.draw(f)
.unwrap();
}
}
}
let hint = if snippet_mode {
"^N/^P move Enter insert Esc close"
} else if command_mode {
"^N/^P move Enter change Esc close"
} else {
"^N/^P move Enter open Esc close"
};
Text::with_baseline(hint, Point::new(2, hint_y), style, Baseline::Top)
.draw(f)
.unwrap();
}
}

66
editor/src/search.rs Normal file
View File

@@ -0,0 +1,66 @@
//! `/` search execution and `n`/`N` repeats (smartcase + accent folding).
use super::*;
impl Editor {
/// Run the typed `/` search: remember the pattern (a bare `/`+Enter repeats
/// the last one, like vim) and jump forward once. Literal substring — no
/// regex on a writing appliance — matched **smartcase** (an all-lowercase
/// pattern is case-insensitive; one capital makes it exact, vim-style) and
/// always **accent-folded** (`/ete` finds `été`; see [`fold`]).
pub(crate) fn execute_search(&mut self) {
if !self.cmdline.is_empty() {
self.last_search = self.cmdline.clone();
}
self.search_repeat(1, true);
}
/// Jump `n` matches of [`last_search`](Self::last_search) forward
/// (`fwd`, the `/`-Enter and `n` step) or backward (`N`), wrapping around
/// the buffer with a "wrapped" notice. A missing pattern or an absent
/// match posts a notice and leaves the caret alone.
pub(crate) fn search_repeat(&mut self, n: usize, fwd: bool) {
if self.last_search.is_empty() {
self.set_notice("no previous search");
return;
}
let pat = self.last_search.clone();
// Smartcase: any capital in the pattern makes the search exact
// (`n`/`N` recompute from the remembered pattern, so a repeat behaves
// like the original search).
let ci = !pat.chars().any(char::is_uppercase);
for _ in 0..n {
// Start strictly past (or before) the caret so a caret already on a
// match moves to the next one, per vim.
let hit = if fwd {
let start = if self.caret >= self.text.len() {
self.text.len()
} else {
self.next_char(self.caret)
};
match find_fold(&self.text[start..], &pat, ci) {
Some(i) => Some((start + i, false)),
None => find_fold(&self.text, &pat, ci).map(|i| (i, true)),
}
} else {
match rfind_fold(&self.text[..self.caret], &pat, ci) {
Some(i) => Some((i, false)),
None => rfind_fold(&self.text, &pat, ci).map(|i| (i, true)),
}
};
match hit {
Some((pos, wrapped)) => {
self.caret = pos;
if wrapped {
self.set_notice("wrapped");
}
}
None => {
self.set_notice(format!("not found: {pat}"));
return; // absent now, absent on every repeat
}
}
}
}
}

161
editor/src/snippets.rs Normal file
View File

@@ -0,0 +1,161 @@
//! `.typoena.snippets.json`: snippet parsing and tab-stop extraction.
/// The git-tracked snippet library, read at boot like [`PREFS_PATH`]. The host
/// reads this file and hands the parsed list to [`Editor::set_snippets`]; a
/// missing or malformed file is non-fatal (no snippets, editor runs). It lives in
/// the Tracked repo so the library syncs across devices. Full format reference:
/// `docs/typoena-snippets.md`.
pub const SNIPPETS_PATH: &str = "/sd/repo/.typoena.snippets.json";
/// One snippet: an inline trigger [`prefix`](Snippet::prefix), a
/// [`body`](Snippet::body) carrying `$1..$n`/`$0` tab stops, and a
/// [`name`](Snippet::name)/[`description`](Snippet::description) the `$` palette
/// shows. Parsed from the Zed-compatible JSON by [`Snippets::parse`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snippet {
/// Display name — the top-level JSON key. What the `$` palette lists.
pub name: String,
/// The word that triggers inline Tab-expansion.
pub prefix: String,
/// Literal body text with `$1..$n`/`$0` stops. `${n:label}` placeholders are
/// stripped to bare `$n` at parse time (no completion popup to show a label,
/// no overtype model to fill it) — see [`strip_stop_labels`].
pub body: String,
/// Human description; the `$` palette fuzzy-matches and shows it. Empty if the
/// JSON entry omits it.
pub description: String,
}
/// A parsed snippet library. [`parse`](Snippets::parse) reads the Zed JSON shape;
/// [`Editor::set_snippets`] installs it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Snippets(pub Vec<Snippet>);
/// The Zed JSON value for one snippet: `body` is a string or an array of lines.
#[derive(serde::Deserialize)]
pub(crate) struct RawSnippet {
prefix: String,
body: RawBody,
#[serde(default)]
description: String,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
pub(crate) enum RawBody {
/// `"body": ["line", "line"]` — Zed's multi-line form (joined with `\n`).
Lines(Vec<String>),
/// `"body": "one line"`.
Text(String),
}
impl Snippets {
/// Parse the Zed-compatible `.typoena.snippets.json`: an object keyed by
/// display name, each value `{ prefix, body, description? }` where `body` is a
/// string or an array of lines. Labels in `${n:label}` stops are stripped to
/// `$n`. Entries come back sorted by name (a `BTreeMap` parse — deterministic,
/// and the empty-`$` palette order; a query re-ranks by fuzzy score anyway). An
/// empty (or whitespace-only) file means "no snippets", same as a missing one —
/// only a malformed file is an `Err` the host logs before booting with none.
pub fn parse(src: &str) -> Result<Self, serde_json::Error> {
if src.trim().is_empty() {
return Ok(Self::default());
}
let raw: std::collections::BTreeMap<String, RawSnippet> = serde_json::from_str(src)?;
let snippets = raw
.into_iter()
.map(|(name, r)| {
let body = match r.body {
RawBody::Text(s) => s,
RawBody::Lines(v) => v.join("\n"),
};
Snippet {
name,
prefix: r.prefix,
body: strip_stop_labels(&body),
description: r.description,
}
})
.collect();
Ok(Self(snippets))
}
}
/// Rewrite `${n:label}` (and `${n}`) tab stops to a bare `$n`, leaving plain
/// `$n`/`$0` and every other `$` untouched. The editor has no completion popup to
/// surface a label and no selection/overtype model to fill one, so the label is
/// only noise to delete — dropping it is what lets a Zed snippet file with
/// `${1:Titre}` load unchanged. Byte-indexed but UTF-8-safe: it only ever indexes
/// the ASCII `$ { } :` and digits; any multi-byte char is copied whole.
pub(crate) fn strip_stop_labels(body: &str) -> String {
let b = body.as_bytes();
let mut out = String::with_capacity(body.len());
let mut i = 0;
while i < body.len() {
if b[i] == b'$' && i + 1 < body.len() && b[i + 1] == b'{' {
// Read the digits right after "${".
let mut j = i + 2;
while j < body.len() && b[j].is_ascii_digit() {
j += 1;
}
// A numbered placeholder `${<digits>…}` → `$<digits>`, dropping the rest.
if j > i + 2 {
if let Some(close) = body[j..].find('}') {
out.push('$');
out.push_str(&body[i + 2..j]);
i = j + close + 1;
continue;
}
}
// Not a numbered placeholder (or unclosed) — emit the `$` literally.
out.push('$');
i += 1;
continue;
}
let c = body[i..].chars().next().unwrap();
out.push(c);
i += c.len_utf8();
}
out
}
/// Split a snippet body into its literal text (tab-stop markers removed) and the
/// caret **visit order** of those stops as byte offsets into that literal: `$1 …
/// $n` ascending, then `$0` last. If the body has numbered stops but no explicit
/// `$0`, a final stop at the body end is appended (so the last Tab lands past the
/// text). A body with no stops returns an empty stop list (the caret just lands at
/// the end — no session). `$` not followed by a digit is literal.
pub(crate) fn parse_snippet_body(body: &str) -> (String, Vec<usize>) {
let b = body.as_bytes();
let mut literal = String::with_capacity(body.len());
let mut stops: Vec<(u32, usize)> = Vec::new(); // (stop number, offset in `literal`)
let mut i = 0;
while i < body.len() {
if b[i] == b'$' {
let mut j = i + 1;
while j < body.len() && b[j].is_ascii_digit() {
j += 1;
}
if j > i + 1 {
let num: u32 = body[i + 1..j].parse().unwrap_or(0);
stops.push((num, literal.len()));
i = j;
continue;
}
literal.push('$'); // a lone `$` (e.g. a price) is literal text
i += 1;
continue;
}
let c = body[i..].chars().next().unwrap();
literal.push(c);
i += c.len_utf8();
}
// Ensure a final resting stop: `$0` if present, else an implicit one at the end.
if !stops.is_empty() && !stops.iter().any(|&(n, _)| n == 0) {
stops.push((0, literal.len()));
}
// Visit order: $1..$n ascending, $0 (the final rest) last.
stops.sort_by_key(|&(n, _)| if n == 0 { u32::MAX } else { n });
(literal, stops.into_iter().map(|(_, off)| off).collect())
}

2682
editor/src/tests.rs Normal file

File diff suppressed because it is too large Load Diff

66
editor/src/undo.rs Normal file
View File

@@ -0,0 +1,66 @@
//! Snapshot undo/redo, bounded to [`UNDO_DEPTH`] checkpoints.
use super::*;
/// 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
/// between saves anyway.
pub(crate) const UNDO_DEPTH: usize = 100;
impl Editor {
/// Record the current `(text, caret)` as an undo baseline, at the *start* of
/// a change-group, and drop the redo history (a new edit forks the timeline).
/// Called once per change: on entering Insert (the whole session undoes
/// together), and before each Normal-mode edit (`x`, `dd`, operators, paste,
/// `: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.
pub(crate) 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
}
self.undo.push((self.text.clone(), self.caret));
if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0); // drop the oldest group
}
self.redo.clear();
}
/// `u` — restore the most recent undo baseline, pushing the current state to
/// the redo stack. Lands in Normal mode with the caret clamped onto a char
/// boundary. No-op with nothing to undo.
pub(crate) fn undo(&mut self) {
if let Some((text, caret)) = self.undo.pop() {
self.redo.push((self.text.clone(), self.caret));
self.restore(text, caret);
}
}
/// `Ctrl-r` — reapply the most recently undone state. No-op with nothing to
/// redo.
pub(crate) fn redo(&mut self) {
if let Some((text, caret)) = self.redo.pop() {
self.undo.push((self.text.clone(), self.caret));
self.restore(text, caret);
}
}
/// Swap in a snapshot's buffer + caret, landing in Normal on a char boundary.
pub(crate) fn restore(&mut self, text: String, caret: usize) {
self.text = text;
self.caret = caret.min(self.text.len());
while self.caret > 0 && !self.text.is_char_boundary(self.caret) {
self.caret -= 1;
}
self.mode = Mode::Normal;
self.reset_pending();
}
// --- Buffers (multi-file) ----------------------------------------------
}

194
editor/src/visual.rs Normal file
View File

@@ -0,0 +1,194 @@
//! Visual mode: charwise/linewise selection with y/d/c over the span.
use super::*;
impl Editor {
/// True while a Visual selection is active (charwise or linewise).
pub(crate) fn in_visual(&self) -> bool {
matches!(self.mode, Mode::Visual | Mode::VisualLine)
}
/// Dispatch a key in Visual/VisualLine. Motions extend the selection (the
/// anchor stays put, the caret moves); `y`/`d`/`c` act on the span and
/// leave Visual; `v`/`V` switch submode or toggle back to Normal; `Esc`
/// cancels. Counts and `gg`/`G` work as in Normal.
pub(crate) fn visual_key(&mut self, key: Key) {
let c = match key {
Key::Char(c) => c,
Key::HalfPageDown => {
self.count = 0;
self.move_display_rows(HALF_PAGE as isize);
return;
}
Key::HalfPageUp => {
self.count = 0;
self.move_display_rows(-(HALF_PAGE as isize));
return;
}
Key::Escape => {
self.exit_visual();
return;
}
// Cmd-p works from every mode: drop the selection (as Esc would)
// and open the palette.
Key::Palette => {
self.exit_visual();
self.open_palette();
return;
}
// Enter/Backspace/etc. carry no Visual meaning; drop any count.
_ => {
self.count = 0;
self.pending_g = false;
return;
}
};
// `gg` — jump to the top, extending the selection.
if self.pending_g {
self.pending_g = false;
if c == 'g' {
self.caret = 0;
}
self.count = 0;
return;
}
// Count prefix, exactly as in Normal (a leading `0` is the motion).
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
return;
}
let n = self.count.max(1);
match c {
'g' => {
self.pending_g = true;
return;
}
// `v` toggles charwise off (or switches VisualLine → charwise);
// `V` toggles linewise off (or switches charwise → linewise).
'v' => {
if self.mode == Mode::Visual {
self.exit_visual();
} else {
self.mode = Mode::Visual;
}
}
'V' => {
if self.mode == Mode::VisualLine {
self.exit_visual();
} else {
self.mode = Mode::VisualLine;
}
}
'y' => self.visual_yank(),
'd' => self.visual_delete(),
'c' => self.visual_change(),
// Any other char: a shared motion extends the selection, or is a
// no-op. Unlike Normal, edit keys (`x`, `p`, …) aren't bound here.
_ => {
self.move_by(c, n);
}
}
self.count = 0;
}
/// The current selection as `(start, end, linewise)` byte offsets, `start <
/// end` (or equal on an empty buffer). Charwise is vim-inclusive of the char
/// under the further caret; linewise always spans whole logical lines from
/// the anchor's line through the caret's.
pub(crate) fn visual_span(&self) -> (usize, usize, bool) {
let anchor = self.visual_anchor.unwrap_or(self.caret);
let lo = anchor.min(self.caret);
let hi = anchor.max(self.caret);
if self.mode == Mode::VisualLine {
(self.line_start(lo), self.line_end(hi), true)
} else {
(lo, self.next_char(hi).min(self.text.len()), false)
}
}
/// Copy the selection into the unnamed register (linewise from `V`, charwise
/// otherwise), leave the caret at the selection start, and return to Normal.
pub(crate) fn visual_yank(&mut self) {
let (s, e, line) = self.visual_span();
self.register = self.selection_text(s, e, line);
self.register_linewise = line;
self.caret = s;
self.exit_visual();
}
/// Delete the selection (filling the register like `visual_yank`), leaving
/// the caret at the span start, and return to Normal. Linewise removes whole
/// lines including a bounding newline, mirroring `dd`.
pub(crate) fn visual_delete(&mut self) {
let (s, e, line) = self.visual_span();
self.register = self.selection_text(s, e, line);
self.register_linewise = line;
self.checkpoint();
let (ds, de) = self.delete_bounds(s, e, line);
self.text.replace_range(ds..de, "");
self.caret = if line {
self.line_start(ds.min(self.text.len()))
} else {
ds.min(self.text.len())
};
self.exit_visual();
}
/// Change the selection: delete it (filling the register) and drop into
/// Insert at the start. A linewise change clears the lines' text but leaves
/// one empty line to type on (like `cc`), rather than removing the line.
pub(crate) fn visual_change(&mut self) {
let (s, e, line) = self.visual_span();
self.register = self.selection_text(s, e, line);
self.register_linewise = line;
self.checkpoint();
// Linewise: replace only `s..e` (the text, keeping the final newline) so
// one blank line remains. Charwise: remove the exact span.
self.text.replace_range(s..e, "");
self.caret = s.min(self.text.len());
self.visual_anchor = None;
self.pending_g = false;
self.count = 0;
self.mode = Mode::Insert;
}
/// The register contents for a selection span: charwise is the raw slice;
/// linewise gets a synthesised trailing newline (as `yy`/`dd` store lines).
pub(crate) fn selection_text(&self, s: usize, e: usize, line: bool) -> String {
let mut block = self.text[s..e].to_string();
if line && !block.ends_with('\n') {
block.push('\n');
}
block
}
/// Byte range to actually remove for a delete. Charwise is the span as-is;
/// linewise also eats the trailing newline (or, on the last line, the
/// preceding one) so no blank line is left behind — matching `dd`.
pub(crate) fn delete_bounds(&self, s: usize, e: usize, line: bool) -> (usize, usize) {
if !line {
return (s, e);
}
if e < self.text.len() {
(s, self.next_char(e)) // eat the trailing '\n' at `e`
} else if s > 0 {
(self.prev_char(s), e) // last line: eat the preceding '\n' instead
} else {
(s, e) // whole buffer
}
}
/// Leave Visual for Normal, clearing the anchor and any pending state.
pub(crate) fn exit_visual(&mut self) {
self.mode = Mode::Normal;
self.visual_anchor = None;
self.pending_g = false;
self.count = 0;
}
// --- View mode ---------------------------------------------------------
}