feat(editor): add Visual mode (v/V) with y/d/c, move View to gr

Charwise `v` and linewise `V` selection with yank/delete/change on the
span; motions and counts extend it. Read-only View moves off v/V to
`gr` (go-read). Selection renders reverse-video on the 1-bit panel.
Normal motions factored into a shared move_by. Firmware -> 0.4.0.
This commit is contained in:
Julien Calixte
2026-07-11 20:50:16 +02:00
parent 470a9d25d0
commit fa0ea56e1a
4 changed files with 518 additions and 38 deletions

View File

@@ -69,7 +69,7 @@ surface (mostly `usb_kbd.rs`) is in [`MEMORY_AUDIT.md`](MEMORY_AUDIT.md).
| HAL / runtime | `esp-idf-svc`, `esp-idf-hal` | std build: heap, threads, VFS, mbedtls, Wi-Fi stack. |
| Display | Custom SSD1683 driver (`src/epd.rs`) + `embedded-graphics` | Dual-controller 792×272 panel; dirty-rect partial refresh (~630 ms measured). |
| UI layer | Custom thin widget layer | Ratatui's API _shape_ without its char-grid terminal model ([ADR-002](docs/adr.md#adr-002-ui-strategy--custom-widgets-on-embedded-graphics-not-ratatui)). |
| Editor core | Custom, in-tree (`src/editor.rs`) | Modal (Normal / Insert / View / Command), motions, operators + text objects. Plain-ASCII buffer until the v0.2 UTF-8 work. |
| Editor core | Custom, in-tree (`src/editor.rs`) | Modal (Normal / Insert / Visual / VisualLine / View / Command), motions, operators + text objects. Plain-ASCII buffer until the v0.2 UTF-8 work. |
| USB host | `esp-idf` TinyUSB bindings | Boot-protocol HID; verified on hardware (Spike 4). |
| Git | **libgit2 via `git2`**, built as an esp-idf component with mbedTLS (`firmware/components/libgit2/`) | `gix` was the original pick but can't push over HTTPS — the [ADR-004](docs/adr.md#adr-004-git-implementation--gitoxide-gix) kill-switch fired ([postmortem](docs/postmortems/2026-07-05-spike7-gix-https-push.md)). On-device add → commit → push verified; ~16 s cold-`:sync` [latency breakdown](docs/notes/sync-latency.md). |
| TLS | `mbedtls` via `esp-idf` | GitHub HTTPS with the chain checked against embedded roots; ≈35 KB heap measured during handshake (Spike 6). |
@@ -106,7 +106,7 @@ source live in [`docs/macroplan.md`](docs/macroplan.md).
| [v0.2](docs/macroplan.md#v02--vim-navigation--) | Vim nav | Normal/Insert, motions, line numbers. |
| [v0.2.5](docs/macroplan.md#v025--international-input--) | Intl input | US-Intl dead keys: à é ê ç, `'`+space = `'`. |
| [v0.3](docs/macroplan.md#v03--vim-editing--) | Vim edit | `dd yy p`, undo/redo, counts. |
| [v0.4](docs/macroplan.md#v04--visual-mode--ex-commands--) | Visual + ex | `v V`, `:w :q :e` command line. |
| [v0.4](docs/macroplan.md#v04--visual-mode--ex-commands--) | Visual + ex | `v`/`V` select + `y d c`, `gr` read, `:` ex. |
| [v0.5](docs/macroplan.md#v05--file-palette--multi-file--) | Files | `Ctrl-P` over `/repo` + `/local`, buffers. |
| [v0.6](docs/macroplan.md#v06--markdown-affordances--) | Markdown | Headings, list continuation, soft-wrap. |
| [v0.7](docs/macroplan.md#v07--search--better-git--) | Search + git | `/` search, `:Gpull`. |

View File

@@ -46,8 +46,8 @@ learning = "Core complete 44 days early, host-tested and partially smoke-tested
name = "v0.4 visual + ex"
start = 2026-08-24
original = 2026-09-07
status = "on-track"
note = ": command-line mechanism and :fmt done early; Visual mode not started."
delivered = 2026-07-11
learning = "Core complete 58 days early, host-tested. Visual (v) and VisualLine (V) selection with y/d/c landed 2026-07-11 (charwise vim-inclusive of the char under the caret; linewise spans whole lines and pastes like yy/dd), plus the recorded v/V→Visual reassignment: the read-only View mode moved to `gr` (go-read). Selection is drawn as reverse-video cells on the 1-bit panel with the caret punched back to normal video so the active end stands out; 18 new editor tests (83 total). The `:` command mechanism and :fmt were already done; `:e <path>` was deliberately deferred to v0.5 where its multi-file/buffer-lifecycle machinery (Spikes 11/14) lives, rather than half-building file-open here. Firmware bumped to 0.4.0. On-device smoke-test of Visual still pending (pure editor-core, low risk)."
[[feature]]
name = "v0.5 palette + multi-file"
@@ -110,9 +110,13 @@ 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, and
partially smoke-tested on the panel: `dd`/`yy`/`Ctrl-r` good, a multi-line-paste
scroll bug found + fixed); 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.
scroll bug found + fixed). **v0.4 visual + ex is complete in core** the same day
too — charwise/linewise **Visual** selection (`v`/`V` with `y`/`d`/`c`), the
read-only View mode moved to `gr`, and the selection drawn as reverse-video on
the panel; `:e` was deferred to v0.5. Host-tested (83 editor tests); on-device
smoke-test pending. The firmware crate is bumped to **0.4.0**. Most of v0.6
Markdown also already runs. Version numbers track shippable device releases, not
raw core progress — the 0.4.0 bump reflects the v0.4 feature set being met.
Marks: `[x]` done in core · `[~]` partially done · `[ ]` not started. An
inline `(✓)` marks the done half of a split item.
@@ -280,22 +284,35 @@ 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 — [~]
## v0.4 — Visual mode + ex commands — [x]
**Status:** the `:` command-line mechanism is built (Command mode + status-strip
echo), but only `:fmt` exists — `:w :q :wq :e` remain. Visual mode is not
started.
**Status:** COMPLETE in core 2026-07-11, host-tested (83 editor tests), on-device
smoke-test pending. Charwise **Visual** (`v`) and linewise **VisualLine** (`V`)
selection landed with `y`/`d`/`c` on the span: charwise is vim-inclusive of the
char under the further caret, linewise spans whole logical lines and fills the
register linewise (so `Vy`…`p` copies a line, `Vd` deletes it like `dd`). Motions
(`h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d/u`) and counts extend the selection;
`v`/`V` toggle/switch submode, `Esc` cancels. The selection renders as
reverse-video cells (black fill, glyphs redrawn white) — the only selection
affordance on a 1-bit panel — with the caret cell punched back to *normal* video
so the active end stands out. The Normal-mode motions were factored into a shared
`move_by` helper so Normal and Visual can't drift.
**DECISION (2026-07-07):** `v`/`V` = **Visual** selection (vim-standard). The
read-only **View** (reading/scroll) mode currently bound to `v`/`V` moves off
those keys and gets its own trigger (exact key TBD when Visual lands). View mode
stays — it just frees `v`/`V` for Visual.
**DECISION (2026-07-07, resolved 2026-07-11):** `v`/`V` = **Visual** selection
(vim-standard). The read-only **View** (reading/scroll) mode that used to sit on
`v`/`V` moved to **`gr`** (go-read) — a `g`-prefixed gesture reusing the existing
pending-`g` machinery, no vim clash. View mode stays; `v`/`V` are now Visual.
- [ ] Visual char (`v`) and line (`V`) modes, `y d c` on selections
- [x] Visual char (`v`) and line (`V`) modes, `y d c` on selections — landed
2026-07-11 (18 new tests). Known limits (deferred): no `o` swap-ends, no
`x`/`s` operator aliases, no Visual `.` repeat, no `:'<,'>` range commands.
- [~] `:` command line (mechanism ✓; `:w`/`:wq`/`:x` save, `:fmt`/`:sync`/`:gl`
wired; `:e <path>` remains, `:q` deliberately dropped — nothing to quit
to). Command-line editing added 2026-07-11: Ctrl-W deletes the previous
word, Cmd-Backspace clears the line.
wired; `:q` deliberately dropped — nothing to quit to). Command-line
editing added 2026-07-11: Ctrl-W deletes the previous word, Cmd-Backspace
clears the line. **`:e <path>` deferred to v0.5** — opening another file
needs host file-IO + buffer switching, which is v0.5's multi-file work
(gated behind Spikes 11/14); half-building it here ahead of its
dirty-buffer handling wasn't worth it.
- [x] Ahead of schedule / unscheduled: `:fmt` Markdown formatter
(table alignment, blank-line collapse, trailing-whitespace strip)

View File

@@ -1,5 +1,6 @@
//! Modal text editor core: a vim-style buffer with Normal / Insert (edit) /
//! View (read-only) modes, rendered onto the e-paper [`Frame`].
//! Visual (selection) / View (read-only) modes, rendered onto the e-paper
//! [`Frame`].
//!
//! The buffer is a UTF-8 `String` (the keyboard's dead-key composer feeds it
//! accented Latin-9 characters). `caret` is a byte offset that always sits on a
@@ -61,7 +62,14 @@ pub enum Mode {
Normal,
/// Text entry — keys insert at the caret.
Insert,
/// Read-only reading: keys scroll the viewport, edits are locked out.
/// Charwise selection: an anchor is dropped at the caret (`visual_anchor`)
/// and motions extend the span; `y`/`d`/`c` act on it, `Esc`/`v` leave.
Visual,
/// Linewise selection (`V`): the span always covers whole logical lines
/// from the anchor's line to the caret's, whatever the columns.
VisualLine,
/// Read-only reading (entered with `gr`): keys scroll the viewport, edits
/// are locked out.
View,
/// `:` command line — keys accumulate a command shown in the status strip;
/// Enter runs it, Esc cancels. Handles `:fmt` (in-core) plus `:w`/`:sync`
@@ -114,8 +122,12 @@ pub struct Editor {
/// After an operator, an `i`/`a` text-object prefix awaiting the object
/// char. `Some(false)` = inner (`i`), `Some(true)` = around (`a`).
pending_obj: Option<bool>,
/// First `g` of a `gg` awaiting the second.
/// First `g` of a `gg`/`gr` awaiting the second.
pending_g: bool,
/// The fixed end of a Visual selection (byte offset), dropped when `v`/`V`
/// enters Visual and cleared on leaving. The selection spans from here to
/// the caret; `None` outside Visual/VisualLine.
visual_anchor: Option<usize>,
/// The `:` command line being typed (valid only in `Mode::Command`).
cmdline: String,
/// Word count as of the last stats refresh. The panel shows this snapshot,
@@ -186,6 +198,7 @@ impl Editor {
pending_op: None,
pending_obj: None,
pending_g: false,
visual_anchor: None,
cmdline: String::new(),
shown_words: 0,
keyboard_present: false,
@@ -294,6 +307,10 @@ impl Editor {
self.normal_key(key);
Effect::None
}
Mode::Visual | Mode::VisualLine => {
self.visual_key(key);
Effect::None
}
Mode::View => {
self.view_key(key);
Effect::None
@@ -477,8 +494,12 @@ impl Editor {
if self.pending_g {
self.pending_g = false;
if c == 'g' {
self.caret = 0;
match c {
'g' => self.caret = 0,
// `gr` (go-read): enter the read-only View/scroll mode. `v`/`V`
// used to trigger it but now belong to Visual selection.
'r' => self.mode = Mode::View,
_ => {}
}
self.count = 0;
return;
@@ -492,16 +513,6 @@ impl Editor {
let n = self.count.max(1);
match c {
'h' => (0..n).for_each(|_| self.move_left()),
'l' => (0..n).for_each(|_| self.move_right()),
'j' => (0..n).for_each(|_| self.move_down()),
'k' => (0..n).for_each(|_| self.move_up()),
'w' => (0..n).for_each(|_| self.caret = self.word_forward_pos(self.caret)),
'b' => (0..n).for_each(|_| self.caret = self.word_back_pos(self.caret)),
'e' => (0..n).for_each(|_| self.caret = self.word_end_pos(self.caret)),
'0' => self.caret = self.line_start(self.caret),
'$' => self.caret = self.line_end(self.caret),
'G' => self.caret = self.line_start(self.text.len()),
'g' => {
self.pending_g = true;
return;
@@ -572,18 +583,52 @@ impl Editor {
self.caret = p;
self.mode = Mode::Insert;
}
'v' | 'V' => self.mode = Mode::View,
// Drop an anchor at the caret and enter Visual (charwise `v`) /
// VisualLine (`V`); motions then extend the selection.
'v' => {
self.visual_anchor = Some(self.caret);
self.mode = Mode::Visual;
}
'V' => {
self.visual_anchor = Some(self.caret);
self.mode = Mode::VisualLine;
}
':' => {
self.reset_pending();
self.cmdline.clear();
self.mode = Mode::Command;
return;
}
_ => {}
// Any remaining char is either a shared motion (h/l/j/k/w/b/e/0/$/G)
// or unknown; `move_by` applies the former and ignores the latter.
_ => {
self.move_by(c, n);
}
}
self.count = 0;
}
/// Apply a plain caret motion shared by Normal and Visual — `h l j k`,
/// `w b e`, `0 $`, `G` — `n` times, returning whether `c` was a motion (and
/// so consumed). `gg`/`gr` are handled by their callers' pending-`g` state,
/// not here.
fn move_by(&mut self, c: char, n: usize) -> bool {
match c {
'h' => (0..n).for_each(|_| self.move_left()),
'l' => (0..n).for_each(|_| self.move_right()),
'j' => (0..n).for_each(|_| self.move_down()),
'k' => (0..n).for_each(|_| self.move_up()),
'w' => (0..n).for_each(|_| self.caret = self.word_forward_pos(self.caret)),
'b' => (0..n).for_each(|_| self.caret = self.word_back_pos(self.caret)),
'e' => (0..n).for_each(|_| self.caret = self.word_end_pos(self.caret)),
'0' => self.caret = self.line_start(self.caret),
'$' => self.caret = self.line_end(self.caret),
'G' => self.caret = self.line_start(self.text.len()),
_ => return false,
}
true
}
// --- Command mode (`:`) ------------------------------------------------
fn command_key(&mut self, key: Key) -> Effect {
@@ -737,6 +782,187 @@ impl Editor {
self.reset_pending();
}
// --- Visual mode -------------------------------------------------------
/// True while a Visual selection is active (charwise or linewise).
fn in_visual(&self) -> bool {
matches!(self.mode, Mode::Visual | Mode::VisualLine)
}
/// Dispatch a key in Visual/VisualLine. Motions extend the selection (the
/// anchor stays put, the caret moves); `y`/`d`/`c` act on the span and
/// leave Visual; `v`/`V` switch submode or toggle back to Normal; `Esc`
/// cancels. Counts and `gg`/`G` work as in Normal.
fn visual_key(&mut self, key: Key) {
let c = match key {
Key::Char(c) => c,
Key::HalfPageDown => {
self.count = 0;
self.move_display_rows(HALF_PAGE as isize);
return;
}
Key::HalfPageUp => {
self.count = 0;
self.move_display_rows(-(HALF_PAGE as isize));
return;
}
Key::Escape => {
self.exit_visual();
return;
}
// Enter/Backspace/etc. carry no Visual meaning; drop any count.
_ => {
self.count = 0;
self.pending_g = false;
return;
}
};
// `gg` — jump to the top, extending the selection.
if self.pending_g {
self.pending_g = false;
if c == 'g' {
self.caret = 0;
}
self.count = 0;
return;
}
// Count prefix, exactly as in Normal (a leading `0` is the motion).
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
return;
}
let n = self.count.max(1);
match c {
'g' => {
self.pending_g = true;
return;
}
// `v` toggles charwise off (or switches VisualLine → charwise);
// `V` toggles linewise off (or switches charwise → linewise).
'v' => {
if self.mode == Mode::Visual {
self.exit_visual();
} else {
self.mode = Mode::Visual;
}
}
'V' => {
if self.mode == Mode::VisualLine {
self.exit_visual();
} else {
self.mode = Mode::VisualLine;
}
}
'y' => self.visual_yank(),
'd' => self.visual_delete(),
'c' => self.visual_change(),
// Any other char: a shared motion extends the selection, or is a
// no-op. Unlike Normal, edit keys (`x`, `p`, …) aren't bound here.
_ => {
self.move_by(c, n);
}
}
self.count = 0;
}
/// The current selection as `(start, end, linewise)` byte offsets, `start <
/// end` (or equal on an empty buffer). Charwise is vim-inclusive of the char
/// under the further caret; linewise always spans whole logical lines from
/// the anchor's line through the caret's.
fn visual_span(&self) -> (usize, usize, bool) {
let anchor = self.visual_anchor.unwrap_or(self.caret);
let lo = anchor.min(self.caret);
let hi = anchor.max(self.caret);
if self.mode == Mode::VisualLine {
(self.line_start(lo), self.line_end(hi), true)
} else {
(lo, self.next_char(hi).min(self.text.len()), false)
}
}
/// Copy the selection into the unnamed register (linewise from `V`, charwise
/// otherwise), leave the caret at the selection start, and return to Normal.
fn visual_yank(&mut self) {
let (s, e, line) = self.visual_span();
self.register = self.selection_text(s, e, line);
self.register_linewise = line;
self.caret = s;
self.exit_visual();
}
/// Delete the selection (filling the register like `visual_yank`), leaving
/// the caret at the span start, and return to Normal. Linewise removes whole
/// lines including a bounding newline, mirroring `dd`.
fn visual_delete(&mut self) {
let (s, e, line) = self.visual_span();
self.register = self.selection_text(s, e, line);
self.register_linewise = line;
self.checkpoint();
let (ds, de) = self.delete_bounds(s, e, line);
self.text.replace_range(ds..de, "");
self.caret = if line {
self.line_start(ds.min(self.text.len()))
} else {
ds.min(self.text.len())
};
self.exit_visual();
}
/// Change the selection: delete it (filling the register) and drop into
/// Insert at the start. A linewise change clears the lines' text but leaves
/// one empty line to type on (like `cc`), rather than removing the line.
fn visual_change(&mut self) {
let (s, e, line) = self.visual_span();
self.register = self.selection_text(s, e, line);
self.register_linewise = line;
self.checkpoint();
// Linewise: replace only `s..e` (the text, keeping the final newline) so
// one blank line remains. Charwise: remove the exact span.
self.text.replace_range(s..e, "");
self.caret = s.min(self.text.len());
self.visual_anchor = None;
self.pending_g = false;
self.count = 0;
self.mode = Mode::Insert;
}
/// The register contents for a selection span: charwise is the raw slice;
/// linewise gets a synthesised trailing newline (as `yy`/`dd` store lines).
fn selection_text(&self, s: usize, e: usize, line: bool) -> String {
let mut block = self.text[s..e].to_string();
if line && !block.ends_with('\n') {
block.push('\n');
}
block
}
/// Byte range to actually remove for a delete. Charwise is the span as-is;
/// linewise also eats the trailing newline (or, on the last line, the
/// preceding one) so no blank line is left behind — matching `dd`.
fn delete_bounds(&self, s: usize, e: usize, line: bool) -> (usize, usize) {
if !line {
return (s, e);
}
if e < self.text.len() {
(s, self.next_char(e)) // eat the trailing '\n' at `e`
} else if s > 0 {
(self.prev_char(s), e) // last line: eat the preceding '\n' instead
} else {
(s, e) // whole buffer
}
}
/// Leave Visual for Normal, clearing the anchor and any pending state.
fn exit_visual(&mut self) {
self.mode = Mode::Normal;
self.visual_anchor = None;
self.pending_g = false;
self.count = 0;
}
// --- View mode ---------------------------------------------------------
fn view_key(&mut self, key: Key) {
@@ -1469,6 +1695,42 @@ impl Editor {
}
}
// Visual selection: reverse-video the selected cells (black fill, glyphs
// redrawn white). A second pass so the text loop above stays untouched;
// on a 1-bit panel this inversion is the only selection affordance.
if self.in_visual() {
let (ss, se, lw) = self.visual_span();
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
for (vis, li) in (self.scroll_top..end).enumerate() {
let y = vis as i32 * CH;
let rs = lay[li].start;
let re = rs + lay[li].text.len();
let (col_a, col_b) = if rs.max(ss) < re.min(se) {
let a = rs.max(ss);
let b = re.min(se);
(self.text[rs..a].chars().count(), self.text[rs..b].chars().count())
} else if lw && lay[li].text.is_empty() && rs >= ss && rs <= se {
// A blank line inside a linewise selection: a 1-cell mark so
// the empty row still reads as selected.
(0, 1)
} else {
continue;
};
let x = gx + col_a as i32 * CW;
let w = (col_b - col_a).max(1) as u32 * CW as u32;
Rectangle::new(Point::new(x, y), Size::new(w, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(&mut f)
.unwrap();
let seg: String = lay[li].text.chars().skip(col_a).take(col_b - col_a).collect();
if !seg.is_empty() {
Text::with_baseline(&seg, Point::new(x, y), inv, Baseline::Top)
.draw(&mut f)
.unwrap();
}
}
}
if crow >= self.scroll_top && crow < self.scroll_top + ROWS {
let x = gx + ccol.min(cols - 1) as i32 * CW;
let y = (crow - self.scroll_top) as i32 * CH;
@@ -1499,6 +1761,26 @@ impl Editor {
.draw(&mut f)
.unwrap();
}
Mode::Visual | Mode::VisualLine if cursor_on => {
// The selection painted this cell inverted; punch the caret
// back to normal video (white cell, black glyph) so the
// active end stands out from the rest of the selection.
Rectangle::new(Point::new(x, y), Size::new(CW as u32, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
.draw(&mut f)
.unwrap();
if let Some(ch) = lay[crow].text.chars().nth(ccol) {
let mut buf = [0u8; 4];
Text::with_baseline(
ch.encode_utf8(&mut buf),
Point::new(x, y),
text_style,
Baseline::Top,
)
.draw(&mut f)
.unwrap();
}
}
_ => {}
}
}
@@ -1560,6 +1842,8 @@ impl Editor {
let name = match self.mode {
Mode::Normal => "NORMAL",
Mode::Insert => "INSERT",
Mode::Visual => "VISUAL",
Mode::VisualLine => "V-LINE",
Mode::View => "VIEW",
Mode::Command => unreachable!(),
};
@@ -2103,7 +2387,8 @@ mod tests {
fn half_page_scrolls_viewport_in_view_mode() {
let mut e = Editor::with_text(vec!["a"; 40].join("\n"));
let caret_before = e.caret;
e.handle(Key::Char('v')); // Normal -> View
e.handle(Key::Char('g')); // `gr` -> View (v/V are now Visual)
e.handle(Key::Char('r'));
assert_eq!(e.mode(), Mode::View);
e.handle(Key::HalfPageDown);
assert_eq!(e.scroll_top(), HALF_PAGE);
@@ -2504,4 +2789,182 @@ mod tests {
e.handle(Key::Char('j')); // any key dismisses the snackbar
assert_eq!(e.notice, None);
}
// ---- Visual mode (v0.4) ----
/// Feed a run of characters as Normal-mode keys.
fn send(e: &mut Editor, s: &str) {
for c in s.chars() {
e.handle(Key::Char(c));
}
}
#[test]
fn v_enters_charwise_visual_and_anchors_at_the_caret() {
let mut e = Editor::with_text("hello".into());
e.caret = 2;
e.handle(Key::Char('v'));
assert_eq!(e.mode(), Mode::Visual);
assert_eq!(e.visual_anchor, Some(2));
}
#[test]
fn capital_v_enters_linewise_visual() {
let mut e = Editor::with_text("hello".into());
e.handle(Key::Char('V'));
assert_eq!(e.mode(), Mode::VisualLine);
}
#[test]
fn charwise_yank_is_inclusive_and_lands_the_caret_at_the_start() {
let mut e = Editor::with_text("hello world".into());
e.caret = 0;
send(&mut e, "vey"); // select "hello" (e -> last char of the word), yank
assert_eq!(e.mode(), Mode::Normal);
assert_eq!(e.caret, 0);
assert_eq!(e.register, "hello");
assert!(!e.register_linewise);
}
#[test]
fn vy_yanks_the_single_char_under_the_caret() {
let mut e = Editor::with_text("hello".into());
e.caret = 1;
send(&mut e, "vy");
assert_eq!(e.register, "e");
}
#[test]
fn charwise_delete_removes_the_span_and_fills_the_register() {
let mut e = Editor::with_text("hello world".into());
e.caret = 0;
send(&mut e, "ved"); // select "hello", delete
assert_eq!(e.text(), " world");
assert_eq!(e.caret, 0);
assert_eq!(e.register, "hello");
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn charwise_change_deletes_the_span_and_enters_insert() {
let mut e = Editor::with_text("hello".into());
e.caret = 0;
send(&mut e, "v$c"); // select the whole line, change
assert_eq!(e.mode(), Mode::Insert);
assert_eq!(e.text(), "");
send(&mut e, "bye");
assert_eq!(e.text(), "bye");
}
#[test]
fn count_in_visual_extends_the_selection() {
let mut e = Editor::with_text("abcdef".into());
e.caret = 0;
send(&mut e, "v2ld"); // select a,b,c (2l from a), delete
assert_eq!(e.text(), "def");
}
#[test]
fn linewise_delete_removes_the_whole_line_like_dd() {
let mut e = Editor::with_text("one\ntwo\nthree".into());
e.caret = e.text().find("two").unwrap();
send(&mut e, "Vd");
assert_eq!(e.text(), "one\nthree");
assert!(e.register_linewise);
assert_eq!(e.register, "two\n");
}
#[test]
fn linewise_selection_spans_multiple_lines_with_j() {
let mut e = Editor::with_text("a\nb\nc\nd".into());
e.caret = 0;
send(&mut e, "Vjd"); // select lines a and b, delete both
assert_eq!(e.text(), "c\nd");
}
#[test]
fn linewise_yank_then_paste_copies_the_line_below() {
let mut e = Editor::with_text("one\ntwo".into());
e.caret = 0;
send(&mut e, "Vy"); // yank line "one" linewise
assert_eq!(e.register, "one\n");
send(&mut e, "p");
assert_eq!(e.text(), "one\none\ntwo");
}
#[test]
fn linewise_change_clears_the_line_but_keeps_one_to_type_on() {
let mut e = Editor::with_text("one\ntwo\nthree".into());
e.caret = e.text().find("two").unwrap();
send(&mut e, "Vc");
assert_eq!(e.mode(), Mode::Insert);
assert_eq!(e.text(), "one\n\nthree"); // the line's text is gone, the row remains
send(&mut e, "X");
assert_eq!(e.text(), "one\nX\nthree");
}
#[test]
fn esc_leaves_visual_without_touching_the_buffer() {
let mut e = Editor::with_text("hello".into());
e.caret = 2;
send(&mut e, "vll");
e.handle(Key::Escape);
assert_eq!(e.mode(), Mode::Normal);
assert_eq!(e.text(), "hello");
assert_eq!(e.visual_anchor, None);
}
#[test]
fn v_toggles_charwise_visual_off() {
let mut e = Editor::with_text("hello".into());
send(&mut e, "vv");
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn capital_v_then_v_switches_to_charwise() {
let mut e = Editor::with_text("hello".into());
send(&mut e, "Vv");
assert_eq!(e.mode(), Mode::Visual);
}
#[test]
fn gr_enters_view_and_v_no_longer_does() {
let mut e = Editor::with_text("hello".into());
send(&mut e, "gr");
assert_eq!(e.mode(), Mode::View);
e.handle(Key::Escape);
e.handle(Key::Char('v'));
assert_eq!(e.mode(), Mode::Visual); // v is Visual now, not View
}
#[test]
fn visual_ops_do_not_clobber_the_dot_register() {
let mut e = Editor::with_text("abcdef".into());
e.caret = 0;
e.handle(Key::Char('x')); // dot = x ; "bcdef"
send(&mut e, "vld"); // a visual delete must not become the new dot
e.handle(Key::Char('.')); // repeats the x
// buffer after x -> "bcdef"; vld deletes "bc" -> "def"; . deletes 'd' -> "ef"
assert_eq!(e.text(), "ef");
}
#[test]
fn draw_inverts_the_selected_cells() {
let mut e = Editor::with_text("hello world".into());
e.caret = 0;
let normal = e.draw(true).bytes().to_vec();
send(&mut e, "ve"); // select "hello"
let visual = e.draw(true).bytes().to_vec();
assert_ne!(normal, visual); // the selection changed pixels
}
#[test]
fn draw_runs_for_a_linewise_selection_over_a_blank_line() {
let mut e = Editor::with_text("a\n\nb".into());
e.caret = 0;
send(&mut e, "Vjj"); // select all three rows, including the blank one
let _ = e.draw(true); // must not panic on the empty-row highlight path
assert_eq!(e.mode(), Mode::VisualLine);
}
}

View File

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