diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1067 |
1 files changed, 1002 insertions, 65 deletions
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 }; } } } |
