feat(editor): / forward search with n/N repeat (v0.7)
Literal case-sensitive substring search sharing the : command line's editing (cmd_prompt discriminates the prompt; Enter dispatches). Jumps on Enter only — incremental caret-chasing loses to the e-ink refresh cost, same call as the snippet no-popup decision. Wraps with a notice, keeps the caret on a miss, bare / repeats the editor-global last pattern. n/N live in move_by so counts and Visual extension come free; they are not operator targets.
This commit is contained in:
@@ -3,11 +3,21 @@
|
||||
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
|
||||
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
|
||||
|
||||
**Status:** the **`:gl` pull command landed in the editor** (2026-07-11,
|
||||
**Status:** **`/` search + `n N` done in core** (2026-07-13, host-tested — 207
|
||||
editor tests). The **`:gl` pull command landed in the editor** (2026-07-11,
|
||||
host-tested) — `Effect::Pull` + a firmware stub; the on-device fetch +
|
||||
fast-forward is still to build. Search not started.
|
||||
fast-forward is still to build.
|
||||
|
||||
- [ ] `/` forward search, `n N`
|
||||
- [x] `/` forward search, `n N` — done in core 2026-07-13 (host-tested).
|
||||
Literal, case-sensitive substring (no regex on-device, no smartcase
|
||||
surprises); `/` reuses the `:` command line's editing with a `/` prompt
|
||||
(one command-line mode, as in vim), and the jump happens on **Enter
|
||||
only** — no incremental caret-chasing, for the same e-ink-refresh reason
|
||||
snippets have no completion popup. Wraps around with a "wrapped" notice;
|
||||
"not found: pat" leaves the caret alone; a bare `/`+Enter repeats the
|
||||
last pattern, which is editor-global (survives buffer switches, like
|
||||
vim's search register). `n`/`N` are motions (counts work, Visual extends
|
||||
over them); deliberately **not** operator targets (`dn` out of scope).
|
||||
- [~] `:gl` — pull: fetch + **fast-forward only**, refuse on divergence and
|
||||
surface it (renamed from the planned `:Gpull`). Editor command +
|
||||
`Effect::Pull` done 2026-07-11 (host-tested); the git-thread
|
||||
|
||||
@@ -679,6 +679,13 @@ pub struct Editor {
|
||||
visual_anchor: Option<usize>,
|
||||
/// The `:` command line being typed (valid only in `Mode::Command`).
|
||||
cmdline: String,
|
||||
/// Which prompt opened the command line — `':'` (ex command) or `'/'`
|
||||
/// (search). Both share `Mode::Command`'s line editing (vim models them as
|
||||
/// one command-line mode); Enter dispatches on this.
|
||||
cmd_prompt: char,
|
||||
/// The last `/` pattern, kept for `n`/`N` and a bare `/`+Enter repeat.
|
||||
/// Editor-global (not per-buffer), like vim's search register.
|
||||
last_search: String,
|
||||
/// Word count as of the last stats refresh. The panel shows this snapshot,
|
||||
/// not a live count, so ordinary typing never repaints the panel row — it is
|
||||
/// refreshed on a typing pause / non-Insert action via `refresh_stats`.
|
||||
@@ -842,6 +849,8 @@ impl Editor {
|
||||
pending_g: false,
|
||||
visual_anchor: None,
|
||||
cmdline: String::new(),
|
||||
cmd_prompt: ':',
|
||||
last_search: String::new(),
|
||||
shown_words: 0,
|
||||
keyboard_present: false,
|
||||
notice: None,
|
||||
@@ -1392,6 +1401,18 @@ impl Editor {
|
||||
':' => {
|
||||
self.reset_pending();
|
||||
self.cmdline.clear();
|
||||
self.cmd_prompt = ':';
|
||||
self.mode = Mode::Command;
|
||||
return;
|
||||
}
|
||||
// `/` opens the same command line with a search prompt. The jump
|
||||
// happens on Enter only — no incremental caret-chasing; the e-ink
|
||||
// refresh cost rules that out (same call as the no-completion-popup
|
||||
// snippet decision).
|
||||
'/' => {
|
||||
self.reset_pending();
|
||||
self.cmdline.clear();
|
||||
self.cmd_prompt = '/';
|
||||
self.mode = Mode::Command;
|
||||
return;
|
||||
}
|
||||
@@ -1420,6 +1441,11 @@ impl Editor {
|
||||
'0' => self.caret = self.line_start(self.caret),
|
||||
'$' => self.caret = self.line_end(self.caret),
|
||||
'G' => self.caret = self.line_start(self.text.len()),
|
||||
// Repeat the last `/` search; a motion here so Visual extends over
|
||||
// it for free. Deliberately not an operator target (`dn` is not in
|
||||
// scope) — operators resolve their own motion table in `normal_key`.
|
||||
'n' => self.search_repeat(n, true),
|
||||
'N' => self.search_repeat(n, false),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
@@ -1437,7 +1463,11 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
Key::Enter => {
|
||||
self.execute_command();
|
||||
if self.cmd_prompt == '/' {
|
||||
self.execute_search();
|
||||
} else {
|
||||
self.execute_command();
|
||||
}
|
||||
self.cmdline.clear();
|
||||
// Most commands return to Normal; one that opened another mode
|
||||
// (`:settings` → the palette) set it during `execute_command`, so
|
||||
@@ -1498,6 +1528,60 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the typed `/` search: remember the pattern (a bare `/`+Enter repeats
|
||||
/// the last one, like vim) and jump forward once. Literal, case-sensitive
|
||||
/// substring — no regex on a writing appliance, and no smartcase surprises.
|
||||
fn execute_search(&mut self) {
|
||||
if !self.cmdline.is_empty() {
|
||||
self.last_search = self.cmdline.clone();
|
||||
}
|
||||
self.search_repeat(1, true);
|
||||
}
|
||||
|
||||
/// Jump `n` matches of [`last_search`](Self::last_search) forward
|
||||
/// (`fwd`, the `/`-Enter and `n` step) or backward (`N`), wrapping around
|
||||
/// the buffer with a "wrapped" notice. A missing pattern or an absent
|
||||
/// match posts a notice and leaves the caret alone.
|
||||
fn search_repeat(&mut self, n: usize, fwd: bool) {
|
||||
if self.last_search.is_empty() {
|
||||
self.set_notice("no previous search");
|
||||
return;
|
||||
}
|
||||
let pat = self.last_search.clone();
|
||||
for _ in 0..n {
|
||||
// Start strictly past (or before) the caret so a caret already on a
|
||||
// match moves to the next one, per vim.
|
||||
let hit = if fwd {
|
||||
let start = if self.caret >= self.text.len() {
|
||||
self.text.len()
|
||||
} else {
|
||||
self.next_char(self.caret)
|
||||
};
|
||||
match self.text[start..].find(&pat) {
|
||||
Some(i) => Some((start + i, false)),
|
||||
None => self.text.find(&pat).map(|i| (i, true)),
|
||||
}
|
||||
} else {
|
||||
match self.text[..self.caret].rfind(&pat) {
|
||||
Some(i) => Some((i, false)),
|
||||
None => self.text.rfind(&pat).map(|i| (i, true)),
|
||||
}
|
||||
};
|
||||
match hit {
|
||||
Some((pos, wrapped)) => {
|
||||
self.caret = pos;
|
||||
if wrapped {
|
||||
self.set_notice("wrapped");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.set_notice(format!("not found: {pat}"));
|
||||
return; // absent now, absent on every repeat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue an [`Effect::Save`] of the active buffer. Posts "no file name" for an
|
||||
/// unnamed scratch buffer (nothing to save to) rather than writing to `""`.
|
||||
fn request_save_active(&mut self) {
|
||||
@@ -3484,7 +3568,7 @@ impl Editor {
|
||||
.draw(f)
|
||||
.unwrap();
|
||||
|
||||
let s = format!(":{}", self.cmdline);
|
||||
let s = format!("{}{}", self.cmd_prompt, self.cmdline);
|
||||
let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
|
||||
Text::with_baseline(&s, Point::new(2, HEIGHT as i32 - CH), style, Baseline::Top)
|
||||
.draw(f)
|
||||
@@ -6183,4 +6267,164 @@ mod tests {
|
||||
assert!(e.snippet_hint.is_some());
|
||||
let _ = e.draw(true); // the `» name` panel row must render cleanly
|
||||
}
|
||||
|
||||
// --- `/` search (v0.7) --------------------------------------------------
|
||||
|
||||
/// A fresh Normal-mode editor over `text`, caret normalized to 0 with `gg`
|
||||
/// (a loaded file resumes at its end).
|
||||
fn over(text: &str) -> Editor {
|
||||
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, text.into());
|
||||
e.handle(Key::Char('g'));
|
||||
e.handle(Key::Char('g'));
|
||||
e
|
||||
}
|
||||
|
||||
/// Run `/{pat}<Enter>` on `e`.
|
||||
fn search(e: &mut Editor, pat: &str) {
|
||||
e.handle(Key::Char('/'));
|
||||
for c in pat.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_opens_the_search_prompt_and_esc_cancels() {
|
||||
let mut e = over("alpha beta");
|
||||
e.handle(Key::Char('/'));
|
||||
assert_eq!(e.mode(), Mode::Command); // command-line mode, `/` prompt
|
||||
e.handle(Key::Char('b'));
|
||||
e.handle(Key::Escape);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
assert_eq!(e.caret, 0); // cancelled search never moves the caret
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_jumps_past_the_caret_to_the_next_match() {
|
||||
let mut e = over("alpha beta alpha");
|
||||
search(&mut e, "alpha");
|
||||
// The caret sits on the first "alpha"; search starts *after* it.
|
||||
assert_eq!(e.caret, 11);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_wraps_to_the_top_with_a_notice() {
|
||||
let mut e = over("alpha beta");
|
||||
search(&mut e, "beta"); // caret → 6
|
||||
search(&mut e, "alpha"); // no match after 6 → wraps to 0
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.notice.as_deref(), Some("wrapped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_not_found_keeps_the_caret_and_says_so() {
|
||||
let mut e = over("alpha beta");
|
||||
search(&mut e, "gamma");
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.notice.as_deref(), Some("not found: gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_repeats_forward_and_wraps() {
|
||||
let mut e = over("ab x ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
assert_eq!(e.caret, 5);
|
||||
e.handle(Key::Char('n')); // → 10
|
||||
assert_eq!(e.caret, 10);
|
||||
e.handle(Key::Char('n')); // wraps → 0
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.notice.as_deref(), Some("wrapped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capital_n_repeats_backward_and_wraps() {
|
||||
let mut e = over("ab x ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
e.handle(Key::Char('N')); // back → 0
|
||||
assert_eq!(e.caret, 0);
|
||||
e.handle(Key::Char('N')); // wraps to the last match → 10
|
||||
assert_eq!(e.caret, 10);
|
||||
assert_eq!(e.notice.as_deref(), Some("wrapped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_applies_to_n() {
|
||||
let mut e = over("ab ab ab ab");
|
||||
search(&mut e, "ab"); // → 3
|
||||
e.handle(Key::Char('2'));
|
||||
e.handle(Key::Char('n')); // 2 matches forward → 9
|
||||
assert_eq!(e.caret, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_without_a_previous_search_says_so() {
|
||||
let mut e = over("alpha");
|
||||
e.handle(Key::Char('n'));
|
||||
assert_eq!(e.caret, 0);
|
||||
assert_eq!(e.notice.as_deref(), Some("no previous search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_slash_repeats_the_last_search() {
|
||||
let mut e = over("ab x ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
search(&mut e, ""); // bare `/` Enter reuses "ab" → 10
|
||||
assert_eq!(e.caret, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_is_case_sensitive_and_literal() {
|
||||
let mut e = over("Alpha alpha");
|
||||
search(&mut e, "alpha");
|
||||
assert_eq!(e.caret, 6); // "Alpha" does not match
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_lands_on_char_boundaries_in_multibyte_text() {
|
||||
let mut e = over("héé ém"); // 'é' is 2 bytes
|
||||
search(&mut e, "ém");
|
||||
assert_eq!(e.caret, 6); // byte offset of the standalone "ém"
|
||||
assert_eq!(&e.text[e.caret..e.caret + 3], "ém");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_extends_a_visual_selection() {
|
||||
let mut e = over("ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
e.handle(Key::Char('N')); // back → 0
|
||||
e.handle(Key::Char('v')); // Visual, anchor at 0
|
||||
e.handle(Key::Char('n')); // extend to the next match
|
||||
assert_eq!(e.mode(), Mode::Visual);
|
||||
assert_eq!(e.caret, 5);
|
||||
e.handle(Key::Char('y')); // yank the span (inclusive of the caret char)
|
||||
assert_eq!(e.register, "ab x a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_search_survives_a_buffer_switch() {
|
||||
let mut e = over("ab x ab");
|
||||
search(&mut e, "ab"); // → 5
|
||||
e.handle(Key::Char(':'));
|
||||
for c in "enew /sd/repo/other.md".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Enter);
|
||||
e.handle(Key::Char('i'));
|
||||
for c in "ab cd ab".chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
e.handle(Key::Escape);
|
||||
e.handle(Key::Char('0'));
|
||||
e.handle(Key::Char('n')); // the pattern is editor-global, like vim
|
||||
assert_eq!(e.caret, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_prompt_draws_without_panic() {
|
||||
let mut e = over("alpha");
|
||||
e.handle(Key::Char('/'));
|
||||
e.handle(Key::Char('a'));
|
||||
let _ = e.draw(true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user