From 4315121885bebd48f8c03a2b1c7b0bd943c67afd Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:31:55 -0300 Subject: Alt-J joins lines and selects the spaces it put in helix-golf's text_into_array turns lines into an array with %ms"i,: join_selections_space joins like J and leaves one cursor on each space it inserted. J and Alt-J now make one edit over every range, as helix does, rather than J being replayed per range; that is what lets Alt-J hand back all the spaces as the new selection. Co-Authored-By: Claude Opus 5.5 --- src/normal.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src/normal.zig') diff --git a/src/normal.zig b/src/normal.zig index da707e26..c976823c 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -540,7 +540,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .switch_case => return edit.normalCase(p, t, .toggle), .lowercase => return edit.normalCase(p, t, .lower), .uppercase => return edit.normalCase(p, t, .upper), - .join_lines => return edit.normalJoin(p, t), + .join_lines => return edit.normalJoin(p, t, false), + .join_select_space => return edit.normalJoin(p, t, true), .indent => return edit.normalIndent(p, t, op.count, true), .unindent => return edit.normalIndent(p, t, op.count, false), .comment_toggle => return edit.normalToggleComment(p, t), -- cgit v1.3 From 122281b0ea3473f8950910765c487ef3fc82e7d4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:33:48 -0300 Subject: Alt-( and Alt-) rotate the text of the selections helix-golf swaps keys and values with rotate_selection_contents (webS_to_ , then per line). Each range's text moves to the next range (the previous one for Alt-(), in one edit; a count rotates by that many ranges, each range comes back over the text it now holds in its own direction, and the primary follows its text. That is the harness's helix; 25.07.1 read the count as a group size and left the primary alone. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 4 ++-- src/config.zig | 2 ++ src/edit.zig | 51 ++++++++++++++++++++++++++++++++++++++++++++++ src/modal.zig | 15 +++++++++++++- src/normal.zig | 2 ++ test/hxcases/cases.jsonl | 7 +++++++ test/hxcases/goldens.jsonl | 7 +++++++ 7 files changed, 85 insertions(+), 3 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 7fe2c476..23d63454 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -227,7 +227,7 @@ grew `sels` + `primary`, emitted only when there is more than one range. | `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 | | `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 a column | **skipped**: needs visual (tab-expanded) columns, which nothing else in pardes measures | -| `Alt-(` / `Alt-)` | rotate the CONTENTS of the selections | skipped: a separate feature from rotating which range is primary | +| `Alt-)` / `Alt-(` / `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 | Anything that reaches outside the buffer — a builtin, a language query — runs once from the primary and drops back to a single cursor rather than firing per @@ -347,7 +347,7 @@ text. | `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 | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | -| `&`, `Alt-(` / `Alt-)` | align selections, rotate selection CONTENTS | see the multiple-cursors table for why | +| `&` | align selections | see the multiple-cursors table for why | | `Alt-\|` `!` `Alt-!` `$` | shell pipe-to (output discarded), insert output, append output, keep-by-exit-status | shell — `\|` (`shell_pipe`, output replaces the selection) is implemented in section A; these four are the other members of helix's shell family. `$` is additionally taken for line-end here (section A) | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | diff --git a/src/config.zig b/src/config.zig index 372c4884..edc73837 100644 --- a/src/config.zig +++ b/src/config.zig @@ -539,6 +539,8 @@ pub const to_lowercase: []const Chord = &.{.{ .cp = '`' }}; pub const to_uppercase: []const Chord = &.{.{ .cp = '`', .alt = true }}; pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; pub const join_select_space: []const Chord = &.{.{ .cp = 'J', .alt = true }}; +pub const rotate_contents_fwd: []const Chord = &.{.{ .cp = ')', .alt = true }}; +pub const rotate_contents_back: []const Chord = &.{.{ .cp = '(', .alt = true }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; pub const format: []const Chord = &.{.{ .cp = '=' }}; diff --git a/src/edit.zig b/src/edit.zig index 09c3ffe7..2dc79ab5 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1292,6 +1292,57 @@ pub fn normalJoin(p: *Pardes, t: *Text, select_space: bool) void { t.setRanges(t2, rs[0..n], &.{}, pri, expl); } +/// `Alt-)` / `Alt-(`: rotate the TEXT of the selections forward / back by +/// one range (count ranges), in one edit (helix reorder_selection_contents). +/// Each range comes back over the text it now holds, in its own direction, +/// and the primary moves with the text it had. +pub fn normalRotateContents(p: *Pardes, t: *Text, forward: bool, cnt: usize) void { + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + var b = selRows(t); + for (t.sels[0..t.nsel]) |s| { + b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); + b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); + } + const eb = editTextEol(p, t, b) orelse return; + const text = eb.text; + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, eb.row0, &rs); + const n = got.n; + if (n < 2) return; + const by = @min(cnt, n); + var chs: [Text.max_selections]TextChange = undefined; + for (rs[0..n], 0..) |r, i| { + const src = rs[if (forward) (i + n - by) % n else (i + by) % n]; + chs[i] = .{ + .from = @min(r.anchor, r.head), + .to = @max(r.anchor, r.head), + .ins = text[@min(src.anchor, src.head)..@max(src.anchor, src.head)], + }; + } + pushUndo(p, t); + const new = applyChanges(p, text, chs[0..n]) catch return; + var cells: [Text.max_selections]Text.SelRange = undefined; + var delta: i64 = 0; + for (chs[0..n], rs[0..n], 0..) |ch, r, i| { + const from: usize = @intCast(@as(i64, @intCast(ch.from)) + delta); + delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from)); + const to = from + ch.ins.len; + const c = Text.rangeCells(new, if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }); + const cc = modal.positionAt(new, c.cur); + const ac = modal.positionAt(new, c.anc); + cells[i] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + } + setEditText(p, t, new); + const t2 = flatSurface(p, t) catch return; + for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col); + t.setRanges(t2, rs[0..n], &.{}, if (forward) (got.pri + by) % n else (got.pri + n - by) % n, expl); +} + pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { t.select = false; const sr = selRows(t); diff --git a/src/modal.zig b/src/modal.zig index 0e6aba61..6bf7918f 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -109,6 +109,8 @@ pub const Normal = struct { to_uppercase, join_lines, join_select_space, + rotate_contents_fwd, + rotate_contents_back, indent, unindent, format, @@ -262,6 +264,8 @@ pub const Normal = struct { uppercase, join_lines, join_select_space, + rotate_contents_forward, + rotate_contents_backward, indent, unindent, comment_toggle, @@ -319,7 +323,14 @@ pub const Normal = struct { .search_step, => .once, .edit => |edit| switch (edit.kind) { - .join_lines, .join_select_space, .comment_toggle, .undo, .redo => .once, + .join_lines, + .join_select_space, + .rotate_contents_forward, + .rotate_contents_backward, + .comment_toggle, + .undo, + .redo, + => .once, else => .per_selection, }, else => .per_selection, @@ -552,6 +563,8 @@ pub const Normal = struct { if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } }); if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } }); if (key.has(.join_select_space)) return resultAction(.{ .edit = .{ .kind = .join_select_space, .count = count } }); + if (key.has(.rotate_contents_fwd)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_forward, .count = count } }); + if (key.has(.rotate_contents_back)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_backward, .count = count } }); if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); if (key.has(.format)) return resultAction(.{ .lsp = .format }); diff --git a/src/normal.zig b/src/normal.zig index c976823c..9c6f2acd 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -542,6 +542,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .uppercase => return edit.normalCase(p, t, .upper), .join_lines => return edit.normalJoin(p, t, false), .join_select_space => return edit.normalJoin(p, t, true), + .rotate_contents_forward => return edit.normalRotateContents(p, t, true, op.count), + .rotate_contents_backward => return edit.normalRotateContents(p, t, false, op.count), .indent => return edit.normalIndent(p, t, op.count, true), .unindent => return edit.normalIndent(p, t, op.count, false), .comment_toggle => return edit.normalToggleComment(p, t), diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 0b8858e6..471d3a6c 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -404,6 +404,13 @@ {"name": "msel-trim", "pane": "file", "text": " ab \n", "keys": "%_"} {"name": "msel-trim-all-ws", "pane": "file", "text": " \n", "keys": "%_"} {"name": "msel-trim-multi", "pane": "file", "text": " ab \n cd \n", "keys": "xC_"} +{"name": "msel-rotate-contents-fwd", "pane": "file", "text": "a b c\n", "keys": "%s\\w"} +{"name": "msel-rotate-contents-back", "pane": "file", "text": "a b c\n", "keys": "%s\\w"} +{"name": "msel-rotate-contents-count", "pane": "file", "text": "a b c d\n", "keys": "%s\\w2"} +{"name": "msel-rotate-contents-lengths", "pane": "file", "text": "one to three\n", "keys": "%s\\w+)"} +{"name": "msel-rotate-contents-backward", "pane": "file", "text": "ab cde\n", "keys": "%s\\w+"} +{"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%"} +{"name": "rotate-contents-one", "pane": "file", "text": "ab\n", "keys": "vl"} {"name": "msel-insert", "pane": "file", "text": "aa\nbb\n", "keys": "Cix"} {"name": "msel-insert-tty", "pane": "tty", "text": "aa\nbb\n", "keys": "Cix"} {"name": "msel-append", "pane": "file", "text": "aa\nbb\n", "keys": "Caz"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 79745d43..516b2417 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -404,6 +404,13 @@ {"name":"msel-trim","text":" ab \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":2}} {"name":"msel-trim-all-ws","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} {"name":"msel-trim-multi","text":" ab \n cd \n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-rotate-contents-fwd","text":"c a b\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}],"primary":1} +{"name":"msel-rotate-contents-back","text":"b c a\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}],"primary":2} +{"name":"msel-rotate-contents-count","text":"c d a b\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}],"primary":2} +{"name":"msel-rotate-contents-lengths","text":"to three one\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":9}}],"primary":0} +{"name":"msel-rotate-contents-backward","text":"cde ab\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5}}],"primary":1} +{"name":"msel-rotate-contents-lines","text":"bbb\naa\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"rotate-contents-one","text":"ab\n","mode":"select","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}} {"name":"msel-insert","text":"xaa\nxbb\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":"msel-insert-tty","text":"xaa\nxbb\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":"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} -- cgit v1.3 From 9bade7130bc0aa8cbcea82c446fc5747453b1360 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:38:50 -0300 Subject: & aligns the selections into columns helix-golf's enumerate_and_align ends on %s |\d+&. align_selections treats the k-th range of each line as column k and puts spaces before each range until its head reaches the widest head of its column, measured in display cells, which File.rawDisplayCol already gives. A range over several lines refuses, as in helix. The example itself still needs more than 64 ranges, which the selection model does not hold. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 3 +- src/config.zig | 1 + src/edit.zig | 80 ++++++++++++++++++++++++++++++++++++++++++++++ src/modal.zig | 4 +++ src/normal.zig | 1 + test/hxcases/cases.jsonl | 7 ++++ test/hxcases/goldens.jsonl | 7 ++++ 7 files changed, 101 insertions(+), 2 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 8023ba6e..dc0f3647 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -226,7 +226,7 @@ grew `sels` + `primary`, emitted only when there is more than one range. | 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 | | `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 a column | **skipped**: needs visual (tab-expanded) columns, which nothing else in pardes measures | +| `&` | 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-(` / `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 | Anything that reaches outside the buffer — a builtin, a language query — runs @@ -347,7 +347,6 @@ text. | `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 | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | -| `&` | align selections | see the multiple-cursors table for why | | `Alt-\|` `!` `Alt-!` `$` | shell pipe-to (output discarded), insert output, append output, keep-by-exit-status | shell — `\|` (`shell_pipe`, output replaces the selection) is implemented in section A; these four are the other members of helix's shell family. `$` is additionally taken for line-end here (section A) | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | diff --git a/src/config.zig b/src/config.zig index edc73837..85a6bc59 100644 --- a/src/config.zig +++ b/src/config.zig @@ -541,6 +541,7 @@ pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; pub const join_select_space: []const Chord = &.{.{ .cp = 'J', .alt = true }}; pub const rotate_contents_fwd: []const Chord = &.{.{ .cp = ')', .alt = true }}; pub const rotate_contents_back: []const Chord = &.{.{ .cp = '(', .alt = true }}; +pub const align_selections: []const Chord = &.{.{ .cp = '&' }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; pub const format: []const Chord = &.{.{ .cp = '=' }}; diff --git a/src/edit.zig b/src/edit.zig index 5ba46ca7..24354df8 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1354,6 +1354,86 @@ pub fn normalRotateContents(p: *Pardes, t: *Text, forward: bool, cnt: usize) voi t.setRanges(t2, rs[0..n], &.{}, if (forward) (got.pri + by) % n else (got.pri + n - by) % n, expl); } +/// `&`: pad the selections into columns, in one edit (helix +/// align_selections). The k-th range of every line is a column; spaces go in +/// before each range so its head lands on the column's widest head, measured +/// in display cells. A range over several lines aligns nothing. +pub fn normalAlign(p: *Pardes, t: *Text) void { + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + var b = selRows(t); + for (t.sels[0..t.nsel]) |s| { + b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); + b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); + } + const eb = editTextEol(p, t, b) orelse return; + const text = eb.text; + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, eb.row0, &rs); + const n = got.n; + var row: [Text.max_selections]usize = undefined; + var col: [Text.max_selections]usize = undefined; + var widths: [Text.max_selections]usize = @splat(0); + var line: usize = std.math.maxInt(usize); + var k: usize = 0; + var run: usize = 0; + for (rs[0..n], 0..) |r, i| { + row[i] = modal.lineAtOffset(text, r.head); + if (modal.lineAtOffset(text, r.anchor) != row[i]) return; + const ls = modal.lineStartOffset(text, row[i]); + col[i] = panes.File.rawDisplayCol(text[ls..modal.lineEndOffset(text, row[i])], r.head - ls); + if (row[i] != line) { + line = row[i]; + k = 0; + run = 0; + } + widths[k] = @max(widths[k], col[i] - run); + run = col[i]; + k += 1; + } + t.select = false; // helix exit_select_mode + // each column's head position, left to right + for (1..n) |i| widths[i] += widths[i - 1]; + const arena = p.scratch.allocator(); + var chs: [Text.max_selections]TextChange = undefined; + var m: usize = 0; + line = std.math.maxInt(usize); + for (rs[0..n], 0..) |r, i| { + if (row[i] != line) { + line = row[i]; + k = 0; + run = 0; + } + const pad = widths[k] - col[i] - run; + k += 1; + run += pad; + if (pad == 0) continue; + const spaces = arena.alloc(u8, pad) catch return; + @memset(spaces, ' '); + const at = @min(r.anchor, r.head); + chs[m] = .{ .from = at, .to = at, .ins = spaces }; + m += 1; + } + if (m == 0) return; + pushUndo(p, t); + const new = applyChanges(p, text, chs[0..m]) catch return; + var cells: [Text.max_selections]Text.SelRange = undefined; + for (rs[0..n], 0..) |r, i| { + const c = Text.rangeCells(text, r); + const cc = modal.positionAt(new, mapThroughChanges(chs[0..m], c.cur)); + const ac = modal.positionAt(new, mapThroughChanges(chs[0..m], c.anc)); + cells[i] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + } + setEditText(p, t, new); + const t2 = flatSurface(p, t) catch return; + for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col); + t.setRanges(t2, rs[0..n], &.{}, got.pri, expl); +} + pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { t.select = false; const sr = selRows(t); diff --git a/src/modal.zig b/src/modal.zig index 6bf7918f..aadaab00 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -111,6 +111,7 @@ pub const Normal = struct { join_select_space, rotate_contents_fwd, rotate_contents_back, + align_selections, indent, unindent, format, @@ -266,6 +267,7 @@ pub const Normal = struct { join_select_space, rotate_contents_forward, rotate_contents_backward, + align_selections, indent, unindent, comment_toggle, @@ -327,6 +329,7 @@ pub const Normal = struct { .join_select_space, .rotate_contents_forward, .rotate_contents_backward, + .align_selections, .comment_toggle, .undo, .redo, @@ -565,6 +568,7 @@ pub const Normal = struct { if (key.has(.join_select_space)) return resultAction(.{ .edit = .{ .kind = .join_select_space, .count = count } }); if (key.has(.rotate_contents_fwd)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_forward, .count = count } }); if (key.has(.rotate_contents_back)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_backward, .count = count } }); + if (key.has(.align_selections)) return resultAction(.{ .edit = .{ .kind = .align_selections, .count = count } }); if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); if (key.has(.format)) return resultAction(.{ .lsp = .format }); diff --git a/src/normal.zig b/src/normal.zig index 9c6f2acd..5a6c1cd5 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -544,6 +544,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .join_select_space => return edit.normalJoin(p, t, true), .rotate_contents_forward => return edit.normalRotateContents(p, t, true, op.count), .rotate_contents_backward => return edit.normalRotateContents(p, t, false, op.count), + .align_selections => return edit.normalAlign(p, t), .indent => return edit.normalIndent(p, t, op.count, true), .unindent => return edit.normalIndent(p, t, op.count, false), .comment_toggle => return edit.normalToggleComment(p, t), diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index cfd82cce..487cd445 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -415,6 +415,13 @@ {"name": "msel-rotate-contents-lengths", "pane": "file", "text": "one to three\n", "keys": "%s\\w+)"} {"name": "msel-rotate-contents-backward", "pane": "file", "text": "ab cde\n", "keys": "%s\\w+"} {"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%"} +{"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} +{"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} +{"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} +{"name": "msel-align-words", "pane": "file", "text": "x = 1\nlonger = 22\n", "keys": "%s\\w+&"} +{"name": "msel-align-backward", "pane": "file", "text": "x = 11\nlonger = 222\n", "keys": "%s\\d+&"} +{"name": "align-multiline-refused", "pane": "file", "text": "ab\ncd\n", "keys": "vj&"} +{"name": "msel-align-select-mode", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=v&"} {"name": "rotate-contents-one", "pane": "file", "text": "ab\n", "keys": "vl"} {"name": "msel-insert", "pane": "file", "text": "aa\nbb\n", "keys": "Cix"} {"name": "msel-insert-tty", "pane": "tty", "text": "aa\nbb\n", "keys": "Cix"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index e258b485..006524e8 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -415,6 +415,13 @@ {"name":"msel-rotate-contents-lengths","text":"to three one\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":9}}],"primary":0} {"name":"msel-rotate-contents-backward","text":"cde ab\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5}}],"primary":1} {"name":"msel-rotate-contents-lines","text":"bbb\naa\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} +{"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} +{"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} +{"name":"msel-align-words","text":" x = 1\nlonger = 22\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5},"sels":[{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":9}}],"primary":0} +{"name":"msel-align-backward","text":"x = 11\nlonger = 222\n","mode":"normal","cursor":{"row":0,"col":9},"anchor":{"row":0,"col":10},"sels":[{"cursor":{"row":0,"col":9},"anchor":{"row":0,"col":10}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":11}}],"primary":0} +{"name":"align-multiline-refused","text":"ab\ncd\n","mode":"select","cursor":{"row":1,"col":0},"anchor":{"row":0,"col":0}} +{"name":"msel-align-select-mode","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"rotate-contents-one","text":"ab\n","mode":"select","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}} {"name":"msel-insert","text":"xaa\nxbb\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":"msel-insert-tty","text":"xaa\nxbb\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} -- cgit v1.3 From 767aefd1e1cfd79e2a240a59d2d16bfb326f062d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:59:00 -0300 Subject: Registers hold a value per range, and " names one Registers.zig replaces the one yank buffer: every register keeps a value per range, y fills them in document order, and p, P, R and insert Ctrl-r put value i at range i, repeating the last (helix paste_impl). " names the register for the next command; _ swallows, # numbers the ranges, . is each range's text, % the file's name, / the last s/S pattern, and + and * are the system clipboard through the ClipYank and ClipPaste paths. SPC y now writes + alone, as helix's does. The acme chords and a paste into a terminal take the default register joined by newlines. The msel-yank-paste waiver is gone. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 21 +++++---- docs/selections.md | 15 +++--- src/Registers.zig | 106 ++++++++++++++++++++++++++++++++++++++++++ src/Text.zig | 2 + src/config.zig | 2 + src/edit.zig | 112 +++++++++++++++++++++++++++++++++++++-------- src/gui/gui.zig | 2 +- src/look.zig | 6 ++- src/modal.zig | 13 ++++++ src/mouse.zig | 12 ++--- src/normal.zig | 17 +++++-- src/pardes.zig | 22 ++++++--- test/hxcases/cases.jsonl | 13 ++++++ test/hxcases/goldens.jsonl | 13 ++++++ test/hxcases/waivers.jsonl | 1 - test/panes.zig | 20 ++++---- 16 files changed, 312 insertions(+), 65 deletions(-) create mode 100644 src/Registers.zig (limited to 'src/normal.zig') 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` / `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 (`"` 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 ` ?` 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 | | `o` / `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 | +| `"` | 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 ` | 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-(` / `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 | -| `"` ``, 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. `"` 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 (`"`), 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 : 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, + /// `"`: 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 { + // `"` 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 (`"`); 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"} {"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": "CyRy"} +{"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": "%i#"} +{"name": "reg-search-paste", "pane": "file", "text": "ab\n", "keys": "%sb\"/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": "\"aylia"} +{"name": "reg-ctrl-r-esc", "pane": "file", "text": "ab\n", "keys": "ix"} {"name": "msel-esc-keeps", "pane": "file", "text": "aa\nbb\n", "keys": "C"} {"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' } }); -- cgit v1.3 From c811c34ed3bcd23954414125b45c7cac67307d5d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:04:12 -0300 Subject: Every range lives through insert mode, carried through each edit Insert mode dropped the selection on the first key and restored only the primary's appended-over span on Esc. Now each insert-mode edit is made at the bare cursor as before, and the range is then carried through it with helix's Range::map rules: typing slides an i range and stretches an a range, Enter (which now also drops the blanks before the cursor, as helix does) slides or stretches by what the cursor moved, and an arrow collapses. The replay gives every other range the same. Esc after a pulls each range's end back by one (helix restore_cursor), which replaces Text.append_at. The wiX-edit-drops-sel and msel-append waivers are gone. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 36 ++++++++----- docs/selections.md | 22 +++++--- src/Output.zig | 2 +- src/Terminal.zig | 8 +-- src/Text.zig | 4 +- src/edit.zig | 127 ++++++++++++++++++++++++++++++++++++--------- src/look.zig | 2 +- src/mouse.zig | 4 +- src/normal.zig | 6 +-- src/tagline.zig | 4 +- test/hxcases/cases.jsonl | 15 ++++++ test/hxcases/goldens.jsonl | 15 ++++++ test/hxcases/waivers.jsonl | 2 - test/mode.zig | 6 +-- test/panes.zig | 4 +- transactions.txt | 15 ++---- 16 files changed, 194 insertions(+), 78 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 90efc463..35f62765 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -7,10 +7,17 @@ checkout (`~/05-genizah/helix`); that checkout now sits on the local `pardes-harness` branch, whose three commits add only the harness, so `book/src/keymap.md` is byte-identical at its tip (694e7dfd). As of phase 5 every helix-equivalent row in A and B is differentially verified against real -helix (see "Differential testing" at the bottom). Five of the six waivers are -named in the row they belong to; the sixth, `wiX-edit-drops-sel`, belongs to no -single key — it is the anchor-only divergence every insert-mode edit shares — -and is described in the Files list at the bottom instead. +helix (see "Differential testing" at the bottom). Every waiver is named in the +row it belongs to. + +**The reference helix is fixed at one commit:** the `hx-harness` built from +`694e7dfdd` on the genizah checkout's `pardes-harness` branch, which is upstream +`278b24389` (2026-06-29, `25.07-905`) plus the three harness commits. "helix" +in this document means that build, not the 25.07.1 release, which differs on +counted `Alt-(`/`Alt-)` and on `&`. Upstream master was 82 commits further on +2026-09-28 (`079a789e`); of those only `416a0e09` (continuing a comment in an +injected `comment` layer) touches the editing code, and it needs tree-sitter, +which the harness runs without. Code map, by SYMBOL — line numbers rot, names do not. Body-normal key RECOGNITION is `modal.Normal` in `src/modal.zig`: a state machine over `Role` (one @@ -227,7 +234,7 @@ grew `sels` + `primary`, emitted only when there is more than one range. | `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 | | `"` | 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 ` | 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 | +| insert mode with a selection | every range is carried through each edit as helix maps it (`Range::map`): typing at the head of an `i` range slides it, typing at the end of an `a` range stretches it, Enter slides or stretches it by what the cursor moved, and an arrow key collapses it. Esc after `a` pulls each range's end back one character (helix `restore_cursor`) | `edit.insertKey`, `Text.restore_cursor` | helix-verified | | `&` | 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-(` / `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 | @@ -389,11 +396,11 @@ 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. Three live ones: - `wiX-edit-drops-sel` and `msel-append` (helix maps selections through - 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 +- `waivers.jsonl` — named exemptions, each with a reason. One live one: + `alt-c-window-op` (the pardes window op deliberately shadows helix + change-noyank). `wiX-edit-drops-sel` and `msel-append` went when insert + mode began carrying every range through its edits, `msel-yank-paste` 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 @@ -412,10 +419,11 @@ Files (all in `test/hxcases/`): tested when an earlier one misses. The goldens are `hx-harness test/hxcases/golf.jsonl`; `zig build hxgolf` runs them. All ten examples reproduce their published result in hx 25.07.1 under the site's own conditions (a file of the example's language, auto-pairs - on). Under the harness pins two do not: `csv_to_sql` needs auto-pairs to - close its `VALUES (`, and `invert_dictionary_2` needs 25.07's - `Alt-(` (rotate within groups of count) where the harness's newer - helix rotates by count. + on). Under the harness two do not, and their goldens are the harness's + anyway, which pardes follows: `csv_to_sql` needs auto-pairs (off in the + harness and absent in pardes) to close its `VALUES (`, and + `invert_dictionary_2` needs 25.07.1's `Alt-(` (rotate within groups + of count) where the reference helix rotates by count. - `smoke.jsonl` — 20 cases referenced by nothing in the tree: no build step, no script. Either wire it up or delete it. diff --git a/docs/selections.md b/docs/selections.md index 0f000e2c..c08ccb14 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -3,6 +3,8 @@ How normal mode holds and changes its selections, and where that differs from helix's `Selection` (a list of ranges over gap offsets, with a primary index). Key-by-key behaviour is in docs/helix-keys.md; this is the model underneath. +"helix" here is the reference build docs/helix-keys.md names: `hx-harness` +at `694e7dfdd`, upstream `278b24389`. ## What a selection is @@ -25,8 +27,9 @@ plus up to 63 others. `s`, `n`/`N`, ...) rather than left behind by a motion. Only pardes reads it: the look (Enter) and execute (Tab) chords act on explicit selections and on the word under the cursor otherwise. -- `select` is `v` extend mode. `append_at` remembers where an `a` began, so - Esc can give back the range it appended over. +- `select` is `v` extend mode. `restore_cursor` says the insert session + began with `a`, so Esc gives each range back the character it was + stretched by. `Text.setRanges` is the one writer of a whole selection: it sorts by start, merges ranges that overlap or share a start (helix `normalize`), follows the @@ -67,6 +70,17 @@ A parsed action has a scope (`modal.Normal.Action.scope`). look) runs once from the primary and drops back to one cursor (`normal.multiOnce`). +## Insert mode + +Each range lives on through insert mode. Every insert-mode edit happens at +the cursor, so `edit.insertKey` runs it on the bare cursor and then carries +the range through what it did to the text, with helix's `Range::map` rules: +at an insertion, a backward range's head and an empty range move past it +and a forward range's head stays before it, so typing slides an `i` range +and stretches an `a` range. Enter slides or stretches the range by what the +cursor moved; an arrow key collapses it. The other ranges get the same by +the replay. + ## Undo, registers, repeat - One keystroke is one undo step however many ranges it edited: only the @@ -86,10 +100,6 @@ A parsed action has a scope (`modal.Normal.Action.scope`). ## Where it differs from helix -- 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 - `append_at` span comes back (waivers `wiX-edit-drops-sel`, `msel-append`). - Direction of a one-character range: both of its cells are the same cell, so it has none. helix can flip one (`Alt-;`) and the commands that read the head (`&`, the next extend) see the difference. diff --git a/src/Output.zig b/src/Output.zig index 6e1765a7..201b5491 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -749,7 +749,7 @@ pub fn resetBody(p: *Pardes, pane: *Pane) void { pane.body.select = false; pane.sel = @splat(.{}); pane.body.sticky_col = -1; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.normal.clear(); pane.look_at = null; if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) { diff --git a/src/Terminal.zig b/src/Terminal.zig index e204b44c..e3378007 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -717,7 +717,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { if (comptime !enabled) return; if (pane.terminal == null) return; const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or - pane.body.msel.active or pane.body.nsel > 0 or pane.body.append_at != null or pane.look_at != null or + pane.body.msel.active or pane.body.nsel > 0 or pane.look_at != null or pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0; // Sticky bottom, as every other terminal does it: output drags the // viewport down only when the viewport was already on the last row, so @@ -843,10 +843,6 @@ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { extra[nextra + 1] = &selection.arow; nextra += 2; } - if (pane.body.append_at) |*at| { - extra[nextra] = &at.row; - nextra += 1; - } if (pane.look_at) |*at| { extra[nextra] = &at.row; nextra += 1; @@ -1124,7 +1120,7 @@ pub fn enterTty(p: *Pardes, id: usize) void { // keeps its modal endpoint for the next return to editor mode. pane.body.cur_pinned = selected; pane.body.select = false; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; pane.body.normal.clear(); } diff --git a/src/Text.zig b/src/Text.zig index 7ad243a1..764b8a6a 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -65,7 +65,9 @@ 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, -append_at: ?struct { row: i32, col: i32 } = null, +/// The insert session began with `a`: on Esc every range gives back the +/// one character it was stretched by (helix restore_cursor). +restore_cursor: bool = false, /// insert `Ctrl-r` was pressed: the next key names the register to insert reg_wait: bool = false, normal: modal.Normal.State = .{}, diff --git a/src/edit.zig b/src/edit.zig index 14d8b7ed..8f6f09c6 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -344,28 +344,20 @@ pub fn exitInsert(p: *Pardes, t: *Text) void { t.reg_wait = false; t.msel.active = false; t.normal.clear(); - if (t.append_at) |aa| { - t.append_at = null; - const text = flatSurface(p, t) catch return; - const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); - const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); - const back = @max(a_off, modal.prevGrapheme(text, gap)); - const bc = modal.positionAt(text, back); - t.cur_row = @intCast(bc.row); - t.cur_col = @intCast(bc.col); - t.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; - t.cur_pinned = true; - t.show(); - for (t.sels[0..t.nsel]) |*s| { - const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); - const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap); - const bp = modal.positionAt(text, b2); - s.row = @intCast(bp.row); - s.col = @intCast(bp.col); - s.arow = s.row; - s.acol = s.col; - } + if (!t.restore_cursor) return; + t.restore_cursor = false; + // helix enter_normal_mode: every range, from its start to the char + // before its end when it points forward, else to its end + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + const text = flatSurface(p, t) catch return; + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, 0, &rs); + for (rs[0..got.n]) |*r| { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + r.* = .{ .anchor = from, .head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to }; } + t.setRanges(text, rs[0..got.n], &.{}, got.pri, expl); } pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 { @@ -518,7 +510,7 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { // `u` undoes the whole session and restores what was selected pushUndo(p, t); t.select = false; - t.append_at = null; + t.restore_cursor = false; t.sticky_col = -1; const text = flatSurface(p, t) catch { t.mode = .insert; @@ -544,12 +536,14 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { const lo_col = if (b) |bb| bb.lo_col else t.cur_col; const hi_row = if (b) |bb| bb.hi_row else t.cur_row; const hi_col = if (b) |bb| bb.hi_col else t.cur_col; - t.append_at = .{ .row = lo_row, .col = lo_col }; + t.restore_cursor = true; const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); const gc = modal.positionAt(text, gap); t.cur_row = @intCast(gc.row); t.cur_col = @intCast(gc.col); - t.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false }; + // helix append_mode: the range reaches one past its end, which + // is where typing goes; a bare cursor becomes a range too + t.vsel = .{ .active = true, .row = lo_row, .col = lo_col, .explicit = false }; t.cur_pinned = true; }, .line_start => { @@ -634,6 +628,72 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { t.reg_wait = true; return; } + // helix maps every range through each insert-mode edit (Range::map): + // the edits below work on the bare cursor, and the range the cursor + // heads is carried through what they did to the text afterwards + if (!t.vsel.active) return insertEdit(p, t, key); + const old = flatSurface(p, t) catch return insertEdit(p, t, key); + const r = t.primaryRange(old, 0); + const c = modal.selectionCursor(old, r); + const old_len = old.len; + const explicit = t.vsel.explicit; + t.vsel.active = false; + insertEdit(p, t, key); + if (t.mode != .insert) return; + const new = flatSurface(p, t) catch return; + const c2 = modal.offsetAt(new, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); + // every insert-mode edit happens at the cursor: an insertion there, or + // a deletion that ends there (backward) or starts there (forward) + if (key.cp == Key.enter) { + // helix insert_newline: an appending range stretches by what the + // cursor moved, any other slides by it + const d = @as(isize, @intCast(c2)) - @as(isize, @intCast(c)); + const head: usize = @intCast(@as(isize, @intCast(r.head)) + d); + const anchor: usize = if (c > r.anchor) r.anchor else @intCast(@as(isize, @intCast(r.anchor)) + d); + return setInsertRange(t, new, .{ .anchor = anchor, .head = head }, explicit); + } + var at: usize = c; + var del: usize = 0; + var ins: usize = 0; + if (new.len > old_len and c2 == c + (new.len - old_len)) { + ins = new.len - old_len; + } else if (new.len < old_len and c2 + (old_len - new.len) == c) { + at = c2; + del = old_len - new.len; + } else if (new.len < old_len and c2 == c) { + del = old_len - new.len; + } else if (new.len != old_len or c2 != c) { + return; // a motion: the range collapses onto the cursor (helix) + } + // helix Assoc: the head sticks after an insertion at it when the range + // points backward or is empty, the anchor when it points forward + const fwd = r.anchor < r.head; + setInsertRange(t, new, .{ + .anchor = mapThroughEdit(r.anchor, at, del, ins, !fwd and r.anchor != r.head), + .head = mapThroughEdit(r.head, at, del, ins, fwd), + }, explicit); +} + +fn setInsertRange(t: *Text, new: []const u8, mapped: modal.Selection, explicit: bool) void { + const cells = Text.rangeCells(new, mapped); + const cc = modal.positionAt(new, cells.cur); + const ac = modal.positionAt(new, cells.anc); + t.cur_row = @intCast(cc.row); + t.cur_col = @intCast(cc.col); + t.vsel = .{ .active = cells.anc != cells.cur, .row = @intCast(ac.row), .col = @intCast(ac.col), .explicit = explicit }; +} + +/// A position through one edit at `at` that deleted `del` bytes and put +/// `ins` in (one of the two is 0). A position exactly at an insertion stays +/// before it when `before` is set, else moves past it. +fn mapThroughEdit(pos: usize, at: usize, del: usize, ins: usize, before: bool) usize { + if (pos < at) return pos; + if (del > 0) return if (pos < at + del) at else pos - del; + if (pos == at and before) return pos; + return pos + ins; +} + +fn insertEdit(p: *Pardes, t: *Text, key: Key) void { t.vsel.active = false; if (!t.cur_pinned) pinPaneCursor(p, t); _ = clampFileCursor(t); @@ -748,12 +808,29 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { switch (key.cp) { Key.enter => { const line = modal.lineSlice(text, c.row); + const before = line[0..@min(c.col, line.len)]; + // helix insert_newline: after nothing but blanks, the line + // itself moves down, blanks and all; else the blanks before the + // cursor go and the new line takes the indent + if (std.mem.indexOfNone(u8, before, " \t") == null) { + const new = modal.insertAt(p.gpa, text, .{ .row = c.row, .col = 0 }, "\n") catch return; + setEditText(p, t, new); + t.cur_row += 1; + t.cur_pinned = true; + t.show(); + return; + } const indent = modal.newlineIndentWidth(line, c.col); const arena = p.scratch.allocator(); const block = arena.alloc(u8, 1 + indent) catch return; block[0] = '\n'; @memset(block[1..], ' '); - const new = modal.insertAt(p.gpa, text, c, block) catch return; + // a tag keeps its blanks: they part its words, and the last + // may be its read-only prefix's + const kept = if (t.what == .body) std.mem.trimEnd(u8, before, " \t").len else c.col; + const cut = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = kept }, c) catch return; + defer p.gpa.free(cut); + const new = modal.insertAt(p.gpa, cut, .{ .row = c.row, .col = kept }, block) catch return; setEditText(p, t, new); t.cur_row += 1; t.cur_col = @intCast(indent); diff --git a/src/look.zig b/src/look.zig index 5e2d6068..30fb0dab 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1016,7 +1016,7 @@ pub fn clearNavigationSelection(p: *Pardes, pane: *Pane) void { pane.body.nsel = 0; pane.body.select = false; pane.body.normal.clear(); - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot); pane.sel = @splat(.{}); diff --git a/src/mouse.zig b/src/mouse.zig index 91ec41a6..d1c32b40 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -747,7 +747,7 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { pane.body.vsel.active = false; pane.body.nsel = 0; pane.body.select = false; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; pane.body.normal.clear(); edit.setYank(p, text); @@ -859,7 +859,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { pane.body.vsel.active = false; pane.body.nsel = 0; // a click says WHERE the one cursor is pane.body.select = false; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; if (s.ctrl) p.lspRequest(s.id, .definition, ""); } diff --git a/src/normal.zig b/src/normal.zig index f5e868a4..53e416fd 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -149,7 +149,7 @@ const KeyState = struct { normal: modal.Normal.State, find_op: u8, find_ch: u21, - append_at: @FieldType(Text, "append_at"), + restore_cursor: bool, reg_wait: bool, fn of(t: *Text) KeyState { @@ -159,7 +159,7 @@ const KeyState = struct { .normal = t.normal, .find_op = t.find_op, .find_ch = t.find_ch, - .append_at = t.append_at, + .restore_cursor = t.restore_cursor, .reg_wait = t.reg_wait, }; } @@ -170,7 +170,7 @@ const KeyState = struct { t.normal = s.normal; t.find_op = s.find_op; t.find_ch = s.find_ch; - t.append_at = s.append_at; + t.restore_cursor = s.restore_cursor; t.reg_wait = s.reg_wait; } }; diff --git a/src/tagline.zig b/src/tagline.zig index 14bf5677..1c060a66 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -219,7 +219,7 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void { t.msel.active = false; t.nsel = 0; t.select = false; - t.append_at = null; + t.restore_cursor = false; t.sticky_col = -1; t.mode = .normal; t.normal.clear(); @@ -325,7 +325,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void { t.msel.active = false; t.nsel = 0; t.select = false; - t.append_at = null; + t.restore_cursor = false; t.mode = .normal; t.normal.clear(); } diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 7ba44f09..ba75c611 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -344,6 +344,21 @@ {"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"} +{"name": "reg-selection-insert", "pane": "file", "text": "ab\n", "keys": "vli."} +{"name": "ins-sel-backspace", "pane": "file", "text": "alpha beta\n", "keys": "wi"} +{"name": "ins-append-enter", "pane": "file", "text": "ab cd\n", "keys": "wax"} +{"name": "ins-append-backspace", "pane": "file", "text": "ab cd\n", "keys": "ea"} +{"name": "ins-sel-delete", "pane": "file", "text": "ab cd\n", "keys": "wi"} +{"name": "ins-sel-arrow-collapses", "pane": "file", "text": "ab cd\n", "keys": "wi"} +{"name": "msel-insert-keeps-ranges", "pane": "file", "text": "ab cd\nab cd\n", "keys": "wCiX"} +{"name": "msel-append-lines", "pane": "file", "text": "aa\nbb\n", "keys": "%a!"} +{"name": "ins-append-bare", "pane": "file", "text": "ab\n", "keys": "axy"} +{"name": "ins-change-then-type", "pane": "file", "text": "ab cd\n", "keys": "wcX"} +{"name": "ins-open-below-sel", "pane": "file", "text": "ab\ncd\n", "keys": "xoX"} +{"name": "ins-enter-trims-blanks", "pane": "file", "text": "ab cd\n", "keys": "5li"} +{"name": "ins-enter-in-indent", "pane": "file", "text": " ab\n", "keys": "2li"} +{"name": "ins-enter-after-open", "pane": "file", "text": " ab\n", "keys": "ox"} +{"name": "msel-ins-enter", "pane": "file", "text": "ab cd\nab cd\n", "keys": "wCa"} {"name": "a-esc-restore", "pane": "file", "text": "abc\n", "keys": "a"} {"name": "ax-restore", "pane": "file", "text": "abc\n", "keys": "ax"} {"name": "lax-restore", "pane": "file", "text": "abc\n", "keys": "lax"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index f6a6aada..188b7ce4 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -344,6 +344,21 @@ {"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":"reg-selection-insert","text":"abab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":3}} +{"name":"ins-sel-backspace","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}} +{"name":"ins-append-enter","text":"ab\nxcd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":0,"col":0}} +{"name":"ins-append-backspace","text":" cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"ins-sel-delete","text":"b cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":1}} +{"name":"ins-sel-arrow-collapses","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"msel-insert-keeps-ranges","text":"Xab cd\nXab cd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":3},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":3}}],"primary":1} +{"name":"msel-append-lines","text":"aa!\nbb!\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}}],"primary":0} +{"name":"ins-append-bare","text":"axyb\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"ins-change-then-type","text":"Xcd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"ins-open-below-sel","text":"ab\nX\ncd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"ins-enter-trims-blanks","text":"ab\ncd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} +{"name":"ins-enter-in-indent","text":"\n ab\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}} +{"name":"ins-enter-after-open","text":" ab\n\n x\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}} +{"name":"msel-ins-enter","text":"ab\ncd\nab\ncd\n","mode":"normal","cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}],"primary":1} {"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}} diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl index 8ac3bd9c..2d74e346 100644 --- a/test/hxcases/waivers.jsonl +++ b/test/hxcases/waivers.jsonl @@ -1,3 +1 @@ -{"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}} diff --git a/test/mode.zig b/test/mode.zig index c7d7d548..cba93c30 100644 --- a/test/mode.zig +++ b/test/mode.zig @@ -79,14 +79,14 @@ test "Mode from actual tag Exec preserves parked file and output insert transact p.update(.{ .key = .{ .cp = pardes.Key.tab } }); try std.testing.expectEqual(panes.Text.Mode.normal, pane.body.mode); try std.testing.expect(pane.focus == .body); - try std.testing.expect(pane.body.append_at == null); + try std.testing.expect(!pane.body.restore_cursor); p.update(.{ .key = .{ .cp = 'u' } }); try std.testing.expectEqualStrings("ab\n", pane.file.?.content); p.update(.{ .key = .{ .cp = 'a' } }); p.update(.{ .key = .{ .cp = 'z', .text = "z" } }); - try std.testing.expect(pane.body.append_at != null); + try std.testing.expect(pane.body.restore_cursor); try mode(p, 0, .normal); - try std.testing.expect(pane.body.append_at == null); + try std.testing.expect(!pane.body.restore_cursor); p.update(.{ .key = .{ .cp = 'u' } }); try std.testing.expectEqualStrings("ab\n", pane.file.?.content); } diff --git a/test/panes.zig b/test/panes.zig index cbf9d097..4e98f449 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -4547,7 +4547,7 @@ const LookResetTests = struct { pane.body.nsel = 1; pane.body.select = true; pane.body.normal.count = 5; - pane.body.append_at = .{ .row = 0, .col = 3 }; + pane.body.restore_cursor = true; pane.body.sticky_col = 7; for (0..pane.sel.len) |slot| { pane.clearPointerSelection(slot); @@ -4567,7 +4567,7 @@ const LookResetTests = struct { try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); try std.testing.expect(!pane.body.select); try std.testing.expectEqual(modal.Normal.State{}, pane.body.normal); - try std.testing.expect(pane.body.append_at == null); + try std.testing.expect(!pane.body.restore_cursor); try std.testing.expectEqual(@as(i32, -1), pane.body.sticky_col); for (pane.sel) |selection| try std.testing.expectEqual(.none, selection.state); for (pane.pointer_selections) |selection| try std.testing.expect(selection == null); diff --git a/transactions.txt b/transactions.txt index 6c2e7646..8d2fda3f 100644 --- a/transactions.txt +++ b/transactions.txt @@ -1,13 +1,8 @@ -Selection anchors through insert-mode edits, and the transaction model -======================================================================= - -STATUS: still open. `wiX-edit-drops-sel` is line 1 of test/hxcases/waivers.jsonl, -so the gap below describes the code as it stands rather than something already -fixed. One correction to the sentence that follows: it is not quite "the one -place". Line 3 of the same file, `msel-append`, says in its own words "Same -family as wiX-edit-drops-sel: pardes does not map selections through insert-mode -edits" — so the gap has two waivers, and what is singular is the CAUSE below, -not the number of divergences it produces. +STATUS: closed for the waivers. Insert mode now carries every range through +each edit (edit.insertKey maps the range with helix's Range::map rules after +the edit is made), so `wiX-edit-drops-sel` and `msel-append` are gone. What +follows is the original analysis; the delta layer it proposes was not needed +for that and stays unbuilt. 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 -- cgit v1.3 From 3992e9def8692710590deacbc20bd925e0f8d64a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:20:24 -0300 Subject: Q records keys into a register and q types them again; . repeats the last insert Macro.zig keeps keys to be typed again. Q records into @ (or ") in helix's key notation until the next Q; q replays a register's keys count times through the modal handling, where Esc does not hop panes and Enter, Tab and Space neither look, execute nor open the leader. . replays the normal command that entered the last insert session once and what was typed in it count times (helix repeat_last_insert). a at the end of the text now adds the newline helix's append_mode does, which a terminal's text does not. helix-golf's invert_dictionary passes. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 15 ++-- docs/selections.md | 5 +- src/Macro.zig | 176 ++++++++++++++++++++++++++++++++++++++ src/config.zig | 3 + src/edit.zig | 15 +++- src/modal.zig | 12 +++ src/normal.zig | 42 ++++++++- src/pardes.zig | 50 +++++++++-- test/hxcases/cases.jsonl | 16 ++++ test/hxcases/goldens.jsonl | 16 ++++ test/hxcases/parity-waivers.jsonl | 1 + 11 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 src/Macro.zig (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index cd33ccbe..3d02893b 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -80,8 +80,8 @@ at the bottom. `gj` the textual one. With the toggle off a line is one visual row and the two pairs are the same motion. - **Undo is snapshot-per-edit-op / per-insert-session**, not a - transaction log. Anything requiring replayable edits (`.`, - macros) needs new machinery. + transaction log. `.` and macros replay KEYS (`Macro.zig`), not + edits, so they need none. ## A. Implemented today @@ -147,6 +147,9 @@ language-backend queries, and the shell pipe. | `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus | | `A-!` | the same, appended AFTER each selection | helix `shell_append_output`. Marker `!+` | out of corpus | | `$` | keep only the selections a shell command exits 0 on: each range's text on its stdin, one run per range, its output discarded; the primary stays if kept, else the last kept range takes over, and keeping none changes nothing | helix `shell_keep_pipe`. The runner's answer is all or nothing, so the command runs as `(cmd) >/dev/null; echo $?` and the status comes back as the output. Marker `$` | out of corpus | +| `Q` / `"Q` | record the keys that follow into `@` (or the named register) until the next `Q`; kept as helix writes a macro, in key notation (`xt,S=_`), so `"@p` pastes it and a yanked text can be replayed | helix `record_macro` | helix-verified | +| `q` / `q` / `"q` | type a register's keys again, n times. Replayed keys go to the modal handling only: Esc does not hop panes, Enter and Tab neither look nor execute, and Space does not open the leader | helix `replay_macro`; `Macro.zig`, `Pardes.replayKeys` | helix-verified | +| `.` / `.` | repeat the last insert session: the normal command that entered it once, the keys typed n times, then leave insert mode the way it was left | helix `repeat_last_insert`; the same key log as macros | helix-verified | ## B. To implement @@ -352,7 +355,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 | -| `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 | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | @@ -363,7 +365,6 @@ text. | `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` is the look-ring motion, which REPLACES the selection with the span it lands on) | ## Differential testing (phase 5) @@ -403,13 +404,15 @@ Files (all in `test/hxcases/`): 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 +- `parity-waivers.jsonl` — 15 named exemptions for the parity gate, in three classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the tty toggle), eight pty VIEWPORT divergences (a terminal's view cannot scroll below the vt's live grid bottom, so the cursor snaps into a different scrolloff band — no text differs), and five case texts a pty cannot hold verbatim (a literal TAB the emulator expands, a file with no trailing - newline, an all-whitespace last row the dump trims). + newline, an all-whitespace last row the dump trims). One more, + `dot-append-count`: `a` at a file's end grows it by a newline (helix + append_mode), which a terminal's text, not the editor's to grow, does not. - `golf.jsonl` + `golf-goldens.jsonl` — every example on helix-golf (github.com/nik-rev/helix-golf, imported at d78c18c): one case per numbered step of each walkthrough, its keys being the command up to and including diff --git a/docs/selections.md b/docs/selections.md index 35fa806f..202ec3d9 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -98,8 +98,9 @@ the replay. answer from the shell that comes later, so `"+p` pastes once, at the primary, and insert `Ctrl-r +` types what pardes last put on the clipboard rather than asking the desktop. -- `Alt-.` repeats the last `f`/`t`/`F`/`T`. There is no `.` and there are no - macros. +- `Alt-.` repeats the last `f`/`t`/`F`/`T`. `.` and macros (`Q`/`q`) are + keys typed again (`Macro.zig`), so whatever those keys do to the ranges + happens again: there is no edit log underneath. ## Where it differs from helix diff --git a/src/Macro.zig b/src/Macro.zig new file mode 100644 index 00000000..7475992e --- /dev/null +++ b/src/Macro.zig @@ -0,0 +1,176 @@ +//! Keys kept to be typed again. `Q` records the keys that follow into a +//! register until the next `Q`, and `q` types them again (helix macros); +//! `.` types again the last insert session together with the normal command +//! that began it (helix repeat_last_insert). A macro is kept in its register +//! as helix writes one, in key notation (`xt,S=_`), so it can be +//! pasted, edited and yanked back like any text. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const Key = pardes.Key; + +const Macro = @This(); + +/// The register `Q` is recording into, 0 when it is not. +recording: u21 = 0, +recorded: std.ArrayList(Stored) = .empty, +/// The keys of the normal command under way (a count, `"`, a prefix +/// and the key that completes it), dropped when it completes without +/// entering insert mode. +command: std.ArrayList(Stored) = .empty, +/// `.`'s: the command that began the last insert session and every key +/// typed in it, the one that left it included. +last_insert: std.ArrayList(Stored) = .empty, +/// Where in `last_insert` the typed keys begin, after the command's. +typed_from: usize = 0, +/// Keys are going into `last_insert`. +inserting: bool = false, +/// How deep macros are being typed again; `.`'s replay is `repeating`. +replaying: u8 = 0, +repeating: bool = false, + +/// A key as it can be typed again: what it was, not the text it came with. +pub const Stored = struct { + cp: u21, + ctrl: bool = false, + alt: bool = false, + shift: bool = false, + + pub fn of(key: Key) Stored { + return .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }; + } + + /// The key again, its text made from its codepoint into `buf`. + pub fn typed(s: Stored, buf: *[4]u8) Key { + var k: Key = .{ .cp = s.cp, .ctrl = s.ctrl, .alt = s.alt, .shift = s.shift }; + if (!s.ctrl and !s.alt and s.cp >= 0x20 and s.cp != Key.backspace and s.cp < 0xF0000) { + const n = std.unicode.utf8Encode(s.cp, buf) catch return k; + k.text = buf[0..n]; + } + return k; + } +}; + +pub fn deinit(m: *Macro, gpa: std.mem.Allocator) void { + m.recorded.deinit(gpa); + m.command.deinit(gpa); + m.last_insert.deinit(gpa); + m.* = .{}; +} + +/// A typed key, each of its characters one key: what a host delivers as +/// one key event with a run of text is that run typed. +pub fn append(list: *std.ArrayList(Stored), gpa: std.mem.Allocator, key: Key) void { + const view = std.unicode.Utf8View.init(key.text) catch null; + if (key.text.len == 0 or key.ctrl or key.alt or view == null) { + list.append(gpa, .of(key)) catch {}; + return; + } + var it = view.?.iterator(); + while (it.nextCodepoint()) |cp| list.append(gpa, .{ .cp = cp, .shift = key.shift }) catch {}; +} + +const names = [_]struct { cp: u21, name: []const u8 }{ + .{ .cp = Key.escape, .name = "esc" }, + .{ .cp = Key.enter, .name = "ret" }, + .{ .cp = Key.tab, .name = "tab" }, + .{ .cp = Key.backspace, .name = "backspace" }, + .{ .cp = Key.delete, .name = "del" }, + .{ .cp = ' ', .name = "space" }, + .{ .cp = '<', .name = "lt" }, + .{ .cp = '>', .name = "gt" }, + .{ .cp = '-', .name = "minus" }, + .{ .cp = Key.up, .name = "up" }, + .{ .cp = Key.down, .name = "down" }, + .{ .cp = Key.left, .name = "left" }, + .{ .cp = Key.right, .name = "right" }, + .{ .cp = Key.home, .name = "home" }, + .{ .cp = Key.end, .name = "end" }, + .{ .cp = Key.page_up, .name = "pageup" }, + .{ .cp = Key.page_down, .name = "pagedown" }, +}; + +/// Keys in helix's notation: a plain character as itself, anything else +/// in angle brackets with its modifiers (``, ``, ``). +pub fn encode(gpa: std.mem.Allocator, keys: []const Stored) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + for (keys) |k| { + var name: ?[]const u8 = null; + for (names) |n| if (n.cp == k.cp) { + name = n.name; + }; + // `-` is only spelled out after a modifier, where helix needs it + if (k.cp == '-' and !k.ctrl and !k.alt) name = null; + var buf: [4]u8 = undefined; + const bare = if (name) |n| n else buf[0..(std.unicode.utf8Encode(k.cp, &buf) catch continue)]; + if (!k.ctrl and !k.alt and name == null) { + try out.appendSlice(gpa, bare); + continue; + } + try out.append(gpa, '<'); + if (k.ctrl) try out.appendSlice(gpa, "C-"); + if (k.alt) try out.appendSlice(gpa, "A-"); + try out.appendSlice(gpa, bare); + try out.append(gpa, '>'); + } + return out.toOwnedSlice(gpa); +} + +/// The keys helix notation names; what it cannot read ends the list. +pub fn decode(gpa: std.mem.Allocator, text: []const u8) ![]Stored { + var out: std.ArrayList(Stored) = .empty; + errdefer out.deinit(gpa); + var i: usize = 0; + while (i < text.len) { + const len = std.unicode.utf8ByteSequenceLength(text[i]) catch break; + if (i + len > text.len) break; + if (text[i] != '<') { + try out.append(gpa, .{ .cp = std.unicode.utf8Decode(text[i..][0..len]) catch break }); + i += len; + continue; + } + const close = std.mem.indexOfScalarPos(u8, text, i + 1, '>') orelse break; + var name = text[i + 1 .. close]; + i = close + 1; + var k: Stored = .{ .cp = 0 }; + while (name.len > 2 and name[1] == '-') : (name = name[2..]) switch (name[0]) { + 'C' => k.ctrl = true, + 'A' => k.alt = true, + 'S' => k.shift = true, + else => break, + }; + for (names) |n| if (std.mem.eql(u8, n.name, name)) { + k.cp = n.cp; + }; + if (k.cp == 0) k.cp = std.unicode.utf8Decode(name) catch break; + try out.append(gpa, k); + } + return out.toOwnedSlice(gpa); +} + +test "a macro's keys go into helix notation and come back the same" { + const gpa = std.testing.allocator; + const keys = [_]Stored{ + .{ .cp = 'x' }, + .{ .cp = 't' }, + .{ .cp = ',' }, + .{ .cp = 'S' }, + .{ .cp = '=' }, + .{ .cp = Key.enter }, + .{ .cp = '_' }, + .{ .cp = '(', .alt = true }, + .{ .cp = 'r', .ctrl = true }, + .{ .cp = '<' }, + .{ .cp = ' ' }, + .{ .cp = '-' }, + .{ .cp = '-', .alt = true }, + .{ .cp = 'λ' }, + .{ .cp = Key.escape }, + }; + const text = try encode(gpa, &keys); + defer gpa.free(text); + try std.testing.expectEqualStrings("xt,S=_-λ", text); + const back = try decode(gpa, text); + defer gpa.free(back); + try std.testing.expectEqualSlices(Stored, &keys, back); +} diff --git a/src/config.zig b/src/config.zig index 91665b14..bc053176 100644 --- a/src/config.zig +++ b/src/config.zig @@ -267,6 +267,9 @@ pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; pub const keep_pipe: []const Chord = &.{.{ .cp = '$' }}; +pub const macro_record: []const Chord = &.{.{ .cp = 'Q' }}; +pub const macro_replay: []const Chord = &.{.{ .cp = 'q' }}; +pub const repeat_insert: []const Chord = &.{.{ .cp = '.' }}; // Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; diff --git a/src/edit.zig b/src/edit.zig index 3e12f169..a373741c 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -546,8 +546,19 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { const hi_row = if (b) |bb| bb.hi_row else t.cur_row; const hi_col = if (b) |bb| bb.hi_col else t.cur_col; t.restore_cursor = true; - const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); - const gc = modal.positionAt(text, gap); + const hi = modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) }); + const gap = modal.nextGrapheme(text, hi); + // helix append_mode: a range that reaches the end of the text + // gets a newline after it to append before (a terminal's text + // is not the editor's to grow) + const terminal = t.what == .body and if (t.pane()) |pane| pane.isTerminal() else false; + var now = text; + if (hi < text.len and gap >= text.len and !terminal) { + const grown = std.mem.concat(p.gpa, u8, &.{ text, "\n" }) catch return; + setEditText(p, t, grown); + now = grown; // what setEditText keeps; `text` may be gone + } + const gc = modal.positionAt(now, gap); t.cur_row = @intCast(gc.row); t.cur_col = @intCast(gc.col); // helix append_mode: the range reaches one past its end, which diff --git a/src/modal.zig b/src/modal.zig index bce941d5..34c4dac7 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -129,6 +129,9 @@ pub const Normal = struct { insert_output, append_output, keep_pipe, + macro_record, + macro_replay, + repeat_insert, search, search_next, search_prev, @@ -320,6 +323,9 @@ pub const Normal = struct { pipe_selection: PipeBehavior, search, search_step: Direction, + macro_record, + macro_replay: u32, + repeat_insert: u32, pub fn scope(value: Action) Scope { return switch (value) { @@ -331,6 +337,9 @@ pub const Normal = struct { .pipe_selection, .search, .search_step, + .macro_record, + .macro_replay, + .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { .join_lines, @@ -602,6 +611,9 @@ pub const Normal = struct { if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); if (key.has(.keep_pipe)) return resultAction(.{ .pipe_selection = .keep }); + if (key.has(.macro_record)) return resultAction(.macro_record); + if (key.has(.macro_replay)) return resultAction(.{ .macro_replay = count }); + if (key.has(.repeat_insert)) return resultAction(.{ .repeat_insert = count }); if (key.has(.search)) return resultAction(.search); if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); diff --git a/src/normal.zig b/src/normal.zig index 53e416fd..542c9fb3 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -18,6 +18,7 @@ const Key = pardes.Key; const hit = pardes.hit; const sel_slot = pardes.sel_slot; const Pardes = pardes.Pardes; +const Macro = pardes.Macro; fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; @@ -324,6 +325,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void switch (semantic) { .escape => { t.select = false; + // typed again by a macro, Esc is only helix's + if (p.macro.replaying > 0) return; return exec.runBuiltin(p, .Last, p.active, "", null); }, .goto => |go| switch (go.target) { @@ -571,7 +574,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .format => .format, }, ""), .adjust_number => |delta| return edit.normalAdjustNumber(p, t, delta), - .leader => { + .leader => if (p.macro.replaying == 0) { p.leader_on = true; p.leader_n = 0; }, @@ -588,6 +591,43 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void p, if (direction == .forward) @as(i32, 1) else -1, ), + .macro_record => { + const m = &p.macro; + if (m.recording == 0) { + m.recording = if (p.register != 0) p.register else '@'; + m.recorded.clearRetainingCapacity(); + return; + } + // the keys of this command (a count, `"`, the `Q`) are not + // the macro's + m.recorded.shrinkRetainingCapacity(m.recorded.items.len -| (m.command.items.len + 1)); + const keys = Macro.encode(p.gpa, m.recorded.items) catch return; + defer p.gpa.free(keys); + p.registers.put(p.gpa, m.recording, keys, 0, 1, true); + m.recording = 0; + }, + .macro_replay => |count| { + const name: u21 = if (p.register != 0) p.register else '@'; + const notation = p.registers.value(name, 0) orelse return; + const keys = Macro.decode(p.scratch.allocator(), notation) catch return; + p.register = 0; + for (0..count) |_| p.replayKeys(keys); + }, + .repeat_insert => |count| { + const m = &p.macro; + if (m.last_insert.items.len == 0 or m.repeating) return; + if (m.inserting) return; // the session is still open + const keys = p.scratch.allocator().dupe(Macro.Stored, m.last_insert.items) catch return; + m.repeating = true; + defer m.repeating = false; + // helix repeat_last_insert: the command once, what was typed + // count times, and out of insert mode the way it was left + const typed = @min(m.typed_from, keys.len); + p.replayKeys(keys[0..typed]); + if (keys.len == typed) return; + for (0..count) |_| p.replayKeys(keys[typed .. keys.len - 1]); + p.replayKeys(keys[keys.len - 1 ..]); + }, } } diff --git a/src/pardes.zig b/src/pardes.zig index c9d93979..87041f39 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -18,6 +18,7 @@ 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 Macro = @import("Macro.zig"); pub const Pipe = @import("Pipe.zig"); pub const colors = @import("colors.zig"); pub const Theme = colors.Theme; @@ -455,6 +456,7 @@ test { _ = @import("edit.zig"); _ = @import("Messages.zig"); _ = @import("Registers.zig"); + _ = @import("Macro.zig"); _ = @import("selection_pipe.zig"); _ = @import("Pipe.zig"); _ = @import("colors.zig"); @@ -4096,6 +4098,7 @@ pub const Pardes = struct { in_len: usize = 0, registers: Registers = .{}, + macro: Macro = .{}, /// an edited text nothing kept (a tag's shown text): see edit.retireEditText retired_edit: ?[]u8 = null, clip_pending: ?edit.ClipRequest = null, @@ -4269,6 +4272,7 @@ pub const Pardes = struct { for (&p.col_tags) |*t| t.deinit(gpa); p.global_tag.deinit(gpa); p.registers.deinit(gpa); + p.macro.deinit(gpa); if (p.retired_edit) |r| gpa.free(r); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); @@ -5125,7 +5129,21 @@ pub const Pardes = struct { } else panes.Terminal.enterTty(p, id); } + /// Type `keys` again (a macro, `.`): through the modal handling as + /// typed, except that no key leaves the text for pardes's own acts -- + /// Esc does not hop panes, and Enter and Tab neither look nor execute. + pub fn replayKeys(p: *Pardes, keys: []const Macro.Stored) void { + if (p.macro.replaying >= 8) return; // a macro typing itself + p.macro.replaying += 1; + defer p.macro.replaying -= 1; + for (keys) |stored| { + var buf: [4]u8 = undefined; + p.handleKey(stored.typed(&buf)); + } + } + fn handleKey(p: *Pardes, key: Key) void { + if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key); // A question waiting on a pane takes the next key, whatever mode the // pane or a header edit is in: that key is its answer. for (p.panes, 0..) |slot, id| { @@ -5266,7 +5284,7 @@ pub const Pardes = struct { if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split); return; } - if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { + if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { // A header's words are the workspace's and column's commands, so // Enter runs them there, as the execute key does. const header_word = t.what == .column or t.what == .workspace; @@ -5320,14 +5338,36 @@ pub const Pardes = struct { defer if (t.refused) { t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; }; + const was = t.mode; switch (t.mode) { .normal => normal.handleNormal(p, t, key), - .insert => { - if (hit(key, config.escape) and !t.reg_wait) return edit.exitInsert(p, t); - edit.handleInsert(p, t, key); - }, + .insert => if (hit(key, config.escape) and !t.reg_wait) + edit.exitInsert(p, t) + else + edit.handleInsert(p, t, key), .tty => panes.Terminal.forwardKey(p, p.active, key), } + // `.`'s log: the keys of the normal command that entered insert + // mode, then everything typed until it is left + if (p.macro.repeating) return; + const m = &p.macro; + switch (was) { + .normal => { + Macro.append(&m.command, p.gpa, key); + if (t.mode == .insert) { + m.last_insert.clearRetainingCapacity(); + m.last_insert.appendSlice(p.gpa, m.command.items) catch {}; + m.typed_from = m.last_insert.items.len; + m.inserting = true; + m.command.clearRetainingCapacity(); + } else if (std.meta.eql(t.normal, modal.Normal.State{})) m.command.clearRetainingCapacity(); + }, + .insert => if (m.inserting) { + Macro.append(&m.last_insert, p.gpa, key); + if (t.mode != .insert) m.inserting = false; + }, + .tty => {}, + } } fn leaderKey(p: *Pardes, key: Key) void { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index ba75c611..8d6b0e6e 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -470,6 +470,22 @@ {"name": "reg-count-after-quote", "pane": "file", "text": "ab\n", "keys": "\"ay\"a3p"} {"name": "reg-ctrl-r-named", "pane": "file", "text": "ab\n", "keys": "\"aylia"} {"name": "reg-ctrl-r-esc", "pane": "file", "text": "ab\n", "keys": "ix"} +{"name": "macro-record-replay", "pane": "file", "text": "a\nb\nc\n", "keys": "QA!jQq"} +{"name": "macro-count", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "QA!jQ2q"} +{"name": "macro-named", "pane": "file", "text": "a\nb\nc\n", "keys": "\"aQA!jQ\"aq"} +{"name": "macro-register-text", "pane": "file", "text": "a\nb\n", "keys": "QA!jQ\"@P"} +{"name": "macro-with-prompt", "pane": "file", "text": "a,b\nc,d\n", "keys": "QxS,djQq"} +{"name": "macro-esc-in-normal", "pane": "file", "text": "ab\ncd\n", "keys": "QvljQq"} +{"name": "macro-empty", "pane": "file", "text": "ab\n", "keys": "QQq"} +{"name": "dot-insert", "pane": "file", "text": "ab\ncd\n", "keys": "iXj.."} +{"name": "dot-append-count", "pane": "file", "text": "ab\ncd\n", "keys": "aYZj3."} +{"name": "dot-change", "pane": "file", "text": "ab cd\nef gh\n", "keys": "wcXjb."} +{"name": "dot-open-below", "pane": "file", "text": "a\nb\n", "keys": "oXj."} +{"name": "dot-counted-command", "pane": "file", "text": "abcd\n", "keys": "2lixy."} +{"name": "dot-multi", "pane": "file", "text": "ab\nab\n", "keys": "CiX."} +{"name": "dot-nothing", "pane": "file", "text": "ab\n", "keys": ".x"} +{"name": "append-at-end-adds-newline", "pane": "file", "text": "ab\n", "keys": "jaX"} +{"name": "append-at-end-no-newline", "pane": "file", "text": "ab", "keys": "laX"} {"name": "msel-esc-keeps", "pane": "file", "text": "aa\nbb\n", "keys": "C"} {"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 188b7ce4..d62cb633 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -470,6 +470,22 @@ {"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":"macro-record-replay","text":"a!\nb!\nc\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}} +{"name":"macro-count","text":"a!\nb!\nc!\nd\n","mode":"normal","cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}} +{"name":"macro-named","text":"a!\nb!\nc\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}} +{"name":"macro-register-text","text":"a!\nbA!j\n","mode":"normal","cursor":{"row":1,"col":8},"anchor":{"row":1,"col":1}} +{"name":"macro-with-prompt","text":",c,d\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} +{"name":"macro-esc-in-normal","text":"ab\ncd\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}} +{"name":"macro-empty","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"dot-insert","text":"Xab\ncXXd\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}} +{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":1,"col":2}} +{"name":"dot-change","text":"Xcd\nX gh\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"dot-open-below","text":"a\nX\nb\nX\n","mode":"normal","cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}} +{"name":"dot-counted-command","text":"abxyxycd\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}} +{"name":"dot-multi","text":"XXab\nXXab\n","mode":"normal","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":"dot-nothing","text":"ab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"append-at-end-adds-newline","text":"ab\nX","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"append-at-end-no-newline","text":"abX\n","mode":"normal","cursor":{"row":0,"col":2},"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/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index ad24eaa9..2393681e 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -12,3 +12,4 @@ {"name":"comment-noeol","reason":"Terminal capture restores a trailing newline that the source omits.","reference":{"name":"comment-noeol","text":"# alpha","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},"expected":{"name":"comment-noeol","text":"# alpha\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}} {"name":"comment-only-blank","reason":"Terminal capture expands tabs and trims trailing blank rows.","reference":{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-only-blank","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}} {"name":"ins-backspace-indent-tab","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"ins-backspace-indent-tab","text":"\tx\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},"expected":{"name":"ins-backspace-indent-tab","text":" x\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} +{"name":"dot-append-count","reason":"A terminal's text is not the editor's to grow: a at its end adds no newline to append before, where a file gets one (helix append_mode).","reference":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":1,"col":2}},"expected":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ","mode":"normal","cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}}} -- cgit v1.3 From 7ce5d94b5d48b68c86d015060ebcabe63e299e1a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:24:59 -0300 Subject: A selection holds up to 1024 ranges on the desktop, 64 on the board The limit was 64 everywhere, which helix-golf's enumerate_and_align outgrows (%s |\d+ makes 121 ranges). It is now memory.limits.selections, per platform, and the counts that hold it are u16. The ranges stay inline in each Text, so a pane grows by about 80 KiB on the desktop (three texts and the regex prompt's snapshot); the Pane size budget in Terminal.zig moves from 128 to 160 KiB and says why. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- docs/selections.md | 8 +++++--- src/Pipe.zig | 6 +++--- src/Terminal.zig | 5 ++++- src/Text.zig | 5 +++-- src/exec.zig | 4 ++-- src/memory.zig | 4 ++++ src/normal.zig | 2 +- src/panes.zig | 4 ++-- src/pardes.zig | 4 ++-- test/hxcases/cases.jsonl | 2 ++ test/hxcases/goldens.jsonl | 2 ++ test/panes.zig | 10 +++++----- 13 files changed, 36 insertions(+), 22 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 3d02893b..1c9e0763 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -276,7 +276,7 @@ keeps the matches it found so far. | an all-lowercase pattern | matches case-blind | helix's smart-case. mvzr has no such flag, so the surface is lowercased instead (ASCII folding is byte-for-byte, so the offsets are identical) | helix-verified | | `^` and `$` | hold at every line's start and end | helix compiles with `multi_line(true)`; pardes gets the same by searching each line as its own haystack (`src/regexp.zig`) | helix-verified | | `.` | never matches a newline | as the Rust regex crate: a pattern without `\n` searches line by line, and one that names `\n` has its `.`s made `[^\n]` (`src/regexp.zig`) | helix-verified | -| more than 64 matches | the ones past `MAX_SELS` are dropped | the ceiling the whole selection model has, not this key's | +| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The ranges are held inline in every `Text` (about 20 KiB each on the desktop) | helix-verified up to the limit | | `K` / `Alt-K` | keep / remove ranges matching a regex | **skipped**: the same prompt, filtering instead of splitting — worth adding next | ### `Ctrl-c` — toggle comments diff --git a/docs/selections.md b/docs/selections.md index 202ec3d9..59c4415b 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -10,7 +10,8 @@ at `694e7dfdd`, upstream `278b24389`. Every `Text` (src/Text.zig) has its own: a pane's body, its tag, a prompt's answer, a column's or the workspace's tag. A selection is one primary range -plus up to 63 others. +plus others, up to `memory.limits.selections` in all (1024 on the +desktop, 64 on the board). - The PRIMARY is where the cursor always was: `cur_row`/`cur_col` is the head, `vsel.row`/`vsel.col` the anchor while `vsel.active`, and a bare @@ -33,7 +34,7 @@ plus up to 63 others. `Text.setRanges` is the one writer of a whole selection: it sorts by start, merges ranges that overlap or share a start (helix `normalize`), follows the -primary through the merges, and keeps the first 64. `setRange` writes one +primary through the merges, and keeps the first `max_selections`. `setRange` writes one range and drops the others. ## Making, splitting, merging, rotating, removing @@ -107,7 +108,8 @@ the replay. - Direction of a one-character range: both of its cells are the same cell, so it has none. helix can flip one (`Alt-;`) and the commands that read the head (`&`, the next extend) see the difference. -- Count: at most 64 ranges; matches past that are dropped silently. +- Count: at most `memory.limits.selections` ranges (1024 on the desktop, 64 + on the board); matches past that are dropped silently. - `n`/`N` walk the look ring (acme), not regex search hits, and there is no `*`; helix-golf's `*`, `""N` and `n` do not apply. - `s`/`S` match as sam does (docs/helix-keys.md, "Regex selection"): each diff --git a/src/Pipe.zig b/src/Pipe.zig index 37145edf..48e98b7b 100644 --- a/src/Pipe.zig +++ b/src/Pipe.zig @@ -55,10 +55,10 @@ pub const PendingPipe = struct { cwd: []u8, inputs: []selection_pipe.Input, ranges: [panes.Text.max_selections]modal.Selection, - primary: u8, + primary: u16, explicit: bool, how: modal.Normal.PipeBehavior, - nranges: u8, + nranges: u16, pub fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { gpa.free(wait.command); @@ -453,7 +453,7 @@ test "selection pipe replaces all ranges atomically and undo restores them" { p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); - try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 6), pane.body.sels[0].col); try std.testing.expect(!denied.has_induced_failure); diff --git a/src/Terminal.zig b/src/Terminal.zig index 4f94533d..1ff561b1 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -545,7 +545,10 @@ test "replay ownership is terminal-only and construction rolls back on allocatio try std.testing.expectEqual(terminal and enabled, pane.terminal != null); } }; - try std.testing.expect(@sizeOf(Pane) < 128 * 1024); + // Most of it is the three texts' selections, limits.selections ranges + // each held inline (about 20 KiB a text on the desktop), and the regex + // prompt's snapshot of them. + try std.testing.expect(@sizeOf(Pane) < 160 * 1024); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); } diff --git a/src/Text.zig b/src/Text.zig index 764b8a6a..56ee199b 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -11,6 +11,7 @@ const std = @import("std"); const panes = @import("panes.zig"); const modal = @import("modal.zig"); const config = @import("config.zig"); +const memory = @import("memory.zig"); const Pane = panes.Pane; const File = panes.File; const Terminal = panes.Terminal; @@ -36,7 +37,7 @@ pub const CharSel = struct { explicit: bool = false, }; -pub const max_selections = 64; +pub const max_selections = memory.limits.selections; pub const SelRange = struct { row: i32, @@ -60,7 +61,7 @@ cur_col: i32 = 0, msel: LineSel = .{}, vsel: CharSel = .{}, sels: [max_selections - 1]SelRange = undefined, -nsel: u8 = 0, +nsel: u16 = 0, select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. diff --git a/src/exec.zig b/src/exec.zig index 1b82d2a8..455a325e 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -752,7 +752,7 @@ test "the acme chords act once per selection, not once on the primary" { .{ .anchor = 7, .head = 13 }, }; pane.body.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); - try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); const before = p.ncol; p.update(.{ .key = .{ .cp = Key.tab } }); // config.exec_key: Exec @@ -760,7 +760,7 @@ test "the acme chords act once per selection, not once on the primary" { // other cursor, so one keystroke over two cursors made one column. try std.testing.expectEqual(before + 2, p.ncol); // ...and the chord consumed the selection exactly as it does with one. - try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.body.nsel); + try std.testing.expectEqual(@as(u16, 0), p.panes[0].?.body.nsel); } test "a switch clicked with a selection held flips unless the selection says on or off" { diff --git a/src/memory.zig b/src/memory.zig index 7b6ee2c0..abfd40f6 100644 --- a/src/memory.zig +++ b/src/memory.zig @@ -17,6 +17,9 @@ pub const limits = struct { pub const wrap_rows = if (board) 128 else 256; pub const undo_max = if (board) 16 else 256; pub const message_log = if (board) 16 else 128; + /// ranges one selection holds; helix has no limit, and a `%s` over a + /// file's words wants hundreds + pub const selections = if (board) 64 else 1024; /// /log's ring, which records whether or not anyone reads it. pub const log_bytes: usize = if (board) 4 * KiB else 64 * KiB; pub const max_tag_tail: usize = if (board) 512 else 4096; @@ -187,6 +190,7 @@ test "memory limits preserve desktop capacities" { try std.testing.expectEqual(256, limits.wrap_rows); try std.testing.expectEqual(256, limits.undo_max); try std.testing.expectEqual(128, limits.message_log); + try std.testing.expectEqual(1024, limits.selections); try std.testing.expectEqual(@as(usize, 4096), limits.max_tag_tail); try std.testing.expectEqual(@as(usize, 4095), limits.host_path_cap); try std.testing.expectEqual(config.embed_sources, limits.embedded_sources); diff --git a/src/normal.zig b/src/normal.zig index 542c9fb3..98ba80d6 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -771,7 +771,7 @@ test "flat text movement and selection replay need no scratch rows" { pane.body.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; handleNormal(p, &pane.body, .{ .cp = 'l' }); handleNormal(p, &pane.body, .{ .cp = 'j' }); - try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); try std.testing.expectEqual(@as(i32, 1025), pane.body.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 3073), pane.body.sels[0].row); diff --git a/src/panes.zig b/src/panes.zig index 9234912f..67954abd 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -155,8 +155,8 @@ pub const Pane = struct { search_row: ?usize = null, look_at: ?LookSpot = null, sel_snap: [Text.max_selections]modal.Selection = undefined, - nsel_snap: u8 = 0, - sel_snap_pri: u8 = 0, + nsel_snap: u16 = 0, + sel_snap_pri: u16 = 0, sel_snap_expl: bool = false, /// The tag: what the user keeps after the computed prefix (the path, the /// dirty marker, a PDF's page), its cursor, selections and undo. The diff --git a/src/pardes.zig b/src/pardes.zig index 87041f39..a7bcb195 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3123,7 +3123,7 @@ test "s in a header selects in the header's own text" { const text = try edit.flatSurface(p, &p.global_tag); const got = p.global_tag.ranges(text, 0, &ranges); try std.testing.expectEqual(@as(usize, 3), got.n); // Newcol, Joincol, NextColor - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); } test "Enter in a header runs the word under its cursor, as the execute key does" { @@ -7547,7 +7547,7 @@ test "jump history clears selections and lands at the recorded cursor" { try std.testing.expectEqual(@as(usize, 2), p.njumps); try std.testing.expectEqual(@as(u32, 3), p.jumps[0].line); try std.testing.expect(!pane.body.vsel.active and !pane.body.msel.active and !pane.body.select); - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); try std.testing.expectEqual(.none, pane.sel[0].state); try std.testing.expect(pane.pointer_selections[0] == null); try std.testing.expectEqual(@as(i32, if (index == 0) 2 else 0), pane.body.cur_row); diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 8d6b0e6e..7aeb12e8 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -430,6 +430,8 @@ {"name": "msel-rotate-contents-lengths", "pane": "file", "text": "one to three\n", "keys": "%s\\w+)"} {"name": "msel-rotate-contents-backward", "pane": "file", "text": "ab cde\n", "keys": "%s\\w+"} {"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%"} +{"name": "sel-many-ranges", "pane": "file", "text": "w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n", "keys": "%sw"} +{"name": "sel-many-ranges-edit", "pane": "file", "text": "w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n", "keys": "%swcxy"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index d62cb633..b8221c4c 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -430,6 +430,8 @@ {"name":"msel-rotate-contents-lengths","text":"to three one\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":9}}],"primary":0} {"name":"msel-rotate-contents-backward","text":"cde ab\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5}}],"primary":1} {"name":"msel-rotate-contents-lines","text":"bbb\naa\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"sel-many-ranges","text":"w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\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":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":0,"col":12},"anchor":{"row":0,"col":12}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":14}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":16}},{"cursor":{"row":0,"col":18},"anchor":{"row":0,"col":18}},{"cursor":{"row":0,"col":20},"anchor":{"row":0,"col":20}},{"cursor":{"row":0,"col":22},"anchor":{"row":0,"col":22}},{"cursor":{"row":0,"col":24},"anchor":{"row":0,"col":24}},{"cursor":{"row":0,"col":26},"anchor":{"row":0,"col":26}},{"cursor":{"row":0,"col":28},"anchor":{"row":0,"col":28}},{"cursor":{"row":0,"col":30},"anchor":{"row":0,"col":30}},{"cursor":{"row":0,"col":32},"anchor":{"row":0,"col":32}},{"cursor":{"row":0,"col":34},"anchor":{"row":0,"col":34}},{"cursor":{"row":0,"col":36},"anchor":{"row":0,"col":36}},{"cursor":{"row":0,"col":38},"anchor":{"row":0,"col":38}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}},{"cursor":{"row":1,"col":6},"anchor":{"row":1,"col":6}},{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":8}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":10}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":12}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":14}},{"cursor":{"row":1,"col":16},"anchor":{"row":1,"col":16}},{"cursor":{"row":1,"col":18},"anchor":{"row":1,"col":18}},{"cursor":{"row":1,"col":20},"anchor":{"row":1,"col":20}},{"cursor":{"row":1,"col":22},"anchor":{"row":1,"col":22}},{"cursor":{"row":1,"col":24},"anchor":{"row":1,"col":24}},{"cursor":{"row":1,"col":26},"anchor":{"row":1,"col":26}},{"cursor":{"row":1,"col":28},"anchor":{"row":1,"col":28}},{"cursor":{"row":1,"col":30},"anchor":{"row":1,"col":30}},{"cursor":{"row":1,"col":32},"anchor":{"row":1,"col":32}},{"cursor":{"row":1,"col":34},"anchor":{"row":1,"col":34}},{"cursor":{"row":1,"col":36},"anchor":{"row":1,"col":36}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":38}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}},{"cursor":{"row":2,"col":4},"anchor":{"row":2,"col":4}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}},{"cursor":{"row":2,"col":8},"anchor":{"row":2,"col":8}},{"cursor":{"row":2,"col":10},"anchor":{"row":2,"col":10}},{"cursor":{"row":2,"col":12},"anchor":{"row":2,"col":12}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":16},"anchor":{"row":2,"col":16}},{"cursor":{"row":2,"col":18},"anchor":{"row":2,"col":18}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":2,"col":22},"anchor":{"row":2,"col":22}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":24}},{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":26}},{"cursor":{"row":2,"col":28},"anchor":{"row":2,"col":28}},{"cursor":{"row":2,"col":30},"anchor":{"row":2,"col":30}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":34},"anchor":{"row":2,"col":34}},{"cursor":{"row":2,"col":36},"anchor":{"row":2,"col":36}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":38}},{"cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}},{"cursor":{"row":3,"col":2},"anchor":{"row":3,"col":2}},{"cursor":{"row":3,"col":4},"anchor":{"row":3,"col":4}},{"cursor":{"row":3,"col":6},"anchor":{"row":3,"col":6}},{"cursor":{"row":3,"col":8},"anchor":{"row":3,"col":8}},{"cursor":{"row":3,"col":10},"anchor":{"row":3,"col":10}},{"cursor":{"row":3,"col":12},"anchor":{"row":3,"col":12}},{"cursor":{"row":3,"col":14},"anchor":{"row":3,"col":14}},{"cursor":{"row":3,"col":16},"anchor":{"row":3,"col":16}},{"cursor":{"row":3,"col":18},"anchor":{"row":3,"col":18}},{"cursor":{"row":3,"col":20},"anchor":{"row":3,"col":20}},{"cursor":{"row":3,"col":22},"anchor":{"row":3,"col":22}},{"cursor":{"row":3,"col":24},"anchor":{"row":3,"col":24}},{"cursor":{"row":3,"col":26},"anchor":{"row":3,"col":26}},{"cursor":{"row":3,"col":28},"anchor":{"row":3,"col":28}},{"cursor":{"row":3,"col":30},"anchor":{"row":3,"col":30}},{"cursor":{"row":3,"col":32},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":34},"anchor":{"row":3,"col":34}},{"cursor":{"row":3,"col":36},"anchor":{"row":3,"col":36}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":38}},{"cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":2}},{"cursor":{"row":4,"col":4},"anchor":{"row":4,"col":4}},{"cursor":{"row":4,"col":6},"anchor":{"row":4,"col":6}},{"cursor":{"row":4,"col":8},"anchor":{"row":4,"col":8}},{"cursor":{"row":4,"col":10},"anchor":{"row":4,"col":10}},{"cursor":{"row":4,"col":12},"anchor":{"row":4,"col":12}},{"cursor":{"row":4,"col":14},"anchor":{"row":4,"col":14}},{"cursor":{"row":4,"col":16},"anchor":{"row":4,"col":16}},{"cursor":{"row":4,"col":18},"anchor":{"row":4,"col":18}},{"cursor":{"row":4,"col":20},"anchor":{"row":4,"col":20}},{"cursor":{"row":4,"col":22},"anchor":{"row":4,"col":22}},{"cursor":{"row":4,"col":24},"anchor":{"row":4,"col":24}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":28},"anchor":{"row":4,"col":28}},{"cursor":{"row":4,"col":30},"anchor":{"row":4,"col":30}},{"cursor":{"row":4,"col":32},"anchor":{"row":4,"col":32}},{"cursor":{"row":4,"col":34},"anchor":{"row":4,"col":34}},{"cursor":{"row":4,"col":36},"anchor":{"row":4,"col":36}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":2}},{"cursor":{"row":5,"col":4},"anchor":{"row":5,"col":4}},{"cursor":{"row":5,"col":6},"anchor":{"row":5,"col":6}},{"cursor":{"row":5,"col":8},"anchor":{"row":5,"col":8}},{"cursor":{"row":5,"col":10},"anchor":{"row":5,"col":10}},{"cursor":{"row":5,"col":12},"anchor":{"row":5,"col":12}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":14}},{"cursor":{"row":5,"col":16},"anchor":{"row":5,"col":16}},{"cursor":{"row":5,"col":18},"anchor":{"row":5,"col":18}},{"cursor":{"row":5,"col":20},"anchor":{"row":5,"col":20}},{"cursor":{"row":5,"col":22},"anchor":{"row":5,"col":22}},{"cursor":{"row":5,"col":24},"anchor":{"row":5,"col":24}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":26}},{"cursor":{"row":5,"col":28},"anchor":{"row":5,"col":28}},{"cursor":{"row":5,"col":30},"anchor":{"row":5,"col":30}},{"cursor":{"row":5,"col":32},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":34},"anchor":{"row":5,"col":34}},{"cursor":{"row":5,"col":36},"anchor":{"row":5,"col":36}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":38}},{"cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":2}},{"cursor":{"row":6,"col":4},"anchor":{"row":6,"col":4}},{"cursor":{"row":6,"col":6},"anchor":{"row":6,"col":6}},{"cursor":{"row":6,"col":8},"anchor":{"row":6,"col":8}},{"cursor":{"row":6,"col":10},"anchor":{"row":6,"col":10}},{"cursor":{"row":6,"col":12},"anchor":{"row":6,"col":12}},{"cursor":{"row":6,"col":14},"anchor":{"row":6,"col":14}},{"cursor":{"row":6,"col":16},"anchor":{"row":6,"col":16}},{"cursor":{"row":6,"col":18},"anchor":{"row":6,"col":18}},{"cursor":{"row":6,"col":20},"anchor":{"row":6,"col":20}},{"cursor":{"row":6,"col":22},"anchor":{"row":6,"col":22}},{"cursor":{"row":6,"col":24},"anchor":{"row":6,"col":24}},{"cursor":{"row":6,"col":26},"anchor":{"row":6,"col":26}},{"cursor":{"row":6,"col":28},"anchor":{"row":6,"col":28}},{"cursor":{"row":6,"col":30},"anchor":{"row":6,"col":30}},{"cursor":{"row":6,"col":32},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":34},"anchor":{"row":6,"col":34}},{"cursor":{"row":6,"col":36},"anchor":{"row":6,"col":36}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":38}},{"cursor":{"row":7,"col":0},"anchor":{"row":7,"col":0}},{"cursor":{"row":7,"col":2},"anchor":{"row":7,"col":2}},{"cursor":{"row":7,"col":4},"anchor":{"row":7,"col":4}},{"cursor":{"row":7,"col":6},"anchor":{"row":7,"col":6}},{"cursor":{"row":7,"col":8},"anchor":{"row":7,"col":8}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":7,"col":12},"anchor":{"row":7,"col":12}},{"cursor":{"row":7,"col":14},"anchor":{"row":7,"col":14}},{"cursor":{"row":7,"col":16},"anchor":{"row":7,"col":16}},{"cursor":{"row":7,"col":18},"anchor":{"row":7,"col":18}},{"cursor":{"row":7,"col":20},"anchor":{"row":7,"col":20}},{"cursor":{"row":7,"col":22},"anchor":{"row":7,"col":22}},{"cursor":{"row":7,"col":24},"anchor":{"row":7,"col":24}},{"cursor":{"row":7,"col":26},"anchor":{"row":7,"col":26}},{"cursor":{"row":7,"col":28},"anchor":{"row":7,"col":28}},{"cursor":{"row":7,"col":30},"anchor":{"row":7,"col":30}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":34},"anchor":{"row":7,"col":34}},{"cursor":{"row":7,"col":36},"anchor":{"row":7,"col":36}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":38}},{"cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}},{"cursor":{"row":8,"col":2},"anchor":{"row":8,"col":2}},{"cursor":{"row":8,"col":4},"anchor":{"row":8,"col":4}},{"cursor":{"row":8,"col":6},"anchor":{"row":8,"col":6}},{"cursor":{"row":8,"col":8},"anchor":{"row":8,"col":8}},{"cursor":{"row":8,"col":10},"anchor":{"row":8,"col":10}},{"cursor":{"row":8,"col":12},"anchor":{"row":8,"col":12}},{"cursor":{"row":8,"col":14},"anchor":{"row":8,"col":14}},{"cursor":{"row":8,"col":16},"anchor":{"row":8,"col":16}},{"cursor":{"row":8,"col":18},"anchor":{"row":8,"col":18}},{"cursor":{"row":8,"col":20},"anchor":{"row":8,"col":20}},{"cursor":{"row":8,"col":22},"anchor":{"row":8,"col":22}},{"cursor":{"row":8,"col":24},"anchor":{"row":8,"col":24}},{"cursor":{"row":8,"col":26},"anchor":{"row":8,"col":26}},{"cursor":{"row":8,"col":28},"anchor":{"row":8,"col":28}},{"cursor":{"row":8,"col":30},"anchor":{"row":8,"col":30}},{"cursor":{"row":8,"col":32},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":34},"anchor":{"row":8,"col":34}},{"cursor":{"row":8,"col":36},"anchor":{"row":8,"col":36}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":38}},{"cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}},{"cursor":{"row":9,"col":2},"anchor":{"row":9,"col":2}},{"cursor":{"row":9,"col":4},"anchor":{"row":9,"col":4}},{"cursor":{"row":9,"col":6},"anchor":{"row":9,"col":6}},{"cursor":{"row":9,"col":8},"anchor":{"row":9,"col":8}},{"cursor":{"row":9,"col":10},"anchor":{"row":9,"col":10}},{"cursor":{"row":9,"col":12},"anchor":{"row":9,"col":12}},{"cursor":{"row":9,"col":14},"anchor":{"row":9,"col":14}},{"cursor":{"row":9,"col":16},"anchor":{"row":9,"col":16}},{"cursor":{"row":9,"col":18},"anchor":{"row":9,"col":18}},{"cursor":{"row":9,"col":20},"anchor":{"row":9,"col":20}},{"cursor":{"row":9,"col":22},"anchor":{"row":9,"col":22}},{"cursor":{"row":9,"col":24},"anchor":{"row":9,"col":24}},{"cursor":{"row":9,"col":26},"anchor":{"row":9,"col":26}},{"cursor":{"row":9,"col":28},"anchor":{"row":9,"col":28}},{"cursor":{"row":9,"col":30},"anchor":{"row":9,"col":30}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":34},"anchor":{"row":9,"col":34}},{"cursor":{"row":9,"col":36},"anchor":{"row":9,"col":36}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":38}}],"primary":0} +{"name":"sel-many-ranges-edit","text":"xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":14}},{"cursor":{"row":0,"col":17},"anchor":{"row":0,"col":17}},{"cursor":{"row":0,"col":20},"anchor":{"row":0,"col":20}},{"cursor":{"row":0,"col":23},"anchor":{"row":0,"col":23}},{"cursor":{"row":0,"col":26},"anchor":{"row":0,"col":26}},{"cursor":{"row":0,"col":29},"anchor":{"row":0,"col":29}},{"cursor":{"row":0,"col":32},"anchor":{"row":0,"col":32}},{"cursor":{"row":0,"col":35},"anchor":{"row":0,"col":35}},{"cursor":{"row":0,"col":38},"anchor":{"row":0,"col":38}},{"cursor":{"row":0,"col":41},"anchor":{"row":0,"col":41}},{"cursor":{"row":0,"col":44},"anchor":{"row":0,"col":44}},{"cursor":{"row":0,"col":47},"anchor":{"row":0,"col":47}},{"cursor":{"row":0,"col":50},"anchor":{"row":0,"col":50}},{"cursor":{"row":0,"col":53},"anchor":{"row":0,"col":53}},{"cursor":{"row":0,"col":56},"anchor":{"row":0,"col":56}},{"cursor":{"row":0,"col":59},"anchor":{"row":0,"col":59}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":8}},{"cursor":{"row":1,"col":11},"anchor":{"row":1,"col":11}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":14}},{"cursor":{"row":1,"col":17},"anchor":{"row":1,"col":17}},{"cursor":{"row":1,"col":20},"anchor":{"row":1,"col":20}},{"cursor":{"row":1,"col":23},"anchor":{"row":1,"col":23}},{"cursor":{"row":1,"col":26},"anchor":{"row":1,"col":26}},{"cursor":{"row":1,"col":29},"anchor":{"row":1,"col":29}},{"cursor":{"row":1,"col":32},"anchor":{"row":1,"col":32}},{"cursor":{"row":1,"col":35},"anchor":{"row":1,"col":35}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":38}},{"cursor":{"row":1,"col":41},"anchor":{"row":1,"col":41}},{"cursor":{"row":1,"col":44},"anchor":{"row":1,"col":44}},{"cursor":{"row":1,"col":47},"anchor":{"row":1,"col":47}},{"cursor":{"row":1,"col":50},"anchor":{"row":1,"col":50}},{"cursor":{"row":1,"col":53},"anchor":{"row":1,"col":53}},{"cursor":{"row":1,"col":56},"anchor":{"row":1,"col":56}},{"cursor":{"row":1,"col":59},"anchor":{"row":1,"col":59}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}},{"cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}},{"cursor":{"row":2,"col":8},"anchor":{"row":2,"col":8}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":17},"anchor":{"row":2,"col":17}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":2,"col":23},"anchor":{"row":2,"col":23}},{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":26}},{"cursor":{"row":2,"col":29},"anchor":{"row":2,"col":29}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":35},"anchor":{"row":2,"col":35}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":38}},{"cursor":{"row":2,"col":41},"anchor":{"row":2,"col":41}},{"cursor":{"row":2,"col":44},"anchor":{"row":2,"col":44}},{"cursor":{"row":2,"col":47},"anchor":{"row":2,"col":47}},{"cursor":{"row":2,"col":50},"anchor":{"row":2,"col":50}},{"cursor":{"row":2,"col":53},"anchor":{"row":2,"col":53}},{"cursor":{"row":2,"col":56},"anchor":{"row":2,"col":56}},{"cursor":{"row":2,"col":59},"anchor":{"row":2,"col":59}},{"cursor":{"row":3,"col":2},"anchor":{"row":3,"col":2}},{"cursor":{"row":3,"col":5},"anchor":{"row":3,"col":5}},{"cursor":{"row":3,"col":8},"anchor":{"row":3,"col":8}},{"cursor":{"row":3,"col":11},"anchor":{"row":3,"col":11}},{"cursor":{"row":3,"col":14},"anchor":{"row":3,"col":14}},{"cursor":{"row":3,"col":17},"anchor":{"row":3,"col":17}},{"cursor":{"row":3,"col":20},"anchor":{"row":3,"col":20}},{"cursor":{"row":3,"col":23},"anchor":{"row":3,"col":23}},{"cursor":{"row":3,"col":26},"anchor":{"row":3,"col":26}},{"cursor":{"row":3,"col":29},"anchor":{"row":3,"col":29}},{"cursor":{"row":3,"col":32},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":35},"anchor":{"row":3,"col":35}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":38}},{"cursor":{"row":3,"col":41},"anchor":{"row":3,"col":41}},{"cursor":{"row":3,"col":44},"anchor":{"row":3,"col":44}},{"cursor":{"row":3,"col":47},"anchor":{"row":3,"col":47}},{"cursor":{"row":3,"col":50},"anchor":{"row":3,"col":50}},{"cursor":{"row":3,"col":53},"anchor":{"row":3,"col":53}},{"cursor":{"row":3,"col":56},"anchor":{"row":3,"col":56}},{"cursor":{"row":3,"col":59},"anchor":{"row":3,"col":59}},{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":2}},{"cursor":{"row":4,"col":5},"anchor":{"row":4,"col":5}},{"cursor":{"row":4,"col":8},"anchor":{"row":4,"col":8}},{"cursor":{"row":4,"col":11},"anchor":{"row":4,"col":11}},{"cursor":{"row":4,"col":14},"anchor":{"row":4,"col":14}},{"cursor":{"row":4,"col":17},"anchor":{"row":4,"col":17}},{"cursor":{"row":4,"col":20},"anchor":{"row":4,"col":20}},{"cursor":{"row":4,"col":23},"anchor":{"row":4,"col":23}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":29},"anchor":{"row":4,"col":29}},{"cursor":{"row":4,"col":32},"anchor":{"row":4,"col":32}},{"cursor":{"row":4,"col":35},"anchor":{"row":4,"col":35}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":4,"col":41},"anchor":{"row":4,"col":41}},{"cursor":{"row":4,"col":44},"anchor":{"row":4,"col":44}},{"cursor":{"row":4,"col":47},"anchor":{"row":4,"col":47}},{"cursor":{"row":4,"col":50},"anchor":{"row":4,"col":50}},{"cursor":{"row":4,"col":53},"anchor":{"row":4,"col":53}},{"cursor":{"row":4,"col":56},"anchor":{"row":4,"col":56}},{"cursor":{"row":4,"col":59},"anchor":{"row":4,"col":59}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":2}},{"cursor":{"row":5,"col":5},"anchor":{"row":5,"col":5}},{"cursor":{"row":5,"col":8},"anchor":{"row":5,"col":8}},{"cursor":{"row":5,"col":11},"anchor":{"row":5,"col":11}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":14}},{"cursor":{"row":5,"col":17},"anchor":{"row":5,"col":17}},{"cursor":{"row":5,"col":20},"anchor":{"row":5,"col":20}},{"cursor":{"row":5,"col":23},"anchor":{"row":5,"col":23}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":26}},{"cursor":{"row":5,"col":29},"anchor":{"row":5,"col":29}},{"cursor":{"row":5,"col":32},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":35},"anchor":{"row":5,"col":35}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":38}},{"cursor":{"row":5,"col":41},"anchor":{"row":5,"col":41}},{"cursor":{"row":5,"col":44},"anchor":{"row":5,"col":44}},{"cursor":{"row":5,"col":47},"anchor":{"row":5,"col":47}},{"cursor":{"row":5,"col":50},"anchor":{"row":5,"col":50}},{"cursor":{"row":5,"col":53},"anchor":{"row":5,"col":53}},{"cursor":{"row":5,"col":56},"anchor":{"row":5,"col":56}},{"cursor":{"row":5,"col":59},"anchor":{"row":5,"col":59}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":2}},{"cursor":{"row":6,"col":5},"anchor":{"row":6,"col":5}},{"cursor":{"row":6,"col":8},"anchor":{"row":6,"col":8}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":11}},{"cursor":{"row":6,"col":14},"anchor":{"row":6,"col":14}},{"cursor":{"row":6,"col":17},"anchor":{"row":6,"col":17}},{"cursor":{"row":6,"col":20},"anchor":{"row":6,"col":20}},{"cursor":{"row":6,"col":23},"anchor":{"row":6,"col":23}},{"cursor":{"row":6,"col":26},"anchor":{"row":6,"col":26}},{"cursor":{"row":6,"col":29},"anchor":{"row":6,"col":29}},{"cursor":{"row":6,"col":32},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":35},"anchor":{"row":6,"col":35}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":38}},{"cursor":{"row":6,"col":41},"anchor":{"row":6,"col":41}},{"cursor":{"row":6,"col":44},"anchor":{"row":6,"col":44}},{"cursor":{"row":6,"col":47},"anchor":{"row":6,"col":47}},{"cursor":{"row":6,"col":50},"anchor":{"row":6,"col":50}},{"cursor":{"row":6,"col":53},"anchor":{"row":6,"col":53}},{"cursor":{"row":6,"col":56},"anchor":{"row":6,"col":56}},{"cursor":{"row":6,"col":59},"anchor":{"row":6,"col":59}},{"cursor":{"row":7,"col":2},"anchor":{"row":7,"col":2}},{"cursor":{"row":7,"col":5},"anchor":{"row":7,"col":5}},{"cursor":{"row":7,"col":8},"anchor":{"row":7,"col":8}},{"cursor":{"row":7,"col":11},"anchor":{"row":7,"col":11}},{"cursor":{"row":7,"col":14},"anchor":{"row":7,"col":14}},{"cursor":{"row":7,"col":17},"anchor":{"row":7,"col":17}},{"cursor":{"row":7,"col":20},"anchor":{"row":7,"col":20}},{"cursor":{"row":7,"col":23},"anchor":{"row":7,"col":23}},{"cursor":{"row":7,"col":26},"anchor":{"row":7,"col":26}},{"cursor":{"row":7,"col":29},"anchor":{"row":7,"col":29}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":35},"anchor":{"row":7,"col":35}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":38}},{"cursor":{"row":7,"col":41},"anchor":{"row":7,"col":41}},{"cursor":{"row":7,"col":44},"anchor":{"row":7,"col":44}},{"cursor":{"row":7,"col":47},"anchor":{"row":7,"col":47}},{"cursor":{"row":7,"col":50},"anchor":{"row":7,"col":50}},{"cursor":{"row":7,"col":53},"anchor":{"row":7,"col":53}},{"cursor":{"row":7,"col":56},"anchor":{"row":7,"col":56}},{"cursor":{"row":7,"col":59},"anchor":{"row":7,"col":59}},{"cursor":{"row":8,"col":2},"anchor":{"row":8,"col":2}},{"cursor":{"row":8,"col":5},"anchor":{"row":8,"col":5}},{"cursor":{"row":8,"col":8},"anchor":{"row":8,"col":8}},{"cursor":{"row":8,"col":11},"anchor":{"row":8,"col":11}},{"cursor":{"row":8,"col":14},"anchor":{"row":8,"col":14}},{"cursor":{"row":8,"col":17},"anchor":{"row":8,"col":17}},{"cursor":{"row":8,"col":20},"anchor":{"row":8,"col":20}},{"cursor":{"row":8,"col":23},"anchor":{"row":8,"col":23}},{"cursor":{"row":8,"col":26},"anchor":{"row":8,"col":26}},{"cursor":{"row":8,"col":29},"anchor":{"row":8,"col":29}},{"cursor":{"row":8,"col":32},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":35},"anchor":{"row":8,"col":35}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":38}},{"cursor":{"row":8,"col":41},"anchor":{"row":8,"col":41}},{"cursor":{"row":8,"col":44},"anchor":{"row":8,"col":44}},{"cursor":{"row":8,"col":47},"anchor":{"row":8,"col":47}},{"cursor":{"row":8,"col":50},"anchor":{"row":8,"col":50}},{"cursor":{"row":8,"col":53},"anchor":{"row":8,"col":53}},{"cursor":{"row":8,"col":56},"anchor":{"row":8,"col":56}},{"cursor":{"row":8,"col":59},"anchor":{"row":8,"col":59}},{"cursor":{"row":9,"col":2},"anchor":{"row":9,"col":2}},{"cursor":{"row":9,"col":5},"anchor":{"row":9,"col":5}},{"cursor":{"row":9,"col":8},"anchor":{"row":9,"col":8}},{"cursor":{"row":9,"col":11},"anchor":{"row":9,"col":11}},{"cursor":{"row":9,"col":14},"anchor":{"row":9,"col":14}},{"cursor":{"row":9,"col":17},"anchor":{"row":9,"col":17}},{"cursor":{"row":9,"col":20},"anchor":{"row":9,"col":20}},{"cursor":{"row":9,"col":23},"anchor":{"row":9,"col":23}},{"cursor":{"row":9,"col":26},"anchor":{"row":9,"col":26}},{"cursor":{"row":9,"col":29},"anchor":{"row":9,"col":29}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":35},"anchor":{"row":9,"col":35}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":38}},{"cursor":{"row":9,"col":41},"anchor":{"row":9,"col":41}},{"cursor":{"row":9,"col":44},"anchor":{"row":9,"col":44}},{"cursor":{"row":9,"col":47},"anchor":{"row":9,"col":47}},{"cursor":{"row":9,"col":50},"anchor":{"row":9,"col":50}},{"cursor":{"row":9,"col":53},"anchor":{"row":9,"col":53}},{"cursor":{"row":9,"col":56},"anchor":{"row":9,"col":56}},{"cursor":{"row":9,"col":59},"anchor":{"row":9,"col":59}}],"primary":0} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} diff --git a/test/panes.zig b/test/panes.zig index f0e1ba3d..a56c07bf 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -305,7 +305,7 @@ const TagNavigationTests = struct { try std.testing.expectEqual(@as(usize, 2), got.n); for (ranges[0..got.n]) |range| try std.testing.expectEqualStrings("S", text[@min(range.anchor, range.head)..@max(range.anchor, range.head)]); // the body's selection is its own - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); // `/` from the tag is the body's search, as acme's Look from a tag is key(p, ','); key(p, '/'); @@ -1984,8 +1984,8 @@ test "prompt cancellation restores the selection before regex previews" { p.update(.{ .key = .{ .cp = case.pattern[0], .text = case.pattern } }); try std.testing.expectEqual(case.secondary, pane.body.nsel); p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); - try std.testing.expectEqual(@as(u8, 0), pane.nsel_snap); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.nsel_snap); try std.testing.expectEqual(@as(i32, 10), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.body.vsel.col); try std.testing.expect(pane.body.vsel.active and pane.body.vsel.explicit); @@ -2761,7 +2761,7 @@ const OutputTests = struct { try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "short\n"), null); try std.testing.expectEqualStrings("short\n", result.file.?.content); try std.testing.expect(!result.body.vsel.active and !result.body.msel.active and !result.body.select); - try std.testing.expectEqual(@as(u8, 0), result.body.nsel); + try std.testing.expectEqual(@as(u16, 0), result.body.nsel); try std.testing.expectEqual(@as(i32, 0), result.body.cur_row); try std.testing.expectEqual(@as(i32, 0), result.body.cur_col); try std.testing.expectEqual(@as(usize, 0), result.file.?.scroll); @@ -4581,7 +4581,7 @@ const LookResetTests = struct { fn cleared(pane: *const Pane) !void { try std.testing.expect(!pane.body.msel.active); - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); try std.testing.expect(!pane.body.select); try std.testing.expectEqual(modal.Normal.State{}, pane.body.normal); try std.testing.expect(!pane.body.restore_cursor); -- cgit v1.3 From 79cdf76fd2dd0ba03622bfe742b525b770d51684 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:29:36 -0300 Subject: K and Alt-K keep or drop the ranges a regex matches in; Alt-: turns every range forward K and Alt-K share the s/S prompt, which now carries a mode rather than a split flag: a range stays when a match starts inside it (K) or when none does (Alt-K), the primary becomes the first, and keeping none leaves the selection as it was (helix keep_or_remove_matches). Alt-: is ensure_selections_forward. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 4 ++-- docs/selections.md | 2 ++ src/Text.zig | 9 +++++++ src/config.zig | 5 ++++ src/edit.zig | 5 ++-- src/look.zig | 2 +- src/modal.zig | 15 +++++++++--- src/normal.zig | 58 ++++++++++++++++++++++++++++++++-------------- src/pardes.zig | 4 ++-- test/hxcases/cases.jsonl | 8 +++++++ test/hxcases/goldens.jsonl | 8 +++++++ 11 files changed, 92 insertions(+), 28 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 1c9e0763..5c3972a5 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -212,6 +212,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | --- | --- | --- | --- | | `;` | 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) | +| `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified | | `%` | 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) | @@ -277,7 +278,7 @@ keeps the matches it found so far. | `^` and `$` | hold at every line's start and end | helix compiles with `multi_line(true)`; pardes gets the same by searching each line as its own haystack (`src/regexp.zig`) | helix-verified | | `.` | never matches a newline | as the Rust regex crate: a pattern without `\n` searches line by line, and one that names `\n` has its `.`s made `[^\n]` (`src/regexp.zig`) | helix-verified | | many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The ranges are held inline in every `Text` (about 20 KiB each on the desktop) | helix-verified up to the limit | -| `K` / `Alt-K` | keep / remove ranges matching a regex | **skipped**: the same prompt, filtering instead of splitting — worth adding next | +| `K` / `Alt-K` Enter | keep only the ranges a match starts inside / only those none does; primary 0, and keeping none leaves the selection alone | helix `keep_selections` / `remove_selections`, on the same prompt as `s`/`S` (markers `Keep /`, `Remove /`). The range is searched as `s` searches it, line by line with its lines' context, where helix matches the range's text alone: a `^` right at a range that starts mid-line matches in helix and not here | helix-verified | ### `Ctrl-c` — toggle comments @@ -357,7 +358,6 @@ text. | 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 | | `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 | -| `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | diff --git a/docs/selections.md b/docs/selections.md index 59c4415b..203cf1de 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -44,6 +44,8 @@ range and drops the others. per line), `o`/`O`, and `Alt-J` (the spaces a join put in). `s`/`S` re-run on every keystroke from the selection the prompt opened on (`Pane.sel_snap`); Esc is that snapshot restored. +- `K`/`Alt-K` keep or drop the ranges a regex matches in, on the `s` prompt; + `Alt-:` turns every range forward. - `Alt-minus` merges all into one, `Alt-_` the consecutive ones, `_` trims whitespace (dropping all-blank ranges). - `)`/`(` rotate which range is primary; `Alt-)`/`Alt-(` rotate the TEXT diff --git a/src/Text.zig b/src/Text.zig index 56ee199b..411c2ec8 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -328,6 +328,15 @@ pub fn multiSelAction(t: *Text, text: []const u8, kind: modal.Normal.Multi, cnt: } return setRanges(t, text, out[0..m], &.{}, pri, expl); } + if (kind == .ensure_forward) { + // helix ensure_selections_forward: every range points forward + for (rs[0..n]) |*r| { + const from = @min(r.anchor, r.head); + r.head = @max(r.anchor, r.head); + r.anchor = from; + } + return setRanges(t, text, rs[0..n], &.{}, got.pri, expl); + } if (kind == .split_newline) { // helix selection::split_on_newline — one range per line the // selection covers, the newlines themselves left out diff --git a/src/config.zig b/src/config.zig index bc053176..8c5c9771 100644 --- a/src/config.zig +++ b/src/config.zig @@ -414,6 +414,8 @@ pub const symbol_marker = " WsSymbols /"; pub const save_marker = " Save "; pub const select_marker = " Select /"; pub const split_marker = " Split /"; +pub const keep_marker = " Keep /"; +pub const remove_marker = " Remove /"; pub const pipe_marker = " |"; pub const search_buffer = "+Search"; @@ -532,6 +534,9 @@ pub const trim_sels: []const Chord = &.{.{ .cp = '_' }}; pub const select_regex: []const Chord = &.{.{ .cp = 's' }}; pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; +pub const keep_regex: []const Chord = &.{.{ .cp = 'K' }}; +pub const remove_regex: []const Chord = &.{.{ .cp = 'K', .alt = true }}; +pub const ensure_forward: []const Chord = &.{.{ .cp = ':', .alt = true }}; pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; diff --git a/src/edit.zig b/src/edit.zig index a373741c..5e0d20a4 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -148,7 +148,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { n += 1; }; if (n > 0) insertKey(p, &pane.input, .{ .cp = line[0], .text = line[0..n] }); - if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode); return; } if (pane.focus == .tag) return pasteInto(p, &pane.tag, bytes); @@ -355,7 +355,8 @@ pub fn exitInsert(p: *Pardes, t: *Text) void { for (rs[0..got.n]) |*r| { const from = @min(r.anchor, r.head); const to = @max(r.anchor, r.head); - r.* = .{ .anchor = from, .head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to }; + const head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to; + r.* = .{ .anchor = from, .head = head }; } t.setRanges(text, rs[0..got.n], &.{}, got.pri, expl); } diff --git a/src/look.zig b/src/look.zig index 30fb0dab..3da2e044 100644 --- a/src/look.zig +++ b/src/look.zig @@ -505,7 +505,7 @@ pub fn submitSearch(p: *Pardes, id: usize) void { 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); + return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode); } const armed = pane.promptText() orelse return; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; diff --git a/src/modal.zig b/src/modal.zig index 34c4dac7..f5f3081e 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -97,6 +97,9 @@ pub const Normal = struct { trim_sels, select_regex, split_regex, + keep_regex, + remove_regex, + ensure_forward, delete, delete_noyank, @@ -210,6 +213,8 @@ pub const Normal = struct { } }; + /// what the s/S/K/Alt-K prompt does with its pattern's matches + pub const RegexMode = enum { select, split, keep, remove }; pub const Scope = enum { once, per_selection }; pub const Direction = enum { backward, forward }; pub const Motion = enum { @@ -262,6 +267,7 @@ pub const Normal = struct { merge, merge_consecutive, trim, + ensure_forward, }; pub const Edit = enum { delete, @@ -314,7 +320,7 @@ pub const Normal = struct { insert: struct { kind: Insert, count: u32 }, select: struct { kind: Select, count: u32 }, multi: struct { kind: Multi, count: u32 }, - select_regex: bool, // false = select, true = split + select_regex: RegexMode, edit: struct { kind: Edit, count: u32 }, lsp: Lsp, adjust_number: i64, @@ -577,8 +583,11 @@ pub const Normal = struct { if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } }); if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } }); if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } }); - if (key.has(.select_regex)) return resultAction(.{ .select_regex = false }); - if (key.has(.split_regex)) return resultAction(.{ .select_regex = true }); + if (key.has(.select_regex)) return resultAction(.{ .select_regex = .select }); + if (key.has(.split_regex)) return resultAction(.{ .select_regex = .split }); + if (key.has(.keep_regex)) return resultAction(.{ .select_regex = .keep }); + if (key.has(.remove_regex)) return resultAction(.{ .select_regex = .remove }); + if (key.has(.ensure_forward)) return resultAction(.{ .multi = .{ .kind = .ensure_forward, .count = count } }); if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); diff --git a/src/normal.zig b/src/normal.zig index 98ba80d6..d6c646ef 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -69,27 +69,38 @@ pub fn multiOnce(p: *Pardes) bool { return p.multi_first; } -fn startSelRegex(p: *Pardes, pane: *Pane, t: *Text, split: bool) void { +fn startSelRegex(p: *Pardes, pane: *Pane, t: *Text, mode: modal.Normal.RegexMode) void { const text = edit.flatSurface(p, t) catch return; const got = t.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; - p.startPrompt(pane, t, .{ .search = if (split) config.split_marker else config.select_marker }); + p.startPrompt(pane, t, .{ .search = regexMarker(mode) }); } -pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { +fn regexMarker(mode: modal.Normal.RegexMode) []const u8 { + return switch (mode) { + .select => config.select_marker, + .split => config.split_marker, + .keep => config.keep_marker, + .remove => config.remove_marker, + }; +} + +pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, mode: modal.Normal.RegexMode } { if (pane.prompt != .search or pane.nsel_snap == 0) return null; const armed = pane.input.own orelse ""; - const split = std.mem.startsWith(u8, armed, config.split_marker); - if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null; + const mode: modal.Normal.RegexMode = for (std.enums.values(modal.Normal.RegexMode)) |m| { + if (std.mem.startsWith(u8, armed, regexMarker(m))) break m; + } else return null; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null; - return .{ .pat = armed[slash + 1 ..], .split = split }; + return .{ .pat = armed[slash + 1 ..], .mode = mode }; } -/// Select (or split on) `pat` inside the selections `t` had when s/S was -/// pressed; the prompt's snapshot of them lives on `pane`. -pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: bool) void { +/// Select (or split on, keep or remove by) `pat` inside the selections `t` +/// had when s/S/K/Alt-K was pressed; the prompt's snapshot of them lives on +/// `pane`. +pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: modal.Normal.RegexMode) void { const text = edit.flatSurface(p, t) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [Text.max_selections]modal.Selection = undefined; @@ -108,6 +119,17 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: const from = @min(r.anchor, r.head); const to = @min(@max(r.anchor, r.head), text.len); if (from >= to) continue; + if (mode == .keep or mode == .remove) { + // helix keep_or_remove_matches: the range stays when a match + // starts inside it (K), or when none does (Alt-K) + const hit_at = re.find(hay_all, from, to, to) catch null; + const found = if (hit_at) |h| h.start < to else false; + if (found == (mode == .keep) and m < Text.max_selections) { + out[m] = r; + m += 1; + } + continue; + } // Searched as sam searches, the way addr does (regexp.zig): the // text around the selection still says where its lines begin. var at = from; @@ -115,7 +137,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: while (at < to and m < Text.max_selections) { // A search too slow to finish keeps what it found so far. const hit_at = (re.find(hay_all, at, to, to) catch break) orelse break; - if (split) { + if (mode == .split) { out[m] = .{ .anchor = piece, .head = hit_at.start }; m += 1; piece = hit_at.end; @@ -126,7 +148,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: // an empty match would otherwise never advance at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1; } - if (split and piece < to and m < Text.max_selections) { + if (mode == .split and piece < to and m < Text.max_selections) { out[m] = .{ .anchor = piece, .head = to }; m += 1; } @@ -542,7 +564,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void }, // s, S and | answer for the text they are pressed in; `/` searches // the body wherever it is pressed, as acme's Look from a tag does. - .select_regex => |split| return startSelRegex(p, pane, t, split), + .select_regex => |mode| return startSelRegex(p, pane, t, mode), .edit => |op| switch (op.kind) { .delete => return edit.normalDelete(p, t, true), .delete_noyank => return edit.normalDelete(p, t, false), @@ -795,20 +817,20 @@ test "s and S search a multi-line selection as addr does: ^ and $ per line, . wi defer p.deinit(); const text = "alpha x\nbeta alpha\nalphabet\n"; const pane = try p.setTestFile(text); - const Case = struct { pat: []const u8, split: bool, want: []const [2]usize }; + const Case = struct { pat: []const u8, mode: modal.Normal.RegexMode, want: []const [2]usize }; for ([_]Case{ // ^ at each line's start, not only the selection's - .{ .pat = "^alpha", .split = false, .want = &.{ .{ 0, 5 }, .{ 19, 24 } } }, + .{ .pat = "^alpha", .mode = .select, .want = &.{ .{ 0, 5 }, .{ 19, 24 } } }, // $ at each line's end - .{ .pat = "alpha$", .split = false, .want = &.{.{ 13, 18 }} }, + .{ .pat = "alpha$", .mode = .select, .want = &.{.{ 13, 18 }} }, // . stops at the newline: one selection per line, not one for all - .{ .pat = "a.*", .split = false, .want = &.{ .{ 0, 7 }, .{ 11, 18 }, .{ 19, 27 } } }, + .{ .pat = "a.*", .mode = .select, .want = &.{ .{ 0, 7 }, .{ 11, 18 }, .{ 19, 27 } } }, // S splits on a line's end - .{ .pat = "$", .split = true, .want = &.{ .{ 0, 7 }, .{ 7, 18 }, .{ 18, 27 }, .{ 27, 28 } } }, + .{ .pat = "$", .mode = .split, .want = &.{ .{ 0, 7 }, .{ 7, 18 }, .{ 18, 27 }, .{ 27, 28 } } }, }) |c| { pane.sel_snap[0] = .{ .anchor = 0, .head = text.len }; pane.nsel_snap = 1; - applySelRegex(p, pane, &pane.body, c.pat, c.split); + applySelRegex(p, pane, &pane.body, c.pat, c.mode); var out: [Text.max_selections]modal.Selection = undefined; const got = pane.body.ranges(text, 0, &out); try std.testing.expectEqual(c.want.len, got.n); diff --git a/src/pardes.zig b/src/pardes.zig index a7bcb195..d43b38b1 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5270,7 +5270,7 @@ pub const Pardes = struct { .search => if (submit) look.submitSearch(p, p.active) else if (normal.selRegexArmed(pane)) |_| if (target) |answered| - normal.applySelRegex(p, pane, answered, "", false), + normal.applySelRegex(p, pane, answered, "", .select), .none, .del_side, .repl_choice, .name => unreachable, } pane.closePrompt(); @@ -5281,7 +5281,7 @@ pub const Pardes = struct { return; } edit.handleInsert(p, &pane.input, key); - if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode); return; } if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 7aeb12e8..43cbbf11 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -432,6 +432,14 @@ {"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%"} {"name": "sel-many-ranges", "pane": "file", "text": "w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n", "keys": "%sw"} {"name": "sel-many-ranges-edit", "pane": "file", "text": "w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n", "keys": "%swcxy"} +{"name": "keep-regex", "pane": "file", "text": "foo bar baz\n", "keys": "%s\\w+Kba"} +{"name": "remove-regex", "pane": "file", "text": "foo bar baz\n", "keys": "%s\\w+ba"} +{"name": "keep-regex-none", "pane": "file", "text": "foo bar\n", "keys": "%s\\w+Kzz"} +{"name": "keep-regex-primary", "pane": "file", "text": "a1 b2 a3\n", "keys": "%s\\w+))Ka"} +{"name": "keep-regex-esc", "pane": "file", "text": "foo bar\n", "keys": "%s\\w+Kfo"} +{"name": "keep-regex-lines", "pane": "file", "text": "foo\nbar\nbaz\n", "keys": "%K^b"} +{"name": "ensure-forward", "pane": "file", "text": "ab cd\n", "keys": "%s\\w+"} +{"name": "ensure-forward-one", "pane": "file", "text": "abc\n", "keys": "vll"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index b8221c4c..9839335a 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -432,6 +432,14 @@ {"name":"msel-rotate-contents-lines","text":"bbb\naa\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} {"name":"sel-many-ranges","text":"w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\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":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":0,"col":12},"anchor":{"row":0,"col":12}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":14}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":16}},{"cursor":{"row":0,"col":18},"anchor":{"row":0,"col":18}},{"cursor":{"row":0,"col":20},"anchor":{"row":0,"col":20}},{"cursor":{"row":0,"col":22},"anchor":{"row":0,"col":22}},{"cursor":{"row":0,"col":24},"anchor":{"row":0,"col":24}},{"cursor":{"row":0,"col":26},"anchor":{"row":0,"col":26}},{"cursor":{"row":0,"col":28},"anchor":{"row":0,"col":28}},{"cursor":{"row":0,"col":30},"anchor":{"row":0,"col":30}},{"cursor":{"row":0,"col":32},"anchor":{"row":0,"col":32}},{"cursor":{"row":0,"col":34},"anchor":{"row":0,"col":34}},{"cursor":{"row":0,"col":36},"anchor":{"row":0,"col":36}},{"cursor":{"row":0,"col":38},"anchor":{"row":0,"col":38}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}},{"cursor":{"row":1,"col":6},"anchor":{"row":1,"col":6}},{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":8}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":10}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":12}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":14}},{"cursor":{"row":1,"col":16},"anchor":{"row":1,"col":16}},{"cursor":{"row":1,"col":18},"anchor":{"row":1,"col":18}},{"cursor":{"row":1,"col":20},"anchor":{"row":1,"col":20}},{"cursor":{"row":1,"col":22},"anchor":{"row":1,"col":22}},{"cursor":{"row":1,"col":24},"anchor":{"row":1,"col":24}},{"cursor":{"row":1,"col":26},"anchor":{"row":1,"col":26}},{"cursor":{"row":1,"col":28},"anchor":{"row":1,"col":28}},{"cursor":{"row":1,"col":30},"anchor":{"row":1,"col":30}},{"cursor":{"row":1,"col":32},"anchor":{"row":1,"col":32}},{"cursor":{"row":1,"col":34},"anchor":{"row":1,"col":34}},{"cursor":{"row":1,"col":36},"anchor":{"row":1,"col":36}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":38}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}},{"cursor":{"row":2,"col":4},"anchor":{"row":2,"col":4}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}},{"cursor":{"row":2,"col":8},"anchor":{"row":2,"col":8}},{"cursor":{"row":2,"col":10},"anchor":{"row":2,"col":10}},{"cursor":{"row":2,"col":12},"anchor":{"row":2,"col":12}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":16},"anchor":{"row":2,"col":16}},{"cursor":{"row":2,"col":18},"anchor":{"row":2,"col":18}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":2,"col":22},"anchor":{"row":2,"col":22}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":24}},{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":26}},{"cursor":{"row":2,"col":28},"anchor":{"row":2,"col":28}},{"cursor":{"row":2,"col":30},"anchor":{"row":2,"col":30}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":34},"anchor":{"row":2,"col":34}},{"cursor":{"row":2,"col":36},"anchor":{"row":2,"col":36}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":38}},{"cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}},{"cursor":{"row":3,"col":2},"anchor":{"row":3,"col":2}},{"cursor":{"row":3,"col":4},"anchor":{"row":3,"col":4}},{"cursor":{"row":3,"col":6},"anchor":{"row":3,"col":6}},{"cursor":{"row":3,"col":8},"anchor":{"row":3,"col":8}},{"cursor":{"row":3,"col":10},"anchor":{"row":3,"col":10}},{"cursor":{"row":3,"col":12},"anchor":{"row":3,"col":12}},{"cursor":{"row":3,"col":14},"anchor":{"row":3,"col":14}},{"cursor":{"row":3,"col":16},"anchor":{"row":3,"col":16}},{"cursor":{"row":3,"col":18},"anchor":{"row":3,"col":18}},{"cursor":{"row":3,"col":20},"anchor":{"row":3,"col":20}},{"cursor":{"row":3,"col":22},"anchor":{"row":3,"col":22}},{"cursor":{"row":3,"col":24},"anchor":{"row":3,"col":24}},{"cursor":{"row":3,"col":26},"anchor":{"row":3,"col":26}},{"cursor":{"row":3,"col":28},"anchor":{"row":3,"col":28}},{"cursor":{"row":3,"col":30},"anchor":{"row":3,"col":30}},{"cursor":{"row":3,"col":32},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":34},"anchor":{"row":3,"col":34}},{"cursor":{"row":3,"col":36},"anchor":{"row":3,"col":36}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":38}},{"cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":2}},{"cursor":{"row":4,"col":4},"anchor":{"row":4,"col":4}},{"cursor":{"row":4,"col":6},"anchor":{"row":4,"col":6}},{"cursor":{"row":4,"col":8},"anchor":{"row":4,"col":8}},{"cursor":{"row":4,"col":10},"anchor":{"row":4,"col":10}},{"cursor":{"row":4,"col":12},"anchor":{"row":4,"col":12}},{"cursor":{"row":4,"col":14},"anchor":{"row":4,"col":14}},{"cursor":{"row":4,"col":16},"anchor":{"row":4,"col":16}},{"cursor":{"row":4,"col":18},"anchor":{"row":4,"col":18}},{"cursor":{"row":4,"col":20},"anchor":{"row":4,"col":20}},{"cursor":{"row":4,"col":22},"anchor":{"row":4,"col":22}},{"cursor":{"row":4,"col":24},"anchor":{"row":4,"col":24}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":28},"anchor":{"row":4,"col":28}},{"cursor":{"row":4,"col":30},"anchor":{"row":4,"col":30}},{"cursor":{"row":4,"col":32},"anchor":{"row":4,"col":32}},{"cursor":{"row":4,"col":34},"anchor":{"row":4,"col":34}},{"cursor":{"row":4,"col":36},"anchor":{"row":4,"col":36}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":2}},{"cursor":{"row":5,"col":4},"anchor":{"row":5,"col":4}},{"cursor":{"row":5,"col":6},"anchor":{"row":5,"col":6}},{"cursor":{"row":5,"col":8},"anchor":{"row":5,"col":8}},{"cursor":{"row":5,"col":10},"anchor":{"row":5,"col":10}},{"cursor":{"row":5,"col":12},"anchor":{"row":5,"col":12}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":14}},{"cursor":{"row":5,"col":16},"anchor":{"row":5,"col":16}},{"cursor":{"row":5,"col":18},"anchor":{"row":5,"col":18}},{"cursor":{"row":5,"col":20},"anchor":{"row":5,"col":20}},{"cursor":{"row":5,"col":22},"anchor":{"row":5,"col":22}},{"cursor":{"row":5,"col":24},"anchor":{"row":5,"col":24}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":26}},{"cursor":{"row":5,"col":28},"anchor":{"row":5,"col":28}},{"cursor":{"row":5,"col":30},"anchor":{"row":5,"col":30}},{"cursor":{"row":5,"col":32},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":34},"anchor":{"row":5,"col":34}},{"cursor":{"row":5,"col":36},"anchor":{"row":5,"col":36}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":38}},{"cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":2}},{"cursor":{"row":6,"col":4},"anchor":{"row":6,"col":4}},{"cursor":{"row":6,"col":6},"anchor":{"row":6,"col":6}},{"cursor":{"row":6,"col":8},"anchor":{"row":6,"col":8}},{"cursor":{"row":6,"col":10},"anchor":{"row":6,"col":10}},{"cursor":{"row":6,"col":12},"anchor":{"row":6,"col":12}},{"cursor":{"row":6,"col":14},"anchor":{"row":6,"col":14}},{"cursor":{"row":6,"col":16},"anchor":{"row":6,"col":16}},{"cursor":{"row":6,"col":18},"anchor":{"row":6,"col":18}},{"cursor":{"row":6,"col":20},"anchor":{"row":6,"col":20}},{"cursor":{"row":6,"col":22},"anchor":{"row":6,"col":22}},{"cursor":{"row":6,"col":24},"anchor":{"row":6,"col":24}},{"cursor":{"row":6,"col":26},"anchor":{"row":6,"col":26}},{"cursor":{"row":6,"col":28},"anchor":{"row":6,"col":28}},{"cursor":{"row":6,"col":30},"anchor":{"row":6,"col":30}},{"cursor":{"row":6,"col":32},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":34},"anchor":{"row":6,"col":34}},{"cursor":{"row":6,"col":36},"anchor":{"row":6,"col":36}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":38}},{"cursor":{"row":7,"col":0},"anchor":{"row":7,"col":0}},{"cursor":{"row":7,"col":2},"anchor":{"row":7,"col":2}},{"cursor":{"row":7,"col":4},"anchor":{"row":7,"col":4}},{"cursor":{"row":7,"col":6},"anchor":{"row":7,"col":6}},{"cursor":{"row":7,"col":8},"anchor":{"row":7,"col":8}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":7,"col":12},"anchor":{"row":7,"col":12}},{"cursor":{"row":7,"col":14},"anchor":{"row":7,"col":14}},{"cursor":{"row":7,"col":16},"anchor":{"row":7,"col":16}},{"cursor":{"row":7,"col":18},"anchor":{"row":7,"col":18}},{"cursor":{"row":7,"col":20},"anchor":{"row":7,"col":20}},{"cursor":{"row":7,"col":22},"anchor":{"row":7,"col":22}},{"cursor":{"row":7,"col":24},"anchor":{"row":7,"col":24}},{"cursor":{"row":7,"col":26},"anchor":{"row":7,"col":26}},{"cursor":{"row":7,"col":28},"anchor":{"row":7,"col":28}},{"cursor":{"row":7,"col":30},"anchor":{"row":7,"col":30}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":34},"anchor":{"row":7,"col":34}},{"cursor":{"row":7,"col":36},"anchor":{"row":7,"col":36}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":38}},{"cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}},{"cursor":{"row":8,"col":2},"anchor":{"row":8,"col":2}},{"cursor":{"row":8,"col":4},"anchor":{"row":8,"col":4}},{"cursor":{"row":8,"col":6},"anchor":{"row":8,"col":6}},{"cursor":{"row":8,"col":8},"anchor":{"row":8,"col":8}},{"cursor":{"row":8,"col":10},"anchor":{"row":8,"col":10}},{"cursor":{"row":8,"col":12},"anchor":{"row":8,"col":12}},{"cursor":{"row":8,"col":14},"anchor":{"row":8,"col":14}},{"cursor":{"row":8,"col":16},"anchor":{"row":8,"col":16}},{"cursor":{"row":8,"col":18},"anchor":{"row":8,"col":18}},{"cursor":{"row":8,"col":20},"anchor":{"row":8,"col":20}},{"cursor":{"row":8,"col":22},"anchor":{"row":8,"col":22}},{"cursor":{"row":8,"col":24},"anchor":{"row":8,"col":24}},{"cursor":{"row":8,"col":26},"anchor":{"row":8,"col":26}},{"cursor":{"row":8,"col":28},"anchor":{"row":8,"col":28}},{"cursor":{"row":8,"col":30},"anchor":{"row":8,"col":30}},{"cursor":{"row":8,"col":32},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":34},"anchor":{"row":8,"col":34}},{"cursor":{"row":8,"col":36},"anchor":{"row":8,"col":36}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":38}},{"cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}},{"cursor":{"row":9,"col":2},"anchor":{"row":9,"col":2}},{"cursor":{"row":9,"col":4},"anchor":{"row":9,"col":4}},{"cursor":{"row":9,"col":6},"anchor":{"row":9,"col":6}},{"cursor":{"row":9,"col":8},"anchor":{"row":9,"col":8}},{"cursor":{"row":9,"col":10},"anchor":{"row":9,"col":10}},{"cursor":{"row":9,"col":12},"anchor":{"row":9,"col":12}},{"cursor":{"row":9,"col":14},"anchor":{"row":9,"col":14}},{"cursor":{"row":9,"col":16},"anchor":{"row":9,"col":16}},{"cursor":{"row":9,"col":18},"anchor":{"row":9,"col":18}},{"cursor":{"row":9,"col":20},"anchor":{"row":9,"col":20}},{"cursor":{"row":9,"col":22},"anchor":{"row":9,"col":22}},{"cursor":{"row":9,"col":24},"anchor":{"row":9,"col":24}},{"cursor":{"row":9,"col":26},"anchor":{"row":9,"col":26}},{"cursor":{"row":9,"col":28},"anchor":{"row":9,"col":28}},{"cursor":{"row":9,"col":30},"anchor":{"row":9,"col":30}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":34},"anchor":{"row":9,"col":34}},{"cursor":{"row":9,"col":36},"anchor":{"row":9,"col":36}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":38}}],"primary":0} {"name":"sel-many-ranges-edit","text":"xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":14}},{"cursor":{"row":0,"col":17},"anchor":{"row":0,"col":17}},{"cursor":{"row":0,"col":20},"anchor":{"row":0,"col":20}},{"cursor":{"row":0,"col":23},"anchor":{"row":0,"col":23}},{"cursor":{"row":0,"col":26},"anchor":{"row":0,"col":26}},{"cursor":{"row":0,"col":29},"anchor":{"row":0,"col":29}},{"cursor":{"row":0,"col":32},"anchor":{"row":0,"col":32}},{"cursor":{"row":0,"col":35},"anchor":{"row":0,"col":35}},{"cursor":{"row":0,"col":38},"anchor":{"row":0,"col":38}},{"cursor":{"row":0,"col":41},"anchor":{"row":0,"col":41}},{"cursor":{"row":0,"col":44},"anchor":{"row":0,"col":44}},{"cursor":{"row":0,"col":47},"anchor":{"row":0,"col":47}},{"cursor":{"row":0,"col":50},"anchor":{"row":0,"col":50}},{"cursor":{"row":0,"col":53},"anchor":{"row":0,"col":53}},{"cursor":{"row":0,"col":56},"anchor":{"row":0,"col":56}},{"cursor":{"row":0,"col":59},"anchor":{"row":0,"col":59}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":8}},{"cursor":{"row":1,"col":11},"anchor":{"row":1,"col":11}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":14}},{"cursor":{"row":1,"col":17},"anchor":{"row":1,"col":17}},{"cursor":{"row":1,"col":20},"anchor":{"row":1,"col":20}},{"cursor":{"row":1,"col":23},"anchor":{"row":1,"col":23}},{"cursor":{"row":1,"col":26},"anchor":{"row":1,"col":26}},{"cursor":{"row":1,"col":29},"anchor":{"row":1,"col":29}},{"cursor":{"row":1,"col":32},"anchor":{"row":1,"col":32}},{"cursor":{"row":1,"col":35},"anchor":{"row":1,"col":35}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":38}},{"cursor":{"row":1,"col":41},"anchor":{"row":1,"col":41}},{"cursor":{"row":1,"col":44},"anchor":{"row":1,"col":44}},{"cursor":{"row":1,"col":47},"anchor":{"row":1,"col":47}},{"cursor":{"row":1,"col":50},"anchor":{"row":1,"col":50}},{"cursor":{"row":1,"col":53},"anchor":{"row":1,"col":53}},{"cursor":{"row":1,"col":56},"anchor":{"row":1,"col":56}},{"cursor":{"row":1,"col":59},"anchor":{"row":1,"col":59}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}},{"cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}},{"cursor":{"row":2,"col":8},"anchor":{"row":2,"col":8}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":17},"anchor":{"row":2,"col":17}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":2,"col":23},"anchor":{"row":2,"col":23}},{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":26}},{"cursor":{"row":2,"col":29},"anchor":{"row":2,"col":29}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":35},"anchor":{"row":2,"col":35}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":38}},{"cursor":{"row":2,"col":41},"anchor":{"row":2,"col":41}},{"cursor":{"row":2,"col":44},"anchor":{"row":2,"col":44}},{"cursor":{"row":2,"col":47},"anchor":{"row":2,"col":47}},{"cursor":{"row":2,"col":50},"anchor":{"row":2,"col":50}},{"cursor":{"row":2,"col":53},"anchor":{"row":2,"col":53}},{"cursor":{"row":2,"col":56},"anchor":{"row":2,"col":56}},{"cursor":{"row":2,"col":59},"anchor":{"row":2,"col":59}},{"cursor":{"row":3,"col":2},"anchor":{"row":3,"col":2}},{"cursor":{"row":3,"col":5},"anchor":{"row":3,"col":5}},{"cursor":{"row":3,"col":8},"anchor":{"row":3,"col":8}},{"cursor":{"row":3,"col":11},"anchor":{"row":3,"col":11}},{"cursor":{"row":3,"col":14},"anchor":{"row":3,"col":14}},{"cursor":{"row":3,"col":17},"anchor":{"row":3,"col":17}},{"cursor":{"row":3,"col":20},"anchor":{"row":3,"col":20}},{"cursor":{"row":3,"col":23},"anchor":{"row":3,"col":23}},{"cursor":{"row":3,"col":26},"anchor":{"row":3,"col":26}},{"cursor":{"row":3,"col":29},"anchor":{"row":3,"col":29}},{"cursor":{"row":3,"col":32},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":35},"anchor":{"row":3,"col":35}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":38}},{"cursor":{"row":3,"col":41},"anchor":{"row":3,"col":41}},{"cursor":{"row":3,"col":44},"anchor":{"row":3,"col":44}},{"cursor":{"row":3,"col":47},"anchor":{"row":3,"col":47}},{"cursor":{"row":3,"col":50},"anchor":{"row":3,"col":50}},{"cursor":{"row":3,"col":53},"anchor":{"row":3,"col":53}},{"cursor":{"row":3,"col":56},"anchor":{"row":3,"col":56}},{"cursor":{"row":3,"col":59},"anchor":{"row":3,"col":59}},{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":2}},{"cursor":{"row":4,"col":5},"anchor":{"row":4,"col":5}},{"cursor":{"row":4,"col":8},"anchor":{"row":4,"col":8}},{"cursor":{"row":4,"col":11},"anchor":{"row":4,"col":11}},{"cursor":{"row":4,"col":14},"anchor":{"row":4,"col":14}},{"cursor":{"row":4,"col":17},"anchor":{"row":4,"col":17}},{"cursor":{"row":4,"col":20},"anchor":{"row":4,"col":20}},{"cursor":{"row":4,"col":23},"anchor":{"row":4,"col":23}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":29},"anchor":{"row":4,"col":29}},{"cursor":{"row":4,"col":32},"anchor":{"row":4,"col":32}},{"cursor":{"row":4,"col":35},"anchor":{"row":4,"col":35}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":4,"col":41},"anchor":{"row":4,"col":41}},{"cursor":{"row":4,"col":44},"anchor":{"row":4,"col":44}},{"cursor":{"row":4,"col":47},"anchor":{"row":4,"col":47}},{"cursor":{"row":4,"col":50},"anchor":{"row":4,"col":50}},{"cursor":{"row":4,"col":53},"anchor":{"row":4,"col":53}},{"cursor":{"row":4,"col":56},"anchor":{"row":4,"col":56}},{"cursor":{"row":4,"col":59},"anchor":{"row":4,"col":59}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":2}},{"cursor":{"row":5,"col":5},"anchor":{"row":5,"col":5}},{"cursor":{"row":5,"col":8},"anchor":{"row":5,"col":8}},{"cursor":{"row":5,"col":11},"anchor":{"row":5,"col":11}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":14}},{"cursor":{"row":5,"col":17},"anchor":{"row":5,"col":17}},{"cursor":{"row":5,"col":20},"anchor":{"row":5,"col":20}},{"cursor":{"row":5,"col":23},"anchor":{"row":5,"col":23}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":26}},{"cursor":{"row":5,"col":29},"anchor":{"row":5,"col":29}},{"cursor":{"row":5,"col":32},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":35},"anchor":{"row":5,"col":35}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":38}},{"cursor":{"row":5,"col":41},"anchor":{"row":5,"col":41}},{"cursor":{"row":5,"col":44},"anchor":{"row":5,"col":44}},{"cursor":{"row":5,"col":47},"anchor":{"row":5,"col":47}},{"cursor":{"row":5,"col":50},"anchor":{"row":5,"col":50}},{"cursor":{"row":5,"col":53},"anchor":{"row":5,"col":53}},{"cursor":{"row":5,"col":56},"anchor":{"row":5,"col":56}},{"cursor":{"row":5,"col":59},"anchor":{"row":5,"col":59}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":2}},{"cursor":{"row":6,"col":5},"anchor":{"row":6,"col":5}},{"cursor":{"row":6,"col":8},"anchor":{"row":6,"col":8}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":11}},{"cursor":{"row":6,"col":14},"anchor":{"row":6,"col":14}},{"cursor":{"row":6,"col":17},"anchor":{"row":6,"col":17}},{"cursor":{"row":6,"col":20},"anchor":{"row":6,"col":20}},{"cursor":{"row":6,"col":23},"anchor":{"row":6,"col":23}},{"cursor":{"row":6,"col":26},"anchor":{"row":6,"col":26}},{"cursor":{"row":6,"col":29},"anchor":{"row":6,"col":29}},{"cursor":{"row":6,"col":32},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":35},"anchor":{"row":6,"col":35}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":38}},{"cursor":{"row":6,"col":41},"anchor":{"row":6,"col":41}},{"cursor":{"row":6,"col":44},"anchor":{"row":6,"col":44}},{"cursor":{"row":6,"col":47},"anchor":{"row":6,"col":47}},{"cursor":{"row":6,"col":50},"anchor":{"row":6,"col":50}},{"cursor":{"row":6,"col":53},"anchor":{"row":6,"col":53}},{"cursor":{"row":6,"col":56},"anchor":{"row":6,"col":56}},{"cursor":{"row":6,"col":59},"anchor":{"row":6,"col":59}},{"cursor":{"row":7,"col":2},"anchor":{"row":7,"col":2}},{"cursor":{"row":7,"col":5},"anchor":{"row":7,"col":5}},{"cursor":{"row":7,"col":8},"anchor":{"row":7,"col":8}},{"cursor":{"row":7,"col":11},"anchor":{"row":7,"col":11}},{"cursor":{"row":7,"col":14},"anchor":{"row":7,"col":14}},{"cursor":{"row":7,"col":17},"anchor":{"row":7,"col":17}},{"cursor":{"row":7,"col":20},"anchor":{"row":7,"col":20}},{"cursor":{"row":7,"col":23},"anchor":{"row":7,"col":23}},{"cursor":{"row":7,"col":26},"anchor":{"row":7,"col":26}},{"cursor":{"row":7,"col":29},"anchor":{"row":7,"col":29}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":35},"anchor":{"row":7,"col":35}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":38}},{"cursor":{"row":7,"col":41},"anchor":{"row":7,"col":41}},{"cursor":{"row":7,"col":44},"anchor":{"row":7,"col":44}},{"cursor":{"row":7,"col":47},"anchor":{"row":7,"col":47}},{"cursor":{"row":7,"col":50},"anchor":{"row":7,"col":50}},{"cursor":{"row":7,"col":53},"anchor":{"row":7,"col":53}},{"cursor":{"row":7,"col":56},"anchor":{"row":7,"col":56}},{"cursor":{"row":7,"col":59},"anchor":{"row":7,"col":59}},{"cursor":{"row":8,"col":2},"anchor":{"row":8,"col":2}},{"cursor":{"row":8,"col":5},"anchor":{"row":8,"col":5}},{"cursor":{"row":8,"col":8},"anchor":{"row":8,"col":8}},{"cursor":{"row":8,"col":11},"anchor":{"row":8,"col":11}},{"cursor":{"row":8,"col":14},"anchor":{"row":8,"col":14}},{"cursor":{"row":8,"col":17},"anchor":{"row":8,"col":17}},{"cursor":{"row":8,"col":20},"anchor":{"row":8,"col":20}},{"cursor":{"row":8,"col":23},"anchor":{"row":8,"col":23}},{"cursor":{"row":8,"col":26},"anchor":{"row":8,"col":26}},{"cursor":{"row":8,"col":29},"anchor":{"row":8,"col":29}},{"cursor":{"row":8,"col":32},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":35},"anchor":{"row":8,"col":35}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":38}},{"cursor":{"row":8,"col":41},"anchor":{"row":8,"col":41}},{"cursor":{"row":8,"col":44},"anchor":{"row":8,"col":44}},{"cursor":{"row":8,"col":47},"anchor":{"row":8,"col":47}},{"cursor":{"row":8,"col":50},"anchor":{"row":8,"col":50}},{"cursor":{"row":8,"col":53},"anchor":{"row":8,"col":53}},{"cursor":{"row":8,"col":56},"anchor":{"row":8,"col":56}},{"cursor":{"row":8,"col":59},"anchor":{"row":8,"col":59}},{"cursor":{"row":9,"col":2},"anchor":{"row":9,"col":2}},{"cursor":{"row":9,"col":5},"anchor":{"row":9,"col":5}},{"cursor":{"row":9,"col":8},"anchor":{"row":9,"col":8}},{"cursor":{"row":9,"col":11},"anchor":{"row":9,"col":11}},{"cursor":{"row":9,"col":14},"anchor":{"row":9,"col":14}},{"cursor":{"row":9,"col":17},"anchor":{"row":9,"col":17}},{"cursor":{"row":9,"col":20},"anchor":{"row":9,"col":20}},{"cursor":{"row":9,"col":23},"anchor":{"row":9,"col":23}},{"cursor":{"row":9,"col":26},"anchor":{"row":9,"col":26}},{"cursor":{"row":9,"col":29},"anchor":{"row":9,"col":29}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":35},"anchor":{"row":9,"col":35}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":38}},{"cursor":{"row":9,"col":41},"anchor":{"row":9,"col":41}},{"cursor":{"row":9,"col":44},"anchor":{"row":9,"col":44}},{"cursor":{"row":9,"col":47},"anchor":{"row":9,"col":47}},{"cursor":{"row":9,"col":50},"anchor":{"row":9,"col":50}},{"cursor":{"row":9,"col":53},"anchor":{"row":9,"col":53}},{"cursor":{"row":9,"col":56},"anchor":{"row":9,"col":56}},{"cursor":{"row":9,"col":59},"anchor":{"row":9,"col":59}}],"primary":0} +{"name":"keep-regex","text":"foo bar baz\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":8}}],"primary":0} +{"name":"remove-regex","text":"foo bar baz\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"keep-regex-none","text":"foo bar\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4}}],"primary":0} +{"name":"keep-regex-primary","text":"a1 b2 a3\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":6}}],"primary":0} +{"name":"keep-regex-esc","text":"foo bar\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4}}],"primary":0} +{"name":"keep-regex-lines","text":"foo\nbar\nbaz\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"ensure-forward","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0} +{"name":"ensure-forward-one","text":"abc\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3 From e87e16547a17864b2a0cd574c1aa42d44125bc6c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:35:27 -0300 Subject: Alt-o, Alt-i and the other tree-sitter keys walk the file's syntax tree helix's expand/shrink, previous/next sibling, all siblings, all children and parent-node end/start (Alt-o/up, Alt-i/down, Alt-p/left, Alt-right, Alt-a, Alt-I, Alt-e, Alt-b; Alt-n stays the new terminal) walk a whole-file parse the file keeps (File.node_tree), made on the first such key after an edit and reused until the next, never per key. syntax.walkNodes follows helix's TreeCursor over every node, anonymous ones included, and Alt-i goes back to what Alt-o grew from while the selection still holds it. The harness runs without grammars, so the cases prove the plain-text no-op; a test replays eleven JSON walks whose results were taken from the installed hx. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 4 +- src/File.zig | 13 +++++ src/config.zig | 10 ++++ src/modal.zig | 20 ++++++++ src/normal.zig | 124 +++++++++++++++++++++++++++++++++++++++++++++ src/syntax.zig | 90 ++++++++++++++++++++++++++++++++ test/hxcases/cases.jsonl | 1 + test/hxcases/goldens.jsonl | 1 + 8 files changed, 261 insertions(+), 2 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 5c3972a5..f2901b92 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -127,7 +127,7 @@ language-backend queries, and the shell pipe. | `:` (normal, body or tag) | in the body, focuses the pane's tag in normal mode at its remembered cursor (the first time, on `Save`); in the tag, goes back to the body; in a column or workspace tag, back to the active pane. The tag's normal and insert modes ARE the body's: every motion, selection, edit and undo works there, and `0` goes to the line's start, the path's. Tab runs the word under the cursor or the selection and Enter looks it up (in a column or workspace tag Enter runs it too), and either hands the keyboard back to the body first. Clicks choose a new cursor position and type into the tag. | Each tag keeps its own cursor during the session. The computed path/marker/page is reachable and yankable but read-only: an edit into it is refused, and typing into a file's path drafts a new name. File-name changes are staged as described in [editable tags](tags.md). | pardes-specific | | `Ctrl-w k` / `SPC w k` (pane with nothing above) | focuses its column's tag, then the workspace tag; with `ColumnTags` disabled it goes directly to the workspace. `Ctrl-w j` walks back to the panes, `Ctrl-w h`/`l` walk the column tags. Headers edit exactly as a pane tag does. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific | | `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — editor normal mode only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; Raw **tty** mode forwards Ctrl-w to the child. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode it moves focus to the neighbour's BODY, as from a body, and from the top pane `Up` reaches the column and workspace tags | pardes-specific | -| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific | +| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` (select next sibling), which pardes spells `Alt-right` alone | 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 ` ?` 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 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 | @@ -213,6 +213,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `;` | 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) | | `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified | +| `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | walk the file's syntax tree from every range: grow to the enclosing node (and remember what it grew from), shrink back to that or to the first child, the previous / next sibling (anonymous ones included, so often a comma), every named sibling or child, the enclosing named node's end / start (stretching to it in select mode) | helix `expand_selection` and kin, on a whole-file parse (`File.node_tree`) made on the first such key after an edit and reused until the next; a range the tree says nothing about, and every range of a file without a grammar, stays. Checked against the installed hx on JSON (`normal.zig` test): the harness runs without grammars, so it only proves the plain-text no-op | helix-verified (plain text), hx-checked (JSON) | | `%` | 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) | @@ -352,7 +353,6 @@ text. | `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 | | 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 | diff --git a/src/File.zig b/src/File.zig index 8ba58fbc..2c4f0aec 100644 --- a/src/File.zig +++ b/src/File.zig @@ -69,9 +69,19 @@ pub const State = struct { tree_context: bool = false, context_declarations: []syntax.ContextDeclaration = &.{}, context_revision: ?u32 = null, + /// The whole file's parse for the keys that walk its nodes (Alt-o and + /// kin), made on the first of them after an edit and kept until the next + /// one: `syntax.parseTree`'s, opaque here. + node_tree: ?*anyopaque = null, + node_tree_revision: u32 = 0, + /// helix's object_selections: what Alt-o grew from, for Alt-i to go back + /// to; gpa-owned ranges and the primary's index. + node_history: std.ArrayList(NodeStep) = .empty, history: *History, }; +pub const NodeStep = struct { ranges: []modal.Selection, primary: usize }; + pub fn dumpPane( arena: std.mem.Allocator, pane: *const Pane, @@ -816,6 +826,9 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void { if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights); locations.freeRows(p.gpa, file.location_rows); if (file.context_declarations.len > 0) p.tree_sitter_gpa.free(file.context_declarations); + if (file.node_tree) |tree| syntax.freeTree(tree); + for (file.node_history.items) |step| p.gpa.free(step.ranges); + file.node_history.deinit(p.gpa); for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content); for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content); p.gpa.destroy(file.history); diff --git a/src/config.zig b/src/config.zig index 8c5c9771..959f4c4a 100644 --- a/src/config.zig +++ b/src/config.zig @@ -537,6 +537,16 @@ pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; pub const keep_regex: []const Chord = &.{.{ .cp = 'K' }}; pub const remove_regex: []const Chord = &.{.{ .cp = 'K', .alt = true }}; pub const ensure_forward: []const Chord = &.{.{ .cp = ':', .alt = true }}; +// helix's tree-sitter selection; Alt-n stays the new terminal, so the next +// sibling is Alt-right alone +pub const expand_node: []const Chord = &.{ .{ .cp = 'o', .alt = true }, .{ .cp = Key.up, .alt = true } }; +pub const shrink_node: []const Chord = &.{ .{ .cp = 'i', .alt = true }, .{ .cp = Key.down, .alt = true } }; +pub const prev_sibling: []const Chord = &.{ .{ .cp = 'p', .alt = true }, .{ .cp = Key.left, .alt = true } }; +pub const next_sibling: []const Chord = &.{.{ .cp = Key.right, .alt = true }}; +pub const all_siblings: []const Chord = &.{.{ .cp = 'a', .alt = true }}; +pub const all_children: []const Chord = &.{.{ .cp = 'I', .alt = true }}; +pub const parent_node_end: []const Chord = &.{.{ .cp = 'e', .alt = true }}; +pub const parent_node_start: []const Chord = &.{.{ .cp = 'b', .alt = true }}; pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; diff --git a/src/modal.zig b/src/modal.zig index f5f3081e..ae008f60 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -100,6 +100,14 @@ pub const Normal = struct { keep_regex, remove_regex, ensure_forward, + expand_node, + shrink_node, + next_sibling, + prev_sibling, + all_siblings, + all_children, + parent_node_end, + parent_node_start, delete, delete_noyank, @@ -213,6 +221,8 @@ pub const Normal = struct { } }; + /// helix's tree-sitter selection keys: where a range goes in the tree + pub const NodeWalk = enum { expand, shrink, next_sibling, prev_sibling, all_siblings, all_children, parent_end, parent_start }; /// what the s/S/K/Alt-K prompt does with its pattern's matches pub const RegexMode = enum { select, split, keep, remove }; pub const Scope = enum { once, per_selection }; @@ -331,6 +341,7 @@ pub const Normal = struct { search_step: Direction, macro_record, macro_replay: u32, + node: NodeWalk, repeat_insert: u32, pub fn scope(value: Action) Scope { @@ -345,6 +356,7 @@ pub const Normal = struct { .search_step, .macro_record, .macro_replay, + .node, .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { @@ -588,6 +600,14 @@ pub const Normal = struct { if (key.has(.keep_regex)) return resultAction(.{ .select_regex = .keep }); if (key.has(.remove_regex)) return resultAction(.{ .select_regex = .remove }); if (key.has(.ensure_forward)) return resultAction(.{ .multi = .{ .kind = .ensure_forward, .count = count } }); + if (key.has(.expand_node)) return resultAction(.{ .node = .expand }); + if (key.has(.shrink_node)) return resultAction(.{ .node = .shrink }); + if (key.has(.next_sibling)) return resultAction(.{ .node = .next_sibling }); + if (key.has(.prev_sibling)) return resultAction(.{ .node = .prev_sibling }); + if (key.has(.all_siblings)) return resultAction(.{ .node = .all_siblings }); + if (key.has(.all_children)) return resultAction(.{ .node = .all_children }); + if (key.has(.parent_node_end)) return resultAction(.{ .node = .parent_end }); + if (key.has(.parent_node_start)) return resultAction(.{ .node = .parent_start }); if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); diff --git a/src/normal.zig b/src/normal.zig index d6c646ef..bc450581 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -7,6 +7,7 @@ const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); const regexp = @import("regexp.zig"); +const syntax = @import("syntax.zig"); const modal = @import("modal.zig"); const panes = @import("panes.zig"); const edit = @import("edit.zig"); @@ -166,6 +167,83 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: m t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } +/// helix's tree-sitter selection keys: every range walks the file's parse, +/// which is made on the first of these keys after an edit and kept, never +/// made again for a key that changed nothing. A range the tree has nothing +/// to say about stays as it was; a text without a grammar, all of them. +fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + if (f.node_tree == null or f.node_tree_revision != f.revision) { + if (f.node_tree) |old| syntax.freeTree(old); + f.node_tree = syntax.parseTree(f.path, f.content); + f.node_tree_revision = f.revision; + for (f.node_history.items) |step| p.gpa.free(step.ranges); + f.node_history.clearRetainingCapacity(); + } + const tree = f.node_tree orelse return; + const text = f.content; + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + const arena = p.scratch.allocator(); + const rs = arena.alloc(modal.Selection, Text.max_selections) catch return; + const got = t.ranges(text, 0, rs[0..Text.max_selections]); + if (walk == .shrink) if (f.node_history.pop()) |prev| { + defer p.gpa.free(prev.ranges); + // back to what Alt-o grew from, while the selection still holds it + const holds = for (prev.ranges) |pr| { + const pf = @min(pr.anchor, pr.head); + const pt = @max(pr.anchor, pr.head); + if (for (rs[0..got.n]) |r| { + if (@min(r.anchor, r.head) <= pf and pt <= @max(r.anchor, r.head)) break true; + } else false) continue; + break false; + } else true; + if (holds) return t.setRanges(text, prev.ranges, &.{}, prev.primary, expl); + for (f.node_history.items) |step| p.gpa.free(step.ranges); + f.node_history.clearRetainingCapacity(); + }; + const out = arena.alloc(modal.Selection, Text.max_selections) catch return; + const spans = arena.alloc(syntax.Span, Text.max_selections) catch return; + var m: usize = 0; + for (rs[0..got.n]) |r| { + if (m == out.len) break; + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + const back = r.head < r.anchor; + const n = syntax.walkNodes(tree, walk, from, to, modal.selectionCursor(text, r), spans[0 .. out.len - m]); + if (n == 0) { + out[m] = r; + m += 1; + continue; + } + for (spans[0..n]) |s| { + out[m] = switch (walk) { + .next_sibling => .{ .anchor = s.from, .head = s.to }, + .prev_sibling => .{ .anchor = s.to, .head = s.from }, + // helix move_parent_node_end: a one-character range on the + // bound, or stretched to it in select mode + .parent_end, .parent_start => bound: { + const head = s.from; + if (t.select) { + const anchor = r.anchor; + break :bound .{ .anchor = anchor, .head = if (head >= anchor) modal.nextGrapheme(text, head) else head }; + } + const next = modal.nextGrapheme(text, head); + break :bound if (back) .{ .anchor = next, .head = head } else .{ .anchor = head, .head = next }; + }, + else => if (back) .{ .anchor = s.to, .head = s.from } else .{ .anchor = s.from, .head = s.to }, + }; + m += 1; + } + } + const same = m == got.n and std.mem.eql(u8, std.mem.sliceAsBytes(out[0..m]), std.mem.sliceAsBytes(rs[0..m])); + if (walk == .expand and !same) remember: { + const ranges = p.gpa.dupe(modal.Selection, rs[0..got.n]) catch break :remember; + f.node_history.append(p.gpa, .{ .ranges = ranges, .primary = got.pri }) catch p.gpa.free(ranges); + } + t.setRanges(text, out[0..m], &.{}, @min(got.pri, m - 1), expl); +} + const KeyState = struct { mode: Text.Mode, select: bool, @@ -613,6 +691,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void p, if (direction == .forward) @as(i32, 1) else -1, ), + .node => |walk| return nodeSelect(p, pane, t, walk), .macro_record => { const m = &p.macro; if (m.recording == 0) { @@ -840,3 +919,48 @@ test "s and S search a multi-line selection as addr does: ^ and $ per line, . wi } } } + +test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { + if (!syntax.supportsPath("a.json")) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const text = "{\"a\": [1, 2, 3], \"b\": true}\n"; + // each checked against the installed hx through a pty: the keys, then + // the text it saved + const Case = struct { keys: []const u8, want: []const u8 }; + for ([_]Case{ + .{ .keys = "10l\x1bo" ++ "d", .want = "{\"a\": , \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bod", .want = "{, \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bo\x1bod", .want = "\n" }, + .{ .keys = "10l\x1bo\x1bIix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bo\x1b>d", .want = "{\"a\": [1, 2, 3] \"b\": true}\n" }, + .{ .keys = "10l\x1bpd", .want = "{\"a\": [1 2, 3], \"b\": true}\n" }, + .{ .keys = "10l\x1baix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bo\x1bid", .want = "{\"a\": , \"b\": true}\n" }, + .{ .keys = "6lx\x1bid", .want = "\n" }, + .{ .keys = "10l\x1beiX\x1b", .want = "{\"a\": [1, 2X, 3], \"b\": true}\n" }, + .{ .keys = "10l\x1bb\x1bbiX\x1b", .want = "{X\"a\": [1, 2, 3], \"b\": true}\n" }, + }) |c| { + const pane = try p.setTestFile(text); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, "/test.json"); + // ESC before a key is Alt on it; a lone ESC is Esc; `>` is Alt-right + var i: usize = 0; + while (i < c.keys.len) : (i += 1) { + const ch = c.keys[i]; + if (ch == 0x1b and i + 1 < c.keys.len) { + i += 1; + const k = c.keys[i]; + p.update(.{ .key = if (k == '>') .{ .cp = Key.right, .alt = true } else .{ .cp = k, .alt = true } }); + } else if (ch == 0x1b) { + p.update(.{ .key = .{ .cp = Key.escape } }); + } else p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); + while (p.nextEffect()) |_| {} + } + std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| { + std.debug.print("keys {s}\n", .{c.keys}); + return err; + }; + } +} diff --git a/src/syntax.zig b/src/syntax.zig index 9c1a4f4b..89fc4198 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -337,6 +337,96 @@ pub fn supportsPath(path: []const u8) bool { return false; } +/// A whole file's parse, kept by the file for the keys that walk its nodes +/// (helix's Alt-o and kin). Opaque outside this file; null without a grammar. +pub fn parseTree(path: []const u8, content: []const u8) ?*anyopaque { + if (!enabled) return null; + const selected = (forExt(std.fs.path.extension(path)) catch return null) orelse return null; + const tree = selected.parser.parseString(content, null) orelse return null; + return @ptrCast(tree); +} + +pub fn freeTree(tree: *anyopaque) void { + if (!enabled) return; + const t: *ts.Tree = @ptrCast(@alignCast(tree)); + t.destroy(); +} + +/// Where a node walk goes from a range (helix object.rs and movement.rs). +pub const NodeWalk = @import("modal.zig").Normal.NodeWalk; +pub const Span = struct { from: usize, to: usize }; + +/// The spans `walk` takes the byte range [from, to) to, into `out`; `at` is +/// the range's cursor. None when the tree has nothing to say there. +pub fn walkNodes(tree_ptr: *anyopaque, walk: NodeWalk, from: usize, to: usize, at: usize, out: []Span) usize { + if (!enabled or out.len == 0) return 0; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const lo: u32 = @intCast(@min(from, std.math.maxInt(u32))); + const hi: u32 = @intCast(@min(to, std.math.maxInt(u32))); + if (walk == .parent_end or walk == .parent_start) { + var node = tree.rootNode().namedDescendantForByteRange(lo, hi) orelse return 0; + if (walk == .parent_end) { + out[0] = .{ .from = node.endByte(), .to = node.endByte() }; + return 1; + } + // already at the node's lo: its first ancestor that starts earlier + if (node.startByte() == at) { + const first = node.startByte(); + while (node.startByte() >= first or !node.isNamed()) node = node.parent() orelse break; + } + out[0] = .{ .from = node.startByte(), .to = node.startByte() }; + return 1; + } + var cursor = tree.walk(); + defer cursor.destroy(); + // helix TreeCursor.reset_to_byte_range: the smallest node holding the range + while (true) { + const node = cursor.node(); + if (lo < node.startByte() or hi > node.endByte()) { + _ = cursor.gotoParent(); + break; + } + if (cursor.gotoFirstChildForByte(lo) == null) break; + } + switch (walk) { + .expand => while (cursor.node().startByte() == lo and cursor.node().endByte() == hi) { + if (!cursor.gotoParent()) break; + }, + .shrink => _ = cursor.gotoFirstChild(), + .next_sibling => while (!cursor.gotoNextSibling()) { + if (!cursor.gotoParent()) break; + }, + .prev_sibling => while (!cursor.gotoPreviousSibling()) { + if (!cursor.gotoParent()) break; + }, + .all_siblings, .all_children => { + if (walk == .all_siblings) { + while (true) { + if (!cursor.gotoParent()) return 0; + if (cursor.node().childCount() > 1) break; + } + } + // the named children, or nothing to say + var n: usize = 0; + if (!cursor.gotoFirstChild()) return 0; + while (true) { + const child = cursor.node(); + if (child.isNamed() and n < out.len) { + out[n] = .{ .from = child.startByte(), .to = child.endByte() }; + n += 1; + } + if (!cursor.gotoNextSibling()) break; + } + return n; + }, + .parent_end, .parent_start => unreachable, + } + const node = cursor.node(); + out[0] = .{ .from = node.startByte(), .to = node.endByte() }; + return 1; +} + + /// Owned source analysis. Both slices use the allocator passed to analyzeSource. /// The parsed tree is released before returning; callers can cache this result. pub const SourceAnalysis = struct { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 43cbbf11..8a8f8a94 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -440,6 +440,7 @@ {"name": "keep-regex-lines", "pane": "file", "text": "foo\nbar\nbaz\n", "keys": "%K^b"} {"name": "ensure-forward", "pane": "file", "text": "ab cd\n", "keys": "%s\\w+"} {"name": "ensure-forward-one", "pane": "file", "text": "abc\n", "keys": "vll"} +{"name": "node-keys-plain-text", "pane": "file", "text": "ab cd\n", "keys": "w"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 9839335a..f1df5c0a 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -440,6 +440,7 @@ {"name":"keep-regex-lines","text":"foo\nbar\nbaz\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}],"primary":0} {"name":"ensure-forward","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0} {"name":"ensure-forward-one","text":"abc\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"node-keys-plain-text","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3 From bdbab17204efdb785b6ab3cc2022484485c0ded2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:38:06 -0300 Subject: g. goes to where the last edit ended helix goto_last_modification: every range moves to the end of the last change (the start of a deletion, the end of an insertion), extending in select mode. The position is kept on the Text in surface rows, set where setEditText hands a body its new text, so a terminal's typed text has one too and moves with its scrollback. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/Terminal.zig | 6 +++++- src/Text.zig | 3 +++ src/config.zig | 1 + src/edit.zig | 7 +++++++ src/modal.zig | 3 +++ src/normal.zig | 4 ++++ test/hxcases/cases.jsonl | 5 +++++ test/hxcases/goldens.jsonl | 5 +++++ 9 files changed, 34 insertions(+), 2 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index f2901b92..2928d78e 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -184,6 +184,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `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|`, `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) | +| `g.` | go to where the last edit ended (a deletion's start, an insertion's end), every range there, extending in select mode | helix `goto_last_modification`. The position is `Text.last_edit`, set by `edit.setEditText` from where the old and new texts part; helix's is the last history revision's, so with nothing edited yet pardes stays put where the harness's helix goes to the end of its setup text | helix-verified | | `gt` / `gc` / `gb` | goto screen top / center / bottom | view-relative (`pane.scroll()` + `pane.rows`), column kept (clamped) | helix-verified (phase 5) | | `gj` / `gk` | VISUAL line down / up (+ count) | follows the wrapped body's own breaks (`file_pane.visualRow`, the same walk `fillBody` renders), keeping the goal column INSIDE the row; the last row of a line steps into the next line's first. Wrap off = one row per line, and this IS `j`/`k`. helix assigns the pair the other way round (its `j` is the visual one) | pardes-specific | | `PageUp` / `PageDown` | FULL page (was half) | `Ctrl-u`/`Ctrl-d` stay the half-page pair | helix-verified (phase 5) | @@ -351,7 +352,6 @@ text. | 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 | | `]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 | diff --git a/src/Terminal.zig b/src/Terminal.zig index 1ff561b1..4c29e151 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -720,7 +720,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { if (comptime !enabled) return; if (pane.terminal == null) return; const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or - pane.body.msel.active or pane.body.nsel > 0 or pane.look_at != null or + pane.body.msel.active or pane.body.nsel > 0 or pane.body.last_edit != null or pane.look_at != null or pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0; // Sticky bottom, as every other terminal does it: output drags the // viewport down only when the viewport was already on the last row, so @@ -846,6 +846,10 @@ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { extra[nextra + 1] = &selection.arow; nextra += 2; } + if (pane.body.last_edit) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } if (pane.look_at) |*at| { extra[nextra] = &at.row; nextra += 1; diff --git a/src/Text.zig b/src/Text.zig index 411c2ec8..70ec5369 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -69,6 +69,9 @@ sticky_col: i32 = -1, /// The insert session began with `a`: on Esc every range gives back the /// one character it was stretched by (helix restore_cursor). restore_cursor: bool = false, +/// Where the last edit ended, a deletion's start or an insertion's end, in +/// surface rows: for `g.` (helix last_edit_pos). +last_edit: ?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 = .{}, diff --git a/src/config.zig b/src/config.zig index 959f4c4a..993b6fe0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -487,6 +487,7 @@ pub const goto_column: []const Chord = &.{.{ .cp = '|' }}; pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; +pub const goto_last_modification: []const Chord = &.{.{ .cp = '.' }}; pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; diff --git a/src/edit.zig b/src/edit.zig index 5e0d20a4..9398674a 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -484,6 +484,13 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { return; } const pane = t.pane() orelse return retireEditText(p, new); + // what changed ended where the texts agree again from their ends + const old: []const u8, const row0: i32 = if (pane.file) |f| .{ f.content, 0 } else if (pane.ovl) |o| .{ o.text, o.row } else .{ "", 0 }; + const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len); + var back: usize = 0; + while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1; + const end = modal.positionAt(new, new.len - back); + t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) }; if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); } diff --git a/src/modal.zig b/src/modal.zig index ae008f60..0171da93 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -28,6 +28,7 @@ pub const Normal = struct { goto_view_top, goto_view_center, goto_view_bottom, + goto_last_modification, goto_definition, goto_declaration, goto_type_definition, @@ -251,6 +252,7 @@ pub const Normal = struct { view_top, view_center, view_bottom, + last_modification, }; pub const View = enum { top, center, bottom, scroll_down, scroll_up }; pub const Find = enum { forward, backward, till_forward, till_backward }; @@ -437,6 +439,7 @@ pub const Normal = struct { if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_last_modification)) return resultAction(.{ .goto = .{ .target = .last_modification, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); diff --git a/src/normal.zig b/src/normal.zig index bc450581..228c9c39 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -460,6 +460,10 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .view_top => return t.gotoWindow(text, range, .top, go.count), .view_center => return t.gotoWindow(text, range, .center, go.count), .view_bottom => return t.gotoWindow(text, range, .bottom, go.count), + .last_modification => { + const at = t.last_edit orelse return; + return t.pointMove(text, range, modal.offsetAt(text, .{ .row = @intCast(@max(0, at.row)), .col = @intCast(@max(0, at.col)) })); + }, }, .view => |view| if (t.what == .body) switch (view) { .top => { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 8a8f8a94..03b20ff5 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -441,6 +441,11 @@ {"name": "ensure-forward", "pane": "file", "text": "ab cd\n", "keys": "%s\\w+"} {"name": "ensure-forward-one", "pane": "file", "text": "abc\n", "keys": "vll"} {"name": "node-keys-plain-text", "pane": "file", "text": "ab cd\n", "keys": "w"} +{"name": "goto-last-mod", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jliXYgg"} +{"name": "goto-last-mod-go", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jliXYggg."} +{"name": "goto-last-mod-delete", "pane": "file", "text": "abc\ndef\n", "keys": "jlxdgg"} +{"name": "goto-last-mod-delete-go", "pane": "file", "text": "abc\ndef\n", "keys": "jldggg."} +{"name": "goto-last-mod-select", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jjiXggvg."} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index f1df5c0a..8e642073 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -441,6 +441,11 @@ {"name":"ensure-forward","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0} {"name":"ensure-forward-one","text":"abc\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} {"name":"node-keys-plain-text","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"goto-last-mod","text":"ab\ncXYd\nef\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"goto-last-mod-go","text":"ab\ncXYd\nef\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}} +{"name":"goto-last-mod-delete","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"goto-last-mod-delete-go","text":"abc\ndf\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"goto-last-mod-select","text":"ab\ncd\nXef\n","mode":"select","cursor":{"row":2,"col":1},"anchor":{"row":0,"col":0}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3 From 78daa25e5c99cc0d71da36b6e0dcdd362b0cb284 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:41:37 -0300 Subject: Tab after text leaves the syntax node in insert mode; Shift-Tab indents helix smart_tab: in insert mode, Tab where text precedes the cursor on its line moves past the end of the node the cursor is in, the parent-node end of Alt-e; in leading blanks it indents. Only where the file has a grammar: the reference pins smart tab off, which a file without a grammar agrees with, so there Tab still indents. Shift-Tab is insert_tab, and the differential harness now reads the S- modifier. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/edit.zig | 12 ++++++++++++ src/normal.zig | 6 +++++- test/hxcases/cases.jsonl | 2 ++ test/hxcases/goldens.jsonl | 2 ++ test/hxdiff.zig | 3 +++ 6 files changed, 25 insertions(+), 2 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index bb260802..c0ffe0d7 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -341,6 +341,7 @@ text. | `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` | indent to the next 4-column stop with SPACES (`insertTab`, `edit.zig`: `pad = INDENT_W - col % INDENT_W`) — no `\t` byte ever reaches the file | helix's Spaces indent style; helix smart-tab skipped. After a `.` in a file the backend speaks, Tab instead asks for completion and only indents if the answer is empty (section A) | helix-verified (phase 5) | +| insert `Tab` after text / `Shift-Tab` | in a file with a grammar, Tab after text on its line leaves the syntax node the cursor is in (to the enclosing named node's end); Shift-Tab always indents | helix `smart_tab` / `insert_tab`. The reference pins smart tab off, so a file without a grammar indents as the harness's helix does, where stock helix would do nothing there. Smart tab is checked against the installed hx on JSON (`normal.zig` test) | helix-verified (plain text), hx-checked (JSON) | | insert `Ctrl-s` | commit an undo checkpoint: `u` after the session goes back to the text as it was here, a second `u` to before the session | helix `commit_undo_checkpoint`; a snapshot pushed mid-session | helix-verified | ## C. Skipped @@ -361,7 +362,6 @@ text. | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | -| `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 | | 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` is the look-ring motion, which REPLACES the selection with the span it lands on) | diff --git a/src/edit.zig b/src/edit.zig index 39050b9d..01b34237 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -13,6 +13,7 @@ const body_layer = @import("body_layer.zig"); const config = @import("config.zig"); const pdf_enabled = panes.Pdf.enabled; const lsp = @import("lsp/lsp.zig"); +const syntax = @import("syntax.zig"); const platform = pardes.platform; const Pane = panes.Pane; const Text = panes.Text; @@ -929,6 +930,17 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { p.lspRequest(p.active, .completion, ""); if (p.lsp_seq != seq) return; } + // helix smart_tab: after text, Tab leaves the syntax node the + // cursor is in; only where the file has a grammar, since the + // reference pins smart tab off, which a file without one agrees + // with. Shift-Tab always types the tab. + const after_text = std.mem.indexOfNone(u8, ln[0..@min(c.col, ln.len)], " \t") != null; + if (!key.shift and after_text and t.what == .body) smart: { + const pane = t.pane() orelse break :smart; + const f = pane.file orelse break :smart; + if (!syntax.supportsPath(f.path)) break :smart; + return normal.nodeSelect(p, pane, t, .parent_end); + } insertTab(p, t); }, else => {}, diff --git a/src/normal.zig b/src/normal.zig index 228c9c39..f9d7cf25 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -171,7 +171,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: m /// which is made on the first of these keys after an edit and kept, never /// made again for a key that changed nothing. A range the tree has nothing /// to say about stays as it was; a text without a grammar, all of them. -fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { +pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { if (t.what != .body) return; const f = if (pane.file) |*file| file else return; if (f.node_tree == null or f.node_tree_revision != f.revision) { @@ -945,6 +945,8 @@ test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { .{ .keys = "6lx\x1bid", .want = "\n" }, .{ .keys = "10l\x1beiX\x1b", .want = "{\"a\": [1, 2X, 3], \"b\": true}\n" }, .{ .keys = "10l\x1bb\x1bbiX\x1b", .want = "{X\"a\": [1, 2, 3], \"b\": true}\n" }, + // insert Tab after text leaves the node (smart tab) + .{ .keys = "8li\tX\x1b", .want = "{\"a\": [1, 2, 3]X, \"b\": true}\n" }, }) |c| { const pane = try p.setTestFile(text); p.gpa.free(pane.file.?.path); @@ -959,6 +961,8 @@ test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { p.update(.{ .key = if (k == '>') .{ .cp = Key.right, .alt = true } else .{ .cp = k, .alt = true } }); } else if (ch == 0x1b) { p.update(.{ .key = .{ .cp = Key.escape } }); + } else if (ch == '\t') { + p.update(.{ .key = .{ .cp = Key.tab } }); } else p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); while (p.nextEffect()) |_| {} } diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 15e53590..f589755a 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -452,6 +452,8 @@ {"name": "ins-checkpoint-undo-twice", "pane": "file", "text": "ab\n", "keys": "iXYuu"} {"name": "ins-checkpoint-redo", "pane": "file", "text": "ab\n", "keys": "iXYuuU"} {"name": "msel-ins-checkpoint", "pane": "file", "text": "ab\nab\n", "keys": "CiXYu"} +{"name": "ins-tab-after-text-plain", "pane": "file", "text": "ab cd\n", "keys": "2liX"} +{"name": "ins-shift-tab", "pane": "file", "text": "ab cd\n", "keys": "2liX"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 7a195980..ce1b461c 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -452,6 +452,8 @@ {"name":"ins-checkpoint-undo-twice","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"ins-checkpoint-redo","text":"Xab\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} {"name":"msel-ins-checkpoint","text":"Xab\nXab\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":"ins-tab-after-text-plain","text":"ab X cd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}} +{"name":"ins-shift-tab","text":"ab X cd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} diff --git a/test/hxdiff.zig b/test/hxdiff.zig index 627bbac3..48c7ab93 100644 --- a/test/hxdiff.zig +++ b/test/hxdiff.zig @@ -419,6 +419,9 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result } else if (std.mem.startsWith(u8, name, "A-")) { key.alt = true; name = name[2..]; + } else if (std.mem.startsWith(u8, name, "S-")) { + key.shift = true; + name = name[2..]; } else break; } if (name.len == 1 or (name.len > 1 and name.len <= 4 and name[0] >= 0x80)) { -- cgit v1.3 From 71c5b400ced873cddd77497b7a9fd06184954813 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:43:59 -0300 Subject: Z arms the view keys until Esc, and zm puts the cursor's column mid-view Z is helix's sticky view mode: the z keys stay armed after each until Esc, which leaves the mode and does nothing else (no pane hop). zm is align_view_middle: an unwrapped view scrolls sideways so the cursor's column is in its middle. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 7 ++++--- src/config.zig | 2 ++ src/modal.zig | 21 +++++++++++++++++---- src/normal.zig | 10 ++++++++++ test/hxcases/cases.jsonl | 5 +++++ test/hxcases/goldens.jsonl | 5 +++++ test/hxcases/parity-waivers.jsonl | 4 ++++ 7 files changed, 47 insertions(+), 7 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index c0ffe0d7..8ee81930 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -110,6 +110,8 @@ language-backend queries, and the shell pipe. | `Ctrl-b` | file panes: full page up. Terminal panes: **toggle raw tty/editor mode** (`opts.tty_toggle`, configurable; `Shift-Esc` also enters). In raw tty Shift-Esc goes to the child | 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 | +| `Z` | sticky view mode: the `z` keys stay armed after each until Esc, which only leaves the mode; other keys do nothing meanwhile | helix's sticky view node | helix-verified | +| `zm` | scroll the unwrapped view so the cursor's column is in its middle | helix `align_view_middle`; with soft wrap on there is no horizontal scroll, and nothing happens | out of corpus (the harness's text never scrolls sideways) | | `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; `o` opens n | matches `open_below`/`open_above` | helix-verified | @@ -362,8 +364,6 @@ text. | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | -| `Z` (sticky view mode) | persistent view mode | marginal; `z` one-shots suffice | -| `zm` (view) | align middle horizontally | marginal even with hscroll | | 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` is the look-ring motion, which REPLACES the selection with the span it lands on) | ## Differential testing (phase 5) @@ -403,7 +403,7 @@ Files (all in `test/hxcases/`): 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` — 15 named exemptions for the parity gate, in three +- `parity-waivers.jsonl` — 19 named exemptions for the parity gate, in three classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the tty toggle), eight pty VIEWPORT divergences (a terminal's view cannot scroll below the vt's live grid bottom, so the cursor snaps into a different @@ -412,6 +412,7 @@ Files (all in `test/hxcases/`): newline, an all-whitespace last row the dump trims). One more, `dot-append-count`: `a` at a file's end grows it by a newline (helix append_mode), which a terminal's text, not the editor's to grow, does not. + The sticky-view cases join the viewport class. - `golf.jsonl` + `golf-goldens.jsonl` — every example on helix-golf (github.com/nik-rev/helix-golf, imported at d78c18c): one case per numbered step of each walkthrough, its keys being the command up to and including diff --git a/src/config.zig b/src/config.zig index ecaa3357..daa40028 100644 --- a/src/config.zig +++ b/src/config.zig @@ -440,6 +440,7 @@ pub const errors_buffer = "+Errors"; // Modal bindings below are checked against Helix by hxdiff and hxparity. pub const goto_prefix = 'g'; pub const view_prefix = 'z'; +pub const view_prefix_sticky = 'Z'; pub const match_prefix = 'm'; pub const replace_prefix = 'r'; pub const next_prefix = ']'; @@ -473,6 +474,7 @@ pub const page_up: []const Chord = &.{ .{ .cp = 'b', .ctrl = true }, .{ .cp = Ke pub const view_top: []const Chord = &.{.{ .cp = 't' }}; pub const view_center: []const Chord = &.{ .{ .cp = 'z' }, .{ .cp = 'c' } }; pub const view_bottom: []const Chord = &.{.{ .cp = 'b' }}; +pub const view_middle: []const Chord = &.{.{ .cp = 'm' }}; pub const view_scroll_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; pub const view_scroll_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } }; diff --git a/src/modal.zig b/src/modal.zig index 0171da93..16196b4f 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -16,6 +16,7 @@ pub const Normal = struct { prefix_next, prefix_prev, prefix_register, + prefix_view_sticky, goto_file_start, goto_last_line, @@ -38,6 +39,7 @@ pub const Normal = struct { view_top, view_center, view_bottom, + view_middle, view_scroll_down, view_scroll_up, @@ -196,9 +198,12 @@ pub const Normal = struct { held_char: u21 = 0, /// `"`: the register the next command names, 0 for none register: u21 = 0, + /// `Z`: the view keys stay armed until Esc (helix's sticky view) + sticky: bool = false, + /// Drop what is pending; sticky view mode stays armed through it. pub fn clear(state: *State) void { - state.* = .{}; + state.* = if (state.sticky) .{ .sticky = true, .prefix = .view } else .{}; } }; @@ -254,7 +259,7 @@ pub const Normal = struct { view_bottom, last_modification, }; - pub const View = enum { top, center, bottom, scroll_down, scroll_up }; + pub const View = enum { top, center, bottom, middle, scroll_down, scroll_up }; pub const Find = enum { forward, backward, till_forward, till_backward }; pub const Line = enum { start, end, first_nonws }; pub const Page = enum { half_down, half_up, down, up }; @@ -407,7 +412,10 @@ pub const Normal = struct { pub fn parse(state: *State, key: Input) Result { if (key.has(.escape)) { - state.clear(); + // Esc out of sticky view mode leaves that mode and nothing else + const sticky = state.sticky; + state.* = .{}; + if (sticky) return .ignored; return resultAction(.escape); } @@ -448,8 +456,9 @@ pub const Normal = struct { return .ignored; }, .view => { - state.prefix = .none; + if (!state.sticky) state.prefix = .none; if (key.has(.view_top)) return resultAction(.{ .view = .top }); + if (key.has(.view_middle)) return resultAction(.{ .view = .middle }); if (key.has(.view_center)) return resultAction(.{ .view = .center }); if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom }); if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down }); @@ -543,6 +552,10 @@ pub const Normal = struct { // Prefix setters retain the count for their continuation. if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0); if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0); + if (key.has(.prefix_view_sticky)) { + state.sticky = true; + return armPrefix(state, .view, 0); + } if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0); if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0); if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0); diff --git a/src/normal.zig b/src/normal.zig index f9d7cf25..f7ba3810 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -29,6 +29,7 @@ fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { return switch (role) { .prefix_goto => "goto_prefix", .prefix_view => "view_prefix", + .prefix_view_sticky => "view_prefix_sticky", .prefix_match => "match_prefix", .prefix_find_fwd => "find_char_fwd", .prefix_find_back => "find_char_back", @@ -478,6 +479,15 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void pane.scrollBy(t.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); }, + .middle => { + // helix align_view_middle: the cursor's column to the middle + // of an unwrapped view + if (pane.body_wrapped) return; + const line = text[modal.lineStartOffset(text, modal.lineAtOffset(text, cur))..modal.lineEndOffset(text, modal.lineAtOffset(text, cur))]; + const w: usize = @intCast(@max(1, @as(i32, pane.cols) - @as(i32, panes.File.gutterWidth(pane)))); + const col = panes.File.rawDisplayCol(line, @intCast(@max(0, t.cur_col))); + pane.hscroll = @intCast(panes.File.rawAtDisplay(line, col -| w / 2)); + }, .scroll_down => return t.scrollViewMove(text, range, 1), .scroll_up => return t.scrollViewMove(text, range, -1), }, diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index f589755a..15988ef6 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -454,6 +454,11 @@ {"name": "msel-ins-checkpoint", "pane": "file", "text": "ab\nab\n", "keys": "CiXYu"} {"name": "ins-tab-after-text-plain", "pane": "file", "text": "ab cd\n", "keys": "2liX"} {"name": "ins-shift-tab", "pane": "file", "text": "ab cd\n", "keys": "2liX"} +{"name": "sticky-view-scroll", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "Zjjj"} +{"name": "sticky-view-then-move", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "Zjjj"} +{"name": "sticky-view-ignores", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "Zjxj"} +{"name": "view-middle", "pane": "file", "text": "abcdef\n", "keys": "3lzm"} +{"name": "view-z-not-sticky", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "zjj"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index ce1b461c..d434c834 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -454,6 +454,11 @@ {"name":"msel-ins-checkpoint","text":"Xab\nXab\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":"ins-tab-after-text-plain","text":"ab X cd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}} {"name":"ins-shift-tab","text":"ab X cd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}} +{"name":"sticky-view-scroll","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}} +{"name":"sticky-view-then-move","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}} +{"name":"sticky-view-ignores","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} +{"name":"view-middle","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} +{"name":"view-z-not-sticky","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index 2393681e..e28f3ff5 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -13,3 +13,7 @@ {"name":"comment-only-blank","reason":"Terminal capture expands tabs and trims trailing blank rows.","reference":{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-only-blank","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}} {"name":"ins-backspace-indent-tab","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"ins-backspace-indent-tab","text":"\tx\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},"expected":{"name":"ins-backspace-indent-tab","text":" x\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} {"name":"dot-append-count","reason":"A terminal's text is not the editor's to grow: a at its end adds no newline to append before, where a file gets one (helix append_mode).","reference":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":1,"col":2}},"expected":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ","mode":"normal","cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}}} +{"name":"sticky-view-scroll","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"sticky-view-scroll","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},"expected":{"name":"sticky-view-scroll","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":43,"col":0},"anchor":{"row":43,"col":0}}} +{"name":"sticky-view-then-move","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"sticky-view-then-move","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},"expected":{"name":"sticky-view-then-move","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":44,"col":0},"anchor":{"row":44,"col":0}}} +{"name":"sticky-view-ignores","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"sticky-view-ignores","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},"expected":{"name":"sticky-view-ignores","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":43,"col":0},"anchor":{"row":43,"col":0}}} +{"name":"view-z-not-sticky","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"view-z-not-sticky","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},"expected":{"name":"view-z-not-sticky","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":44,"col":0},"anchor":{"row":44,"col":0}}} -- cgit v1.3 From ec4f300060ec0c93cc920dd5562eae1e3153d8b7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:46:46 -0300 Subject: gw labels the words in view and jumps to the one whose label is typed helix goto_word: the words of two or more word characters between the view's first and last lines get two-letter labels, nearest the cursor first, one forward and one back in turn (modal.jumpWords). The next two keys choose one and select it, stretching the selection to it in select mode; any other key takes the labels away. The words live on Pane.jump, and renderBody paints their labels over the body's cells in the selection colours, the one change on the render side. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/body_layer.zig | 23 +++++++++++++++++++++ src/config.zig | 3 +++ src/modal.zig | 51 ++++++++++++++++++++++++++++++++++++++++++++++ src/normal.zig | 39 +++++++++++++++++++++++++++++++++++ src/panes.zig | 4 ++++ src/pardes.zig | 2 ++ test/hxcases/cases.jsonl | 7 +++++++ test/hxcases/goldens.jsonl | 7 +++++++ 9 files changed, 137 insertions(+), 1 deletion(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 8ee81930..fedf578d 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -187,6 +187,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `g|`, `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) | | `g.` | go to where the last edit ended (a deletion's start, an insertion's end), every range there, extending in select mode | helix `goto_last_modification`. The position is `Text.last_edit`, set by `edit.setEditText` from where the old and new texts part; helix's is the last history revision's, so with nothing edited yet pardes stays put where the harness's helix goes to the end of its setup text | helix-verified | +| `gw` | label the words in view (two word characters or more) with two letters each, nearest the cursor first, one forward and one back in turn; typing a label selects its word (stretching the selection to it in select mode), any other key takes the labels away | helix `goto_word`, alphabet a-z (`config.jump_label_alphabet`). The labels are painted over the body's cells in `body_layer.renderBody`, in the selection colours; the words live on `Pane.jump` | helix-verified | | `gt` / `gc` / `gb` | goto screen top / center / bottom | view-relative (`pane.scroll()` + `pane.rows`), column kept (clamped) | helix-verified (phase 5) | | `gj` / `gk` | VISUAL line down / up (+ count) | follows the wrapped body's own breaks (`file_pane.visualRow`, the same walk `fillBody` renders), keeping the goal column INSIDE the row; the last row of a line steps into the next line's first. Wrap off = one row per line, and this IS `j`/`k`. helix assigns the pair the other way round (its `j` is the visual one) | pardes-specific | | `PageUp` / `PageDown` | FULL page (was half) | `Ctrl-u`/`Ctrl-d` stay the half-page pair | helix-verified (phase 5) | @@ -355,7 +356,6 @@ text. | 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 | | 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 | | `]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 | diff --git a/src/body_layer.zig b/src/body_layer.zig index c8e229c4..a354512f 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -477,6 +477,29 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id } } + // `gw`'s labels, two letters over the start of each word + if (pane.jump.items.len > 0) labels: { + const flat = edit.flatSurface(p, &pane.body) catch break :labels; + const letters = config.jump_label_alphabet; + const style: CellStyle = .{ .fg = .{ .rgb = th.sel_fg }, .bg = .{ .rgb = th.sel_bg }, .bold = true }; + for (pane.jump.items, 0..) |word, i| { + if (pane.jump_first) |first| if (i / letters.len != first) continue; + var at = @min(word.anchor, word.head); + for ([_]u8{ letters[i / letters.len], letters[i % letters.len] }) |letter| { + const pos = modal.positionAt(flat, at); + const row: i32 = @intCast(pos.row); + const wp = pane.wrapRow(row, @intCast(pos.col)); + const cx = if (pane.file != null) + @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, wp.at, @intCast(pos.col)) + else + panes.File.lineDisplayOffset(modal.lineSlice(flat, pos.row), @intCast(@max(0, wp.at)), pos.col); + if (wp.row >= 0 and wp.row < body_h and cx >= 0 and cx < tw) + s.set(tx + @as(u16, @intCast(cx)), body_y + @as(u16, @intCast(wp.row)), &.{letter}, style); + at = modal.nextGrapheme(flat, at); + } + } + } + // cursor: tracks the shell cursor until pinned by a click or a key // (the tag's or a prompt's cursor wins while that is being typed into) if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) { diff --git a/src/config.zig b/src/config.zig index daa40028..3739d3f4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -490,6 +490,9 @@ pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; pub const goto_last_modification: []const Chord = &.{.{ .cp = '.' }}; +pub const goto_word: []const Chord = &.{.{ .cp = 'w' }}; +/// the jump labels' letters, helix's jump_label_alphabet +pub const jump_label_alphabet = "abcdefghijklmnopqrstuvwxyz"; pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; diff --git a/src/modal.zig b/src/modal.zig index 16196b4f..6371475c 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -30,6 +30,7 @@ pub const Normal = struct { goto_view_center, goto_view_bottom, goto_last_modification, + goto_word, goto_definition, goto_declaration, goto_type_definition, @@ -346,6 +347,7 @@ pub const Normal = struct { pipe_selection: PipeBehavior, search, search_step: Direction, + goto_word, macro_record, macro_replay: u32, node: NodeWalk, @@ -364,6 +366,7 @@ pub const Normal = struct { .macro_record, .macro_replay, .node, + .goto_word, .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { @@ -447,6 +450,7 @@ pub const Normal = struct { if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_word)) return resultAction(.goto_word); if (key.has(.goto_last_modification)) return resultAction(.{ .goto = .{ .target = .last_modification, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); @@ -1382,6 +1386,53 @@ fn wmIsPrev(t: WordTarget) bool { } /// w/b/e/W/B/E: helix word_move — each step selects the traversed span. +/// helix jump_to_word's candidates: the words of two or more word +/// characters between `start` and `end`, nearest the cursor first, taking one +/// forward and one back in turn, each over its word pointing the way it was +/// found, as many as `out` holds. +pub fn jumpWords(text: []const u8, cursor: usize, start: usize, end: usize, out: []Selection) usize { + var fwd: Selection = .{ .anchor = cursor, .head = cursor }; + var rev = fwd; + if (cursor < text.len and !isWordWhitespace(wordClassAt(text, cursor))) { + const word_end = moveWord(text, fwd, 1, .next_word_end); + if (word_end.anchor == cursor) fwd = word_end; // a word of one grapheme + const word_start = moveWord(text, rev, 1, .prev_word_start); + if (word_start.anchor == nextGrapheme(text, cursor)) rev = word_start; + } + var n: usize = 0; + while (n < out.len) { + var changed = false; + while (fwd.head < end) { + fwd = moveWord(text, fwd, 1, .next_word_end); + // the two graphemes before the head are word characters + const one = prevGrapheme(text, fwd.head); + if (fwd.head == 0 or one == 0 or wordClassAt(text, one) != .word or wordClassAt(text, prevGrapheme(text, one)) != .word) continue; + changed = true; + var from = fwd.anchor; + while (from < fwd.head and wordClassAt(text, from) != .word) from = nextGrapheme(text, from); + fwd.anchor = from; + out[n] = fwd; + n += 1; + break; + } + if (n == out.len) break; + while (rev.head > start) { + rev = moveWord(text, rev, 1, .prev_word_start); + // the two graphemes from the head are word characters + if (wordClassAt(text, rev.head) != .word or wordClassAt(text, nextGrapheme(text, rev.head)) != .word) continue; + changed = true; + var to = rev.anchor; + while (to > rev.head and wordClassAt(text, prevGrapheme(text, to)) != .word) to = prevGrapheme(text, to); + rev.anchor = to; + out[n] = rev; + n += 1; + break; + } + if (!changed) break; + } + return n; +} + pub fn moveWord(text: []const u8, r0: Selection, count: usize, target: WordTarget) Selection { const is_prev = wmIsPrev(target); if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0; diff --git a/src/normal.zig b/src/normal.zig index f7ba3810..4459ab53 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -245,6 +245,44 @@ pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk t.setRanges(text, out[0..m], &.{}, @min(got.pri, m - 1), expl); } +/// `gw` (helix goto_word): label the words in view, nearest the cursor first, +/// with two letters each; the next two keys choose one (`jumpKey`). +fn startJump(p: *Pardes, pane: *Pane, t: *Text) void { + if (t.what != .body) return; + const text = edit.flatSurface(p, t) catch return; + const cursor = modal.selectionCursor(text, t.primaryRange(text, 0)); + const top: usize = @intCast(@max(0, pane.scroll())); + const start = @min(modal.lineStartOffset(text, top), text.len); + const end = @min(modal.lineStartOffset(text, top + pane.rows), text.len); + const letters = config.jump_label_alphabet.len; + pane.jump.resize(p.gpa, letters * letters) catch return; + pane.jump.shrinkRetainingCapacity(modal.jumpWords(text, cursor, start, end, pane.jump.items)); + pane.jump_first = null; +} + +/// A key while `gw`'s labels are up: a label's first letter, then its +/// second, which selects that word; anything else takes the labels away. +pub fn jumpKey(p: *Pardes, pane: *Pane, t: *Text, key: Key) void { + const letters = config.jump_label_alphabet; + const at = if (key.ctrl or key.alt or key.cp > 0x7f) null else std.mem.indexOfScalar(u8, letters, @intCast(key.cp)); + const i = at orelse return pane.jump.clearRetainingCapacity(); + const first = pane.jump_first orelse { + if (i * letters.len > pane.jump.items.len) return pane.jump.clearRetainingCapacity(); + pane.jump_first = @intCast(i); + return; + }; + const chosen = first * letters.len + i; + defer pane.jump.clearRetainingCapacity(); + if (chosen >= pane.jump.items.len) return; + const r = pane.jump.items[chosen]; + const text = edit.flatSurface(p, t) catch return; + if (!t.select) return t.setRange(text, 0, .{ .anchor = @min(r.anchor, r.head), .head = @max(r.anchor, r.head) }, true); + // select mode stretches the primary to the word (helix Movement::Extend) + const pr = t.primaryRange(text, 0); + const anchor = if (r.anchor < r.head) @min(r.anchor, @min(pr.anchor, pr.head)) else @max(r.anchor, @max(pr.anchor, pr.head)); + t.setRange(text, 0, .{ .anchor = anchor, .head = r.head }, true); +} + const KeyState = struct { mode: Text.Mode, select: bool, @@ -706,6 +744,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void if (direction == .forward) @as(i32, 1) else -1, ), .node => |walk| return nodeSelect(p, pane, t, walk), + .goto_word => return startJump(p, pane, t), .macro_record => { const m = &p.macro; if (m.recording == 0) { diff --git a/src/panes.zig b/src/panes.zig index 67954abd..7114b68e 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -154,6 +154,10 @@ pub const Pane = struct { search_pane: ?usize = null, search_row: ?usize = null, look_at: ?LookSpot = null, + /// `gw`'s words, labelled in order while it waits for a label to be + /// typed (gpa-owned); `jump_first` is the label's first letter once typed. + jump: std.ArrayList(modal.Selection) = .empty, + jump_first: ?u8 = null, sel_snap: [Text.max_selections]modal.Selection = undefined, nsel_snap: u16 = 0, sel_snap_pri: u16 = 0, diff --git a/src/pardes.zig b/src/pardes.zig index d43b38b1..6b397017 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4395,6 +4395,7 @@ pub const Pardes = struct { pane.body.deinit(p.gpa); pane.tag.deinit(p.gpa); pane.input.deinit(p.gpa); + pane.jump.deinit(p.gpa); panes.Terminal.deinitEmulator(pane, p.gpa); pane.clearCwd(); pane.fs.deinit(p.gpa); @@ -5284,6 +5285,7 @@ pub const Pardes = struct { if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode); return; } + if (t == &pane.body and pane.jump.items.len > 0) return normal.jumpKey(p, pane, t, key); if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { // A header's words are the workspace's and column's commands, so // Enter runs them there, as the execute key does. diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 15988ef6..f36afe11 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -459,6 +459,13 @@ {"name": "sticky-view-ignores", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "Zjxj"} {"name": "view-middle", "pane": "file", "text": "abcdef\n", "keys": "3lzm"} {"name": "view-z-not-sticky", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "zjj"} +{"name": "goto-word-first", "pane": "file", "text": "alpha beta gamma\ndelta x\n", "keys": "gwaa"} +{"name": "goto-word-second", "pane": "file", "text": "alpha beta gamma\ndelta x\n", "keys": "gwab"} +{"name": "goto-word-back", "pane": "file", "text": "alpha beta gamma\ndelta x\n", "keys": "jwgwab"} +{"name": "goto-word-cancel", "pane": "file", "text": "alpha beta gamma\n", "keys": "gwzl"} +{"name": "goto-word-bad-second", "pane": "file", "text": "alpha beta\n", "keys": "gwa9l"} +{"name": "goto-word-extend", "pane": "file", "text": "alpha beta gamma\n", "keys": "wvgwab"} +{"name": "goto-word-mid", "pane": "file", "text": "one two three four five\n", "keys": "2wgwaa"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index d434c834..2dab15c0 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -459,6 +459,13 @@ {"name":"sticky-view-ignores","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} {"name":"view-middle","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} {"name":"view-z-not-sticky","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} +{"name":"goto-word-first","text":"alpha beta gamma\ndelta x\n","mode":"normal","cursor":{"row":0,"col":9},"anchor":{"row":0,"col":6}} +{"name":"goto-word-second","text":"alpha beta gamma\ndelta x\n","mode":"normal","cursor":{"row":0,"col":15},"anchor":{"row":0,"col":11}} +{"name":"goto-word-back","text":"alpha beta gamma\ndelta x\n","mode":"normal","cursor":{"row":0,"col":15},"anchor":{"row":0,"col":11}} +{"name":"goto-word-cancel","text":"alpha beta gamma\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"goto-word-bad-second","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"goto-word-extend","text":"alpha beta gamma\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}} +{"name":"goto-word-mid","text":"one two three four five\n","mode":"normal","cursor":{"row":0,"col":12},"anchor":{"row":0,"col":8}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3 From 68e30f560e46bb9a30433eb23b4edb1b93d69f46 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:57:01 -0300 Subject: A selection's other ranges live out of line, made at the second range and freed at one The ranges beside the primary were an inline array in every Text, which at 1024 grew each pane by about 60 KiB. They are now allocated when a second range appears (Text.reserveSels) and given back when the selection is one range again (Text.dropSels); a column's or the workspace's tag allocates from Text.gpa, any other text from its pane's. A refused tag edit restores them by copy. The Pane size budget is back to 128 KiB, and a test holds that a single cursor allocates nothing for them. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- docs/selections.md | 5 ++++- src/Terminal.zig | 5 +---- src/Text.zig | 42 +++++++++++++++++++++++++++++++++++++++--- src/edit.zig | 10 ++++++++-- src/layout.zig | 6 +++--- src/normal.zig | 28 ++++++++++++++++++++++++---- src/pardes.zig | 10 ++++++++-- test/panes.zig | 1 + 9 files changed, 89 insertions(+), 20 deletions(-) (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 88aca887..1fb1c67a 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -282,7 +282,7 @@ keeps the matches it found so far. | an all-lowercase pattern | matches case-blind | helix's smart-case. mvzr has no such flag, so the surface is lowercased instead (ASCII folding is byte-for-byte, so the offsets are identical) | helix-verified | | `^` and `$` | hold at every line's start and end | helix compiles with `multi_line(true)`; pardes gets the same by searching each line as its own haystack (`src/regexp.zig`) | helix-verified | | `.` | never matches a newline | as the Rust regex crate: a pattern without `\n` searches line by line, and one that names `\n` has its `.`s made `[^\n]` (`src/regexp.zig`) | helix-verified | -| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The ranges are held inline in every `Text` (about 20 KiB each on the desktop) | helix-verified up to the limit | +| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The room for them is allocated when a second range appears and given back at one range, so a single cursor costs nothing | helix-verified up to the limit | | `K` / `Alt-K` Enter | keep only the ranges a match starts inside / only those none does; primary 0, and keeping none leaves the selection alone | helix `keep_selections` / `remove_selections`, on the same prompt as `s`/`S` (markers `Keep /`, `Remove /`). The range is searched as `s` searches it, line by line with its lines' context, where helix matches the range's text alone: a `^` right at a range that starts mid-line matches in helix and not here | helix-verified | ### `Ctrl-c` — toggle comments diff --git a/docs/selections.md b/docs/selections.md index 203cf1de..5cc1df1a 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -17,7 +17,10 @@ desktop, 64 on the board). head, `vsel.row`/`vsel.col` the anchor while `vsel.active`, and a bare cursor (inactive `vsel`) is the one-character range under it. `msel` is the older whole-line form (`r0`..`r1`), kept for search-result highlights. -- The others are `sels[0..nsel]`, each a `SelRange` of head and anchor plus +- The others are `sels[0..nsel]`, in room allocated when a second range + appears and given back when the selection is one range again (a column's + or the workspace's tag allocates from `Text.gpa`, any other text from its + pane's), each a `SelRange` of head and anchor plus its own `j`/`k` goal column, in document order. The primary is not in the list; `Text.ranges` slots it in and returns its index. - Ends are CELLS, the characters a block cursor sits on, where helix's are diff --git a/src/Terminal.zig b/src/Terminal.zig index 4c29e151..ba156ada 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -545,10 +545,7 @@ test "replay ownership is terminal-only and construction rolls back on allocatio try std.testing.expectEqual(terminal and enabled, pane.terminal != null); } }; - // Most of it is the three texts' selections, limits.selections ranges - // each held inline (about 20 KiB a text on the desktop), and the regex - // prompt's snapshot of them. - try std.testing.expect(@sizeOf(Pane) < 160 * 1024); + try std.testing.expect(@sizeOf(Pane) < 128 * 1024); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); } diff --git a/src/Text.zig b/src/Text.zig index 70ec5369..a44241ca 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -60,8 +60,14 @@ cur_row: i32 = 0, cur_col: i32 = 0, msel: LineSel = .{}, vsel: CharSel = .{}, -sels: [max_selections - 1]SelRange = undefined, +/// The ranges beside the primary, `nsel` of them: room for max_selections - 1 +/// made when a second range appears and given back when the selection is one +/// range again, so a single cursor holds nothing here. +sels: []SelRange = &.{}, nsel: u16 = 0, +/// What a column's or the workspace's tag allocates its ranges from; a text +/// with a pane uses the pane's. +gpa: ?std.mem.Allocator = null, select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. @@ -101,10 +107,39 @@ pub fn pane(t: *Text) ?*Pane { /// Free what a text owns: its characters and the buffers its undo keeps. pub fn deinit(t: *Text, gpa: std.mem.Allocator) void { + if (t.sels.len > 0) gpa.free(t.sels); if (t.own) |own| gpa.free(own); for (t.ed_undo[0..t.ed_undo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); - t.* = .{ .what = t.what }; + t.* = .{ .what = t.what, .gpa = t.gpa }; +} + +fn allocator(t: *Text) ?std.mem.Allocator { + return if (t.pane()) |w| w.gpa else t.gpa; +} + +/// Room for the ranges beside the primary; false when there is none to be had. +pub fn reserveSels(t: *Text) bool { + if (t.sels.len > 0) return true; + const gpa = t.allocator() orelse return false; + t.sels = gpa.alloc(SelRange, max_selections - 1) catch return false; + return true; +} + +/// The ranges beside the primary as they were (a refused edit's undoing). +pub fn restoreSels(t: *Text, kept: []const SelRange) void { + if (kept.len == 0) return t.dropSels(); + if (!t.reserveSels()) return; + @memcpy(t.sels[0..kept.len], kept); + t.nsel = @intCast(kept.len); +} + +/// Back to one range: the room for the others goes. +pub fn dropSels(t: *Text) void { + t.nsel = 0; + if (t.sels.len == 0) return; + if (t.allocator()) |gpa| gpa.free(t.sels); + t.sels = &.{}; } /// Bring the cursor into view (acme's textshow). Only a body scrolls to it; @@ -185,7 +220,7 @@ pub fn setRange(t: *Text, text: []const u8, row0: i32, r0: modal.Selection, expl .explicit = explicit or t.select, }; t.msel.active = false; - t.nsel = 0; // writing ONE range means the selection IS that range + t.dropSels(); // writing ONE range means the selection IS that range t.cur_pinned = true; t.sticky_col = -1; t.normal.clear(); @@ -262,6 +297,7 @@ pub fn setRanges(t: *Text, text: []const u8, in: []const modal.Selection, sticky } n = k + 1; setRange(t, text, 0, r[pri], explicit); + if (n > 1 and !t.reserveSels()) return; // no room: the primary alone t.sticky_col = st[pri]; var w: usize = 0; for (r[0..n], 0..) |x, idx| { diff --git a/src/edit.zig b/src/edit.zig index 01b34237..5437a67d 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -167,10 +167,16 @@ fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void { if (bytes.len == 0) return; const typed: pardes.Key = .{ .cp = bytes[0], .text = bytes }; if (t.what == .tag and t.mode == .insert) if (t.pane()) |pane| if (tagline.typedIntoName(p, pane, typed)) return; - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; if (t.mode == .insert) insertKey(p, t, typed) else pasteText(p, t, bytes, false, 1); - if (t.refused) t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + if (t.refused) { + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); + } } pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { diff --git a/src/layout.zig b/src/layout.zig index 8e44f1d9..be8bad67 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -947,7 +947,7 @@ pub fn removePane(p: *Pardes, id: usize) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } } @@ -971,7 +971,7 @@ pub fn joinCol(p: *Pardes) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } @@ -1021,7 +1021,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { p.col_serial[j] = p.col_serial[j - 1]; } p.col_weight[c] = new_weight; - p.col_tags[c] = .{ .what = .column }; + p.col_tags[c] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[c] = 0; p.col_panes[c][0] = id; p.col_n[c] = 1; diff --git a/src/normal.zig b/src/normal.zig index 4459ab53..2aea2e54 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -686,10 +686,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .all => return t.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { - if (multi.kind == .keep_primary) { - t.nsel = 0; - return; - } + if (multi.kind == .keep_primary) return t.dropSels(); return t.multiSelAction(text, multi.kind, multi.count); }, // s, S and | answer for the text they are pressed in; `/` searches @@ -922,6 +919,7 @@ test "flat text movement and selection replay need no scratch rows" { pane.body.cur_row = 1024; pane.body.cur_col = 0; pane.body.nsel = 1; + _ = pane.body.reserveSels(); pane.body.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; handleNormal(p, &pane.body, .{ .cp = 'l' }); handleNormal(p, &pane.body, .{ .cp = 'j' }); @@ -1021,3 +1019,25 @@ test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { }; } } + +test "one cursor holds no room for other ranges; a second range makes it and one again gives it back" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("alpha beta\nalpha beta\n"); + for ("wxe%;gg") |cp| handleNormal(p, &pane.body, .{ .cp = cp }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + try std.testing.expect(pane.body.sels.len > 0); + handleNormal(p, &pane.body, .{ .cp = 'l' }); // replayed at both, still two + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + handleNormal(p, &pane.body, .{ .cp = ',' }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + handleNormal(p, &pane.body, .{ .cp = ';' }); + handleNormal(p, &pane.body, .{ .cp = 'x' }); // x at both: still two lines + handleNormal(p, &pane.body, .{ .cp = '_', .alt = true }); // merged to one + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); +} diff --git a/src/pardes.zig b/src/pardes.zig index 6b397017..187f3ed3 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4146,6 +4146,8 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .col_tags = @splat(.{ .what = .column, .gpa = gpa }), + .global_tag = .{ .what = .workspace, .gpa = gpa }, .fs = .{}, .screen_w = opts.cols, .screen_h = opts.rows, @@ -5335,10 +5337,14 @@ pub const Pardes = struct { if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return; // A tag's prefix is read-only: a key whose edit reached into it // changes nothing, not even where the cursor is. - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; defer if (t.refused) { - t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); }; const was = t.mode; switch (t.mode) { diff --git a/test/panes.zig b/test/panes.zig index a56c07bf..181317ca 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -4560,6 +4560,7 @@ const LookResetTests = struct { pane.body.cur_col = 3; pane.body.vsel = .{ .active = true, .explicit = true, .row = 0, .col = 0 }; pane.body.msel = .{ .active = true, .r0 = 0, .r1 = 1 }; + _ = pane.body.reserveSels(); pane.body.sels[0] = .{ .row = 1, .col = 2, .arow = 0, .acol = 1 }; pane.body.nsel = 1; pane.body.select = true; -- cgit v1.3 From df4d11bb95bc7ea9554b55d2211c4704a2594f08 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 17:04:53 -0300 Subject: ]f, mif and the other textobject keys use helix's own queries helix's textobjects.scm for pardes's grammars are vendored in vendor/queries under MPL-2.0, each with a header naming its source and what it inherits, inlined; a README says what is there and what is not. syntax.objectAt and objectNext run them over the file's kept parse, with their #eq? and #match? predicates, for ]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x and mi/ma with f t a c T e x. erlang's and zig's queries do not compile against the grammar versions pardes builds and are left out; fortran, markdown and powershell have none in helix. A test replays sixteen Python and JSON cases whose results were taken from the installed hx, and another holds that every vendored query compiles. Co-Authored-By: Claude Opus 5.5 --- build.zig | 4 + docs/helix-keys.md | 2 +- src/config.zig | 7 + src/edit.zig | 19 ++ src/modal.zig | 12 + src/normal.zig | 86 ++++++- src/syntax.zig | 144 ++++++++++++ vendor/queries/README.md | 15 ++ vendor/queries/ada/textobjects.scm | 26 +++ vendor/queries/bash/textobjects.scm | 17 ++ vendor/queries/c/textobjects.scm | 32 +++ vendor/queries/c_sharp/textobjects.scm | 25 ++ vendor/queries/clojure/textobjects.scm | 76 ++++++ vendor/queries/cpp/textobjects.scm | 39 ++++ vendor/queries/css/textobjects.scm | 27 +++ vendor/queries/elixir/textobjects.scm | 40 ++++ vendor/queries/go/textobjects.scm | 39 ++++ vendor/queries/haskell/textobjects.scm | 29 +++ vendor/queries/helix.LICENSE | 373 ++++++++++++++++++++++++++++++ vendor/queries/html/textobjects.scm | 14 ++ vendor/queries/java/textobjects.scm | 49 ++++ vendor/queries/javascript/textobjects.scm | 8 + vendor/queries/json/textobjects.scm | 13 ++ vendor/queries/kotlin/textobjects.scm | 50 ++++ vendor/queries/ocaml/textobjects.scm | 37 +++ vendor/queries/pascal/textobjects.scm | 14 ++ vendor/queries/php/textobjects.scm | 63 +++++ vendor/queries/python/textobjects.scm | 40 ++++ vendor/queries/ruby/textobjects.scm | 61 +++++ vendor/queries/rust/textobjects.scm | 90 +++++++ vendor/queries/scala/textobjects.scm | 70 ++++++ vendor/queries/typst/textobjects.scm | 28 +++ 32 files changed, 1540 insertions(+), 9 deletions(-) create mode 100644 vendor/queries/README.md create mode 100644 vendor/queries/ada/textobjects.scm create mode 100644 vendor/queries/bash/textobjects.scm create mode 100644 vendor/queries/c/textobjects.scm create mode 100644 vendor/queries/c_sharp/textobjects.scm create mode 100644 vendor/queries/clojure/textobjects.scm create mode 100644 vendor/queries/cpp/textobjects.scm create mode 100644 vendor/queries/css/textobjects.scm create mode 100644 vendor/queries/elixir/textobjects.scm create mode 100644 vendor/queries/go/textobjects.scm create mode 100644 vendor/queries/haskell/textobjects.scm create mode 100644 vendor/queries/helix.LICENSE create mode 100644 vendor/queries/html/textobjects.scm create mode 100644 vendor/queries/java/textobjects.scm create mode 100644 vendor/queries/javascript/textobjects.scm create mode 100644 vendor/queries/json/textobjects.scm create mode 100644 vendor/queries/kotlin/textobjects.scm create mode 100644 vendor/queries/ocaml/textobjects.scm create mode 100644 vendor/queries/pascal/textobjects.scm create mode 100644 vendor/queries/php/textobjects.scm create mode 100644 vendor/queries/python/textobjects.scm create mode 100644 vendor/queries/ruby/textobjects.scm create mode 100644 vendor/queries/rust/textobjects.scm create mode 100644 vendor/queries/scala/textobjects.scm create mode 100644 vendor/queries/typst/textobjects.scm (limited to 'src/normal.zig') diff --git a/build.zig b/build.zig index f4a3b542..51c4f76d 100644 --- a/build.zig +++ b/build.zig @@ -384,6 +384,10 @@ pub fn build(b: *std.Build) void { const query_path = dep.path(g.query); const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights"); ts_queries.addOption([]const u8, g.name ++ "_highlights", query); + // helix's textobject queries, vendored (vendor/queries); a + // grammar without one gets none + const objects = std.Io.Dir.cwd().readFileAlloc(io, b.pathFromRoot("vendor/queries/" ++ g.name ++ "/textobjects.scm"), b.allocator, .limited(0x100000)) catch ""; + ts_queries.addOption([]const u8, g.name ++ "_textobjects", objects); const lib = b.addLibrary(.{ .name = "tree-sitter-" ++ g.name, diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 1fb1c67a..b6ac6475 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -218,6 +218,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `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) | | `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified | | `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | walk the file's syntax tree from every range: grow to the enclosing node (and remember what it grew from), shrink back to that or to the first child, the previous / next sibling (anonymous ones included, so often a comma), every named sibling or child, the enclosing named node's end / start (stretching to it in select mode) | helix `expand_selection` and kin, on a whole-file parse (`File.node_tree`) made on the first such key after an edit and reused until the next; a range the tree says nothing about, and every range of a file without a grammar, stays. Checked against the installed hx on JSON (`normal.zig` test): the harness runs without grammars, so it only proves the plain-text no-op | helix-verified (plain text), hx-checked (JSON) | +| `]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x`, `mi`/`ma` + `f t a c T e x` | function, class, argument, comment, test, entry, element: jump to the next one starting after the cursor / the previous one ending before it (count times; select mode stretches to it), or select the smallest one around the cursor, inside or around | helix `goto_ts_object` / `textobject_treesitter`, from helix's own `textobjects.scm` vendored in `vendor/queries` (MPL-2.0), with its `#eq?`/`#match?` predicates; `syntax.objectAt`/`objectNext` on the file's kept parse. Grammars without a query (fortran, markdown, powershell) or whose query does not compile against pardes's grammar (erlang, zig) have none, and the keys do nothing there. Checked against the installed hx on Python and JSON (`normal.zig` test) | hx-checked | | `%` | 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) | @@ -357,7 +358,6 @@ text. | 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 | | 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 | -| `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` (and `mif`/`maf`…) | TS unimpaired jumps | open question: they read a grammar's `textobjects.scm`, and of pardes's 29 grammars only Ada ships one; the rest need queries vendored from helix (MPL-2.0) or nvim-treesitter-textobjects (Apache-2.0). The node walks they build on are in A | | `]g [g ]G [G` | git change jumps | **deferred by decision**: needs a diff base per file (jj `@-` or git HEAD, fetched by the host on open and save) and line hunks from diffz; the design is written up, the work waits | | 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 | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | diff --git a/src/config.zig b/src/config.zig index 3739d3f4..78787ad2 100644 --- a/src/config.zig +++ b/src/config.zig @@ -511,6 +511,13 @@ pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }}; pub const add_newline: []const Chord = &.{.{ .cp = ' ' }}; pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }}; +pub const goto_function: []const Chord = &.{.{ .cp = 'f' }}; +pub const goto_class: []const Chord = &.{.{ .cp = 't' }}; +pub const goto_parameter: []const Chord = &.{.{ .cp = 'a' }}; +pub const goto_comment: []const Chord = &.{.{ .cp = 'c' }}; +pub const goto_test: []const Chord = &.{.{ .cp = 'T' }}; +pub const goto_entry: []const Chord = &.{.{ .cp = 'e' }}; +pub const goto_xml_element: []const Chord = &.{.{ .cp = 'x' }}; pub const insert: []const Chord = &.{.{ .cp = 'i' }}; pub const append: []const Chord = &.{.{ .cp = 'a' }}; diff --git a/src/edit.zig b/src/edit.zig index 5437a67d..1b947749 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1858,6 +1858,25 @@ fn pairFor(ch: u21) ?struct { o: u8, c: u8 } { pub fn textobjectSelect(p: *Pardes, t: *Text, text: []const u8, obj: u21, around: bool) void { const range = t.primaryRange(text, 0); + // helix's tree-sitter objects, from the grammar's textobjects query + const object: ?modal.Normal.Object = switch (obj) { + 'f' => .function, + 't' => .class, + 'a' => .parameter, + 'c' => .comment, + 'T' => .@"test", + 'e' => .entry, + 'x' => .xml_element, + else => null, + }; + if (object) |o| { + if (t.what != .body) return; + const pane = t.pane() orelse return; + const f = if (pane.file) |*file| file else return; + const tree = normal.fileTree(p, f) orelse return; + const s = syntax.objectAt(f.path, tree, text, o, around, modal.selectionCursor(text, range)) orelse return; + return t.setRange(text, 0, .{ .anchor = s.from, .head = s.to }, false); + } switch (obj) { 'w', 'W' => { const r = modal.selectWord(text, range, around, obj == 'W'); diff --git a/src/modal.zig b/src/modal.zig index 6371475c..cc7f1ebd 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -54,6 +54,13 @@ pub const Normal = struct { add_newline, goto_diagnostic, goto_diagnostic_end, + goto_function, + goto_class, + goto_parameter, + goto_comment, + goto_test, + goto_entry, + goto_xml_element, move_left, move_right, @@ -229,6 +236,8 @@ pub const Normal = struct { }; /// helix's tree-sitter selection keys: where a range goes in the tree + /// helix's tree-sitter textobjects, by what their queries call them + pub const Object = enum { function, class, parameter, comment, @"test", entry, xml_element }; pub const NodeWalk = enum { expand, shrink, next_sibling, prev_sibling, all_siblings, all_children, parent_end, parent_start }; /// what the s/S/K/Alt-K prompt does with its pattern's matches pub const RegexMode = enum { select, split, keep, remove }; @@ -351,6 +360,7 @@ pub const Normal = struct { macro_record, macro_replay: u32, node: NodeWalk, + object_jump: struct { object: Object, direction: Direction, count: u32 }, repeat_insert: u32, pub fn scope(value: Action) Scope { @@ -540,6 +550,8 @@ pub const Normal = struct { if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } }); if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } }); if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); + inline for (.{ .{ .goto_function, .function }, .{ .goto_class, .class }, .{ .goto_parameter, .parameter }, .{ .goto_comment, .comment }, .{ .goto_test, .@"test" }, .{ .goto_entry, .entry }, .{ .goto_xml_element, .xml_element } }) |pair| + if (key.has(pair[0])) return resultAction(.{ .object_jump = .{ .object = pair[1], .direction = direction, .count = count } }); return .ignored; }, .register => { diff --git a/src/normal.zig b/src/normal.zig index 2aea2e54..b68f9ba6 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -168,13 +168,9 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: m t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } -/// helix's tree-sitter selection keys: every range walks the file's parse, -/// which is made on the first of these keys after an edit and kept, never -/// made again for a key that changed nothing. A range the tree has nothing -/// to say about stays as it was; a text without a grammar, all of them. -pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { - if (t.what != .body) return; - const f = if (pane.file) |*file| file else return; +/// The file's parse, made on the first key that walks it after an edit and +/// kept until the next: never made again for a key that changed nothing. +pub fn fileTree(p: *Pardes, f: *panes.File.State) ?*anyopaque { if (f.node_tree == null or f.node_tree_revision != f.revision) { if (f.node_tree) |old| syntax.freeTree(old); f.node_tree = syntax.parseTree(f.path, f.content); @@ -182,7 +178,40 @@ pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk for (f.node_history.items) |step| p.gpa.free(step.ranges); f.node_history.clearRetainingCapacity(); } - const tree = f.node_tree orelse return; + return f.node_tree; +} + +/// `]f`/`[f` and kin (helix goto_ts_object): to the next object starting +/// after the cursor or the previous one ending before it, count times; select +/// mode stretches the range to it. +fn objectJump(p: *Pardes, pane: *Pane, t: *Text, text: []const u8, range: modal.Selection, object: modal.Normal.Object, forward: bool, count: u32) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + const tree = fileTree(p, f) orelse return; + var last = range; + for (0..count) |_| { + const s = syntax.objectNext(f.path, tree, text, object, forward, modal.selectionCursor(text, last)) orelse break; + const next: modal.Selection = .{ .anchor = s.from, .head = s.to }; + if (std.meta.eql(next, last)) break; + last = next; + } + if (std.meta.eql(last, range)) return; + if (t.select) { + const head = if (last.head < range.anchor) last.anchor else last.head; + return t.setRange(text, 0, .{ .anchor = range.anchor, .head = head }, false); + } + const out: modal.Selection = if (forward) last else .{ .anchor = last.head, .head = last.anchor }; + t.setRange(text, 0, out, false); +} + +/// helix's tree-sitter selection keys: every range walks the file's parse, +/// which is made on the first of these keys after an edit and kept, never +/// made again for a key that changed nothing. A range the tree has nothing +/// to say about stays as it was; a text without a grammar, all of them. +pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + const tree = fileTree(p, f) orelse return; const text = f.content; const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; const arena = p.scratch.allocator(); @@ -742,6 +771,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void ), .node => |walk| return nodeSelect(p, pane, t, walk), .goto_word => return startJump(p, pane, t), + .object_jump => |jump| return objectJump(p, pane, t, text, range, jump.object, jump.direction == .forward, jump.count), .macro_record => { const m = &p.macro; if (m.recording == 0) { @@ -1041,3 +1071,43 @@ test "one cursor holds no room for other ranges; a second range makes it and one try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); } + +test "tree-sitter textobject keys do in Python and JSON what hx 25.07.1 does" { + if (!syntax.supportsPath("a.py") or !syntax.supportsPath("a.json")) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + // each checked against the installed hx through a pty: the keys, then + // the text it saved + const Case = struct { path: []const u8, text: []const u8, keys: []const u8, want: []const u8 }; + for ([_]Case{ + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]fd", .want = "def alpha(a, b):\n return a + b\n\n\n\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]f]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "2]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "Gk[fd", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmifd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmafd", .want = "\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmiad", .want = "def alpha(, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmaad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]Td", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]cd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n \n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "v]fd", .want = "\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]td", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]ad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]ed", .want = "{, \"b\": [1, 2]}\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]e]ed", .want = "{\"a\": 1, }\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "8lmaed", .want = "{\"a\": 1,\"b\": [1, 2]}\n" }, + }) |c| { + const pane = try p.setTestFile(c.text); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, c.path); + for (c.keys, 0..) |ch, i| { + p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); + while (p.nextEffect()) |_| {} + } + std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| { + std.debug.print("keys {s} on {s}\n", .{ c.keys, c.path }); + return err; + }; + } +} diff --git a/src/syntax.zig b/src/syntax.zig index 89fc4198..3a83800b 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -3,6 +3,7 @@ const config = @import("pardes_config"); const tracy = @import("tracy.zig"); const grammar_manifest = @import("grammar_manifest.zig"); const look = @import("look.zig"); +const regexp = @import("regexp.zig"); const c_heap = @import("c_heap"); pub const enabled = config.syntax_highlighting; @@ -27,6 +28,10 @@ const Spec = struct { exts: []const []const u8, language: *const fn () callconv(.c) *const ts.Language, query_src: []const u8, + /// helix's textobjects.scm for the grammar (vendor/queries), "" for none + objects_src: []const u8 = "", + objects: ?*ts.Query = null, + objects_failed: bool = false, selected: ?Selected = null, capture_styles: [256]u8 = undefined, }; @@ -131,6 +136,7 @@ fn initSpecs() [specCount()]Spec { .exts = g.exts, .language = @extern(LanguageFn, .{ .name = "tree_sitter_" ++ g.name }), .query_src = querySrc(g), + .objects_src = @field(ts_queries, g.name ++ "_textobjects"), }; i += 1; } @@ -173,6 +179,9 @@ pub fn stop() void { selected.query.destroy(); } spec.selected = null; + if (spec.objects) |q| q.destroy(); + spec.objects = null; + spec.objects_failed = false; } ts_set_allocator(null, null, null, null); syntax_started = false; @@ -427,6 +436,141 @@ pub fn walkNodes(tree_ptr: *anyopaque, walk: NodeWalk, from: usize, to: usize, a } +/// helix's textobjects, which its `]f`, `mif` and kin select: the name a +/// query's captures begin with (`function.around`, `test.inside`). +pub const Object = @import("modal.zig").Normal.Object; + +fn objectName(o: Object) []const u8 { + return if (o == .xml_element) "xml-element" else @tagName(o); +} + +/// The grammar's textobject query for `path`, compiled the first time; null +/// when the grammar has none. +fn objectQuery(path: []const u8) ?*ts.Query { + const ext = std.fs.path.extension(path); + for (&specs) |*spec| for (spec.exts) |choice| { + if (!std.ascii.eqlIgnoreCase(ext, choice)) continue; + if (spec.objects) |q| return q; + if (spec.objects_src.len == 0 or spec.objects_failed) return null; + var error_offset: u32 = 0; + spec.objects = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch { + spec.objects_failed = true; + return null; + }; + return spec.objects; + }; + return null; +} + +fn captureId(q: *const ts.Query, name: []const u8) ?u32 { + for (0..q.captureCount()) |i| if (std.mem.eql(u8, q.captureNameForId(@intCast(i)) orelse "", name)) return @intCast(i); + return null; +} + +/// A match's `capture`, as one span over all its nodes (helix's grouped +/// capture), when the match's `#eq?` and `#match?` hold. +fn matchSpan(q: *const ts.Query, match: anytype, capture: u32, text: []const u8) ?Span { + var span: ?Span = null; + for (match.captures) |c| if (c.index == capture) { + const s: Span = .{ .from = c.node.startByte(), .to = c.node.endByte() }; + span = if (span) |old| .{ .from = @min(old.from, s.from), .to = @max(old.to, s.to) } else s; + }; + const found = span orelse return null; + const steps = q.predicatesForPattern(match.pattern_index); + var i: usize = 0; + while (i < steps.len) { + var end = i; + while (end < steps.len and steps[end].type != .done) end += 1; + const pred = steps[i..end]; + i = end + 1; + if (pred.len != 3 or pred[0].type != .string or pred[1].type != .capture) continue; + const op = q.stringValueForId(pred[0].value_id) orelse continue; + const subject = for (match.captures) |c| { + if (c.index == pred[1].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)]; + } else continue; + const want = if (pred[2].type == .string) q.stringValueForId(pred[2].value_id) orelse continue else for (match.captures) |c| { + if (c.index == pred[2].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)]; + } else continue; + if (std.mem.eql(u8, op, "eq?")) { + if (!std.mem.eql(u8, subject, want)) return null; + } else if (std.mem.eql(u8, op, "match?")) { + var re = regexp.Regex.compile(want) catch continue; + if ((re.find(subject, 0, subject.len, subject.len) catch null) == null) return null; + } + } + return found; +} + +/// `mi`/`ma`: the smallest `object.inside` (or `.around`) holding byte `at` +/// (helix textobject_treesitter). None without a query or such an object. +pub fn objectAt(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, around: bool, at: usize) ?Span { + if (!enabled) return null; + const q = objectQuery(path) orelse return null; + var buf: [32]u8 = undefined; + const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), if (around) "around" else "inside" }) catch return null; + const capture = captureId(q, want) orelse return null; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const cursor = ts.QueryCursor.create(); + defer cursor.destroy(); + cursor.exec(q, tree.rootNode()); + var best: ?Span = null; + while (cursor.nextMatch()) |match| { + const s = matchSpan(q, match, capture, text) orelse continue; + if (!(s.from <= at and at < s.to)) continue; + if (best == null or s.to - s.from < best.?.to - best.?.from) best = s; + } + const s = best orelse return null; + if (s.from >= text.len or s.to >= text.len) return null; + return s; +} + +/// `]f`/`[f` and kin: the next object starting after byte `at`, or the +/// previous one ending before it (helix goto_treesitter_object), from the +/// first of `.movement`, `.around`, `.inside` the query names. +pub fn objectNext(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, forward: bool, at: usize) ?Span { + if (!enabled) return null; + const q = objectQuery(path) orelse return null; + var buf: [32]u8 = undefined; + const capture = for ([_][]const u8{ "movement", "around", "inside" }) |kind| { + const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), kind }) catch return null; + if (captureId(q, want)) |id| break id; + } else return null; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const cursor = ts.QueryCursor.create(); + defer cursor.destroy(); + cursor.exec(q, tree.rootNode()); + var best: ?Span = null; + while (cursor.nextMatch()) |match| { + const s = matchSpan(q, match, capture, text) orelse continue; + if (forward) { + if (s.from <= at) continue; + if (best == null or s.from < best.?.from or (s.from == best.?.from and s.to > best.?.to)) best = s; + } else { + if (s.to >= at) continue; + if (best == null or s.to > best.?.to or (s.to == best.?.to and s.from < best.?.from)) best = s; + } + } + const s = best orelse return null; + if (s.from >= text.len or s.to >= text.len) return null; + return s; +} + +test "every vendored textobject query compiles against its grammar" { + if (!enabled) return; + var failed: usize = 0; + for (&specs) |*spec| { + if (spec.objects_src.len == 0) continue; + var error_offset: u32 = 0; + const q = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch { + std.debug.print("textobjects for {s} do not compile (offset {d})\n", .{ spec.name, error_offset }); + failed += 1; + continue; + }; + q.destroy(); + } + try std.testing.expectEqual(@as(usize, 0), failed); +} + /// Owned source analysis. Both slices use the allocator passed to analyzeSource. /// The parsed tree is released before returning; callers can cache this result. pub const SourceAnalysis = struct { diff --git a/vendor/queries/README.md b/vendor/queries/README.md new file mode 100644 index 00000000..abd37d00 --- /dev/null +++ b/vendor/queries/README.md @@ -0,0 +1,15 @@ +# Textobject queries + +`/textobjects.scm` is helix's own query for that grammar, vendored +from helix (https://github.com/helix-editor/helix) at 278b24389, the +reference build docs/helix-keys.md names. Each file says where it came from +and what it inherits, inlined. They are Mozilla Public License 2.0, whose text +is `helix.LICENSE` here. They give `]f`, `[f`, `mif` and the other +tree-sitter textobject keys what a function, class, argument, comment, test, +entry or element is. + +Not vendored: fortran, markdown and powershell (helix has no query for +them), and erlang and zig, whose helix queries do not compile against the +grammar versions pardes builds (a node name either side has renamed). Those +grammars have no textobjects, and the keys do nothing there, as in helix +without a query. diff --git a/vendor/queries/ada/textobjects.scm b/vendor/queries/ada/textobjects.scm new file mode 100644 index 00000000..a6f9c2ff --- /dev/null +++ b/vendor/queries/ada/textobjects.scm @@ -0,0 +1,26 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/ada/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +;; Support for high-level text objects selections. +;; For instance: +;; maf (v)isually select (a) (f)unction or subprogram +;; mif (v)isually select (i)nside a (f)unction or subprogram +;; mai (v)isually select (a) (i)f statement (or loop) +;; mii (v)isually select (i)nside an (i)f statement (or loop) +;; +;; For navigations using textobjects, check link below: +;; https://docs.helix-editor.com/master/usage.html#navigating-using-tree-sitter-textobjects +;; +;; For Textobject queries explanation, check out link below: +;; https://docs.helix-editor.com/master/guides/textobject.html + +(subprogram_body) @function.around +(subprogram_body (non_empty_declarative_part) @function.inside) +(subprogram_body (handled_sequence_of_statements) @function.inside) +(function_specification) @function.around +(procedure_specification) @function.around +(package_declaration) @function.around +(generic_package_declaration) @function.around +(package_body) @function.around diff --git a/vendor/queries/bash/textobjects.scm b/vendor/queries/bash/textobjects.scm new file mode 100644 index 00000000..8e7ad4f9 --- /dev/null +++ b/vendor/queries/bash/textobjects.scm @@ -0,0 +1,17 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/bash/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (_) @function.inside) @function.around + +(command + argument: (_) @parameter.inside) + +(comment) @comment.inside + +(comment)+ @comment.around + +(array + (_) @entry.around) diff --git a/vendor/queries/c/textobjects.scm b/vendor/queries/c/textobjects.scm new file mode 100644 index 00000000..34a5682f --- /dev/null +++ b/vendor/queries/c/textobjects.scm @@ -0,0 +1,32 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/c/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (_) @function.inside) @function.around + +(struct_specifier + body: (_) @class.inside) @class.around + +(enum_specifier + body: (_) @class.inside) @class.around + +(union_specifier + body: (_) @class.inside) @class.around + +(parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +(enumerator + (_) @entry.inside) @entry.around + +(initializer_list + (_) @entry.around) diff --git a/vendor/queries/c_sharp/textobjects.scm b/vendor/queries/c_sharp/textobjects.scm new file mode 100644 index 00000000..0b90f428 --- /dev/null +++ b/vendor/queries/c_sharp/textobjects.scm @@ -0,0 +1,25 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/c-sharp/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +[ + (class_declaration body: (_) @class.inside) + (struct_declaration body: (_) @class.inside) + (interface_declaration body: (_) @class.inside) + (enum_declaration body: (_) @class.inside) + (delegate_declaration) + (record_declaration body: (_) @class.inside) +] @class.around + +(constructor_declaration body: (_) @function.inside) @function.around + +(destructor_declaration body: (_) @function.inside) @function.around + +(method_declaration body: (_) @function.inside) @function.around + +(property_declaration (_) @function.inside) @function.around + +(parameter (_) @parameter.inside) @parameter.around + +(comment)+ @comment.around diff --git a/vendor/queries/clojure/textobjects.scm b/vendor/queries/clojure/textobjects.scm new file mode 100644 index 00000000..5121ff8c --- /dev/null +++ b/vendor/queries/clojure/textobjects.scm @@ -0,0 +1,76 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/clojure/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Function definitions (defn, defn-, defmacro, defmethod, etc.) +(list_lit + . + (sym_lit) @_keyword + . + (sym_lit) + (_)* @function.inside + (#match? @_keyword "^(defn|defn-|defmacro|defmethod|defmulti|definline)$")) @function.around + +; Anonymous functions (fn) +(list_lit + . + (sym_lit) @_fn + (_)* @function.inside + (#match? @_fn "^fn$")) @function.around + +; Anonymous function shorthand #() +(anon_fn_lit + (_)* @function.inside) @function.around + +; deftype, defrecord, defprotocol +(list_lit + . + (sym_lit) @_keyword + . + (sym_lit) + (_)* @class.inside + (#match? @_keyword "^(deftype|defrecord|defprotocol|definterface|defstruct)$")) @class.around + +; Test definitions (deftest) +(list_lit + . + (sym_lit) @_keyword + . + (sym_lit) + (_)* @test.inside + (#match? @_keyword "^deftest$")) @test.around + +; Function parameters in vectors +(vec_lit + (_)* @parameter.inside) @parameter.around + +; List entries +(list_lit + (_) @entry.inside @entry.around) + +; Vector entries +(vec_lit + (_) @entry.inside @entry.around) + +; Map entries +(map_lit + (_) @entry.inside @entry.around) + +; Set entries +(set_lit + (_) @entry.inside @entry.around) + +; Comments +(comment) @comment.inside +(comment)+ @comment.around + +; Discard expressions (also treated as comments) +(dis_expr) @comment.inside + +; Comment special form (comment ...) +(list_lit + . + (sym_lit) @_comment + (_)* @comment.inside + (#match? @_comment "^comment$")) @comment.around diff --git a/vendor/queries/cpp/textobjects.scm b/vendor/queries/cpp/textobjects.scm new file mode 100644 index 00000000..dba417a9 --- /dev/null +++ b/vendor/queries/cpp/textobjects.scm @@ -0,0 +1,39 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/cpp/textobjects.scm, with what it inherits from c inlined. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (_) @function.inside) @function.around + +(struct_specifier + body: (_) @class.inside) @class.around + +(enum_specifier + body: (_) @class.inside) @class.around + +(union_specifier + body: (_) @class.inside) @class.around + +(parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +(enumerator + (_) @entry.inside) @entry.around + +(initializer_list + (_) @entry.around) + + +(lambda_expression + body: (_) @function.inside) @function.around + +(class_specifier + body: (_) @class.inside) @class.around diff --git a/vendor/queries/css/textobjects.scm b/vendor/queries/css/textobjects.scm new file mode 100644 index 00000000..c6713c53 --- /dev/null +++ b/vendor/queries/css/textobjects.scm @@ -0,0 +1,27 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/css/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Rule sets and their declaration blocks +(rule_set) @function.around +(rule_set + (block) @function.inside) + +(keyframe_block_list) @function.around +(keyframe_block + (block) @function.inside) + +; At-rules with a body (e.g. @media, @supports) +(media_statement) @class.around +(media_statement + (block) @class.inside) + +; Declarations as key/value entries +(declaration) @entry.around +(declaration + ":" + (_) @entry.inside) + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/elixir/textobjects.scm b/vendor/queries/elixir/textobjects.scm new file mode 100644 index 00000000..ebe18d08 --- /dev/null +++ b/vendor/queries/elixir/textobjects.scm @@ -0,0 +1,40 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/elixir/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Function heads and guards have no body at all, so `keywords` and `do_block` nodes are both optional +((call + target: (identifier) @_keyword + (arguments + [ + (call + (arguments (_)? @parameter.inside)) + ; function has a guard + (binary_operator + left: + (call + (arguments (_)? @parameter.inside))) + ] + ; body is "do: body" instead of a do-block + (keywords + (pair + value: (_) @function.inside))?)? + (do_block (_)* @function.inside)?) + (#match? @_keyword "^(def|defdelegate|defguard|defguardp|defmacro|defmacrop|defn|defnp|defp)$")) @function.around + +(anonymous_function + (stab_clause right: (body) @function.inside)) @function.around + +((call + target: (identifier) @_keyword + (do_block (_)* @class.inside)) + (#match? @_keyword "^(defmodule|defprotocol|defimpl)$")) @class.around + +((call + target: (identifier) @_keyword + (arguments ((string) . (_)?)) + (do_block (_)* @test.inside)?) + (#match? @_keyword "^(test|describe)$")) @test.around + +(comment)+ @comment.around @comment.inside diff --git a/vendor/queries/go/textobjects.scm b/vendor/queries/go/textobjects.scm new file mode 100644 index 00000000..48529015 --- /dev/null +++ b/vendor/queries/go/textobjects.scm @@ -0,0 +1,39 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/go/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_declaration + body: (block)? @function.inside) @function.around + +(func_literal + body: (block)? @function.inside) @function.around + +(method_declaration + body: (block)? @function.inside) @function.around + +;; struct and interface declaration as class textobject? +(type_declaration + (type_spec (type_identifier) (struct_type (field_declaration_list (_)?) @class.inside))) @class.around + +(type_declaration + (type_spec (type_identifier) (interface_type (method_elem)+ @class.inside))) @class.around + +(type_parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +; Go test/benchmark/example/fuzz functions (all live in _test.go). +((function_declaration + name: (identifier) @_name + body: (block)? @test.inside) @test.around + (#match? @_name "^(Test|Benchmark|Example|Fuzz)")) diff --git a/vendor/queries/haskell/textobjects.scm b/vendor/queries/haskell/textobjects.scm new file mode 100644 index 00000000..2a085248 --- /dev/null +++ b/vendor/queries/haskell/textobjects.scm @@ -0,0 +1,29 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/haskell/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(comment) @comment.inside +(comment)+ @comment.around + +(newtype + (newtype_constructor + (_) @class.inside)) @class.around +(data_type + constructors: (_) @class.inside) @class.around +(decl/function + (match expression:(_) @function.inside)) @function.around +(lambda + expression:(_) @function.inside) @function.around + +(decl/function + patterns: (patterns + (_) @parameter.inside)) + +(expression/lambda + patterns: (patterns + (_) @parameter.inside)) + +(decl/function + (infix + (pattern) @parameter.inside)) diff --git a/vendor/queries/helix.LICENSE b/vendor/queries/helix.LICENSE new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/vendor/queries/helix.LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/vendor/queries/html/textobjects.scm b/vendor/queries/html/textobjects.scm new file mode 100644 index 00000000..b7c4b83e --- /dev/null +++ b/vendor/queries/html/textobjects.scm @@ -0,0 +1,14 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/html/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(script_element (start_tag) (_) @xml-element.inside (end_tag)) @xml-element.around + +(style_element (start_tag) (_) @xml-element.inside (end_tag)) @xml-element.around + +(element (start_tag) (_)* @xml-element.inside (end_tag)) + +(element) @xml-element.around + +(comment) @comment.around diff --git a/vendor/queries/java/textobjects.scm b/vendor/queries/java/textobjects.scm new file mode 100644 index 00000000..306efb56 --- /dev/null +++ b/vendor/queries/java/textobjects.scm @@ -0,0 +1,49 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/java/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(method_declaration + body: (_)? @function.inside) @function.around + +(constructor_declaration + body: (_) @function.inside) @function.around + +(interface_declaration + body: (_) @class.inside) @class.around + +(class_declaration + body: (_) @class.inside) @class.around + +(record_declaration + body: (_) @class.inside) @class.around + +(enum_declaration + body: (_) @class.inside) @class.around + +(formal_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +[ + (line_comment) + (block_comment) +] @comment.inside + +(line_comment)+ @comment.around + +(block_comment) @comment.around + +(array_initializer + (_) @entry.around) + +(enum_body + (enum_constant) @entry.around) diff --git a/vendor/queries/javascript/textobjects.scm b/vendor/queries/javascript/textobjects.scm new file mode 100644 index 00000000..50fe599c --- /dev/null +++ b/vendor/queries/javascript/textobjects.scm @@ -0,0 +1,8 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/javascript/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; See runtime/queries/ecma/README.md for more info. + +; inherits: _javascript,ecma diff --git a/vendor/queries/json/textobjects.scm b/vendor/queries/json/textobjects.scm new file mode 100644 index 00000000..e0df6ecd --- /dev/null +++ b/vendor/queries/json/textobjects.scm @@ -0,0 +1,13 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/json/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(pair + (_) @entry.inside) @entry.around + +(array + (_) @entry.around) + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/kotlin/textobjects.scm b/vendor/queries/kotlin/textobjects.scm new file mode 100644 index 00000000..b02a8f7e --- /dev/null +++ b/vendor/queries/kotlin/textobjects.scm @@ -0,0 +1,50 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/kotlin/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_declaration + (function_body)? @function.inside) @function.around + +; Unlike function_body above, the constructor body is does not have its own +; symbol in the current grammar. +(secondary_constructor) @function.around + +(class_declaration + (class_body)? @class.inside) @class.around + +(class_declaration + (enum_class_body) @class.inside) @class.around + +[ + (line_comment) + (multiline_comment) +] @comment.inside + +(line_comment)+ @comment.around + +(multiline_comment) @comment.around + +(enum_entry) @entry.around +(lambda_literal) @entry.around +(property_declaration) @entry.around +(object_declaration) @entry.around +(assignment) @entry.around + +; TODO: This doesn't work with annotations yet, but fixing it without breaking +; the case of multiple parameters is non-trivial. +(function_value_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +; secondary constructor uses function_value_parameters above +(primary_constructor + ((_)@parameter.inside . ","? @parameter.around) @parameter.around) + +(function_type_parameters + ((_)@parameter.inside . ","? @parameter.around) @parameter.around) + +(value_arguments + ((_)@parameter.inside . ","? @parameter.around) @parameter.around) + +(lambda_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) diff --git a/vendor/queries/ocaml/textobjects.scm b/vendor/queries/ocaml/textobjects.scm new file mode 100644 index 00000000..9e6bb2cd --- /dev/null +++ b/vendor/queries/ocaml/textobjects.scm @@ -0,0 +1,37 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/ocaml/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Value/function definitions +(value_definition) @function.around +(value_definition + (let_binding + body: (_) @function.inside)) + +(fun_expression + body: (_) @function.inside) @function.around + +(function_expression) @function.around + +(method_definition + body: (_) @function.inside) @function.around + +; Types and modules +(type_definition) @class.around +(type_definition + (type_binding + body: (_) @class.inside)) + +(module_definition) @class.around +(module_definition + (module_binding + body: (_) @class.inside)) + +; Parameters +(parameter) @parameter.around +(parameter + pattern: (_) @parameter.inside) + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/pascal/textobjects.scm b/vendor/queries/pascal/textobjects.scm new file mode 100644 index 00000000..9abb7871 --- /dev/null +++ b/vendor/queries/pascal/textobjects.scm @@ -0,0 +1,14 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/pascal/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(declType (declClass (declSection) @class.inside)) @class.around + +(defProc body: (_) @function.inside) @function.around + +(declArgs (_) @parameter.inside) @parameter.around +(exprArgs (_) @parameter.inside) @parameter.around + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/php/textobjects.scm b/vendor/queries/php/textobjects.scm new file mode 100644 index 00000000..8bf7fab9 --- /dev/null +++ b/vendor/queries/php/textobjects.scm @@ -0,0 +1,63 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/php/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(class_declaration + body: (_) @class.inside) @class.around + +(interface_declaration + body: (_) @class.inside) @class.around + +(trait_declaration + body: (_) @class.inside) @class.around + +(enum_declaration + body: (_) @class.inside) @class.around + +(function_definition + body: (_) @function.inside) @function.around + +(method_declaration + body: (_) @function.inside) @function.around + +(arrow_function + body: (_) @function.inside) @function.around + +(anonymous_function + body: (_) @function.inside) @function.around + +(anonymous_class + body: (_) @class.inside) @class.around + +(property_hook + body: (_) @function.inside) @function.around + +(anonymous_function_use_clause + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(formal_parameters + ([ + (simple_parameter) + (variadic_parameter) + (property_promotion_parameter) + ] @parameter.inside . ","? @parameter.around) @parameter.around) + +(arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +(array_creation_expression + (array_element_initializer + (_) @entry.inside + ) @entry.around @entry.movement) + +(list_literal + (_) @entry.inside @entry.around @entry.movement) + +[ + (enum_case) +] @entry.around @entry.movement diff --git a/vendor/queries/python/textobjects.scm b/vendor/queries/python/textobjects.scm new file mode 100644 index 00000000..c4dbcd0e --- /dev/null +++ b/vendor/queries/python/textobjects.scm @@ -0,0 +1,40 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/python/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (block)? @function.inside) @function.around + +(class_definition + body: (block)? @class.inside) @class.around + +(parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(lambda_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +((function_definition + name: (identifier) @_name + body: (block)? @test.inside) @test.around + (#match? @_name "^test_")) + +(list + (_) @entry.around) + +(tuple + (_) @entry.around) + +(set + (_) @entry.around) + +(pair + (_) @entry.inside) @entry.around diff --git a/vendor/queries/ruby/textobjects.scm b/vendor/queries/ruby/textobjects.scm new file mode 100644 index 00000000..368d80b9 --- /dev/null +++ b/vendor/queries/ruby/textobjects.scm @@ -0,0 +1,61 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/ruby/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Class and Modules +(class + body: (_)? @class.inside) @class.around + +(singleton_class + value: (_) + (_)+ @class.inside) @class.around + +(call + receiver: (constant) @class_const + method: (identifier) @class_method + (#match? @class_const "Class") + (#match? @class_method "new") + (do_block (_)+ @class.inside)) @class.around + +(module + body: (_)? @class.inside) @class.around + +; Functions and Blocks +(singleton_method + body: (_)? @function.inside) @function.around + +(method + body: (_)? @function.inside) @function.around + +(do_block + body: (_)? @function.inside) @function.around + +(block + body: (_)? @function.inside) @function.around + +; Parameters +(method_parameters + (_) @parameter.inside) @parameter.around + +(block_parameters + (_) @parameter.inside) @parameter.around + +(lambda_parameters + (_) @parameter.inside) @parameter.around + +; Comments +(comment) @comment.inside +(comment)+ @comment.around + +(pair + (_) @entry.inside) @entry.around + +(array + (_) @entry.around) + +(string_array + (_) @entry.around) + +(symbol_array + (_) @entry.around) diff --git a/vendor/queries/rust/textobjects.scm b/vendor/queries/rust/textobjects.scm new file mode 100644 index 00000000..d888c762 --- /dev/null +++ b/vendor/queries/rust/textobjects.scm @@ -0,0 +1,90 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/rust/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_item + body: (_) @function.inside) @function.around + +(closure_expression + body: (_) @function.inside) @function.around + +(struct_item + body: (_) @class.inside) @class.around + +(enum_item + body: (_) @class.inside) @class.around + +(union_item + body: (_) @class.inside) @class.around + +(trait_item + body: (_) @class.inside) @class.around + +(impl_item + body: (_) @class.inside) @class.around + +(parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(closure_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(field_initializer_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +[ + (line_comment) + (block_comment) +] @comment.inside + +(line_comment)+ @comment.around + +(block_comment) @comment.around + +(; #[test] + (attribute_item + (attribute + (identifier) @_test_attribute)) + ; allow other attributes like #[should_panic] and comments + [ + (attribute_item) + (line_comment) + ]* + ; the test function + (function_item + body: (_) @test.inside) @test.around + (#eq? @_test_attribute "test")) + +(array_expression + (_) @entry.around) + +(tuple_expression + (_) @entry.around) + +(tuple_pattern + (_) @entry.around) + +; Commonly used vec macro initializer is special cased +(macro_invocation + (identifier) @_id (token_tree (_) @entry.around) + (#eq? @_id "vec")) + +(enum_variant) @entry.around + +(field_declaration + (_) @entry.inside) @entry.around + +(field_initializer + (_) @entry.inside) @entry.around + +(shorthand_field_initializer) @entry.around diff --git a/vendor/queries/scala/textobjects.scm b/vendor/queries/scala/textobjects.scm new file mode 100644 index 00000000..7ff60759 --- /dev/null +++ b/vendor/queries/scala/textobjects.scm @@ -0,0 +1,70 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/scala/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Function queries + +(function_definition + body: (_) @function.inside) @function.around ; Does not include end marker + +(lambda_expression + (_) @function.inside) @function.around + +; Scala 3 braceless lambda +(colon_argument + (_) @function.inside) @function.around + + +; Class queries + +(object_definition + body: (_)? @class.inside) @class.around + +(class_definition + body: (_)? @class.inside) @class.around + +(trait_definition + body: (_)? @class.inside) @class.around + +(type_definition) @class.around + +(enum_case_definitions) @class.around + +(enum_definition + body: (_)? @class.inside) @class.around + + +; Parameter queries + +(parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(class_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(parameter_types + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(bindings + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +; Does not match context bounds or higher-kinded types +(type_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + + +; Comment queries + +[(comment) (block_comment)] @comment.inside +[(comment) (block_comment)] @comment.around ; Does not match consecutive block comments + + +; Test queries +; Not supported diff --git a/vendor/queries/typst/textobjects.scm b/vendor/queries/typst/textobjects.scm new file mode 100644 index 00000000..64c99275 --- /dev/null +++ b/vendor/queries/typst/textobjects.scm @@ -0,0 +1,28 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/typst/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(let + pattern: (call) + value: (_) @function.inside) @function.around + +(call + (group + ((_) @parameter.inside . ","? @parameter.around) @parameter.around)) + +(lambda + pattern: + (group + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + value: (_) @function.inside) @function.around + +(group + [ + (tagged (_) @entry.inside) + (_) + ] @entry.around) + +(comment) @comment.inside + +(comment)+ @comment.around -- cgit v1.3 From c5d96bec3d5279904525292ef933a985a974b017 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 17:13:46 -0300 Subject: Zig has textobjects: helix's query, ported to the zig grammar pardes builds pardes builds maxxnino/tree-sitter-zig, whose node names are not those of the grammar helix's query targets, so the query is ported by hand (vendor/queries/zig, its MPL header saying it is modified and how): a function is a Decl holding an FnProto and a Block with pub beside it, struct/enum/union one ContainerDecl, parameters and arguments ParamDeclList and FnCallArguments, and comments come in three kinds. That grammar's comments take their line's newline, and a node ending in a newline is now taken without it, as helix's grammars have it. The replay test gains fifteen Zig cases from the installed hx; none walks call arguments, which the reference helix counts as parameters and 25.07.1's query does not. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/normal.zig | 22 +++++++++++++++++++-- src/syntax.zig | 5 ++++- vendor/queries/README.md | 7 +++++-- vendor/queries/zig/textobjects.scm | 40 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 vendor/queries/zig/textobjects.scm (limited to 'src/normal.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index b6ac6475..c703a3da 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -218,7 +218,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `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) | | `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified | | `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | walk the file's syntax tree from every range: grow to the enclosing node (and remember what it grew from), shrink back to that or to the first child, the previous / next sibling (anonymous ones included, so often a comma), every named sibling or child, the enclosing named node's end / start (stretching to it in select mode) | helix `expand_selection` and kin, on a whole-file parse (`File.node_tree`) made on the first such key after an edit and reused until the next; a range the tree says nothing about, and every range of a file without a grammar, stays. Checked against the installed hx on JSON (`normal.zig` test): the harness runs without grammars, so it only proves the plain-text no-op | helix-verified (plain text), hx-checked (JSON) | -| `]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x`, `mi`/`ma` + `f t a c T e x` | function, class, argument, comment, test, entry, element: jump to the next one starting after the cursor / the previous one ending before it (count times; select mode stretches to it), or select the smallest one around the cursor, inside or around | helix `goto_ts_object` / `textobject_treesitter`, from helix's own `textobjects.scm` vendored in `vendor/queries` (MPL-2.0), with its `#eq?`/`#match?` predicates; `syntax.objectAt`/`objectNext` on the file's kept parse. Grammars without a query (fortran, markdown, powershell) or whose query does not compile against pardes's grammar (erlang, zig) have none, and the keys do nothing there. Checked against the installed hx on Python and JSON (`normal.zig` test) | hx-checked | +| `]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x`, `mi`/`ma` + `f t a c T e x` | function, class, argument, comment, test, entry, element: jump to the next one starting after the cursor / the previous one ending before it (count times; select mode stretches to it), or select the smallest one around the cursor, inside or around | helix `goto_ts_object` / `textobject_treesitter`, from helix's own `textobjects.scm` vendored in `vendor/queries` (MPL-2.0), with its `#eq?`/`#match?` predicates; `syntax.objectAt`/`objectNext` on the file's kept parse. Zig's query is helix's ported by hand to the zig grammar pardes builds, and a node that ends in its line's newline (that grammar's comments) is taken without it, as helix's grammars have it. Grammars without a query (fortran, markdown, powershell) or whose query does not compile against pardes's grammar (erlang) have none, and the keys do nothing there. Checked against the installed hx on Python, JSON and Zig (`normal.zig` test) | hx-checked | | `%` | 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) | diff --git a/src/normal.zig b/src/normal.zig index b68f9ba6..17ebaac6 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -1072,8 +1072,8 @@ test "one cursor holds no room for other ranges; a second range makes it and one try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); } -test "tree-sitter textobject keys do in Python and JSON what hx 25.07.1 does" { - if (!syntax.supportsPath("a.py") or !syntax.supportsPath("a.json")) return; +test "tree-sitter textobject keys do in Python, JSON and Zig what hx 25.07.1 does" { + if (!syntax.supportsPath("a.py") or !syntax.supportsPath("a.json") or !syntax.supportsPath("a.zig")) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -1097,6 +1097,24 @@ test "tree-sitter textobject keys do in Python and JSON what hx 25.07.1 does" { .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]ed", .want = "{, \"b\": [1, 2]}\n" }, .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]e]ed", .want = "{\"a\": 1, }\n" }, .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "8lmaed", .want = "{\"a\": 1,\"b\": [1, 2]}\n" }, + // Zig: the query is helix's, ported to pardes's zig grammar. No case + // walks call arguments: the reference helix counts them as + // parameters and the installed 25.07.1's query does not. + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]fd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "5jmifd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 \n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "5jmafd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\n\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "9jmitd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "9jmatd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\n\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]Td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\n\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16jmiTd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" \n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "3j11lmiad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "3j11lmaad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add( b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]cd", .want = "const std = @import(\"std\");\n\n\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "4jmacd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16jf2[ad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, ) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16j[fd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]t]td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\n\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, }) |c| { const pane = try p.setTestFile(c.text); p.gpa.free(pane.file.?.path); diff --git a/src/syntax.zig b/src/syntax.zig index 3a83800b..4494945f 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -475,7 +475,10 @@ fn matchSpan(q: *const ts.Query, match: anytype, capture: u32, text: []const u8) const s: Span = .{ .from = c.node.startByte(), .to = c.node.endByte() }; span = if (span) |old| .{ .from = @min(old.from, s.from), .to = @max(old.to, s.to) } else s; }; - const found = span orelse return null; + var found = span orelse return null; + // a node that takes its line's newline (this zig grammar's comments) + // is the line without it, as helix's grammars have it + if (found.to > found.from + 1 and found.to <= text.len and text[found.to - 1] == '\n') found.to -= 1; const steps = q.predicatesForPattern(match.pattern_index); var i: usize = 0; while (i < steps.len) { diff --git a/vendor/queries/README.md b/vendor/queries/README.md index abd37d00..082f4e6b 100644 --- a/vendor/queries/README.md +++ b/vendor/queries/README.md @@ -8,8 +8,11 @@ is `helix.LICENSE` here. They give `]f`, `[f`, `mif` and the other tree-sitter textobject keys what a function, class, argument, comment, test, entry or element is. +`zig/textobjects.scm` is helix's query MODIFIED by hand for the zig grammar +pardes builds, which names its nodes differently; its header says how. + Not vendored: fortran, markdown and powershell (helix has no query for -them), and erlang and zig, whose helix queries do not compile against the -grammar versions pardes builds (a node name either side has renamed). Those +them), and erlang, whose helix query does not compile against the grammar +version pardes builds. Those grammars have no textobjects, and the keys do nothing there, as in helix without a query. diff --git a/vendor/queries/zig/textobjects.scm b/vendor/queries/zig/textobjects.scm new file mode 100644 index 00000000..1c40b137 --- /dev/null +++ b/vendor/queries/zig/textobjects.scm @@ -0,0 +1,40 @@ +; Adapted from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/zig/textobjects.scm, MODIFIED for the zig grammar pardes +; builds (maxxnino/tree-sitter-zig), whose node names differ from the +; tree-sitter-grammars one helix uses: function_declaration is a Decl holding +; an FnProto and a Block, with `pub` a sibling before it; variable_declaration +; is VarDecl; struct/enum/union are one ContainerDecl; parameters and +; arguments are ParamDeclList and FnCallArguments; comments come in three +; kinds. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(_ + "pub"? @function.around + . + (Decl + (FnProto) + (Block) @function.inside) @function.around) + +(TestDecl (Block) @test.inside) @test.around + +; matches all of: struct, enum, union (one ContainerDecl in this grammar), +; `pub` with them where there is one +(_ + "pub"? @class.around + . + (Decl + (VarDecl + (ErrorUnionExpr + (SuffixExpr + (ContainerDecl + [(ContainerField) (Decl) (TestDecl) (ComptimeDecl)] @class.inside))))) @class.around) + +(ParamDeclList + ((ParamDecl) @parameter.inside . ","? @parameter.around) @parameter.around) + +(FnCallArguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +[(line_comment) (doc_comment) (container_doc_comment)] @comment.inside +[(line_comment) (doc_comment) (container_doc_comment)]+ @comment.around -- cgit v1.3