summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig85
-rw-r--r--docs/helix-keys.md291
-rw-r--r--next-next-steps.txt10
-rw-r--r--src/gui.zig2
-rw-r--r--src/modal.zig1102
-rw-r--r--src/pardes.zig1598
-rw-r--r--src/tty.zig1
-rw-r--r--test/hxcases/cases.jsonl360
-rw-r--r--test/hxcases/goldens.jsonl360
-rwxr-xr-xtest/hxcases/regen.sh16
-rw-r--r--test/hxcases/smoke.jsonl20
-rw-r--r--test/hxcases/waivers.jsonl2
-rw-r--r--test/hxdiff.zig355
-rw-r--r--test/snapshots/fileedit.golden2
-rw-r--r--test/snapshots/modal.golden36
-rw-r--r--test/snapshots/modal.snap2
-rw-r--r--test/snapshots/scrolloff.golden8
-rw-r--r--test/snapshots/scrolloff.snap5
-rw-r--r--transactions.txt59
19 files changed, 4096 insertions, 218 deletions
diff --git a/build.zig b/build.zig
index 29188d48..0ef09591 100644
--- a/build.zig
+++ b/build.zig
@@ -57,6 +57,17 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("src/main.zig"),
.link_libc = true,
});
+ // helix differential harness (test/hxdiff.zig): a second compilation of
+ // the sans-IO core, driven headlessly. Mirrors root_mod's wiring for
+ // everything src/pardes.zig pulls in (ghostty-vt, tree-sitter, zstbi, the
+ // option modules); imported as "pardes" by the harness exe below.
+ const hx_core_mod = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/pardes.zig"),
+ .link_libc = true,
+ });
+
// static archives the emcc link needs beyond the zig object (grammars, SDL)
var wasm_libs: std.ArrayList(std.Build.LazyPath) = .empty;
@@ -71,6 +82,7 @@ pub fn build(b: *std.Build) void {
opts.addOption(bool, "syntax_full_grammars", tree_sitter_grammars == .full);
opts.addOption(bool, "enable_tracy", tracy != null);
root_mod.addOptions("pardes_config", opts);
+ hx_core_mod.addOptions("pardes_config", opts);
// Tracy zones (src/tracy.zig): compile the client into the binary only when
// -Dtracy=<path> names a Tracy checkout; otherwise every zone is a no-op.
@@ -79,20 +91,22 @@ pub fn build(b: *std.Build) void {
// every thread — the app wedges before the first frame. Zones don't need
// any of it.
if (tracy) |tracy_path| {
- root_mod.addIncludePath(.{ .cwd_relative = tracy_path });
- root_mod.addCSourceFile(.{
- .file = .{ .cwd_relative = b.pathJoin(&.{ tracy_path, "public", "TracyClient.cpp" }) },
- .flags = &.{
- "-DTRACY_ENABLE=1",
- "-DTRACY_NO_SAMPLING",
- "-DTRACY_NO_CALLSTACK",
- "-DTRACY_NO_SYSTEM_TRACING",
- "-DTRACY_NO_CRASH_HANDLER",
- "-DTRACY_NO_CODE_TRANSFER",
- "-fno-sanitize=undefined",
- },
- });
- root_mod.link_libcpp = true;
+ for ([_]*std.Build.Module{ root_mod, hx_core_mod }) |mod| {
+ mod.addIncludePath(.{ .cwd_relative = tracy_path });
+ mod.addCSourceFile(.{
+ .file = .{ .cwd_relative = b.pathJoin(&.{ tracy_path, "public", "TracyClient.cpp" }) },
+ .flags = &.{
+ "-DTRACY_ENABLE=1",
+ "-DTRACY_NO_SAMPLING",
+ "-DTRACY_NO_CALLSTACK",
+ "-DTRACY_NO_SYSTEM_TRACING",
+ "-DTRACY_NO_CRASH_HANDLER",
+ "-DTRACY_NO_CODE_TRANSFER",
+ "-fno-sanitize=undefined",
+ },
+ });
+ mod.link_libcpp = true;
+ }
}
// tree-sitter: the zig bindings + C runtime, one static lib per grammar,
@@ -112,6 +126,7 @@ pub fn build(b: *std.Build) void {
};
}
root_mod.addImport("tree-sitter", tree_sitter_mod);
+ hx_core_mod.addImport("tree-sitter", tree_sitter_mod);
const Grammar = struct {
name: []const u8,
@@ -176,15 +191,18 @@ pub fn build(b: *std.Build) void {
if (g.scanner) lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/scanner.c"), .flags = &ts_cflags });
lib.root_module.addIncludePath(dep.path(g.src));
root_mod.linkLibrary(lib);
+ hx_core_mod.linkLibrary(lib);
if (is_emscripten) wasm_libs.append(b.allocator, lib.getEmittedBin()) catch @panic("OOM");
}
}
root_mod.addOptions("ts_queries", ts_queries);
+ hx_core_mod.addOptions("ts_queries", ts_queries);
}
// zstbi (C stb_image): image pane decode. zigimg crashes the 0.16 compiler.
const zstbi_dep = b.dependency("zstbi", .{ .target = target, .optimize = optimize });
root_mod.addImport("zstbi", zstbi_dep.module("root"));
+ hx_core_mod.addImport("zstbi", zstbi_dep.module("root"));
// ghostty-vt and vaxis both depend on uucode, but Zig forbids one source
// file in two modules — build a single uucode (our config, unpacked tables)
@@ -252,11 +270,13 @@ pub fn build(b: *std.Build) void {
ghostty_vt.addOptions("terminal_options", term_opts);
}
root_mod.addImport("ghostty-vt", ghostty_vt);
+ hx_core_mod.addImport("ghostty-vt", ghostty_vt);
}
// override uucode on vaxis LAST so it wins over ghostty's build() wiring.
vaxis_mod.addImport("uucode", uucode_mod);
root_mod.addImport("vaxis", vaxis_mod);
root_mod.addImport("uucode", uucode_mod);
+ hx_core_mod.addImport("uucode", uucode_mod);
// SDL3 shell wiring (-Dplatform=gui and web). The sdl package builds SDL3
// from source as a static lib + emits the SDL3/ headers; gui.zig @cImports
@@ -373,6 +393,43 @@ pub fn build(b: *std.Build) void {
run_snap.addArtifactArg(exe);
if (b.args) |args| run_snap.addArgs(args);
b.step("snap", "run the snapshot parity suite (-- --update regenerates goldens)").dependOn(&run_snap.step);
+
+ // helix differential harness: drive the sans-IO core headlessly over
+ // JSON-Lines cases, one contract result line per case on stdout —
+ // the pardes half of the pardes-vs-helix diff (helix's hx-harness on
+ // its pardes-harness branch speaks the same contract).
+ const hxdiff = b.addExecutable(.{
+ .name = "pardes-hxdiff",
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("test/hxdiff.zig"),
+ .link_libc = true,
+ }),
+ });
+ hxdiff.root_module.addImport("pardes", hx_core_mod);
+ b.installArtifact(hxdiff);
+ const run_hxdiff = b.addRunArtifact(hxdiff);
+ if (b.args) |args| run_hxdiff.addArgs(args) else {
+ // no args: run the checked-in differential suite offline —
+ // cases vs the helix goldens, waivers exempting the documented
+ // pardes-isms. Nonzero exit on any unwaivered mismatch.
+ run_hxdiff.addArgs(&.{
+ "test/hxcases/cases.jsonl",
+ "test/hxcases/goldens.jsonl",
+ "test/hxcases/waivers.jsonl",
+ });
+ run_hxdiff.setCwd(b.path("."));
+ }
+ b.step("hxdiff", "run the helix differential suite (-- <cases.jsonl> [goldens.jsonl [waivers.jsonl]])").dependOn(&run_hxdiff.step);
+
+ // modal.zig is pure std — its inline unit tests run here
+ const unit = b.addTest(.{ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/modal.zig"),
+ }) });
+ b.step("unit-test", "run the modal.zig unit tests").dependOn(&b.addRunArtifact(unit).step);
}
}
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
new file mode 100644
index 00000000..4a077c2c
--- /dev/null
+++ b/docs/helix-keys.md
@@ -0,0 +1,291 @@
+# pardes ↔ helix keybinding plan
+
+Living tracking doc for the helix-parity effort. Every key / table row in
+`helix/book/src/keymap.md` (checkout: `~/05-genizah/helix`, HEAD 278b24389)
+appears in exactly one of the three sections below. As of phase 5 every
+helix-equivalent row in A and B is differentially verified against real
+helix (see "Differential testing" at the bottom); the two waivers are
+listed in their rows.
+
+Code map (line numbers approximate): `src/pardes.zig` — `Key` ~296,
+`handleKey` ~1171 (intercept order is load-bearing, see comment there),
+`setPaneRange` ~1566 (helix range → pane state), `handleNormal` ~1736,
+`enterInsert` ~2171, `handleInsert` ~2278, `normalDelete` ~2600,
+`normalYank` ~2686, `normalPaste` ~2727, `normalChange` ~2799, undo ~3386.
+Pure text math: `src/modal.zig` (the `hx*` family is the helix-semantics
+layer: gap offsets + ranges over the flat text). Differential harness:
+`test/hxdiff.zig` + `test/hxcases/{cases,goldens,waivers}.jsonl` +
+`test/hxcases/regen.sh` — see "Differential testing" at the bottom.
+
+## Global semantic divergences (read first)
+
+- **FULL HELIX MOTION MODEL (phase 5 decision — overrides phase 2's
+ "keep point motions").** Motions select the range they traverse with
+ helix's landing conventions: after `w` on "alpha beta" the anchor is
+ (0,0) and the block cursor sits ON the space at col 5 (not vim's col
+ 6); a following `d` deletes "alpha ". Which motions select a range and
+ which collapse to a point follows helix exactly (word/find/till/
+ paragraph-class motions select; h/j/k/l-class char and line moves
+ collapse) — the checked-in helix goldens are the ground truth, verified
+ case-by-case by `zig build hxdiff`. Motion-created ranges live in
+ `pane.vsel` marked IMPLICIT (`vsel.explicit = false`); `v` extend mode,
+ `x`/`X`, terminal `n`/`N` (lookStep) and file `n`/`N` (searchStep) set
+ EXPLICIT ones. `d`/`c`/`y`/`~`/… act on the active selection either
+ way — that is the point of the model. The pardes-specific chords
+ (normal-mode Enter look / Tab execute, the clipboard-`p` intercept)
+ act on EXPLICIT selections only and fall back to the word under the
+ cursor when the selection is implicit motion residue. Mode is reported
+ "select" ONLY while `v` extend mode is on (helix keeps mode normal for
+ x/%/mi-created selections).
+- **Columns are byte offsets** (`cur_col`). ASCII-only harness cases
+ sidestep this; real UTF-8 parity is out of scope.
+- **Terminal panes: shell output is immutable.** Edit ops only ever
+ drop/alter typed insertion runs. All "To implement" edit ops follow the
+ same rule (motion/selection parts work on the full motion surface; the
+ mutating half is file-pane only or run-only).
+- **No soft wrap** in pardes file panes — visual line == textual line, so
+ helix's visual/textual distinction (`j` vs `gj`) collapses.
+- **Undo is snapshot-per-edit-op / per-insert-session**, not a
+ transaction log. Anything requiring replayable edits (`.`,
+ macros) needs new machinery.
+
+## A. Implemented today
+
+Parity column (phase 5): **helix-verified** = matched field-by-field
+(text/mode/cursor/anchor) against the helix goldens by `zig build hxdiff`;
+**pardes-specific** = key helix leaves unbound (or a deliberate pardes
+binding) — cannot mismatch a golden; **waived** = corpus-covered divergence
+with a reason in `test/hxcases/waivers.jsonl`.
+
+| Key | Behavior (pardes today) | Notes / quirks vs helix | Parity |
+| --- | --- | --- | --- |
+| `h` `j` `k` `l`, arrows | char left/right, line up/down — collapse the selection to a point (helix) | no soft wrap so `j`/`k` are both visual and textual; sticky col, phantom-line-blocked | helix-verified |
+| `w` `b` `e` | select to next word start / prev word start / next word end | full helix model incl. landing conventions (block cursor one before the next word after `w`) and newline/punct/EOF edges | helix-verified |
+| `W` `B` `E` | long-word (WORD) variants | same | helix-verified |
+| `Home` / `End` | line start / line end | matches `goto_line_start` / `goto_line_end` | helix-verified |
+| `0` / `$` / `^` | line start / line end / first non-ws | pardes extras — helix leaves these unbound (spells them `gh`/`gl`/`gs`); `0` is a count digit while a count is pending | pardes-specific |
+| `G` | bare `G` is a **no-op**; `<n>G` = goto line n | vim-ism removed (phase 5): helix `goto_line` only acts with a count; `ge` is goto-last-line | helix-verified |
+| `gg` / `<n>gg` | goto first line / line n | | helix-verified |
+| `ge` | goto last content line | matches `goto_last_line` (ignores the trailing empty line) | helix-verified |
+| `gh` / `gl` | line start / line end (last char, not the newline) | | helix-verified |
+| `Ctrl-d` / `Ctrl-u` | half page down/up, cursor follows | matches `page_cursor_half_down/up` | helix-verified |
+| `Ctrl-f` | full page down | matches `page_down`; file panes only get `Ctrl-b` (see next row) | helix-verified |
+| `Ctrl-b` | file panes: full page up. Terminal panes: **raw tty mode toggle** (`opts.tty_toggle`, configurable) | tty toggle is pardes-specific and wins on terminals; harness `pane:"tty"` cases avoid `Ctrl-b` | helix-verified (file) / pardes-specific (tty) |
+| `PageUp` / `PageDown` | full page | matches helix `page_up`/`page_down` (view scroll + cursor snap to the scrolloff edge) | helix-verified |
+| `zt` / `zz` / `zb` | scroll current line to top / center / bottom | matches `align_view_top/center/bottom` (helix harness pins scrolloff to pardes' 3) | helix-verified |
+| `i` `a` | insert at selection start / after selection end | helix semantics: `i` before the selection, `a` selects and appends after it | helix-verified |
+| `I` `A` | insert at first non-ws / line end | matches `insert_at_line_start` / `insert_at_line_end` | helix-verified |
+| `o` `O` | open line below / above, copying the current line's indent levels; `<n>o` opens n | matches `open_below`/`open_above` | helix-verified |
+| `v` | select (extend) mode: motions extend from the fixed anchor; `v` again exits KEEPING the selection | mode "select" is reported only while this is on (helix keeps mode normal for x/%/mi selections) | helix-verified |
+| `x` / `<n>x` | select current line / extend one (n) line(s) down, char-range through the newline, cursor ON the `\n` | col-0 vim-ism removed (phase 5); matches `extend_line_below`, mode stays normal | helix-verified |
+| `d` | delete selection (implicit or explicit); bare cursor = the 1-wide selection | always yanks (`Alt-d` noyank in B); terminals: drops typed runs only | helix-verified |
+| `c` | change (delete + insert mode); linewise selections open a fresh indented line | same shape as `d` | helix-verified |
+| `y` | yank selection; bare cursor yanks the 1-wide selection (char under cursor) | line-yank vim-ism removed (phase 5); yank keeps selection AND cursor (helix). Also emits `set_clipboard` (OSC 52 out) | helix-verified |
+| `u` / `U` | undo / redo | restores the pre-edit selection (helix); snapshot granularity, no `Alt-u`/`Alt-U` history walking (skipped) | helix-verified |
+| `p` (normal) | **async system-clipboard paste**: emits `get_clipboard`, reply arrives as `Event.paste` → sets yank register → pastes | pardes-specific plumbing; placement is helix semantics (B). hxdiff verifies it end-to-end through a fake clipboard that round-trips the yank | helix-verified |
+| `Esc` (normal) | cancel pending prefix / exit select mode; the selection is KEPT | helix Esc does not collapse selections; never drops to tty mode | helix-verified |
+| `Esc` (insert) | back to normal mode, cursor right after the insertion (no vim left-step) | | helix-verified |
+| `Enter` (normal) | acme **look** chord: EXPLICIT selection, else file-ish word under cursor | pardes-specific, keep (helix normal-mode Enter unbound). Covers helix `gf`. Implicit motion residue falls back to the cursor word | pardes-specific |
+| `Tab` (normal) | acme **execute** chord | pardes-specific, keep; explicit-selection rule as Enter | pardes-specific |
+| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — normal/tty modes only | pardes' own window handling (helix window mode skipped, section C). Insert mode owns `Ctrl-w` = delete-word-back; `Ctrl-w` while editing a tag (tags are always insert) is a no-op | pardes-specific |
+| `Alt-n` | new terminal below (any mode) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
+| `Alt-c` | move active terminal to a fresh column (any mode) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) |
+| `/` then `n`/`N` (file panes) | pardes' own grep-into-a-shell-pane search, n/N walk results (EXPLICIT selections) | KEEP, do not touch; not in the corpus (helix `/` is regex search) | pardes-specific |
+| `n`/`N` (terminal panes) | lookable-token motion over scrollback (EXPLICIT selections) | KEEP, do not touch | pardes-specific |
+| insert: printable text | file: real edit; terminal: typed run splice | | helix-verified (file) |
+| insert: `Enter` | newline (keeps indent) | matches `insert_newline` | helix-verified |
+| insert: `Backspace` (+ `Shift-Backspace`) | delete prev char, joins lines at col 0 | matches `delete_char_backward`; `Ctrl-h` alias in B | helix-verified |
+| insert: `Up` `Down` `Left` `Right` | move cursor | matches helix's "not recommended" insert arrows | helix-verified |
+
+## B. To implement
+
+Status: `todo` → set to `done (phase N)` as rows land. All rows verified
+against keymap.md. Edit ops on terminal panes obey the immutable-output
+rule (typed runs only) — same as `d`/`c` today.
+
+Phase 2 state added to `Pane`: `count` (accumulator, capped 0xffff),
+`pending2` (m-mode sub-key), `pending_ch` (mr's `<from>`), `find_op`/`find_ch`
+(Alt-. repeat). `pending` also holds `m` `[` `]` and the char-arg ops
+`f F t T r`. `Pardes.paste_before` carries `P` across the clipboard round
+trip. New pure text math in modal.zig (`findChar`, `matchBracket`,
+`paragraphFwd/Bwd`, textobject/surround ranges, `replaceRange/Chars`,
+`changeCase`, `joinLine`, `indentLines`, `adjustNumber`, `deleteSpan`,
+`advanceBy`) with inline tests; `zig build unit-test` runs them.
+
+### Counts
+
+| Key | Behavior | Notes | Status |
+| --- | --- | --- | --- |
+| `<n>` digit prefix | count for motions (h/j/k/l/arrows, w/b/e/W/B/E, gj/gk), `x`, `f/t` family + `Alt-.`, `G`/`gg`, `g|`, `]p`/`[p`, `]Space`/`[Space`, `J`, `>`/`<`, `Ctrl-a`/`Ctrl-x` | `0` stays line-start when NO count is pending, count-digit otherwise. `Pane.count` accumulator, capped at 0xffff (ponytail). Digits are literal char args while a prefix waits (`f4` finds '4'). Any non-prefix key consumes the count; prefix setters carry it into their continuation | helix-verified (phase 5) |
+| `<n>G`, `<n>gg` | goto line n (1-based, clamped), col 0 | plain `G` is a no-op like helix (vim-ism removed in phase 5; `ge` = last line) | helix-verified (phase 5) |
+
+### Movement
+
+| Key | Behavior | Notes | Status |
+| --- | --- | --- | --- |
+| `f<ch>` / `F<ch>` | find next / prev char (on it) | NOT confined to current line. `pending` holds the waiting op (`f`/`F`/`t`/`T`); ASCII targets only (byte columns — ponytail); not found = no move | helix-verified (phase 5) |
+| `t<ch>` / `T<ch>` | till next / prev char (one short of it) | `modal.findChar` handles all four + counts (nth occurrence, till applied after) | helix-verified (phase 5) |
+| `Alt-.` | repeat last `f`/`t`/`F`/`T` motion (`Pane.find_op`/`find_ch`), takes a count | decision: repeats ONLY the find family, not `m`/`[`/`]` (helix extends it there; marginal) | helix-verified (phase 5) |
+| `g|`, `<n>g|` | goto column n (1 = line start), clamped to the line | | helix-verified (phase 5) |
+| `gs` | goto first non-whitespace | alias of the `^` handler | helix-verified (phase 5) |
+| `gt` / `gc` / `gb` | goto screen top / center / bottom | view-relative (`pane.scroll()` + `pane.rows`), column kept (clamped) | helix-verified (phase 5) |
+| `gj` / `gk` | textual line down / up (+ count) | alias `j`/`k` — no soft wrap | helix-verified (phase 5) |
+| `PageUp` / `PageDown` | FULL page (was half) | `Ctrl-u`/`Ctrl-d` stay the half-page pair | helix-verified (phase 5) |
+
+### Changes
+
+| Key | Behavior | Notes | Status |
+| --- | --- | --- | --- |
+| `r<ch>` | replace selection/char with `<ch>`, newlines kept | terminals: replaces the typed-run byte under the cursor if any, else no-op (`runByteAt`) | helix-verified (phase 5) |
+| `R` | replace selection (or cursor char) with the yank register; pasted text becomes the selection, head on its last char | uses the INTERNAL yank (`p.yank`), no clipboard round trip; empty register = no-op; terminals: no-op | helix-verified (phase 5) |
+| `~` | switch case of selection/char, selection kept | terminals: run byte only | helix-verified (phase 5) |
+| `` ` `` | selection to lowercase | terminals: run byte only | helix-verified (phase 5) |
+| ``Alt-` `` | selection to uppercase | terminals: run byte only | helix-verified (phase 5) |
+| `J` | join lines in selection (or cur+next; helix ignores the count) | helix semantics: the newline + next line's leading whitespace collapse to ONE space (first line untrimmed); selection and cursor STAY where they were (vim's cursor-on-the-space removed in phase 5); terminals: no-op | helix-verified (phase 5) |
+| `>` / `<` | indent / unindent selected lines (count times) | width = `modal.INDENT_W` = **4 spaces** (helix harness pins its no-language indent style to Spaces(4) to match); `<` also takes one leading tab as a level; empty lines never indented (helix); selection kept, positions mapped through the edit (phase 5) | helix-verified (phase 5) |
+| `Ctrl-a` / `Ctrl-x` | increment / decrement the decimal int under the cursor by count | helix-style: under the cursor only (no vim forward scan), `-` handled, i64 saturating, zero-padding width preserved (phase 5); cursor to the last digit, selection kept; terminals: no-op | helix-verified (phase 5) |
+| `Alt-d` | delete without yanking | `normalDelete(yank=false)` | helix-verified (phase 5) |
+| `Alt-c` | change without yanking | **skipped (phase 2)**: the pardes `Alt-c` window op (move pane to fresh column, any mode) wins — it's load-bearing global UX and the parent contract forbids touching it. `Alt-d` + `i` covers the behavior. Corpus-covered and waived (`alt-c-window-op`) | waived (phase 5) |
+| `P` | paste before | same async clipboard round trip as `p`: `Pardes.paste_before` stashes the intent, the `Event.paste` reply consumes it | helix-verified (phase 5) |
+| `p`/`P` semantics | yank ending `\n` pastes as whole lines below/above the SELECTION's line span; else inline at the selection's outer edge. The paste (× count) becomes the selection, head on its last char (helix) | terminals keep the run-splice-at-cursor path (before/after collapses). Still not pty-snap-testable (the snapshot harness never answers OSC 52), but hxdiff verifies it end-to-end via its fake clipboard | helix-verified (phase 5) |
+
+### Selection manipulation
+
+| Key | Behavior | Notes | Status |
+| --- | --- | --- | --- |
+| `;` | collapse selection to cursor | drops vsel/msel, cursor stays | helix-verified (phase 5) |
+| `Alt-;` | flip anchor and head | vsel: swap cursor ↔ anchor; msel: cursor to the other end (r0/r1 swapped to keep the cursor-at-r1 invariant) | helix-verified (phase 5) |
+| `%` | select whole buffer | vsel anchor 0,0, cursor on the buffer's last char | helix-verified (phase 5) |
+| `X` | snap selection to line bounds | vsel → msel over its row span; bare cursor → 1-line msel; msel: already line-wise, no-op | helix-verified (phase 5) |
+| `Alt-x` | shrink selection to line bounds | vsel only: partial first/last lines drop out; nothing left collapses the selection; msel: no-op | helix-verified (phase 5) |
+
+### Match mode (`m` prefix) — plain-text, no tree-sitter
+
+| Key | Behavior | Notes | Status |
+| --- | --- | --- | --- |
+| `mm` | goto matching bracket | dumb text scan with nesting for `()[]{}<>`; ON a bracket only (no TS "nearest pair"); works on terminals via the motion surface | helix-verified (phase 5) |
+| `mi<pair>` / `ma<pair>` | select inside / around textobject | pairs `( ) [ ] { } < >` nesting-aware multi-line; quotes `' " `` ` `` ` **line-scoped** (plain-text strings don't span lines); `w`/`W` word run (+trailing ws around, leading if none); `p` blank-line block (+trailing blanks around). Empty inside (`()`) = no-op. Selections work on terminals; `Pane.pending2` holds the i/a/s/r/d sub-key | helix-verified (phase 5) |
+| `ms<ch>` | surround selection (or cursor char) with the `<ch>` pair; wrap incl. pair becomes the selection | either bracket names its pair; any other ASCII char wraps with itself; file panes only | helix-verified (phase 5) |
+| `mr<from><to>` | replace enclosing `<from>` pair chars with `<to>`'s | `Pane.pending_ch` holds `<from>` while `<to>` pends; file panes only | helix-verified (phase 5) |
+| `md<ch>` | delete the enclosing `<ch>` pair chars | file panes only | helix-verified (phase 5) |
+
+### View mode (`z` prefix) additions
+
+| Key | Behavior | Notes | Status |
+| --- | --- | --- | --- |
+| `zj` / `zk` (+ `zdown`/`zup`) | scroll view down / up one line, cursor untouched | `pane.scrollBy(±1)` | helix-verified (phase 5) |
+| `zc` | center (alias of `zz`) | | helix-verified (phase 5) |
+| `z Ctrl-d` / `z Ctrl-u` | half page with cursor | aliases of the bare handlers | helix-verified (phase 5) |
+| `z Ctrl-f` / `z Ctrl-b` / `z PageUp` / `z PageDown` | full page | aliases | helix-verified (phase 5) |
+
+### Unimpaired (plain-text subset)
+
+| Key | Behavior | Notes | Status |
+| --- | --- | --- | --- |
+| `]p` / `[p` | next / prev paragraph (+ count) | helix `goto_next/prev_paragraph`: selects to the boundary (anchor at the origin), blank-line-delimited blocks, count iterates | helix-verified (phase 5) |
+| `]Space` / `[Space` | add `<n>` blank lines below / above, cursor staying on its text line | file panes only (shell output immutable) | helix-verified (phase 5) |
+
+### Insert mode
+
+All the mutating insert keys follow the pane split: file panes edit content
+(`modal.deleteSpan` for the kills), terminal panes edit ONLY the typed run at
+the cursor (word = space-delimited within the run). Ctrl-h/j/d are normalized
+to Backspace/Enter/Delete and re-dispatched at the top of `handleInsert`. The
+text-insert path now requires no ctrl/alt so modifier combos can't leak their
+text.
+
+| Key | Behavior | Notes | Status |
+| --- | --- | --- | --- |
+| `Ctrl-w` / `Alt-Backspace` | delete word backward (ws then word-class run; at col 0 = the ordinary backspace join) | resolved per proposal: focus prefix restricted to normal/tty; insert owns `Ctrl-w` | helix-verified (phase 5) |
+| `Alt-d` / `Alt-Delete` | delete word forward (word run + trailing ws; at EOL eats the newline) | | helix-verified (phase 5) |
+| `Ctrl-u` | kill to line start | | helix-verified (phase 5) |
+| `Ctrl-k` | kill to line end | | helix-verified (phase 5) |
+| `Ctrl-h` | delete prev char (Backspace alias) | | helix-verified (phase 5) |
+| `Ctrl-d` / `Delete` | delete next char; at line end joins the next line up | `Key.delete` (0xF0009) added + `mapKey` in tty.zig/gui.zig (vaxis + SDLK) + `forwardKey` sends `ESC[3~` in tty mode | helix-verified (phase 5) |
+| `Ctrl-j` | insert newline (Enter alias) | | helix-verified (phase 5) |
+| `Home` / `End` | line start / line end past-the-last-char (`goto_line_end_newline`) | file panes (terminal insert cursor rides its run, as before) | helix-verified (phase 5) |
+| `PageUp` / `PageDown` | cursor page up / down, col kept (clamped to line) | file panes | helix-verified (phase 5) |
+| `Tab` | insert a literal tab (`Key.tab` case + the text path both land `\t`) | file content is correct; NOTE: file-pane RENDERING of a literal tab has no tab-stop expansion (pre-existing — any tab-containing file shows the same); helix smart-tab skipped | helix-verified (phase 5) |
+
+## C. Skipped
+
+| Key(s) | Helix behavior | Reason |
+| --- | --- | --- |
+| `?`, `*`, `Alt-*` | rsearch / selection-as-pattern | search — pardes has its own `/` n N (grep-based, kept as-is); helix regex search machinery not wanted |
+| `Space` mode, all rows (`f F e . b j g G k s S d D r a h ' w c C Alt-c p P y Y R / ?`) | pickers, LSP actions, clipboard menu, global search, palette | pickers/space-mode; clipboard already covered by pardes normal-`p` + OSC 52 yank |
+| Popup `Ctrl-u`/`Ctrl-d`, Completion menu, Signature help tables | LSP popups | LSP |
+| Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor |
+| `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list |
+| `gw` | word-label jump | label-jump overlay machinery, not core editing |
+| `g.` | goto last modification | jumplist/history position tracking |
+| Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags |
+| `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-n`/`Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | syntax-node selection | tree-sitter |
+| `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter |
+| `]g [g ]G [G` | git change jumps | needs VCS diff state |
+| `gd gD gy gr gi`, `=`, `]d [d ]D [D`, insert `Ctrl-x` | definition/refs/format/diagnostics/completion | LSP |
+| `"` `<reg>`, insert `Ctrl-r` | register select / insert | registers — single yank register only |
+| `Q` / `q` | record / replay macro | macros — needs replayable input log |
+| `Ctrl-i` `Ctrl-o` `Ctrl-s` (normal) | jumplist forward/back/save | jumplist |
+| `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes |
+| `s S Alt-s`, `Alt-minus Alt-_`, `& _`, `, Alt-,`, `C Alt-C`, `( ) Alt-( Alt-)`, `K Alt-K`, `Alt-:` | regex select/split, merge, align/trim, rotate, keep/remove, copy-to-line, ensure-forward | multicursor machinery — pardes has exactly one selection |
+| `Alt-J` | join + select the inserted space | marginal over `J`; multicursor-flavored |
+| `\|` `Alt-\|` `!` `Alt-!` `$` | shell pipe/insert/append/keep | shell — pardes executes via Tab / middle-click chords instead |
+| `:` | command mode | side-effects/file-ops — pardes builtins live in the tag |
+| `gf` | goto file under selection | covered by pardes Enter-look |
+| `Ctrl-c` | toggle comments | language-dependent |
+| `Ctrl-z` | suspend | pardes IS the terminal multiplexer |
+| insert `Ctrl-s` | commit undo checkpoint | undo is per-insert-session snapshots; no sub-session checkpoints |
+| `Shift-Tab` (insert), smart-tab semantics | insert tab / smart tab | smart-tab machinery; plain Tab-inserts-tab lands in B |
+| `Z` (sticky view mode) | persistent view mode | marginal; `z` one-shots suffice |
+| `zm` (view) | align middle horizontally | marginal even with hscroll |
+| `.` | repeat last insert | **deferred by decision**: needs recording the insert session's keystrokes and a replay path — a new subsystem; undo is whole-buffer snapshots with no edit log to piggyback on. Revisit after phases 2–5 if the log exists by then for another reason |
+| Select/extend mode section (prose) | `v` turns all motions into extenders, `n`/`N` keep selections | implemented for motions (phase 5: `pane.select` + the fixed anchor, differential-verified — see the `v` row in A); helix's search-`n`/`N` extension doesn't apply (pardes `n`/`N` are its own search/look ops) |
+
+## Differential testing (phase 5)
+
+pardes is diffed key-for-key against real helix (checkout
+`~/05-genizah/helix`, branch `pardes-harness`) over a shared JSON-Lines
+case corpus. Both sides speak the same contract: a case is
+`{"name","pane","text","keys"}` (helix key notation; `pane` is
+`"file"`/`"tty"`, ignored by helix), a result is the full final buffer
+text, the mode (`normal`/`insert`/`select` — select only for `v` extend
+mode), and the block-cursor positions of the primary selection's head
+(`cursor`) and other end (`anchor`), 0-based row + byte col.
+
+Files (all in `test/hxcases/`):
+
+- `cases.jsonl` — the corpus: 360 cases (255 file + 105 tty). Every
+ helix-equivalent row of sections A and B has at least a typical and an
+ edge case; pure navigation/selection bindings get a `pane:"tty"` twin
+ (same text/keys — helix on that text IS the oracle for tty navigation
+ parity). No tty twins for content-mutating ops (shell output is
+ immutable) or doc-marked terminal no-ops. tty case texts keep motions
+ inside the content (the tty motion surface trims trailing blank rows,
+ so ge/G-to-last-line style assertions stay off tty).
+- `goldens.jsonl` — checked-in helix results, regenerated by `regen.sh`
+ (runs the `hx-harness` binary from the helix checkout; override with
+ `$HX_HARNESS`). Only needed when cases change — the diff itself runs
+ offline.
+- `waivers.jsonl` — named exemptions, each with a reason. Two live ones:
+ `wiX-edit-drops-sel` (anchor-only: helix maps the selection through
+ insert-mode edits, pardes drops it on the first edit) and
+ `alt-c-window-op` (the pardes window op deliberately shadows helix
+ change-noyank).
+- `test/hxdiff.zig` builds `pardes-hxdiff`, which drives the sans-IO core
+ headlessly at 80x24 (22 body rows, matching helix's 22 text rows) with
+ a fake clipboard that round-trips the yank register (so `y`/`p`
+ differential-test end-to-end despite the async OSC 52 plumbing).
+
+Run it:
+
+ zig build hxdiff # cases + goldens + waivers, field-by-field;
+ # unwaivered mismatch = per-case report + exit 1
+ zig build hxdiff -- test/hxcases/cases.jsonl # results to stdout, no diff
+ sh test/hxcases/regen.sh # regenerate goldens
+
+The helix half (`hx-harness`) lives on the `pardes-harness` branch:
+a full headless `Application` with LSP/tree-sitter/auto-pairs/word-
+completion off, scrolloff pinned to pardes' 3, no-language indent style
+pinned to Spaces(4), smart-tab off, and the buffer-setup transaction
+committed as its own undo revision. Build:
+`cargo build --release -p helix-term --features helix-term/integration --bin hx-harness`.
diff --git a/next-next-steps.txt b/next-next-steps.txt
new file mode 100644
index 00000000..430f0a7c
--- /dev/null
+++ b/next-next-steps.txt
@@ -0,0 +1,10 @@
+- when clicking in a tty, don't change the mode, keep as it was
+- in insert/normal mode in a tty don't scroll for new input, only in tty mode
+- implement missing acme mouse chords
+- fix crash and decreasing font size on tty
+- for the web version make LOOK on a file focus it if it's loaded as a pane on the snapshot
+- when using a command that needs a selection, if there's none of the current pane, look for selections in other panes.
+- panes can be shinked to just have the tagline visible
+- when a new pane is going to open, if the chosen parent would leave it if less that 2 rows (without counting the tagline), then choose a different one, if none is found then just choose the largest one in height
+- parse the argv so that when you run pardes with no arguments: it opens in tty mode, if you pass a file it'll open that file focused (with a terminal split on the directory pardes was opened from) and if you pass a directory it'll set pardes cwd to there
+- the gui should have a different binary name, and non linux-x64 should have the os and tag along the name too
diff --git a/src/gui.zig b/src/gui.zig
index cc949cd1..d9ddb280 100644
--- a/src/gui.zig
+++ b/src/gui.zig
@@ -1373,6 +1373,7 @@ fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
c.SDLK_END => pardes.Key.end,
c.SDLK_PAGEUP => pardes.Key.page_up,
c.SDLK_PAGEDOWN => pardes.Key.page_down,
+ c.SDLK_DELETE => pardes.Key.delete,
else => blk: {
// letters / digits / punctuation only as a modifier shortcut;
// plain printable (incl. space) goes via TEXT_INPUT
@@ -2408,6 +2409,7 @@ fn mapKey(cp: u21) u21 {
vaxis.Key.end => pardes.Key.end,
vaxis.Key.page_up => pardes.Key.page_up,
vaxis.Key.page_down => pardes.Key.page_down,
+ vaxis.Key.delete => pardes.Key.delete,
else => cp,
};
}
diff --git a/src/modal.zig b/src/modal.zig
index 2a195432..742dbc6f 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -99,6 +99,9 @@ pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor {
}
pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor {
+ // the row can sit past the content (mouse click below a short pane's
+ // last line) — out of range reads as an empty line, like lineLenOf
+ if (c.row >= lines.len) return .{ .row = c.row, .col = 0 };
return .{ .row = c.row, .col = firstNonWs(lines[c.row]) };
}
@@ -210,6 +213,208 @@ pub fn pageUp(c: Cursor, page: usize) Cursor {
return .{ .row = if (c.row > page) c.row - page else 0, .col = c.col };
}
+// the character the cursor sits on; line terminators / EOF read as '\n'.
+fn charAt(lines: []const []const u8, c: Cursor) u8 {
+ if (c.row >= lines.len) return '\n';
+ const line = lines[c.row];
+ if (c.col >= line.len) return '\n';
+ return line[c.col];
+}
+
+// `f`/`F`/`t`/`T`: the nth occurrence of `ch` after/before the cursor, across
+// line boundaries (helix: not confined to the line). `till` stops one position
+// short of the hit. Returns null (no move) when there aren't n occurrences.
+pub fn findChar(lines: []const []const u8, c: Cursor, ch: u21, fwd: bool, till: bool, n: usize) ?Cursor {
+ if (ch > 0x7f) return null; // ponytail: ASCII targets only (byte columns)
+ const target: u8 = @intCast(ch);
+ var p = clampToChar(lines, c);
+ var left = if (n == 0) 1 else n;
+ while (left > 0) {
+ if (fwd) {
+ if (!stepFwd(lines, &p)) return null;
+ } else {
+ if (!stepBwd(lines, &p)) return null;
+ }
+ if (charAt(lines, p) == target) left -= 1;
+ }
+ if (till) {
+ if (fwd) _ = stepBwd(lines, &p) else _ = stepFwd(lines, &p);
+ }
+ return clampToChar(lines, p);
+}
+
+// `mm`: the bracket matching the one under the cursor (dumb text scan with
+// nesting; no tree-sitter). Null when the cursor is not on a bracket.
+pub fn matchBracket(lines: []const []const u8, c: Cursor) ?Cursor {
+ const opens = "([{<";
+ const closes = ")]}>";
+ const start = clampToChar(lines, c);
+ const ch = charAt(lines, start);
+ if (std.mem.indexOfScalar(u8, opens, ch)) |i| {
+ var depth: usize = 0;
+ var p = start;
+ while (true) {
+ const cc = charAt(lines, p);
+ if (cc == opens[i]) depth += 1;
+ if (cc == closes[i]) {
+ depth -= 1;
+ if (depth == 0) return p;
+ }
+ if (!stepFwd(lines, &p)) return null;
+ }
+ }
+ if (std.mem.indexOfScalar(u8, closes, ch)) |i| {
+ var depth: usize = 0;
+ var p = start;
+ while (true) {
+ const cc = charAt(lines, p);
+ if (cc == closes[i]) depth += 1;
+ if (cc == opens[i]) {
+ depth -= 1;
+ if (depth == 0) return p;
+ }
+ if (!stepBwd(lines, &p)) return null;
+ }
+ }
+ return null;
+}
+
+fn isBlank(line: []const u8) bool {
+ return firstNonWs(line) == line.len;
+}
+
+// `]p`: the start of the next blank-line-delimited block (or the last line).
+pub fn paragraphFwd(lines: []const []const u8, c: Cursor) Cursor {
+ var r = c.row;
+ while (r < lines.len and !isBlank(lines[r])) r += 1;
+ while (r < lines.len and isBlank(lines[r])) r += 1;
+ if (r >= lines.len) return gotoLast(lines);
+ return .{ .row = r, .col = 0 };
+}
+
+// `[p`: the start of the current block, or of the previous one when already
+// on a block start / a blank line.
+pub fn paragraphBwd(lines: []const []const u8, c: Cursor) Cursor {
+ if (c.row == 0 or lines.len == 0) return .{ .row = 0, .col = 0 };
+ var r = @min(c.row, lines.len) - 1;
+ while (r > 0 and isBlank(lines[r])) r -= 1;
+ while (r > 0 and !isBlank(lines[r - 1])) r -= 1;
+ return .{ .row = r, .col = 0 };
+}
+
+// ---- textobject / surround range math (mi/ma/ms/mr/md) ----
+
+// an inclusive char range [a, b] in document order
+pub const Range = struct { a: Cursor, b: Cursor };
+
+// the nearest pair of brackets enclosing the cursor (nesting-aware; the
+// cursor sitting ON a bracket belongs to that pair). Positions of the
+// bracket chars themselves.
+pub fn enclosingPair(lines: []const []const u8, c: Cursor, open: u8, close: u8) ?Range {
+ var a = clampToChar(lines, c);
+ if (charAt(lines, a) != open) {
+ var depth: usize = 0;
+ while (true) {
+ if (!stepBwd(lines, &a)) return null;
+ const ch = charAt(lines, a);
+ if (ch == close) depth += 1;
+ if (ch == open) {
+ if (depth == 0) break;
+ depth -= 1;
+ }
+ }
+ }
+ const b = matchBracket(lines, a) orelse return null;
+ return .{ .a = a, .b = b };
+}
+
+// the quote pair around the cursor, scanned on the cursor's line only
+// (plain-text strings don't span lines). Positions of the quote chars.
+pub fn enclosingQuote(lines: []const []const u8, c0: Cursor, q: u8) ?Range {
+ const c = clampToChar(lines, c0);
+ if (c.row >= lines.len) return null;
+ const line = lines[c.row];
+ var i: usize = 0;
+ while (i < line.len) {
+ const o = std.mem.indexOfScalarPos(u8, line, i, q) orelse return null;
+ const e = std.mem.indexOfScalarPos(u8, line, o + 1, q) orelse return null;
+ if (c.col < o) return null; // the cursor sits before any pair
+ if (c.col <= e) return .{ .a = .{ .row = c.row, .col = o }, .b = .{ .row = c.row, .col = e } };
+ i = e + 1;
+ }
+ return null;
+}
+
+// mi/ma over a bracket pair: `around` keeps the brackets, inside shrinks them
+// off (null when nothing is left between them).
+pub fn pairRange(lines: []const []const u8, c: Cursor, open: u8, close: u8, around: bool) ?Range {
+ const r = enclosingPair(lines, c, open, close) orelse return null;
+ if (around) return r;
+ return shrinkOffDelims(lines, r);
+}
+
+pub fn quoteRange(lines: []const []const u8, c: Cursor, q: u8, around: bool) ?Range {
+ const r = enclosingQuote(lines, c, q) orelse return null;
+ if (around) return r;
+ return shrinkOffDelims(lines, r);
+}
+
+fn shrinkOffDelims(lines: []const []const u8, r: Range) ?Range {
+ var a = r.a;
+ var b = r.b;
+ if (!stepFwd(lines, &a)) return null;
+ if (!stepBwd(lines, &b)) return null;
+ if (b.row < a.row or (b.row == a.row and b.col < a.col)) return null; // empty inside
+ return .{ .a = a, .b = b };
+}
+
+// miw/maw (and W): the word run under the cursor; `around` adds the trailing
+// whitespace on the line (or the leading run when there is none).
+pub fn wordRange(lines: []const []const u8, c0: Cursor, long: bool, around: bool) ?Range {
+ const c = clampToChar(lines, c0);
+ if (c.row >= lines.len) return null;
+ const line = lines[c.row];
+ if (line.len == 0 or c.col >= line.len) return null;
+ const k = kindAt(lines, c, long);
+ if (k == .ws) return null;
+ var lo = c.col;
+ while (lo > 0 and kindAt(lines, .{ .row = c.row, .col = lo - 1 }, long) == k) lo -= 1;
+ var hi = c.col;
+ while (hi + 1 < line.len and kindAt(lines, .{ .row = c.row, .col = hi + 1 }, long) == k) hi += 1;
+ if (around) {
+ var h2 = hi;
+ while (h2 + 1 < line.len and (line[h2 + 1] == ' ' or line[h2 + 1] == '\t')) h2 += 1;
+ if (h2 != hi) {
+ hi = h2;
+ } else {
+ while (lo > 0 and (line[lo - 1] == ' ' or line[lo - 1] == '\t')) lo -= 1;
+ }
+ }
+ return .{ .a = .{ .row = c.row, .col = lo }, .b = .{ .row = c.row, .col = hi } };
+}
+
+// mip/map: the blank-line-delimited block around the cursor; `around` adds the
+// trailing blank lines (or the leading ones when there are none).
+pub fn paragraphRange(lines: []const []const u8, c0: Cursor, around: bool) ?Range {
+ const c = clampToChar(lines, c0);
+ if (c.row >= lines.len or isBlank(lines[c.row])) return null;
+ var r0 = c.row;
+ while (r0 > 0 and !isBlank(lines[r0 - 1])) r0 -= 1;
+ var r1 = c.row;
+ while (r1 + 1 < lines.len and !isBlank(lines[r1 + 1])) r1 += 1;
+ if (around) {
+ var r2 = r1;
+ while (r2 + 1 < lines.len and isBlank(lines[r2 + 1])) r2 += 1;
+ if (r2 != r1) {
+ r1 = r2;
+ } else {
+ while (r0 > 0 and isBlank(lines[r0 - 1])) r0 -= 1;
+ }
+ }
+ const llen = lineLenOf(lines, r1);
+ return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = if (llen == 0) 0 else llen - 1 } };
+}
+
// ---- helpers used by motions + main.zig ----
// clamp a (possibly terminator/EOF) position onto a real character.
@@ -354,10 +559,13 @@ pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize,
const off = if (row + 1 < n) lineStartOffset(content, row + 1) else content.len;
var out: std.ArrayList(u8) = .empty;
try out.appendSlice(alloc, content[0..off]);
- if (off < content.len) {
+ if (off < content.len or (content.len > 0 and content[content.len - 1] == '\n')) {
+ // mid-buffer, or below the last line of newline-terminated content:
+ // the pasted block carries its own terminator
try out.appendSlice(alloc, text);
try out.append(alloc, '\n');
} else {
+ // below a terminator-less last line: open it with a newline instead
if (off > 0) try out.append(alloc, '\n');
try out.appendSlice(alloc, text);
}
@@ -365,6 +573,701 @@ pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize,
return out.toOwnedSlice(alloc);
}
+// the cursor position AFTER `text` inserted at `c` (one past its last char).
+pub fn advanceBy(c: Cursor, text: []const u8) Cursor {
+ var r = c.row;
+ var col = c.col;
+ for (text) |ch| {
+ if (ch == '\n') {
+ r += 1;
+ col = 0;
+ } else col += 1;
+ }
+ return .{ .row = r, .col = col };
+}
+
+// replace the inclusive char range [a, b] with `text` (R replace-with-yank).
+pub fn replaceRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, text: []const u8) ![]u8 {
+ const r = rangeBytes(content, a, b);
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0..r.s]);
+ try out.appendSlice(alloc, text);
+ try out.appendSlice(alloc, content[r.e..]);
+ return out.toOwnedSlice(alloc);
+}
+
+// `r<ch>`: overwrite every char in the inclusive range [a, b] with `ch` —
+// NEWLINES TOO (helix replace maps every grapheme, so `xrz` joins lines).
+pub fn replaceChars(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, ch: u8) ![]u8 {
+ const r = rangeBytes(content, a, b);
+ const out = try alloc.dupe(u8, content);
+ for (out[r.s..r.e]) |*p| p.* = ch;
+ return out;
+}
+
+// `~` / `` ` `` / ``Alt-` ``: case-map the inclusive range [a, b].
+pub const CaseOp = enum { toggle, lower, upper };
+
+pub fn changeCase(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, op: CaseOp) ![]u8 {
+ const r = rangeBytes(content, a, b);
+ const out = try alloc.dupe(u8, content);
+ for (out[r.s..r.e]) |*p| {
+ p.* = switch (op) {
+ .toggle => if (std.ascii.isUpper(p.*)) std.ascii.toLower(p.*) else std.ascii.toUpper(p.*),
+ .lower => std.ascii.toLower(p.*),
+ .upper => std.ascii.toUpper(p.*),
+ };
+ }
+ return out;
+}
+
+// `J`: join line `row` with the next — the newline and the next line's leading
+// whitespace become one space (helix join). `col` is the space's column.
+// Null when `row` is the last line.
+pub fn joinLine(alloc: std.mem.Allocator, content: []const u8, row: usize) !?struct { content: []u8, col: usize } {
+ if (row + 1 >= lineCount(content)) return null;
+ const a = lineSlice(content, row);
+ const next = lineSlice(content, row + 1);
+ const b = std.mem.trimStart(u8, next, " \t");
+ const start = lineStartOffset(content, row);
+ const rest = lineStartOffset(content, row + 1) + (next.len - b.len);
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0 .. start + a.len]);
+ try out.append(alloc, ' ');
+ try out.appendSlice(alloc, content[rest..]);
+ return .{ .content = try out.toOwnedSlice(alloc), .col = a.len };
+}
+
+// `>` / `<`: indent/unindent lines [r0, r1]. Fixed width — pardes has no
+// per-language indent config; 4 spaces, one tab counts as one level out.
+pub const INDENT_W = 4;
+
+pub fn indentLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize, add: bool) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitScalar(u8, content, '\n');
+ var row: usize = 0;
+ while (it.next()) |line| : (row += 1) {
+ if (row > 0) try out.append(alloc, '\n');
+ if (row >= @min(r0, r1) and row <= @max(r0, r1)) {
+ if (add) {
+ if (line.len != 0) try out.appendSlice(alloc, " " ** INDENT_W); // empty lines stay empty (helix)
+ try out.appendSlice(alloc, line);
+ } else {
+ var cut: usize = 0;
+ if (line.len > 0 and line[0] == '\t') {
+ cut = 1;
+ } else while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1;
+ try out.appendSlice(alloc, line[cut..]);
+ }
+ } else try out.appendSlice(alloc, line);
+ }
+ return out.toOwnedSlice(alloc);
+}
+
+// `Ctrl-a`/`Ctrl-x`: add `delta` to the decimal integer under the cursor
+// (helix: under the cursor only, no forward scan). Null when the cursor is
+// not on a number. The new cursor sits on the number's last digit.
+pub fn adjustNumber(alloc: std.mem.Allocator, content: []const u8, c: Cursor, delta: i64) !?struct { content: []u8, cur: Cursor } {
+ const line = lineSlice(content, c.row);
+ if (c.col >= line.len) return null;
+ var s = c.col;
+ var e = c.col;
+ if (!std.ascii.isDigit(line[s])) {
+ // sitting on the '-' of a negative number counts
+ if (!(line[s] == '-' and s + 1 < line.len and std.ascii.isDigit(line[s + 1]))) return null;
+ e = s + 1;
+ }
+ while (s > 0 and std.ascii.isDigit(line[s - 1])) s -= 1;
+ if (s > 0 and line[s - 1] == '-') s -= 1;
+ while (e < line.len and std.ascii.isDigit(line[e])) e += 1;
+ const val = std.fmt.parseInt(i64, line[s..e], 10) catch return null;
+ const nv = val +| delta;
+ var buf: [24]u8 = undefined;
+ // "{d}" prints '+' for positive signed ints — format the magnitude unsigned
+ const numstr = if (nv < 0)
+ std.fmt.bufPrint(&buf, "-{d}", .{@abs(nv)}) catch return null
+ else
+ std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null;
+ const off = lineStartOffset(content, c.row);
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0 .. off + s]);
+ try out.appendSlice(alloc, numstr);
+ try out.appendSlice(alloc, content[off + e ..]);
+ return .{ .content = try out.toOwnedSlice(alloc), .cur = .{ .row = c.row, .col = s + numstr.len - 1 } };
+}
+
+// delete the EXCLUSIVE span [a, b) — insert-mode kills. col may equal the
+// line length (the newline); a kill crossing it passes b = (row+1, 0).
+pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 {
+ const s = lineStartOffset(content, a.row) + @min(a.col, lineSlice(content, a.row).len);
+ const e = lineStartOffset(content, b.row) + @min(b.col, lineSlice(content, b.row).len);
+ if (e <= s) return alloc.dupe(u8, content);
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0..s]);
+ try out.appendSlice(alloc, content[e..]);
+ return out.toOwnedSlice(alloc);
+}
+
+// ---- helix range engine (phase 5) ----
+//
+// Gap-offset ranges over the FLAT buffer, ported faithfully from
+// helix-core/src/movement.rs + selection.rs @ 278b24389 (the genizah
+// checkout). Positions are gap offsets 0..=text.len — "char indices" in
+// helix terms, bytes here (ASCII-exact; UTF-8 stepped by sequence, matching
+// the byte-column convention of the rest of pardes). A range with
+// head > anchor selects [anchor, head) with the block cursor ON head-1;
+// head < anchor selects [head, anchor) with the cursor ON head. The
+// differential suite (test/hxcases, `zig build hxdiff`) pins every behavior
+// here key-for-key against a real helix.
+
+pub const HxRange = struct { anchor: usize, head: usize };
+
+/// one grapheme forward (UTF-8 sequence step), clamped at text.len
+pub fn nextGrapheme(text: []const u8, off: usize) usize {
+ if (off >= text.len) return text.len;
+ var o = off + 1;
+ while (o < text.len and (text[o] & 0xC0) == 0x80) o += 1;
+ return o;
+}
+
+pub fn prevGrapheme(text: []const u8, off: usize) usize {
+ if (off == 0) return 0;
+ var o = off - 1;
+ while (o > 0 and (text[o] & 0xC0) == 0x80) o -= 1;
+ return o;
+}
+
+/// the block cursor cell of a range (helix Range::cursor)
+pub fn hxCursor(text: []const u8, r: HxRange) usize {
+ return if (r.head > r.anchor) prevGrapheme(text, r.head) else r.head;
+}
+
+/// helix Range::put_cursor: park the block cursor at cell `idx`, optionally
+/// extending — the anchor shifts one grapheme when the head crosses it so the
+/// anchor CELL stays fixed.
+pub fn hxPutCursor(text: []const u8, r: HxRange, idx: usize, extend: bool) HxRange {
+ if (!extend) return .{ .anchor = idx, .head = idx };
+ var anchor = r.anchor;
+ if (r.head >= r.anchor and idx < r.anchor) {
+ anchor = nextGrapheme(text, r.anchor);
+ } else if (r.head < r.anchor and idx >= r.anchor) {
+ anchor = prevGrapheme(text, r.anchor);
+ }
+ if (anchor <= idx) return .{ .anchor = anchor, .head = nextGrapheme(text, idx) };
+ return .{ .anchor = anchor, .head = idx };
+}
+
+// ropey-style line math: len_lines = count('\n') + 1 — the slot after a
+// trailing '\n' is a real, empty last line and the cursor can sit there.
+pub fn hxLineCount(text: []const u8) usize {
+ return std.mem.count(u8, text, "\n") + 1;
+}
+
+pub fn hxLineOf(text: []const u8, off: usize) usize {
+ return std.mem.count(u8, text[0..@min(off, text.len)], "\n");
+}
+
+/// offset of line's terminator ('\n'), or text.len on the last line
+pub fn hxLineEndIdx(text: []const u8, line: usize) usize {
+ const s = lineStartOffset(text, line);
+ return if (std.mem.indexOfScalarPos(u8, text, s, '\n')) |nl| nl else text.len;
+}
+
+/// gap offset -> (row, col) cell
+pub fn hxPos(text: []const u8, off: usize) Cursor {
+ const row = hxLineOf(text, off);
+ return .{ .row = row, .col = @min(off, text.len) - lineStartOffset(text, row) };
+}
+
+/// (row, col) -> clamped gap offset; col == line length lands ON the '\n'
+pub fn hxOff(text: []const u8, c: Cursor) usize {
+ const row = @min(c.row, hxLineCount(text) - 1);
+ const s = lineStartOffset(text, row);
+ return @min(s + c.col, hxLineEndIdx(text, row));
+}
+
+pub const WordTarget = enum {
+ next_word_start,
+ next_word_end,
+ prev_word_start,
+ prev_word_end,
+ next_long_word_start,
+ next_long_word_end,
+ prev_long_word_start,
+ prev_long_word_end,
+};
+
+// helix categorize_char: Eol is its OWN category, distinct from Whitespace —
+// that distinction is load-bearing in reached_target.
+const HxCat = enum { word, punct, ws, eol };
+
+fn hxCat(b: u8) HxCat {
+ if (b == '\n' or b == '\r') return .eol;
+ if (b == ' ' or b == '\t' or b == 0x0b or b == 0x0c) return .ws;
+ if (std.ascii.isAlphanumeric(b) or b == '_' or b >= 0x80) return .word;
+ return .punct;
+}
+
+fn hxIsWs(b: u8) bool { // Rust char::is_whitespace (includes line endings)
+ const c = hxCat(b);
+ return c == .ws or c == .eol;
+}
+
+fn hxIsWordBoundary(a: u8, b: u8) bool {
+ return hxCat(a) != hxCat(b);
+}
+
+fn hxIsLongBoundary(a: u8, b: u8) bool {
+ const ca = hxCat(a);
+ const cb = hxCat(b);
+ if ((ca == .word and cb == .punct) or (ca == .punct and cb == .word)) return false;
+ return ca != cb;
+}
+
+fn hxReached(target: WordTarget, prev: u8, next: u8) bool {
+ return switch (target) {
+ .next_word_start, .prev_word_end => hxIsWordBoundary(prev, next) and (hxCat(next) == .eol or !hxIsWs(next)),
+ .next_word_end, .prev_word_start => hxIsWordBoundary(prev, next) and (!hxIsWs(prev) or hxCat(next) == .eol),
+ .next_long_word_start, .prev_long_word_end => hxIsLongBoundary(prev, next) and (hxCat(next) == .eol or !hxIsWs(next)),
+ .next_long_word_end, .prev_long_word_start => hxIsLongBoundary(prev, next) and (!hxIsWs(prev) or hxCat(next) == .eol),
+ };
+}
+
+fn wmIsPrev(t: WordTarget) bool {
+ return switch (t) {
+ .prev_word_start, .prev_word_end, .prev_long_word_start, .prev_long_word_end => true,
+ else => false,
+ };
+}
+
+/// w/b/e/W/B/E: helix word_move — each step selects the traversed span.
+pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarget) HxRange {
+ const is_prev = wmIsPrev(target);
+ if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0;
+ // block-cursor prep: collapse to the 1-wide cell at the head, pointing
+ // in the motion direction (the anchor of the input is irrelevant)
+ var r: HxRange = if (is_prev)
+ (if (r0.anchor < r0.head)
+ .{ .anchor = r0.head, .head = prevGrapheme(text, r0.head) }
+ else
+ .{ .anchor = nextGrapheme(text, r0.head), .head = r0.head })
+ else
+ (if (r0.anchor < r0.head)
+ .{ .anchor = prevGrapheme(text, r0.head), .head = r0.head }
+ else
+ .{ .anchor = r0.head, .head = nextGrapheme(text, r0.head) });
+ for (0..@max(1, count)) |_| {
+ const next = hxRangeToTarget(text, target, r, is_prev);
+ if (next.anchor == r.anchor and next.head == r.head) break;
+ r = next;
+ }
+ return r;
+}
+
+// port of CharHelpers::range_to_target — a char iterator walking away from
+// origin.head; when reversed, "next" reads the byte just behind the position.
+fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_prev: bool) HxRange {
+ var anchor = origin.anchor;
+ var head = origin.head;
+ var it = origin.head;
+ var prev_ch: ?u8 = if (is_prev)
+ (if (it < text.len) text[it] else null)
+ else
+ (if (it > 0) text[it - 1] else null);
+
+ // skip any initial newline characters
+ while (true) {
+ const ch: u8 = if (is_prev) blk: {
+ if (it == 0) break;
+ break :blk text[it - 1];
+ } else blk: {
+ if (it >= text.len) break;
+ break :blk text[it];
+ };
+ if (ch != '\n' and ch != '\r') break;
+ if (is_prev) it -= 1 else it += 1;
+ prev_ch = ch;
+ if (is_prev) head -|= 1 else head += 1;
+ }
+ if (prev_ch != null and hxCat(prev_ch.?) == .eol) anchor = head;
+
+ // find the target position
+ const head_start = head;
+ while (true) {
+ const next_ch: u8 = if (is_prev) blk: {
+ if (it == 0) break;
+ it -= 1;
+ break :blk text[it];
+ } else blk: {
+ if (it >= text.len) break;
+ const c = text[it];
+ it += 1;
+ break :blk c;
+ };
+ if (prev_ch == null or hxReached(target, prev_ch.?, next_ch)) {
+ if (head == head_start) anchor = head else break;
+ }
+ prev_ch = next_ch;
+ if (is_prev) head -|= 1 else head += 1;
+ }
+ return .{ .anchor = anchor, .head = head };
+}
+
+/// a ropey "line is a line ending" — the line has no content of its own
+fn hxLineIsEmpty(text: []const u8, line: usize) bool {
+ return lineStartOffset(text, line) == hxLineEndIdx(text, line);
+}
+
+/// ]p / [p: helix move_next_paragraph / move_prev_paragraph
+pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend: bool) HxRange {
+ const nlines = hxLineCount(text);
+ const cursor = hxCursor(text, r);
+ var line = hxLineOf(text, cursor);
+ if (fwd) {
+ const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1);
+ const last_char = prevGrapheme(text, nxt_start) == cursor;
+ const curr_empty = hxLineIsEmpty(text, line);
+ const next_empty = hxLineIsEmpty(text, @min(nlines - 1, line + 1));
+ const curr_empty_to_line = curr_empty and !next_empty;
+ // skip the character after the paragraph boundary
+ if (curr_empty_to_line and last_char) line += 1;
+ var l = line;
+ var last_line = l;
+ for (0..@max(1, count)) |_| {
+ while (l < nlines and !hxLineIsEmpty(text, l)) l += 1;
+ while (l < nlines and hxLineIsEmpty(text, l)) l += 1;
+ if (l == last_line) break;
+ last_line = l;
+ }
+ const head = if (l >= nlines) text.len else lineStartOffset(text, l);
+ const anchor = if (extend)
+ hxPutCursor(text, r, head, true).anchor
+ else if (curr_empty_to_line and last_char)
+ r.head
+ else
+ cursor;
+ return .{ .anchor = anchor, .head = head };
+ }
+ const first_char = lineStartOffset(text, line) == cursor;
+ const prev_empty = hxLineIsEmpty(text, line -| 1);
+ const curr_empty = hxLineIsEmpty(text, line);
+ const prev_empty_to_line = prev_empty and !curr_empty;
+ // skip the character before the paragraph boundary
+ if (prev_empty_to_line and !first_char) line += 1;
+ var l = line;
+ var last_line = l;
+ for (0..@max(1, count)) |_| {
+ while (l > 0 and hxLineIsEmpty(text, l - 1)) l -= 1;
+ while (l > 0 and !hxLineIsEmpty(text, l - 1)) l -= 1;
+ if (l == last_line) break;
+ last_line = l;
+ }
+ const head = lineStartOffset(text, l);
+ const anchor = if (extend)
+ hxPutCursor(text, r, head, true).anchor
+ else if (prev_empty_to_line and first_char)
+ cursor
+ else
+ r.head;
+ return .{ .anchor = anchor, .head = head };
+}
+
+/// j/k target: helix move_vertically — goal_col clamps to the line's content
+/// length, i.e. the cursor may land ON the '\n' of a shorter line.
+pub fn hxVertTarget(text: []const u8, pos: usize, down: bool, count: usize, goal_col: usize) usize {
+ const nlines = hxLineCount(text);
+ const line = hxLineOf(text, pos);
+ const nline = if (down) @min(line + @max(1, count), nlines - 1) else line -| @max(1, count);
+ const s = lineStartOffset(text, nline);
+ return @min(s + goal_col, hxLineEndIdx(text, nline));
+}
+
+/// f/F/t/T target cell. helix find_char: the exclusive (till) search starts
+/// one further out so repeats make progress; not-found = null (no move).
+pub fn hxFindTarget(text: []const u8, cursor: usize, ch: u8, fwd: bool, till: bool, count: usize) ?usize {
+ var left = @max(1, count);
+ if (fwd) {
+ const head = nextGrapheme(text, cursor);
+ var i = if (till) head + 1 else head;
+ if (i > text.len) return null;
+ while (i < text.len) : (i += 1) {
+ if (text[i] == ch) {
+ left -= 1;
+ if (left == 0) return if (till) i - 1 else i;
+ }
+ }
+ return null;
+ }
+ var i = if (till) cursor -| 1 else cursor;
+ while (i > 0) {
+ i -= 1;
+ if (text[i] == ch) {
+ left -= 1;
+ if (left == 0) return if (till) i + 1 else i;
+ }
+ }
+ return null;
+}
+
+// helix textobject.rs find_word_boundary
+fn hxFindWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usize {
+ var prev: HxCat = if (fwd)
+ (if (pos0 == 0) .ws else hxCat(text[pos0 - 1]))
+ else
+ (if (pos0 >= text.len) .ws else hxCat(text[pos0]));
+ var pos = pos0;
+ var it = pos0;
+ while (true) {
+ const ch: u8 = if (fwd) blk: {
+ if (it >= text.len) break;
+ const c = text[it];
+ it += 1;
+ break :blk c;
+ } else blk: {
+ if (it == 0) break;
+ it -= 1;
+ break :blk text[it];
+ };
+ const cat = hxCat(ch);
+ if (cat == .eol or cat == .ws) return pos;
+ if (!long and cat != prev and pos != 0 and pos != text.len) return pos;
+ if (fwd) pos += 1 else pos -|= 1;
+ prev = cat;
+ }
+ return pos;
+}
+
+/// miw/maw (and W): helix textobject_word — on whitespace it selects the
+/// whitespace run's boundary (a 1-wide cursor there)
+pub fn hxTextobjectWord(text: []const u8, r: HxRange, around: bool, long: bool) HxRange {
+ const pos = hxCursor(text, r);
+ const word_start = hxFindWordBoundary(text, pos, false, long);
+ const cat: HxCat = if (pos < text.len) hxCat(text[pos]) else .ws;
+ const word_end = if (cat == .ws or cat == .eol) pos else hxFindWordBoundary(text, pos + 1, true, long);
+ if (word_start == word_end or !around) return .{ .anchor = word_start, .head = word_end };
+ var end = word_end;
+ while (end < text.len and hxIsWs(text[end]) and hxCat(text[end]) != .eol) end += 1;
+ if (end > word_end) return .{ .anchor = word_start, .head = end };
+ var start = word_start;
+ while (start > 0 and hxIsWs(text[start - 1]) and hxCat(text[start - 1]) != .eol) start -= 1;
+ return .{ .anchor = start, .head = word_end };
+}
+
+/// mip/map: helix textobject_paragraph
+pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count: usize) HxRange {
+ const nlines = hxLineCount(text);
+ const cursor = hxCursor(text, r);
+ var line = hxLineOf(text, cursor);
+ const prev_empty = hxLineIsEmpty(text, line -| 1);
+ const curr_empty = hxLineIsEmpty(text, line);
+ const next_empty = line + 1 >= nlines or hxLineIsEmpty(text, line + 1);
+ const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1);
+ const last_char = prevGrapheme(text, nxt_start) == cursor;
+ const prev_empty_to_line = prev_empty and !curr_empty;
+ const curr_empty_to_line = curr_empty and !next_empty;
+
+ var line_back = line;
+ if (prev_empty_to_line or curr_empty_to_line) line_back += 1;
+ // do not include the current paragraph on a paragraph end (include next)
+ if (!(curr_empty_to_line and last_char)) {
+ while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1;
+ while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1;
+ }
+ if (curr_empty_to_line and last_char) line += 1;
+ const n = @max(1, count);
+ var count_done: usize = 0;
+ for (0..n) |_| {
+ var done = false;
+ while (line < nlines and !hxLineIsEmpty(text, line)) {
+ line += 1;
+ done = true;
+ }
+ while (line < nlines and hxLineIsEmpty(text, line)) line += 1;
+ if (done) count_done += 1;
+ }
+ // search one paragraph backwards when we ran off the end
+ if (count_done != n and line >= nlines) {
+ while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1;
+ while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1;
+ }
+ if (!around) {
+ // inside: drop the trailing whitespace paragraph
+ while (line > 0 and hxLineIsEmpty(text, line - 1)) line -= 1;
+ }
+ return .{
+ .anchor = lineStartOffset(text, line_back),
+ .head = if (line >= nlines) text.len else lineStartOffset(text, line),
+ };
+}
+
+test "hx textobject word and paragraph" {
+ const t = "alpha beta gamma\n";
+ // miw mid-word
+ var r = hxTextobjectWord(t, .{ .anchor = 8, .head = 9 }, false, false);
+ try std.testing.expectEqual(@as(usize, 6), r.anchor);
+ try std.testing.expectEqual(@as(usize, 10), r.head);
+ // maw on the space after "beta": collapses to the boundary
+ r = hxTextobjectWord(t, .{ .anchor = 10, .head = 11 }, true, false);
+ try std.testing.expectEqual(@as(usize, 10), r.anchor);
+ try std.testing.expectEqual(@as(usize, 10), r.head);
+ const t2 = "aa\n\ncc\n";
+ // mip from the blank line selects the NEXT paragraph
+ r = hxTextobjectParagraph(t2, .{ .anchor = 3, .head = 4 }, false, 1);
+ try std.testing.expectEqual(@as(usize, 4), r.anchor);
+ try std.testing.expectEqual(@as(usize, 7), r.head);
+}
+
+/// leading-whitespace visual width (tab -> next multiple of INDENT_W)
+pub fn hxIndentWidth(line: []const u8) usize {
+ var w: usize = 0;
+ for (line) |ch| {
+ if (ch == ' ') w += 1 else if (ch == '\t') w = (w / INDENT_W + 1) * INDENT_W else break;
+ }
+ return w;
+}
+
+/// full indent LEVELS of a line as spaces (helix indent_level_for_line:
+/// partial levels round down) — what o/O/insert-newline copy.
+pub fn hxIndentString(line: []const u8) []const u8 {
+ const level = hxIndentWidth(line) / INDENT_W;
+ const max = " "; // 8 levels is plenty (ponytail)
+ return max[0..@min(level * INDENT_W, max.len)];
+}
+
+/// helix Ctrl-a / Ctrl-x: increment the SELECTED text as a decimal integer.
+/// Zero-padding is preserved (width follows sign flips, helix-style).
+/// Ponytail: no 0x/0o/0b bases, no '_' separators — decimal only.
+pub fn hxIncrement(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]u8 {
+ if (frag.len == 0) return null;
+ const neg = frag[0] == '-';
+ const digits = if (neg) frag[1..] else frag;
+ if (digits.len == 0) return null;
+ for (digits) |ch| if (!std.ascii.isDigit(ch)) return null;
+ const val = std.fmt.parseInt(i128, frag, 10) catch return null;
+ const nv = val +| @as(i128, amount);
+ const pad = digits[0] == '0';
+ const neg_after = nv < 0;
+ // format_length includes the sign, adjusted when the sign flips
+ var flen: usize = frag.len;
+ if (neg and !neg_after) flen -= 1;
+ if (!neg and neg_after) flen += 1;
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(alloc);
+ var buf: [48]u8 = undefined;
+ // "{d}" prints '+' for positive signed ints — format the magnitude unsigned
+ const mag = std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null;
+ if (neg_after) try out.append(alloc, '-');
+ if (pad) {
+ const want = flen - @as(usize, if (neg_after) 1 else 0);
+ var i = mag.len;
+ while (i < want) : (i += 1) try out.append(alloc, '0');
+ }
+ try out.appendSlice(alloc, mag);
+ return try out.toOwnedSlice(alloc);
+}
+
+test "hx word moves match helix" {
+ const t = "alpha beta\n";
+ // w from a fresh 1-wide cursor selects "alpha " (cursor on the space)
+ var r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_start);
+ try std.testing.expectEqual(@as(usize, 0), r.anchor);
+ try std.testing.expectEqual(@as(usize, 6), r.head);
+ // e from the same start ends on 'a' of alpha
+ r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_end);
+ try std.testing.expectEqual(@as(usize, 5), r.head);
+ try std.testing.expectEqual(@as(usize, 0), r.anchor);
+ // b from the w result selects "alpha" backward
+ r = hxWordMove(t, .{ .anchor = 6, .head = 10 }, 1, .prev_word_start);
+ try std.testing.expectEqual(@as(usize, 10), r.anchor);
+ try std.testing.expectEqual(@as(usize, 6), r.head);
+ // 2w on "one two three": anchor comes from the last hop only
+ const t2 = "one two three\n";
+ r = hxWordMove(t2, .{ .anchor = 0, .head = 1 }, 2, .next_word_start);
+ try std.testing.expectEqual(@as(usize, 4), r.anchor);
+ try std.testing.expectEqual(@as(usize, 8), r.head);
+ // w at EOF collapses to a zero-width range at len
+ const t3 = "alpha\n";
+ r = hxWordMove(t3, .{ .anchor = 0, .head = 5 }, 1, .next_word_start);
+ try std.testing.expectEqual(@as(usize, 6), r.head);
+ try std.testing.expectEqual(@as(usize, 6), r.anchor);
+ // W treats punct runs as word chars
+ const t4 = "foo.bar baz\n";
+ r = hxWordMove(t4, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start);
+ try std.testing.expectEqual(@as(usize, 0), r.anchor);
+ try std.testing.expectEqual(@as(usize, 8), r.head);
+}
+
+test "hx put cursor keeps the anchor cell across crossings" {
+ const t = "abcdef\n";
+ // forward range [2,3) extended left of the anchor: anchor cell stays 2
+ var r = hxPutCursor(t, .{ .anchor = 2, .head = 3 }, 0, true);
+ try std.testing.expectEqual(@as(usize, 3), r.anchor);
+ try std.testing.expectEqual(@as(usize, 0), r.head);
+ try std.testing.expectEqual(@as(usize, 0), hxCursor(t, r));
+ // and back: cursor to 4 -> forward again, anchor gap back to 2
+ r = hxPutCursor(t, r, 4, true);
+ try std.testing.expectEqual(@as(usize, 2), r.anchor);
+ try std.testing.expectEqual(@as(usize, 5), r.head);
+}
+
+test "hx paragraph moves" {
+ const t = "aa\nbb\n\ncc\ndd\n\nee\n";
+ // ]p from the top selects through the blank line to the next block
+ var r = hxParaMove(t, .{ .anchor = 0, .head = 1 }, 1, true, false);
+ try std.testing.expectEqual(@as(usize, 0), r.anchor);
+ try std.testing.expectEqual(@as(usize, 7), r.head);
+ // [p from "ee" (line 6, offset 14) goes back to "cc" block start
+ r = hxParaMove(t, .{ .anchor = 14, .head = 15 }, 1, false, false);
+ try std.testing.expectEqual(@as(usize, 14), r.anchor);
+ try std.testing.expectEqual(@as(usize, 7), r.head);
+}
+
+test "hx vertical: goal col clamps onto the newline cell" {
+ const t = "abcdef\nab\nabcdef\n";
+ // from (0,5) down: line "ab" clamps to its '\n' at offset 9
+ try std.testing.expectEqual(@as(usize, 9), hxVertTarget(t, 5, true, 1, 5));
+ // two down with the same goal restores col 5
+ try std.testing.expectEqual(@as(usize, 15), hxVertTarget(t, 9, true, 1, 5));
+}
+
+test "hx find targets" {
+ const t = "abcabc\n";
+ try std.testing.expectEqual(@as(usize, 2), hxFindTarget(t, 0, 'c', true, false, 1).?);
+ try std.testing.expectEqual(@as(usize, 5), hxFindTarget(t, 0, 'c', true, false, 2).?);
+ try std.testing.expectEqual(@as(usize, 1), hxFindTarget(t, 0, 'c', true, true, 1).?);
+ // till repeat skips the adjacent target: from cell 1, next tc reaches 4
+ try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 1, 'c', true, true, 1).?);
+ try std.testing.expectEqual(@as(usize, 3), hxFindTarget(t, 5, 'a', false, false, 1).?);
+ try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 5, 'a', false, true, 1).?);
+ try std.testing.expectEqual(@as(?usize, null), hxFindTarget(t, 0, 'z', true, false, 1));
+}
+
+test "hx increment" {
+ const a = std.testing.allocator;
+ {
+ const r = (try hxIncrement(a, "15", 1)).?;
+ defer a.free(r);
+ try std.testing.expectEqualStrings("16", r);
+ }
+ {
+ const r = (try hxIncrement(a, "007", 1)).?;
+ defer a.free(r);
+ try std.testing.expectEqualStrings("008", r);
+ }
+ {
+ const r = (try hxIncrement(a, "-3", 1)).?;
+ defer a.free(r);
+ try std.testing.expectEqualStrings("-2", r);
+ }
+ {
+ const r = (try hxIncrement(a, "9", -10)).?;
+ defer a.free(r);
+ try std.testing.expectEqualStrings("-1", r);
+ }
+ try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "a 1", 1));
+ try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "", 1));
+}
+
// ---- file-pane modal step ----
// One keypress on an editable FILE pane. This is the single source of truth for
// file-modal behavior: main.zig delegates file panes here, and the tutor-driven
@@ -794,6 +1697,10 @@ test "char/line motions" {
try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 }));
// first non-ws
try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 }));
+ // cursor row past the content (mouse click below a short pane): no panic
+ try std.testing.expectEqual(Cursor{ .row = 24, .col = 0 }, firstNonWsOf(&lines, Cursor{ .row = 24, .col = 3 }));
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&lines, Cursor{ .row = 24, .col = 0 }));
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&[_][]const u8{}, Cursor{ .row = 5, .col = 0 }));
}
test "word motions w/b/e" {
@@ -947,6 +1854,199 @@ test "clearLine" {
try std.testing.expectEqualStrings("keep\n\nkeep2", r);
}
+test "findChar f/F/t/T across lines and counts" {
+ const lines = [_][]const u8{ "abcabc", "xa" };
+ const w = &lines;
+ // f: next occurrence, on it
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 1).?);
+ // count: 2fa crosses into the next line
+ try std.testing.expectEqual(Cursor{ .row = 1, .col = 1 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 2).?);
+ // t stops one short
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, true, 1).?);
+ // F backward, on it
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, false, 1).?);
+ // T backward stops one after
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, true, 1).?);
+ // not found: null, no move
+ try std.testing.expectEqual(@as(?Cursor, null), findChar(w, .{ .row = 0, .col = 0 }, 'z', true, false, 1));
+}
+
+test "matchBracket nesting both directions" {
+ const lines = [_][]const u8{"a (b (c) d) e"};
+ const w = &lines;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, matchBracket(w, .{ .row = 0, .col = 2 }).?);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, matchBracket(w, .{ .row = 0, .col = 10 }).?);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, matchBracket(w, .{ .row = 0, .col = 5 }).?);
+ try std.testing.expectEqual(@as(?Cursor, null), matchBracket(w, .{ .row = 0, .col = 0 }));
+}
+
+test "matchBracket across lines" {
+ const lines = [_][]const u8{ "if (x) {", " y", "}" };
+ const w = &lines;
+ try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, matchBracket(w, .{ .row = 0, .col = 7 }).?);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, matchBracket(w, .{ .row = 2, .col = 0 }).?);
+}
+
+test "paragraph motions" {
+ const lines = [_][]const u8{ "one", "two", "", "", "three", "four", "", "five" };
+ const w = &lines;
+ try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphFwd(w, .{ .row = 0, .col = 1 }));
+ try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 4, .col = 0 }));
+ // no next block: the last line
+ try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 7, .col = 0 }));
+ // from mid-block up to its start; from a start up to the previous block's
+ try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphBwd(w, .{ .row = 5, .col = 1 }));
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 4, .col = 0 }));
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 0, .col = 0 }));
+}
+
+test "pairRange inside/around, cursor on and between brackets" {
+ const lines = [_][]const u8{"f(a, (b))"};
+ const w = &lines;
+ const around = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', true).?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, around.a);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, around.b);
+ const inside = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', false).?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, inside.a);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, inside.b);
+ // cursor on the nested open picks the nested pair
+ const nested = pairRange(w, .{ .row = 0, .col = 5 }, '(', ')', false).?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.a);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.b);
+ // empty pair: no inside
+ const empty = [_][]const u8{"()"};
+ try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 0 }, '(', ')', false));
+ // not enclosed
+ try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 1 }, '[', ']', false));
+}
+
+test "quoteRange line-scoped" {
+ const lines = [_][]const u8{"say 'hi there' end"};
+ const w = &lines;
+ const r = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', true).?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, r.a);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, r.b);
+ const ri = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', false).?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, ri.a);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 12 }, ri.b);
+ // cursor after the pair: not enclosed
+ try std.testing.expectEqual(@as(?Range, null), quoteRange(w, .{ .row = 0, .col = 16 }, '\'', true));
+}
+
+test "wordRange inside/around" {
+ const lines = [_][]const u8{"one two.three"};
+ const w = &lines;
+ const r = wordRange(w, .{ .row = 0, .col = 1 }, false, false).?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, r.b);
+ // around eats the trailing spaces
+ const ra = wordRange(w, .{ .row = 0, .col = 1 }, false, true).?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, ra.b);
+ // long word spans the dot
+ const rl = wordRange(w, .{ .row = 0, .col = 6 }, true, false).?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, rl.a);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, rl.b);
+ // on whitespace: none
+ try std.testing.expectEqual(@as(?Range, null), wordRange(w, .{ .row = 0, .col = 3 }, false, false));
+}
+
+test "paragraphRange inside/around" {
+ const lines = [_][]const u8{ "a", "b", "", "c" };
+ const w = &lines;
+ const r = paragraphRange(w, .{ .row = 1, .col = 0 }, false).?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a);
+ try std.testing.expectEqual(Cursor{ .row = 1, .col = 0 }, r.b);
+ const ra = paragraphRange(w, .{ .row = 1, .col = 0 }, true).?;
+ try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, ra.b);
+ try std.testing.expectEqual(@as(?Range, null), paragraphRange(w, .{ .row = 2, .col = 0 }, false));
+}
+
+test "advanceBy" {
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, advanceBy(.{ .row = 0, .col = 2 }, "abc"));
+ try std.testing.expectEqual(Cursor{ .row = 2, .col = 1 }, advanceBy(.{ .row = 0, .col = 2 }, "a\nbc\nd"));
+}
+
+test "replaceRange" {
+ const a = std.testing.allocator;
+ const r = try replaceRange(a, "hello world", .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 4 }, "bye");
+ defer a.free(r);
+ try std.testing.expectEqualStrings("bye world", r);
+}
+
+test "replaceChars overwrites newlines too" {
+ const a = std.testing.allocator;
+ const r = try replaceChars(a, "ab\ncd", .{ .row = 0, .col = 1 }, .{ .row = 1, .col = 0 }, 'x');
+ defer a.free(r);
+ try std.testing.expectEqualStrings("axxxd", r);
+}
+
+test "changeCase" {
+ const a = std.testing.allocator;
+ const t = try changeCase(a, "aB cD", .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 4 }, .toggle);
+ defer a.free(t);
+ try std.testing.expectEqualStrings("Ab Cd", t);
+ const lo = try changeCase(a, "AB CD", .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 1 }, .lower);
+ defer a.free(lo);
+ try std.testing.expectEqualStrings("ab CD", lo);
+ const up = try changeCase(a, "ab cd", .{ .row = 0, .col = 3 }, .{ .row = 0, .col = 4 }, .upper);
+ defer a.free(up);
+ try std.testing.expectEqualStrings("ab CD", up);
+}
+
+test "joinLine" {
+ const a = std.testing.allocator;
+ const r = (try joinLine(a, "one\n two\nthree", 0)).?;
+ defer a.free(r.content);
+ try std.testing.expectEqualStrings("one two\nthree", r.content);
+ try std.testing.expectEqual(@as(usize, 3), r.col);
+ // last line: nothing to join
+ try std.testing.expectEqual(@as(?@TypeOf(r), null), try joinLine(a, "one", 0));
+}
+
+test "indentLines add and remove" {
+ const a = std.testing.allocator;
+ const r = try indentLines(a, "one\n\ntwo", 0, 2, true);
+ defer a.free(r);
+ try std.testing.expectEqualStrings(" one\n\n two", r);
+ const u = try indentLines(a, " one\n\ttwo\n three\nx", 0, 2, false);
+ defer a.free(u);
+ try std.testing.expectEqualStrings("one\ntwo\nthree\nx", u);
+}
+
+test "adjustNumber" {
+ const a = std.testing.allocator;
+ const r = (try adjustNumber(a, "x 41 y", .{ .row = 0, .col = 3 }, 1)).?;
+ defer a.free(r.content);
+ try std.testing.expectEqualStrings("x 42 y", r.content);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, r.cur);
+ // negative, cursor on the '-'
+ const n = (try adjustNumber(a, "v=-1;", .{ .row = 0, .col = 2 }, -1)).?;
+ defer a.free(n.content);
+ try std.testing.expectEqualStrings("v=-2;", n.content);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, n.cur);
+ // width change moves the last-digit column
+ const g = (try adjustNumber(a, "9", .{ .row = 0, .col = 0 }, 1)).?;
+ defer a.free(g.content);
+ try std.testing.expectEqualStrings("10", g.content);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, g.cur);
+ // not on a number
+ try std.testing.expectEqual(@as(?@TypeOf(r), null), try adjustNumber(a, "abc", .{ .row = 0, .col = 0 }, 1));
+}
+
+test "deleteSpan including the newline" {
+ const a = std.testing.allocator;
+ const r = try deleteSpan(a, "hello world", .{ .row = 0, .col = 2 }, .{ .row = 0, .col = 5 });
+ defer a.free(r);
+ try std.testing.expectEqualStrings("he world", r);
+ const j = try deleteSpan(a, "ab\ncd", .{ .row = 0, .col = 2 }, .{ .row = 1, .col = 0 });
+ defer a.free(j);
+ try std.testing.expectEqualStrings("abcd", j);
+ // empty span: copy
+ const e = try deleteSpan(a, "ab", .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 1 });
+ defer a.free(e);
+ try std.testing.expectEqualStrings("ab", e);
+}
+
test "pasteLineBelow" {
const content = "one\ntwo";
const a = std.testing.allocator;
diff --git a/src/pardes.zig b/src/pardes.zig
index 51965bd4..53ec4172 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -312,6 +312,7 @@ pub const Key = struct {
pub const end: u21 = 0xF0006;
pub const page_up: u21 = 0xF0007;
pub const page_down: u21 = 0xF0008;
+ pub const delete: u21 = 0xF0009;
};
fn is(key: Key, cp: u21) bool {
@@ -322,6 +323,10 @@ fn isC(key: Key, cp: u21) bool {
return key.cp == cp and key.ctrl and !key.alt;
}
+fn isA(key: Key, cp: u21) bool {
+ return key.cp == cp and !key.ctrl and key.alt;
+}
+
pub const Event = union(enum) {
key: Key,
mouse: Mouse,
@@ -405,12 +410,16 @@ pub const LineSel = struct {
r1: i32 = 0,
};
-/// A modal char-range selection (helix `v`): the anchor lives here, the head is
-/// the pane cursor. Mutually exclusive with LineSel.
+/// A modal char-range selection: the anchor lives here, the head is the pane
+/// cursor. Since the helix motion model landed, EVERY motion leaves one of
+/// these — `explicit` separates user-intent selections (v / x / X / terminal
+/// n/N / file-search n/N) from bare motion residue: the acme Enter/Tab chords
+/// only act on explicit ones. Mutually exclusive with LineSel.
pub const CharSel = struct {
active: bool = false,
row: i32 = 0,
col: i32 = 0,
+ explicit: bool = false,
};
/// Undo snapshot of a terminal pane's edit state (whole-state, dumb on purpose).
@@ -422,7 +431,16 @@ pub const EditSnap = struct {
cur_off: u16,
};
-/// A file pane's backing: content is gpa-owned; undo/redo are content snapshots.
+/// Undo snapshot of a file pane: content + the selection at commit time —
+/// helix undo restores the selection along with the text.
+pub const FileSnap = struct {
+ content: []u8,
+ cur_row: i32,
+ cur_col: i32,
+ vsel: CharSel,
+};
+
+/// A file pane's backing: content is gpa-owned; undo/redo are whole snapshots.
pub const File = struct {
path: []u8,
content: []u8,
@@ -431,8 +449,8 @@ pub const File = struct {
highlights: []u8 = &.{},
highlight_start: usize = 0,
syntax_dirty: bool = true,
- undo: std.ArrayList([]u8) = .empty,
- redo: std.ArrayList([]u8) = .empty,
+ undo: std.ArrayList(FileSnap) = .empty,
+ redo: std.ArrayList(FileSnap) = .empty,
};
/// An image pane's backing: the decoded+downscaled RGBA (kept so the petscii
@@ -469,7 +487,29 @@ pub const Pane = struct {
image: ?Image = null,
msel: LineSel = .{},
vsel: CharSel = .{},
- /// one pending prefix codepoint (0 = none): `g` and `z`
+ /// helix select/extend mode (`v`): motions extend the selection from its
+ /// fixed anchor instead of replacing it. Reported as mode "select";
+ /// pane.mode stays .normal (insert/tty transitions drop it).
+ select: bool = false,
+ /// sticky goal column for j/k runs (helix old_visual_position): any
+ /// non-vertical range write resets it to -1.
+ sticky_col: i32 = -1,
+ /// an `a` append session's original block-cursor cell: Esc backs the
+ /// cursor up one grapheme and rebuilds the appended-over selection from
+ /// here (helix doc.restore_cursor). Null outside `a` sessions.
+ append_at: ?struct { row: i32, col: i32 } = null,
+ /// match-mode second key in flight (`m` + i/a/s/r/d awaiting its char)
+ pending2: u21 = 0,
+ /// captured first char argument (`mr<from><to>` holds <from> here)
+ pending_ch: u21 = 0,
+ /// count prefix accumulator (0 = none). ponytail: capped at 0xffff —
+ /// nobody types a bigger count, and it keeps the row math in i32 range.
+ count: u32 = 0,
+ /// last f/F/t/T motion, for Alt-. repeat
+ find_op: u8 = 0,
+ find_ch: u21 = 0,
+ /// one pending prefix codepoint (0 = none): `g` `z` `m` `[` `]`, or an
+ /// op waiting for its char argument (`f` `F` `t` `T` `r`)
pending: u21 = 0,
/// `/` file search: the pattern is typed into the tag tail (no popup);
/// search_at is the tail length before " /" was appended (submit/cancel
@@ -683,6 +723,9 @@ pub const Pardes = struct {
/// modal yank register (gpa-owned); a yank also mirrors out to the system
/// clipboard via the set_clipboard effect.
yank: ?[]u8 = null,
+ /// a normal-mode `P` is in flight: the next Event.paste pastes BEFORE
+ /// (the get_clipboard round trip carries no argument)
+ paste_before: bool = false,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
/// where the shell wrote the last dump (shell reports back after
@@ -781,9 +824,9 @@ pub const Pardes = struct {
p.gpa.free(f.path);
p.gpa.free(f.content);
if (f.highlights.len > 0) p.gpa.free(f.highlights);
- for (f.undo.items) |s| p.gpa.free(s);
+ for (f.undo.items) |s| p.gpa.free(s.content);
f.undo.deinit(p.gpa);
- for (f.redo.items) |s| p.gpa.free(s);
+ for (f.redo.items) |s| p.gpa.free(s.content);
f.redo.deinit(p.gpa);
}
pane.tag_tail.deinit(p.gpa);
@@ -902,9 +945,10 @@ pub const Pardes = struct {
.key => |key| p.handleKey(key),
.mouse => |m| p.handleMouse(m),
.paste => |bytes| {
- // the OSC 52 system-clipboard reply for a normal-mode `p`
+ // the OSC 52 system-clipboard reply for a normal-mode `p`/`P`
p.setYank(bytes);
- if (p.panes[p.active]) |pane| p.normalPaste(pane);
+ if (p.panes[p.active]) |pane| p.normalPaste(pane, p.paste_before);
+ p.paste_before = false;
},
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
@@ -1133,7 +1177,9 @@ pub const Pardes = struct {
if (p.panes[p.active]) |nt| nt.pending = 0;
return;
}
- if (isC(key, 'w')) {
+ // insert mode owns Ctrl-w (delete word backward, helix); the focus
+ // prefix keeps normal/tty
+ if (isC(key, 'w') and pane.mode != .insert) {
p.ctrl_w_pending = true;
return;
}
@@ -1178,21 +1224,26 @@ pub const Pardes = struct {
return;
}
// normal-mode Enter/Tab: the acme chords — look at (Enter) or execute
- // (Tab) the modal selection, else the file-ish word under the cursor
- // (on the tag tail when it's focused for editing, else the body).
+ // (Tab) the EXPLICIT modal selection (v/x/X, terminal n/N, search
+ // n/N); implicit motion residue falls back to the file-ish word
+ // under the cursor (on the tag tail when it's focused, else the body).
if (pane.mode == .normal and (is(key, Key.enter) or is(key, Key.tab))) {
const btn: Mouse.Button = if (is(key, Key.tab)) .middle else .right;
pane.pinCursor();
- if (p.currentSelText(pane)) |txt| {
- pane.vsel.active = false;
- pane.msel.active = false;
- p.actOnSelection(btn, p.active, txt, null);
- } else {
- var sel = cursorWordSel(pane);
- p.expandSel(pane, &sel);
- const word = p.selectionText(pane, sel) catch return;
- p.actOnSelection(btn, p.active, word, null);
+ const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ if (explicit) {
+ if (p.currentSelText(pane)) |txt| {
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.select = false;
+ p.actOnSelection(btn, p.active, txt, null);
+ return;
+ }
}
+ var sel = cursorWordSel(pane);
+ p.expandSel(pane, &sel);
+ const word = p.selectionText(pane, sel) catch return;
+ p.actOnSelection(btn, p.active, word, null);
return;
}
// tag editing intercepts every other key: a modal one-line editor
@@ -1201,9 +1252,12 @@ pub const Pardes = struct {
p.tagInsertKey(pane, key);
return;
}
- // normal-mode `p` pastes the SYSTEM clipboard: an async OSC 52 round
- // trip — request here, the reply arrives later as Event.paste.
- if (pane.mode == .normal and is(key, 'p')) {
+ // normal-mode `p`/`P` paste the SYSTEM clipboard: an async OSC 52
+ // round trip — request here, the reply arrives later as Event.paste.
+ // Skipped while a prefix waits for its char argument (f/r/m and
+ // friends take `p` literally).
+ if (pane.mode == .normal and pane.pending == 0 and (is(key, 'p') or is(key, 'P'))) {
+ p.paste_before = is(key, 'P');
p.emit(.get_clipboard);
return;
}
@@ -1214,6 +1268,25 @@ pub const Pardes = struct {
pane.mode = .normal;
pane.msel.active = false;
pane.pending = 0;
+ // leaving an `a` append session: the cursor backs up one
+ // grapheme and the appended-over span becomes the
+ // implicit selection (helix doc.restore_cursor)
+ if (pane.append_at) |aa| {
+ pane.append_at = null;
+ if (pane.file != null) {
+ const pl = p.paneCursorLines(pane) catch return;
+ const text = p.flatSurface(pane, pl) catch return;
+ const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) });
+ const back = modal.prevGrapheme(text, gap);
+ const bc = modal.hxPos(text, back);
+ pane.cur_row = @intCast(bc.row);
+ pane.cur_col = @intCast(bc.col);
+ const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) });
+ pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false };
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ }
+ }
return;
}
p.handleInsert(pane, key);
@@ -1251,6 +1324,9 @@ pub const Pardes = struct {
pane.mode = .tty;
pane.msel.active = false;
pane.vsel.active = false;
+ pane.select = false;
+ pane.append_at = null;
+ pane.sticky_col = -1;
pane.pending = 0;
}
@@ -1404,7 +1480,9 @@ pub const Pardes = struct {
/// The lines the cursor moves over, absolute rows. File: all content lines.
/// Terminal: the WHOLE history+active grid (prompt rows hidden, runs
/// spliced) so motions ride the scrollback. Scratch-arena backed.
- fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines {
+ /// (pub only for test/hxdiff.zig — the helix differential harness dumps
+ /// this surface as a tty case's final text.)
+ pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines {
const arena = p.scratch.allocator();
if (pane.file) |f| {
var ls: std.ArrayList([]const u8) = .empty;
@@ -1442,89 +1520,498 @@ pub const Pardes = struct {
pane.cur_pinned = true;
}
- /// apply a motion: move the cursor, keep it visible. A line selection
- /// collapses on motion; a char selection extends (its head is the cursor).
- fn applyMove(pane: *Pane, pl: PaneLines, c: modal.Cursor) void {
- fromModalCursor(pane, pl, c);
+ // ---- helix range plumbing (see modal.zig "helix range engine") ----
+ // The pane's cursor + vsel cells render ONE helix gap range over the flat
+ // motion surface. Every motion builds the current range, transforms it the
+ // way the helix command would, and writes it back: normal mode REPLACES
+ // the selection with the motion's range, select mode (v) extends it via
+ // put_cursor. The differential suite (zig build hxdiff) pins all of this
+ // against a real helix, key for key.
+
+ /// the flat motion surface: file content as-is; terminals join the
+ /// cursor-lines dump (scratch-arena backed, same lifetime as pl)
+ fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 {
+ if (pane.file) |f| return f.content;
+ const arena = p.scratch.allocator();
+ var buf: std.ArrayList(u8) = .empty;
+ for (pl.lines, 0..) |ln, i| {
+ if (i > 0) try buf.append(arena, '\n');
+ try buf.appendSlice(arena, ln);
+ }
+ return buf.items;
+ }
+
+ /// the current selection as a helix gap range over `text`
+ fn paneRange(pane: *Pane, text: []const u8) modal.HxRange {
+ const c = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) });
+ if (pane.msel.active) {
+ // legacy line selection (file-search results highlight): linewise
+ const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1)));
+ const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1)));
+ const s = modal.lineStartOffset(text, r0);
+ const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1);
+ return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) };
+ }
+ if (pane.vsel.active) {
+ const a = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) });
+ if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) };
+ return .{ .anchor = modal.nextGrapheme(text, a), .head = c };
+ }
+ return .{ .anchor = c, .head = modal.nextGrapheme(text, c) };
+ }
+
+ /// write a helix range back into pane state. `explicit` marks user-intent
+ /// selections (v/x/X, terminal n/N, file-search n/N) — the acme chords
+ /// act only on those; motion residue stays implicit.
+ fn setPaneRange(pane: *Pane, pl: PaneLines, text: []const u8, r0: modal.HxRange, explicit: bool) void {
+ var r = r0;
+ if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1
+ var cur_off: usize = undefined;
+ var anc_off: usize = undefined;
+ if (r.head > r.anchor) {
+ cur_off = modal.prevGrapheme(text, r.head);
+ anc_off = r.anchor;
+ } else if (r.head < r.anchor) {
+ cur_off = r.head;
+ anc_off = modal.prevGrapheme(text, r.anchor);
+ } else {
+ cur_off = r.head;
+ anc_off = r.head;
+ }
+ const cc = modal.hxPos(text, cur_off);
+ const ac = modal.hxPos(text, anc_off);
+ pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0;
+ pane.cur_col = @intCast(cc.col);
+ pane.vsel = .{
+ .active = anc_off != cur_off or pane.select,
+ .row = @as(i32, @intCast(ac.row)) + pl.row0,
+ .col = @intCast(ac.col),
+ .explicit = explicit or pane.select,
+ };
pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
pane.pending = 0;
pane.ensureCursorVisible();
}
+ /// point-target motion: collapse there (extend in select mode)
+ fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void {
+ setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false);
+ }
+
+ /// word motions select their traversed span (extend mode: head only)
+ fn wordMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize, target: modal.WordTarget) void {
+ const wr = modal.hxWordMove(text, range, cnt, target);
+ const res = if (pane.select) modal.hxPutCursor(text, range, modal.hxCursor(text, wr), true) else wr;
+ setPaneRange(pane, pl, text, res, false);
+ }
+
+ /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move)
+ fn findMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, ch: u21, fwd: bool, till: bool, cnt: usize) void {
+ if (ch > 0x7f) return; // ponytail: ASCII targets only (byte columns)
+ const cur = modal.hxCursor(text, range);
+ const t = modal.hxFindTarget(text, cur, @intCast(ch), fwd, till, cnt) orelse return;
+ const res = if (pane.select)
+ modal.hxPutCursor(text, range, t, true)
+ else
+ modal.hxPutCursor(text, .{ .anchor = cur, .head = cur }, t, true);
+ setPaneRange(pane, pl, text, res, false);
+ }
+
+ /// j/k and friends: sticky goal column, clamped onto short lines' newline
+ fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void {
+ const cur = modal.hxCursor(text, range);
+ const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else modal.hxPos(text, cur).col;
+ const t = modal.hxVertTarget(text, cur, down, cnt, goal);
+ // extend mode never walks onto the empty trailing line (helix)
+ if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return;
+ setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false);
+ pane.sticky_col = @intCast(goal);
+ }
+
+ /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows
+ fn halfPageMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool) void {
+ const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2));
+ pane.scrollBy(if (down) half else -half);
+ verticalMove(pane, pl, text, range, down, @intCast(half));
+ }
+
+ /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift
+ /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0
+ fn scrollViewMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, delta: i32) void {
+ const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
+ pane.scrollBy(delta);
+ const top = pane.scroll();
+ const last_row: i32 = @intCast(modal.hxLineCount(text) - 1);
+ const cur = modal.hxCursor(text, range);
+ if (delta > 0) {
+ const snap: i32 = @max(0, @min(top + margin, last_row));
+ const head = modal.lineStartOffset(text, @intCast(snap));
+ if (head <= cur) return;
+ const anchor = if (pane.select) range.anchor else head;
+ setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false);
+ } else {
+ const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row));
+ const head = modal.lineStartOffset(text, @intCast(snap));
+ if (head >= cur) return;
+ const anchor = if (pane.select) range.anchor else head;
+ setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false);
+ }
+ }
+
+ /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window)
+ fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: u21, cnt: usize) void {
+ const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
+ const top = pane.scroll();
+ const last_row: i32 = @intCast(modal.hxLineCount(text) - 1);
+ const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top);
+ const n: i32 = @intCast(cnt - 1);
+ var vline: i32 = switch (which) {
+ 't' => top + margin + n,
+ 'c' => top + @divTrunc(last_vis, 2),
+ else => top + last_vis - (margin + n),
+ };
+ vline = @max(vline, top + margin);
+ vline = @min(vline, top + last_vis - margin);
+ const row: i32 = std.math.clamp(vline, 0, last_row);
+ pointMove(pane, pl, text, range, modal.lineStartOffset(text, @intCast(row)));
+ }
+
+ /// helix Range::line_range — the inclusive line span a range covers
+ fn rangeLineSpan(text: []const u8, r: modal.HxRange) struct { start: usize, end: usize } {
+ const from = @min(r.anchor, r.head);
+ const to = @max(r.anchor, r.head);
+ const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from);
+ return .{ .start = modal.hxLineOf(text, from), .end = modal.hxLineOf(text, to_adj) };
+ }
+
+ fn lineStartOrEof(text: []const u8, line: usize) usize {
+ if (line >= modal.hxLineCount(text)) return text.len;
+ return modal.lineStartOffset(text, line);
+ }
+
+ /// helix `x` extend_line_below: full lines incl. the newline, cursor ON
+ /// the last one's '\n'; an already-line-bounded selection grows downward
+ fn lineSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize) void {
+ const span = rangeLineSpan(text, range);
+ const start = modal.lineStartOffset(text, span.start);
+ const end = lineStartOrEof(text, span.end + 1);
+ const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end;
+ const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full));
+ setPaneRange(pane, pl, text, .{ .anchor = start, .head = head }, true);
+ }
+
+ /// helix `X` extend_to_line_bounds (direction kept)
+ fn lineBoundsSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void {
+ const span = rangeLineSpan(text, range);
+ const start = modal.lineStartOffset(text, span.start);
+ const end = lineStartOrEof(text, span.end + 1);
+ const r: modal.HxRange = if (range.head < range.anchor)
+ .{ .anchor = end, .head = start }
+ else
+ .{ .anchor = start, .head = end };
+ setPaneRange(pane, pl, text, r, true);
+ }
+
+ /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched)
+ fn shrinkSelToLineBounds(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void {
+ const span = rangeLineSpan(text, range);
+ if (span.start == span.end) return;
+ const from = @min(range.anchor, range.head);
+ const to = @max(range.anchor, range.head);
+ var start = modal.lineStartOffset(text, span.start);
+ var end = lineStartOrEof(text, span.end + 1);
+ if (start != from) start = lineStartOrEof(text, span.start + 1);
+ if (end != to) end = modal.lineStartOffset(text, span.end);
+ const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ const r: modal.HxRange = if (range.head < range.anchor)
+ .{ .anchor = end, .head = start }
+ else
+ .{ .anchor = start, .head = end };
+ setPaneRange(pane, pl, text, r, expl);
+ }
+
fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void {
pane.pinCursor();
const pl = p.paneCursorLines(pane) catch return;
- const cur = toModalCursor(pane, pl);
+ const text = p.flatSurface(pane, pl) catch return;
const lines = pl.lines;
- const half: usize = @max(1, pane.rows / 2);
- // Esc collapses any selection / pending prefix; it does NOT drop to tty
+ // Esc: exit select mode / drop pending prefixes + count. The
+ // selection itself SURVIVES (helix; `;` collapses it) — motions
+ // replace it anyway.
if (is(key, Key.escape)) {
- pane.vsel.active = false;
- pane.msel.active = false;
+ pane.select = false;
pane.pending = 0;
+ pane.pending2 = 0;
+ pane.pending_ch = 0;
+ pane.count = 0;
+ return;
+ }
+ // count prefix: digits accumulate while no prefix op is in flight
+ // (`0` stays line-start until a count begins)
+ if (pane.pending == 0 and !key.ctrl and !key.alt and key.cp >= '0' and key.cp <= '9' and !(key.cp == '0' and pane.count == 0)) {
+ if (pane.count < 0xffff) pane.count = pane.count * 10 + (key.cp - '0');
return;
}
+ // any key but a prefix setter consumes the count (setters re-arm it)
+ const cnt: usize = @max(1, pane.count);
+ const has_count = pane.count != 0;
+ const saved_count = pane.count;
+ pane.count = 0;
+
+ const range = paneRange(pane, text);
+ const cur = modal.hxCursor(text, range); // block-cursor gap offset
+ // the last line a goto may land on: helix skips the empty trailing line
+ const max_line = blk: {
+ const nl = modal.hxLineCount(text);
+ break :blk if (modal.lineStartOffset(text, nl - 1) >= text.len) nl -| 2 else nl - 1;
+ };
+
// prefix continuations
if (pane.pending == 'g') {
pane.pending = 0;
- if (is(key, 'g')) return applyMove(pane, pl, modal.gotoFirst());
- if (is(key, 'e')) return applyMove(pane, pl, modal.gotoLast(lines));
- if (is(key, 'h')) return applyMove(pane, pl, modal.lineStart(cur));
- if (is(key, 'l')) return applyMove(pane, pl, modal.lineEnd(lines, cur));
+ if (is(key, 'g')) {
+ const line = if (has_count) @min(cnt - 1, max_line) else 0;
+ return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line));
+ }
+ if (is(key, 'e')) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, max_line));
+ if (is(key, 'h')) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur)));
+ if (is(key, 'l')) {
+ const line = modal.hxLineOf(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line))));
+ }
+ if (is(key, 's')) {
+ const line = modal.hxLineOf(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ const slice = text[ls..modal.hxLineEndIdx(text, line)];
+ const nw = modal.firstNonWs(slice);
+ if (nw == slice.len) return; // all-whitespace line: no move (helix)
+ return pointMove(pane, pl, text, range, ls + nw);
+ }
+ if (is(key, 'j')) return verticalMove(pane, pl, text, range, true, cnt);
+ if (is(key, 'k')) return verticalMove(pane, pl, text, range, false, cnt);
+ if (is(key, '|')) {
+ const line = modal.hxLineOf(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ return pointMove(pane, pl, text, range, @min(ls + (cnt - 1), modal.hxLineEndIdx(text, line)));
+ }
+ if (is(key, 't') or is(key, 'c') or is(key, 'b')) return gotoWindow(pane, pl, text, range, key.cp, cnt);
return;
}
if (pane.pending == 'z') {
pane.pending = 0;
- if (is(key, 't')) return pane.scrollBy(pane.cur_row - pane.scroll());
- if (is(key, 'z')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows / 2)));
- if (is(key, 'b')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1));
+ if (is(key, 't')) {
+ pane.scrollBy(pane.cur_row - pane.scroll());
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (is(key, 'z') or is(key, 'c')) {
+ pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1));
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (is(key, 'b')) {
+ pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1));
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (is(key, 'j') or is(key, Key.down)) return scrollViewMove(pane, pl, text, range, 1);
+ if (is(key, 'k') or is(key, Key.up)) return scrollViewMove(pane, pl, text, range, -1);
+ if (isC(key, 'd')) return halfPageMove(pane, pl, text, range, true);
+ if (isC(key, 'u')) return halfPageMove(pane, pl, text, range, false);
+ if (isC(key, 'f') or is(key, Key.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows));
+ if (isC(key, 'b') or is(key, Key.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows));
return;
}
- if (is(key, 'g')) {
- pane.pending = 'g';
+ if (pane.pending == 'f' or pane.pending == 'F' or pane.pending == 't' or pane.pending == 'T') {
+ const op: u8 = @intCast(pane.pending);
+ pane.pending = 0;
+ if (key.ctrl or key.alt or key.cp >= 0xF0000) return; // not a char
+ pane.find_op = op;
+ pane.find_ch = key.cp;
+ return findMove(pane, pl, text, range, key.cp, op == 'f' or op == 't', op == 't' or op == 'T', cnt);
+ }
+ if (pane.pending == 'r') {
+ pane.pending = 0;
+ if (key.ctrl or key.alt or key.cp >= 0xF0000 or key.cp > 0x7f) return;
+ return p.normalReplaceChar(pane, @intCast(key.cp));
+ }
+ if (pane.pending == 'm') {
+ if (pane.pending2 == 0) {
+ if (is(key, 'm')) {
+ pane.pending = 0;
+ const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return;
+ return pointMove(pane, pl, text, range, modal.hxOff(text, mc));
+ }
+ if (is(key, 'i') or is(key, 'a') or is(key, 's') or is(key, 'r') or is(key, 'd')) {
+ pane.pending2 = key.cp; // pending stays 'm': the char arg is next
+ return;
+ }
+ pane.pending = 0;
+ return;
+ }
+ const sub = pane.pending2;
+ if (key.ctrl or key.alt or key.cp >= 0xF0000) {
+ pane.pending = 0;
+ pane.pending2 = 0;
+ pane.pending_ch = 0;
+ return;
+ }
+ if (sub == 'r' and pane.pending_ch == 0) {
+ pane.pending_ch = key.cp; // mr<from><to>: hold <from>, wait for <to>
+ return;
+ }
+ pane.pending = 0;
+ pane.pending2 = 0;
+ const from = pane.pending_ch;
+ pane.pending_ch = 0;
+ switch (sub) {
+ 'i', 'a' => return p.textobjectSelect(pane, pl, key.cp, sub == 'a'),
+ 's' => return p.surroundAdd(pane, key.cp),
+ 'd' => return p.surroundDelete(pane, pl, key.cp),
+ 'r' => return p.surroundReplace(pane, pl, from, key.cp),
+ else => return,
+ }
+ }
+ if (pane.pending == ']' or pane.pending == '[') {
+ const fwd = pane.pending == ']';
+ pane.pending = 0;
+ if (is(key, 'p')) {
+ const r2 = modal.hxParaMove(text, range, cnt, fwd, pane.select);
+ return setPaneRange(pane, pl, text, r2, false);
+ }
+ if (is(key, ' ')) return p.addNewline(pane, fwd, cnt);
return;
}
- if (is(key, 'z')) {
- pane.pending = 'z';
+ // prefix setters (the count survives into the continuation)
+ if (is(key, 'g') or is(key, 'z') or is(key, 'f') or is(key, 'F') or is(key, 't') or is(key, 'T') or is(key, 'r') or is(key, ']') or is(key, '[')) {
+ pane.pending = key.cp;
+ pane.count = saved_count;
+ return;
+ }
+ if (is(key, 'm')) {
+ pane.pending = 'm';
+ pane.pending2 = 0;
+ pane.pending_ch = 0;
return;
}
// movement
- if (is(key, 'h') or is(key, Key.left)) return applyMove(pane, pl, modal.charLeft(cur));
- if (is(key, 'l') or is(key, Key.right)) return applyMove(pane, pl, modal.charRight(lines, cur));
- if (is(key, 'j') or is(key, Key.down)) return applyMove(pane, pl, modal.lineDown(lines, cur));
- if (is(key, 'k') or is(key, Key.up)) return applyMove(pane, pl, modal.lineUp(lines, cur));
- if (is(key, 'w')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, false));
- if (is(key, 'b')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, false));
- if (is(key, 'e')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, false));
- if (is(key, 'W')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, true));
- if (is(key, 'B')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, true));
- if (is(key, 'E')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, true));
- if (is(key, '0') or is(key, Key.home)) return applyMove(pane, pl, modal.lineStart(cur));
- if (is(key, '$') or is(key, Key.end)) return applyMove(pane, pl, modal.lineEnd(lines, cur));
- if (is(key, '^')) return applyMove(pane, pl, modal.firstNonWsOf(lines, cur));
- if (is(key, 'G')) return applyMove(pane, pl, modal.gotoLast(lines));
- if (isC(key, 'd') or is(key, Key.page_down))
- return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, half)));
- if (isC(key, 'u') or is(key, Key.page_up))
- return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, half)));
- if (isC(key, 'f'))
- return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, pane.rows))));
- if (isC(key, 'b'))
- return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, pane.rows))));
+ if (is(key, 'h') or is(key, Key.left)) {
+ var t = cur;
+ for (0..cnt) |_| t = modal.prevGrapheme(text, t);
+ return pointMove(pane, pl, text, range, t);
+ }
+ if (is(key, 'l') or is(key, Key.right)) {
+ var t = cur;
+ for (0..cnt) |_| t = modal.nextGrapheme(text, t);
+ return pointMove(pane, pl, text, range, t);
+ }
+ if (is(key, 'j') or is(key, Key.down)) return verticalMove(pane, pl, text, range, true, cnt);
+ if (is(key, 'k') or is(key, Key.up)) return verticalMove(pane, pl, text, range, false, cnt);
+ if (is(key, 'w')) return wordMove(pane, pl, text, range, cnt, .next_word_start);
+ if (is(key, 'b')) return wordMove(pane, pl, text, range, cnt, .prev_word_start);
+ if (is(key, 'e')) return wordMove(pane, pl, text, range, cnt, .next_word_end);
+ if (is(key, 'W')) return wordMove(pane, pl, text, range, cnt, .next_long_word_start);
+ if (is(key, 'B')) return wordMove(pane, pl, text, range, cnt, .prev_long_word_start);
+ if (is(key, 'E')) return wordMove(pane, pl, text, range, cnt, .next_long_word_end);
+ if (isA(key, '.')) {
+ // repeat the last f/F/t/T; a count repeats the motion N times
+ if (pane.find_op == 0 or pane.find_ch > 0x7f) return;
+ const fwd = pane.find_op == 'f' or pane.find_op == 't';
+ const till = pane.find_op == 't' or pane.find_op == 'T';
+ var r = range;
+ var moved = false;
+ for (0..cnt) |_| {
+ const cc = modal.hxCursor(text, r);
+ const t = modal.hxFindTarget(text, cc, @intCast(pane.find_ch), fwd, till, 1) orelse break;
+ r = if (pane.select)
+ modal.hxPutCursor(text, r, t, true)
+ else
+ modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, t, true);
+ moved = true;
+ }
+ if (!moved) return;
+ return setPaneRange(pane, pl, text, r, false);
+ }
+ if (is(key, '0') or is(key, Key.home))
+ return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur)));
+ if (is(key, '$') or is(key, Key.end)) {
+ const line = modal.hxLineOf(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line))));
+ }
+ if (is(key, '^')) {
+ const line = modal.hxLineOf(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ const slice = text[ls..modal.hxLineEndIdx(text, line)];
+ const nw = modal.firstNonWs(slice);
+ if (nw == slice.len) return;
+ return pointMove(pane, pl, text, range, ls + nw);
+ }
+ if (is(key, 'G')) {
+ // helix goto_line: only acts WITH a count (bare G is a no-op;
+ // ge is goto-last-line)
+ if (!has_count) return;
+ const line = @min(cnt - 1, max_line);
+ return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line));
+ }
+ if (isC(key, 'd')) return halfPageMove(pane, pl, text, range, true);
+ if (isC(key, 'u')) return halfPageMove(pane, pl, text, range, false);
+ if (isC(key, 'f') or is(key, Key.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows));
+ if (isC(key, 'b') or is(key, Key.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows));
// insert entry
- if (is(key, 'i')) return p.enterInsert(pane, .at);
- if (is(key, 'a')) return p.enterInsert(pane, .append);
- if (is(key, 'I')) return p.enterInsert(pane, .line_start);
- if (is(key, 'A')) return p.enterInsert(pane, .line_end);
- if (is(key, 'o')) return p.enterInsert(pane, .open_below);
- if (is(key, 'O')) return p.enterInsert(pane, .open_above);
- // selection + edit
- if (is(key, 'v')) return charSelect(pane);
- if (is(key, 'x')) return lineSelect(pane);
- if (is(key, 'd')) return p.normalDelete(pane);
+ if (is(key, 'i')) return p.enterInsert(pane, .at, cnt);
+ if (is(key, 'a')) return p.enterInsert(pane, .append, cnt);
+ if (is(key, 'I')) return p.enterInsert(pane, .line_start, cnt);
+ if (is(key, 'A')) return p.enterInsert(pane, .line_end, cnt);
+ if (is(key, 'o')) return p.enterInsert(pane, .open_below, cnt);
+ if (is(key, 'O')) return p.enterInsert(pane, .open_above, cnt);
+ // selections
+ if (is(key, 'v')) {
+ if (pane.select) {
+ pane.select = false; // exit select mode, selection kept (helix)
+ } else {
+ pane.select = true;
+ if (pane.msel.active) {
+ // adopt the legacy line highlight as a char range
+ setPaneRange(pane, pl, text, range, true);
+ } else if (!pane.vsel.active) {
+ pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true };
+ } else {
+ pane.vsel.explicit = true;
+ }
+ }
+ pane.cur_pinned = true;
+ pane.pending = 0;
+ return;
+ }
+ if (is(key, 'x')) return lineSelect(pane, pl, text, range, cnt);
+ if (is(key, 'X')) return lineBoundsSelect(pane, pl, text, range);
+ if (isA(key, 'x')) return shrinkSelToLineBounds(pane, pl, text, range);
+ if (is(key, ';')) return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false);
+ if (isA(key, ';')) {
+ const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, expl);
+ }
+ if (is(key, '%')) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false);
+ // edits
+ if (is(key, 'd')) return p.normalDelete(pane, true);
+ if (isA(key, 'd')) return p.normalDelete(pane, false);
if (is(key, 'c')) return p.normalChange(pane);
if (is(key, 'y')) return p.normalYank(pane);
+ if (is(key, 'R')) return p.normalReplaceYank(pane);
+ if (is(key, '~')) return p.normalCase(pane, .toggle);
+ if (is(key, '`')) return p.normalCase(pane, .lower);
+ if (isA(key, '`')) return p.normalCase(pane, .upper);
+ if (is(key, 'J')) return p.normalJoin(pane);
+ if (is(key, '>')) return p.normalIndent(pane, cnt, true);
+ if (is(key, '<')) return p.normalIndent(pane, cnt, false);
+ if (isC(key, 'a')) return p.normalAdjustNumber(pane, @intCast(cnt));
+ if (isC(key, 'x')) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt)));
if (is(key, 'u')) return p.doUndo(pane);
if (is(key, 'U')) return p.doRedo(pane);
// file search: `/` types a pattern into the tag, n/N walk the results
@@ -1665,8 +2152,9 @@ pub const Pardes = struct {
if (delta > 0) break; // first token forward; keep the last one backward
}
if (hit_t1 == 0) continue;
- // anchor the selection at the token's end, cursor at its START
- pane.vsel = .{ .active = true, .row = r, .col = @intCast(hit_t1 - 1) };
+ // anchor the selection at the token's end, cursor at its START;
+ // EXPLICIT: Enter's look chord acts on it
+ pane.vsel = .{ .active = true, .row = r, .col = @intCast(hit_t1 - 1), .explicit = true };
pane.msel.active = false;
pane.cur_row = r;
pane.cur_col = @intCast(hit_t0);
@@ -1680,10 +2168,14 @@ pub const Pardes = struct {
const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
- fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt) void {
+ fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void {
pane.pinCursor();
- // snapshot once per insert session so `u` undoes the whole session
+ // snapshot once per insert session (WITH the pre-insert selection) so
+ // `u` undoes the whole session and restores what was selected
p.pushUndo(pane);
+ pane.select = false;
+ pane.append_at = null;
+ pane.sticky_col = -1;
const pl = p.paneCursorLines(pane) catch {
pane.mode = .insert;
pane.msel.active = false;
@@ -1691,35 +2183,79 @@ pub const Pardes = struct {
pane.pending = 0;
return;
};
+ const text = p.flatSurface(pane, pl) catch return;
const cur = toModalCursor(pane, pl);
const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0;
+ // helix selection-aware entry: `i` to the selection's START (the
+ // selection flips and survives until the first edit); `a` one past
+ // its END, remembering the origin cell for the Esc restore
+ const b: ?Bounds = if (pane.vsel.active) vselBounds(pane) else null;
switch (where) {
- .at => {},
- .append => fromModalCursor(pane, pl, modal.charRight(pl.lines, cur)),
- .line_start => fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)),
+ .at => {
+ if (b) |bb| {
+ pane.cur_row = bb.lo_row;
+ pane.cur_col = bb.lo_col;
+ pane.vsel = .{ .active = true, .row = bb.hi_row, .col = bb.hi_col, .explicit = false };
+ pane.cur_pinned = true;
+ }
+ },
+ .append => {
+ const lo_row = if (b) |bb| bb.lo_row else pane.cur_row;
+ const lo_col = if (b) |bb| bb.lo_col else pane.cur_col;
+ const hi_row = if (b) |bb| bb.hi_row else pane.cur_row;
+ const hi_col = if (b) |bb| bb.hi_col else pane.cur_col;
+ pane.append_at = .{ .row = lo_row, .col = lo_col };
+ const gap = modal.nextGrapheme(text, modal.hxOff(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) }));
+ const gc = modal.hxPos(text, gap);
+ pane.cur_row = @intCast(gc.row);
+ pane.cur_col = @intCast(gc.col);
+ pane.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false };
+ pane.cur_pinned = true;
+ },
+ .line_start => {
+ fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur));
+ pane.vsel.active = false;
+ },
.line_end => {
pane.cur_col = @intCast(llen);
pane.cur_pinned = true;
+ pane.vsel.active = false;
},
- .open_below => {
+ .open_below, .open_above => {
+ pane.vsel.active = false;
+ const below = where == .open_below;
if (pane.file) |*f| {
- const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return;
+ // helix `open`: the new line copies the current line's
+ // full indent LEVELS; <n>o opens n lines, cursor on the
+ // first
+ const row: usize = if (b) |bb|
+ @intCast(@max(0, if (below) bb.hi_row else bb.lo_row))
+ else
+ cur.row;
+ const ind = modal.hxIndentString(modal.lineSlice(f.content, row));
+ const arena = p.scratch.allocator();
+ var block: std.ArrayList(u8) = .empty;
+ for (0..cnt) |_| {
+ if (below) {
+ block.append(arena, '\n') catch return;
+ block.appendSlice(arena, ind) catch return;
+ } else {
+ block.appendSlice(arena, ind) catch return;
+ block.append(arena, '\n') catch return;
+ }
+ }
+ const at: modal.Cursor = if (below)
+ .{ .row = row, .col = modal.lineSlice(f.content, row).len }
+ else
+ .{ .row = row, .col = 0 };
+ const new = modal.insertAt(p.gpa, f.content, at, block.items) catch return;
p.setFileContent(f, new);
- pane.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0;
- pane.cur_col = 0;
- } else {
+ pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + pl.row0;
+ pane.cur_col = @intCast(ind.len);
+ } else if (below) {
const bottom = pane.scroll() + @as(i32, pane.rows) - 1;
pane.cur_row = @min(bottom, pane.cur_row + 1);
pane.cur_col = 0;
- }
- pane.cur_off = 0;
- pane.cur_pinned = true;
- },
- .open_above => {
- if (pane.file) |*f| {
- const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return;
- p.setFileContent(f, new);
- pane.cur_col = 0;
} else {
const top = pane.scroll();
pane.cur_row = @max(top, pane.cur_row - 1);
@@ -1733,7 +2269,6 @@ pub const Pardes = struct {
pane.cur_off = if (where == .at) 0 else if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
pane.mode = .insert;
pane.msel.active = false;
- pane.vsel.active = false;
pane.pending = 0;
pane.ensureCursorVisible();
}
@@ -1741,9 +2276,18 @@ pub const Pardes = struct {
/// insert mode: file panes get real content edits; terminals get run
/// splices — edits only ever touch YOUR runs, shell output is immutable.
fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void {
+ // helix aliases: normalize to the base key and re-dispatch
+ if (isC(key, 'h')) return p.handleInsert(pane, .{ .cp = Key.backspace });
+ if (isC(key, 'j')) return p.handleInsert(pane, .{ .cp = Key.enter });
+ if (isC(key, 'd')) return p.handleInsert(pane, .{ .cp = Key.delete });
+ // a selection carried into insert (i/a) survives only until the next
+ // key: helix maps it through every edit, pardes drops it instead —
+ // its only pardes use (the acme chords) needs explicit selections
+ // anyway, and those never enter insert mode
+ pane.vsel.active = false;
if (pane.file) |*f| {
const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
- if (key.text.len > 0) {
+ if (key.text.len > 0 and !key.ctrl and !key.alt) {
const new = modal.insertAt(p.gpa, f.content, c, key.text) catch return;
p.setFileContent(f, new);
for (key.text) |ch| {
@@ -1756,12 +2300,61 @@ pub const Pardes = struct {
pane.ensureCursorVisible();
return;
}
+ // helix insert-mode kills (word/line; deleteSpan is exclusive)
+ if (isC(key, 'w') or (key.alt and !key.ctrl and key.cp == Key.backspace)) {
+ // helix delete_word_backward: to the previous word start —
+ // crossing the newline at col 0 (the word before it goes too)
+ const g = modal.hxOff(f.content, c);
+ if (g == 0) return;
+ const wr = modal.hxWordMove(f.content, .{ .anchor = g, .head = g }, 1, .prev_word_start);
+ const from = @min(wr.anchor, wr.head);
+ const fc = modal.hxPos(f.content, from);
+ const new = modal.deleteSpan(p.gpa, f.content, fc, c) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row = @intCast(fc.row);
+ pane.cur_col = @intCast(fc.col);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (key.alt and !key.ctrl and (key.cp == 'd' or key.cp == Key.delete)) {
+ // helix delete_word_forward: to the next word END (trailing
+ // whitespace survives), crossing newlines at line ends
+ const g = modal.hxOff(f.content, c);
+ const wr = modal.hxWordMove(f.content, .{ .anchor = g, .head = g }, 1, .next_word_end);
+ const to = @max(wr.anchor, wr.head);
+ if (to <= g) return;
+ const new = modal.deleteSpan(p.gpa, f.content, c, modal.hxPos(f.content, to)) catch return;
+ p.setFileContent(f, new);
+ pane.cur_pinned = true;
+ return;
+ }
+ if (isC(key, 'u')) {
+ const new = modal.deleteSpan(p.gpa, f.content, .{ .row = c.row, .col = 0 }, c) catch return;
+ p.setFileContent(f, new);
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (isC(key, 'k')) {
+ const llen = modal.lineSlice(f.content, c.row).len;
+ const new = modal.deleteSpan(p.gpa, f.content, c, .{ .row = c.row, .col = llen }) catch return;
+ p.setFileContent(f, new);
+ pane.cur_pinned = true;
+ return;
+ }
switch (key.cp) {
Key.enter => {
- const new = modal.insertAt(p.gpa, f.content, c, "\n") catch return;
+ // helix insert_newline: the new line copies the current
+ // line's full indent LEVELS (plain-text heuristic)
+ const ind = modal.hxIndentString(modal.lineSlice(f.content, c.row));
+ const arena = p.scratch.allocator();
+ const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return;
+ const new = modal.insertAt(p.gpa, f.content, c, block) catch return;
p.setFileContent(f, new);
pane.cur_row += 1;
- pane.cur_col = 0;
+ pane.cur_col = @intCast(ind.len);
pane.cur_pinned = true;
pane.ensureCursorVisible();
},
@@ -1771,8 +2364,9 @@ pub const Pardes = struct {
p.setFileContent(f, new);
pane.cur_col -= 1;
} else if (pane.cur_row > 0) {
+ // at col 0: eat the previous newline (join up)
const prevlen = modal.lineSlice(f.content, c.row - 1).len;
- const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return;
+ const new = modal.deleteSpan(p.gpa, f.content, .{ .row = c.row - 1, .col = prevlen }, .{ .row = c.row, .col = 0 }) catch return;
p.setFileContent(f, new);
pane.cur_row -= 1;
pane.cur_col = @intCast(prevlen);
@@ -1793,16 +2387,101 @@ pub const Pardes = struct {
fromModalCursor(pane, pl, nc);
pane.ensureCursorVisible();
},
+ Key.delete => {
+ // delete forward; at line end eat the newline (join)
+ const line = modal.lineSlice(f.content, c.row);
+ if (c.col < line.len) {
+ const new = modal.deleteChar(p.gpa, f.content, c) catch return;
+ p.setFileContent(f, new);
+ } else if (c.row + 1 < modal.lineCount(f.content)) {
+ const new = modal.deleteSpan(p.gpa, f.content, c, .{ .row = c.row + 1, .col = 0 }) catch return;
+ p.setFileContent(f, new);
+ }
+ pane.cur_pinned = true;
+ },
+ Key.tab => {
+ // helix insert_tab with a Spaces indent style: spaces to
+ // the next tab stop (smart-tab machinery skipped)
+ const pad = modal.INDENT_W - (c.col % modal.INDENT_W);
+ const spaces = " ";
+ const new = modal.insertAt(p.gpa, f.content, c, spaces[0..pad]) catch return;
+ p.setFileContent(f, new);
+ pane.cur_col += @intCast(pad);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.home => {
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.end => {
+ // helix insert End: past the last char (goto_line_end_newline)
+ pane.cur_col = @intCast(modal.lineSlice(f.content, c.row).len);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.page_up, Key.page_down => {
+ // helix binds insert pageup/pagedown to the same view
+ // scroll + cursor snap as normal mode
+ const pl = p.paneCursorLines(pane) catch return;
+ const text = p.flatSurface(pane, pl) catch return;
+ const range = paneRange(pane, text);
+ const step: i32 = @intCast(@max(1, pane.rows));
+ scrollViewMove(pane, pl, text, range, if (key.cp == Key.page_down) step else -step);
+ },
else => {},
}
return;
}
if (!pane.cur_pinned) pane.pinCursor();
- if (key.text.len > 0) {
+ if (key.text.len > 0 and !key.ctrl and !key.alt) {
insertRunText(pane, key.text);
return;
}
+ // kills confined to the typed run at the cursor (output is immutable)
+ if (isC(key, 'w') or (key.alt and !key.ctrl and key.cp == Key.backspace)) {
+ const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return;
+ const e = &pane.edits[ei];
+ var t: usize = @min(pane.cur_off, e.len);
+ while (t > 0 and e.text[t - 1] == ' ') t -= 1;
+ while (t > 0 and e.text[t - 1] != ' ') t -= 1;
+ const cut = @min(pane.cur_off, e.len) - t;
+ var k: usize = t;
+ while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut];
+ e.len -= @intCast(cut);
+ pane.cur_off = @intCast(t);
+ return;
+ }
+ if (key.alt and !key.ctrl and (key.cp == 'd' or key.cp == Key.delete)) {
+ const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return;
+ const e = &pane.edits[ei];
+ var t: usize = @min(pane.cur_off, e.len);
+ while (t < e.len and e.text[t] != ' ') t += 1;
+ while (t < e.len and e.text[t] == ' ') t += 1;
+ const cut = t - @min(pane.cur_off, e.len);
+ var k: usize = @min(pane.cur_off, e.len);
+ while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut];
+ e.len -= @intCast(cut);
+ return;
+ }
+ if (isC(key, 'u')) {
+ const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return;
+ const e = &pane.edits[ei];
+ const cut = @min(pane.cur_off, e.len);
+ var k: usize = 0;
+ while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut];
+ e.len -= @intCast(cut);
+ pane.cur_off = 0;
+ return;
+ }
+ if (isC(key, 'k')) {
+ const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return;
+ const e = &pane.edits[ei];
+ e.len = @min(pane.cur_off, e.len);
+ return;
+ }
switch (key.cp) {
Key.backspace => {
const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return;
@@ -1814,6 +2493,15 @@ pub const Pardes = struct {
pane.cur_off -= 1;
}
},
+ Key.delete => {
+ const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return;
+ const e = &pane.edits[ei];
+ if (pane.cur_off < e.len) {
+ var k: usize = pane.cur_off;
+ while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
+ e.len -= 1;
+ }
+ },
else => {},
}
}
@@ -1838,36 +2526,6 @@ pub const Pardes = struct {
}
}
- // ---- selections ----
-
- /// helix `v`: toggle char-range select, anchored at the cursor
- fn charSelect(pane: *Pane) void {
- if (pane.vsel.active) {
- pane.vsel.active = false;
- } else {
- pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col };
- }
- pane.msel.active = false;
- pane.cur_pinned = true;
- pane.pending = 0;
- }
-
- /// helix `x`: extend the line selection down by one (or start it)
- fn lineSelect(pane: *Pane) void {
- if (!pane.msel.active) {
- pane.msel = .{ .active = true, .r0 = pane.cur_row, .r1 = pane.cur_row };
- } else {
- const last = pane.lastNavRow();
- pane.msel.r1 = @min(pane.msel.r1 + 1, last);
- pane.cur_row = pane.msel.r1;
- }
- pane.vsel.active = false;
- pane.cur_col = 0;
- pane.cur_off = 0;
- pane.cur_pinned = true;
- pane.pending = 0;
- }
-
const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
/// the char selection [anchor, cursor] normalized to document order
@@ -1939,10 +2597,12 @@ pub const Pardes = struct {
// ---- edit operations (d / c / y / p) ----
- fn normalDelete(p: *Pardes, pane: *Pane) void {
+ fn normalDelete(p: *Pardes, pane: *Pane, yank: bool) void {
+ pane.select = false; // helix delete exits select mode
+ pane.sticky_col = -1;
if (pane.vsel.active) {
const b = vselBounds(pane);
- p.setYank(p.vselText(pane)); // read the range before the move
+ if (yank) p.setYank(p.vselText(pane)); // read the range before the move
p.pushUndo(pane);
if (pane.file) |*f| {
const d = modal.deleteRange(p.gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch {
@@ -1976,14 +2636,14 @@ pub const Pardes = struct {
return;
};
p.setFileContent(f, d.content);
- p.setYank(d.deleted);
+ if (yank) p.setYank(d.deleted);
p.gpa.free(d.deleted);
const n = modal.lineCount(f.content);
pane.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1));
pane.cur_col = 0;
} else {
clearEditsInRange(pane, r0, r1);
- p.setYank(p.yankRows(pane, r0, r1));
+ if (yank) p.setYank(p.yankRows(pane, r0, r1));
}
pane.msel.active = false;
pane.cur_off = 0;
@@ -1992,8 +2652,17 @@ pub const Pardes = struct {
} else if (pane.file) |*f| {
p.pushUndo(pane);
const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
- const new = modal.deleteChar(p.gpa, f.content, c) catch return;
- p.setFileContent(f, new);
+ if (yank) {
+ const arena = p.scratch.allocator();
+ p.setYank(modal.rangeText(arena, f.content, c, c) catch return);
+ }
+ // deleteRange (not deleteChar): the cursor may sit ON a '\n'
+ // cell, where a delete joins the lines (helix 1-wide selection)
+ const d = modal.deleteRange(p.gpa, f.content, c, c) catch return;
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ const llen = modal.lineSlice(f.content, c.row).len;
+ pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen)));
} else {
p.deleteRunCharAtCursor(pane);
}
@@ -2015,15 +2684,10 @@ pub const Pardes = struct {
}
fn normalYank(p: *Pardes, pane: *Pane) void {
+ pane.select = false; // helix yank exits select mode, selection KEPT
if (pane.vsel.active) {
- const b = vselBounds(pane);
+ // helix: yank moves neither the cursor nor the selection
p.setYank(p.vselText(pane));
- pane.cur_row = b.lo_row;
- pane.cur_col = b.lo_col;
- pane.cur_off = 0;
- pane.cur_pinned = true;
- pane.vsel.active = false;
- pane.ensureCursorVisible();
return;
}
if (pane.msel.active) {
@@ -2043,35 +2707,131 @@ pub const Pardes = struct {
} else {
p.setYank(p.yankRows(pane, r0, r1));
}
- pane.cur_row = r0;
- pane.cur_col = 0;
- pane.msel.active = false;
- } else if (pane.file) |f| {
- p.setYank(modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))));
} else {
- p.setYank(p.yankRows(pane, pane.cur_row, pane.cur_row));
+ // helix: a bare cursor IS a 1-wide selection — yank the char
+ // under it, file and terminal alike (the line-yank vim-ism died
+ // with the motion model). vselText already speaks both.
+ const save = pane.vsel;
+ pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = false };
+ p.setYank(p.vselText(pane));
+ pane.vsel = save;
}
}
- fn normalPaste(p: *Pardes, pane: *Pane) void {
- const y = p.yank orelse return;
+ /// helix p/P: a yank ending in '\n' pastes as whole lines below/above
+ /// the SELECTION's line span; anything else splices inline at the
+ /// selection's outer edge. The paste (repeated <count> times) becomes
+ /// the implicit selection, cursor on its last char (linewise: ON the
+ /// last pasted line's newline). Terminals ignore `before` (runs splice
+ /// at the cursor either way).
+ fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void {
+ const y0 = p.yank orelse return;
+ if (y0.len == 0) return;
p.pushUndo(pane);
+ pane.select = false;
+ pane.sticky_col = -1;
+ const cnt: usize = @max(1, pane.count);
+ pane.count = 0;
+ const arena = p.scratch.allocator();
+ var y: []const u8 = y0;
+ if (cnt > 1) {
+ var buf: std.ArrayList(u8) = .empty;
+ for (0..cnt) |_| buf.appendSlice(arena, y0) catch return;
+ y = buf.items;
+ }
if (pane.file) |*f| {
- const new = modal.pasteLineBelow(p.gpa, f.content, @intCast(@max(0, pane.cur_row)), y) catch return;
- p.setFileContent(f, new);
- pane.cur_row += 1;
- pane.cur_col = 0;
- } else {
- insertRunText(pane, y);
+ const b: Bounds = if (pane.vsel.active)
+ vselBounds(pane)
+ else
+ .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col };
+ if (y[y.len - 1] == '\n') {
+ const block_text = y[0 .. y.len - 1];
+ const n = modal.lineCount(block_text);
+ if (before) {
+ const row: usize = @intCast(@max(0, b.lo_row));
+ const block = std.fmt.allocPrint(arena, "{s}\n", .{block_text}) catch return;
+ const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = 0 }, block) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row = @intCast(row + n - 1);
+ } else {
+ const row: usize = @intCast(@max(0, b.hi_row));
+ const new = modal.pasteLineBelow(p.gpa, f.content, row, block_text) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row = @intCast(row + n);
+ }
+ pane.vsel = .{ .active = true, .row = pane.cur_row - @as(i32, @intCast(n - 1)), .col = 0, .explicit = false };
+ // cursor ON the last pasted line's '\n' (helix)
+ const llen = modal.lineSlice(f.content, @intCast(pane.cur_row)).len;
+ pane.cur_col = @intCast(llen);
+ } else {
+ const at: modal.Cursor = if (before)
+ .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }
+ else blk: {
+ const hrow: usize = @intCast(@max(0, b.hi_row));
+ const gcol = @as(usize, @intCast(@max(0, b.hi_col))) + 1;
+ if (gcol > modal.lineSlice(f.content, hrow).len) break :blk .{ .row = hrow + 1, .col = 0 };
+ break :blk .{ .row = hrow, .col = gcol };
+ };
+ const new = modal.insertAt(p.gpa, f.content, at, y) catch return;
+ p.setFileContent(f, new);
+ pane.vsel = .{ .active = y.len > 1, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false };
+ const end = modal.advanceBy(at, y);
+ if (end.col > 0) {
+ pane.cur_row = @intCast(end.row);
+ pane.cur_col = @intCast(end.col - 1);
+ } else {
+ pane.cur_row = @intCast(end.row -| 1);
+ pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len);
+ }
+ }
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
}
+ insertRunText(pane, y);
pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
pane.cur_pinned = true;
pane.ensureCursorVisible();
}
fn normalChange(p: *Pardes, pane: *Pane) void {
+ pane.select = false;
+ pane.sticky_col = -1;
if (pane.vsel.active) {
const b = vselBounds(pane);
+ // helix: a LINEWISE selection (whole lines incl. their final
+ // newline) deletes the lines and opens a fresh one above,
+ // copying the indent of the line that moves up
+ if (pane.file) |*f| {
+ const hi_len = modal.lineSlice(f.content, @intCast(@max(0, b.hi_row))).len;
+ const linewise = b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len;
+ if (linewise) {
+ p.setYank(p.vselText(pane));
+ p.pushUndo(pane);
+ const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, b.lo_row)), @intCast(@max(0, b.hi_row))) catch return;
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ const n = modal.lineCount(f.content);
+ const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row))), if (n == 0) 0 else n - 1);
+ const ind = modal.hxIndentString(modal.lineSlice(f.content, row));
+ const arena = p.scratch.allocator();
+ const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return;
+ const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = 0 }, block) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row = @intCast(row);
+ pane.cur_col = @intCast(ind.len);
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.mode = .insert;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ return;
+ }
+ }
p.setYank(p.vselText(pane));
p.pushUndo(pane);
if (pane.file) |*f| {
@@ -2143,8 +2903,13 @@ pub const Pardes = struct {
} else if (pane.file) |*f| {
p.pushUndo(pane);
const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
- const new = modal.deleteChar(p.gpa, f.content, c) catch return;
- p.setFileContent(f, new);
+ const arena = p.scratch.allocator();
+ p.setYank(modal.rangeText(arena, f.content, c, c) catch return);
+ const d = modal.deleteRange(p.gpa, f.content, c, c) catch return;
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ const llen = modal.lineSlice(f.content, c.row).len;
+ pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen)));
pane.mode = .insert;
pane.cur_off = 0;
pane.pending = 0;
@@ -2156,6 +2921,434 @@ pub const Pardes = struct {
}
}
+ // ---- helix change ops (r R ~ ` J > < Ctrl-a m-mode ]space) ----
+ // Terminals follow the immutable-output rule: r/~/`/Alt-` touch the typed
+ // run byte under the cursor if there is one; the rest are clean no-ops.
+
+ /// the typed-run byte under the (visual) cursor, if any
+ fn runByteAt(pane: *Pane, row: i32, col: i32) ?*u8 {
+ for (pane.edits[0..pane.nedits]) |*e| {
+ if (e.row != row or e.len == 0) continue;
+ const vstart = pane.renderedCol(e.row, e.col, 0);
+ if (col < vstart or col >= vstart + @as(i32, e.len)) continue;
+ return &e.text[@intCast(col - vstart)];
+ }
+ return null;
+ }
+
+ /// the selection as an inclusive cursor range: vsel span, msel line span,
+ /// else the char under the cursor (helix's implicit 1-wide selection)
+ fn selRange(pane: *Pane, content: []const u8) modal.Range {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ return .{
+ .a = .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) },
+ .b = .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) },
+ };
+ }
+ if (pane.msel.active) {
+ const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1)));
+ const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1)));
+ const llen = modal.lineSlice(content, r1).len;
+ return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = llen -| 1 } };
+ }
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ return .{ .a = c, .b = c };
+ }
+
+ /// `r<ch>`: overwrite the selection (or the cursor char) with ch —
+ /// newlines included (helix), so `xrz` joins the selected lines
+ fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u8) void {
+ pane.select = false;
+ if (pane.file) |*f| {
+ const r = selRange(pane, f.content);
+ p.pushUndo(pane);
+ const new = modal.replaceChars(p.gpa, f.content, r.a, r.b, ch) catch return;
+ p.setFileContent(f, new);
+ } else if (runByteAt(pane, pane.cur_row, pane.cur_col)) |b| {
+ p.pushUndo(pane);
+ b.* = ch;
+ }
+ }
+
+ /// `R`: replace the selection (or the cursor char) with the yank register;
+ /// the pasted text becomes the selection, head on its last char (which
+ /// for a trailing-newline yank is the '\n' cell of the last full line)
+ fn normalReplaceYank(p: *Pardes, pane: *Pane) void {
+ const y = p.yank orelse return;
+ if (y.len == 0) return;
+ if (pane.file == null) return; // shell output is immutable
+ pane.select = false;
+ const f = &pane.file.?;
+ const r = selRange(pane, f.content);
+ p.pushUndo(pane);
+ const new = modal.replaceRange(p.gpa, f.content, r.a, r.b, y) catch return;
+ p.setFileContent(f, new);
+ pane.msel.active = false;
+ pane.vsel = .{ .active = y.len > 1, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false };
+ const end = modal.advanceBy(r.a, y);
+ if (end.col > 0) {
+ pane.cur_row = @intCast(end.row);
+ pane.cur_col = @intCast(end.col - 1);
+ } else {
+ // the yank ended in '\n': the cursor lands ON that newline
+ pane.cur_row = @intCast(end.row -| 1);
+ pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len);
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+ }
+
+ /// `~` / `` ` `` / ``Alt-` ``: case-map the selection (or the cursor char),
+ /// keeping the selection (helix)
+ fn normalCase(p: *Pardes, pane: *Pane, op: modal.CaseOp) void {
+ pane.select = false;
+ if (pane.file) |*f| {
+ const r = selRange(pane, f.content);
+ p.pushUndo(pane);
+ const new = modal.changeCase(p.gpa, f.content, r.a, r.b, op) catch return;
+ p.setFileContent(f, new);
+ } else if (runByteAt(pane, pane.cur_row, pane.cur_col)) |b| {
+ p.pushUndo(pane);
+ b.* = switch (op) {
+ .toggle => if (std.ascii.isUpper(b.*)) std.ascii.toLower(b.*) else std.ascii.toUpper(b.*),
+ .lower => std.ascii.toLower(b.*),
+ .upper => std.ascii.toUpper(b.*),
+ };
+ }
+ }
+
+ /// a change to apply to file content: delete [from, to), insert `ins`
+ /// spaces at `from`. Ascending and disjoint.
+ const TextChange = struct { from: usize, to: usize, ins: usize };
+
+ /// map an original-text offset through a change list (insertions AT a
+ /// position push it right — helix Assoc::After; positions inside a
+ /// deleted span collapse to its start)
+ fn mapThroughChanges(chs: []const TextChange, pos: usize) usize {
+ var delta: i64 = 0;
+ for (chs) |ch| {
+ if (pos < ch.from) break;
+ if (ch.from == ch.to) {
+ delta += @intCast(ch.ins);
+ continue;
+ }
+ if (pos == ch.from) break;
+ if (pos < ch.to) return @intCast(@as(i64, @intCast(ch.from)) + delta);
+ delta += @as(i64, @intCast(ch.ins)) - @as(i64, @intCast(ch.to - ch.from));
+ }
+ return @intCast(@as(i64, @intCast(pos)) + delta);
+ }
+
+ /// apply a change list; the insertions are spaces (join/indent only ever
+ /// insert those). gpa-owned result.
+ fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(p.gpa);
+ var copied: usize = 0;
+ for (chs) |ch| {
+ try out.appendSlice(p.gpa, text[copied..ch.from]);
+ for (0..ch.ins) |_| try out.append(p.gpa, ' ');
+ copied = ch.to;
+ }
+ try out.appendSlice(p.gpa, text[copied..]);
+ return out.toOwnedSlice(p.gpa);
+ }
+
+ /// `J`: helix join_selections — join the selection's line span (a bare
+ /// cursor joins with the next line): each '\n' + following indent become
+ /// one space, EXCEPT before content-less lines (no space) — and on the
+ /// buffer's last line the trailing newline is deleted. The selection and
+ /// cursor map through the edit; the count is ignored (helix).
+ fn normalJoin(p: *Pardes, pane: *Pane) void {
+ if (pane.file == null) return; // joining shell output would edit it
+ const f = &pane.file.?;
+ const text = f.content;
+ const range = paneRange(pane, text);
+ const span = rangeLineSpan(text, range);
+ const nlines = modal.hxLineCount(text);
+ var end = span.end;
+ if (span.start == end) end = @min(end + 1, nlines - 1);
+ if (end <= span.start) return;
+ const arena = p.scratch.allocator();
+ var chs: std.ArrayList(TextChange) = .empty;
+ var l = span.start;
+ while (l < end) : (l += 1) {
+ const from = modal.hxLineEndIdx(text, l);
+ var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1);
+ while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1;
+ const sep: usize = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) 0 else 1;
+ chs.append(arena, .{ .from = from, .to = to, .ins = sep }) catch return;
+ }
+ if (chs.items.len == 0) return;
+ p.pushUndo(pane);
+ const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) });
+ const anc_off = if (pane.vsel.active)
+ modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) })
+ else
+ cur_off;
+ const new = p.applyChanges(text, chs.items) catch return;
+ const nc = mapThroughChanges(chs.items, cur_off);
+ const na = mapThroughChanges(chs.items, anc_off);
+ p.setFileContent(f, new);
+ const cc = modal.hxPos(f.content, nc);
+ const ac = modal.hxPos(f.content, na);
+ pane.cur_row = @intCast(cc.row);
+ pane.cur_col = @intCast(cc.col);
+ if (pane.vsel.active) {
+ pane.vsel.row = @intCast(ac.row);
+ pane.vsel.col = @intCast(ac.col);
+ }
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+ }
+
+ /// `>` / `<`: helix indent/unindent over the selection's line span.
+ /// Blank (all-whitespace) lines are skipped; `>` inserts count levels
+ /// realigned to the next INDENT_W stop; `<` removes up to count levels of
+ /// leading whitespace (a tab advances to the next stop). Cursor and
+ /// selection map through the edit.
+ fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void {
+ if (pane.file == null) return; // shell output is immutable
+ pane.select = false;
+ const f = &pane.file.?;
+ const text = f.content;
+ const range = paneRange(pane, text);
+ const span = rangeLineSpan(text, range);
+ const arena = p.scratch.allocator();
+ var chs: std.ArrayList(TextChange) = .empty;
+ var l = span.start;
+ while (l <= span.end) : (l += 1) {
+ const ls = modal.lineStartOffset(text, l);
+ const le = modal.hxLineEndIdx(text, l);
+ const line = text[ls..le];
+ const nw = modal.firstNonWs(line);
+ if (nw == line.len) continue; // blank lines stay blank (helix)
+ if (add) {
+ const ins = modal.INDENT_W * cnt - (nw % modal.INDENT_W);
+ chs.append(arena, .{ .from = ls, .to = ls, .ins = ins }) catch return;
+ } else {
+ const want = modal.INDENT_W * cnt;
+ var w: usize = 0;
+ var pos: usize = 0;
+ while (pos < line.len) {
+ if (line[pos] == ' ') {
+ w += 1;
+ } else if (line[pos] == '\t') {
+ w = (w / modal.INDENT_W + 1) * modal.INDENT_W;
+ } else break;
+ pos += 1;
+ if (w >= want) break;
+ }
+ if (pos > 0) chs.append(arena, .{ .from = ls, .to = ls + pos, .ins = 0 }) catch return;
+ }
+ }
+ if (chs.items.len == 0) return;
+ p.pushUndo(pane);
+ const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) });
+ const anc_off = if (pane.vsel.active)
+ modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) })
+ else
+ cur_off;
+ const new = p.applyChanges(text, chs.items) catch return;
+ const nc = mapThroughChanges(chs.items, cur_off);
+ const na = mapThroughChanges(chs.items, anc_off);
+ p.setFileContent(f, new);
+ const cc = modal.hxPos(f.content, nc);
+ const ac = modal.hxPos(f.content, na);
+ pane.cur_row = @intCast(cc.row);
+ pane.cur_col = @intCast(cc.col);
+ if (pane.vsel.active) {
+ pane.vsel.row = @intCast(ac.row);
+ pane.vsel.col = @intCast(ac.col);
+ }
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+ }
+
+ /// `Ctrl-a` / `Ctrl-x`: increment/decrement the SELECTION as a decimal
+ /// integer (helix: the selected fragment itself, no number scan around
+ /// the cursor); a fragment that isn't an integer is a no-op. The new
+ /// number becomes the selection, cursor on its last char.
+ fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void {
+ if (pane.file == null) return; // shell output is immutable
+ const f = &pane.file.?;
+ const r = selRange(pane, f.content);
+ const arena = p.scratch.allocator();
+ const frag = modal.rangeText(arena, f.content, r.a, r.b) catch return;
+ const rep = (modal.hxIncrement(arena, frag, delta) catch null) orelse return;
+ p.pushUndo(pane);
+ pane.select = false;
+ const new = modal.replaceRange(p.gpa, f.content, r.a, r.b, rep) catch return;
+ p.setFileContent(f, new);
+ const start = modal.lineStartOffset(f.content, r.a.row) + r.a.col;
+ const cc = modal.hxPos(f.content, start + rep.len - 1);
+ pane.vsel = .{ .active = rep.len > 1, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false };
+ pane.msel.active = false;
+ pane.cur_row = @intCast(cc.row);
+ pane.cur_col = @intCast(cc.col);
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+ }
+
+ /// `]Space` / `[Space`: add blank lines below/above, the cursor staying
+ /// on its text line (the selection rides along)
+ fn addNewline(p: *Pardes, pane: *Pane, below: bool, cnt: usize) void {
+ if (pane.file == null) return; // shell output is immutable
+ const f = &pane.file.?;
+ p.pushUndo(pane);
+ const row: usize = @intCast(@max(0, pane.cur_row));
+ const arena = p.scratch.allocator();
+ const nl = arena.alloc(u8, cnt) catch return;
+ @memset(nl, '\n');
+ const llen = modal.lineSlice(f.content, row).len;
+ const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return;
+ p.setFileContent(f, new);
+ if (!below) {
+ pane.cur_row += @intCast(cnt); // the line moved down; stay on it
+ if (pane.vsel.active and pane.vsel.row >= @as(i32, @intCast(row))) pane.vsel.row += @intCast(cnt);
+ } else if (pane.vsel.active and pane.vsel.row > @as(i32, @intCast(row))) {
+ pane.vsel.row += @intCast(cnt);
+ }
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ }
+
+ /// pair mapping for m-mode: either bracket names the pair; anything else
+ /// surrounds with itself (quotes)
+ fn pairFor(ch: u21) ?struct { o: u8, c: u8 } {
+ return switch (ch) {
+ '(', ')' => .{ .o = '(', .c = ')' },
+ '[', ']' => .{ .o = '[', .c = ']' },
+ '{', '}' => .{ .o = '{', .c = '}' },
+ '<', '>' => .{ .o = '<', .c = '>' },
+ else => if (ch <= 0x7f) .{ .o = @intCast(ch), .c = @intCast(ch) } else null,
+ };
+ }
+
+ /// `mi<obj>` / `ma<obj>`: select inside/around a textobject (pure range
+ /// math over the motion surface — works on terminals too). Word and
+ /// paragraph objects are helix textobject.rs ports; pairs/quotes are the
+ /// plain-text scans (quotes line-scoped — ponytail).
+ fn textobjectSelect(p: *Pardes, pane: *Pane, pl: PaneLines, obj: u21, around: bool) void {
+ const text = p.flatSurface(pane, pl) catch return;
+ const range = paneRange(pane, text);
+ switch (obj) {
+ 'w', 'W' => {
+ const r = modal.hxTextobjectWord(text, range, around, obj == 'W');
+ return setPaneRange(pane, pl, text, r, false);
+ },
+ 'p' => {
+ const r = modal.hxTextobjectParagraph(text, range, around, 1);
+ return setPaneRange(pane, pl, text, r, false);
+ },
+ else => {},
+ }
+ const cur = modal.hxPos(text, modal.hxCursor(text, range));
+ const pair: ?modal.Range = switch (obj) {
+ '\'', '"', '`' => modal.enclosingQuote(pl.lines, cur, @intCast(obj)),
+ '(', ')' => modal.enclosingPair(pl.lines, cur, '(', ')'),
+ '[', ']' => modal.enclosingPair(pl.lines, cur, '[', ']'),
+ '{', '}' => modal.enclosingPair(pl.lines, cur, '{', '}'),
+ '<', '>' => modal.enclosingPair(pl.lines, cur, '<', '>'),
+ else => null,
+ };
+ const pr = pair orelse return;
+ var a = modal.hxOff(text, pr.a);
+ var head = modal.nextGrapheme(text, modal.hxOff(text, pr.b));
+ if (!around) {
+ // inside: shrink off the delimiters; an EMPTY pair collapses to
+ // a 1-wide cursor on the closing char (helix)
+ a = modal.nextGrapheme(text, a);
+ head = modal.prevGrapheme(text, head);
+ }
+ setPaneRange(pane, pl, text, .{ .anchor = a, .head = head }, false);
+ }
+
+ /// `ms<ch>`: wrap the selection (or the cursor char) in a pair; the wrap
+ /// including the pair becomes the selection
+ fn surroundAdd(p: *Pardes, pane: *Pane, ch: u21) void {
+ if (pane.file == null) return; // shell output is immutable
+ const pr = pairFor(ch) orelse return;
+ pane.select = false;
+ const f = &pane.file.?;
+ const r = selRange(pane, f.content);
+ p.pushUndo(pane);
+ var new = modal.insertAt(p.gpa, f.content, .{ .row = r.b.row, .col = r.b.col + 1 }, &[1]u8{pr.c}) catch return;
+ p.setFileContent(f, new);
+ new = modal.insertAt(p.gpa, f.content, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return;
+ p.setFileContent(f, new);
+ pane.msel.active = false;
+ pane.vsel = .{ .active = true, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false };
+ pane.cur_row = @intCast(r.b.row);
+ pane.cur_col = @intCast(r.b.col + 1 + @as(usize, if (r.a.row == r.b.row) 1 else 0));
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+ }
+
+ /// `md<ch>`: delete the enclosing pair's chars; the cursor maps through
+ fn surroundDelete(p: *Pardes, pane: *Pane, pl: PaneLines, ch: u21) void {
+ if (pane.file == null) return; // shell output is immutable
+ const pr = pairFor(ch) orelse return;
+ const f = &pane.file.?;
+ const text = p.flatSurface(pane, pl) catch return;
+ const cur = toModalCursor(pane, pl);
+ const r = (if (pr.o == pr.c)
+ modal.enclosingQuote(pl.lines, cur, pr.o)
+ else
+ modal.enclosingPair(pl.lines, cur, pr.o, pr.c)) orelse return;
+ p.pushUndo(pane);
+ const a_off = modal.hxOff(text, r.a);
+ const b_off = modal.hxOff(text, r.b);
+ const cur_off = modal.hxOff(text, cur);
+ // the close first, so the open's position stays valid
+ var new = modal.deleteChar(p.gpa, f.content, r.b) catch return;
+ p.setFileContent(f, new);
+ new = modal.deleteChar(p.gpa, f.content, r.a) catch return;
+ p.setFileContent(f, new);
+ var nc = cur_off;
+ if (nc > b_off) nc -= 1;
+ if (nc > a_off) nc -= 1;
+ const cc = modal.hxPos(f.content, nc);
+ pane.cur_row = @intCast(cc.row);
+ pane.cur_col = @intCast(cc.col);
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ }
+
+ /// `mr<from><to>`: swap the enclosing <from> pair's chars for <to>'s
+ fn surroundReplace(p: *Pardes, pane: *Pane, pl: PaneLines, from: u21, to: u21) void {
+ if (pane.file == null) return; // shell output is immutable
+ const fp = pairFor(from) orelse return;
+ const tp = pairFor(to) orelse return;
+ const f = &pane.file.?;
+ const cur = toModalCursor(pane, pl);
+ const r = (if (fp.o == fp.c)
+ modal.enclosingQuote(pl.lines, cur, fp.o)
+ else
+ modal.enclosingPair(pl.lines, cur, fp.o, fp.c)) orelse return;
+ p.pushUndo(pane);
+ const out = p.gpa.dupe(u8, f.content) catch return;
+ out[modal.lineStartOffset(out, r.a.row) + r.a.col] = tp.o;
+ out[modal.lineStartOffset(out, r.b.row) + r.b.col] = tp.c;
+ p.setFileContent(f, out);
+ }
+
// ---- dumb undo/redo: whole-state snapshots, one per edit op ----
fn setFileContent(p: *Pardes, f: *File, new: []u8) void {
@@ -2192,14 +3385,19 @@ pub const Pardes = struct {
fn pushUndo(p: *Pardes, pane: *Pane) void {
if (pane.file) |*f| {
- if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1], f.content)) return;
- const snap = p.gpa.dupe(u8, f.content) catch return;
+ if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1].content, f.content)) return;
+ const snap: FileSnap = .{
+ .content = p.gpa.dupe(u8, f.content) catch return,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ };
f.undo.append(p.gpa, snap) catch {
- p.gpa.free(snap);
+ p.gpa.free(snap.content);
return;
};
- if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0));
- for (f.redo.items) |s| p.gpa.free(s);
+ if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0).content);
+ for (f.redo.items) |s| p.gpa.free(s.content);
f.redo.clearRetainingCapacity();
} else {
const snap = snapTerm(pane);
@@ -2213,14 +3411,19 @@ pub const Pardes = struct {
fn doUndo(p: *Pardes, pane: *Pane) void {
if (pane.file) |*f| {
if (f.undo.items.len == 0) return;
- const cur = p.gpa.dupe(u8, f.content) catch return;
+ const cur: FileSnap = .{
+ .content = p.gpa.dupe(u8, f.content) catch return,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ };
f.redo.append(p.gpa, cur) catch {
- p.gpa.free(cur);
+ p.gpa.free(cur.content);
return;
};
const prev = f.undo.pop() orelse return;
- p.setFileContent(f, prev);
- clampCursorToFile(pane, f);
+ p.setFileContent(f, prev.content);
+ restoreFileSnap(pane, f, prev);
return;
}
if (pane.ed_undo.items.len == 0) return;
@@ -2231,14 +3434,19 @@ pub const Pardes = struct {
fn doRedo(p: *Pardes, pane: *Pane) void {
if (pane.file) |*f| {
if (f.redo.items.len == 0) return;
- const cur = p.gpa.dupe(u8, f.content) catch return;
+ const cur: FileSnap = .{
+ .content = p.gpa.dupe(u8, f.content) catch return,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ };
f.undo.append(p.gpa, cur) catch {
- p.gpa.free(cur);
+ p.gpa.free(cur.content);
return;
};
const next = f.redo.pop() orelse return;
- p.setFileContent(f, next);
- clampCursorToFile(pane, f);
+ p.setFileContent(f, next.content);
+ restoreFileSnap(pane, f, next);
return;
}
if (pane.ed_redo.items.len == 0) return;
@@ -2246,6 +3454,22 @@ pub const Pardes = struct {
restoreTerm(pane, pane.ed_redo.pop() orelse return);
}
+ /// undo/redo restores the selection recorded with the snapshot (helix
+ /// keeps selections in its history transactions)
+ fn restoreFileSnap(pane: *Pane, f: *File, snap: FileSnap) void {
+ const n = modal.lineCount(f.content);
+ const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), if (n == 0) 0 else n - 1);
+ const llen = modal.lineSlice(f.content, row).len;
+ pane.cur_row = @intCast(row);
+ pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen));
+ pane.vsel = snap.vsel;
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+ }
+
fn restoreTerm(pane: *Pane, snap: EditSnap) void {
pane.edits = snap.edits;
pane.nedits = snap.nedits;
@@ -2544,6 +3768,7 @@ pub const Pardes = struct {
Key.down => "\x1b[B",
Key.right => "\x1b[C",
Key.left => "\x1b[D",
+ Key.delete => "\x1b[3~",
else => null,
};
};
@@ -2735,6 +3960,25 @@ pub const Pardes = struct {
return pane;
}
+ /// TEST-ONLY, called by test/hxdiff.zig (the helix differential harness):
+ /// swap the tty_only boot pane for a file pane holding `content` verbatim
+ /// — openFileView minus the disk read (cases carry their buffer inline).
+ /// Unreachable from any shell; keep it dumb.
+ pub fn hxOpenFileContent(p: *Pardes, content: []const u8) !*Pane {
+ const copy = try p.gpa.dupe(u8, content);
+ errdefer p.gpa.free(copy);
+ const path = try p.gpa.dupe(u8, "/hxcase.txt");
+ errdefer p.gpa.free(path);
+ p.deinitPane(p.panes[0].?);
+ p.panes[0] = null;
+ const pane = try p.newDocPane(0);
+ pane.file = .{ .path = path, .content = copy };
+ pane.cur_pinned = true;
+ p.active = 0;
+ p.sync();
+ return pane;
+ }
+
fn openImageView(p: *Pardes, id: usize, path: []const u8, petscii: bool) !*Pane {
const path_copy = try p.gpa.dupe(u8, path);
errdefer p.gpa.free(path_copy);
diff --git a/src/tty.zig b/src/tty.zig
index ed97fd2a..7de76f4d 100644
--- a/src/tty.zig
+++ b/src/tty.zig
@@ -411,6 +411,7 @@ fn mapKey(cp: u21) u21 {
vaxis.Key.end => pardes.Key.end,
vaxis.Key.page_up => pardes.Key.page_up,
vaxis.Key.page_down => pardes.Key.page_down,
+ vaxis.Key.delete => pardes.Key.delete,
else => cp,
};
}
diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl
new file mode 100644
index 00000000..386013bf
--- /dev/null
+++ b/test/hxcases/cases.jsonl
@@ -0,0 +1,360 @@
+{"name": "h-basic", "pane": "file", "text": "abc def\n", "keys": "llh"}
+{"name": "h-basic-tty", "pane": "tty", "text": "abc def\n", "keys": "llh"}
+{"name": "h-crosses-nl", "pane": "file", "text": "ab\ncd\n", "keys": "jh"}
+{"name": "h-crosses-nl-tty", "pane": "tty", "text": "ab\ncd\n", "keys": "jh"}
+{"name": "l-basic", "pane": "file", "text": "abc\n", "keys": "l"}
+{"name": "l-basic-tty", "pane": "tty", "text": "abc\n", "keys": "l"}
+{"name": "l-crosses-nl", "pane": "file", "text": "ab\ncd\n", "keys": "lll"}
+{"name": "l-crosses-nl-tty", "pane": "tty", "text": "ab\ncd\n", "keys": "lll"}
+{"name": "l-count", "pane": "file", "text": "abcdef\n", "keys": "3l"}
+{"name": "l-count-tty", "pane": "tty", "text": "abcdef\n", "keys": "3l"}
+{"name": "h-count", "pane": "file", "text": "abcdef\n", "keys": "5l3h"}
+{"name": "h-count-tty", "pane": "tty", "text": "abcdef\n", "keys": "5l3h"}
+{"name": "l-clamp-eof", "pane": "file", "text": "ab\n", "keys": "9l"}
+{"name": "j-basic", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "j"}
+{"name": "j-basic-tty", "pane": "tty", "text": "aa\nbb\ncc\n", "keys": "j"}
+{"name": "j-onto-nl-cell", "pane": "file", "text": "abcdef\nab\nabcdef\n", "keys": "glj"}
+{"name": "j-onto-nl-cell-tty", "pane": "tty", "text": "abcdef\nab\nabcdef\n", "keys": "glj"}
+{"name": "j-sticky-col", "pane": "file", "text": "abcdef\nab\nabcdef\n", "keys": "gljj"}
+{"name": "j-sticky-col-tty", "pane": "tty", "text": "abcdef\nab\nabcdef\n", "keys": "gljj"}
+{"name": "j-phantom", "pane": "file", "text": "one\ntwo\n", "keys": "jj"}
+{"name": "k-basic", "pane": "file", "text": "aa\nbb\n", "keys": "jk"}
+{"name": "k-basic-tty", "pane": "tty", "text": "aa\nbb\n", "keys": "jk"}
+{"name": "k-sticky-col", "pane": "file", "text": "abcdef\nab\nabcdef\n", "keys": "2jglkk"}
+{"name": "k-sticky-col-tty", "pane": "tty", "text": "abcdef\nab\nabcdef\n", "keys": "2jglkk"}
+{"name": "j-count", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "2j"}
+{"name": "j-count-tty", "pane": "tty", "text": "a\nb\nc\nd\n", "keys": "2j"}
+{"name": "arrow-right-down", "pane": "file", "text": "abc\ndef\n", "keys": "<right><down>"}
+{"name": "arrow-right-down-tty", "pane": "tty", "text": "abc\ndef\n", "keys": "<right><down>"}
+{"name": "arrow-left-up", "pane": "file", "text": "abc\ndef\n", "keys": "j<right><left><up>"}
+{"name": "arrow-left-up-tty", "pane": "tty", "text": "abc\ndef\n", "keys": "j<right><left><up>"}
+{"name": "w-basic", "pane": "file", "text": "alpha beta\n", "keys": "w"}
+{"name": "w-basic-tty", "pane": "tty", "text": "alpha beta\n", "keys": "w"}
+{"name": "w-punct", "pane": "file", "text": "foo.bar baz\n", "keys": "w"}
+{"name": "w-punct-tty", "pane": "tty", "text": "foo.bar baz\n", "keys": "w"}
+{"name": "w-punct-run", "pane": "file", "text": "foo.bar baz\n", "keys": "ww"}
+{"name": "w-punct-run-tty", "pane": "tty", "text": "foo.bar baz\n", "keys": "ww"}
+{"name": "w-count", "pane": "file", "text": "one two three four\n", "keys": "2w"}
+{"name": "w-count-tty", "pane": "tty", "text": "one two three four\n", "keys": "2w"}
+{"name": "w-eol-cross", "pane": "file", "text": "ab cd\nef\n", "keys": "www"}
+{"name": "w-eol-cross-tty", "pane": "tty", "text": "ab cd\nef\n", "keys": "www"}
+{"name": "w-from-empty-line", "pane": "file", "text": "\nabc\n", "keys": "w"}
+{"name": "w-skips-blank", "pane": "file", "text": "aa\n\nbb\n", "keys": "ww"}
+{"name": "w-skips-blank-tty", "pane": "tty", "text": "aa\n\nbb\n", "keys": "ww"}
+{"name": "w-eof-collapse", "pane": "file", "text": "alpha\n", "keys": "ww"}
+{"name": "b-basic", "pane": "file", "text": "alpha beta\n", "keys": "wwb"}
+{"name": "b-basic-tty", "pane": "tty", "text": "alpha beta\n", "keys": "wwb"}
+{"name": "b-bof-noop", "pane": "file", "text": "alpha\n", "keys": "b"}
+{"name": "b-bof-noop-tty", "pane": "tty", "text": "alpha\n", "keys": "b"}
+{"name": "b-from-nl-cell", "pane": "file", "text": "ab\ncd\n", "keys": "llb"}
+{"name": "b-from-nl-cell-tty", "pane": "tty", "text": "ab\ncd\n", "keys": "llb"}
+{"name": "b-count", "pane": "file", "text": "a bb ccc dddd\n", "keys": "gl2b"}
+{"name": "b-count-tty", "pane": "tty", "text": "a bb ccc dddd\n", "keys": "gl2b"}
+{"name": "e-basic", "pane": "file", "text": "alpha beta\n", "keys": "e"}
+{"name": "e-basic-tty", "pane": "tty", "text": "alpha beta\n", "keys": "e"}
+{"name": "e-from-mid", "pane": "file", "text": "alpha beta\n", "keys": "le"}
+{"name": "e-from-mid-tty", "pane": "tty", "text": "alpha beta\n", "keys": "le"}
+{"name": "e-count", "pane": "file", "text": "a bb ccc dddd\n", "keys": "2e"}
+{"name": "e-count-tty", "pane": "tty", "text": "a bb ccc dddd\n", "keys": "2e"}
+{"name": "e-cross-blank", "pane": "file", "text": "aa\n\nbb\n", "keys": "ee"}
+{"name": "e-cross-blank-tty", "pane": "tty", "text": "aa\n\nbb\n", "keys": "ee"}
+{"name": "W-basic", "pane": "file", "text": "foo.bar baz\n", "keys": "W"}
+{"name": "W-basic-tty", "pane": "tty", "text": "foo.bar baz\n", "keys": "W"}
+{"name": "B-basic", "pane": "file", "text": "foo.bar baz\n", "keys": "WB"}
+{"name": "B-basic-tty", "pane": "tty", "text": "foo.bar baz\n", "keys": "WB"}
+{"name": "E-basic", "pane": "file", "text": "foo.bar baz\n", "keys": "E"}
+{"name": "E-basic-tty", "pane": "tty", "text": "foo.bar baz\n", "keys": "E"}
+{"name": "f-basic", "pane": "file", "text": "abcabc\n", "keys": "fc"}
+{"name": "f-basic-tty", "pane": "tty", "text": "abcabc\n", "keys": "fc"}
+{"name": "f-count", "pane": "file", "text": "abcabc\n", "keys": "2fc"}
+{"name": "f-count-tty", "pane": "tty", "text": "abcabc\n", "keys": "2fc"}
+{"name": "f-cross-line", "pane": "file", "text": "one\ntwo\n", "keys": "fw"}
+{"name": "f-cross-line-tty", "pane": "tty", "text": "one\ntwo\n", "keys": "fw"}
+{"name": "f-notfound-noop", "pane": "file", "text": "abc\n", "keys": "fz"}
+{"name": "f-notfound-noop-tty", "pane": "tty", "text": "abc\n", "keys": "fz"}
+{"name": "f-digit-arg", "pane": "file", "text": "a4b\n", "keys": "f4"}
+{"name": "f-digit-arg-tty", "pane": "tty", "text": "a4b\n", "keys": "f4"}
+{"name": "f-repeat-same-target", "pane": "file", "text": "abcabc\n", "keys": "fafa"}
+{"name": "t-basic", "pane": "file", "text": "abcabc\n", "keys": "tc"}
+{"name": "t-basic-tty", "pane": "tty", "text": "abcabc\n", "keys": "tc"}
+{"name": "t-twice-skips-edge", "pane": "file", "text": "abcabc\n", "keys": "tctc"}
+{"name": "t-twice-skips-edge-tty", "pane": "tty", "text": "abcabc\n", "keys": "tctc"}
+{"name": "F-backward", "pane": "file", "text": "abcabc\n", "keys": "glFa"}
+{"name": "F-backward-tty", "pane": "tty", "text": "abcabc\n", "keys": "glFa"}
+{"name": "T-backward", "pane": "file", "text": "abcabc\n", "keys": "glTa"}
+{"name": "T-backward-tty", "pane": "tty", "text": "abcabc\n", "keys": "glTa"}
+{"name": "alt-dot-repeat-f", "pane": "file", "text": "abcabc\n", "keys": "fc<A-.>"}
+{"name": "alt-dot-repeat-f-tty", "pane": "tty", "text": "abcabc\n", "keys": "fc<A-.>"}
+{"name": "alt-dot-repeat-t", "pane": "file", "text": "abcabc\n", "keys": "tc<A-.>"}
+{"name": "alt-dot-repeat-t-tty", "pane": "tty", "text": "abcabc\n", "keys": "tc<A-.>"}
+{"name": "alt-dot-count", "pane": "file", "text": "axbxcxd\n", "keys": "fx2<A-.>"}
+{"name": "alt-dot-count-tty", "pane": "tty", "text": "axbxcxd\n", "keys": "fx2<A-.>"}
+{"name": "G-count", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "5G"}
+{"name": "G-count-tty", "pane": "tty", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "5G"}
+{"name": "G-count-clamp", "pane": "file", "text": "one\ntwo\n", "keys": "99G"}
+{"name": "G-count-clamp-tty", "pane": "tty", "text": "one\ntwo\n", "keys": "99G"}
+{"name": "G-bare-noop", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "jG"}
+{"name": "G-bare-noop-tty", "pane": "tty", "text": "aa\nbb\ncc\n", "keys": "jG"}
+{"name": "count-multidigit", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "10j"}
+{"name": "count-multidigit-tty", "pane": "tty", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "10j"}
+{"name": "G-count-resets-col", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "3l5G"}
+{"name": "G-count-resets-col-tty", "pane": "tty", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "3l5G"}
+{"name": "gg-basic", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "jjgg"}
+{"name": "gg-basic-tty", "pane": "tty", "text": "aa\nbb\ncc\n", "keys": "jjgg"}
+{"name": "gg-count", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "3gg"}
+{"name": "gg-count-tty", "pane": "tty", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "3gg"}
+{"name": "ge-basic", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "ge"}
+{"name": "ge-basic-tty", "pane": "tty", "text": "aa\nbb\ncc\n", "keys": "ge"}
+{"name": "ge-col0", "pane": "file", "text": "alpha beta\n", "keys": "wwge"}
+{"name": "ge-col0-tty", "pane": "tty", "text": "alpha beta\n", "keys": "wwge"}
+{"name": "gh-basic", "pane": "file", "text": "abc\n", "keys": "llgh"}
+{"name": "gh-basic-tty", "pane": "tty", "text": "abc\n", "keys": "llgh"}
+{"name": "gl-basic", "pane": "file", "text": "abc def\n", "keys": "gl"}
+{"name": "gl-basic-tty", "pane": "tty", "text": "abc def\n", "keys": "gl"}
+{"name": "gl-empty-line", "pane": "file", "text": "\nabc\n", "keys": "gl"}
+{"name": "gl-empty-line-tty", "pane": "tty", "text": "\nabc\n", "keys": "gl"}
+{"name": "gs-basic", "pane": "file", "text": " abc\n", "keys": "llgs"}
+{"name": "gs-basic-tty", "pane": "tty", "text": " abc\n", "keys": "llgs"}
+{"name": "gs-allws-noop", "pane": "file", "text": " \nx\n", "keys": "gs"}
+{"name": "gs-allws-noop-tty", "pane": "tty", "text": " \nx\n", "keys": "gs"}
+{"name": "gbar-count", "pane": "file", "text": "abcdef\n", "keys": "4g|"}
+{"name": "gbar-count-tty", "pane": "tty", "text": "abcdef\n", "keys": "4g|"}
+{"name": "gbar-clamp", "pane": "file", "text": "ab\n", "keys": "9g|"}
+{"name": "gbar-clamp-tty", "pane": "tty", "text": "ab\n", "keys": "9g|"}
+{"name": "gbar-nocount", "pane": "file", "text": "abcdef\n", "keys": "llg|"}
+{"name": "gbar-nocount-tty", "pane": "tty", "text": "abcdef\n", "keys": "llg|"}
+{"name": "gj-basic", "pane": "file", "text": "abcdef\nab\nabcdef\n", "keys": "glgjgj"}
+{"name": "gj-basic-tty", "pane": "tty", "text": "abcdef\nab\nabcdef\n", "keys": "glgjgj"}
+{"name": "gk-basic", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "jjgk"}
+{"name": "gk-basic-tty", "pane": "tty", "text": "aa\nbb\ncc\n", "keys": "jjgk"}
+{"name": "home-basic", "pane": "file", "text": "abc def\n", "keys": "w<home>"}
+{"name": "home-basic-tty", "pane": "tty", "text": "abc def\n", "keys": "w<home>"}
+{"name": "end-basic", "pane": "file", "text": "abc def\n", "keys": "<end>"}
+{"name": "end-basic-tty", "pane": "tty", "text": "abc def\n", "keys": "<end>"}
+{"name": "end-empty-line", "pane": "file", "text": "\nabc\n", "keys": "<end>"}
+{"name": "end-empty-line-tty", "pane": "tty", "text": "\nabc\n", "keys": "<end>"}
+{"name": "gt-noscroll", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "5jgt"}
+{"name": "gt-noscroll-tty", "pane": "tty", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "5jgt"}
+{"name": "gc-noscroll", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "gc"}
+{"name": "gb-noscroll", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "5jgb"}
+{"name": "gt-after-zz", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "15jzzgt"}
+{"name": "gc-after-zz", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "15jzzgc"}
+{"name": "gb-after-zz", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "15jzzgb"}
+{"name": "gt-count", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "15jzz2gt"}
+{"name": "ctrl-d-basic", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "<C-d>"}
+{"name": "ctrl-d-small", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "<C-d>"}
+{"name": "ctrl-d-sticky", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "5l<C-d>"}
+{"name": "ctrl-u-back", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "20j<C-u>"}
+{"name": "ctrl-f-page", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "<C-f>"}
+{"name": "ctrl-f-small", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "<C-f>"}
+{"name": "ctrl-b-page", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "20j<C-b>"}
+{"name": "pgdn-basic", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "<pagedown>"}
+{"name": "pgup-basic", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "25j<pageup>"}
+{"name": "pgup-noop-in-view", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "5j<pageup>"}
+{"name": "pgup-noop-in-view-tty", "pane": "tty", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n", "keys": "5j<pageup>"}
+{"name": "v-ctrl-d-extends", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "v<C-d>"}
+{"name": "zt-then-gt", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "12jztgt"}
+{"name": "zz-then-gt", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "15jzzgt"}
+{"name": "zb-then-gt", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "12jzbgt"}
+{"name": "zc-alias-zz", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "15jzcgt"}
+{"name": "zj-snaps-cursor", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "zj"}
+{"name": "zj-noop-in-band", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "10jzj"}
+{"name": "zk-bottom-snap", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "20jzk"}
+{"name": "z-ctrl-d-alias", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "z<C-d>"}
+{"name": "z-pgdn-alias", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "z<pagedown>"}
+{"name": "v-reports-select", "pane": "file", "text": "alpha beta\n", "keys": "v"}
+{"name": "v-reports-select-tty", "pane": "tty", "text": "alpha beta\n", "keys": "v"}
+{"name": "v-extend-w", "pane": "file", "text": "alpha beta gamma\n", "keys": "vww"}
+{"name": "v-extend-w-tty", "pane": "tty", "text": "alpha beta gamma\n", "keys": "vww"}
+{"name": "v-anchor-fixed-crossing", "pane": "file", "text": "abcdef\n", "keys": "llvhhh"}
+{"name": "v-anchor-fixed-crossing-tty", "pane": "tty", "text": "abcdef\n", "keys": "llvhhh"}
+{"name": "v-exit-keeps-sel", "pane": "file", "text": "alpha beta\n", "keys": "vwv"}
+{"name": "v-exit-keeps-sel-tty", "pane": "tty", "text": "alpha beta\n", "keys": "vwv"}
+{"name": "v-esc-keeps-sel", "pane": "file", "text": "alpha beta\n", "keys": "vw<esc>"}
+{"name": "v-esc-keeps-sel-tty", "pane": "tty", "text": "alpha beta\n", "keys": "vw<esc>"}
+{"name": "v-gl-extends", "pane": "file", "text": "abc def\n", "keys": "vgl"}
+{"name": "v-gl-extends-tty", "pane": "tty", "text": "abc def\n", "keys": "vgl"}
+{"name": "v-gg-extends", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "jjvgg"}
+{"name": "v-gg-extends-tty", "pane": "tty", "text": "aa\nbb\ncc\n", "keys": "jjvgg"}
+{"name": "v-find-extends", "pane": "file", "text": "abcabc\n", "keys": "vfcfc"}
+{"name": "v-find-extends-tty", "pane": "tty", "text": "abcabc\n", "keys": "vfcfc"}
+{"name": "v-x-extends-line", "pane": "file", "text": "ab\ncd\nef\n", "keys": "vjx"}
+{"name": "v-x-extends-line-tty", "pane": "tty", "text": "ab\ncd\nef\n", "keys": "vjx"}
+{"name": "v-j-phantom-blocked", "pane": "file", "text": "one\ntwo\n", "keys": "vjj"}
+{"name": "w-esc-keeps-sel", "pane": "file", "text": "alpha beta\n", "keys": "w<esc>"}
+{"name": "w-esc-keeps-sel-tty", "pane": "tty", "text": "alpha beta\n", "keys": "w<esc>"}
+{"name": "semi-collapse", "pane": "file", "text": "alpha beta\n", "keys": "w;"}
+{"name": "semi-collapse-tty", "pane": "tty", "text": "alpha beta\n", "keys": "w;"}
+{"name": "altsemi-flip", "pane": "file", "text": "alpha beta\n", "keys": "w<A-;>"}
+{"name": "altsemi-flip-tty", "pane": "tty", "text": "alpha beta\n", "keys": "w<A-;>"}
+{"name": "altsemi-flip-x", "pane": "file", "text": "one\ntwo\ncc\n", "keys": "x<A-;>"}
+{"name": "altsemi-flip-x-tty", "pane": "tty", "text": "one\ntwo\ncc\n", "keys": "x<A-;>"}
+{"name": "percent-select-all", "pane": "file", "text": "one\ntwo\n", "keys": "%"}
+{"name": "percent-then-d", "pane": "file", "text": "one\ntwo\n", "keys": "%d"}
+{"name": "x-selects-line", "pane": "file", "text": "one\ntwo\nthree\n", "keys": "x"}
+{"name": "x-selects-line-tty", "pane": "tty", "text": "one\ntwo\nthree\n", "keys": "x"}
+{"name": "x-extends", "pane": "file", "text": "one\ntwo\nthree\n", "keys": "xx"}
+{"name": "x-extends-tty", "pane": "tty", "text": "one\ntwo\nthree\n", "keys": "xx"}
+{"name": "x-count", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "2x"}
+{"name": "x-count-tty", "pane": "tty", "text": "a\nb\nc\nd\n", "keys": "2x"}
+{"name": "x-empty-line", "pane": "file", "text": "a\n\nb\nc\n", "keys": "jx"}
+{"name": "x-empty-line-tty", "pane": "tty", "text": "a\n\nb\nc\n", "keys": "jx"}
+{"name": "x-last-line", "pane": "file", "text": "one\ntwo\n", "keys": "jx"}
+{"name": "x-then-w-drops", "pane": "file", "text": "aa\nbb cc\n", "keys": "xw"}
+{"name": "x-then-w-drops-tty", "pane": "tty", "text": "aa\nbb cc\n", "keys": "xw"}
+{"name": "x-then-d", "pane": "file", "text": "one\ntwo\nthree\n", "keys": "xd"}
+{"name": "X-bare", "pane": "file", "text": "abc\ndef\n", "keys": "lX"}
+{"name": "X-bare-tty", "pane": "tty", "text": "abc\ndef\n", "keys": "lX"}
+{"name": "X-from-v", "pane": "file", "text": "abc\ndef\nghi\n", "keys": "lvjX"}
+{"name": "X-from-v-tty", "pane": "tty", "text": "abc\ndef\nghi\n", "keys": "lvjX"}
+{"name": "X-keeps-direction", "pane": "file", "text": "abc\ndef\nghi\n", "keys": "jlvkX"}
+{"name": "X-keeps-direction-tty", "pane": "tty", "text": "abc\ndef\nghi\n", "keys": "jlvkX"}
+{"name": "X-noop-when-full", "pane": "file", "text": "abc\ndef\n", "keys": "XX"}
+{"name": "altx-shrink", "pane": "file", "text": "abc\ndef\nghi\n", "keys": "lv2j<A-x>"}
+{"name": "altx-shrink-tty", "pane": "tty", "text": "abc\ndef\nghi\n", "keys": "lv2j<A-x>"}
+{"name": "altx-single-line-noop", "pane": "file", "text": "abcdef\n", "keys": "lvll<A-x>"}
+{"name": "altx-single-line-noop-tty", "pane": "tty", "text": "abcdef\n", "keys": "lvll<A-x>"}
+{"name": "mm-basic", "pane": "file", "text": "a(bc(d)e)f\n", "keys": "lmm"}
+{"name": "mm-basic-tty", "pane": "tty", "text": "a(bc(d)e)f\n", "keys": "lmm"}
+{"name": "mm-back", "pane": "file", "text": "a(bc(d)e)f\n", "keys": "8lmm"}
+{"name": "mm-back-tty", "pane": "tty", "text": "a(bc(d)e)f\n", "keys": "8lmm"}
+{"name": "mm-nonbracket-noop", "pane": "file", "text": "abc\n", "keys": "mm"}
+{"name": "mm-nonbracket-noop-tty", "pane": "tty", "text": "abc\n", "keys": "mm"}
+{"name": "mi-paren", "pane": "file", "text": "fn(a, b)\n", "keys": "fami("}
+{"name": "mi-paren-tty", "pane": "tty", "text": "fn(a, b)\n", "keys": "fami("}
+{"name": "ma-paren", "pane": "file", "text": "fn(a, b)\n", "keys": "fama("}
+{"name": "ma-paren-tty", "pane": "tty", "text": "fn(a, b)\n", "keys": "fama("}
+{"name": "mi-nested", "pane": "file", "text": "(a(b)c)\n", "keys": "3lmi("}
+{"name": "mi-nested-tty", "pane": "tty", "text": "(a(b)c)\n", "keys": "3lmi("}
+{"name": "mi-word", "pane": "file", "text": "alpha beta\n", "keys": "wwmiw"}
+{"name": "mi-word-tty", "pane": "tty", "text": "alpha beta\n", "keys": "wwmiw"}
+{"name": "mi-WORD", "pane": "file", "text": "a.b c.d\n", "keys": "miW"}
+{"name": "mi-WORD-tty", "pane": "tty", "text": "a.b c.d\n", "keys": "miW"}
+{"name": "ma-word", "pane": "file", "text": "alpha beta gamma\n", "keys": "wwmaw"}
+{"name": "mi-quote", "pane": "file", "text": "say \"hi there\" ok\n", "keys": "6lmi\""}
+{"name": "ma-quote", "pane": "file", "text": "say \"hi there\" ok\n", "keys": "6lma\""}
+{"name": "mi-para", "pane": "file", "text": "aa\nbb\n\ncc\n", "keys": "mip"}
+{"name": "mi-para-tty", "pane": "tty", "text": "aa\nbb\n\ncc\n", "keys": "mip"}
+{"name": "ma-para", "pane": "file", "text": "aa\nbb\n\ncc\n", "keys": "map"}
+{"name": "mi-para-blank", "pane": "file", "text": "aa\n\ncc\n", "keys": "jmip"}
+{"name": "mi-empty-pair", "pane": "file", "text": "a()b\n", "keys": "lmi("}
+{"name": "par-fwd", "pane": "file", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "]p"}
+{"name": "par-fwd-tty", "pane": "tty", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "]p"}
+{"name": "par-fwd-count", "pane": "file", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "2]p"}
+{"name": "par-fwd-count-tty", "pane": "tty", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "2]p"}
+{"name": "par-bwd", "pane": "file", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "6j[p"}
+{"name": "par-bwd-tty", "pane": "tty", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "6j[p"}
+{"name": "par-fwd-mid", "pane": "file", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "j]p"}
+{"name": "par-fwd-mid-tty", "pane": "tty", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "j]p"}
+{"name": "par-bwd-mid", "pane": "file", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "4j[p"}
+{"name": "par-bwd-mid-tty", "pane": "tty", "text": "aa\nbb\n\ncc\ndd\n\nee\n", "keys": "4j[p"}
+{"name": "par-fwd-at-end", "pane": "file", "text": "aa\n\nbb\n", "keys": "2j]p"}
+{"name": "add-nl-below", "pane": "file", "text": "aa\nbb\n", "keys": "]<space>"}
+{"name": "add-nl-above", "pane": "file", "text": "aa\nbb\n", "keys": "j[<space>"}
+{"name": "add-nl-count", "pane": "file", "text": "aa\nbb\n", "keys": "2]<space>"}
+{"name": "d-word", "pane": "file", "text": "alpha beta\n", "keys": "wd"}
+{"name": "d-bare-char", "pane": "file", "text": "abc\n", "keys": "d"}
+{"name": "d-on-nl-joins", "pane": "file", "text": "ab\ncd\n", "keys": "lld"}
+{"name": "d-x-lines", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "xd"}
+{"name": "d-percent-all", "pane": "file", "text": "one\ntwo\n", "keys": "%d"}
+{"name": "alt-d-noyank", "pane": "file", "text": "alpha beta\n", "keys": "w<A-d>"}
+{"name": "alt-c-window-op", "pane": "file", "text": "abc def\n", "keys": "<A-c>"}
+{"name": "d-then-p", "pane": "file", "text": "alpha beta\n", "keys": "wdp"}
+{"name": "c-word", "pane": "file", "text": "alpha beta gamma\n", "keys": "wcX<esc>"}
+{"name": "c-bare", "pane": "file", "text": "abc\n", "keys": "cZ<esc>"}
+{"name": "c-linewise", "pane": "file", "text": "aaa\nbbb\n", "keys": "xcX<esc>"}
+{"name": "c-linewise-indent", "pane": "file", "text": " aa\nbb\n", "keys": "xcZ<esc>"}
+{"name": "y-keeps-sel", "pane": "file", "text": "alpha beta\n", "keys": "wy"}
+{"name": "y-keeps-sel-tty", "pane": "tty", "text": "alpha beta\n", "keys": "wy"}
+{"name": "y-bare-char", "pane": "file", "text": "abc def\n", "keys": "yp"}
+{"name": "y-bare-eol", "pane": "file", "text": "ab\ncd\n", "keys": "glyp"}
+{"name": "yp-word", "pane": "file", "text": "alpha beta\n", "keys": "weyp"}
+{"name": "xyp-linewise", "pane": "file", "text": "one\ntwo\n", "keys": "xyp"}
+{"name": "xyp-below-end-line", "pane": "file", "text": "one\ntwo\n", "keys": "xyjp"}
+{"name": "xyP-linewise", "pane": "file", "text": "one\ntwo\n", "keys": "xyP"}
+{"name": "vlyp-inline", "pane": "file", "text": "abcd\n", "keys": "vlyp"}
+{"name": "vlyP-inline", "pane": "file", "text": "abcd\n", "keys": "vlyP"}
+{"name": "p-count", "pane": "file", "text": "ab\n", "keys": "vy2p"}
+{"name": "p-after-sel-end", "pane": "file", "text": "abcd\n", "keys": "vlyllp"}
+{"name": "wP-before", "pane": "file", "text": "one two\n", "keys": "wyggwP"}
+{"name": "u-restores-sel", "pane": "file", "text": "alpha beta\n", "keys": "wdu"}
+{"name": "u-insert-session", "pane": "file", "text": "abc\n", "keys": "ixyz<esc>u"}
+{"name": "U-redo", "pane": "file", "text": "alpha beta\n", "keys": "wduU"}
+{"name": "u-linewise", "pane": "file", "text": "aaa\nbbb\n", "keys": "xdu"}
+{"name": "r-bare", "pane": "file", "text": "abc\n", "keys": "rz"}
+{"name": "r-sel-keeps", "pane": "file", "text": "abcdef\n", "keys": "vllrz"}
+{"name": "r-x-replaces-nl", "pane": "file", "text": "ab\ncd\n", "keys": "xrz"}
+{"name": "r-cancel-esc", "pane": "file", "text": "abc\n", "keys": "r<esc>l"}
+{"name": "R-inline", "pane": "file", "text": "alpha beta\n", "keys": "wyggR"}
+{"name": "R-linewise", "pane": "file", "text": "one\ntwo\n", "keys": "xyjR"}
+{"name": "tilde-sel-keeps", "pane": "file", "text": "abc\n", "keys": "vll~"}
+{"name": "tilde-bare", "pane": "file", "text": "aBc\n", "keys": "~"}
+{"name": "backtick-lower", "pane": "file", "text": "ABC\n", "keys": "vll`"}
+{"name": "alt-backtick-upper", "pane": "file", "text": "abc\n", "keys": "vll<A-`>"}
+{"name": "J-cursor-stays", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "J"}
+{"name": "J-sel-lines", "pane": "file", "text": "aa\nbb\ncc\ndd\n", "keys": "xxJ"}
+{"name": "J-ws-collapse", "pane": "file", "text": "aa\n bb\n", "keys": "J"}
+{"name": "J-empty-next-nospace", "pane": "file", "text": "aa\n\nbb\n", "keys": "J"}
+{"name": "J-last-line-eats-nl", "pane": "file", "text": "aa\nbb\n", "keys": "jJ"}
+{"name": "J-count-ignored", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "3J"}
+{"name": "indent-basic", "pane": "file", "text": "foo\nbar\n", "keys": "<gt>"}
+{"name": "indent-realign", "pane": "file", "text": " x\n", "keys": "gs<gt>"}
+{"name": "indent-cursor-maps", "pane": "file", "text": "foo\n", "keys": "ll<gt>"}
+{"name": "indent-x-sel", "pane": "file", "text": "a\n\nb\n", "keys": "x2x<gt>"}
+{"name": "indent-count", "pane": "file", "text": "x\n", "keys": "2<gt>"}
+{"name": "unindent-basic", "pane": "file", "text": " x\n", "keys": "gs<lt>"}
+{"name": "unindent-tab", "pane": "file", "text": "\tx\n", "keys": "<lt>"}
+{"name": "unindent-partial", "pane": "file", "text": " x\n", "keys": "<lt>"}
+{"name": "unindent-cursor", "pane": "file", "text": " ab\n", "keys": "gl<lt>"}
+{"name": "ctrl-a-sel", "pane": "file", "text": "15 x\n", "keys": "vl<C-a>"}
+{"name": "ctrl-a-bare-digit", "pane": "file", "text": "7\n", "keys": "<C-a>"}
+{"name": "ctrl-a-noop-nonint", "pane": "file", "text": "a 15 b\n", "keys": "f1<C-a>"}
+{"name": "ctrl-a-neg-sel", "pane": "file", "text": "-3\n", "keys": "vl<C-a>"}
+{"name": "ctrl-a-pad", "pane": "file", "text": "007\n", "keys": "vll<C-a>"}
+{"name": "ctrl-a-count", "pane": "file", "text": "5\n", "keys": "3<C-a>"}
+{"name": "ctrl-x-basic", "pane": "file", "text": "9\n", "keys": "<C-x>"}
+{"name": "ms-sel", "pane": "file", "text": "hello\n", "keys": "vllms("}
+{"name": "ms-bare", "pane": "file", "text": "ab\n", "keys": "ms("}
+{"name": "ms-quote-self", "pane": "file", "text": "ab cd\n", "keys": "wems'"}
+{"name": "md-basic", "pane": "file", "text": "a(bcd)e\n", "keys": "3lmd("}
+{"name": "md-quote", "pane": "file", "text": "a 'bc' d\n", "keys": "3lmd'"}
+{"name": "mr-basic", "pane": "file", "text": "a(bcd)e\n", "keys": "3lmr(["}
+{"name": "mr-to-quote", "pane": "file", "text": "a 'bc' d\n", "keys": "3lmr'\""}
+{"name": "o-basic", "pane": "file", "text": "ab\n", "keys": "ox<esc>"}
+{"name": "o-copies-indent-level", "pane": "file", "text": " foo\n", "keys": "o"}
+{"name": "o-partial-indent-drops", "pane": "file", "text": " foo\n", "keys": "o"}
+{"name": "o-count", "pane": "file", "text": "ab\n", "keys": "2o<esc>"}
+{"name": "O-basic", "pane": "file", "text": "aa\nbb\n", "keys": "jOx<esc>"}
+{"name": "O-copies-indent", "pane": "file", "text": " foo\n", "keys": "O"}
+{"name": "i-type-esc", "pane": "file", "text": "alpha\n", "keys": "ihello <esc>"}
+{"name": "i-keeps-sel-start", "pane": "file", "text": "alpha beta\n", "keys": "wi"}
+{"name": "wa-sel-end", "pane": "file", "text": "alpha beta\n", "keys": "wa"}
+{"name": "wiX-edit-drops-sel", "pane": "file", "text": "alpha beta\n", "keys": "wiX<esc>"}
+{"name": "a-esc-restore", "pane": "file", "text": "abc\n", "keys": "a<esc>"}
+{"name": "ax-restore", "pane": "file", "text": "abc\n", "keys": "ax<esc>"}
+{"name": "lax-restore", "pane": "file", "text": "abc\n", "keys": "lax<esc>"}
+{"name": "A-eol", "pane": "file", "text": "abc\ndef\n", "keys": "A<esc>"}
+{"name": "A-type", "pane": "file", "text": "one two\n", "keys": "AX<esc>"}
+{"name": "I-firstnonws", "pane": "file", "text": " abc\n", "keys": "llI"}
+{"name": "esc-pending-cancel", "pane": "file", "text": "abc\n", "keys": "f<esc>l"}
+{"name": "ins-enter-mid", "pane": "file", "text": "ab\n", "keys": "a<ret><esc>"}
+{"name": "ins-enter-indent", "pane": "file", "text": " foo\n", "keys": "A<ret>x<esc>"}
+{"name": "ins-backspace", "pane": "file", "text": "abc\n", "keys": "lli<backspace><esc>"}
+{"name": "ins-backspace-join", "pane": "file", "text": "ab\ncd\n", "keys": "ji<backspace><esc>"}
+{"name": "ins-ctrl-w", "pane": "file", "text": "one two\n", "keys": "A<C-w><esc>"}
+{"name": "ins-ctrl-w-col0-join", "pane": "file", "text": "ab\ncd\n", "keys": "ji<C-w><esc>"}
+{"name": "ins-alt-bs", "pane": "file", "text": "one two\n", "keys": "A<A-backspace><esc>"}
+{"name": "ins-alt-d", "pane": "file", "text": "one two\n", "keys": "i<A-d><esc>"}
+{"name": "ins-alt-del", "pane": "file", "text": "one two\n", "keys": "i<A-del><esc>"}
+{"name": "ins-ctrl-u", "pane": "file", "text": "one two\n", "keys": "A<C-u><esc>"}
+{"name": "ins-ctrl-k", "pane": "file", "text": "one two\n", "keys": "wi<C-k><esc>"}
+{"name": "ins-ctrl-h", "pane": "file", "text": "abc\n", "keys": "lli<C-h><esc>"}
+{"name": "ins-ctrl-d", "pane": "file", "text": "abc\n", "keys": "i<C-d><esc>"}
+{"name": "ins-del-eol-join", "pane": "file", "text": "ab\ncd\n", "keys": "gla<del><esc>"}
+{"name": "ins-ctrl-j", "pane": "file", "text": "ab\n", "keys": "a<C-j><esc>"}
+{"name": "ins-home", "pane": "file", "text": " abc\n", "keys": "gli<home>x<esc>"}
+{"name": "ins-end", "pane": "file", "text": " abc\n", "keys": "i<end>x<esc>"}
+{"name": "ins-pgdn", "pane": "file", "text": "l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n", "keys": "i<pagedown><esc>"}
+{"name": "ins-arrows", "pane": "file", "text": "abc\ndef\n", "keys": "i<down><right>x<esc>"}
+{"name": "ins-tab-col0", "pane": "file", "text": "ab\n", "keys": "i<tab><esc>"}
+{"name": "ins-tab-mid", "pane": "file", "text": "ab\n", "keys": "li<tab><esc>"}
diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl
new file mode 100644
index 00000000..baa45d34
--- /dev/null
+++ b/test/hxcases/goldens.jsonl
@@ -0,0 +1,360 @@
+{"name":"h-basic","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"h-basic-tty","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"h-crosses-nl","text":"ab\ncd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"h-crosses-nl-tty","text":"ab\ncd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"l-basic","text":"abc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"l-basic-tty","text":"abc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"l-crosses-nl","text":"ab\ncd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"l-crosses-nl-tty","text":"ab\ncd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"l-count","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"l-count-tty","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"h-count","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"h-count-tty","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"l-clamp-eof","text":"ab\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"j-basic","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"j-basic-tty","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"j-onto-nl-cell","text":"abcdef\nab\nabcdef\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}
+{"name":"j-onto-nl-cell-tty","text":"abcdef\nab\nabcdef\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}
+{"name":"j-sticky-col","text":"abcdef\nab\nabcdef\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}}
+{"name":"j-sticky-col-tty","text":"abcdef\nab\nabcdef\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}}
+{"name":"j-phantom","text":"one\ntwo\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"k-basic","text":"aa\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"k-basic-tty","text":"aa\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"k-sticky-col","text":"abcdef\nab\nabcdef\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}}
+{"name":"k-sticky-col-tty","text":"abcdef\nab\nabcdef\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}}
+{"name":"j-count","text":"a\nb\nc\nd\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"j-count-tty","text":"a\nb\nc\nd\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"arrow-right-down","text":"abc\ndef\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}
+{"name":"arrow-right-down-tty","text":"abc\ndef\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}
+{"name":"arrow-left-up","text":"abc\ndef\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"arrow-left-up-tty","text":"abc\ndef\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"w-basic","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"w-basic-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"w-punct","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"w-punct-tty","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"w-punct-run","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"w-punct-run-tty","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"w-count","text":"one two three four\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":4}}
+{"name":"w-count-tty","text":"one two three four\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":4}}
+{"name":"w-eol-cross","text":"ab cd\nef\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}
+{"name":"w-eol-cross-tty","text":"ab cd\nef\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}
+{"name":"w-from-empty-line","text":"\nabc\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}}
+{"name":"w-skips-blank","text":"aa\n\nbb\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":0}}
+{"name":"w-skips-blank-tty","text":"aa\n\nbb\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":0}}
+{"name":"w-eof-collapse","text":"alpha\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"b-basic","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":9}}
+{"name":"b-basic-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":9}}
+{"name":"b-bof-noop","text":"alpha\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"b-bof-noop-tty","text":"alpha\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"b-from-nl-cell","text":"ab\ncd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":1}}
+{"name":"b-from-nl-cell-tty","text":"ab\ncd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":1}}
+{"name":"b-count","text":"a bb ccc dddd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":8}}
+{"name":"b-count-tty","text":"a bb ccc dddd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":8}}
+{"name":"e-basic","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0}}
+{"name":"e-basic-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0}}
+{"name":"e-from-mid","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":1}}
+{"name":"e-from-mid-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":1}}
+{"name":"e-count","text":"a bb ccc dddd\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":4}}
+{"name":"e-count-tty","text":"a bb ccc dddd\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":4}}
+{"name":"e-cross-blank","text":"aa\n\nbb\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":0}}
+{"name":"e-cross-blank-tty","text":"aa\n\nbb\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":0}}
+{"name":"W-basic","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":0}}
+{"name":"W-basic-tty","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":0}}
+{"name":"B-basic","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":7}}
+{"name":"B-basic-tty","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":7}}
+{"name":"E-basic","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":0}}
+{"name":"E-basic-tty","text":"foo.bar baz\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":0}}
+{"name":"f-basic","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"f-basic-tty","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"f-count","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"f-count-tty","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"f-cross-line","text":"one\ntwo\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"f-cross-line-tty","text":"one\ntwo\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"f-notfound-noop","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"f-notfound-noop-tty","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"f-digit-arg","text":"a4b\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"f-digit-arg-tty","text":"a4b\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"f-repeat-same-target","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"t-basic","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"t-basic-tty","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"t-twice-skips-edge","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":1}}
+{"name":"t-twice-skips-edge-tty","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":1}}
+{"name":"F-backward","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":5}}
+{"name":"F-backward-tty","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":5}}
+{"name":"T-backward","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5}}
+{"name":"T-backward-tty","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5}}
+{"name":"alt-dot-repeat-f","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":2}}
+{"name":"alt-dot-repeat-f-tty","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":2}}
+{"name":"alt-dot-repeat-t","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":1}}
+{"name":"alt-dot-repeat-t-tty","text":"abcabc\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":1}}
+{"name":"alt-dot-count","text":"axbxcxd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":3}}
+{"name":"alt-dot-count-tty","text":"axbxcxd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":3}}
+{"name":"G-count","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}}
+{"name":"G-count-tty","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}}
+{"name":"G-count-clamp","text":"one\ntwo\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"G-count-clamp-tty","text":"one\ntwo\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"G-bare-noop","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"G-bare-noop-tty","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"count-multidigit","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}}
+{"name":"count-multidigit-tty","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}}
+{"name":"G-count-resets-col","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}}
+{"name":"G-count-resets-col-tty","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}}
+{"name":"gg-basic","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gg-basic-tty","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gg-count","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"gg-count-tty","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"ge-basic","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"ge-basic-tty","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"ge-col0","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ge-col0-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gh-basic","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gh-basic-tty","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gl-basic","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}
+{"name":"gl-basic-tty","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}
+{"name":"gl-empty-line","text":"\nabc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gl-empty-line-tty","text":"\nabc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gs-basic","text":" abc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"gs-basic-tty","text":" abc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"gs-allws-noop","text":" \nx\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gs-allws-noop-tty","text":" \nx\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gbar-count","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"gbar-count-tty","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"gbar-clamp","text":"ab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"gbar-clamp-tty","text":"ab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"gbar-nocount","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gbar-nocount-tty","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gj-basic","text":"abcdef\nab\nabcdef\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}}
+{"name":"gj-basic-tty","text":"abcdef\nab\nabcdef\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}}
+{"name":"gk-basic","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"gk-basic-tty","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"home-basic","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"home-basic-tty","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"end-basic","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}
+{"name":"end-basic-tty","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}
+{"name":"end-empty-line","text":"\nabc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"end-empty-line-tty","text":"\nabc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"gt-noscroll","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}}
+{"name":"gt-noscroll-tty","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}}
+{"name":"gc-noscroll","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}}
+{"name":"gb-noscroll","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}}
+{"name":"gt-after-zz","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}}
+{"name":"gc-after-zz","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":15,"col":0},"anchor":{"row":15,"col":0}}
+{"name":"gb-after-zz","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":23,"col":0},"anchor":{"row":23,"col":0}}
+{"name":"gt-count","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}}
+{"name":"ctrl-d-basic","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":11,"col":0},"anchor":{"row":11,"col":0}}
+{"name":"ctrl-d-small","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":11,"col":0},"anchor":{"row":11,"col":0}}
+{"name":"ctrl-d-sticky","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":1},"anchor":{"row":12,"col":1}}
+{"name":"ctrl-u-back","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}}
+{"name":"ctrl-f-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}}
+{"name":"ctrl-f-small","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}
+{"name":"ctrl-b-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":18,"col":0},"anchor":{"row":18,"col":0}}
+{"name":"pgdn-basic","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}}
+{"name":"pgup-basic","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":18,"col":0},"anchor":{"row":18,"col":0}}
+{"name":"pgup-noop-in-view","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}}
+{"name":"pgup-noop-in-view-tty","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}}
+{"name":"v-ctrl-d-extends","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"select","cursor":{"row":11,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"zt-then-gt","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}
+{"name":"zz-then-gt","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}}
+{"name":"zb-then-gt","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}}
+{"name":"zc-alias-zz","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}}
+{"name":"zj-snaps-cursor","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}}
+{"name":"zj-noop-in-band","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}}
+{"name":"zk-bottom-snap","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":19,"col":0},"anchor":{"row":19,"col":0}}
+{"name":"z-ctrl-d-alias","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":11,"col":0},"anchor":{"row":11,"col":0}}
+{"name":"z-pgdn-alias","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}}
+{"name":"v-reports-select","text":"alpha beta\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"v-reports-select-tty","text":"alpha beta\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"v-extend-w","text":"alpha beta gamma\n","mode":"select","cursor":{"row":0,"col":10},"anchor":{"row":0,"col":0}}
+{"name":"v-extend-w-tty","text":"alpha beta gamma\n","mode":"select","cursor":{"row":0,"col":10},"anchor":{"row":0,"col":0}}
+{"name":"v-anchor-fixed-crossing","text":"abcdef\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}}
+{"name":"v-anchor-fixed-crossing-tty","text":"abcdef\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}}
+{"name":"v-exit-keeps-sel","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"v-exit-keeps-sel-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"v-esc-keeps-sel","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"v-esc-keeps-sel-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"v-gl-extends","text":"abc def\n","mode":"select","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":0}}
+{"name":"v-gl-extends-tty","text":"abc def\n","mode":"select","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":0}}
+{"name":"v-gg-extends","text":"aa\nbb\ncc\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"v-gg-extends-tty","text":"aa\nbb\ncc\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"v-find-extends","text":"abcabc\n","mode":"select","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"v-find-extends-tty","text":"abcabc\n","mode":"select","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"v-x-extends-line","text":"ab\ncd\nef\n","mode":"select","cursor":{"row":1,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"v-x-extends-line-tty","text":"ab\ncd\nef\n","mode":"select","cursor":{"row":1,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"v-j-phantom-blocked","text":"one\ntwo\n","mode":"select","cursor":{"row":1,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"w-esc-keeps-sel","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"w-esc-keeps-sel-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"semi-collapse","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}}
+{"name":"semi-collapse-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}}
+{"name":"altsemi-flip","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}}
+{"name":"altsemi-flip-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}}
+{"name":"altsemi-flip-x","text":"one\ntwo\ncc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":3}}
+{"name":"altsemi-flip-x-tty","text":"one\ntwo\ncc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":3}}
+{"name":"percent-select-all","text":"one\ntwo\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"percent-then-d","text":"","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"x-selects-line","text":"one\ntwo\nthree\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"x-selects-line-tty","text":"one\ntwo\nthree\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"x-extends","text":"one\ntwo\nthree\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"x-extends-tty","text":"one\ntwo\nthree\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"x-count","text":"a\nb\nc\nd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"x-count-tty","text":"a\nb\nc\nd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"x-empty-line","text":"a\n\nb\nc\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":1,"col":0}}
+{"name":"x-empty-line-tty","text":"a\n\nb\nc\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":1,"col":0}}
+{"name":"x-last-line","text":"one\ntwo\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":0}}
+{"name":"x-then-w-drops","text":"aa\nbb cc\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}}
+{"name":"x-then-w-drops-tty","text":"aa\nbb cc\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}}
+{"name":"x-then-d","text":"two\nthree\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"X-bare","text":"abc\ndef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"X-bare-tty","text":"abc\ndef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"X-from-v","text":"abc\ndef\nghi\n","mode":"select","cursor":{"row":1,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"X-from-v-tty","text":"abc\ndef\nghi\n","mode":"select","cursor":{"row":1,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"X-keeps-direction","text":"abc\ndef\nghi\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":1,"col":3}}
+{"name":"X-keeps-direction-tty","text":"abc\ndef\nghi\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":1,"col":3}}
+{"name":"X-noop-when-full","text":"abc\ndef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"altx-shrink","text":"abc\ndef\nghi\n","mode":"select","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":0}}
+{"name":"altx-shrink-tty","text":"abc\ndef\nghi\n","mode":"select","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":0}}
+{"name":"altx-single-line-noop","text":"abcdef\n","mode":"select","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":1}}
+{"name":"altx-single-line-noop-tty","text":"abcdef\n","mode":"select","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":1}}
+{"name":"mm-basic","text":"a(bc(d)e)f\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}}
+{"name":"mm-basic-tty","text":"a(bc(d)e)f\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}}
+{"name":"mm-back","text":"a(bc(d)e)f\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"mm-back-tty","text":"a(bc(d)e)f\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"mm-nonbracket-noop","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"mm-nonbracket-noop-tty","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"mi-paren","text":"fn(a, b)\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":3}}
+{"name":"mi-paren-tty","text":"fn(a, b)\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":3}}
+{"name":"ma-paren","text":"fn(a, b)\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":2}}
+{"name":"ma-paren-tty","text":"fn(a, b)\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":2}}
+{"name":"mi-nested","text":"(a(b)c)\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"mi-nested-tty","text":"(a(b)c)\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"mi-word","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":9},"anchor":{"row":0,"col":6}}
+{"name":"mi-word-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":9},"anchor":{"row":0,"col":6}}
+{"name":"mi-WORD","text":"a.b c.d\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"mi-WORD-tty","text":"a.b c.d\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"ma-word","text":"alpha beta gamma\n","mode":"normal","cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}}
+{"name":"mi-quote","text":"say \"hi there\" ok\n","mode":"normal","cursor":{"row":0,"col":12},"anchor":{"row":0,"col":5}}
+{"name":"ma-quote","text":"say \"hi there\" ok\n","mode":"normal","cursor":{"row":0,"col":13},"anchor":{"row":0,"col":4}}
+{"name":"mi-para","text":"aa\nbb\n\ncc\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"mi-para-tty","text":"aa\nbb\n\ncc\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"ma-para","text":"aa\nbb\n\ncc\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"mi-para-blank","text":"aa\n\ncc\n","mode":"normal","cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}
+{"name":"mi-empty-pair","text":"a()b\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"par-fwd","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"par-fwd-tty","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"par-fwd-count","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"par-fwd-count-tty","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"par-bwd","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":5,"col":0}}
+{"name":"par-bwd-tty","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":5,"col":0}}
+{"name":"par-fwd-mid","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"par-fwd-mid-tty","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"par-bwd-mid","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":4,"col":0}}
+{"name":"par-bwd-mid-tty","text":"aa\nbb\n\ncc\ndd\n\nee\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":4,"col":0}}
+{"name":"par-fwd-at-end","text":"aa\n\nbb\n","mode":"normal","cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}
+{"name":"add-nl-below","text":"aa\n\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"add-nl-above","text":"aa\n\nbb\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}
+{"name":"add-nl-count","text":"aa\n\n\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"d-word","text":"beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"d-bare-char","text":"bc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"d-on-nl-joins","text":"abcd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"d-x-lines","text":"bbb\nccc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"d-percent-all","text":"","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"alt-d-noyank","text":"beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"alt-c-window-op","text":"bc def\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"d-then-p","text":"balpha eta\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":1}}
+{"name":"c-word","text":"Xbeta gamma\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"c-bare","text":"Zbc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"c-linewise","text":"X\nbbb\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"c-linewise-indent","text":"Z\nbb\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"y-keeps-sel","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"y-keeps-sel-tty","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"y-bare-char","text":"aabc def\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"y-bare-eol","text":"abb\ncd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"yp-word","text":"alpha beta beta\n","mode":"normal","cursor":{"row":0,"col":14},"anchor":{"row":0,"col":10}}
+{"name":"xyp-linewise","text":"one\none\ntwo\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":0}}
+{"name":"xyp-below-end-line","text":"one\ntwo\none\n","mode":"normal","cursor":{"row":2,"col":3},"anchor":{"row":2,"col":0}}
+{"name":"xyP-linewise","text":"one\none\ntwo\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"vlyp-inline","text":"ababcd\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":2}}
+{"name":"vlyP-inline","text":"ababcd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"p-count","text":"aaab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":1}}
+{"name":"p-after-sel-end","text":"abcdab\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":4}}
+{"name":"wP-before","text":"one one two\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"u-restores-sel","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"u-insert-session","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"U-redo","text":"beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"u-linewise","text":"aaa\nbbb\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}
+{"name":"r-bare","text":"zbc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"r-sel-keeps","text":"zzzdef\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"r-x-replaces-nl","text":"zzzcd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"r-cancel-esc","text":"abc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"R-inline","text":"alpha lpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"R-linewise","text":"one\ntwoone\n","mode":"normal","cursor":{"row":1,"col":6},"anchor":{"row":1,"col":3}}
+{"name":"tilde-sel-keeps","text":"ABC\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"tilde-bare","text":"ABc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"backtick-lower","text":"abc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"alt-backtick-upper","text":"ABC\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"J-cursor-stays","text":"aa bb\ncc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"J-sel-lines","text":"aa bb\ncc\ndd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+{"name":"J-ws-collapse","text":"aa bb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"J-empty-next-nospace","text":"aa\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"J-last-line-eats-nl","text":"aa\nbb","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"J-count-ignored","text":"a b\nc\nd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"indent-basic","text":" foo\nbar\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}
+{"name":"indent-realign","text":" x\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}}
+{"name":"indent-cursor-maps","text":" foo\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}
+{"name":"indent-x-sel","text":" a\n\n b\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":0,"col":4}}
+{"name":"indent-count","text":" x\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}}
+{"name":"unindent-basic","text":" x\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}
+{"name":"unindent-tab","text":"x\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"unindent-partial","text":"x\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"unindent-cursor","text":"ab\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"ctrl-a-sel","text":"16 x\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"ctrl-a-bare-digit","text":"8\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ctrl-a-noop-nonint","text":"a 15 b\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"ctrl-a-neg-sel","text":"-2\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"ctrl-a-pad","text":"008\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"ctrl-a-count","text":"8\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ctrl-x-basic","text":"8\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ms-sel","text":"(hel)lo\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0}}
+{"name":"ms-bare","text":"(a)b\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"ms-quote-self","text":"ab' cd'\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":2}}
+{"name":"md-basic","text":"abcde\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"md-quote","text":"a bc d\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"mr-basic","text":"a[bcd]e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"mr-to-quote","text":"a \"bc\" d\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"o-basic","text":"ab\nx\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}
+{"name":"o-copies-indent-level","text":" foo\n \n","mode":"insert","cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}}
+{"name":"o-partial-indent-drops","text":" foo\n\n","mode":"insert","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"o-count","text":"ab\n\n\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"O-basic","text":"aa\nx\nbb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}
+{"name":"O-copies-indent","text":" \n foo\n","mode":"insert","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}
+{"name":"i-type-esc","text":"hello alpha\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}
+{"name":"i-keeps-sel-start","text":"alpha beta\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}}
+{"name":"wa-sel-end","text":"alpha beta\n","mode":"insert","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":0}}
+{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":6}}
+{"name":"a-esc-restore","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ax-restore","text":"axbc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"lax-restore","text":"abxc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":1}}
+{"name":"A-eol","text":"abc\ndef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"A-type","text":"one twoX\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}}
+{"name":"I-firstnonws","text":" abc\n","mode":"insert","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"esc-pending-cancel","text":"abc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"ins-enter-mid","text":"a\nb\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"ins-enter-indent","text":" foo\n x\n","mode":"normal","cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}
+{"name":"ins-backspace","text":"ac\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"ins-backspace-join","text":"abcd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"ins-ctrl-w","text":"one \n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}
+{"name":"ins-ctrl-w-col0-join","text":"cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ins-alt-bs","text":"one \n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}
+{"name":"ins-alt-d","text":" two\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ins-alt-del","text":" two\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ins-ctrl-u","text":"\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ins-ctrl-k","text":"\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ins-ctrl-h","text":"ac\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"ins-ctrl-d","text":"bc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ins-del-eol-join","text":"abcd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"ins-ctrl-j","text":"a\nb\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
+{"name":"ins-home","text":"x abc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"ins-end","text":" abcx\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}
+{"name":"ins-pgdn","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}}
+{"name":"ins-arrows","text":"abc\ndxef\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}
+{"name":"ins-tab-col0","text":" ab\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}
+{"name":"ins-tab-mid","text":"a b\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}
diff --git a/test/hxcases/regen.sh b/test/hxcases/regen.sh
new file mode 100755
index 00000000..308cc9d2
--- /dev/null
+++ b/test/hxcases/regen.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+# Regenerate test/hxcases/goldens.jsonl by running the helix reference harness
+# over cases.jsonl. The harness is hx-harness on the pardes-harness branch of
+# the genizah helix checkout (see docs/helix-keys.md, "Differential testing"):
+# cd ~/05-genizah/helix && git switch pardes-harness && \
+# cargo build --release -p helix-term --features helix-term/integration --bin hx-harness
+# Override the binary with $HX_HARNESS. helix ignores pane:"tty" — a tty
+# twin's golden is helix on the same text, which IS the oracle for tty
+# navigation parity.
+set -eu
+cd "$(dirname "$0")"
+HX_HARNESS="${HX_HARNESS:-$HOME/05-genizah/helix/target/release/hx-harness}"
+[ -x "$HX_HARNESS" ] || { echo "regen.sh: no hx-harness at $HX_HARNESS (set \$HX_HARNESS)" >&2; exit 1; }
+"$HX_HARNESS" cases.jsonl > goldens.jsonl.tmp
+mv goldens.jsonl.tmp goldens.jsonl
+wc -l goldens.jsonl
diff --git a/test/hxcases/smoke.jsonl b/test/hxcases/smoke.jsonl
new file mode 100644
index 00000000..0c25adda
--- /dev/null
+++ b/test/hxcases/smoke.jsonl
@@ -0,0 +1,20 @@
+{"name":"w-basic","pane":"file","text":"alpha beta\n","keys":"w"}
+{"name":"w-then-b","pane":"file","text":"alpha beta\n","keys":"wb"}
+{"name":"e-basic","pane":"file","text":"alpha beta\n","keys":"e"}
+{"name":"x-line","pane":"file","text":"one\ntwo\nthree\n","keys":"x"}
+{"name":"x-twice","pane":"file","text":"one\ntwo\nthree\n","keys":"xx"}
+{"name":"d-char","pane":"file","text":"alpha beta\n","keys":"d"}
+{"name":"x-then-d","pane":"file","text":"one\ntwo\nthree\n","keys":"xd"}
+{"name":"i-type-esc","pane":"file","text":"alpha\n","keys":"ihello <esc>"}
+{"name":"f-char","pane":"file","text":"alpha beta\n","keys":"ft"}
+{"name":"f-cross-newline","pane":"file","text":"one\ntwo\n","keys":"fw"}
+{"name":"r-char","pane":"file","text":"alpha\n","keys":"rX"}
+{"name":"j-join","pane":"file","text":"one\ntwo\nthree\n","keys":"J"}
+{"name":"semicolon-collapse","pane":"file","text":"alpha beta\n","keys":"w;"}
+{"name":"percent-all","pane":"file","text":"one\ntwo\n","keys":"%"}
+{"name":"mi-paren","pane":"file","text":"fn(a, b)\n","keys":"fami("}
+{"name":"count-2w","pane":"file","text":"one two three four\n","keys":"2w"}
+{"name":"ge-then-gt","pane":"file","text":"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\nl30\n","keys":"gegt"}
+{"name":"zt-then-gt","pane":"file","text":"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\nl30\n","keys":"12jztgt"}
+{"name":"v-select","pane":"file","text":"alpha beta\n","keys":"vw"}
+{"name":"noop","pane":"file","text":"alpha\n","keys":""}
diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl
new file mode 100644
index 00000000..8110a84c
--- /dev/null
+++ b/test/hxcases/waivers.jsonl
@@ -0,0 +1,2 @@
+{"name":"wiX-edit-drops-sel","reason":"helix maps the selection through every insert-mode edit; pardes drops it on the first edit instead (anchor-only divergence, text+cursor+mode match). Re-judged phase 5 and kept: mapping the anchor through all ~23 mutation sites in handleInsert (multi-line inserts, EOL joins, kill runs) is genuinely invasive, and nothing in pardes consumes an implicit selection after insert-mode typing - the acme chords need explicit selections, which never enter insert."}
+{"name":"alt-c-window-op","reason":"deliberate pardes binding: Alt-c is the move-pane-to-fresh-column window op in ANY mode (do-not-touch contract); helix Alt-c is change-noyank. Alt-d + i covers the helix behavior; the buffer is untouched on the pardes side."}
diff --git a/test/hxdiff.zig b/test/hxdiff.zig
new file mode 100644
index 00000000..62638e23
--- /dev/null
+++ b/test/hxdiff.zig
@@ -0,0 +1,355 @@
+// pardes-hxdiff: the pardes half of the helix differential harness. Drives
+// the sans-IO core (src/pardes.zig) headlessly over JSON-Lines cases and
+// prints one contract result line per case on stdout, input order — the same
+// case/result contract helix's hx-harness speaks (pardes-harness branch in
+// the genizah checkout), so the two editors diff key-for-key.
+//
+// case: {"name":"w-basic","pane":"file","text":"alpha beta\n","keys":"w"}
+// result: {"name":"w-basic","text":"alpha beta\n","mode":"normal",
+// "cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+//
+// zig build hxdiff (diff the checked-in corpus)
+// zig build hxdiff -- <cases.jsonl> (results to stdout, no diff)
+// ./zig-out/bin/pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]
+//
+// DIFF MODE (goldens given): every result is compared field-by-field
+// (text/mode/cursor/anchor) against the same-named golden line. Waivers
+// ({"name":...,"reason":...}) exempt named cases — each must carry a reason.
+// Any unwaivered mismatch prints a per-case report and exits 1.
+//
+// Viewport is fixed at 80x24, matching the helix harness: one pane = 22 body
+// rows (topbar + tag take two; helix: statusline + commandline take two).
+// pane "file" swaps the boot pane for a file pane holding the case text
+// (Pardes.hxOpenFileContent, the test-only entry). pane "tty" keeps the
+// tty_only boot shell — no real pty, the spawn effect is ignored — feeds the
+// text as pty output with \n cooked to \r\n (ONLCR), then leaves tty mode
+// with Ctrl-<tty_toggle> exactly like a live session and parks the cursor at
+// (0,0), mirroring the helix harness's initial Selection::single(0,1). The
+// final tty text is the motion surface (paneCursorLines) re-joined, plus the
+// trailing '\n' ghostty's dump trims off the cursor's empty row — the case
+// text always ends in one, so this restores byte-for-byte comparability.
+// Keys are helix notation: printables verbatim, <esc> <ret> <tab> <space>
+// <backspace> <del> <up/down/left/right> <home/end> <pageup/pagedown>
+// <C-x> <A-x> <lt> <gt>. The clipboard round-trips through a fake:
+// set_clipboard stores the core's yank register, get_clipboard answers with
+// Event.paste, so y-then-p behaves like a live session.
+const std = @import("std");
+const libc = std.c;
+const pardes = @import("pardes");
+
+const gpa = std.heap.page_allocator;
+
+pub fn main(init: std.process.Init) !void {
+ var arena_state = std.heap.ArenaAllocator.init(gpa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+
+ var cases_path: ?[]const u8 = null;
+ var goldens_path: ?[]const u8 = null;
+ var waivers_path: ?[]const u8 = null;
+ for (args[1..]) |a| {
+ if (cases_path == null) {
+ cases_path = a;
+ } else if (goldens_path == null) {
+ goldens_path = a;
+ } else if (waivers_path == null) {
+ waivers_path = a;
+ } else fatal("usage: pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]", .{});
+ }
+ const path = cases_path orelse fatal("usage: pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]", .{});
+
+ const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) });
+
+ // goldens + waivers, keyed by name (diff mode)
+ var goldens: std.StringHashMap(Result) = .init(arena);
+ if (goldens_path) |gp| {
+ const gsrc = readFileAlloc(arena, gp) catch |e| fatal("read {s}: {s}", .{ gp, @errorName(e) });
+ var glines = std.mem.splitScalar(u8, gsrc, '\n');
+ var lno: usize = 0;
+ while (glines.next()) |raw| {
+ lno += 1;
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const g = std.json.parseFromSliceLeaky(Result, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
+ fatal("{s}:{d}: bad golden line: {s}", .{ gp, lno, @errorName(e) });
+ goldens.put(g.name, g) catch fatal("oom", .{});
+ }
+ }
+ var waivers: std.StringHashMap([]const u8) = .init(arena);
+ if (waivers_path) |wp| {
+ const wsrc = readFileAlloc(arena, wp) catch |e| fatal("read {s}: {s}", .{ wp, @errorName(e) });
+ var wlines = std.mem.splitScalar(u8, wsrc, '\n');
+ var lno: usize = 0;
+ while (wlines.next()) |raw| {
+ lno += 1;
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const w = std.json.parseFromSliceLeaky(Waiver, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
+ fatal("{s}:{d}: bad waiver line: {s}", .{ wp, lno, @errorName(e) });
+ if (w.reason.len == 0) fatal("{s}:{d}: waiver {s} MUST carry a reason", .{ wp, lno, w.name });
+ waivers.put(w.name, w.reason) catch fatal("oom", .{});
+ }
+ }
+
+ var out: std.ArrayList(u8) = .empty;
+ var n_cases: usize = 0;
+ var n_bad: usize = 0;
+ var n_waived: usize = 0;
+ var lines = std.mem.splitScalar(u8, src, '\n');
+ var lineno: usize = 0;
+ while (lines.next()) |raw| {
+ lineno += 1;
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
+ fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) });
+ n_cases += 1;
+ const res = runCase(arena, case) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) });
+ if (goldens_path == null) {
+ const jl = std.json.Stringify.valueAlloc(arena, res, .{}) catch fatal("oom", .{});
+ out.appendSlice(arena, jl) catch fatal("oom", .{});
+ out.append(arena, '\n') catch fatal("oom", .{});
+ continue;
+ }
+ const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name});
+ const diff = diffResult(arena, g, res);
+ if (diff.len == 0) continue;
+ if (waivers.get(case.name)) |reason| {
+ n_waived += 1;
+ std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason });
+ continue;
+ }
+ n_bad += 1;
+ std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff });
+ }
+ if (goldens_path == null) {
+ try std.Io.File.stdout().writeStreamingAll(init.io, out.items);
+ return;
+ }
+ std.debug.print("hxdiff: {d} cases, {d} mismatches, {d} waived\n", .{ n_cases, n_bad, n_waived });
+ if (n_bad > 0) std.process.exit(1);
+}
+
+const Case = struct {
+ name: []const u8,
+ pane: []const u8 = "file",
+ text: []const u8,
+ keys: []const u8,
+};
+
+const Pos = struct { row: i32, col: i32 };
+
+// field order is the contract's emit order (Stringify writes declaration order)
+const Result = struct {
+ name: []const u8,
+ text: []const u8,
+ mode: []const u8,
+ cursor: Pos,
+ anchor: Pos,
+};
+
+const Waiver = struct {
+ name: []const u8,
+ reason: []const u8,
+};
+
+/// field-by-field compare; returns a human-readable report ("" = match)
+fn diffResult(arena: std.mem.Allocator, want: Result, got: Result) []const u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ if (!std.mem.eql(u8, want.text, got.text)) {
+ appendf(arena, &buf, " text: helix {f} != pardes {f}\n", .{ std.json.fmt(want.text, .{}), std.json.fmt(got.text, .{}) });
+ }
+ if (!std.mem.eql(u8, want.mode, got.mode)) {
+ appendf(arena, &buf, " mode: helix {s} != pardes {s}\n", .{ want.mode, got.mode });
+ }
+ if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) {
+ appendf(arena, &buf, " cursor: helix ({d},{d}) != pardes ({d},{d})\n", .{ @as(i64, want.cursor.row), @as(i64, want.cursor.col), @as(i64, got.cursor.row), @as(i64, got.cursor.col) });
+ }
+ if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) {
+ appendf(arena, &buf, " anchor: helix ({d},{d}) != pardes ({d},{d})\n", .{ @as(i64, want.anchor.row), @as(i64, want.anchor.col), @as(i64, got.anchor.row), @as(i64, got.anchor.col) });
+ }
+ return buf.items;
+}
+
+fn appendf(arena: std.mem.Allocator, buf: *std.ArrayList(u8), comptime fmt: []const u8, fargs: anytype) void {
+ const s = std.fmt.allocPrint(arena, fmt, fargs) catch return;
+ buf.appendSlice(arena, s) catch {};
+}
+
+fn runCase(arena: std.mem.Allocator, case: Case) !Result {
+ // fresh core per case: the tty_only boot — one shell pane, tty mode, no
+ // greeting. Live-session contract: sizes arrive as the first resize event.
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ var clip: []const u8 = "";
+ core.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ pump(core, arena, &clip);
+
+ if (std.mem.eql(u8, case.pane, "file")) {
+ _ = try core.hxOpenFileContent(case.text);
+ } else if (std.mem.eql(u8, case.pane, "tty")) {
+ // the harness is the pty here: cook \n to \r\n the way ONLCR would
+ var cooked: std.ArrayList(u8) = .empty;
+ for (case.text) |ch| {
+ if (ch == '\n') try cooked.append(arena, '\r');
+ try cooked.append(arena, ch);
+ }
+ core.update(.{ .output = .{ .pane = 0, .bytes = cooked.items } });
+ pump(core, arena, &clip);
+ // leave tty mode the way a live session does: Ctrl-<tty_toggle>,
+ // then park the cursor at (0,0) — the helix harness's initial
+ // Selection::single(0,1) equivalent (a live exit would leave the
+ // modal cursor tracking the shell cursor below the output)
+ core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } });
+ pump(core, arena, &clip);
+ if (core.panes[core.active]) |pane| {
+ pane.cur_row = 0;
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ }
+ } else fatal("case {s}: unknown pane {s}", .{ case.name, case.pane });
+
+ // keys: helix notation — printables verbatim, <...> specials
+ var ki: usize = 0;
+ while (ki < case.keys.len) {
+ var key: pardes.Key = .{ .cp = 0 };
+ if (case.keys[ki] == '<') {
+ const close = std.mem.indexOfScalarPos(u8, case.keys, ki, '>') orelse
+ fatal("case {s}: unterminated < in keys", .{case.name});
+ var name = case.keys[ki + 1 .. close];
+ ki = close + 1;
+ while (true) {
+ if (std.mem.startsWith(u8, name, "C-")) {
+ key.ctrl = true;
+ name = name[2..];
+ } else if (std.mem.startsWith(u8, name, "A-")) {
+ key.alt = true;
+ name = name[2..];
+ } else break;
+ }
+ if (name.len == 1) {
+ key.cp = name[0];
+ if (!key.ctrl and !key.alt) key.text = name;
+ } else if (std.mem.eql(u8, name, "esc")) {
+ key.cp = pardes.Key.escape;
+ } else if (std.mem.eql(u8, name, "ret")) {
+ key.cp = pardes.Key.enter;
+ } else if (std.mem.eql(u8, name, "tab")) {
+ key.cp = pardes.Key.tab;
+ } else if (std.mem.eql(u8, name, "space")) {
+ key.cp = ' ';
+ if (!key.ctrl and !key.alt) key.text = " ";
+ } else if (std.mem.eql(u8, name, "backspace")) {
+ key.cp = pardes.Key.backspace;
+ } else if (std.mem.eql(u8, name, "del")) {
+ key.cp = pardes.Key.delete;
+ } else if (std.mem.eql(u8, name, "lt")) {
+ key.cp = '<';
+ if (!key.ctrl and !key.alt) key.text = "<";
+ } else if (std.mem.eql(u8, name, "gt")) {
+ key.cp = '>';
+ if (!key.ctrl and !key.alt) key.text = ">";
+ } else if (std.mem.eql(u8, name, "up")) {
+ key.cp = pardes.Key.up;
+ } else if (std.mem.eql(u8, name, "down")) {
+ key.cp = pardes.Key.down;
+ } else if (std.mem.eql(u8, name, "left")) {
+ key.cp = pardes.Key.left;
+ } else if (std.mem.eql(u8, name, "right")) {
+ key.cp = pardes.Key.right;
+ } else if (std.mem.eql(u8, name, "home")) {
+ key.cp = pardes.Key.home;
+ } else if (std.mem.eql(u8, name, "end")) {
+ key.cp = pardes.Key.end;
+ } else if (std.mem.eql(u8, name, "pageup")) {
+ key.cp = pardes.Key.page_up;
+ } else if (std.mem.eql(u8, name, "pagedown")) {
+ key.cp = pardes.Key.page_down;
+ } else fatal("case {s}: unknown key <{s}>", .{ case.name, name });
+ } else {
+ key.cp = case.keys[ki];
+ key.text = case.keys[ki .. ki + 1];
+ ki += 1;
+ }
+ core.update(.{ .key = key });
+ pump(core, arena, &clip);
+ }
+
+ // one contract result line off the active pane's final state
+ const pane = core.panes[core.active] orelse return error.NoActivePane;
+ var text: []const u8 = undefined;
+ if (pane.file) |f| {
+ text = f.content;
+ } else {
+ // the tty motion surface, dumped honestly: whole scrollback+grid,
+ // prompt rows blanked, typed runs spliced — plus the trailing '\n'
+ // ghostty's dump trims off the cursor's empty final row
+ const pl = try core.paneCursorLines(pane);
+ var buf: std.ArrayList(u8) = .empty;
+ for (pl.lines, 0..) |ln, i| {
+ if (i > 0) try buf.append(arena, '\n');
+ try buf.appendSlice(arena, ln);
+ }
+ if (buf.items.len > 0 and buf.items[buf.items.len - 1] != '\n') try buf.append(arena, '\n');
+ text = buf.items;
+ }
+ var anchor: Pos = .{ .row = pane.cur_row, .col = pane.cur_col };
+ if (pane.vsel.active) {
+ anchor = .{ .row = pane.vsel.row, .col = pane.vsel.col };
+ } else if (pane.msel.active) {
+ // line selection: the far end (the r0/r1 not holding the cursor), col 0
+ anchor = .{ .row = if (pane.cur_row == pane.msel.r1) pane.msel.r0 else pane.msel.r1, .col = 0 };
+ }
+ // mode "select" ONLY while v-extend is on (helix keeps x/%/mi selections
+ // in normal mode); pane.mode itself never holds a select state
+ const mode = if (pane.mode == .normal and pane.select) "select" else @tagName(pane.mode);
+ return .{
+ .name = case.name,
+ .text = try arena.dupe(u8, text),
+ .mode = mode,
+ .cursor = .{ .row = pane.cur_row, .col = pane.cur_col },
+ .anchor = anchor,
+ };
+}
+
+/// Drain queued effects. The fake clipboard: set_clipboard snapshots the
+/// core's yank register, get_clipboard answers with Event.paste — the async
+/// OSC 52 round trip of a live shell, collapsed to a synchronous reply.
+/// Everything else (spawn, write, resize_pty, ...) is ignored: no real pty.
+fn pump(core: *pardes.Pardes, arena: std.mem.Allocator, clip: *[]const u8) void {
+ while (true) {
+ var want_paste = false;
+ while (core.nextEffect()) |eff| switch (eff) {
+ .set_clipboard => clip.* = if (core.yank) |y| (arena.dupe(u8, y) catch "") else "",
+ .get_clipboard => want_paste = true,
+ else => {},
+ };
+ if (!want_paste) return;
+ core.update(.{ .paste = clip.* });
+ }
+}
+
+fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 {
+ const path_z = try arena.dupeZ(u8, path);
+ const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
+ if (fd < 0) return error.OpenFailed;
+ defer _ = libc.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ var chunk: [16384]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &chunk, chunk.len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(arena, chunk[0..@intCast(n)]);
+ }
+ return buf.items;
+}
+
+fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
+ std.debug.print("pardes-hxdiff: " ++ fmt ++ "\n", args);
+ std.process.exit(1);
+}
diff --git a/test/snapshots/fileedit.golden b/test/snapshots/fileedit.golden
index 72950b96..13d6835a 100644
--- a/test/snapshots/fileedit.golden
+++ b/test/snapshots/fileedit.golden
@@ -60,7 +60,7 @@
|
|
|
-== snap undone grid=100x30 cursor=7,3
+== snap undone grid=100x30 cursor=13,3
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/fileedit/cwd/f.txt Save Del NOR /tmp/pardes-snap/fileedit/cwd Del
| 1 liEDIT''MEne 1
diff --git a/test/snapshots/modal.golden b/test/snapshots/modal.golden
index 668fd02c..dfe34130 100644
--- a/test/snapshots/modal.golden
+++ b/test/snapshots/modal.golden
@@ -29,7 +29,7 @@
|
|
|
-== snap moved grid=100x30 cursor=2,6
+== snap moved grid=100x30 cursor=5,6
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del
|
@@ -60,7 +60,7 @@
|
|
|
-== snap xsel grid=100x30 cursor=2,6
+== snap xsel grid=100x30 cursor=15,6
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del
|
@@ -98,7 +98,7 @@
|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
-|6: 0-1 d,#525252, 2-49 #d8c8a8,#4a3a1e, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-15 #d8c8a8,#4a3a1e, 16-49 d,d, 50-51 d,#525252, 52-99 d,d,
|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
@@ -122,7 +122,7 @@
|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
-== snap xsel2 grid=100x30 cursor=2,7
+== snap xsel2 grid=100x30 cursor=15,7
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del
|
@@ -153,7 +153,7 @@
|
|
|
-== snap yanked grid=100x30 cursor=2,6
+== snap yanked grid=100x30 cursor=15,7
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del
|
@@ -184,7 +184,7 @@
|
|
|
-== snap vsel grid=100x30 cursor=3,7
+== snap vsel grid=100x30 cursor=16,8
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del
|
@@ -223,8 +223,8 @@
|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|6: 0-1 d,#525252, 2-49 #d8c8a8,#4a3a1e, 50-51 d,#525252, 52-99 d,d,
-|7: 0-1 d,#525252, 2-3 #d8c8a8,#4a3a1e, 4-49 d,d, 50-51 d,#525252, 52-99 d,d,
-|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 #d8c8a8,#4a3a1e, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-16 #d8c8a8,#4a3a1e, 17-49 d,d, 50-51 d,#525252, 52-99 d,d,
|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
@@ -246,15 +246,15 @@
|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
-== snap typed grid=100x30 cursor=3,7
+== snap typed grid=100x30 cursor=2,6
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del
|
| words.txt words.txt
|
|
-| one two three
-| fTYPEDour five six
+| TYPEDone two three
+| four five six
| seven eight nine
|
|
@@ -277,7 +277,7 @@
|
|
|
-== snap undone grid=100x30 cursor=3,7
+== snap undone grid=100x30 cursor=16,8
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del
|
@@ -308,15 +308,15 @@
|
|
|
-== snap redone grid=100x30 cursor=3,7
+== snap redone grid=100x30 cursor=2,6
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del
|
| words.txt words.txt
|
|
-| one two three
-| fTYPEDour five six
+| TYPEDone two three
+| four five six
| seven eight nine
|
|
@@ -339,15 +339,15 @@
|
|
|
-== snap bottom grid=100x30 cursor=2,9
+== snap bottom grid=100x30 cursor=2,8
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del
|
| words.txt words.txt
|
|
-| one two three
-| fTYPEDour five six
+| TYPEDone two three
+| four five six
| seven eight nine
|
|
diff --git a/test/snapshots/modal.snap b/test/snapshots/modal.snap
index 73905175..68809e18 100644
--- a/test/snapshots/modal.snap
+++ b/test/snapshots/modal.snap
@@ -48,6 +48,6 @@ snap undone
key U
stable 400 5000
snap redone
-key G
+key g e
stable 400 5000
snap bottom
diff --git a/test/snapshots/scrolloff.golden b/test/snapshots/scrolloff.golden
index 90e0e83f..f2165a10 100644
--- a/test/snapshots/scrolloff.golden
+++ b/test/snapshots/scrolloff.golden
@@ -122,7 +122,7 @@
|
|
|
-== snap end grid=100x30 cursor=7,7
+== snap end grid=100x30 cursor=7,6
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/scrolloff/cwd/g.txt Save De NOR /tmp/pardes-snap/scrolloff/cwd Del
| 36 line 36
@@ -156,12 +156,12 @@
== snap kup grid=100x30 cursor=7,4
|Kill Newcol Tutor Debug Colors NextColor Dump
| NOR /tmp/pardes-snap/scrolloff/cwd/g.txt Save De NOR /tmp/pardes-snap/scrolloff/cwd Del
-| 31 line 31
-| 32 line 32 g.txt
+| 30 line 30
+| 31 line 31 g.txt
+| 32 line 32
| 33 line 33
| 34 line 34
| 35 line 35
-| 36 line 36
| NOR /tmp/pardes-snap/scrolloff/cwd Del
|
| g.txt
diff --git a/test/snapshots/scrolloff.snap b/test/snapshots/scrolloff.snap
index 067e851f..af64a3c7 100644
--- a/test/snapshots/scrolloff.snap
+++ b/test/snapshots/scrolloff.snap
@@ -27,8 +27,9 @@ snap scrolled1
key j j j j
stable 400 5000
snap jdown
-# (d) G: bottom clamp, view stops at EOF-at-bottom, cursor on the bottom row
-key G
+# (d) ge (goto last line; bare G is a helix no-op): bottom clamp, view stops
+# at EOF-at-bottom, cursor on the bottom row
+key g e
stable 400 5000
snap end
# (c) k-walk up: cursor climbs to the top margin, then the view scrolls
diff --git a/transactions.txt b/transactions.txt
new file mode 100644
index 00000000..b1233d3d
--- /dev/null
+++ b/transactions.txt
@@ -0,0 +1,59 @@
+Selection anchors through insert-mode edits, and the transaction model
+=======================================================================
+
+The one place the helix differential suite shows a real structural gap
+(waiver "wiX-edit-drops-sel" in test/hxcases/waivers.jsonl): helix keeps a
+selection ALIVE across insert mode, pardes drops it on the first edit.
+
+What helix does
+---------------
+A selection doesn't die when you enter insert mode. Every edit is a
+Transaction — a structured delta (retain N, insert "...", delete N) — and
+all stored positions are MAPPED through it, the selection anchor included.
+Insert a char before the anchor and it slides right by one; delete a line
+above it and its row shifts up. Esc back to normal mode and the old
+selection is still there, coordinates adjusted to cover the same logical
+text.
+
+Example ("alpha beta" + keys w i X <esc>):
+ after w: anchor (0,0), cursor on the space (0,5), "alpha " selected
+ i, type X: buffer is "Xalpha beta"
+ after esc: helix — anchor (0,1), selection still spans "alpha " (remapped)
+ pardes — selection gone, anchor == cursor
+Final text, cursor, and mode agree on both sides; only the anchor differs.
+
+What pardes does today
+----------------------
+No transaction layer. handleInsert mutates the buffer directly at ~23
+distinct sites (char inserts, newline, backspace incl. the line-join case,
+Ctrl-w/Alt-d word kills, Ctrl-u/Ctrl-k line kills, multi-line paste, and
+the terminal run-splice variants). Keeping the anchor alive would mean
+hand-written row/col shift arithmetic at every one of those sites — 23
+error-prone edit paths to maintain for state nothing reads: d/c/y take a
+fresh selection from the next motion, and the acme Enter/Tab chords only
+act on EXPLICIT selections (v/x/n/N), which never enter insert mode. Hence
+the waiver instead of a fix.
+
+Why we might adopt the transaction model anyway
+-----------------------------------------------
+A small transaction layer (edits as deltas + one mapThroughEdit(pos, edit)
+helper applied at each mutation site) would buy, in rough order of value:
+ - "." repeat-last-insert (currently deferred in docs/helix-keys.md for
+ exactly this reason: no edit log to replay)
+ - delta-based undo/redo instead of whole-buffer snapshots (pushUndo
+ dupes f.content per edit op today — fine for small files, O(file) per op)
+ - the waived anchor parity for free (selections survive insert mode,
+ remapped like helix)
+ - any future position-carrying state (marks, jumplist, multiple
+ selections) gets remapping for free instead of per-site arithmetic
+
+Upgrade path, if/when
+---------------------
+1. Define the delta type in modal.zig (pure, unit-testable): a list of
+ (retain, insert, delete) spans, plus mapThroughEdit for a (row,col).
+2. Make the ~23 handleInsert mutation sites EMIT a delta and apply it in
+ one place, instead of splicing content inline.
+3. Undo becomes a log of inverse deltas; "." becomes a replay of the last
+ insert session's deltas; drop the y-anchor waiver and its harness case.
+Adopt only when one of the consumers above is actually wanted — the delta
+layer alone is machinery without a user.