summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/helix-keys.md4
-rw-r--r--src/config.zig2
-rw-r--r--src/edit.zig51
-rw-r--r--src/modal.zig15
-rw-r--r--src/normal.zig2
-rw-r--r--test/hxcases/cases.jsonl7
-rw-r--r--test/hxcases/goldens.jsonl7
7 files changed, 85 insertions, 3 deletions
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-(` / `<n>Alt-)` | rotate the CONTENTS of the selections forward / back by one range (n ranges), in one edit; each range comes back over the text it now holds and the primary moves with its text | helix `rotate_selection_contents_*` as of the harness's helix: 25.07.1 read the count as a GROUP size instead (rotate by one within each run of n ranges) and left the primary where it was, which is what helix-golf's `invert_dictionary_2` relies on | helix-verified |
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": "x<A-x>C_"}
+{"name": "msel-rotate-contents-fwd", "pane": "file", "text": "a b c\n", "keys": "%s\\w<ret><A-)>"}
+{"name": "msel-rotate-contents-back", "pane": "file", "text": "a b c\n", "keys": "%s\\w<ret><A-(>"}
+{"name": "msel-rotate-contents-count", "pane": "file", "text": "a b c d\n", "keys": "%s\\w<ret>2<A-)>"}
+{"name": "msel-rotate-contents-lengths", "pane": "file", "text": "one to three\n", "keys": "%s\\w+<ret>)<A-(>"}
+{"name": "msel-rotate-contents-backward", "pane": "file", "text": "ab cde\n", "keys": "%s\\w+<ret><A-;><A-)>"}
+{"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%<A-s><A-)>"}
+{"name": "rotate-contents-one", "pane": "file", "text": "ab\n", "keys": "vl<A-)>"}
{"name": "msel-insert", "pane": "file", "text": "aa\nbb\n", "keys": "Cix<esc>"}
{"name": "msel-insert-tty", "pane": "tty", "text": "aa\nbb\n", "keys": "Cix<esc>"}
{"name": "msel-append", "pane": "file", "text": "aa\nbb\n", "keys": "Caz<esc>"}
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}