Compare commits

..

4 Commits

Author SHA1 Message Date
Julien Calixte
f950abdc4a docs(macroplan): mark v0.3 editing complete
Records v0.3 as delivered in core (host-tested, on-device smoke-test pending)
and notes the known dot-repeat limits. Also reorders the plan so the Status
block follows the macroplan source.
2026-07-11 20:03:59 +02:00
Julien Calixte
137a51eb76 chore(firmware): bump version to 0.3.0
v0.3 editing (register/yank/paste, undo/redo, dot-repeat) is complete, so the
device release version tracks the shipped feature set.
2026-07-11 20:03:59 +02:00
Julien Calixte
ba8f4e9a92 feat(editor): add v0.3 editing — register, undo/redo, and dot-repeat
Yank/paste via one unnamed register: `y` operator (yy/yw/yiw/y$), `p`/`P`,
with `x`/`d`/`c` also filling it so `dd`…`p` moves a line. Undo/redo (`u`,
`Ctrl-r`) as bounded full-buffer snapshots — one Insert session is one undo
group. `.` replays the last change from its recorded keystrokes, so it
repeats insert sessions like `ciwfoo<Esc>`.
2026-07-11 20:03:52 +02:00
Julien Calixte
8e4e3a7586 feat(keymap): add Ctrl-r redo intent
Decodes Ctrl+R (HID 0x15) to a new Key::Redo, the inverse of the editor's
`u`. Meaningful in Normal mode; ignored elsewhere.
2026-07-11 20:03:42 +02:00
4 changed files with 644 additions and 44 deletions

View File

