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:
@@ -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).
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user