diff options
| -rw-r--r-- | docs/helix-keys.md | 3 | ||||
| -rw-r--r-- | src/config.zig | 1 | ||||
| -rw-r--r-- | src/edit.zig | 80 | ||||
| -rw-r--r-- | src/modal.zig | 4 | ||||
| -rw-r--r-- | src/normal.zig | 1 | ||||
| -rw-r--r-- | test/hxcases/cases.jsonl | 7 | ||||
| -rw-r--r-- | test/hxcases/goldens.jsonl | 7 |
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} |
