diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 15:31:55 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | 4315121885bebd48f8c03a2b1c7b0bd943c67afd (patch) | |
| tree | b9072c654cd486a7e817c7c3377bc49716e3afd2 | |
| parent | e35e4654c34cf303cf3bc6077a3cbffee70f7909 (diff) | |
| download | pardes-4315121885bebd48f8c03a2b1c7b0bd943c67afd.tar.gz pardes-4315121885bebd48f8c03a2b1c7b0bd943c67afd.zip | |
Alt-J joins lines and selects the spaces it put in
helix-golf's text_into_array turns lines into an array with %<A-s>ms"<A-J>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 <[email protected]>
| -rw-r--r-- | docs/helix-keys.md | 2 | ||||
| -rw-r--r-- | src/config.zig | 1 | ||||
| -rw-r--r-- | src/edit.zig | 103 | ||||
| -rw-r--r-- | src/modal.zig | 5 | ||||
| -rw-r--r-- | src/normal.zig | 3 | ||||
| -rw-r--r-- | test/hxcases/cases.jsonl | 6 | ||||
| -rw-r--r-- | test/hxcases/goldens.jsonl | 6 |
7 files changed, 86 insertions, 40 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md index f6880f33..7fe2c476 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -187,6 +187,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `` ` `` | selection to lowercase | terminals: run byte only | helix-verified (phase 5) | | ``Alt-` `` | selection to uppercase | terminals: run byte only | helix-verified (phase 5) | | `J` | join lines in selection (or cur+next; helix ignores the count) | helix semantics: the newline + next line's leading whitespace collapse to ONE space (first line untrimmed); selection and cursor STAY where they were (vim's cursor-on-the-space removed in phase 5); terminals: no-op | helix-verified (phase 5) | +| `Alt-J` | join like `J`, then select the spaces the join put in (one bare cursor each); a join that put none in keeps the selection | helix `join_selections_space`. `J` and `Alt-J` are one edit over every range, so a line two ranges share is joined once | helix-verified | | `>` / `<` | indent / unindent selected lines (count times) | width = `modal.INDENT_W` = **4 spaces** (helix harness pins its no-language indent style to Spaces(4) to match); `<` also takes one leading tab as a level; empty lines never indented (helix); selection kept, positions mapped through the edit (phase 5) | helix-verified (phase 5) | | `Ctrl-a` / `Ctrl-x` | increment / decrement the decimal int under the cursor by count | helix-style: under the cursor only (no vim forward scan), `-` handled, i64 saturating, zero-padding width preserved (phase 5); cursor to the last digit, selection kept; terminals: no-op | helix-verified (phase 5) | | `Alt-d` | delete without yanking | `normalDelete(yank=false)` | helix-verified (phase 5) | @@ -347,7 +348,6 @@ text. | `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 | -| `Alt-J` | join + select the inserted space | marginal over `J` | | `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 9ad9cc7e..372c4884 100644 --- a/src/config.zig +++ b/src/config.zig @@ -538,6 +538,7 @@ pub const switch_case: []const Chord = &.{.{ .cp = '~' }}; 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 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 b548c7c6..09c3ffe7 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1214,53 +1214,82 @@ fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 { return out; } -pub fn normalJoin(p: *Pardes, t: *Text) void { +/// `J` / `Alt-J`: join every range's lines with the next, in ONE edit +/// (helix join_selections): the newline and the next line's leading blanks +/// become one space, or nothing when that line is blank, and a line two +/// ranges share is joined once. `Alt-J` then selects the spaces it put in. +pub fn normalJoin(p: *Pardes, t: *Text, select_space: bool) void { + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; // a join always eats the newline of its last line, so the buffer has - // to reach one row PAST the selection - const sr = selRows(t); - const eb = editText(p, t, sr.lo_row, sr.hi_row + 1, -1) orelse return; + // to reach one row PAST every range + 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 = editText(p, t, b.lo_row, b.hi_row + 1, -1) orelse return; const text = eb.text; - const range = t.primaryRange(text, eb.row0); - const span = Text.rangeLineSpan(text, range); + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, eb.row0, &rs); const nlines = modal.cursorLineCount(text); - var end = span.end; - if (span.start == end) end = @min(end + 1, nlines - 1); - if (end <= span.start) return; const arena = p.scratch.allocator(); - const chs = arena.alloc(TextChange, end - span.start) catch return; + const chs = arena.alloc(TextChange, nlines) catch return; var chs_len: usize = 0; - var l = span.start; - while (l < end) : (l += 1) { - const from = modal.lineEndOffset(text, l); - var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); - while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; - const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " "; - chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; - chs_len += 1; + var next: usize = 0; // lines below this one are joined already + for (rs[0..got.n]) |r| { + const span = Text.rangeLineSpan(text, r); + var end = span.end; + if (span.start == end) end = @min(end + 1, nlines - 1); + var l = @max(span.start, next); + while (l < end) : (l += 1) { + const from = modal.lineEndOffset(text, l); + var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); + while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; + const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " "; + chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; + chs_len += 1; + } + next = @max(next, end); } if (chs_len == 0) return; pushUndo(p, t); - const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.cur_col)) }); - const anc_off = if (t.vsel.active) - modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row - eb.row0)), .col = @intCast(@max(0, t.vsel.col)) }) - else - cur_off; const new = applyChanges(p, text, chs[0..chs_len]) catch return; - const nc = mapThroughChanges(chs[0..chs_len], cur_off); - const na = mapThroughChanges(chs[0..chs_len], anc_off); - setEditText(p, t, new); - const cc = modal.positionAt(new, nc); - const ac = modal.positionAt(new, na); - t.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; - t.cur_col = @intCast(cc.col); - if (t.vsel.active) { - t.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; - t.vsel.col = @intCast(ac.col); + // every range's cells ride through the edit; Alt-J replaces them with + // the spaces, when it put any in + var cells: [Text.max_selections]Text.SelRange = undefined; + var n: usize = 0; + var pri = got.pri; + if (select_space) { + var delta: i64 = 0; + for (chs[0..chs_len]) |ch| { + const at: usize = @intCast(@as(i64, @intCast(ch.from)) + delta); + delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from)); + if (ch.ins.len == 0 or n == cells.len) continue; + const pos = modal.positionAt(new, at); + cells[n] = .{ .row = @as(i32, @intCast(pos.row)) + eb.row0, .col = @intCast(pos.col), .arow = @as(i32, @intCast(pos.row)) + eb.row0, .acol = @intCast(pos.col) }; + n += 1; + } + pri = 0; } - t.msel.active = false; - t.cur_pinned = true; - t.sticky_col = -1; - t.show(); + if (n == 0) { + for (rs[0..got.n]) |r| { + const c = Text.rangeCells(text, r); + const cc = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.cur)); + const ac = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.anc)); + cells[n] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + n += 1; + } + pri = got.pri; + } + 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], &.{}, pri, expl); } pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { diff --git a/src/modal.zig b/src/modal.zig index 3827d11f..0e6aba61 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -108,6 +108,7 @@ pub const Normal = struct { to_lowercase, to_uppercase, join_lines, + join_select_space, indent, unindent, format, @@ -260,6 +261,7 @@ pub const Normal = struct { lowercase, uppercase, join_lines, + join_select_space, indent, unindent, comment_toggle, @@ -317,7 +319,7 @@ pub const Normal = struct { .search_step, => .once, .edit => |edit| switch (edit.kind) { - .comment_toggle, .undo, .redo => .once, + .join_lines, .join_select_space, .comment_toggle, .undo, .redo => .once, else => .per_selection, }, else => .per_selection, @@ -549,6 +551,7 @@ pub const Normal = struct { if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } }); 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(.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 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), diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index ada7363b..0b8858e6 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -302,6 +302,12 @@ {"name": "J-empty-next-nospace", "pane": "file", "text": "aa\n\nbb\n", "keys": "J"} {"name": "J-last-line-eats-nl", "pane": "file", "text": "aa\nbb\n", "keys": "jJ"} {"name": "J-count-ignored", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "3J"} +{"name": "msel-J-same-line", "pane": "file", "text": "aa bb\ncc\ndd\n", "keys": "wCJ"} +{"name": "msel-J-shared-line", "pane": "file", "text": "ab\ncd\nef\n", "keys": "%s\\w<ret>J"} +{"name": "alt-J-selects-space", "pane": "file", "text": "aa\n bb\ncc\n", "keys": "xx<A-J>"} +{"name": "alt-J-each-range", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "%<A-s><A-J>"} +{"name": "alt-J-blank-no-space", "pane": "file", "text": "aa\n\nbb\n", "keys": "<A-J>"} +{"name": "msel-J-two-lines", "pane": "file", "text": "aa bb\ncc\ndd\nee\n", "keys": "Cs\\w<ret>J"} {"name": "indent-basic", "pane": "file", "text": "foo\nbar\n", "keys": "<gt>"} {"name": "indent-realign", "pane": "file", "text": " x\n", "keys": "gs<gt>"} {"name": "indent-cursor-maps", "pane": "file", "text": "foo\n", "keys": "ll<gt>"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 51ab8939..79745d43 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -302,6 +302,12 @@ {"name":"J-empty-next-nospace","text":"aa\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"J-last-line-eats-nl","text":"aa\nbb","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} {"name":"J-count-ignored","text":"a b\nc\nd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"msel-J-same-line","text":"aa bb cc dd\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":6},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":6}}],"primary":1} +{"name":"msel-J-shared-line","text":"ab cd ef","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":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":7}}],"primary":0} +{"name":"alt-J-selects-space","text":"aa bb\ncc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"alt-J-each-range","text":"a b c d","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":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}}],"primary":0} +{"name":"alt-J-blank-no-space","text":"aa\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"msel-J-two-lines","text":"aa bb cc dd\nee\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":6},"anchor":{"row":0,"col":6}}],"primary":0} {"name":"indent-basic","text":" foo\nbar\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} {"name":"indent-realign","text":" x\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}} {"name":"indent-cursor-maps","text":" foo\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}} |
