summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/helix-keys.md21
-rw-r--r--docs/selections.md15
-rw-r--r--src/Registers.zig106
-rw-r--r--src/Text.zig2
-rw-r--r--src/config.zig2
-rw-r--r--src/edit.zig112
-rw-r--r--src/gui/gui.zig2
-rw-r--r--src/look.zig6
-rw-r--r--src/modal.zig13
-rw-r--r--src/mouse.zig12
-rw-r--r--src/normal.zig17
-rw-r--r--src/pardes.zig22
-rw-r--r--test/hxcases/cases.jsonl13
-rw-r--r--test/hxcases/goldens.jsonl13
-rw-r--r--test/hxcases/waivers.jsonl1
-rw-r--r--test/panes.zig20
16 files changed, 312 insertions, 65 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index 972ef0d2..90efc463 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -110,9 +110,9 @@ language-backend queries, and the shell pipe.
| `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). Writes the DEFAULT REGISTER and nothing else — the system clipboard is `SPC y`, which is helix's own split and so moves this row TOWARDS helix, not away: a `d` of one character can no longer clobber what the desktop was holding | helix-verified |
+| `y` | yank each range's text into the register (`"<reg>` names one, else `"`), one value per range | line-yank vim-ism removed (phase 5); yank keeps selection AND cursor (helix). The system clipboard is `"+y` / `SPC y`, helix's own split: a `d` of one character never clobbers what the desktop holds | 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) | paste the core's yank register after the selection | helix default-register semantics, and only the register — nothing on this path reads or writes the system clipboard. `SPC p` is the word that does, and on a tty its read is OSC 52, which most terminals refuse: an honest no-op there rather than a paste of the wrong text | helix-verified |
+| `p` (normal) | paste the register after each range: value i at range i, the last value repeated when there are fewer | helix `paste_impl`. `"+p` asks the system clipboard (`SPC p`); on a tty that read is OSC 52, which most terminals refuse: an honest no-op rather than a paste of the wrong text | helix-verified |
| `Esc` (body normal) | clear a pending modal prefix / exit select mode, keeping the selection, then run `Last`: hop to the pane you were in before this one, whichever kind it was, exactly like `SPC j j` — so held down it alternates between two panes, two files as readily as a file and its shell. A PDF pane is the ONE exception: there Esc is the document's own cancel (drop the mouse selection and the search overlay, stay where you are reading) and `Shift-Esc` is the hop out, while raw tty only intercepts unmodified Esc at a detected shell prompt | Pardes-specific focus binding layered on helix's cleanup. A leader path, tag, topbar or search owns Esc while it is active; raw tty forwards it | pardes-specific (cleanup 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 |
@@ -123,7 +123,7 @@ language-backend queries, and the shell pipe.
| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
| `Alt-c` | move active terminal to a fresh column (outside raw tty) | 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`) |
| `Space` (normal, body) | starts the pardes LEADER: a key path runs the same builtin words used by tags and the topbar. The main groups are `f` files, `h` docs, `c` columns, `t` toggles/effects, `a` panel animations, `s` session, `j` jumps, `l` language, and `w` directional focus; `SPC ?` lists every path and `<prefix> ?` lists one group in `+Help`. Neither `Exit` (quits the editor, acme's Exit) nor `Kill` (stops the commands pardes typed into terminals, acme's Kill) has a leader path: both are the topbar's and the root `ctl`'s. The pending path appears on the active pane's transient body/message row. Esc or an unmapped key abandons it. Paths, Help rows, and dispatch are all generated from the builtin registry at comptime; the leader applies only to a BODY in normal mode because tags and tty programs own their input | pardes-specific; helix spends Space on pickers/LSP (section C), while pardes uses acme-style executable words |
-| `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the ONLY five words in pardes that touch the desktop's clipboard — `y`/`d`/`c`/`p`/`P`/`R` and the acme cut/paste chords are the internal register alone. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. One divergence: pardes keeps a single register VALUE where helix keeps one per range, so `SPC y` at N cursors joins them with newlines (`Pardes.setYank`, the divergence `msel-yank-paste` already waives). On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus |
+| `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the same as `"+y`, `"+p`, `"+P`, `"+R`: register `+` (and `*`) IS the desktop's clipboard, the only register that reaches it. `SPC y` writes `+` alone, as helix's does, not the default register too. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. The clipboard holds one text, so N values go out joined by newlines, as helix sends them. On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus |
| a paste from the OUTER terminal | one `Event.paste`, spliced in at the cursor | the tty shell enables bracketed paste and coalesces `paste_start`..`paste_end` into a single event; before that the bytes arrived as individual key presses and normal mode RAN them, which is how a pasted `d` deleted a line. The bytes deliberately never enter the yank register — clipboard and default register are separate stores in both directions | pardes-specific |
| `/` (any pane) | pardes' own plain-substring search into a `+Search` output buffer: the pattern is typed on a line of its own on the pane's notice band, Enter fills the buffer, and its rows are ordinary look targets. Enter also GOES to the first row — the buffer is focused and then the step `n` is and the look Enter is run in it (`Pardes.lookFirstHit`), so `/foo` lands on the first hit with the matched span selected. A pattern that matched nothing opens its empty buffer and moves nothing | KEEP, do not touch; not in the corpus (helix `/` is regex search). Find and Grep answer with OTHER files and deliberately do NOT jump. The stepping half is the next row | pardes-specific |
| `n` / `N` (any pane) | MOTION, not a jump: move the SELECTION to the next / previous look-able text and open NOTHING. Enter — the look chord — on what it leaves selected is what opens it | KEEP, do not touch; not in the corpus (helix's `n`/`N` walk regex search hits). What a step selects is the pane's GRAIN (`output_pane.Grain`, read in `Pardes.lookSpanIn`): in FREE TEXT — a terminal, a file, a PDF, a prose answer buffer — the largest whitespace-delimited run `look.resolve` can act on (`look.lookableSpan`, wrapper punctuation peeled off both ends), several to a line; in a RESULTS BUFFER one stop per ROW, the largest run its head resolves as (`look.lookableLineSpan`), because a row there IS one location and the words after it are the match rather than a second place to go; in a COMMAND list the whole line. The walk is a RING across PANES: every pane that has performed a Look, most recent first (`Pardes.look_src`), then the output buffers that have not, newest first, and only when both are empty the active pane. Exhausting a pane enters the next at its first (forward) / last (backward) span and the end wraps to the start, so `N` is the exact inverse of `n`. What it lands on becomes an EXPLICIT `vsel` with the cursor on its FIRST column, in the pane the walk focuses. ONE motion in every pane kind and every buffer kind — a PDF steps the `+Search` buffer its own search filled, `n` to select the row and Enter to jump. The single thing a buffer may change is that grain, and it changes it by BEING a kind of buffer rather than by a branch: `output_pane.Traits.steps` (a list of locations) makes a step take one row at a time, and `Traits.commands` makes it take the WHOLE LINE, because a command list (`ThemeSel`/`FontSel`) holds words to run and there is no path inside `Theme gruvbox` to pick out. Tab on what `n` selected wears the theme, which is the same middle click on the row is. `]d`/`[d` are helix's diagnostic motions, a different binding, and they do still jump to each diagnostic (`docs/lsp.md`) | pardes-specific |
@@ -224,7 +224,9 @@ grew `sels` + `primary`, emitted only when there is more than one range.
| `_` | trim whitespace off both ends of every range | empty and all-whitespace ranges drop; nothing left = collapse + keep primary (helix) | helix-verified |
| `<n>o` / `<n>O` | open n lines, one cursor per line | helix `open` with a count; this is why the `o-count` golden gained a `sels` field | helix-verified |
| every motion/operator | acts at every cursor | replayed last-range-first, so an edit never disturbs a range still waiting; each pass's result is remembered as a distance from the END of the text, which an earlier edit cannot move | helix-verified (56 msel-* cases) |
-| `y` with several ranges | joins the ranges' text with newlines into the ONE register | helix keeps a register VALUE per range and pastes value[i] at range[i]. Waived (`msel-yank-paste`) | waived |
+| `y` with several ranges | one value per range, pasted back value i at range i by `p`/`P`/`R` and insert `Ctrl-r`; the acme cut/paste chords and a paste into a terminal take the values joined by newlines | `Registers.zig` | helix-verified |
+| `"<reg>` | the next command's register: any character names one of its own. Computed ones: `_` swallows writes and reads nothing (`"_d`), `#` is each range's number from 1, `.` each range's text, `%` the file's name, `/` the last `s`/`S` pattern; `+`/`*` the system clipboard. A count typed before `"` stays the command's | helix `select_register` | helix-verified |
+| insert `Ctrl-r <reg>` | type this range's value of the register (`Ctrl-r #` numbers the cursors). Esc after `Ctrl-r` only cancels it. `Ctrl-r +` reads what pardes last put on the clipboard, not the desktop's | helix `insert_register` | helix-verified |
| `a` … `Esc` with several ranges | the primary's appended-over span is restored; the others collapse to bare cursors | `Pane.append_at` is a single field. Waived (`msel-append`) | waived |
| `&` | align selections into columns: the k-th range of each line is column k, and spaces go in before each range until its head reaches the column's widest head, in display cells (`File.rawDisplayCol`) | helix `align_selections` as of the harness's helix (25.07.1 grouped columns differently). A range over several lines refuses. A TAB counts as `tab_width` cells, not up to the next stop | helix-verified |
| `Alt-)` / `Alt-(` / `<n>Alt-)` | rotate the CONTENTS of the selections forward / back by one range (n ranges), in one edit; each range comes back over the text it now holds and the primary moves with its text | helix `rotate_selection_contents_*` as of the harness's helix: 25.07.1 read the count as a GROUP size instead (rotate by one within each run of n ranges) and left the primary where it was, which is what helix-golf's `invert_dictionary_2` relies on | helix-verified |
@@ -342,7 +344,6 @@ text.
| `]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 |
| insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped |
-| `"` `<reg>`, insert `Ctrl-r` | register select / insert | registers — one yank register and no way to name a second; the system clipboard is not spelled as a register here either, it is the five `SPC` commands in A |
| `Q` / `q` | record / replay macro | macros — needs replayable input log |
| `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped |
| `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes |
@@ -388,12 +389,12 @@ Files (all in `test/hxcases/`):
(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. Four live ones:
+- `waivers.jsonl` — named exemptions, each with a reason. Three live ones:
`wiX-edit-drops-sel` and `msel-append` (helix maps selections through
- insert-mode edits, pardes does not), `alt-c-window-op` (the pardes
- window op deliberately shadows helix change-noyank) and `msel-yank-paste`
- (one yank register, not one value per range). `sel-regex-caret` and
- `sel-regex-dot-newline` went when `s`/`S` began searching line by line.
+ insert-mode edits, pardes does not) and `alt-c-window-op` (the pardes
+ window op deliberately shadows helix change-noyank). `msel-yank-paste`
+ went with registers of one value per range; `sel-regex-caret` and
+ `sel-regex-dot-newline` when `s`/`S` began searching line by line.
- `parity.jsonl` — 80 further cases, used only by the parity gate below.
- `parity-waivers.jsonl` — 14 named exemptions for the parity gate, in three
classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the
diff --git a/docs/selections.md b/docs/selections.md
index 5b992cc4..0f000e2c 100644
--- a/docs/selections.md
+++ b/docs/selections.md
@@ -74,19 +74,18 @@ A parsed action has a scope (`modal.Normal.Action.scope`).
- An undo snapshot (`File.Snapshot`) holds the content and the PRIMARY
range. Undo puts the primary back and leaves the other ranges where they
were; helix restores the whole selection of that revision.
-- There is ONE register, `Pardes.yank`, written by `y`, `d` and `c` and read
- by `p`, `P` and `R`. A yank at several ranges joins their texts with
- newlines into it, and a paste puts that whole value at every range.
+- Registers (`Registers.zig`) hold one value per range. A yank under the
+ replay fills slot `Pardes.multi_index` of `multi_count`, and `p`, `P`, `R`
+ and insert `Ctrl-r` read slot i at range i, the last value standing in
+ for missing ones. `"<reg>` names the register (`Pardes.register` for the
+ length of one command); `_ # . % /` are computed or special and `+`/`*`
+ is the system clipboard. The acme chords and a paste into a terminal read
+ the default register joined by newlines.
- `Alt-.` repeats the last `f`/`t`/`F`/`T`. There is no `.` and there are no
macros.
## Where it differs from helix
-- Registers: helix keeps one value PER RANGE and pastes value `i` at range
- `i`; it has named registers (`"a`), special ones (`#` range indices,
- `.` the selection, `/` the search, `_` the black hole) and inserts a
- register in insert mode with `Ctrl-r`. None exist here (waiver
- `msel-yank-paste`).
- Insert mode: helix maps every range through each keystroke's edit, so a
range an `i`/`a` started from stretches over what is typed and keeps its
anchor. Here only the cursor cells move, and after Esc only the primary's
diff --git a/src/Registers.zig b/src/Registers.zig
new file mode 100644
index 00000000..d2a42434
--- /dev/null
+++ b/src/Registers.zig
@@ -0,0 +1,106 @@
+//! helix's registers: named stores of text, each holding one VALUE PER RANGE
+//! of the selection that wrote it. `"` is the default, `+` the one the
+//! system clipboard is copied from, `/` the last s/S pattern, `@` the last
+//! macro; any other character names a register of its own. The registers
+//! computed from the editor rather than stored (`_`, `#`, `.`, `%`) and the
+//! clipboard's paste side are read in edit.zig, which has the pane at hand.
+const std = @import("std");
+
+const Registers = @This();
+
+list: std.ArrayList(Reg) = .empty,
+
+pub const Reg = struct {
+ name: u21,
+ /// gpa-owned, one per range, in document order
+ values: [][]u8,
+ /// the values joined by newlines, for the readers that take one text
+ /// (the acme chords, a paste into a terminal, the clipboard); gpa-owned,
+ /// made on first read
+ joined: ?[]u8 = null,
+};
+
+pub const default = '"';
+
+pub fn deinit(r: *Registers, gpa: std.mem.Allocator) void {
+ for (r.list.items) |*reg| freeReg(gpa, reg);
+ r.list.deinit(gpa);
+ r.* = .{};
+}
+
+fn freeReg(gpa: std.mem.Allocator, reg: *Reg) void {
+ for (reg.values) |v| gpa.free(v);
+ gpa.free(reg.values);
+ if (reg.joined) |j| gpa.free(j);
+}
+
+pub fn get(r: *Registers, name: u21) ?*Reg {
+ for (r.list.items) |*reg| if (reg.name == name) return reg;
+ return null;
+}
+
+/// Value `i` of register `name`: a register holding fewer values than there
+/// are ranges repeats its last (helix paste_impl).
+pub fn value(r: *Registers, name: u21, i: usize) ?[]const u8 {
+ const reg = r.get(name) orelse return null;
+ if (reg.values.len == 0) return null;
+ return reg.values[@min(i, reg.values.len - 1)];
+}
+
+/// Store value `i` of `n`. `fresh` starts the register over with `n` empty
+/// values; the replay across ranges passes it on its first pass only, so
+/// every range fills its own slot.
+pub fn put(r: *Registers, gpa: std.mem.Allocator, name: u21, bytes: []const u8, i: usize, n: usize, fresh: bool) void {
+ const copy = gpa.dupe(u8, bytes) catch return;
+ const reg = r.get(name) orelse blk: {
+ r.list.append(gpa, .{ .name = name, .values = &.{} }) catch {
+ gpa.free(copy);
+ return;
+ };
+ break :blk &r.list.items[r.list.items.len - 1];
+ };
+ if (fresh or reg.values.len != n) {
+ const values = gpa.alloc([]u8, n) catch {
+ gpa.free(copy);
+ return;
+ };
+ for (values) |*v| v.* = &.{};
+ freeReg(gpa, reg);
+ reg.* = .{ .name = name, .values = values };
+ }
+ gpa.free(reg.values[i]);
+ reg.values[i] = copy;
+ if (reg.joined) |j| gpa.free(j);
+ reg.joined = null;
+}
+
+/// The whole register as one text: its values joined by newlines, no
+/// newline added after a value that already ends in one.
+pub fn text(r: *Registers, gpa: std.mem.Allocator, name: u21) ?[]const u8 {
+ const reg = r.get(name) orelse return null;
+ if (reg.joined) |j| return j;
+ var out: std.ArrayList(u8) = .empty;
+ for (reg.values, 0..) |v, i| {
+ if (i > 0 and !std.mem.endsWith(u8, reg.values[i - 1], "\n")) out.append(gpa, '\n') catch return null;
+ out.appendSlice(gpa, v) catch return null;
+ }
+ reg.joined = out.toOwnedSlice(gpa) catch return null;
+ return reg.joined;
+}
+
+test "a register holds a value per range and joins them for one-text readers" {
+ const gpa = std.testing.allocator;
+ var r: Registers = .{};
+ defer r.deinit(gpa);
+ try std.testing.expect(r.value(default, 0) == null);
+ // a replay fills the last range first
+ r.put(gpa, default, "two", 1, 2, true);
+ r.put(gpa, default, "one\n", 0, 2, false);
+ try std.testing.expectEqualStrings("one\n", r.value(default, 0).?);
+ try std.testing.expectEqualStrings("two", r.value(default, 1).?);
+ try std.testing.expectEqualStrings("two", r.value(default, 5).?);
+ try std.testing.expectEqualStrings("one\ntwo", r.text(gpa, default).?);
+ r.put(gpa, 'a', "x", 0, 1, true);
+ try std.testing.expectEqualStrings("x", r.text(gpa, 'a').?);
+ try std.testing.expectEqualStrings("one\ntwo", r.text(gpa, default).?);
+}
diff --git a/src/Text.zig b/src/Text.zig
index 43dd8a95..7ad243a1 100644
--- a/src/Text.zig
+++ b/src/Text.zig
@@ -66,6 +66,8 @@ select: bool = false,
/// non-vertical range write resets it to -1.
sticky_col: i32 = -1,
append_at: ?struct { row: i32, col: i32 } = null,
+/// insert `Ctrl-r` was pressed: the next key names the register to insert
+reg_wait: bool = false,
normal: modal.Normal.State = .{},
/// last f/F/t/T motion, for Alt-. repeat
find_op: u8 = 0,
diff --git a/src/config.zig b/src/config.zig
index 85a6bc59..4412ef0d 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -492,6 +492,7 @@ pub const match_around = 'a';
pub const surround_add = 's';
pub const surround_replace = 'r';
pub const surround_delete = 'd';
+pub const register_prefix = '"';
pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }};
pub const add_newline: []const Chord = &.{.{ .cp = ' ' }};
@@ -557,6 +558,7 @@ pub const escape: []const Chord = &.{.{ .cp = Key.escape }};
pub const insert_backspace_alias: []const Chord = &.{.{ .cp = 'h', .ctrl = true }};
pub const insert_enter_alias: []const Chord = &.{.{ .cp = 'j', .ctrl = true }};
pub const insert_delete_alias: []const Chord = &.{.{ .cp = 'd', .ctrl = true }};
+pub const insert_register: []const Chord = &.{.{ .cp = 'r', .ctrl = true }};
pub const delete_word_backward: []const Chord = &.{ .{ .cp = 'w', .ctrl = true }, .{ .cp = Key.backspace, .alt = true } };
pub const delete_word_forward: []const Chord = &.{ .{ .cp = 'd', .alt = true }, .{ .cp = Key.delete, .alt = true } };
pub const kill_to_line_start: []const Chord = &.{.{ .cp = 'u', .ctrl = true }};
diff --git a/src/edit.zig b/src/edit.zig
index 24354df8..14d8b7ed 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -26,23 +26,75 @@ const hit = pardes.hit;
const sel_slot = pardes.sel_slot;
const Effect = pardes.Effect;
const Pardes = pardes.Pardes;
+const Registers = pardes.Registers;
+/// Write `text` as this range's value of the register the running command
+/// names (`"<reg>`), the default one otherwise. `_` swallows it, and the
+/// registers computed from the editor (`#`, `.`, `%`) cannot be written.
+/// `+` and `*` are the system clipboard: the value is sent there too.
pub fn setYank(p: *Pardes, text: []const u8) void {
- if (p.multi_on and !p.multi_first) {
- const old = p.yank orelse "";
- const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n";
- const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return;
- if (p.yank) |y| p.gpa.free(y);
- p.yank = joined;
- return;
+ var name: u21 = if (p.register != 0) p.register else Registers.default;
+ switch (name) {
+ '_', '#', '.', '%' => return,
+ '*' => name = '+',
+ else => {},
}
- if (p.yank) |y| p.gpa.free(y);
- p.yank = p.gpa.dupe(u8, text) catch null;
+ const one = !p.multi_on;
+ p.registers.put(p.gpa, name, text, if (one) 0 else p.multi_index, if (one) 1 else p.multi_count, one or p.multi_first);
+ if (name == '+' and (one or p.multi_first)) p.emit(.{ .set_clipboard = {} });
}
pub fn setClipboard(p: *Pardes, text: []const u8) void {
+ const named = p.register;
+ p.register = '+';
+ defer p.register = named;
setYank(p, text);
- if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} });
+}
+
+/// Value `i` of register `name` as the command at range `i` reads it, the
+/// computed registers included: `#` is the range's number from 1, `.` its
+/// text, `%` the file's name, `_` nothing. Scratch- or register-owned.
+pub fn registerValue(p: *Pardes, t: *Text, name: u21) ?[]const u8 {
+ const i = if (p.multi_on) p.multi_index else 0;
+ return switch (name) {
+ '_' => null,
+ '#' => std.fmt.allocPrint(p.scratch.allocator(), "{d}", .{i + 1}) catch null,
+ '.' => selText(p, t),
+ '%' => if (t.what == .body) if (t.pane()) |pane| (if (pane.file) |f| f.path else null) else null else null,
+ else => p.registers.value(name, i),
+ };
+}
+
+/// the text of the range under the cursor: the selection, or the cursor's
+/// own character
+fn selText(p: *Pardes, t: *Text) []const u8 {
+ if (t.msel.active) return yankRows(p, t, @min(t.msel.r0, t.msel.r1), @max(t.msel.r0, t.msel.r1));
+ const save = t.vsel;
+ defer t.vsel = save;
+ if (!t.vsel.active) t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col, .explicit = false };
+ return vselText(p, t);
+}
+
+/// `p` / `P` / `R` from the register the command names: this range's value
+/// of it. The clipboard's is asked of the shell and pasted when it answers,
+/// once, at the primary.
+pub fn pasteRegister(p: *Pardes, t: *Text, how: enum { after, before, replace }, count: usize) void {
+ const name: u21 = if (p.register != 0) p.register else Registers.default;
+ if (name == '+' or name == '*') {
+ if (!normal.multiOnce(p)) return;
+ const pane = t.pane() orelse return;
+ return clipRequest(p, p.paneIdOf(pane) orelse return, switch (how) {
+ .after => .after,
+ .before => .before,
+ .replace => .replace,
+ });
+ }
+ const y = registerValue(p, t, name) orelse return;
+ switch (how) {
+ .after => pasteText(p, t, y, false, count),
+ .before => pasteText(p, t, y, true, count),
+ .replace => replaceWithText(p, t, y),
+ }
}
/// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers.
@@ -125,6 +177,10 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
if (pv.selection_text.len > 0) setClipboard(p, pv.selection_text);
return;
};
+ // helix's `"+y`: the clipboard register, and only it
+ const named = p.register;
+ p.register = '+';
+ defer p.register = named;
if (pane.body.nsel > 0 and !main_only) {
normal.replaySels(p, &pane.body, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } });
} else {
@@ -133,7 +189,6 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
normalYank(p, &pane.body);
pane.body.nsel = others;
}
- p.emit(.{ .set_clipboard = {} });
}
/// tag + '\n' + body: the full selectable pane text, a row to a screen row
@@ -286,6 +341,7 @@ pub fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 {
pub fn exitInsert(p: *Pardes, t: *Text) void {
t.mode = .normal;
+ t.reg_wait = false;
t.msel.active = false;
t.normal.clear();
if (t.append_at) |aa| {
@@ -565,6 +621,19 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
if (hit(key, config.insert_backspace_alias)) return insertKey(p, t, .{ .cp = Key.backspace });
if (hit(key, config.insert_enter_alias)) return insertKey(p, t, .{ .cp = Key.enter });
if (hit(key, config.insert_delete_alias)) return insertKey(p, t, .{ .cp = Key.delete });
+ // Ctrl-r <reg>: type this range's value of the register (helix
+ // insert_register)
+ if (t.reg_wait) {
+ t.reg_wait = false;
+ if (key.ctrl or key.alt or key.cp >= 0xF0000) return;
+ const y = registerValue(p, t, key.cp) orelse return;
+ if (y.len > 0) insertKey(p, t, .{ .cp = key.cp, .text = y });
+ return;
+ }
+ if (hit(key, config.insert_register)) {
+ t.reg_wait = true;
+ return;
+ }
t.vsel.active = false;
if (!t.cur_pinned) pinPaneCursor(p, t);
_ = clampFileCursor(t);
@@ -1132,8 +1201,9 @@ pub fn normalReplaceChar(p: *Pardes, t: *Text, ch: u21) void {
/// `R`: replace the selection (or the cursor char) with the DEFAULT
/// register. `SPC R` is the same verb over the system clipboard.
-pub fn normalReplaceYank(p: *Pardes, t: *Text) void {
- replaceWithText(p, t, p.yank orelse return);
+/// The acme chords' replace: the default register as one text.
+pub fn chordReplace(p: *Pardes, t: *Text) void {
+ replaceWithText(p, t, p.registers.text(p.gpa, Registers.default) orelse return);
}
/// The pasted text becomes the selection, in the direction the replaced one
@@ -1957,16 +2027,20 @@ test "only the SPC clipboard commands cross to the system clipboard" {
_ = drainedEffect(p, .set_clipboard);
p.update(.{ .key = .{ .cp = 'y' } });
- try std.testing.expect(p.yank != null and p.yank.?.len > 0);
+ const plain = p.registers.text(gpa, Registers.default) orelse return error.MissingYank;
+ try std.testing.expect(plain.len > 0);
try std.testing.expect(!drainedEffect(p, .set_clipboard));
p.update(.{ .key = .{ .cp = 'd' } });
try std.testing.expect(!drainedEffect(p, .set_clipboard));
- // `SPC y` is the one that does, and it puts the SAME text there that `y`
- // put in the register — it is the ordinary yank path plus the mirror.
+ // `SPC y` is the one that does: helix's `"+y`, the ordinary yank into
+ // the clipboard register instead of the default one.
p.update(.{ .key = .{ .cp = ' ' } });
p.update(.{ .key = .{ .cp = 'y' } });
- const yanked = p.yank orelse return error.MissingYank;
+ const yanked = try gpa.dupe(u8, p.registers.text(gpa, Registers.default) orelse "");
+ defer gpa.free(yanked);
+ const clip: []const u8 = p.registers.text(gpa, '+') orelse "";
+ try std.testing.expect(clip.len > 0);
try std.testing.expect(drainedEffect(p, .set_clipboard));
const before = pane.file.?.content.len;
@@ -1980,14 +2054,14 @@ test "only the SPC clipboard commands cross to the system clipboard" {
try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "PASTED") != null);
// ...and it did NOT land in the register on its way past, nor echo back
// out to the clipboard it came from.
- try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+ try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse "");
try std.testing.expect(!drainedEffect(p, .set_clipboard));
// An UNSOLICITED paste — the outer terminal's bracketed paste, a Cmd-V —
// is the same event with no request behind it, and means paste after.
p.update(.{ .paste = "UNASKED" });
try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "UNASKED") != null);
- try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+ try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse "");
// A request the shell never answers dies at the next keystroke rather
// than firing late into whatever pane is focused by then.
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 46bd5ffc..8f6248ce 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4385,7 +4385,7 @@ test "the headless grid host round-trips a yank back as a paste" {
core.update(.{ .key = .{ .cp = ' ' } });
core.update(.{ .key = .{ .cp = 'y' } });
while (core.nextEffect()) |e| core.perform(e);
- const yanked = core.yank orelse return error.MissingYank;
+ const yanked = core.registers.text(core.gpa, '+') orelse return error.MissingYank;
try std.testing.expect(yanked.len > 0);
const before = pane.file.?.content.len;
diff --git a/src/look.zig b/src/look.zig
index d7e763d3..5e2d6068 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -502,7 +502,11 @@ const SearchStart = enum { top, cursor };
pub fn submitSearch(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
- if (normal.selRegexArmed(pane)) |a| return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split);
+ if (normal.selRegexArmed(pane)) |a| {
+ // helix keeps the pattern in `/`
+ if (a.pat.len > 0) p.registers.put(p.gpa, '/', a.pat, 0, 1, true);
+ return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split);
+ }
const armed = pane.promptText() orelse return;
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return;
if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]);
diff --git a/src/modal.zig b/src/modal.zig
index aadaab00..aec1c579 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -15,6 +15,7 @@ pub const Normal = struct {
prefix_replace,
prefix_next,
prefix_prev,
+ prefix_register,
goto_file_start,
goto_last_line,
@@ -160,6 +161,7 @@ pub const Normal = struct {
replace,
next,
prev,
+ register,
};
pub const MatchSub = enum(u8) {
@@ -176,6 +178,8 @@ pub const Normal = struct {
prefix: Prefix = .none,
match_sub: MatchSub = .none,
held_char: u21 = 0,
+ /// `"<reg>`: the register the next command names, 0 for none
+ register: u21 = 0,
pub fn clear(state: *State) void {
state.* = .{};
@@ -491,6 +495,14 @@ pub const Normal = struct {
if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } });
return .ignored;
},
+ .register => {
+ state.prefix = .none;
+ const char = key.literal() orelse return .ignored;
+ state.register = char;
+ // the count typed before `"` is still the command's
+ state.count = if (counted.explicit) count else 0;
+ return .pending;
+ },
.none => {},
}
@@ -505,6 +517,7 @@ pub const Normal = struct {
if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0);
if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0);
if (key.has(.prefix_match)) return armPrefix(state, .match, 0);
+ if (key.has(.prefix_register)) return armPrefix(state, .register, if (counted.explicit) count else 0);
if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } });
if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } });
diff --git a/src/mouse.zig b/src/mouse.zig
index f222b5da..91ec41a6 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -963,8 +963,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
if (cut) {
if (t.vsel.active) edit.normalDelete(p, t, true);
} else if (t.vsel.active) {
- edit.normalReplaceYank(p, t);
- } else edit.pasteText(p, t, p.yank orelse return, true, 1);
+ edit.chordReplace(p, t);
+ } else edit.pasteText(p, t, p.registers.text(p.gpa, pardes.Registers.default) orelse return, true, 1);
return;
}
if (pane.body.mode == .tty) {
@@ -973,7 +973,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
pane.sel[sel_slot].state = .none; // drop the sweep highlight
}
if (cut) return;
- const y = p.yank orelse return;
+ const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return;
if (y.len == 0) return;
if (panes.Terminal.reportsMouse(pane)) {
const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
@@ -1016,11 +1016,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
if (cut) {
if (pane.body.vsel.active) edit.normalDelete(p, &pane.body, true);
} else if (pane.body.vsel.active) {
- edit.normalReplaceYank(p, &pane.body);
+ edit.chordReplace(p, &pane.body);
} else if (pane.file) |*f| {
// bare cursor: splice the register in literally (no linewise
// magic) so a paste right after a cut restores the text exactly
- const y = p.yank orelse return;
+ const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return;
if (y.len == 0) return;
const at = modal.Cursor{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) };
edit.pushUndo(p, &pane.body);
@@ -1039,7 +1039,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
pane.body.cur_pinned = true;
pane.body.sticky_col = -1;
pane.ensureCursorVisible();
- } else edit.pasteText(p, &pane.body, p.yank orelse return, true, 1);
+ } else edit.pasteText(p, &pane.body, p.registers.text(p.gpa, pardes.Registers.default) orelse return, true, 1);
}
test "plain left click clears explicit modal selection" {
diff --git a/src/normal.zig b/src/normal.zig
index 5a6c1cd5..f5e868a4 100644
--- a/src/normal.zig
+++ b/src/normal.zig
@@ -35,6 +35,7 @@ fn roleBindingName(comptime role: modal.Normal.Role) []const u8 {
.prefix_replace => "replace_prefix",
.prefix_next => "next_prefix",
.prefix_prev => "prev_prefix",
+ .prefix_register => "register_prefix",
else => @tagName(role),
};
}
@@ -149,6 +150,7 @@ const KeyState = struct {
find_op: u8,
find_ch: u21,
append_at: @FieldType(Text, "append_at"),
+ reg_wait: bool,
fn of(t: *Text) KeyState {
return .{
@@ -158,6 +160,7 @@ const KeyState = struct {
.find_op = t.find_op,
.find_ch = t.find_ch,
.append_at = t.append_at,
+ .reg_wait = t.reg_wait,
};
}
@@ -168,6 +171,7 @@ const KeyState = struct {
t.find_op = s.find_op;
t.find_ch = s.find_ch;
t.append_at = s.append_at;
+ t.reg_wait = s.reg_wait;
}
};
@@ -227,6 +231,8 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void {
while (i > 0) {
i -= 1;
p.multi_first = passes == 0;
+ p.multi_index = i;
+ p.multi_count = n;
passes += 1;
saved.into(t);
t.nsel = 0; // the handler underneath sees ONE selection
@@ -284,11 +290,16 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void {
}
pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void {
+ // `"<reg>` is held by the parser until the command it names arrives
+ const named = t.normal.register;
const parsed = modal.Normal.parse(&t.normal, normalInput(key));
const semantic = switch (parsed) {
.pending, .ignored, .unbound => return,
.action => |value| value,
};
+ t.normal.register = 0;
+ p.register = named;
+ defer p.register = 0;
if (t.nsel == 0 or semantic.scope() == .once)
return executeNormalAction(p, t, semantic);
replaySels(p, t, .{ .normal = semantic });
@@ -534,9 +545,9 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
.delete_noyank => return edit.normalDelete(p, t, false),
.change => return edit.normalChange(p, t),
.yank => return edit.normalYank(p, t),
- .replace_with_yank => return edit.normalReplaceYank(p, t),
- .paste_after => return edit.pasteText(p, t, p.yank orelse return, false, op.count),
- .paste_before => return edit.pasteText(p, t, p.yank orelse return, true, op.count),
+ .replace_with_yank => return edit.pasteRegister(p, t, .replace, op.count),
+ .paste_after => return edit.pasteRegister(p, t, .after, op.count),
+ .paste_before => return edit.pasteRegister(p, t, .before, op.count),
.switch_case => return edit.normalCase(p, t, .toggle),
.lowercase => return edit.normalCase(p, t, .lower),
.uppercase => return edit.normalCase(p, t, .upper),
diff --git a/src/pardes.zig b/src/pardes.zig
index 9821839f..e4645d70 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -17,6 +17,7 @@ pub const normal = @import("normal.zig");
pub const edit = @import("edit.zig");
pub const body_layer = @import("body_layer.zig");
pub const Messages = @import("Messages.zig");
+pub const Registers = @import("Registers.zig");
pub const Pipe = @import("Pipe.zig");
pub const colors = @import("colors.zig");
pub const Theme = colors.Theme;
@@ -453,6 +454,7 @@ test {
_ = @import("regexp.zig");
_ = @import("edit.zig");
_ = @import("Messages.zig");
+ _ = @import("Registers.zig");
_ = @import("selection_pipe.zig");
_ = @import("Pipe.zig");
_ = @import("colors.zig");
@@ -2326,7 +2328,7 @@ test "a host with no methods at all is a complete in-process pardes" {
try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y);
// the clipboard round-trips through the in-process one
- p.yank = try gpa.dupe(u8, "copied");
+ p.registers.put(gpa, '+', "copied", 0, 1, true);
p.perform(.set_clipboard);
try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items);
@@ -4003,6 +4005,14 @@ pub const Pardes = struct {
multi_on: bool = false,
multi_first: bool = false,
multi_stop: bool = false,
+ /// While a key is replayed across the ranges: which range this pass is,
+ /// in document order, and how many there are. A register value belongs
+ /// to its range by this index.
+ multi_index: usize = 0,
+ multi_count: usize = 1,
+ /// The register the running normal command names (`"<reg>`); 0 for the
+ /// default one.
+ register: u21 = 0,
leader_on: bool = false,
leader_keys: [4]u8 = undefined,
leader_n: u8 = 0,
@@ -4085,7 +4095,7 @@ pub const Pardes = struct {
in_head: usize = 0,
in_len: usize = 0,
- yank: ?[]u8 = null,
+ registers: Registers = .{},
clip_pending: ?edit.ClipRequest = null,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
@@ -4256,7 +4266,7 @@ pub const Pardes = struct {
const gpa = p.gpa;
for (&p.col_tags) |*t| t.deinit(gpa);
p.global_tag.deinit(gpa);
- if (p.yank) |y| gpa.free(y);
+ p.registers.deinit(gpa);
if (p.dump_out) |d| gpa.free(d);
p.fallback.deinit();
if (p.last_dump) |d| gpa.free(d);
@@ -4826,7 +4836,7 @@ pub const Pardes = struct {
}
},
.set_clipboard => {
- const text = p.yank orelse "";
+ const text = p.registers.text(p.gpa, '+') orelse "";
if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text);
},
// No desktop to ask: answer from the in-process clipboard at once,
@@ -5162,7 +5172,7 @@ pub const Pardes = struct {
// in it the chord is the program's -- vim's visual block,
// readline's quoted-insert -- and swallowing it would make
// Ctrl-V a black hole in every full-screen application.
- if (p.yank) |text| return edit.typeToTty(p, p.active, pane, text);
+ if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text);
}
return panes.Terminal.forwardKey(p, p.active, key);
}
@@ -5310,7 +5320,7 @@ pub const Pardes = struct {
switch (t.mode) {
.normal => normal.handleNormal(p, t, key),
.insert => {
- if (hit(key, config.escape)) return edit.exitInsert(p, t);
+ if (hit(key, config.escape) and !t.reg_wait) return edit.exitInsert(p, t);
edit.handleInsert(p, t, key);
},
.tty => panes.Terminal.forwardKey(p, p.active, key),
diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl
index 487cd445..7ba44f09 100644
--- a/test/hxcases/cases.jsonl
+++ b/test/hxcases/cases.jsonl
@@ -442,6 +442,19 @@
{"name": "msel-collapse", "pane": "file", "text": "abcd\nabcd\n", "keys": "vllC;"}
{"name": "msel-flip", "pane": "file", "text": "abcd\nabcd\n", "keys": "vllC<A-;>"}
{"name": "msel-yank-paste", "pane": "file", "text": "ab\ncd\n", "keys": "Cyp"}
+{"name": "reg-named-yank-paste", "pane": "file", "text": "ab cd\n", "keys": "\"ayw\"ap"}
+{"name": "reg-named-keeps-default", "pane": "file", "text": "ab cd\n", "keys": "yw\"ay\"_dp"}
+{"name": "reg-blackhole-delete", "pane": "file", "text": "ab cd\n", "keys": "yw\"_dP"}
+{"name": "msel-reg-paste-per-range", "pane": "file", "text": "ab\ncd\n", "keys": "Cyl2Pp"}
+{"name": "msel-reg-fewer-values", "pane": "file", "text": "ab\ncd\nef\n", "keys": "yCCp"}
+{"name": "msel-reg-replace-per-range", "pane": "file", "text": "ab\ncd\n", "keys": "Cy<A-(>Ry"}
+{"name": "msel-reg-more-values-one-range", "pane": "file", "text": "ab\ncd\n", "keys": "Cy,p"}
+{"name": "reg-index-insert", "pane": "file", "text": "x\nx\nx\n", "keys": "%<A-s>i<C-r>#<esc>"}
+{"name": "reg-search-paste", "pane": "file", "text": "ab\n", "keys": "%sb<ret>\"/p"}
+{"name": "reg-count-before-quote", "pane": "file", "text": "ab\n", "keys": "y2\"ap"}
+{"name": "reg-count-after-quote", "pane": "file", "text": "ab\n", "keys": "\"ay\"a3p"}
+{"name": "reg-ctrl-r-named", "pane": "file", "text": "ab\n", "keys": "\"ayli<C-r>a<esc>"}
+{"name": "reg-ctrl-r-esc", "pane": "file", "text": "ab\n", "keys": "i<C-r><esc>x"}
{"name": "msel-esc-keeps", "pane": "file", "text": "aa\nbb\n", "keys": "C<esc>"}
{"name": "msel-select-mode", "pane": "file", "text": "abcd\nabcd\n", "keys": "Cvl"}
{"name": "msel-open-count", "pane": "file", "text": "ab\n", "keys": "2O"}
diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl
index 006524e8..f6a6aada 100644
--- a/test/hxcases/goldens.jsonl
+++ b/test/hxcases/goldens.jsonl
@@ -442,6 +442,19 @@
{"name":"msel-collapse","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}],"primary":1}
{"name":"msel-flip","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":2}}],"primary":1}
{"name":"msel-yank-paste","text":"aab\nccd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1}
+{"name":"reg-named-yank-paste","text":"ab acd\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}
+{"name":"reg-named-keeps-default","text":"cad\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"reg-blackhole-delete","text":"acd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"msel-reg-paste-per-range","text":"aaaab\nccccd\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":1}
+{"name":"msel-reg-fewer-values","text":"aab\ncad\neaf\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}}],"primary":2}
+{"name":"msel-reg-replace-per-range","text":"ab\ncd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0}
+{"name":"msel-reg-more-values-one-range","text":"ab\ncad\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}
+{"name":"reg-index-insert","text":"1x\n2x\n3x\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}}],"primary":0}
+{"name":"reg-search-paste","text":"abb\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"reg-count-before-quote","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"reg-count-after-quote","text":"aaaab\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":1}}
+{"name":"reg-ctrl-r-named","text":"aab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}
+{"name":"reg-ctrl-r-esc","text":"xab\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
{"name":"msel-esc-keeps","text":"aa\nbb\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1}
{"name":"msel-select-mode","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1}
{"name":"msel-open-count","text":"\n\nab\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0}
diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl
index 01e6cfa7..8ac3bd9c 100644
--- a/test/hxcases/waivers.jsonl
+++ b/test/hxcases/waivers.jsonl
@@ -1,4 +1,3 @@
{"name":"wiX-edit-drops-sel","reason":"Insert edits clear the selection instead of tracking its anchor.","reference":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":6}},"expected":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}}
{"name":"alt-c-window-op","reason":"Alt-c moves a pane to a new column instead of Helix's change-noyank.","reference":{"name":"alt-c-window-op","text":"bc def\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"alt-c-window-op","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}}
{"name":"msel-append","reason":"Append-mode selection origins are tracked only for the primary selection.","reference":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1},"expected":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1}}
-{"name":"msel-yank-paste","reason":"Pardes has one combined yank register, not one value per selection.","reference":{"name":"msel-yank-paste","text":"aab\nccd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1},"expected":{"name":"msel-yank-paste","text":"aa\ncb\nca\ncd\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":0,"col":1}},{"cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1}}],"primary":1}}
diff --git a/test/panes.zig b/test/panes.zig
index d0551cfc..cbf9d097 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -325,7 +325,7 @@ const TagNavigationTests = struct {
try std.testing.expectEqual(@as(i32, 0), pane.tag.cur_col);
key(p, 'E');
key(p, 'y');
- try std.testing.expectEqualStrings("/test.txt", p.yank.?);
+ try std.testing.expectEqualStrings("/test.txt", p.registers.text(p.gpa, '"').?);
// an edit that reaches into it is refused, the cursor with it
const before = try std.testing.allocator.dupe(u8, try pardes.edit.flatSurface(p, &pane.tag));
defer std.testing.allocator.free(before);
@@ -848,12 +848,12 @@ const TtySelectionTests = struct {
const x = rect.x + config.GUTTER;
const y = p.bodyTop(pane, rect);
JumpSelectionTests.drag(p, x + 1, y, x + 3, y);
- try std.testing.expectEqualStrings("lph", p.yank orelse return error.MissingAutomaticTtyYank);
+ try std.testing.expectEqualStrings("lph", p.registers.text(p.gpa, '"') orelse return error.MissingAutomaticTtyYank);
p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } });
p.update(.{ .key = .{ .cp = 'l' } });
try std.testing.expectEqual(@as(i32, 4), pane.body.cur_col);
p.update(.{ .key = .{ .cp = 'y' } });
- try std.testing.expectEqualStrings("a", p.yank.?);
+ try std.testing.expectEqualStrings("a", p.registers.text(p.gpa, '"').?);
try noChildInput(p);
}
@@ -881,7 +881,7 @@ const TtySelectionTests = struct {
const expected = if (prompt) "$ ab" else "lph\nrav";
try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode);
try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?);
- try std.testing.expectEqualStrings(expected, p.yank orelse return error.MissingAutomaticTtyYank);
+ try std.testing.expectEqualStrings(expected, p.registers.text(p.gpa, '"') orelse return error.MissingAutomaticTtyYank);
try noChildInput(p);
try std.testing.expect(p.executeBuiltinLine(0, "ClipYank"));
@@ -897,7 +897,7 @@ const TtySelectionTests = struct {
}
try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?);
p.update(.{ .key = .{ .cp = 'y' } });
- try std.testing.expectEqualStrings(expected, p.yank.?);
+ try std.testing.expectEqualStrings(expected, p.registers.text(p.gpa, '"').?);
try noChildInput(p);
p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } });
try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode);
@@ -908,7 +908,7 @@ const TtySelectionTests = struct {
_ = try p.render(frame.allocator());
try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode);
try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?);
- try std.testing.expectEqualStrings(expected, p.yank.?);
+ try std.testing.expectEqualStrings(expected, p.registers.text(p.gpa, '"').?);
try noChildInput(p);
}
}
@@ -940,7 +940,7 @@ const TtySelectionTests = struct {
const sx = source.x + config.GUTTER;
const sy = if (p.settings.tag_bottom) source.y else source.y + pardes.BOX_H;
JumpSelectionTests.drag(p, sx + 1, sy, sx + 3, sy + 1);
- try std.testing.expectEqualStrings("lph\nrav", p.yank orelse return error.MissingAutomaticTtyYank);
+ try std.testing.expectEqualStrings("lph\nrav", p.registers.text(p.gpa, '"') orelse return error.MissingAutomaticTtyYank);
try noChildInput(p);
const target = p.rects[1];
@@ -1001,7 +1001,7 @@ const TtySelectionTests = struct {
try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode);
if (bracketed) p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } });
try std.testing.expectEqual(bracketed, panes.Terminal.bracketedPaste(pane));
- p.yank = try p.gpa.dupe(u8, "one\ntwo"); // what a `y` anywhere left behind
+ p.registers.put(p.gpa, '"', "one\ntwo", 0, 1, true); // what a `y` anywhere left behind
var buf: [256]u8 = undefined;
_ = childInput(p, &buf);
@@ -1033,7 +1033,7 @@ const TtySelectionTests = struct {
);
}
// ...and neither chord put the clipboard in the register on its way.
- try std.testing.expectEqualStrings("one\ntwo", p.yank orelse "");
+ try std.testing.expectEqualStrings("one\ntwo", p.registers.text(p.gpa, '"') orelse "");
}
}
};
@@ -4242,7 +4242,7 @@ test "an empty counted paste leaves no count for the next motion" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 });
defer p.deinit();
const pane = try p.setTestFile("abcdefgh\n");
- if (empty_register) p.yank = try p.gpa.dupe(u8, "");
+ if (empty_register) p.registers.put(p.gpa, '"', "", 0, 1, true);
for ("3p") |cp| p.update(.{ .key = .{ .cp = cp } });
try std.testing.expectEqual(modal.Normal.State{}, pane.body.normal);
p.update(.{ .key = .{ .cp = 'l' } });