feat(editor): add v0.5 file palette (Cmd-P) with fuzzy match

Cmd-P opens a modal transient palette over the writing column: a bare
fuzzy-search input, the ranked file list, and the selected row in reverse
video. A pure host-testable fuzzy_score (subsequence + word-boundary and
consecutive-run bonuses) ranks results; an in-core MRU floats recently
opened files first and is shared with :e (both route through open_path).
The host feeds the file list once at boot (enumerate_files over /sd/repo
and /sd/local). Ctrl-n/Ctrl-p navigate the list; Enter opens via the same
park/evict path as :e; Esc closes.

Ctrl-n/Ctrl-p also become down/up line motions in Normal and View (vim
CTRL-N/CTRL-P, count-aware), which is why the palette opener is Cmd-P
alone. No `>` prefix on the file input — `>` is reserved for the command
palette (slice 4).

112 editor + 28 keymap tests; the no-git firmware binary builds clean.
This commit is contained in:
Julien Calixte
2026-07-12 00:11:33 +02:00
parent 2215da939d
commit e967773bd6
5 changed files with 648 additions and 18 deletions

View File

@@ -48,7 +48,7 @@ _Avoid_: tab, window, document (a buffer is not a UI chrome element); "the file"
when you mean the in-memory copy rather than the bytes on the card.
**Open**:
Bringing a **File** into the **active buffer**, via `Ctrl-P` (the file palette)
Bringing a **File** into the **active buffer**, via `Cmd-P` (the file palette)
or `:e`. Scope is read from where the file lives (`/sd/repo`**Tracked**,
`/sd/local`**Local**), never chosen at open time.
_Avoid_: load (implementation talk for the disk read behind an Open).

View File

@@ -332,18 +332,39 @@ saved before it is evicted (nothing leaves RAM unsaved); `:e <path>` opens by
prefix (`/sd/repo` → Tracked, `/sd/local` → Local); `:sync` is refused in-core in
a Local buffer. Firmware drains the queue **to empty** each batch (a `Load` can
cascade an eviction `Save`), and `persistence::{load_path,save_path}` generalise
the atomic save off the hard-coded `notes.md`. 93 editor tests pass; the default
(no-git) firmware binary builds clean. Remaining v0.5 slices: 2 palette + fuzzy +
transient panel, 3 `:enew` + delete, 4 prefs + palette command mode.
the atomic save off the hard-coded `notes.md`.
- [ ] `Ctrl-P` opens fuzzy file palette over **both** `/sd/repo/` and
`/sd/local/`, with a scope marker (e.g. `[git]` / `[local]`) per result
**Slice 2 of 4 landed in core 2026-07-11**, host-tested: the `Cmd-P` file
**palette** — a modal transient panel over the writing column with a bare
fuzzy-search input (no `>` prefix: `>` is reserved for the command palette,
slice 4 — VS Code semantics), the ranked list, and the selected row in reverse
video. A pure host-testable fuzzy matcher (`fuzzy_score`: subsequence match,
boundary + consecutive-run bonuses, no penalties) ranks results; an in-core MRU
floats recently-opened files to the top on an empty query and is **shared with
`:e`** (both flow through `open_path`). The host feeds the file list once at boot
(`set_file_list`, enumerating `/sd/repo` + `/sd/local`, dotfiles skipped);
`Ctrl-n`/`Ctrl-p` (fzf-style; `Ctrl-d`/`Ctrl-u` too) move the selection — the
60 % board has no arrow keys — Enter opens via the same park/evict path as `:e`,
Esc (or `Cmd-P` again) closes. Same slice: **`Ctrl-n`/`Ctrl-p` also work as
down/up line motions in Normal mode** (vim `CTRL-N`≡`j`, `CTRL-P`≡`k`,
count-aware), which is why the palette opener moved to `Cmd-P` alone. Scope
shows as the inline `repo/…` vs `local/…` label rather than the planned
`[git]`/`[local]` badge — it also disambiguates subpaths, not just scope. 111
editor tests + 28 keymap tests pass; the no-git firmware binary builds clean. The
transient-panel refresh on the panel is the **Spike 11** on-device gate (still
pending); file-list refresh after create/delete arrives with slice 3. Remaining
v0.5 slices: 3 `:enew` + delete, 4 prefs + palette command mode.
- [~] `Cmd-P` opens fuzzy file palette over **both** `/sd/repo/` and
`/sd/local/` — **landed in core (host-tested)**; scope shows as the inline
`repo/…` / `local/…` label instead of a `[git]`/`[local]` badge. On-device
transient-panel refresh (Spike 11) is the remaining gate.
- [~] Open, switch, close buffers (keep ≤ 3 in memory) — **open + switch + the
≤ 3 LRU-resident model with dirty-aware save-before-evict done in core**
(host-tested); `:e <path>` drives it today. Explicit **close** (and the
palette that surfaces switching) still to come.
- [~] `:e` and palette share the same recent-files list — **`:e <path>` landed**
(prefix → scope); the shared recent-files list arrives with the palette.
(host-tested); `:e <path>` **and the palette** drive it today. Explicit
**close** still to come.
- [x] `:e` and palette share the same recent-files list — both open via
`open_path`, which pushes to the in-core MRU that orders the palette.
- [ ] `:enew` creates a new file — prompts for scope (tracked vs local)
- [ ] Delete a file — removes it from the SD card; for a Tracked file the
removal reaches the next `Ctrl-G` Publish's staged set (`git rm` / `add -A`

