summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 05:04:26 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit5bf8d6dd077517270377e5d8551108ecf252374f (patch)
treec7783fa9fc133980d3d6fff0129e8de5a1b43601 /src
parenteefac04995ffad847a4098f16d2e82ccab16438b (diff)
downloadpardes-5bf8d6dd077517270377e5d8551108ecf252374f.tar.gz
pardes-5bf8d6dd077517270377e5d8551108ecf252374f.zip
multiple cursors, regex selection, and Ctrl-c comments
The primary cursor stays exactly where it was — cur_row/cur_col plus vsel — and sels[] holds helix's OTHER ranges. That split is why nothing moved at one cursor: with nsel == 0 not one line of the existing motion, operator, render or mouse code takes a different branch, which is what protects 800 differential cases and 67 goldens. paneRanges/setPaneRanges are the whole list; setPaneRanges IS helix's Selection::new (min width 1, sorted, overlaps merged, primary follows its range through a merge). An ordinary key runs the single-selection handler once per range, visited last-first so an edit never disturbs a range still waiting, and each finished pass is remembered as a distance from the END of the text, which an earlier edit cannot move — helix's change mapping without a change map. pushUndo fires once per keystroke, yanks accumulate, and a builtin acts from the primary and stops the replay, which also closes the use-after-free window if it frees the pane. s and S reuse the / prompt wholesale rather than growing a second one: the pattern is typed into the tag tail, and every keystroke re-runs the match from the selection the prompt opened on, so the preview is live and Esc is just the empty pattern. mvzr does runtime patterns — a bytecode VM in a fixed-size struct with no allocator — with 64 ops and 8 char classes per pattern, no case-insensitive flag (helix's smart case is done by folding a scratch copy), no captures, no multi-line anchors. The last two are the two waivers. Ctrl-c is a whole-list key and not a per-cursor replay, because helix decides comment-vs-uncomment ONCE for the whole selection; replaying it would take that decision n times. Comment tokens are a table in config.zig keyed on the same extension syntax.zig picks grammars by. Found and fixed a pre-existing single-cursor bug on the way: la<bs><esc> left the cursor one cell before where the append began. helix's restore_cursor can never walk past the origin; ours backed up unconditionally. hxdiff was green before AND after — the old one-selection contract could not see it. hxdiff 360 -> 481 cases, hxparity 440 -> 561, all goldens from real helix; the harness contract now reports every range and its primary, omitted when there is one, so 359 of the 360 old goldens are byte-identical. The one that moved is o-count: helix's 2o really does leave two cursors and could not say so before.
Diffstat (limited to 'src')
-rw-r--r--src/config.zig62
-rw-r--r--src/pardes.zig1067
2 files changed, 1064 insertions, 65 deletions
diff --git a/src/config.zig b/src/config.zig
index 7f83155a..8f2175f6 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -443,6 +443,30 @@ pub const url_schemes = [_][]const u8{ "http://", "https://" };
/// a path ending in one of these opens an image pane instead of a file pane
pub const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" };
+/// What `Ctrl-c` (comment_toggle) puts at the front of a line, by file
+/// EXTENSION — which is how src/syntax.zig already tells one language from
+/// another, so this is that same notion and not a second one. A pane whose
+/// path matches nothing here (and every terminal, which has no path at all)
+/// gets `comment_token_default`.
+///
+/// `#` as the default is not a guess: it is helix's own DEFAULT_COMMENT_TOKEN,
+/// which is what a helix buffer with no language configured comments with —
+/// and therefore what the differential oracle answers, since the harness runs
+/// with zero language configs.
+/// Languages with no LINE comment at all (css, html, json, ocaml) are absent
+/// on purpose: helix leaves those buffers on its default too, and inventing a
+/// token for them would be a divergence nothing asked for.
+pub const comment_token_default = "#";
+pub const comment_tokens: []const struct { exts: []const []const u8, token: []const u8 } = &.{
+ .{ .token = "//", .exts = &.{ ".zig", ".zon", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".rs", ".go", ".java", ".scala", ".sc", ".kt", ".kts", ".cs", ".csx", ".php", ".pas", ".pp", ".p", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".swift", ".dart" } },
+ .{ .token = "#", .exts = &.{ ".py", ".pyw", ".sh", ".bash", ".zsh", ".rb", ".rake", ".ex", ".exs", ".ps1", ".psm1", ".psd1", ".pl", ".pm", ".r", ".jl", ".nix", ".toml", ".yaml", ".yml", ".cmake", ".mk", ".tf" } },
+ .{ .token = "--", .exts = &.{ ".lua", ".hs", ".lhs", ".elm", ".sql", ".adb", ".ads", ".ada" } },
+ .{ .token = ";", .exts = &.{ ".clj", ".cljs", ".cljc", ".edn", ".el", ".lisp", ".scm", ".asm", ".s" } },
+ .{ .token = "%", .exts = &.{ ".erl", ".hrl", ".tex", ".cls", ".sty" } },
+ .{ .token = "!", .exts = &.{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" } },
+ .{ .token = "\"", .exts = &.{ ".vim", ".vimrc" } },
+};
+
/// What an armed search writes into the tag tail — and the ONLY record of
/// which search it is: Enter reads the marker back (submitSearch) instead of
/// pardes carrying a second piece of pane state per command. The pattern is
@@ -454,6 +478,12 @@ pub const find_marker = " Find /";
pub const grep_marker = " Grep /";
pub const rename_marker = " Rename /";
pub const symbol_marker = " WsSymbols /";
+/// helix `s` / `S`. The only two markers whose word is NOT a builtin — there
+/// is no Select/Split command to run from a tag, they name the key that armed
+/// the input so the tag still reads as what it is about to do. They also mark
+/// the one input that previews as you type (pardes.zig, previewSelRegex).
+pub const select_marker = " Select /";
+pub const split_marker = " Split /";
/// Output-buffer names (acme's +Errors). Cosmetic now, and deliberately so: a
/// buffer is DERIVED from the command that opened it (output_pane.traits), and
@@ -605,6 +635,33 @@ pub const collapse_selection: []const Chord = &.{.{ .cp = ';' }};
pub const flip_selection: []const Chord = &.{.{ .cp = ';', .alt = true }};
pub const select_all: []const Chord = &.{.{ .cp = '%' }};
+// ---- multiple cursors ----
+//
+// helix's Selection is a LIST of ranges with a primary index, and these ten
+// keys are the ones that act on the list rather than on the text: every other
+// key is replayed once per range instead (pardes.zig, replaySels). Alt-C is
+// Alt-SHIFT-c and so does not collide with pane_to_new_column's Alt-c — `hit`
+// compares the codepoint, and `C` is `C`.
+pub const copy_sel_below: []const Chord = &.{.{ .cp = 'C' }};
+pub const copy_sel_above: []const Chord = &.{.{ .cp = 'C', .alt = true }};
+pub const keep_primary_sel: []const Chord = &.{.{ .cp = ',' }};
+pub const remove_primary_sel: []const Chord = &.{.{ .cp = ',', .alt = true }};
+pub const rotate_sel_fwd: []const Chord = &.{.{ .cp = ')' }};
+pub const rotate_sel_back: []const Chord = &.{.{ .cp = '(' }};
+pub const split_sel_newline: []const Chord = &.{.{ .cp = 's', .alt = true }};
+pub const merge_sels: []const Chord = &.{.{ .cp = '-', .alt = true }};
+pub const merge_consecutive_sels: []const Chord = &.{.{ .cp = '_', .alt = true }};
+pub const trim_sels: []const Chord = &.{.{ .cp = '_' }};
+
+// The other two list-making keys: a REGEX turns each range into many. Both
+// arm the tag input above (select_marker / split_marker) instead of doing
+// anything immediately, so `s` and `S` are the only normal-mode keys whose
+// effect lands a keystroke later, on Enter — or live, as you type.
+// `s` is free here despite `gs` (goto_first_nonws) also being `s`: a pending
+// prefix is matched by the stored codepoint, never by these chords.
+pub const select_regex: []const Chord = &.{.{ .cp = 's' }};
+pub const split_regex: []const Chord = &.{.{ .cp = 'S' }};
+
// ---- edits ----
pub const delete: []const Chord = &.{.{ .cp = 'd' }};
@@ -628,6 +685,11 @@ pub const increment: []const Chord = &.{.{ .cp = 'a', .ctrl = true }};
pub const decrement: []const Chord = &.{.{ .cp = 'x', .ctrl = true }};
pub const undo: []const Chord = &.{.{ .cp = 'u' }};
pub const redo: []const Chord = &.{.{ .cp = 'U' }};
+/// helix `toggle_comments`: comment or uncomment every line the selection
+/// touches, with `comment_tokens` above choosing the token. Ctrl-c reaches a
+/// terminal pane only in NORMAL mode — raw tty forwards it to the program,
+/// where it is still SIGINT.
+pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }};
/// Exit select mode / drop pending prefixes and the count; leave insert mode;
/// abandon a tag, an armed search or the topbar. On an OUTPUT buffer there is
diff --git a/src/pardes.zig b/src/pardes.zig
index c1f8df44..947c1c96 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -21,6 +21,7 @@
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
const uucode = @import("uucode");
+const mvzr = @import("mvzr");
const modal = @import("modal.zig");
const look = @import("look.zig");
const syntax = @import("syntax.zig");
@@ -266,6 +267,12 @@ const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
// modal line/char selection: warm tan, reads as keyboard-driven
const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
+// the OTHER cursors of a multi-selection: the same tan turned down, so the
+// primary is the one that reads as "here" at a glance (helix does the same
+// with its two selection styles). Each secondary's own cursor cell is then
+// painted msel_fg-on-msel_bg — there is only one hardware cursor.
+const msel2_bg = [3]u8{ 0x2e, 0x26, 0x16 };
+const msel2_fg = [3]u8{ 0xa8, 0x9c, 0x84 };
const UNDO_MAX = 256;
const EDIT_UNDO_MAX = 64; // terminal snapshots copy the edit buffer; cap tighter
@@ -662,6 +669,28 @@ pub const CharSel = struct {
explicit: bool = false,
};
+/// ponytail: at most this many cursors at once. helix's `Selection.ranges` is
+/// an unbounded Vec; a fixed array keeps a Pane trivially copyable (the undo
+/// snapshots memcpy it) and costs nothing at one cursor. The ceiling only
+/// bites on `C`/`Alt-s` over a very long selection, where the extra ranges are
+/// simply not created — raise the number, or swap in an ArrayList, if that
+/// ever matters.
+pub const MAX_SELS = 64;
+
+/// One selection range in PANE coordinates: the block-cursor cell and the
+/// anchor cell. Deliberately the same pair `cur_row`/`cur_col` + `vsel`
+/// already are, so a range moves in and out of the primary slot without a
+/// conversion.
+pub const SelRange = struct {
+ row: i32,
+ col: i32,
+ arow: i32,
+ acol: i32,
+ /// this range's own j/k goal column (helix Range::old_visual_position);
+ /// the PRIMARY's copy is Pane.sticky_col
+ sticky: i32 = -1,
+};
+
/// Undo snapshot of a terminal pane's edit buffer (whole-state, dumb on
/// purpose). `ovl` is null for a pane nothing has been typed into yet, and
/// owns its text exactly like FileSnap does.
@@ -674,6 +703,12 @@ pub const EditSnap = struct {
/// Undo snapshot of a file pane: content + the selection at commit time —
/// helix undo restores the selection along with the text.
+/// ponytail: the PRIMARY selection only. With multiple cursors an undo puts
+/// the primary back and leaves the others where they are — which is right
+/// whenever the undone edit is the one they made (the common case, and what
+/// the msel-undo differential case pins), and merely misplaced otherwise.
+/// Storing the whole list would put MAX_SELS ranges in every one of the 256
+/// snapshots a pane keeps, for a case nobody has complained about.
pub const FileSnap = struct {
content: []u8,
cur_row: i32,
@@ -740,6 +775,17 @@ pub const Pane = struct {
image: ?Image = null,
msel: LineSel = .{},
vsel: CharSel = .{},
+ /// MULTIPLE CURSORS. helix's Selection is a list of ranges plus a primary
+ /// index; pardes keeps the PRIMARY exactly where it has always been —
+ /// cur_row/cur_col + vsel — and the other ranges here, document-ordered
+ /// and disjoint (helix's Selection::normalize). That split is the whole
+ /// design: every motion, operator, renderer and mouse path in this file
+ /// still reads one selection, so with `nsel == 0` not a byte of behaviour
+ /// moves, and the 800 differential cases and 66 snapshots keep proving it.
+ /// The extra ranges are driven by replaying the single-selection key
+ /// handler once per range (see replaySels).
+ sels: [MAX_SELS - 1]SelRange = undefined,
+ nsel: u8 = 0,
/// helix select/extend mode (`v`): motions extend the selection from its
/// fixed anchor instead of replacing it. Reported as mode "select";
/// pane.mode stays .normal (insert/tty transitions drop it).
@@ -776,6 +822,21 @@ pub const Pane = struct {
search_at: u16 = 0,
search_pane: ?usize = null,
search_row: ?usize = null,
+ /// The selection an `s`/`S` input was armed on, as gap offsets over the
+ /// motion surface. Every keystroke re-derives the preview FROM here rather
+ /// than from the previous preview — which is what helix's regex_prompt
+ /// does (it reverts to its snapshot before each update), what makes typing
+ /// a pattern one character at a time land on the same answer as pasting it
+ /// whole, and what makes Esc a plain restore with nothing else to undo.
+ /// `nsel_snap == 0` means no such input is armed; exitTagEdit, the one
+ /// place search_edit is ever cleared, clears it too.
+ sel_snap: [MAX_SELS]modal.HxRange = undefined,
+ nsel_snap: u8 = 0,
+ sel_snap_pri: u8 = 0,
+ /// ...including whether it was a user-intent selection: a preview IS one
+ /// (you picked those matches), but restoring must not silently promote
+ /// motion residue into something the acme chords will act on
+ sel_snap_expl: bool = false,
/// the editable tag tail: seeded with the default builtins on first touch;
/// edited in place by a modal one-line editor sharing the pane's mode
tag_tail: std.ArrayList(u8) = .empty,
@@ -1015,6 +1076,16 @@ pub const Pardes = struct {
/// is proof the gesture ended, and still needs no clock.
wheel_guard: u8 = 0,
ctrl_w_pending: bool = false,
+ /// A key is being REPLAYED over several selections (replaySels). The one
+ /// thing the replay cannot do is let a per-pass action that is really a
+ /// per-KEYSTROKE action fire once per range: the undo snapshot must be
+ /// taken once, the yank register accumulates instead of being overwritten,
+ /// and anything that opens, closes or focuses a pane (runBuiltin) or asks
+ /// the language backend (lspRequest) happens on the first pass and stops
+ /// the replay dead — see multiOnce.
+ multi_on: bool = false,
+ multi_first: bool = false,
+ multi_stop: bool = false,
/// SPC leader in flight, holding the key path typed so far (empty = just
/// SPC). Global like ctrl_w_pending — there is one leader and it acts on
/// the active pane, whose tag shows the pending path while it waits.
@@ -1319,11 +1390,38 @@ pub const Pardes = struct {
}
fn setYank(p: *Pardes, text: []const u8) void {
+ // Inside a multi-selection replay the register collects EVERY range's
+ // text, in document order — the passes run last-range-first, so each
+ // new piece goes in front of what is already there.
+ // ponytail: helix keeps one register VALUE per range and pastes
+ // value[i] back at range[i]; pardes has a single register, so N
+ // cursors yank one newline-joined blob and a paste puts that whole
+ // blob at every cursor. Written down as a differential waiver rather
+ // than faked — a per-range register is its own feature.
+ if (p.multi_on and !p.multi_first) {
+ const old = p.yank orelse "";
+ const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n";
+ const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return;
+ if (p.yank) |y| p.gpa.free(y);
+ p.yank = joined;
+ return;
+ }
if (p.yank) |y| p.gpa.free(y);
p.yank = p.gpa.dupe(u8, text) catch null;
p.emit(.{ .set_clipboard = {} });
}
+ /// A per-KEYSTROKE action reached from inside a per-SELECTION replay —
+ /// one that opens, closes or focuses a pane, or asks the language backend.
+ /// It must happen once rather than once per cursor, and once it has, the
+ /// remaining passes are meaningless (the pane they would edit may be gone),
+ /// so the replay stops. True = this pass may go ahead.
+ fn multiOnce(p: *Pardes) bool {
+ if (!p.multi_on) return true;
+ p.multi_stop = true;
+ return p.multi_first;
+ }
+
// ---- tag + selection text (chord sources) ----
/// the live tag prefix: mode + cwd/path (an image has only its path — the
@@ -1389,6 +1487,7 @@ pub const Pardes = struct {
pane.tag_edit = false;
pane.tag_sel = false;
pane.search_edit = false; // an abandoned search input stays as tag text
+ pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed
}
fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } {
@@ -1834,7 +1933,13 @@ pub const Pardes = struct {
// a search input in flight (`/` or Find): Enter searches, Esc abandons;
// both restore the tag tail and hand focus back to the body.
if (pane.search_edit and (hit(key, config.search_submit) or hit(key, config.escape))) {
- if (hit(key, config.search_submit)) p.submitSearch(p.active);
+ if (hit(key, config.search_submit))
+ p.submitSearch(p.active)
+ else if (selRegexArmed(pane)) |_|
+ // Esc on an `s`/`S` preview: back to the selection it opened
+ // on. The empty pattern applies nothing, so this IS the
+ // snapshot — one path, not a second restore.
+ p.applySelRegex(pane, "", false);
pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len));
exitTagEdit(pane);
pane.mode = .normal;
@@ -1846,6 +1951,11 @@ pub const Pardes = struct {
// tag owns Enter/Tab too (that IS the `:` command line).
if (pane.tag_edit) {
if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key);
+ // an armed `s`/`S` re-runs its pattern after EVERY keystroke: that
+ // live preview is what makes it interactive. Through the slot, not
+ // `pane` — a tag chord above can run a builtin that closed it.
+ const pn = p.panes[p.active] orelse return;
+ if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split);
return;
}
// the acme chords in the body — look at / execute (config.look_key and
@@ -1877,7 +1987,11 @@ pub const Pardes = struct {
// Skipped while a prefix waits for its char argument (f/r/m and
// friends take `p` literally).
if (pane.mode == .normal and pane.pending == 0 and (hit(key, config.paste_after) or hit(key, config.paste_before))) {
- p.normalPaste(pane, hit(key, config.paste_before));
+ const before = hit(key, config.paste_before);
+ if (pane.nsel == 0)
+ p.normalPaste(pane, before)
+ else
+ p.replaySels(pane, if (before) .paste_before else .paste_after, key);
return;
}
switch (pane.mode) {
@@ -1895,14 +2009,36 @@ pub const Pardes = struct {
const pl = p.paneCursorLines(pane) catch return;
const text = p.flatSurface(pane, pl) catch return;
const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) });
- const back = modal.prevGrapheme(text, gap);
+ const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) });
+ // helix restore_cursor is Range::new(from, prev(to)) on
+ // the GAP range, so it can never walk back past the
+ // append origin — a session whose edits ate everything
+ // typed collapses ONTO it. Without the clamp the cursor
+ // lands one cell before where the append began (`la`,
+ // Backspace, Esc).
+ const back = @max(a_off, modal.prevGrapheme(text, gap));
const bc = modal.hxPos(text, back);
pane.cur_row = @intCast(bc.row);
pane.cur_col = @intCast(bc.col);
- const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) });
pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false };
pane.cur_pinned = true;
pane.ensureCursorVisible();
+ // The other cursors move the same way the primary did —
+ // every one of them was handed the same keys, so every
+ // session shrank by the same grapheme — but they
+ // COLLAPSE rather than span: only the primary remembers
+ // where its append began (append_at is one field).
+ // ponytail: helix restores every range's appended-over
+ // span; store an origin per SelRange if that matters.
+ for (pane.sels[0..pane.nsel]) |*s| {
+ const sgap = modal.hxOff(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) });
+ const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap);
+ const bp = modal.hxPos(text, b2);
+ s.row = @intCast(bp.row);
+ s.col = @intCast(bp.col);
+ s.arow = s.row;
+ s.acol = s.col;
+ }
}
return;
}
@@ -1972,6 +2108,7 @@ pub const Pardes = struct {
pane.mode = .tty;
pane.msel.active = false;
pane.vsel.active = false;
+ pane.nsel = 0; // raw tty: the program owns the pane, and its cursor is the shell's
pane.select = false;
pane.append_at = null;
pane.sticky_col = -1;
@@ -2209,49 +2346,617 @@ pub const Pardes = struct {
const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1);
return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) };
}
- if (pane.vsel.active) {
- const a = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - row0)), .col = @intCast(@max(0, pane.vsel.col)) });
- if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) };
- return .{ .anchor = modal.nextGrapheme(text, a), .head = c };
- }
+ if (pane.vsel.active) return cellRange(text, pane.vsel.row - row0, pane.vsel.col, pane.cur_row - row0, pane.cur_col);
return .{ .anchor = c, .head = modal.nextGrapheme(text, c) };
}
+ /// a gap range from its two block-cursor CELLS — the arithmetic paneRange
+ /// does for cur/vsel, shared with the extra selections, which are stored
+ /// in exactly the same shape
+ fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.HxRange {
+ const a = modal.hxOff(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) });
+ const c = modal.hxOff(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) });
+ return cellOffRange(text, a, c);
+ }
+
+ /// the same, from the two cells' gap offsets
+ fn cellOffRange(text: []const u8, a: usize, c: usize) modal.HxRange {
+ if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) };
+ return .{ .anchor = modal.nextGrapheme(text, a), .head = c };
+ }
+
+ /// the inverse: a gap range's cursor and anchor CELLS (equal for a bare
+ /// 1-wide cursor). setPaneRange's own conversion, factored out so the
+ /// extra selections write back through the same three lines.
+ fn rangeCells(text: []const u8, r: modal.HxRange) struct { cur: usize, anc: usize } {
+ if (r.head > r.anchor) return .{ .cur = modal.prevGrapheme(text, r.head), .anc = r.anchor };
+ if (r.head < r.anchor) return .{ .cur = r.head, .anc = modal.prevGrapheme(text, r.anchor) };
+ return .{ .cur = r.head, .anc = r.head };
+ }
+
/// write a helix range back into pane state. `explicit` marks user-intent
/// selections (v/x/X, terminal n/N, file-search n/N) — the acme chords
/// act only on those; motion residue stays implicit.
fn setPaneRange(pane: *Pane, pl: PaneLines, text: []const u8, r0: modal.HxRange, explicit: bool) void {
var r = r0;
if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1
- var cur_off: usize = undefined;
- var anc_off: usize = undefined;
- if (r.head > r.anchor) {
- cur_off = modal.prevGrapheme(text, r.head);
- anc_off = r.anchor;
- } else if (r.head < r.anchor) {
- cur_off = r.head;
- anc_off = modal.prevGrapheme(text, r.anchor);
- } else {
- cur_off = r.head;
- anc_off = r.head;
- }
- const cc = modal.hxPos(text, cur_off);
- const ac = modal.hxPos(text, anc_off);
+ const off = rangeCells(text, r);
+ const cc = modal.hxPos(text, off.cur);
+ const ac = modal.hxPos(text, off.anc);
pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0;
pane.cur_col = @intCast(cc.col);
pane.vsel = .{
- .active = anc_off != cur_off or pane.select,
+ .active = off.anc != off.cur or pane.select,
.row = @as(i32, @intCast(ac.row)) + pl.row0,
.col = @intCast(ac.col),
.explicit = explicit or pane.select,
};
pane.msel.active = false;
+ pane.nsel = 0; // writing ONE range means the selection IS that range
pane.cur_pinned = true;
pane.sticky_col = -1;
pane.pending = 0;
pane.ensureCursorVisible();
}
+ // ---- the OTHER selections (helix Selection.ranges / primary_index) ----
+ // Two functions read and write the whole list; everything else in this
+ // file still speaks the single primary range, and replaySels below is what
+ // makes an ordinary key act at every cursor.
+
+ /// The whole selection as helix gap ranges over `text`, DOCUMENT ORDER
+ /// (pane.sels is kept that way, so this only has to slot the primary in).
+ /// Returns how many were written and which index is the primary.
+ fn paneRanges(pane: *Pane, text: []const u8, row0: i32, out: *[MAX_SELS]modal.HxRange) struct { n: usize, pri: usize } {
+ const pr = paneRange(pane, text, row0);
+ var n: usize = 0;
+ var pri: usize = 0;
+ var placed = false;
+ for (pane.sels[0..pane.nsel]) |s| {
+ const r = cellRange(text, s.arow - row0, s.acol, s.row - row0, s.col);
+ if (!placed and @min(pr.anchor, pr.head) <= @min(r.anchor, r.head)) {
+ pri = n;
+ out[n] = pr;
+ n += 1;
+ placed = true;
+ }
+ out[n] = r;
+ n += 1;
+ }
+ if (!placed) {
+ pri = n;
+ out[n] = pr;
+ n += 1;
+ }
+ return .{ .n = n, .pri = pri };
+ }
+
+ /// Write a whole selection back — helix's `Selection::new`: min-width-1,
+ /// sorted by start, overlapping ranges merged (the primary following its
+ /// range through a merge). `ranges[pri]` lands in the primary slot through
+ /// setPaneRange, so nothing downstream can tell it apart from a lone
+ /// cursor; the rest become pane.sels. `sticky` carries each range's own
+ /// j/k goal column, -1 for the ones that have none.
+ fn setPaneRanges(pane: *Pane, pl: PaneLines, text: []const u8, in: []const modal.HxRange, sticky: []const i32, pri0: usize, explicit: bool) void {
+ if (in.len == 0) return; // helix asserts non-empty; here it just means "no change"
+ var r: [MAX_SELS]modal.HxRange = undefined;
+ var st: [MAX_SELS]i32 = undefined;
+ var n: usize = @min(in.len, MAX_SELS);
+ var pri: usize = @min(pri0, n - 1);
+ for (in[0..n], 0..) |x, i| {
+ r[i] = x;
+ if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head);
+ st[i] = if (i < sticky.len) sticky[i] else -1;
+ }
+ // insertion sort by start — n is tiny and usually already ordered
+ var i: usize = 1;
+ while (i < n) : (i += 1) {
+ var j = i;
+ while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) {
+ std.mem.swap(modal.HxRange, &r[j], &r[j - 1]);
+ std.mem.swap(i32, &st[j], &st[j - 1]);
+ if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j;
+ }
+ }
+ // merge overlaps (helix Range::overlaps + Range::merge, kept forward:
+ // a merged range takes the union and loses its direction only when the
+ // two disagree, which is what helix's else-branch does)
+ var k: usize = 0;
+ i = 1;
+ while (i < n) : (i += 1) {
+ const a = r[k];
+ const b = r[i];
+ const af = @min(a.anchor, a.head);
+ const at = @max(a.anchor, a.head);
+ const bf = @min(b.anchor, b.head);
+ const bt = @max(b.anchor, b.head);
+ if (af == bf or (at > bf and bt > af)) {
+ r[k] = if (a.anchor > a.head and b.anchor > b.head)
+ .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) }
+ else
+ .{ .anchor = @min(af, bf), .head = @max(at, bt) };
+ if (pri == i) pri = k;
+ if (st[k] < 0) st[k] = st[i];
+ continue;
+ }
+ k += 1;
+ r[k] = b;
+ st[k] = st[i];
+ if (pri == i) pri = k;
+ }
+ n = k + 1;
+ setPaneRange(pane, pl, text, r[pri], explicit);
+ pane.sticky_col = st[pri];
+ var w: usize = 0;
+ for (r[0..n], 0..) |x, idx| {
+ if (idx == pri) continue;
+ const c = rangeCells(text, x);
+ const cc = modal.hxPos(text, c.cur);
+ const ac = modal.hxPos(text, c.anc);
+ pane.sels[w] = .{
+ .row = @as(i32, @intCast(cc.row)) + pl.row0,
+ .col = @intCast(cc.col),
+ .arow = @as(i32, @intCast(ac.row)) + pl.row0,
+ .acol = @intCast(ac.col),
+ .sticky = st[idx],
+ };
+ w += 1;
+ }
+ pane.nsel = @intCast(w);
+ }
+
+ /// The helix keys that act on the selection LIST rather than on the text.
+ /// Each reads the whole list and writes a whole list back; none is a
+ /// motion, which is why they are exempt from the per-range replay
+ /// (wholeKey). The goal columns are dropped on the way through — every
+ /// one of these is a fresh intent about WHERE the cursors are, the same
+ /// reason setPaneRange resets sticky_col.
+ fn multiSelKey(pane: *Pane, pl: PaneLines, text: []const u8, key: Key, cnt: usize) void {
+ var rs: [MAX_SELS]modal.HxRange = undefined;
+ const got = paneRanges(pane, text, pl.row0, &rs);
+ const n = got.n;
+ const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ if (hit(key, config.remove_primary_sel)) {
+ if (n < 2) return; // helix: "no selections remaining"
+ var out: [MAX_SELS]modal.HxRange = undefined;
+ var m: usize = 0;
+ for (rs[0..n], 0..) |r, i| {
+ if (i == got.pri) continue;
+ out[m] = r;
+ m += 1;
+ }
+ // helix Selection::remove: the NEXT range takes over, or the
+ // previous one when the primary was last
+ return setPaneRanges(pane, pl, text, out[0..m], &.{}, @min(got.pri, m - 1), expl);
+ }
+ if (hit(key, config.rotate_sel_fwd) or hit(key, config.rotate_sel_back)) {
+ const step = cnt % n;
+ const pri = if (hit(key, config.rotate_sel_fwd)) (got.pri + step) % n else (got.pri + (n - step)) % n;
+ return setPaneRanges(pane, pl, text, rs[0..n], &.{}, pri, expl);
+ }
+ if (hit(key, config.merge_sels)) {
+ // helix merge_selections: first.merge(last) — the ranges are
+ // sorted, so that is simply the whole span
+ const lo = @min(rs[0].anchor, rs[0].head);
+ const hi = @max(rs[n - 1].anchor, rs[n - 1].head);
+ const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head;
+ const one: modal.HxRange = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi };
+ return setPaneRanges(pane, pl, text, &.{one}, &.{}, 0, expl);
+ }
+ if (hit(key, config.merge_consecutive_sels)) {
+ // ranges that TOUCH become one; setPaneRanges already merges the
+ // ones that overlap, so widening each by a grapheme says exactly
+ // "consecutive counts as overlapping" and nothing else
+ var out: [MAX_SELS]modal.HxRange = undefined;
+ var m: usize = 0;
+ var pri: usize = 0;
+ for (rs[0..n], 0..) |r, i| {
+ if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) {
+ const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head));
+ const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head));
+ out[m - 1] = .{ .anchor = lo, .head = hi };
+ if (i == got.pri) pri = m - 1;
+ continue;
+ }
+ if (i == got.pri) pri = m;
+ out[m] = r;
+ m += 1;
+ }
+ return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl);
+ }
+ if (hit(key, config.split_sel_newline)) {
+ // helix selection::split_on_newline — one range per line the
+ // selection covers, the newlines themselves left out
+ var out: [MAX_SELS]modal.HxRange = undefined;
+ var m: usize = 0;
+ for (rs[0..n]) |r| {
+ const from = @min(r.anchor, r.head);
+ const to = @max(r.anchor, r.head);
+ if (from == to) {
+ if (m < MAX_SELS) {
+ out[m] = r;
+ m += 1;
+ }
+ continue;
+ }
+ var start = from;
+ while (start < to and m < MAX_SELS) {
+ const eol = modal.hxLineEndIdx(text, modal.hxLineOf(text, start));
+ if (eol >= to) {
+ out[m] = .{ .anchor = start, .head = to };
+ m += 1;
+ break;
+ }
+ out[m] = .{ .anchor = start, .head = eol };
+ m += 1;
+ start = eol + 1;
+ }
+ }
+ if (m == 0) return;
+ return setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0
+ }
+ if (hit(key, config.trim_sels)) {
+ // helix trim_selections: whitespace off both ends; ranges that are
+ // empty or all whitespace are dropped entirely
+ var out: [MAX_SELS]modal.HxRange = undefined;
+ var m: usize = 0;
+ for (rs[0..n]) |r| {
+ var from = @min(r.anchor, r.head);
+ var to = @max(r.anchor, r.head);
+ while (from < to and std.ascii.isWhitespace(text[from])) from += 1;
+ while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1;
+ if (from >= to) continue;
+ out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to };
+ m += 1;
+ }
+ if (m == 0) { // helix: collapse_selection + keep_primary_selection
+ const c = modal.hxCursor(text, rs[got.pri]);
+ return setPaneRange(pane, pl, text, .{ .anchor = c, .head = c }, false);
+ }
+ // helix: the first survivor that OVERLAPS the old primary, else the last
+ const pf = @min(rs[got.pri].anchor, rs[got.pri].head);
+ const pt = @max(rs[got.pri].anchor, rs[got.pri].head);
+ var pri = m - 1;
+ for (out[0..m], 0..) |r, i| {
+ const f = @min(r.anchor, r.head);
+ const t = @max(r.anchor, r.head);
+ if (f == pf or (t > pf and pt > f)) {
+ pri = i;
+ break;
+ }
+ }
+ return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl);
+ }
+ // C / Alt-C — helix copy_selection_on_line, a copy of each range on the
+ // next/previous line that is long enough to hold its columns.
+ // ponytail: BYTE columns, not helix's visual ones, so a TAB counts as
+ // one column here. Everything else in this file measures the same way
+ // (hscroll, the mouse, the renderer's gutter), and fixing it means
+ // teaching all of them tab stops at once.
+ const below = hit(key, config.copy_sel_below);
+ var out: [MAX_SELS]modal.HxRange = undefined;
+ var m: usize = 0;
+ var pri: usize = 0;
+ const nlines = modal.hxLineCount(text);
+ for (rs[0..n], 0..) |r, ri| {
+ const is_pri = ri == got.pri;
+ // head-exclusive: back the leading end off onto its own cell
+ const hp = modal.hxPos(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head);
+ const ap = modal.hxPos(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor));
+ const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1;
+ if (m >= MAX_SELS) break;
+ if (is_pri) pri = m;
+ out[m] = r;
+ m += 1;
+ var made: usize = 0;
+ var k: usize = 0;
+ while (made < cnt and m < MAX_SELS) : (k += 1) {
+ const d = (k + 1) * height;
+ const arow = if (below) ap.row + d else ap.row -| d;
+ const hrow = if (below) hp.row + d else hp.row -| d;
+ if (arow >= nlines or hrow >= nlines) break;
+ const a2 = modal.hxOff(text, .{ .row = arow, .col = ap.col });
+ const h2 = modal.hxOff(text, .{ .row = hrow, .col = hp.col });
+ // a line too short to reach the column is skipped, not clamped
+ if (modal.hxPos(text, a2).col == ap.col and modal.hxPos(text, h2).col == hp.col) {
+ if (is_pri) pri = m;
+ out[m] = modal.hxPutCursor(text, .{ .anchor = a2, .head = a2 }, h2, true);
+ m += 1;
+ made += 1;
+ }
+ if (arow == 0 and hrow == 0) break;
+ }
+ }
+ setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl);
+ }
+
+ // ---- `s` / `S`: the selection LIST from a regex ----
+ // The other two list-making keys, and the only ones that need a pattern
+ // typed first. They reuse the `/` input wholesale (startSearch — the tag
+ // tail IS the prompt) and differ from it in one thing: the pattern is
+ // re-applied on every keystroke, so the selection is the preview.
+
+ /// helix `s` / `S`: arm the tag input for a regex, remembering the
+ /// selection it is about to rewrite. Nothing moves yet — every keystroke
+ /// below re-derives the preview from this snapshot, and Enter simply stops
+ /// while Esc puts the snapshot back.
+ fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void {
+ const pl = p.paneCursorLines(pane) catch return;
+ const text = p.flatSurface(pane, pl) catch return;
+ const got = paneRanges(pane, text, pl.row0, &pane.sel_snap);
+ pane.nsel_snap = @intCast(got.n);
+ pane.sel_snap_pri = @intCast(got.pri);
+ pane.sel_snap_expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ p.startSearch(pane, if (split) config.split_marker else config.select_marker);
+ }
+
+ /// The pattern an armed `s`/`S` input holds right now, and which of the two
+ /// it is — read back off the MARKER, exactly the way submitSearch decides
+ /// which search is running. Null for `/`, Find, Grep and Rename.
+ fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } {
+ if (!pane.search_edit or pane.nsel_snap == 0) return null;
+ const tail = pane.tag_tail.items;
+ const armed = tail[@min(pane.search_at, tail.len)..];
+ const split = std.mem.startsWith(u8, armed, config.split_marker);
+ if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null;
+ const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null;
+ return .{ .pat = armed[slash + 1 ..], .split = split };
+ }
+
+ /// Put the selection back the way `s`/`S` found it and then, if `pat`
+ /// compiles and hits, rewrite it: helix's select_on_matches (every match
+ /// INSIDE each range becomes a range) and split_on_matches (each range
+ /// becomes the pieces BETWEEN its matches).
+ ///
+ /// Anything that yields nothing — an empty pattern, one that will not
+ /// compile, one that does not match — leaves the snapshot standing, which
+ /// is helix's "nothing selected" and also what makes typing a pattern one
+ /// character at a time bearable: every prefix of it is one of those.
+ ///
+ /// ponytail: MAX_SELS ranges, and matches past that are dropped rather
+ /// than growing the list — the ceiling the whole selection model has.
+ /// ponytail: mvzr searches from each match's end, so `^` and `$` assert
+ /// against THAT position rather than against a line the way helix's
+ /// multi_line regex does, and `.` matches a newline like any other byte.
+ /// Both are waived cases; `[^\n]` is the workaround for the second.
+ fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void {
+ const pl = p.paneCursorLines(pane) catch return;
+ const text = p.flatSurface(pane, pl) catch return;
+ const snap = pane.sel_snap[0..pane.nsel_snap];
+ var out: [MAX_SELS]modal.HxRange = undefined;
+ var m: usize = 0;
+ if (pat.len > 0) if (mvzr.compile(pat)) |re| {
+ // helix smart-case: a pattern with no uppercase in it matches
+ // case-blind. mvzr has no such flag — "lowercase your string" is
+ // its own advice — and ASCII folding is byte for byte, so a
+ // lowercased copy of the surface has exactly the same offsets.
+ var hay_all = text;
+ if (for (pat) |c| {
+ if (std.ascii.isUpper(c)) break false;
+ } else true) {
+ const low = p.scratch.allocator().dupe(u8, text) catch return;
+ for (low) |*c| c.* = std.ascii.toLower(c.*);
+ hay_all = low;
+ }
+ for (snap) |r| {
+ const from = @min(r.anchor, r.head);
+ const to = @min(@max(r.anchor, r.head), text.len);
+ if (from >= to) continue;
+ const hay = hay_all[from..to];
+ var at: usize = 0;
+ var piece = from; // split: where the next piece begins
+ while (at < hay.len and m < MAX_SELS) {
+ const hit_at = re.matchPos(at, hay) orelse break;
+ if (split) {
+ out[m] = .{ .anchor = piece, .head = from + hit_at.start };
+ m += 1;
+ piece = from + hit_at.end;
+ } else if (from + hit_at.start != to) {
+ // a match sitting right off the END of the range is
+ // dropped (helix: what `\b` and empty matches produce
+ // there), everything else becomes a range
+ out[m] = .{ .anchor = from + hit_at.start, .head = from + hit_at.end };
+ m += 1;
+ }
+ // an empty match would otherwise never advance
+ at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1;
+ }
+ if (split and piece < to and m < MAX_SELS) {
+ out[m] = .{ .anchor = piece, .head = to };
+ m += 1;
+ }
+ }
+ };
+ if (m == 0) {
+ // the text CAN move under an armed prompt (a tag chord runs a
+ // builtin), and these are raw offsets into the surface as it was
+ for (snap) |*r| {
+ r.anchor = @min(r.anchor, text.len);
+ r.head = @min(r.head, text.len);
+ }
+ return setPaneRanges(pane, pl, text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl);
+ }
+ setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO)
+ }
+
+ /// Everything one keystroke may CONSUME on the way through the modal
+ /// handler. A key means the same thing at every cursor, so the replay puts
+ /// all of it back before each pass and keeps whatever the PRIMARY's pass
+ /// left. (sticky_col is deliberately absent: it is per-range, and rides
+ /// along in SelRange.sticky instead.)
+ const KeyState = struct {
+ mode: Mode,
+ select: bool,
+ count: u32,
+ pending: u21,
+ pending2: u21,
+ pending_ch: u21,
+ find_op: u8,
+ find_ch: u21,
+ append_at: @FieldType(Pane, "append_at"),
+
+ fn of(pane: *Pane) KeyState {
+ return .{
+ .mode = pane.mode,
+ .select = pane.select,
+ .count = pane.count,
+ .pending = pane.pending,
+ .pending2 = pane.pending2,
+ .pending_ch = pane.pending_ch,
+ .find_op = pane.find_op,
+ .find_ch = pane.find_ch,
+ .append_at = pane.append_at,
+ };
+ }
+
+ fn into(s: KeyState, pane: *Pane) void {
+ pane.mode = s.mode;
+ pane.select = s.select;
+ pane.count = s.count;
+ pane.pending = s.pending;
+ pane.pending2 = s.pending2;
+ pane.pending_ch = s.pending_ch;
+ pane.find_op = s.find_op;
+ pane.find_ch = s.find_ch;
+ pane.append_at = s.append_at;
+ }
+ };
+
+ /// what a replayed key does at each cursor
+ const Replay = enum { normal, insert, paste_after, paste_before };
+
+ /// Run one keystroke at EVERY cursor, by replaying the single-selection
+ /// handler once per range. This IS the multiple-cursor mechanism, and it
+ /// is why one cursor costs nothing: with `nsel == 0` nobody calls it, and
+ /// the handler underneath is the same code the 800 differential cases pin.
+ ///
+ /// Two rules make the replay legal without helix's change-mapping:
+ /// * ranges are visited LAST FIRST, so an edit never disturbs the
+ /// row/col of a range still waiting its turn — everything it touches
+ /// is below.
+ /// * a finished pass's result is recorded as a distance from the END of
+ /// the surface, which an edit strictly before it cannot change (the
+ /// text and the position shift by exactly the same amount).
+ fn replaySels(p: *Pardes, pane: *Pane, what: Replay, key: Key) void {
+ const id = p.active;
+ const serial = pane.serial;
+ // the whole selection in pane coordinates, document order. pane.sels
+ // is already ordered, so this only slots the primary into place.
+ var list: [MAX_SELS]SelRange = undefined;
+ var n: usize = 0;
+ var pri: usize = 0;
+ const prim: SelRange = .{
+ .row = pane.cur_row,
+ .col = pane.cur_col,
+ .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row,
+ .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col,
+ .sticky = pane.sticky_col,
+ };
+ const pr = selStart(prim);
+ var placed = false;
+ for (pane.sels[0..pane.nsel]) |s| {
+ const sr = selStart(s);
+ if (!placed and (pr.row < sr.row or (pr.row == sr.row and pr.col <= sr.col))) {
+ pri = n;
+ list[n] = prim;
+ n += 1;
+ placed = true;
+ }
+ list[n] = s;
+ n += 1;
+ }
+ if (!placed) {
+ pri = n;
+ list[n] = prim;
+ n += 1;
+ }
+
+ const saved = KeyState.of(pane);
+ const explicit = pane.vsel.explicit;
+ var after = saved;
+ var after_expl = explicit;
+ // each pass's result: cursor and anchor cells as distances from the
+ // end of the surface text, plus the range's own j/k goal column
+ var res: [MAX_SELS]struct { cur: usize, anc: usize, sticky: i32 } = undefined;
+ p.multi_on = true;
+ p.multi_stop = false;
+ var passes: usize = 0;
+ var i: usize = n;
+ while (i > 0) {
+ i -= 1;
+ p.multi_first = passes == 0;
+ passes += 1;
+ saved.into(pane);
+ pane.nsel = 0; // the handler underneath sees ONE selection
+ pane.cur_row = list[i].row;
+ pane.cur_col = list[i].col;
+ pane.vsel = .{
+ .active = list[i].arow != list[i].row or list[i].acol != list[i].col or pane.select,
+ .row = list[i].arow,
+ .col = list[i].acol,
+ .explicit = explicit,
+ };
+ pane.msel.active = false;
+ pane.sticky_col = list[i].sticky;
+ switch (what) {
+ .normal => p.normalKey(pane, key),
+ .insert => p.insertKey(pane, key),
+ .paste_after => p.normalPaste(pane, false),
+ .paste_before => p.normalPaste(pane, true),
+ }
+ // the stop check comes FIRST: the pass that set it may have freed
+ // this very pane (a builtin closing it), so nothing below may read
+ // through the pointer
+ if (p.multi_stop) break;
+ if (i == pri) {
+ after = KeyState.of(pane);
+ after_expl = pane.vsel.explicit;
+ }
+ const pl = p.paneCursorLines(pane) catch {
+ p.multi_stop = true; // out of memory mid-replay: collapse, don't guess
+ break;
+ };
+ const text = p.flatSurface(pane, pl) catch {
+ p.multi_stop = true;
+ break;
+ };
+ const co = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - pl.row0)), .col = @intCast(@max(0, pane.cur_col)) });
+ const ao = if (pane.vsel.active)
+ modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - pl.row0)), .col = @intCast(@max(0, pane.vsel.col)) })
+ else
+ co;
+ res[i] = .{ .cur = text.len - @min(co, text.len), .anc = text.len - @min(ao, text.len), .sticky = pane.sticky_col };
+ }
+ p.multi_on = false;
+ p.multi_first = false;
+ if (p.multi_stop) {
+ // the pass reached outside the buffer (a builtin, a language
+ // query) and may have closed or reused the pane it ran on: drop
+ // back to one cursor rather than replaying it n more times
+ p.multi_stop = false;
+ const pn = p.panes[id] orelse return;
+ if (pn.serial == serial) pn.nsel = 0;
+ return;
+ }
+ const pl = p.paneCursorLines(pane) catch return;
+ const text = p.flatSurface(pane, pl) catch return;
+ var rs: [MAX_SELS]modal.HxRange = undefined;
+ var st: [MAX_SELS]i32 = undefined;
+ for (res[0..n], 0..) |r, k| {
+ rs[k] = cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len));
+ st[k] = r.sticky;
+ }
+ setPaneRanges(pane, pl, text, rs[0..n], st[0..n], pri, after_expl);
+ after.into(pane);
+ pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass
+ }
+
+ /// a range's start CELL (document order key) — the smaller of its two ends
+ fn selStart(s: SelRange) struct { row: i32, col: i32 } {
+ if (s.arow < s.row or (s.arow == s.row and s.acol < s.col)) return .{ .row = s.arow, .col = s.acol };
+ return .{ .row = s.row, .col = s.col };
+ }
+
/// point-target motion: collapse there (extend in select mode)
fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void {
setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false);
@@ -2389,7 +3094,40 @@ pub const Pardes = struct {
setPaneRange(pane, pl, text, r, expl);
}
+ /// Keys that are about the selection LIST, or about the session rather
+ /// than the text: they act ONCE however many cursors there are. The
+ /// multi-cursor family rewrites the list wholesale, and the rest would
+ /// either fight the replay (Esc, undo) or fire n times (`:`, `/`, n/N, SPC).
+ ///
+ /// A key that turns one range into MANY belongs here and writes its result
+ /// with setPaneRanges — `Alt-s` is the worked example and `s`/`S` are the
+ /// same shape a regex later. A key that merely EDITS wants the opposite:
+ /// leave it out and the replay runs it at every cursor for free. Ctrl-c is
+ /// the exception that proves it: it edits, but comment-or-uncomment is ONE
+ /// decision over every line every cursor touches, and a replay would take
+ /// that decision n times.
+ fn wholeKey(pane: *Pane, key: Key) bool {
+ if (hit(key, config.escape)) return true; // Esc means the same whatever is pending
+ if (pane.pending != 0) return false; // a prefix's char argument is literal
+ return hit(key, config.copy_sel_below) or hit(key, config.copy_sel_above) or
+ hit(key, config.keep_primary_sel) or hit(key, config.remove_primary_sel) or
+ hit(key, config.rotate_sel_fwd) or hit(key, config.rotate_sel_back) or
+ hit(key, config.split_sel_newline) or hit(key, config.merge_sels) or
+ hit(key, config.merge_consecutive_sels) or hit(key, config.trim_sels) or
+ hit(key, config.select_regex) or hit(key, config.split_regex) or
+ hit(key, config.comment_toggle) or
+ hit(key, config.undo) or hit(key, config.redo) or
+ hit(key, config.command_line) or hit(key, config.search) or
+ hit(key, config.search_next) or hit(key, config.search_prev) or
+ hit(key, config.leader);
+ }
+
fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void {
+ if (pane.nsel == 0 or wholeKey(pane, key)) return p.normalKey(pane, key);
+ p.replaySels(pane, .normal, key);
+ }
+
+ fn normalKey(p: *Pardes, pane: *Pane, key: Key) void {
pane.pinCursor();
const pl = p.paneCursorLines(pane) catch return;
const text = p.flatSurface(pane, pl) catch return;
@@ -2698,6 +3436,20 @@ pub const Pardes = struct {
return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, expl);
}
if (hit(key, config.select_all)) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false);
+ // multiple cursors: `,` is the one that needs nothing but the list
+ if (hit(key, config.keep_primary_sel)) {
+ pane.nsel = 0; // helix Selection::single(primary)
+ return;
+ }
+ if (hit(key, config.remove_primary_sel) or hit(key, config.rotate_sel_fwd) or
+ hit(key, config.rotate_sel_back) or hit(key, config.copy_sel_below) or
+ hit(key, config.copy_sel_above) or hit(key, config.split_sel_newline) or
+ hit(key, config.merge_sels) or hit(key, config.merge_consecutive_sels) or
+ hit(key, config.trim_sels)) return multiSelKey(pane, pl, text, key, cnt);
+ // the two that need a REGEX first: arm the tag input and let the
+ // pattern's own keystrokes drive the preview (startSelRegex)
+ if (hit(key, config.select_regex)) return p.startSelRegex(pane, false);
+ if (hit(key, config.split_regex)) return p.startSelRegex(pane, true);
// edits
if (hit(key, config.delete)) return p.normalDelete(pane, true);
if (hit(key, config.delete_noyank)) return p.normalDelete(pane, false);
@@ -2715,6 +3467,7 @@ pub const Pardes = struct {
if (hit(key, config.format)) return p.lspRequest(p.active, .format, "");
if (hit(key, config.increment)) return p.normalAdjustNumber(pane, @intCast(cnt));
if (hit(key, config.decrement)) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt)));
+ if (hit(key, config.comment_toggle)) return p.normalToggleComment(pane);
if (hit(key, config.undo)) return p.doUndo(pane);
if (hit(key, config.redo)) return p.doRedo(pane);
// SPC — the leader: a key path from here runs a BUILTIN with no
@@ -2781,6 +3534,10 @@ pub const Pardes = struct {
/// ate the marker: nothing to run.
fn submitSearch(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
+ // `s`/`S` have already applied themselves keystroke by keystroke;
+ // Enter re-runs the final pattern so a submit is one code path with
+ // the preview and cannot disagree with what is on screen.
+ if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split);
const tail = pane.tag_tail.items;
const armed = tail[@min(pane.search_at, tail.len)..];
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return;
@@ -2919,6 +3676,7 @@ pub const Pardes = struct {
// select the result row in the results pane and keep it in view
rp.msel = .{ .active = true, .r0 = r, .r1 = r };
rp.vsel.active = false;
+ rp.nsel = 0;
rp.cur_row = r;
rp.cur_col = 0;
rp.cur_pinned = true;
@@ -2950,6 +3708,7 @@ pub const Pardes = struct {
/// it. Unsupported kinds never get here (the keymap drops them), so a
/// backend that answers nothing simply never opens a buffer.
pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void {
+ if (!p.multiOnce()) return; // one question per keystroke, from the primary
if (!lsp.supports.contains(kind)) return;
const pane = p.panes[id] orelse return;
// `status` is about the BACKEND, not about a document, so it answers
@@ -3070,6 +3829,7 @@ pub const Pardes = struct {
// EXPLICIT: Enter's look chord acts on it
pane.vsel = .{ .active = true, .row = r, .col = @intCast(hit_t1 - 1), .explicit = true };
pane.msel.active = false;
+ pane.nsel = 0;
pane.cur_row = r;
pane.cur_col = @intCast(hit_t0);
pane.cur_pinned = true;
@@ -3244,6 +4004,24 @@ pub const Pardes = struct {
pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0;
pane.cur_col = @intCast(ind.len);
pane.cur_pinned = true;
+ // helix `open` with a COUNT leaves one cursor per line it
+ // opened — `3o` then typing fills all three. The lines are
+ // consecutive and start at the cursor, so this is just cnt
+ // points a row apart, the first one primary.
+ // (Inside a replay pass the extra ranges cannot be created:
+ // a pass produces exactly one range. `3o` at several cursors
+ // therefore opens the lines but keeps the cursors it had.)
+ if (cnt > 1 and !p.multi_on) opened: {
+ const pl2 = p.paneCursorLines(pane) catch break :opened;
+ const t2 = p.flatSurface(pane, pl2) catch break :opened;
+ var rs: [MAX_SELS]modal.HxRange = undefined;
+ const m = @min(cnt, MAX_SELS);
+ for (0..m) |k| {
+ const o = modal.hxOff(t2, .{ .row = @intCast(@max(0, pane.cur_row - pl2.row0) + @as(i32, @intCast(k))), .col = ind.len });
+ rs[k] = .{ .anchor = o, .head = o };
+ }
+ setPaneRanges(pane, pl2, t2, rs[0..m], &.{}, 0, false);
+ }
},
}
pane.mode = .insert;
@@ -3257,10 +4035,15 @@ pub const Pardes = struct {
/// materializes and grows it), so typing, Enter, joins and the kill runs
/// mean exactly the same thing in a shell pane as in a document.
fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void {
+ if (pane.nsel == 0) return p.insertKey(pane, key);
+ p.replaySels(pane, .insert, key);
+ }
+
+ fn insertKey(p: *Pardes, pane: *Pane, key: Key) void {
// helix aliases: normalize to the base key and re-dispatch
- if (hit(key, config.insert_backspace_alias)) return p.handleInsert(pane, .{ .cp = Key.backspace });
- if (hit(key, config.insert_enter_alias)) return p.handleInsert(pane, .{ .cp = Key.enter });
- if (hit(key, config.insert_delete_alias)) return p.handleInsert(pane, .{ .cp = Key.delete });
+ if (hit(key, config.insert_backspace_alias)) return p.insertKey(pane, .{ .cp = Key.backspace });
+ if (hit(key, config.insert_enter_alias)) return p.insertKey(pane, .{ .cp = Key.enter });
+ if (hit(key, config.insert_delete_alias)) return p.insertKey(pane, .{ .cp = Key.delete });
// a selection carried into insert (i/a) survives only until the next
// key: helix maps it through every edit, pardes drops it instead —
// its only pardes use (the acme chords) needs explicit selections
@@ -3454,21 +4237,16 @@ pub const Pardes = struct {
const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
+ /// a range's two cells, normalized to document order
+ fn cellBounds(s: SelRange) Bounds {
+ if (s.row < s.arow or (s.row == s.arow and s.col < s.acol))
+ return .{ .lo_row = s.row, .lo_col = s.col, .hi_row = s.arow, .hi_col = s.acol };
+ return .{ .lo_row = s.arow, .lo_col = s.acol, .hi_row = s.row, .hi_col = s.col };
+ }
+
/// the char selection [anchor, cursor] normalized to document order
fn vselBounds(pane: *Pane) Bounds {
- var ar = pane.vsel.row;
- var ac = pane.vsel.col;
- var br = pane.cur_row;
- var bc = pane.cur_col;
- if (br < ar or (br == ar and bc < ac)) {
- const sr = ar;
- const sc = ac;
- ar = br;
- ac = bc;
- br = sr;
- bc = sc;
- }
- return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc };
+ return cellBounds(.{ .row = pane.cur_row, .col = pane.cur_col, .arow = pane.vsel.row, .acol = pane.vsel.col });
}
/// the char selection as text. Read off the pane's SURFACE (file content /
@@ -3852,9 +4630,9 @@ pub const Pardes = struct {
p.setEditText(pane, new);
}
- /// a change to apply to file content: delete [from, to), insert `ins`
- /// spaces at `from`. Ascending and disjoint.
- const TextChange = struct { from: usize, to: usize, ins: usize };
+ /// a change to apply to file content: delete [from, to), insert `ins` at
+ /// `from`. Ascending and disjoint.
+ const TextChange = struct { from: usize, to: usize, ins: []const u8 };
/// map an original-text offset through a change list (insertions AT a
/// position push it right — helix Assoc::After; positions inside a
@@ -3864,25 +4642,24 @@ pub const Pardes = struct {
for (chs) |ch| {
if (pos < ch.from) break;
if (ch.from == ch.to) {
- delta += @intCast(ch.ins);
+ delta += @intCast(ch.ins.len);
continue;
}
if (pos == ch.from) break;
if (pos < ch.to) return @intCast(@as(i64, @intCast(ch.from)) + delta);
- delta += @as(i64, @intCast(ch.ins)) - @as(i64, @intCast(ch.to - ch.from));
+ delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from));
}
return @intCast(@as(i64, @intCast(pos)) + delta);
}
- /// apply a change list; the insertions are spaces (join/indent only ever
- /// insert those). gpa-owned result.
+ /// apply a change list. gpa-owned result.
fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(p.gpa);
var copied: usize = 0;
for (chs) |ch| {
try out.appendSlice(p.gpa, text[copied..ch.from]);
- for (0..ch.ins) |_| try out.append(p.gpa, ' ');
+ try out.appendSlice(p.gpa, ch.ins);
copied = ch.to;
}
try out.appendSlice(p.gpa, text[copied..]);
@@ -3913,7 +4690,7 @@ pub const Pardes = struct {
const from = modal.hxLineEndIdx(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: usize = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) 0 else 1;
+ const sep: []const u8 = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) "" else " ";
chs.append(arena, .{ .from = from, .to = to, .ins = sep }) catch return;
}
if (chs.items.len == 0) return;
@@ -3954,6 +4731,9 @@ pub const Pardes = struct {
const range = paneRange(pane, text, eb.row0);
const span = rangeLineSpan(text, range);
const arena = p.scratch.allocator();
+ // one run of spaces, sliced per line: `>` never inserts more than this
+ const pad = arena.alloc(u8, modal.INDENT_W * cnt) catch return;
+ @memset(pad, ' ');
var chs: std.ArrayList(TextChange) = .empty;
var l = span.start;
while (l <= span.end) : (l += 1) {
@@ -3964,7 +4744,7 @@ pub const Pardes = struct {
if (nw == line.len) continue; // blank lines stay blank (helix)
if (add) {
const ins = modal.INDENT_W * cnt - (nw % modal.INDENT_W);
- chs.append(arena, .{ .from = ls, .to = ls, .ins = ins }) catch return;
+ chs.append(arena, .{ .from = ls, .to = ls, .ins = pad[0..ins] }) catch return;
} else {
const want = modal.INDENT_W * cnt;
var w: usize = 0;
@@ -3978,7 +4758,7 @@ pub const Pardes = struct {
pos += 1;
if (w >= want) break;
}
- if (pos > 0) chs.append(arena, .{ .from = ls, .to = ls + pos, .ins = 0 }) catch return;
+ if (pos > 0) chs.append(arena, .{ .from = ls, .to = ls + pos, .ins = "" }) catch return;
}
}
if (chs.items.len == 0) return;
@@ -4006,6 +4786,120 @@ pub const Pardes = struct {
pane.ensureCursorVisible();
}
+ /// `Ctrl-c`: helix toggle_comments. Every line the selection touches gets
+ /// the language's line-comment token put in front of it — or taken off,
+ /// and WHICH of the two is decided once for the whole set: one uncommented
+ /// non-blank line among them and everything gets commented. That single
+ /// decision is why this is a wholeKey instead of a per-cursor replay;
+ /// replayed, a half-commented block would end up half-commented the other
+ /// way round.
+ ///
+ /// The rest is helix's find_line_comment, quirks included: the token goes
+ /// in at the SHALLOWEST indent in the set (so a deeper line is commented
+ /// mid-whitespace), all-blank lines are skipped entirely and do not vote,
+ /// and uncommenting also eats one space after the token unless some line
+ /// lacks it.
+ fn normalToggleComment(p: *Pardes, pane: *Pane) void {
+ const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ pane.select = false; // helix exit_select_mode
+ // the buffer must cover every cursor's rows, not just the primary's
+ var b = selRows(pane);
+ for (pane.sels[0..pane.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));
+ }
+ // ...and ONE ROW PAST them, like normalJoin: a selection may end on
+ // its last line's newline cell, and a terminal buffer that stops at
+ // that line has nowhere to put it (a file's content always does).
+ // Never past the surface's own last row, though — materialising a row
+ // that does not exist yet would ADD a blank line to the pane, and this
+ // op may well decide to change nothing.
+ const pl0 = p.paneCursorLines(pane) catch return;
+ const last_row = pl0.row0 + @as(i32, @intCast(pl0.lines.len)) - 1;
+ const eb = p.editText(pane, b.lo_row, @min(b.hi_row + 1, last_row), -1) orelse return;
+ const text = eb.text;
+ var rs: [MAX_SELS]modal.HxRange = undefined;
+ const got = paneRanges(pane, text, eb.row0, &rs);
+ const nlines = modal.hxLineCount(text);
+ const arena = p.scratch.allocator();
+ // the lines the ranges cover, each ONE ONCE and in order (helix's
+ // min_next_line: two cursors on one line comment it once)
+ var lines: std.ArrayList(usize) = .empty;
+ var next: usize = 0;
+ for (rs[0..got.n]) |r| {
+ const span = rangeLineSpan(text, r);
+ var l = @max(span.start, next);
+ const end = @min(span.end + 1, nlines);
+ while (l < end) : (l += 1) lines.append(arena, l) catch return;
+ next = @max(next, end);
+ }
+ // which token: the file's EXTENSION, which is the same thing
+ // src/syntax.zig tells languages apart by, read off the one table in
+ // config. A terminal and an output buffer have no extension and get
+ // the default, which is what helix does for a buffer with no language.
+ const ext = if (pane.file) |f| std.fs.path.extension(f.path) else "";
+ var token: []const u8 = config.comment_token_default;
+ lang: for (config.comment_tokens) |row| {
+ for (row.exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) {
+ token = row.token;
+ break :lang;
+ };
+ }
+ var commented = true;
+ var indent: usize = std.math.maxInt(usize);
+ var margin: usize = 1;
+ var live: usize = 0;
+ for (lines.items) |l| {
+ const line = text[modal.lineStartOffset(text, l)..modal.hxLineEndIdx(text, l)];
+ const nw = modal.firstNonWs(line);
+ if (nw == line.len) continue;
+ indent = @min(indent, nw);
+ if (!std.mem.startsWith(u8, line[nw..], token)) commented = false;
+ if (nw + token.len >= line.len or line[nw + token.len] != ' ') margin = 0;
+ live += 1;
+ }
+ if (live == 0) return; // nothing but blank lines
+ const ins = std.fmt.allocPrint(arena, "{s} ", .{token}) catch return;
+ var chs: std.ArrayList(TextChange) = .empty;
+ for (lines.items) |l| {
+ const ls = modal.lineStartOffset(text, l);
+ const le = modal.hxLineEndIdx(text, l);
+ const line = text[ls..le];
+ if (modal.firstNonWs(line) == line.len) continue; // blank lines untouched
+ const at = ls + indent;
+ // the @min can never bind: margin is 1 only when every line has a
+ // space after its own token, which is a byte past `at + token.len`
+ chs.append(arena, if (commented)
+ .{ .from = at, .to = @min(at + token.len + margin, le), .ins = "" }
+ else
+ .{ .from = at, .to = at, .ins = ins }) catch return;
+ }
+ p.pushUndo(pane);
+ // one edit, and the WHOLE selection rides through it (helix maps the
+ // selection with the transaction)
+ var cells: [MAX_SELS]SelRange = undefined;
+ const new = p.applyChanges(text, chs.items) catch return;
+ for (rs[0..got.n], 0..) |r, i| {
+ const c = rangeCells(new, .{
+ .anchor = mapThroughChanges(chs.items, r.anchor),
+ .head = mapThroughChanges(chs.items, r.head),
+ });
+ const cc = modal.hxPos(new, c.cur);
+ const ac = modal.hxPos(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),
+ };
+ }
+ p.setEditText(pane, new);
+ const pl2 = p.paneCursorLines(pane) catch return;
+ const t2 = p.flatSurface(pane, pl2) catch return;
+ for (cells[0..got.n], 0..) |s, i| rs[i] = cellRange(t2, s.arow - pl2.row0, s.acol, s.row - pl2.row0, s.col);
+ setPaneRanges(pane, pl2, t2, rs[0..got.n], &.{}, got.pri, expl);
+ }
+
/// `Ctrl-a` / `Ctrl-x`: increment/decrement the SELECTION as a decimal
/// integer (helix: the selected fragment itself, no number scan around
/// the cursor); a fragment that isn't an integer is a no-op. The new
@@ -4201,6 +5095,10 @@ pub const Pardes = struct {
}
fn pushUndo(p: *Pardes, pane: *Pane) void {
+ // one keystroke, one undo step — even when it edited at ten cursors.
+ // (The content-equality guard below cannot do this on its own: by the
+ // second pass the buffer HAS changed, so it would push again.)
+ if (p.multi_on and !p.multi_first) return;
if (pane.file) |*f| {
if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1].content, f.content)) return;
const snap: FileSnap = .{
@@ -4630,6 +5528,7 @@ pub const Pardes = struct {
// dismisses it rather than dragging the anchored span
// to the click (explicit v/x keeps vim's click-extend)
if (!pane.vsel.explicit) pane.vsel.active = false;
+ pane.nsel = 0; // a click says WHERE the one cursor is
// Ctrl-click IS `gd`, asked now that the cursor has
// landed — the mouse spelling of the keyboard motion,
// through the identical request. A ctrl-DRAG still
@@ -4729,6 +5628,7 @@ pub const Pardes = struct {
pane.pending = 0;
if (!pane.isTerminal()) pane.mode = .normal;
pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false };
+ pane.nsel = 0;
}
if (cut) {
if (pane.vsel.active) p.normalDelete(pane, true);
@@ -5286,6 +6186,7 @@ pub const Pardes = struct {
/// relative to `id`) act on `id`. The null-pane check is the one guard
/// every builtin used to share, so it stays here rather than in each.
fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void {
+ if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor
const pane = p.panes[id] orelse return;
const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg };
inline for (comptime builtins.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c);
@@ -6072,23 +6973,59 @@ pub const Pardes = struct {
}
}
}
- // modal char selection (helix `v`): stream-shaped anchor..head highlight
- if (pane.mode == .normal and pane.vsel.active) {
- const bnd = vselBounds(pane);
+ // modal char selection (helix `v`): stream-shaped anchor..head
+ // highlight. The EXTRA cursors are the same shape drawn dimmer, and
+ // each of them also paints its own cursor cell bright: there is one
+ // hardware cursor and the primary owns it, so a secondary cursor has
+ // to be a cell colour or it is invisible.
+ // The extra cursors show in INSERT mode too — that is exactly when you
+ // need to see where your typing is landing — while the primary's
+ // selection highlight stays normal-mode-only, as it always was.
+ // ...and an armed `s`/`S` shows the primary WHATEVER shape it is, even
+ // though the pane is in insert mode for the tag and even when the match
+ // is a single cell: the hardware cursor is off in the tag, so a preview
+ // that leans on it shows every match except the one you are on.
+ const preview = selRegexArmed(pane) != null;
+ const show_prim = (pane.mode == .normal and pane.vsel.active) or preview;
+ const show_extra = pane.mode != .tty and pane.nsel > 0;
+ if (show_prim or show_extra) {
const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0;
const vhs: i32 = if (pane.file != null) pane.hscroll else 0;
- var ar: i32 = bnd.lo_row;
- while (ar <= bnd.hi_row) : (ar += 1) {
- const prow = ar - off + @as(i32, BOX_H);
- if (prow < BOX_H or prow >= @as(i32, r.h)) continue;
- const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col - vhs + vpfx else vpfx;
- const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col - vhs + vpfx else @as(i32, tw) - 1;
- var col: i32 = @max(cstart, vpfx);
- while (col <= cend and col < tw) : (col += 1) {
- const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow)));
+ var si: usize = 0;
+ while (si <= pane.nsel) : (si += 1) {
+ const primary = si == pane.nsel;
+ if (if (primary) !show_prim else !show_extra) continue;
+ const sr = if (primary) SelRange{
+ .row = pane.cur_row,
+ .col = pane.cur_col,
+ .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row,
+ .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col,
+ } else pane.sels[si];
+ const bnd = cellBounds(sr);
+ const bg = if (primary) msel_bg else msel2_bg;
+ const fg = if (primary) msel_fg else msel2_fg;
+ var ar: i32 = bnd.lo_row;
+ while (ar <= bnd.hi_row) : (ar += 1) {
+ const prow = ar - off + @as(i32, BOX_H);
+ if (prow < BOX_H or prow >= @as(i32, r.h)) continue;
+ const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col - vhs + vpfx else vpfx;
+ const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col - vhs + vpfx else @as(i32, tw) - 1;
+ var col: i32 = @max(cstart, vpfx);
+ while (col <= cend and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow)));
+ cell.default = false;
+ cell.style.bg = .{ .rgb = bg };
+ cell.style.fg = .{ .rgb = fg };
+ }
+ }
+ if (primary and !preview) continue; // the hardware cursor IS the primary's
+ const crow = sr.row - off + @as(i32, BOX_H);
+ const ccol = sr.col - vhs + vpfx;
+ if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) {
+ const cell = s.at(tx + @as(u16, @intCast(ccol)), r.y + @as(u16, @intCast(crow)));
cell.default = false;
- cell.style.bg = .{ .rgb = msel_bg };
- cell.style.fg = .{ .rgb = msel_fg };
+ cell.style.bg = .{ .rgb = msel_fg };
+ cell.style.fg = .{ .rgb = msel_bg };
}
}
}