summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 15:31:55 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit4315121885bebd48f8c03a2b1c7b0bd943c67afd (patch)
treeb9072c654cd486a7e817c7c3377bc49716e3afd2
parente35e4654c34cf303cf3bc6077a3cbffee70f7909 (diff)
downloadpardes-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.md2
-rw-r--r--src/config.zig1
-rw-r--r--src/edit.zig103
-rw-r--r--src/modal.zig5
-rw-r--r--src/normal.zig3
-rw-r--r--test/hxcases/cases.jsonl6
-rw-r--r--test/hxcases/goldens.jsonl6
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}}