View File

@@ -89,6 +89,11 @@ pub enum Mode {
/// Enter runs it, Esc cancels. Handles `:fmt` (in-core) plus `:w`/`:sync`
/// (which ask the host to persist/publish via an [`Effect`]).
Command,
/// File palette (`Cmd-P`) — a modal transient panel over the writing column.
/// Typing fuzzy-filters the file list ([`Editor::set_file_list`]); `Ctrl-n`/
/// `Ctrl-p` move the selection, Enter opens it, Esc (or `Cmd-P` again)
/// cancels. See [`Editor::palette_key`].
Palette,
}
/// Which of the two file scopes ([`CONTEXT.md`]) a buffer belongs to. Fixed at
@@ -191,6 +196,58 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
lines
}
/// 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.
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() {
if qi < q.len() && tc.eq_ignore_ascii_case(&q[qi]) {
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)
}
/// 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.
fn palette_label(path: &str) -> &str {
path.strip_prefix("/sd/").unwrap_or(path)
}
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange /
/// `y`ank).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -289,6 +346,21 @@ pub struct Editor {
/// Host-effect queue, drained by [`take_effects`](Self::take_effects) after a
/// key batch. See [`Effect`].
requests: Vec<Effect>,
/// Every openable file, as absolute paths, fed by the host at boot via
/// [`set_file_list`](Self::set_file_list) (an enumeration of `/sd/repo` and
/// `/sd/local`). The palette fuzzy-filters this; empty until the host feeds it.
files: Vec<String>,
/// Recently-opened files, most-recent-first (an MRU), deduped and bounded to
/// [`MRU_MAX`]. Every `:e`/palette open pushes to the front
/// ([`note_recent`](Self::note_recent)); it orders the palette when the query
/// is empty, so the file you were just in is one keystroke away.
recent: Vec<String>,
/// The palette's fuzzy query (valid only in [`Mode::Palette`]).
palette_query: String,
/// The selected row in the palette's *filtered* result list (index into
/// [`palette_matches`](Self::palette_matches), not into [`files`](Self::files)).
/// Reset to 0 whenever the query changes.
palette_sel: usize,
}
/// A resident-but-inactive buffer: everything needed to restore a file's editing
@@ -311,6 +383,12 @@ struct Buffer {
/// evicted; it is saved first if dirty, so an evicted buffer is never lost.
const MAX_RESIDENT: usize = 3;
/// Recent-files (MRU) list length — how many opens the palette remembers to
/// float to the top on an empty query. 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.
const MRU_MAX: usize = 16;
/// 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
@@ -352,6 +430,10 @@ impl Editor {
dirty: false,
parked: Vec::new(),
requests: Vec::new(),
files: Vec::new(),
recent: Vec::new(),
palette_query: String::new(),
palette_sel: 0,
}
}
@@ -504,6 +586,7 @@ impl Editor {
Mode::Visual | Mode::VisualLine => self.visual_key(key),
Mode::View => self.view_key(key),
Mode::Command => self.command_key(key),
Mode::Palette => self.palette_key(key),
}
if !self.replaying {
@@ -573,10 +656,12 @@ impl Editor {
Key::Backspace => self.backspace(),
Key::DeleteWord => self.delete_word_before(),
Key::DeleteLine => self.delete_to_line_start(),
// Half-page scroll is a navigation gesture — Normal/View only. In
// Insert it's a no-op rather than yanking the caret off the text
// you're typing. Redo (Ctrl-r) is likewise Normal-only here.
Key::HalfPageDown | Key::HalfPageUp | Key::Redo => {}
// Half-page scroll and the Ctrl-n/Ctrl-p line motions are navigation
// gestures — Normal/View only. In Insert they're a no-op rather than
// yanking the caret off the text you're typing. Redo (Ctrl-r) and the
// palette (Cmd-p) are likewise ignored here.
Key::HalfPageDown | Key::HalfPageUp | Key::Redo | Key::Palette | Key::Down
| Key::Up => {}
Key::Escape => {
self.mode = Mode::Normal;
// vim drops the caret onto the last inserted char.
@@ -605,12 +690,33 @@ impl Editor {
self.move_display_rows(-(HALF_PAGE as isize));
return;
}
// Ctrl-n/Ctrl-p: move down/up a line (vim CTRL-N ≡ j, CTRL-P ≡ k),
// honouring a leading count (`3<C-n>`) then abandoning the rest of any
// pending command like a plain motion.
Key::Down => {
let n = self.count.max(1);
self.reset_pending();
self.move_by('j', n);
return;
}
Key::Up => {
let n = self.count.max(1);
self.reset_pending();
self.move_by('k', n);
return;
}
// Ctrl-r redo: like any non-motion key it abandons a pending command.
Key::Redo => {
self.reset_pending();
self.redo();
return;
}
// Cmd-p: open the file palette (abandoning any pending command).
Key::Palette => {
self.reset_pending();
self.open_palette();
return;
}
// Esc and other non-character events cancel any pending command.
_ => {
self.reset_pending();
@@ -1007,6 +1113,7 @@ impl Editor {
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);
@@ -1104,6 +1211,129 @@ impl Editor {
self.open_path(path, scope);
}
// --- File palette (Ctrl-P) ---------------------------------------------
/// Feed the palette its file list: every openable file as an absolute path,
/// enumerated by the host from `/sd/repo` and `/sd/local` (at boot for v0.5).
/// 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(&mut self, mut files: Vec<String>) {
files.sort();
files.dedup();
self.files = files;
}
/// 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.
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);
}
/// `Ctrl-P` — open the file palette: empty query (full list, recents first),
/// selection on the first row.
fn open_palette(&mut self) {
self.mode = Mode::Palette;
self.palette_query.clear();
self.palette_sel = 0;
}
/// Leave the palette back to Normal, clearing its query and selection.
fn close_palette(&mut self) {
self.mode = Mode::Normal;
self.palette_query.clear();
self.palette_sel = 0;
}
/// Dispatch a key in [`Mode::Palette`]. Typing fuzzy-filters; `Ctrl-n`/`Ctrl-p`
/// (or `Ctrl-d`/`Ctrl-u`) move the selection down/up; Enter opens the selected
/// file; Esc or `Cmd-P` closes. Backspace on an empty query also closes
/// (mirrors the `:` line). Any query edit resets the selection to the top.
fn palette_key(&mut self, 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.
// Clamped to the result list.
Key::Down | Key::HalfPageDown => {
let n = self.palette_matches().len();
self.palette_sel = (self.palette_sel + 1).min(n.saturating_sub(1));
}
Key::Up | Key::HalfPageUp => self.palette_sel = self.palette_sel.saturating_sub(1),
Key::Enter => self.palette_open_selected(),
// Esc, or Cmd-P again, closes the palette.
Key::Escape | Key::Palette => self.close_palette(),
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.
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.files[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`].
fn palette_matches(&self) -> Vec<usize> {
let mut order: Vec<usize> = Vec::with_capacity(self.files.len());
for r in &self.recent {
if let Some(i) = self.files.iter().position(|f| f == r) {
order.push(i);
}
}
for i in 0..self.files.len() {
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.files[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()
}
// --- Visual mode -------------------------------------------------------
/// True while a Visual selection is active (charwise or linewise).
@@ -1289,8 +1519,9 @@ impl Editor {
fn view_key(&mut self, key: Key) {
match key {
Key::Char('j') => self.scroll_top += 1, // clamped in draw()
Key::Char('k') => self.scroll_top = self.scroll_top.saturating_sub(1),
// j/k and Ctrl-n/Ctrl-p both step one row (View is a pure viewport).
Key::Char('j') | Key::Down => self.scroll_top += 1, // clamped in draw()
Key::Char('k') | Key::Up => self.scroll_top = self.scroll_top.saturating_sub(1),
Key::Char(' ') => self.scroll_top += ROWS,
// Half-page scroll, mirroring Normal mode — here it's a pure
// viewport move (View has no caret to chase). Clamped in draw().
@@ -2109,6 +2340,12 @@ impl Editor {
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);
}
f
}
@@ -2175,6 +2412,7 @@ impl Editor {
Mode::Visual => "VISUAL",
Mode::VisualLine => "V-LINE",
Mode::View => "VIEW",
Mode::Palette => "PALETTE",
Mode::Command => unreachable!(),
};
let mut s = format!("-- {name} --");
@@ -2233,6 +2471,94 @@ impl Editor {
.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.
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 query on the top row with a block caret at the end. No `>` prefix:
// a bare input is "go to file" (VS Code Cmd-P); the `>` prefix is reserved
// for the command palette (v0.5 slice 4). An empty query is just the caret
// over a `Go to file` placeholder that clears on the first keystroke.
if self.palette_query.is_empty() {
Text::with_baseline("Go to file", 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();
let matches = 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 self.files.is_empty() {
"(no files on card)"
} 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 =
palette_label(&self.files[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 = "^N/^P move Enter open Esc close";
Text::with_baseline(hint, Point::new(2, hint_y), style, Baseline::Top)
.draw(f)
.unwrap();
}
}
/// Parse a Markdown list marker at the start of `line`. Returns
@@ -3514,4 +3840,233 @@ mod tests {
e.install_loaded("/sd/repo/d.md".into(), Scope::Tracked, "D".into());
assert!(e.take_effects().is_empty()); // clean buffer: no save on evict
}
// ---- File palette (Ctrl-P) ----
/// A fresh editor over `/sd/repo/notes.md` with a palette file list.
fn palette_editor(files: &[&str]) -> Editor {
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, String::new());
e.set_file_list(files.iter().map(|s| s.to_string()).collect());
e
}
/// The palette's current result as display labels, in ranked order.
fn palette_labels(e: &Editor) -> Vec<&str> {
e.palette_matches().iter().map(|&i| palette_label(&e.files[i])).collect()
}
#[test]
fn fuzzy_score_matches_subsequence_case_insensitively() {
assert!(fuzzy_score("notes", "repo/notes.md").is_some());
assert!(fuzzy_score("NOTES", "repo/notes.md").is_some()); // case-insensitive
assert!(fuzzy_score("rpnm", "repo/notes.md").is_some()); // scattered subsequence
assert!(fuzzy_score("xyz", "repo/notes.md").is_none()); // not a subsequence
assert_eq!(fuzzy_score("", "anything"), Some(0)); // empty query matches all
}
#[test]
fn fuzzy_score_ranks_word_boundaries_above_midword() {
// "no" after the "/" boundary in repo/notes beats a mid-word hit.
let boundary = fuzzy_score("no", "repo/notes.md").unwrap();
let midword = fuzzy_score("no", "cannotes.md").unwrap();
assert!(boundary > midword, "{boundary} !> {midword}");
}
#[test]
fn set_file_list_sorts_and_dedups() {
let mut e = Editor::new();
e.set_file_list(vec![
"/sd/repo/b.md".into(),
"/sd/repo/a.md".into(),
"/sd/repo/b.md".into(),
]);
assert_eq!(e.files, vec!["/sd/repo/a.md", "/sd/repo/b.md"]);
}
#[test]
fn cmd_p_opens_the_palette_and_esc_closes_it() {
let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/repo/todo.md"]);
e.handle(Key::Palette);
assert_eq!(e.mode(), Mode::Palette);
e.handle(Key::Escape);
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn cmd_p_toggles_the_palette_closed() {
let mut e = palette_editor(&["/sd/repo/notes.md"]);
e.handle(Key::Palette);
e.handle(Key::Palette); // same chord closes
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn cmd_p_is_ignored_in_insert_mode() {
let mut e = typed("hi");
e.handle(Key::Palette);
assert_eq!(e.mode(), Mode::Insert); // a Normal gesture only; no-op here
}
#[test]
fn typing_filters_and_enter_opens_the_picked_file() {
let mut e = palette_editor(&[
"/sd/repo/notes.md",
"/sd/repo/todo.md",
"/sd/local/journal.md",
]);
e.handle(Key::Palette);
for c in "todo".chars() {
e.handle(Key::Char(c));
}
e.handle(Key::Enter);
assert_eq!(e.mode(), Mode::Normal); // palette closed
let effs = e.take_effects();
assert_eq!(kinds(&effs), vec![Kind::Load]);
match &effs[0] {
Effect::Load { path, scope } => {
assert_eq!(path, "/sd/repo/todo.md");
assert_eq!(*scope, Scope::Tracked);
}
other => panic!("expected a Load, got {other:?}"),
}
}
#[test]
fn opening_a_local_file_from_the_palette_carries_local_scope() {
let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/local/journal.md"]);
e.handle(Key::Palette);
for c in "journal".chars() {
e.handle(Key::Char(c));
}
e.handle(Key::Enter);
match &e.take_effects()[0] {
Effect::Load { path, scope } => {
assert_eq!(path, "/sd/local/journal.md");
assert_eq!(*scope, Scope::Local); // scope derived from the /sd/local path
}
other => panic!("expected a Local Load, got {other:?}"),
}
}
#[test]
fn opening_the_active_file_from_the_palette_is_a_noop() {
let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/repo/todo.md"]);
e.handle(Key::Palette);
for c in "notes".chars() {
e.handle(Key::Char(c));
}
e.handle(Key::Enter);
// notes.md is already active: no Load, just a closed palette.
assert!(e.take_effects().is_empty());
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn half_page_keys_move_the_selection_clamped() {
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md", "/sd/repo/c.md"]);
e.handle(Key::Palette);
assert_eq!(e.palette_sel, 0);
e.handle(Key::HalfPageDown);
assert_eq!(e.palette_sel, 1);
e.handle(Key::HalfPageDown);
e.handle(Key::HalfPageDown); // clamps at the last row
assert_eq!(e.palette_sel, 2);
e.handle(Key::HalfPageUp);
assert_eq!(e.palette_sel, 1);
}
#[test]
fn ctrl_n_p_navigate_the_palette() {
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md", "/sd/repo/c.md"]);
e.handle(Key::Palette);
e.handle(Key::Down); // Ctrl-n
assert_eq!(e.palette_sel, 1);
e.handle(Key::Down);
e.handle(Key::Down); // clamps at the last row
assert_eq!(e.palette_sel, 2);
e.handle(Key::Up); // Ctrl-p
assert_eq!(e.palette_sel, 1);
}
#[test]
fn ctrl_n_p_move_by_a_line_in_normal_mode() {
// Three lines; caret starts on the last char of line 3 (with_file posture).
let mut e = Editor::with_file("/sd/repo/n.md".into(), Scope::Tracked, "aa\nbb\ncc".into());
e.handle(Key::Char('g')); // gg → top (line 1)
e.handle(Key::Char('g'));
assert_eq!(e.caret, 0);
e.handle(Key::Down); // Ctrl-n → line 2
assert_eq!(e.caret, 3); // start of "bb"
e.handle(Key::Down); // → line 3
assert_eq!(e.caret, 6); // start of "cc"
e.handle(Key::Up); // Ctrl-p → line 2
assert_eq!(e.caret, 3);
}
#[test]
fn ctrl_n_takes_a_count_in_normal_mode() {
let mut e = Editor::with_file("/sd/repo/n.md".into(), Scope::Tracked, "aa\nbb\ncc".into());
e.handle(Key::Char('g'));
e.handle(Key::Char('g')); // top
e.handle(Key::Char('2'));
e.handle(Key::Down); // 2<C-n> → down two lines
assert_eq!(e.caret, 6); // start of "cc"
}
#[test]
fn ctrl_n_p_scroll_in_view_mode() {
let mut e = Editor::with_file("/sd/repo/n.md".into(), Scope::Tracked, "a\nb\nc\nd".into());
e.handle(Key::Char('g'));
e.handle(Key::Char('r')); // gr → View
assert_eq!(e.mode(), Mode::View);
let top = e.scroll_top();
e.handle(Key::Down); // Ctrl-n scrolls like j
assert_eq!(e.scroll_top(), top + 1);
e.handle(Key::Up); // Ctrl-p scrolls like k
assert_eq!(e.scroll_top(), top);
}
#[test]
fn editing_the_query_resets_the_selection_to_the_top() {
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md"]);
e.handle(Key::Palette);
e.handle(Key::HalfPageDown);
assert_eq!(e.palette_sel, 1);
e.handle(Key::Char('a')); // a query edit resets the selection
assert_eq!(e.palette_sel, 0);
}
#[test]
fn backspace_on_an_empty_query_closes_the_palette() {
let mut e = palette_editor(&["/sd/repo/a.md"]);
e.handle(Key::Palette);
e.handle(Key::Backspace);
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn empty_query_orders_recents_first_then_sorted() {
let mut e = palette_editor(&["/sd/repo/b.md", "/sd/repo/a.md", "/sd/repo/c.md"]);
// No opens yet: pure sorted order.
assert_eq!(palette_labels(&e), vec!["repo/a.md", "repo/b.md", "repo/c.md"]);
// Open c.md through the palette; it should float to the front next time.
e.handle(Key::Palette);
for ch in "c.md".chars() {
e.handle(Key::Char(ch));
}
e.handle(Key::Enter);
e.take_effects(); // drop the queued Load; we only care about the MRU
assert_eq!(palette_labels(&e), vec!["repo/c.md", "repo/a.md", "repo/b.md"]);
}
#[test]
fn draw_in_palette_mode_does_not_panic() {
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/local/j.md"]);
e.handle(Key::Palette);
let _ = e.draw(true);
// Empty file list: the "(no files on card)" path must also be safe.
let mut empty = Editor::new();
empty.handle(Key::Palette);
let _ = empty.draw(true);
}
}

View File

@@ -10,7 +10,7 @@ use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver};
use esp_idf_svc::hal::units::FromValueType;
use display::Frame;
use editor::{Editor, Effect, Mode, Scope, CH};
use editor::{Editor, Effect, Mode, Scope, CH, LOCAL_DIR, REPO_DIR};
use firmware::epd::{self, Epd};
use firmware::persistence::{Storage, NOTES};
@@ -112,6 +112,9 @@ fn main() -> anyhow::Result<()> {
.and_then(|s| s.to_str())
.unwrap_or("notes");
ed.set_notice(format!("loaded {name}"));
// Feed the file palette (Ctrl-P). Enumerated once at boot — the v0.5 slices
// that create/delete files (`:enew`, delete) will re-feed it then.
ed.set_file_list(enumerate_files());
let mut updates: u32 = 0;
let mut cursor_shown = true; // the initial render includes the caret
let mut last_activity = Instant::now();
@@ -418,6 +421,38 @@ fn open_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) {
}
}
/// Enumerate the palette's openable files: the top-level regular files in
/// `/sd/repo` and `/sd/local`, as absolute paths. Skips dotfiles (so `.git`,
/// `.typoena.toml`, and the like never show) and anything that isn't a plain
/// file. Best-effort: an unreadable directory (e.g. no `/sd/local` yet)
/// contributes nothing rather than failing. The editor sorts and dedupes.
fn enumerate_files() -> Vec<String> {
let mut out = Vec::new();
for dir in [REPO_DIR, LOCAL_DIR] {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
// Stat rather than trust d_type — FatFS's dirent type can read back
// as unknown; a plain metadata call is reliable here.
if !std::fs::metadata(&path).map(|m| m.is_file()).unwrap_or(false) {
continue;
}
if let Some(p) = path.to_str() {
out.push(p.to_string());
}
}
}
out
}
/// A file's display name — its basename without extension (`/sd/repo/notes.md`
/// → `notes`), for the snackbar. Falls back to the raw path if it has no stem.
fn file_stem(path: &str) -> &str {

View File

@@ -37,6 +37,16 @@ pub enum Key {
/// Ctrl+R — redo (vim `Ctrl-r`); the inverse of `u`. Meaningful in Normal;
/// ignored elsewhere.
Redo,
/// Cmd+P — open the file palette (fuzzy open, v0.5), VS Code "Go to File"
/// style. A Normal-mode gesture; **inside** the palette the same chord closes
/// it (toggle). Esc also closes. Ignored in Insert.
Palette,
/// Ctrl+N — move down: one line in Normal/View (vim `CTRL-N` ≡ `j`), or one
/// row in the file palette. Ignored in Insert.
Down,
/// Ctrl+P — move up: one line in Normal/View (vim `CTRL-P` ≡ `k`), or one row
/// in the file palette. Ignored in Insert.
Up,
/// Caps Lock tapped on its own. A no-op for now; groundwork for a future
/// vim-style normal mode.
Escape,
@@ -150,6 +160,9 @@ fn translate(usage: u8, shift: bool, ctrl: bool, cmd: bool) -> Option<Key> {
0x07 if ctrl => return Some(Key::HalfPageDown), // Ctrl+D, half-page down
0x18 if ctrl => return Some(Key::HalfPageUp), // Ctrl+U, half-page up
0x15 if ctrl => return Some(Key::Redo), // Ctrl+R, redo
0x13 if ctrl => return Some(Key::Up), // Ctrl+P, move up (vim CTRL-P)
0x13 if cmd => return Some(Key::Palette), // Cmd+P, file palette
0x11 if ctrl => return Some(Key::Down), // Ctrl+N, move down (vim CTRL-N)
_ => {}
}
@@ -394,8 +407,14 @@ mod tests {
assert_eq!(translate(0x07, false, true, false), Some(Key::HalfPageDown)); // Ctrl+D
assert_eq!(translate(0x18, false, true, false), Some(Key::HalfPageUp)); // Ctrl+U
assert_eq!(translate(0x15, false, true, false), Some(Key::Redo)); // Ctrl+R
// Without Ctrl these are ordinary letters, not intents.
assert_eq!(translate(0x13, false, true, false), Some(Key::Up)); // Ctrl+P, up
assert_eq!(translate(0x13, false, false, true), Some(Key::Palette)); // Cmd+P, palette
assert_eq!(translate(0x11, false, true, false), Some(Key::Down)); // Ctrl+N, down
assert_eq!(translate(0x11, false, false, true), None); // Cmd+N reserved (:enew, v0.5)
// Without a modifier these are ordinary letters, not intents.
assert_eq!(translate(0x15, false, false, false), Some(Key::Char('r')));
assert_eq!(translate(0x13, false, false, false), Some(Key::Char('p')));
assert_eq!(translate(0x11, false, false, false), Some(Key::Char('n')));
}
#[test]