summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/helix-keys.md3
-rw-r--r--src/config.zig1
-rw-r--r--src/edit.zig80
-rw-r--r--src/modal.zig4
-rw-r--r--src/normal.zig1
-rw-r--r--test/hxcases/cases.jsonl7
-rw-r--r--test/hxcases/goldens.jsonl7
7 files changed, 101 insertions, 2 deletions
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-(` / `<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
@@ -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+<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": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=<ret>&"}
+{"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,<ret>&"}
+{"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,<ret>&"}
+{"name": "msel-align-words", "pane": "file", "text": "x = 1\nlonger = 22\n", "keys": "%s\\w+<ret>&"}
+{"name": "msel-align-backward", "pane": "file", "text": "x = 11\nlonger = 222\n", "keys": "%s\\d+<ret><A-;>&"}
+{"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=<ret>v&"}
{"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>"}
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}