@@ -5,36 +5,11 @@ This file holds the `macroplan` source block (below) and the per-version scope.
The user-facing requirements and engineering targets each release feeds into are
tracked in [`qfd.md`](qfd.md).
## Status — synced 2026-07-11
The editor **core** has been built 23 versions ahead of the device
**releases**, and is now **extracted into a host-testable `editor` crate** (plus
a `display` crate for the panel framebuffer) so `cargo test` exercises it off the
xtensa target. **v0.1 shipped 2026-07-11** (late against the 2026-06-29
baseline): SD storage, save, and **git publish are all wired into the app binary
and hardware-verified** (`:sync` commits on the SD `/sd/repo` and pushes to a
test repo), and the **boot splash (Spike 9) is confirmed on the panel** — a
vector `typoena`-in-a-circle shown at startup while the SD mounts, then the
editor comes up. **Cold boot verified at 4258 ms** (power-on → cursor,
2026-07-11; 742 ms under the ≤ 5 s gate). It first measured ~5.5 s; the fix was
to bring the editor up with a full-area partial (~630 ms) instead of a second
full refresh (~1.9 s) — panel confirmed clean, no ghosting. The 1-hour soak is
attested from real use; the remaining post-ship acceptance checks are power-pull
recovery, 1000-word no-drop, and `Ctrl-G`'s not-yet-built pull-then-retry
(→ v0.9). **v0.2 navigation is COMPLETE 2026-07-11** — Spike 13's on-panel gutter
refresh check passed (single-line edit repaints only rows at/below it, no extra
full refresh), closing the last gate. Beyond that, **v0.2.5 international input
(hardware-verified 2026-07-11)** and most of v0.6 Markdown already run. Version
numbers are unchanged — they track shippable device releases, not core progress.
Marks: `[x]` done in core · `[~]` partially done · `[ ]` not started. An
inline `(✓)` marks the done half of a split item.
## Macro-plan
Macroplan source — paste into the macroplan app to render the week-by-week
view. `original` dates are the June 2026 baseline and never move; slips get
appended as `reestimates`, per-item actuals live in the Status block above.
appended as `reestimates`, per-item actuals live in the Status block below.
```macroplan
title = "Typoena — macro plan"
@@ -64,8 +39,8 @@ learning = "Delivered 23 days early — ahead of its own start window. Dead-key
name = "v0.3 editing"
start = 2026-08-03
original = 2026-08-24
status = "on-track"
note = "Deletes, counts, and operator grammar done early in core; yank/paste, undo/redo remain."
delivered = 2026-07-11
learning = "Core complete 44 days early, host-tested. Register + yank/paste (yy/p/P), snapshot undo/redo (u/Ctrl-r, bounded 100 groups in PSRAM), and keystroke-recorded `.` repeat all landed 2026-07-11; the d/c operator grammar + text objects were already done ahead of schedule. Firmware version bumped to 0.3.0. On-device smoke-test still pending — pure editor-core, no new render surface."
[[feature]]
name = "v0.4 visual + ex"
@@ -113,6 +88,34 @@ week = 2026-06-29
requires = ["v0.1 it writes, it pushes"]
```
## Status — synced 2026-07-11
The editor **core** has been built 23 versions ahead of the device
**releases**, and is now **extracted into a host-testable `editor` crate** (plus
a `display` crate for the panel framebuffer) so `cargo test` exercises it off the
xtensa target. **v0.1 shipped 2026-07-11** (late against the 2026-06-29
baseline): SD storage, save, and **git publish are all wired into the app binary
and hardware-verified** (`:sync` commits on the SD `/sd/repo` and pushes to a
test repo), and the **boot splash (Spike 9) is confirmed on the panel** — a
vector `typoena`-in-a-circle shown at startup while the SD mounts, then the
editor comes up. **Cold boot verified at 4258 ms** (power-on → cursor,
2026-07-11; 742 ms under the ≤ 5 s gate). It first measured ~5.5 s; the fix was
to bring the editor up with a full-area partial (~630 ms) instead of a second
full refresh (~1.9 s) — panel confirmed clean, no ghosting. The 1-hour soak is
attested from real use; the remaining post-ship acceptance checks are power-pull
recovery, 1000-word no-drop, and `Ctrl-G`'s not-yet-built pull-then-retry
(→ v0.9). **v0.2 navigation is COMPLETE 2026-07-11** — Spike 13's on-panel gutter
refresh check passed (single-line edit repaints only rows at/below it, no extra
full refresh), closing the last gate. **v0.2.5 international input** is
hardware-verified (2026-07-11), and **v0.3 editing is complete in core** the same
day (register + yank/paste, snapshot undo/redo, `.` repeat — host-tested,
on-device smoke-test pending); the firmware crate is bumped to **0.3.0**. Most of
v0.6 Markdown also already runs. Version numbers track shippable device releases,
not raw core progress — the 0.3.0 bump reflects the v0.3 feature set being met.
Marks: `[x]` done in core · `[~]` partially done · `[ ]` not started. An
inline `(✓)` marks the done half of a split item.
---
## v0.1 — MVP: "it writes, it pushes" — [x]
@@ -248,19 +251,30 @@ v0.2 UTF-8-correct buffer and the ISO-8859-15 render font. Host-tested.
`` ` ``/`~` — Esc comes from the Caps tap — so grave/tilde accents and
Markdown code fences are reachable on a 60% board without a Fn layer.
## v0.3 — Vim editing — [~]
## v0.3 — Vim editing — [x]
**Status:** deletes and counts done; **yank/paste, undo/redo, and `.` repeat
remain** — there is no register or recorded-op mechanism yet. The `d`/`c`
operator grammar and text objects landed here ahead of schedule (the plan
had scheduled only `dd`/`dw`/`d$`).
**Status:** COMPLETE in core 2026-07-11, host-tested (64 editor + 28 keymap
tests). The three remaining pieces landed together: a single unnamed **register**
with `y`/`yy`/`p`/`P` (and `x`/`d`/`c` filling it, so `dd`…`p` moves a line),
**undo/redo** (`u`/`Ctrl-r`, snapshot-based, bounded to 100 groups in PSRAM — a
whole Insert session undoes as one group), and **`.` repeat** (keystroke-recorded,
so it replays an insert session like `ciwfoo<Esc>`). The `d`/`c` operator grammar
and text objects had already landed ahead of schedule. Pure editor-core work,
riding the already-verified draw/refresh path; **on-device smoke-test pending**
(nothing new to render, so low risk).
- [~] `x dd`, `dw dd d$` (✓); `yy p P` and repeat with `.` remain (need a register / recorded op)
- [ ] Undo / redo (`u`, `Ctrl-r`) — bounded history in PSRAM
- [x] `x dd`, `dw dd d$` (✓); `yy p P` (✓) and `.` repeat (✓) — register + a
keystroke-recorded last-change both landed 2026-07-11
- [x] Undo / redo (`u`, `Ctrl-r`) — snapshot history bounded to 100 groups in
PSRAM; one Insert session = one undo group
- [x] Numeric prefixes (`3dd`, `5j`)
- [x] Ahead of schedule: `c` change operator + text objects
(`ciw`, `di(`, `ca"`, … — inner/around, nesting-aware)
Known limits (deferred): `.` drops a *leading* count (`3x` then `.` deletes one;
a count inside an operator like `d2w` is kept); no named registers; `.` after an
aborted operator (`d<Esc>`) is a no-op.
## v0.4 — Visual mode + ex commands — [~]
**Status:** the `:` command-line mechanism is built (Command mode + status-strip

View File

@@ -89,11 +89,13 @@ pub enum Effect {
Pull,
}
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange).
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange /
/// `y`ank).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Op {
Delete,
Change,
Yank,
}
/// The editor state: buffer, caret, mode, viewport, and pending command state.
@@ -131,8 +133,42 @@ pub struct Editor {
/// is fmt → save → commit → push. Defaults on; the v0.5 `.typoena.toml`
/// `format_on_save` key will drive it.
format_on_save: bool,
/// The unnamed register: the last yanked or deleted text, replayed by
/// `p`/`P`. `y`, `d`, `c`, and `x` all fill it (vim's unnamed register), so
/// `dd`…`p` moves a line. There is one register — no named registers yet.
register: String,
/// Whether [`register`](Self::register) holds whole **lines** (from `yy`/`dd`,
/// stored with a trailing `\n`) rather than a character span (`yw`/`x`). It
/// decides how `p`/`P` reinsert: linewise pastes open a new line, charwise
/// paste inline next to the caret.
register_linewise: bool,
/// Undo history: `(text, caret)` snapshots, one per change-group, oldest
/// first. We snapshot the whole buffer rather than journal diffs — prose
/// notes are small and PSRAM is ample (8 MB), so a full copy per edit is
/// cheap and far simpler to reason about. Bounded to [`UNDO_DEPTH`] groups.
undo: Vec<(String, usize)>,
/// Redo history: states popped by `u`, replayable with `Ctrl-r`. Cleared the
/// moment a fresh edit records a new undo baseline (a new branch of history).
redo: Vec<(String, usize)>,
/// The last completed change, as the exact key sequence that produced it —
/// replayed verbatim by `.`. Recording keystrokes (rather than a structured
/// op) is what lets `.` repeat an insert session like `ciwfoo<Esc>`.
dot: Vec<Key>,
/// The change currently being recorded, if one is in progress (from the
/// initiating key through the key that completes it). Committed to [`dot`] on
/// completion. `None` between changes.
dot_recording: Option<Vec<Key>>,
/// True while `.` is replaying [`dot`], so the replayed keys are neither
/// re-recorded nor able to re-trigger `.`.
replaying: bool,
}
/// Maximum undo depth (change-groups). A full-buffer snapshot per group means
/// worst-case memory is `UNDO_DEPTH × buffer size`; for note-sized files on the
/// 8 MB PSRAM this is negligible, and prose editing rarely nears 100 groups
/// between saves anyway.
const UNDO_DEPTH: usize = 100;
/// One wrapped display line: its text and the buffer offset of its first char.
struct Line {
start: usize,
@@ -155,6 +191,13 @@ impl Editor {
keyboard_present: false,
notice: None,
format_on_save: true,
register: String::new(),
register_linewise: false,
undo: Vec::new(),
redo: Vec::new(),
dot: Vec::new(),
dot_recording: None,
replaying: false,
}
}
@@ -221,7 +264,28 @@ impl Editor {
// moment you move on — no timed repaint (which on e-ink would cost a
// full ~630 ms flash just to erase text).
self.notice = None;
match self.mode {
// `.` repeats the last change — intercepted before dispatch (in Normal,
// not mid-command, not already replaying) so the '.' keystroke itself is
// never inserted or recorded. In Insert mode '.' falls through as a
// literal character.
if !self.replaying
&& self.mode == Mode::Normal
&& self.pending_op.is_none()
&& self.pending_obj.is_none()
&& self.dot_recording.is_none()
&& key == Key::Char('.')
{
self.repeat_last_change();
return Effect::None;
}
// State before dispatch, so `record_dot` can read the transition a key
// caused (entered Insert, started an operator, …).
let before_mode = self.mode;
let before_pending = self.pending_op.is_some() || self.pending_obj.is_some();
let effect = match self.mode {
Mode::Insert => {
self.insert_key(key);
Effect::None
@@ -235,7 +299,64 @@ impl Editor {
Effect::None
}
Mode::Command => self.command_key(key),
};
if !self.replaying {
self.record_dot(key, before_mode, before_pending);
}
effect
}
/// Record `key` into the in-progress change for `.`. Called after dispatch
/// with the mode/operator state as it was *before*. A change is recorded
/// from its initiating key (an edit `x`/`p`/`P`, an operator `d`/`c`, or any
/// key that enters Insert) through the key that completes it — an operator
/// resolving back to Normal, or `Esc` ending an insert session. Yank (`y`)
/// and pure motions never start a recording, so `.` ignores them. The leading
/// count is not captured (so `3x` then `.` deletes one), but a count *inside*
/// an operator is (`d2w` records in full).
fn record_dot(&mut self, key: Key, before_mode: Mode, before_pending: bool) {
if self.dot_recording.is_some() {
self.dot_recording.as_mut().unwrap().push(key);
if self.change_complete() {
self.dot = self.dot_recording.take().unwrap();
}
return;
}
// Not yet recording: does this key begin a change? Only from a clean
// Normal state (no operator already pending — that key belongs to an
// in-progress command we'd have been recording already).
if before_mode == Mode::Normal && !before_pending {
let starts = matches!(key, Key::Char('x') | Key::Char('p') | Key::Char('P'))
|| self.mode == Mode::Insert
|| matches!(self.pending_op, Some(Op::Delete) | Some(Op::Change));
if starts {
self.dot_recording = Some(vec![key]);
if self.change_complete() {
self.dot = self.dot_recording.take().unwrap();
}
}
}
}
/// A recorded change is complete once we're back in Normal with no operator
/// still pending (an immediate edit, a resolved operator, or a finished
/// insert session).
fn change_complete(&self) -> bool {
self.mode == Mode::Normal && self.pending_op.is_none() && self.pending_obj.is_none()
}
/// `.` — replay the last recorded change. Sets [`replaying`](Self::replaying)
/// so the replayed keys are not themselves recorded and cannot recurse.
fn repeat_last_change(&mut self) {
if self.dot.is_empty() {
return;
}
self.replaying = true;
for k in self.dot.clone() {
self.handle(k);
}
self.replaying = false;
}
// --- Insert mode -------------------------------------------------------
@@ -250,8 +371,8 @@ impl Editor {
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.
Key::HalfPageDown | Key::HalfPageUp => {}
// you're typing. Redo (Ctrl-r) is likewise Normal-only here.
Key::HalfPageDown | Key::HalfPageUp | Key::Redo => {}
Key::Escape => {
self.mode = Mode::Normal;
// vim drops the caret onto the last inserted char.
@@ -280,6 +401,12 @@ impl Editor {
self.move_display_rows(-(HALF_PAGE as isize));
return;
}
// Ctrl-r redo: like any non-motion key it abandons a pending command.
Key::Redo => {
self.reset_pending();
self.redo();
return;
}
// Esc and other non-character events cancel any pending command.
_ => {
self.reset_pending();
@@ -316,8 +443,13 @@ impl Editor {
self.count = 0;
return;
}
'd' if op == Op::Delete => (0..n).for_each(|_| self.delete_current_line()),
'd' if op == Op::Delete => {
self.checkpoint(); // one snapshot for the whole `ndd`
self.register_lines(n); // yank the lines before removing them
(0..n).for_each(|_| self.delete_current_line());
}
'c' if op == Op::Change => self.change_current_line(),
'y' if op == Op::Yank => self.register_lines(n), // `yy` — caret stays put
'w' => {
let mut t = self.caret;
(0..n).for_each(|_| t = self.word_forward_pos(t));
@@ -374,7 +506,24 @@ impl Editor {
self.pending_g = true;
return;
}
'x' => (0..n).for_each(|_| self.delete_at_caret()),
'x' => {
self.checkpoint();
// Yank the chars we're about to delete (charwise), so `x`…`p`
// works. `x` never crosses the line end.
let s = self.caret;
let le = self.line_end(s);
let mut e = s;
for _ in 0..n {
if e >= le {
break;
}
e = self.next_char(e);
}
self.register = self.text[s..e].to_string();
self.register_linewise = false;
(0..n).for_each(|_| self.delete_at_caret());
}
'u' => self.undo(),
'd' => {
self.pending_op = Some(Op::Delete);
return;
@@ -383,25 +532,41 @@ impl Editor {
self.pending_op = Some(Op::Change);
return;
}
'i' => self.mode = Mode::Insert,
'y' => {
self.pending_op = Some(Op::Yank);
return;
}
'p' => self.paste_after(n),
'P' => self.paste_before(n),
// Entering Insert snapshots once here; the whole session (up to Esc)
// is one undo group, so `u` reverts an entire typed run at a time.
'i' => {
self.checkpoint();
self.mode = Mode::Insert;
}
'a' => {
self.checkpoint();
self.move_right_append();
self.mode = Mode::Insert;
}
'A' => {
self.checkpoint();
self.caret = self.line_end(self.caret);
self.mode = Mode::Insert;
}
'I' => {
self.checkpoint();
self.caret = self.line_start(self.caret);
self.mode = Mode::Insert;
}
'o' => {
self.checkpoint();
self.caret = self.line_end(self.caret);
self.insert_char('\n');
self.mode = Mode::Insert;
}
'O' => {
self.checkpoint();
let p = self.line_start(self.caret);
self.text.insert(p, '\n');
self.caret = p;
@@ -492,6 +657,7 @@ impl Editor {
/// lines, strip trailing whitespace) and keep the caret on roughly the same
/// line (buffer length changes, so exact restoration isn't possible).
fn format_buffer(&mut self) {
self.checkpoint(); // `:fmt` (and format-on-save) is undoable
let row = self.text[..self.caret].bytes().filter(|&b| b == b'\n').count();
self.text = format_markdown(&self.text);
// Land the caret at the start of the same logical line, clamped.
@@ -522,6 +688,55 @@ impl Editor {
self.pending_g = false;
}
// --- Undo / redo -------------------------------------------------------
/// Record the current `(text, caret)` as an undo baseline, at the *start* of
/// a change-group, and drop the redo history (a new edit forks the timeline).
/// Called once per change: on entering Insert (the whole session undoes
/// together), and before each Normal-mode edit (`x`, `dd`, operators, paste,
/// `:fmt`). If the buffer is unchanged since the last baseline it is a no-op,
/// so calling it more than once before a mutation records only one group.
fn checkpoint(&mut self) {
if self.undo.last().is_some_and(|(t, _)| t == &self.text) {
return; // nothing changed since the last baseline
}
self.undo.push((self.text.clone(), self.caret));
if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0); // drop the oldest group
}
self.redo.clear();
}
/// `u` — restore the most recent undo baseline, pushing the current state to
/// the redo stack. Lands in Normal mode with the caret clamped onto a char
/// boundary. No-op with nothing to undo.
fn undo(&mut self) {
if let Some((text, caret)) = self.undo.pop() {
self.redo.push((self.text.clone(), self.caret));
self.restore(text, caret);
}
}
/// `Ctrl-r` — reapply the most recently undone state. No-op with nothing to
/// redo.
fn redo(&mut self) {
if let Some((text, caret)) = self.redo.pop() {
self.undo.push((self.text.clone(), self.caret));
self.restore(text, caret);
}
}
/// Swap in a snapshot's buffer + caret, landing in Normal on a char boundary.
fn restore(&mut self, text: String, caret: usize) {
self.text = text;
self.caret = caret.min(self.text.len());
while self.caret > 0 && !self.text.is_char_boundary(self.caret) {
self.caret -= 1;
}
self.mode = Mode::Normal;
self.reset_pending();
}
// --- View mode ---------------------------------------------------------
fn view_key(&mut self, key: Key) {
@@ -782,18 +997,100 @@ impl Editor {
/// `cc` — clear the current line's text and drop into insert.
fn change_current_line(&mut self) {
self.checkpoint();
let ls = self.line_start(self.caret);
let le = self.line_end(self.caret);
self.register = format!("{}\n", &self.text[ls..le]); // linewise, like dd
self.register_linewise = true;
self.text.replace_range(ls..le, "");
self.caret = ls;
self.mode = Mode::Insert;
}
/// Yank `n` logical lines from the caret's line into the register, linewise
/// (each line carries its trailing `\n`, synthesised for a final line that
/// lacks one). Backs both `yy`/`nyy` and `dd`/`ndd`'s register capture; does
/// not move the caret or change the buffer.
fn register_lines(&mut self, n: usize) {
let ls = self.line_start(self.caret);
let mut e = ls;
for _ in 0..n {
let le = self.line_end(e);
e = if le < self.text.len() { le + 1 } else { le };
}
let mut block = self.text[ls..e].to_string();
if !block.ends_with('\n') {
block.push('\n'); // the last line has no trailing newline; add one
}
self.register = block;
self.register_linewise = true;
}
/// `p` — paste the register `n` times after the caret. Linewise content
/// opens new line(s) below the current line (caret to the first pasted
/// line); charwise content goes in just after the caret char (caret on the
/// last pasted char). No-op on an empty register.
fn paste_after(&mut self, n: usize) {
if self.register.is_empty() {
return;
}
self.checkpoint();
let content = self.register.repeat(n);
if self.register_linewise {
let le = self.line_end(self.caret);
if le < self.text.len() {
let at = le + 1; // start of the following line
self.text.insert_str(at, &content);
self.caret = at;
} else {
// Last line has no trailing newline: prefix one, drop the
// block's trailing newline so we don't leave a blank line.
let block = content.strip_suffix('\n').unwrap_or(&content);
self.text.insert_str(le, &format!("\n{block}"));
self.caret = le + 1;
}
} else {
let at = if self.text.is_empty() { 0 } else { self.next_char(self.caret) };
self.text.insert_str(at, &content);
self.caret = self.prev_char(at + content.len()); // onto the last char
}
}
/// `P` — paste the register `n` times before the caret. Linewise content
/// opens new line(s) above the current line; charwise content goes in at the
/// caret (caret on the last pasted char). No-op on an empty register.
fn paste_before(&mut self, n: usize) {
if self.register.is_empty() {
return;
}
self.checkpoint();
let content = self.register.repeat(n);
if self.register_linewise {
let ls = self.line_start(self.caret);
self.text.insert_str(ls, &content);
self.caret = ls;
} else {
let at = self.caret;
self.text.insert_str(at, &content);
self.caret = self.prev_char(at + content.len()); // onto the last char
}
}
/// Apply a pending operator over the buffer range `[start, end)` (order
/// independent). Delete removes it; Change removes it and enters insert.
/// independent). All three fill the unnamed register (charwise) with the
/// range. Yank copies and leaves the text; Delete removes it; Change removes
/// it and enters insert. Yank leaves the caret at the range start (vim `yw`),
/// the others land it there because the text collapses to that point.
fn apply_op(&mut self, op: Op, start: usize, end: usize) {
let s = start.min(end);
let e = start.max(end).min(self.text.len());
self.register = self.text[s..e].to_string();
self.register_linewise = false;
if op == Op::Yank {
self.caret = s;
return;
}
self.checkpoint(); // Delete/Change mutate — snapshot for undo
self.text.replace_range(s..e, "");
self.caret = s.min(self.text.len());
if op == Op::Change {
@@ -1236,6 +1533,7 @@ impl Editor {
match self.pending_op {
Some(Op::Delete) => s.push('d'),
Some(Op::Change) => s.push('c'),
Some(Op::Yank) => s.push('y'),
None => {}
}
match self.pending_obj {
@@ -1858,6 +2156,281 @@ mod tests {
assert_eq!(e.mode(), Mode::Command);
}
// ---- Register + yank / paste (v0.3) ----
#[test]
fn yy_then_p_opens_a_copy_of_the_line_below() {
let mut e = Editor::with_text("foo\nbar".to_string());
e.handle(Key::Char('g'));
e.handle(Key::Char('g')); // gg -> caret on line "foo"
e.handle(Key::Char('y'));
e.handle(Key::Char('y')); // yank the line, linewise
e.handle(Key::Char('p')); // paste it after the current line
assert_eq!(e.text(), "foo\nfoo\nbar");
}
#[test]
fn yy_then_capital_p_pastes_the_line_above() {
let mut e = Editor::with_text("foo\nbar".to_string()); // caret on line "bar"
e.handle(Key::Char('y'));
e.handle(Key::Char('y'));
e.handle(Key::Char('P'));
assert_eq!(e.text(), "foo\nbar\nbar");
}
#[test]
fn dd_then_p_moves_a_line_down() {
let mut e = Editor::with_text("one\ntwo\nthree".to_string());
e.handle(Key::Char('g'));
e.handle(Key::Char('g')); // caret on "one"
e.handle(Key::Char('d'));
e.handle(Key::Char('d')); // cut "one" into the register
e.handle(Key::Char('p')); // paste after "two"
assert_eq!(e.text(), "two\none\nthree");
}
#[test]
fn count_dd_captures_all_lines_for_paste() {
let mut e = Editor::with_text("a\nb\nc\nd".to_string());
e.handle(Key::Char('g'));
e.handle(Key::Char('g'));
e.handle(Key::Char('3'));
e.handle(Key::Char('d'));
e.handle(Key::Char('d')); // 3dd — cut three lines
assert_eq!(e.text(), "d");
e.handle(Key::Char('p')); // paste all three back below "d"
assert_eq!(e.text(), "d\na\nb\nc");
}
#[test]
fn x_then_p_replays_the_deleted_char_after_the_caret() {
let mut e = Editor::with_text("abc".to_string());
e.handle(Key::Char('0')); // caret on 'a'
e.handle(Key::Char('x')); // delete 'a' -> "bc", register = "a" (charwise)
e.handle(Key::Char('p')); // paste after 'b'
assert_eq!(e.text(), "bac");
}
#[test]
fn yw_yanks_charwise_and_p_inserts_after_the_caret() {
let mut e = Editor::with_text("foo bar".to_string());
e.handle(Key::Char('0'));
e.handle(Key::Char('y'));
e.handle(Key::Char('w')); // yank "foo " (word + trailing space), caret stays put
e.handle(Key::Char('p'));
assert_eq!(e.text(), "ffoo oo bar"); // charwise paste after the cursor char
}
#[test]
fn capital_p_pastes_a_char_before_the_caret() {
let mut e = Editor::with_text("abc".to_string());
e.handle(Key::Char('0'));
e.handle(Key::Char('x')); // register = "a", text "bc", caret on 'b'
e.handle(Key::Char('l')); // caret on 'c'
e.handle(Key::Char('P')); // paste "a" before 'c'
assert_eq!(e.text(), "bac");
}
#[test]
fn paste_with_an_empty_register_is_a_noop() {
let mut e = Editor::with_text("abc".to_string());
e.handle(Key::Char('p'));
e.handle(Key::Char('P'));
assert_eq!(e.text(), "abc");
}
// ---- Undo / redo (v0.3) ----
#[test]
fn undo_reverts_a_whole_insert_session_at_once() {
let mut e = Editor::new();
e.handle(Key::Char('i'));
for c in "hello".chars() {
e.handle(Key::Char(c));
}
e.handle(Key::Escape);
assert_eq!(e.text(), "hello");
e.handle(Key::Char('u'));
assert_eq!(e.text(), ""); // the entire typed run, not one char
assert_eq!(e.mode(), Mode::Normal); // undo always lands in Normal
}
#[test]
fn redo_reapplies_an_undone_change() {
let mut e = Editor::new();
e.handle(Key::Char('i'));
e.handle(Key::Char('x'));
e.handle(Key::Escape); // "x"
e.handle(Key::Char('u'));
assert_eq!(e.text(), "");
e.handle(Key::Redo); // Ctrl-r
assert_eq!(e.text(), "x");
}
#[test]
fn undo_reverts_dd() {
let mut e = Editor::with_text("one\ntwo".to_string());
e.handle(Key::Char('g'));
e.handle(Key::Char('g'));
e.handle(Key::Char('d'));
e.handle(Key::Char('d'));
assert_eq!(e.text(), "two");
e.handle(Key::Char('u'));
assert_eq!(e.text(), "one\ntwo");
}
#[test]
fn undo_reverts_x_and_restores_the_caret() {
let mut e = Editor::with_text("abc".to_string()); // caret on 'c'
e.handle(Key::Char('x'));
assert_eq!(e.text(), "ab");
e.handle(Key::Char('u'));
assert_eq!(e.text(), "abc");
assert_eq!(e.caret, 2); // caret came back to where the change began
}
#[test]
fn undo_reverts_a_paste() {
let mut e = Editor::with_text("foo\nbar".to_string());
e.handle(Key::Char('g'));
e.handle(Key::Char('g'));
e.handle(Key::Char('y'));
e.handle(Key::Char('y'));
e.handle(Key::Char('p'));
assert_eq!(e.text(), "foo\nfoo\nbar");
e.handle(Key::Char('u'));
assert_eq!(e.text(), "foo\nbar");
}
#[test]
fn a_fresh_edit_after_undo_clears_the_redo_history() {
let mut e = Editor::new();
e.handle(Key::Char('i'));
e.handle(Key::Char('a'));
e.handle(Key::Escape); // "a"
e.handle(Key::Char('u')); // -> ""
e.handle(Key::Char('i'));
e.handle(Key::Char('b'));
e.handle(Key::Escape); // new branch: "b"
e.handle(Key::Redo); // nothing to redo — the "a" branch is gone
assert_eq!(e.text(), "b");
}
#[test]
fn successive_undos_walk_the_history_back() {
let mut e = Editor::new();
e.handle(Key::Char('i'));
e.handle(Key::Char('a'));
e.handle(Key::Escape); // "a"
e.handle(Key::Char('A'));
e.handle(Key::Char('b'));
e.handle(Key::Escape); // "ab"
e.handle(Key::Char('u'));
assert_eq!(e.text(), "a");
e.handle(Key::Char('u'));
assert_eq!(e.text(), "");
}
#[test]
fn undo_with_empty_history_is_a_noop() {
let mut e = Editor::with_text("x".to_string());
e.handle(Key::Char('u'));
assert_eq!(e.text(), "x");
e.handle(Key::Redo);
assert_eq!(e.text(), "x");
}
// ---- `.` repeat (v0.3) ----
#[test]
fn dot_repeats_x() {
let mut e = Editor::with_text("abcde".to_string());
e.handle(Key::Char('0'));
e.handle(Key::Char('x')); // "bcde"
e.handle(Key::Char('.')); // "cde"
e.handle(Key::Char('.')); // "de"
assert_eq!(e.text(), "de");
}
#[test]
fn dot_repeats_dd() {
let mut e = Editor::with_text("a\nb\nc\nd".to_string());
e.handle(Key::Char('g'));
e.handle(Key::Char('g'));
e.handle(Key::Char('d'));
e.handle(Key::Char('d')); // delete "a"
e.handle(Key::Char('.')); // delete "b"
assert_eq!(e.text(), "c\nd");
}
#[test]
fn dot_repeats_dw() {
let mut e = Editor::with_text("foo bar baz".to_string());
e.handle(Key::Char('0'));
e.handle(Key::Char('d'));
e.handle(Key::Char('w')); // "bar baz"
e.handle(Key::Char('.')); // "baz"
assert_eq!(e.text(), "baz");
}
#[test]
fn dot_repeats_a_change_operator_with_its_inserted_text() {
// The reason `.` records keystrokes: it must replay `ciw` *and* the text
// typed in the insert session that followed.
let mut e = Editor::with_text("foo bar".to_string());
e.handle(Key::Char('0'));
e.handle(Key::Char('c'));
e.handle(Key::Char('i'));
e.handle(Key::Char('w'));
e.handle(Key::Char('X'));
e.handle(Key::Escape); // "X bar"
assert_eq!(e.text(), "X bar");
e.handle(Key::Char('w')); // caret onto "bar"
e.handle(Key::Char('.')); // repeat: change that word to "X" too
assert_eq!(e.text(), "X X");
}
#[test]
fn dot_repeats_a_paste() {
let mut e = Editor::with_text("x\na\nb".to_string());
e.handle(Key::Char('g'));
e.handle(Key::Char('g'));
e.handle(Key::Char('y'));
e.handle(Key::Char('y')); // yank line "x"
e.handle(Key::Char('p')); // "x\nx\na\nb"
e.handle(Key::Char('.')); // paste again below
assert_eq!(e.text(), "x\nx\nx\na\nb");
}
#[test]
fn dot_ignores_pure_motions() {
let mut e = Editor::with_text("abc".to_string());
e.handle(Key::Char('0'));
e.handle(Key::Char('l')); // motions only — nothing to repeat
e.handle(Key::Char('.'));
assert_eq!(e.text(), "abc");
}
#[test]
fn a_yank_does_not_become_the_dot_change() {
// `y` is not a `.`-repeatable change; the prior `x` must remain the dot.
let mut e = Editor::with_text("abcdef".to_string());
e.handle(Key::Char('0'));
e.handle(Key::Char('x')); // dot = x; "bcdef"
e.handle(Key::Char('y'));
e.handle(Key::Char('w')); // yank — must not overwrite the dot
e.handle(Key::Char('.')); // repeat the x, not the yank
assert_eq!(e.text(), "cdef");
}
#[test]
fn dot_in_insert_mode_is_a_literal_character() {
let mut e = Editor::new();
e.handle(Key::Char('i'));
e.handle(Key::Char('.'));
assert_eq!(e.text(), "."); // '.' only repeats from Normal
}
#[test]
fn text_getter_reflects_edits() {
let e = typed("hello");

View File

@@ -1,6 +1,6 @@
[package]
name = "firmware"
version = "0.1.0"
version = "0.3.0"
authors = ["Julien Calixte <juliencalixte@gmail.com>"]
edition = "2024"
resolver = "2"

View File

@@ -34,6 +34,9 @@ pub enum Key {
HalfPageDown,
/// Ctrl+U — scroll up half a screen (vim `Ctrl-u`).
HalfPageUp,
/// Ctrl+R — redo (vim `Ctrl-r`); the inverse of `u`. Meaningful in Normal;
/// ignored elsewhere.
Redo,
/// Caps Lock tapped on its own. A no-op for now; groundwork for a future
/// vim-style normal mode.
Escape,
@@ -146,6 +149,7 @@ fn translate(usage: u8, shift: bool, ctrl: bool, cmd: bool) -> Option<Key> {
0x1a if ctrl => return Some(Key::DeleteWord), // Ctrl+W, readline-style
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
_ => {}
}
@@ -385,6 +389,15 @@ mod tests {
assert_eq!(translate(0x29, true, false, false), Some(Key::Char('~')));
}
#[test]
fn translate_ctrl_navigation_and_redo_chords() {
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(0x15, false, false, false), Some(Key::Char('r')));
}
#[test]
fn translate_ctrl_or_cmd_swallows_plain_chars() {
assert_eq!(translate(0x04, false, true, false), None); // Ctrl+a