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:
372
editor/src/buffers.rs
Normal file
372
editor/src/buffers.rs
Normal 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
434
editor/src/editing.rs
Normal 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
121
editor/src/fuzzy.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
5894
editor/src/lib.rs
5894
editor/src/lib.rs
File diff suppressed because it is too large
Load Diff
221
editor/src/markdown.rs
Normal file
221
editor/src/markdown.rs
Normal 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
191
editor/src/motions.rs
Normal 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
506
editor/src/palette.rs
Normal 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
161
editor/src/prefs.rs
Normal 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
769
editor/src/render.rs
Normal 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 — 1–6 `#`
|
||||
/// 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
66
editor/src/search.rs
Normal 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
161
editor/src/snippets.rs
Normal 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
2682
editor/src/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
66
editor/src/undo.rs
Normal file
66
editor/src/undo.rs
Normal 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
194
editor/src/visual.rs
Normal 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 ---------------------------------------------------------
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user