diff --git a/CONTEXT.md b/CONTEXT.md index 3b49f34..402dd71 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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). diff --git a/docs/macroplan.md b/docs/macroplan.md index b6c57fd..37046e0 100644 --- a/docs/macroplan.md +++ b/docs/macroplan.md @@ -332,18 +332,39 @@ saved before it is evicted (nothing leaves RAM unsaved); `:e ` 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 ` drives it today. Explicit **close** (and the - palette that surfaces switching) still to come. -- [~] `:e` and palette share the same recent-files list — **`:e ` landed** - (prefix → scope); the shared recent-files list arrives with the palette. + (host-tested); `:e ` **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` diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 43b9a84..4ce68dd 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -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 { 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 { + let q: Vec = 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 = 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, + /// 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, + /// 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, + /// 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`) 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) { + 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 { + let mut order: Vec = 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 → 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); + } } diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 5e6bc35..61479f7 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -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 { + 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 { diff --git a/keymap/src/lib.rs b/keymap/src/lib.rs index 5be0031..5141b06 100644 --- a/keymap/src/lib.rs +++ b/keymap/src/lib.rs @@ -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 { 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]