summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/body_layer.zig3
-rw-r--r--src/edit.zig7
-rw-r--r--src/fs.zig1
-rw-r--r--src/normal.zig720
-rw-r--r--src/pardes.zig727
5 files changed, 740 insertions, 718 deletions
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 8c345b97..6bac7018 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -5,6 +5,7 @@
//! and cursor.
const std = @import("std");
const pardes = @import("pardes.zig");
+const normal = @import("normal.zig");
const edit = @import("edit.zig");
const config = @import("config.zig");
const limits = @import("memory.zig").limits;
@@ -386,7 +387,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
}
}
}
- const preview = Pardes.selRegexArmed(pane) != null;
+ const preview = normal.selRegexArmed(pane) != null;
const show_prim = (pane.mode == .normal and pane.vsel.active) or preview or
(modal_hover and pane.vsel.active);
const show_extra = pane.mode != .tty and pane.nsel > 0;
diff --git a/src/edit.zig b/src/edit.zig
index e44a3364..f829decc 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -3,6 +3,7 @@
//! operations, replace, case, join, indent, comments, numbers, textobjects
//! and surround, undo, yank and paste, and the pointer selections as text.
const pardes = @import("pardes.zig");
+const normal = @import("normal.zig");
const std = @import("std");
const modal = @import("modal.zig");
const panes = @import("panes.zig");
@@ -86,7 +87,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void {
const range: @TypeOf(Pardes.tagSelBounds(pane)) = if (pane.tag_sel) Pardes.tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col };
const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi;
_ = p.replaceTagText(pane, range.lo, hi, bytes);
- if (Pardes.selRegexArmed(pane)) |a| p.applySelRegex(pane, a.pat, a.split);
+ if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split);
return;
}
if (pane.isTerminal() and pane.mode == .tty) return typeToTty(p, id, pane, bytes);
@@ -103,7 +104,7 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
return;
};
if (pane.nsel > 0 and !main_only) {
- p.replaySels(pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } });
+ normal.replaySels(p, pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } });
} else {
const others = pane.nsel;
pane.nsel = 0;
@@ -471,7 +472,7 @@ pub fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void {
pub fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void {
if (pane.nsel == 0) return insertKey(p, pane, key);
- p.replaySels(pane, .{ .insert = key });
+ normal.replaySels(p, pane, .{ .insert = key });
}
pub fn insertKey(p: *Pardes, pane: *Pane, key: Key) void {
diff --git a/src/fs.zig b/src/fs.zig
index 04a8ed36..5fbd3cd8 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -41,6 +41,7 @@ const source_files = [_]Source{
.{ .path = "src/Messages.zig", .contents = @embedFile("Messages.zig") },
.{ .path = "src/body_layer.zig", .contents = @embedFile("body_layer.zig") },
.{ .path = "src/edit.zig", .contents = @embedFile("edit.zig") },
+ .{ .path = "src/normal.zig", .contents = @embedFile("normal.zig") },
.{ .path = "src/File.zig", .contents = @embedFile("File.zig") },
.{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") },
.{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
diff --git a/src/normal.zig b/src/normal.zig
new file mode 100644
index 00000000..7a18aae5
--- /dev/null
+++ b/src/normal.zig
@@ -0,0 +1,720 @@
+//! Helix normal mode: a key read against the configured bindings becomes
+//! modal actions, replayed across every selection, and each action is done
+//! to the pane; PDF panes get their own normal keys.
+const pardes = @import("pardes.zig");
+const std = @import("std");
+const mvzr = @import("mvzr");
+const modal = @import("modal.zig");
+const panes = @import("panes.zig");
+const edit = @import("edit.zig");
+const config = @import("config.zig");
+const pdf_enabled = panes.Pdf.enabled;
+const Pane = panes.Pane;
+const Key = pardes.Key;
+const hit = pardes.hit;
+const sel_slot = pardes.sel_slot;
+const Pardes = pardes.Pardes;
+
+fn isPrefix(key: Key, cp: u21) bool {
+ return key.cp == cp and !key.ctrl and !key.alt;
+}
+
+fn roleBindingName(comptime role: modal.Normal.Role) []const u8 {
+ return switch (role) {
+ .prefix_goto => "goto_prefix",
+ .prefix_view => "view_prefix",
+ .prefix_match => "match_prefix",
+ .prefix_find_fwd => "find_char_fwd",
+ .prefix_find_back => "find_char_back",
+ .prefix_till_fwd => "till_char_fwd",
+ .prefix_till_back => "till_char_back",
+ .prefix_replace => "replace_prefix",
+ .prefix_next => "next_prefix",
+ .prefix_prev => "prev_prefix",
+ else => @tagName(role),
+ };
+}
+
+fn normalInput(key: Key) modal.Normal.Input {
+ var out: modal.Normal.Input = .{
+ .cp = key.cp,
+ .ctrl = key.ctrl,
+ .alt = key.alt,
+ };
+ inline for (std.enums.values(modal.Normal.Role)) |role| {
+ const name = comptime roleBindingName(role);
+ if (!@hasDecl(config, name))
+ @compileError("normal input role has no config binding: " ++ name);
+ const binding = @field(config, name);
+ const matched = if (@TypeOf(binding) == comptime_int)
+ isPrefix(key, binding)
+ else if (@TypeOf(binding) == []const config.Chord)
+ hit(key, binding)
+ else
+ @compileError("normal input binding has unsupported type: " ++ name);
+ if (matched) out.roles.insert(role);
+ }
+ return out;
+}
+
+pub fn multiOnce(p: *Pardes) bool {
+ if (!p.multi_on) return true;
+ p.multi_stop = true;
+ return p.multi_first;
+}
+
+fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void {
+ const text = edit.flatSurface(p, pane) catch return;
+ const got = pane.ranges(text, 0, &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.startPrompt(pane, .{ .search = if (split) config.split_marker else config.select_marker });
+}
+
+pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } {
+ const prompt_at = switch (pane.prompt) {
+ .search => |at| at,
+ else => return null,
+ };
+ if (pane.nsel_snap == 0) return null;
+ const tail = pane.tagSlice();
+ const armed = tail[@min(prompt_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 };
+}
+
+pub fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void {
+ const text = edit.flatSurface(p, pane) catch return;
+ const snap = pane.sel_snap[0..pane.nsel_snap];
+ var out: [Pane.max_selections]modal.Selection = undefined;
+ var m: usize = 0;
+ if (pat.len > 0) if (mvzr.compile(pat)) |re| {
+ 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 < Pane.max_selections) {
+ 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) {
+ 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 < Pane.max_selections) {
+ 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 pane.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl);
+ }
+ pane.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO)
+}
+
+const KeyState = struct {
+ mode: Pane.Mode,
+ select: bool,
+ normal: modal.Normal.State,
+ find_op: u8,
+ find_ch: u21,
+ append_at: @FieldType(Pane, "append_at"),
+
+ fn of(pane: *Pane) KeyState {
+ return .{
+ .mode = pane.mode,
+ .select = pane.select,
+ .normal = pane.normal,
+ .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.normal = s.normal;
+ 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 = union(enum) {
+ normal: modal.Normal.Action,
+ insert: Key,
+};
+
+pub fn replaySels(p: *Pardes, pane: *Pane, what: Replay) 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: [Pane.max_selections]Pane.SelRange = undefined;
+ var n: usize = 0;
+ var pri: usize = 0;
+ const prim: Pane.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 = Pane.selStart(prim);
+ var placed = false;
+ for (pane.sels[0..pane.nsel]) |s| {
+ const sr = Pane.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: [Pane.max_selections]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 => |normal_action| executeNormalAction(p, pane, normal_action),
+ .insert => |insert_key| edit.insertKey(p, pane, insert_key),
+ }
+ if (p.multi_stop) break;
+ if (i == pri) {
+ after = KeyState.of(pane);
+ after_expl = pane.vsel.explicit;
+ }
+ const text = edit.flatSurface(p, pane) catch {
+ p.multi_stop = true;
+ break;
+ };
+ const co = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) });
+ const ao = if (pane.vsel.active)
+ modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .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) {
+ p.multi_stop = false;
+ const pn = p.panes[id] orelse return;
+ if (pn.serial == serial) pn.nsel = 0;
+ return;
+ }
+ const text = edit.flatSurface(p, pane) catch return;
+ var rs: [Pane.max_selections]modal.Selection = undefined;
+ var st: [Pane.max_selections]i32 = undefined;
+ for (res[0..n], 0..) |r, k| {
+ rs[k] = Pane.cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len));
+ st[k] = r.sticky;
+ }
+ pane.setRanges(text, rs[0..n], st[0..n], pri, after_expl);
+ after.into(pane);
+ pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass
+}
+
+pub fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void {
+ const parsed = modal.Normal.parse(&pane.normal, normalInput(key));
+ const semantic = switch (parsed) {
+ .pending, .ignored, .unbound => return,
+ .action => |value| value,
+ };
+ if (pane.nsel == 0 or semantic.scope() == .once)
+ return executeNormalAction(p, pane, semantic);
+ replaySels(p, pane, .{ .normal = semantic });
+}
+
+fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.Action) void {
+ const raw_selection = pane.rawPointerText(pane.sel[sel_slot]) != null;
+ const before = .{ pane.cur_row, pane.cur_col, pane.vsel, pane.msel, pane.select, pane.nsel, pane.mode };
+ defer if (raw_selection and !std.meta.eql(before, .{ pane.cur_row, pane.cur_col, pane.vsel, pane.msel, pane.select, pane.nsel, pane.mode })) {
+ pane.clearPointerSelection(sel_slot);
+ pane.sel[sel_slot].state = .none;
+ };
+ if (raw_selection and semantic == .edit and semantic.edit.kind == .yank) return edit.normalYank(p, pane);
+ edit.pinPaneCursor(p, pane);
+ const text = edit.flatSurface(p, pane) catch return;
+ const range = pane.primaryRange(text, 0);
+ const cur = modal.selectionCursor(text, range);
+
+ switch (semantic) {
+ .escape => {
+ pane.select = false;
+ return p.runBuiltin(.Last, p.active, "", null);
+ },
+ .goto => |go| switch (go.target) {
+ .file_start => {
+ const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, Pane.maxLine(text)) else 0;
+ return pane.pointMove(text, range, modal.lineStartOffset(text, line));
+ },
+ .last_line => return pane.pointMove(text, range, modal.lineStartOffset(text, Pane.maxLine(text))),
+ .line_start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))),
+ .line_end => {
+ const line = modal.lineAtOffset(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line))));
+ },
+ .first_nonws => {
+ const line = modal.lineAtOffset(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ const slice = text[ls..modal.lineEndOffset(text, line)];
+ const nw = modal.firstNonWs(slice);
+ if (nw == slice.len) return;
+ return pane.pointMove(text, range, ls + nw);
+ },
+ .line_down => return pane.visualMove(text, range, true, go.count, edit.paneWrapWidth(p, pane)),
+ .line_up => return pane.visualMove(text, range, false, go.count, edit.paneWrapWidth(p, pane)),
+ .column => {
+ const line = modal.lineAtOffset(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ const slice = text[ls..modal.lineEndOffset(text, line)];
+ return pane.pointMove(text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1));
+ },
+ .view_top => return pane.gotoWindow(text, range, .top, go.count),
+ .view_center => return pane.gotoWindow(text, range, .center, go.count),
+ .view_bottom => return pane.gotoWindow(text, range, .bottom, go.count),
+ },
+ .view => |view| switch (view) {
+ .top => {
+ pane.scrollBy(pane.cur_row - pane.scroll());
+ pane.ensureCursorVisible();
+ },
+ .center => {
+ pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1));
+ pane.ensureCursorVisible();
+ },
+ .bottom => {
+ pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1));
+ pane.ensureCursorVisible();
+ },
+ .scroll_down => return pane.scrollViewMove(text, range, 1),
+ .scroll_up => return pane.scrollViewMove(text, range, -1),
+ },
+ .find => |find| {
+ const op: u8 = switch (find.kind) {
+ .forward => @intCast(config.find_char_fwd),
+ .backward => @intCast(config.find_char_back),
+ .till_forward => @intCast(config.till_char_fwd),
+ .till_backward => @intCast(config.till_char_back),
+ };
+ pane.find_op = op;
+ pane.find_ch = find.char;
+ return pane.findMove(
+ text,
+ range,
+ find.char,
+ find.kind == .forward or find.kind == .till_forward,
+ find.kind == .till_forward or find.kind == .till_backward,
+ find.count,
+ );
+ },
+ .replace_char => |char| return edit.normalReplaceChar(p, pane, char),
+ .match_bracket => {
+ const lines = edit.paneCursorLines(p, pane) catch return;
+ const mc = modal.matchBracket(lines, modal.positionAt(text, cur)) orelse return;
+ return pane.pointMove(text, range, modal.offsetAt(text, mc));
+ },
+ .textobject => |object| return edit.textobjectSelect(p, pane, text, object.char, object.around),
+ .surround_add => |char| return edit.surroundAdd(p, pane, char),
+ .surround_delete => |char| return edit.surroundDelete(p, pane, char),
+ .surround_replace => |replace| return edit.surroundReplace(p, pane, replace.from, replace.to),
+ .paragraph => |paragraph| {
+ const r2 = modal.moveParagraph(text, range, paragraph.count, paragraph.direction == .forward, pane.select);
+ return pane.setRange(text, 0, r2, false);
+ },
+ .add_newline => |newline| return edit.addNewline(p, pane, newline.direction == .forward, newline.count),
+ .diagnostic => |diagnostic| {
+ const fwd = diagnostic.direction == .forward;
+ if (!diagnostic.endpoint) {
+ if (panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }) and
+ p.searchStep(p.active, if (fwd) 1 else -1)) return;
+ return p.lspRequest(p.active, .diagnostics, "");
+ }
+ if (!panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }))
+ return p.lspRequest(p.active, .diagnostics, "");
+ if (!fwd) {
+ pane.search_row = null;
+ _ = p.searchStep(p.active, 1);
+ return;
+ }
+ const rp = p.panes[pane.search_pane orelse return] orelse return;
+ const rf = rp.file orelse return;
+ pane.search_row = std.mem.count(u8, rf.content, "\n");
+ _ = p.searchStep(p.active, -1);
+ },
+ .move => |move| switch (move.motion) {
+ .left => {
+ var target = cur;
+ for (0..move.count) |_| target = modal.prevGrapheme(text, target);
+ return pane.pointMove(text, range, target);
+ },
+ .right => {
+ var target = cur;
+ for (0..move.count) |_| target = modal.nextGrapheme(text, target);
+ return pane.pointMove(text, range, target);
+ },
+ .down => return pane.verticalMove(text, range, true, move.count),
+ .up => return pane.verticalMove(text, range, false, move.count),
+ .next_word_start => return pane.wordMove(text, range, move.count, .next_word_start),
+ .prev_word_start => return pane.wordMove(text, range, move.count, .prev_word_start),
+ .next_word_end => return pane.wordMove(text, range, move.count, .next_word_end),
+ .next_long_word_start => return pane.wordMove(text, range, move.count, .next_long_word_start),
+ .prev_long_word_start => return pane.wordMove(text, range, move.count, .prev_long_word_start),
+ .next_long_word_end => return pane.wordMove(text, range, move.count, .next_long_word_end),
+ },
+ .repeat_find => |count| {
+ if (pane.find_op == 0) return;
+ const fwd = pane.find_op == config.find_char_fwd or pane.find_op == config.till_char_fwd;
+ const till = pane.find_op == config.till_char_fwd or pane.find_op == config.till_char_back;
+ var repeated = range;
+ var moved = false;
+ for (0..count) |_| {
+ const cc = modal.selectionCursor(text, repeated);
+ const target = modal.findTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break;
+ repeated = if (pane.select)
+ modal.moveSelectionCursor(text, repeated, target, true)
+ else
+ modal.moveSelectionCursor(text, .{ .anchor = cc, .head = cc }, target, true);
+ moved = true;
+ }
+ if (!moved) return;
+ return pane.setRange(text, 0, repeated, false);
+ },
+ .line => |line_kind| switch (line_kind) {
+ .start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))),
+ .end => {
+ const line = modal.lineAtOffset(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line))));
+ },
+ .first_nonws => {
+ const line = modal.lineAtOffset(text, cur);
+ const ls = modal.lineStartOffset(text, line);
+ const slice = text[ls..modal.lineEndOffset(text, line)];
+ const nw = modal.firstNonWs(slice);
+ if (nw == slice.len) return;
+ return pane.pointMove(text, range, ls + nw);
+ },
+ },
+ .goto_line => |go| {
+ if (!go.explicit) return;
+ const line = @min(@as(usize, go.count) - 1, Pane.maxLine(text));
+ return pane.pointMove(text, range, modal.lineStartOffset(text, line));
+ },
+ .page => |page| switch (page.kind) {
+ // Counts were parsed historically but these four text view
+ // actions intentionally move exactly one viewport unit.
+ .half_down => return pane.halfPageMove(text, range, true),
+ .half_up => return pane.halfPageMove(text, range, false),
+ .down => return pane.scrollViewMove(text, range, @as(i32, pane.rows)),
+ .up => return pane.scrollViewMove(text, range, -@as(i32, pane.rows)),
+ },
+ .insert => |insert| {
+ const where: edit.InsertAt = switch (insert.kind) {
+ .at => .at,
+ .append => .append,
+ .line_start => .line_start,
+ .line_end => .line_end,
+ .open_below => .open_below,
+ .open_above => .open_above,
+ };
+ return edit.enterInsert(p, pane, where, insert.count);
+ },
+ .select => |select| switch (select.kind) {
+ .mode => {
+ if (pane.select) {
+ pane.select = false;
+ } else {
+ pane.select = true;
+ if (pane.msel.active) {
+ pane.setRange(text, 0, range, true);
+ } else if (!pane.vsel.active) {
+ pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true };
+ } else {
+ pane.vsel.explicit = true;
+ }
+ }
+ pane.cur_pinned = true;
+ },
+ .line => return pane.lineSelect(text, range, select.count),
+ .line_bounds => return pane.lineBoundsSelect(text, range),
+ .shrink_to_line_bounds => return pane.shrinkSelToLineBounds(text, range),
+ .collapse => return pane.setRange(text, 0, .{ .anchor = cur, .head = cur }, false),
+ .flip => {
+ const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ return pane.setRange(text, 0, .{ .anchor = range.head, .head = range.anchor }, explicit);
+ },
+ .all => return pane.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false),
+ },
+ .multi => |multi| {
+ if (multi.kind == .keep_primary) {
+ pane.nsel = 0;
+ return;
+ }
+ return pane.multiSelAction(text, multi.kind, multi.count);
+ },
+ .select_regex => |split| return startSelRegex(p, pane, split),
+ .edit => |op| switch (op.kind) {
+ .delete => return edit.normalDelete(p, pane, true),
+ .delete_noyank => return edit.normalDelete(p, pane, false),
+ .change => return edit.normalChange(p, pane),
+ .yank => return edit.normalYank(p, pane),
+ .replace_with_yank => return edit.normalReplaceYank(p, pane),
+ .paste_after => return edit.pasteText(p, pane, p.yank orelse return, false, op.count),
+ .paste_before => return edit.pasteText(p, pane, p.yank orelse return, true, op.count),
+ .switch_case => return edit.normalCase(p, pane, .toggle),
+ .lowercase => return edit.normalCase(p, pane, .lower),
+ .uppercase => return edit.normalCase(p, pane, .upper),
+ .join_lines => return edit.normalJoin(p, pane),
+ .indent => return edit.normalIndent(p, pane, op.count, true),
+ .unindent => return edit.normalIndent(p, pane, op.count, false),
+ .comment_toggle => return edit.normalToggleComment(p, pane),
+ .undo => return edit.doUndo(p, pane),
+ .redo => return edit.doRedo(p, pane),
+ },
+ .lsp => |request| return p.lspRequest(p.active, switch (request) {
+ .definition => .definition,
+ .declaration => .declaration,
+ .type_definition => .type_definition,
+ .implementation => .implementation,
+ .references => .references,
+ .format => .format,
+ }, ""),
+ .adjust_number => |delta| return edit.normalAdjustNumber(p, pane, delta),
+ .leader => {
+ p.leader_on = true;
+ p.leader_n = 0;
+ },
+ .command_line => {
+ p.enterTagEdit(pane, -1);
+ if (pane.tag_edit) pane.mode = .normal;
+ },
+ .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }),
+ .search => return p.startPrompt(pane, .{ .search = config.search_marker }),
+ .search_step => |direction| return p.lookWalk(
+ if (direction == .forward) @as(i32, 1) else -1,
+ ),
+ }
+}
+
+pub fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void {
+ if (comptime !pdf_enabled) return;
+ if (pane.pdf == null) return;
+ if (hit(key, config.leave_pane)) {
+ pane.normal.clear();
+ pane.select = false;
+ return p.runBuiltin(.Last, p.active, "", null);
+ }
+ if (pane.normal.prefix == .none and isPrefix(key, 'f')) {
+ pane.normal.clear();
+ return p.runBuiltin(.PdfSections, p.active, "", null);
+ }
+ const parsed = modal.Normal.parse(&pane.normal, normalInput(key));
+ switch (parsed) {
+ .pending, .ignored, .unbound => {},
+ .action => |semantic| {
+ const result = panes.Pdf.applyNormal(
+ &pane.pdf.?,
+ p.pdf_gpa,
+ semantic,
+ p.native_images,
+ .{ .w = p.cell_pixels.w, .h = p.cell_pixels.h },
+ panes.Pdf.paneViewport(p, pane),
+ panes.Pdf.paneGeometry(p, pane),
+ );
+ if (result.page_changed) panes.Pdf.resetPageChrome(pane);
+ switch (result.host) {
+ .none => {},
+ .leader => {
+ p.leader_on = true;
+ p.leader_n = 0;
+ },
+ .command_line => {
+ p.enterTagEdit(pane, -1);
+ if (pane.tag_edit) pane.mode = .normal;
+ },
+ .search => p.startPrompt(pane, .{ .search = config.search_marker }),
+ .search_forward => p.lookWalk(1),
+ .search_backward => p.lookWalk(-1),
+ }
+ },
+ }
+}
+
+test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const long = "a" ** 400;
+ const pane = try p.setTestFile(long ++ "\nsecond\n");
+ p.settings.wrap = true;
+ const width = panes.File.wrapWidth(pane, true);
+ try std.testing.expect(width > 4 and long.len > width * 3);
+
+ // gj holds the column INSIDE the row and lands on the next break; the line
+ // is one line the whole way down, which is the whole point.
+ pane.cur_row = 0;
+ pane.cur_col = 3;
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, @intCast(width * 2 + 3)), pane.cur_col);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col);
+ // A count is counted in rows, not lines.
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, @intCast(width * 3 + 3)), pane.cur_col);
+
+ // Plain j/k are untouched: one FILE line each, whatever the body drew.
+ pane.cur_col = 3;
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+
+ // The last row of a line steps into the next line's first row, and the
+ // first row steps back onto the previous line's LAST row.
+ pane.cur_row = 0;
+ pane.cur_col = @intCast(long.len - 1);
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+ pane.cur_col = 0;
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+ try std.testing.expect(pane.cur_col >= @as(i32, @intCast(long.len - long.len % width)));
+
+ // Wrap OFF: a line is one visual row, so gj/gk ARE j/k again.
+ p.settings.wrap = false;
+ pane.cur_row = 0;
+ pane.cur_col = 3;
+ pane.sticky_col = -1;
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+}
+
+test "flat text movement and selection replay need no scratch rows" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const row_text = "one (two) λ\r\n";
+ const text = try gpa.alloc(u8, 4096 * row_text.len);
+ defer gpa.free(text);
+ for (0..4096) |row| @memcpy(text[row * row_text.len ..][0..row_text.len], row_text);
+ const pane = try p.setTestFile(text);
+ _ = try panes.File.lineIndex(gpa, &pane.file.?);
+ var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ p.scratch.deinit();
+ p.scratch = .init(denied.allocator());
+
+ pane.cur_row = 4094;
+ handleNormal(p, pane, .{ .cp = 'l' });
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
+ handleNormal(p, pane, .{ .cp = 'j' });
+ try std.testing.expectEqual(@as(i32, 4095), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
+ handleNormal(p, pane, .{ .cp = 'h' });
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_col);
+ executeNormalAction(p, pane, .{ .textobject = .{ .char = 'w', .around = false } });
+ try std.testing.expectEqual(@as(i32, 2), pane.cur_col);
+ try std.testing.expectEqual(@as(i32, 0), pane.vsel.col);
+
+ pane.vsel.active = false;
+ pane.cur_row = 1024;
+ pane.cur_col = 0;
+ pane.nsel = 1;
+ pane.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 };
+ handleNormal(p, pane, .{ .cp = 'l' });
+ handleNormal(p, pane, .{ .cp = 'j' });
+ try std.testing.expectEqual(@as(u8, 1), pane.nsel);
+ try std.testing.expectEqual(@as(i32, 1025), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
+ try std.testing.expectEqual(@as(i32, 3073), pane.sels[0].row);
+ try std.testing.expectEqual(@as(i32, 1), pane.sels[0].col);
+ try std.testing.expect(!denied.has_induced_failure);
+ try std.testing.expectEqual(@as(usize, 0), p.scratch.queryCapacity());
+
+ pane.nsel = 0;
+ pane.cur_col = 4;
+ executeNormalAction(p, pane, .match_bracket);
+ try std.testing.expect(denied.has_induced_failure);
+ try std.testing.expectEqual(@as(i32, 4), pane.cur_col);
+ denied.fail_index = std.math.maxInt(usize);
+ executeNormalAction(p, pane, .match_bracket);
+ try std.testing.expectEqual(@as(i32, 8), pane.cur_col);
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index 55e1dc7a..e001aa41 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -11,6 +11,7 @@ pub const syntax = @import("syntax.zig");
pub const locations_config = @import("locations_config.zig");
const tracy = @import("tracy.zig");
pub const panes = @import("panes.zig");
+pub const normal = @import("normal.zig");
pub const edit = @import("edit.zig");
pub const body_layer = @import("body_layer.zig");
pub const Messages = @import("Messages.zig");
@@ -433,6 +434,7 @@ pub const Loc = struct {
};
test {
+ _ = @import("normal.zig");
_ = @import("edit.zig");
_ = @import("Messages.zig");
_ = @import("selection_pipe.zig");
@@ -576,72 +578,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le
try std.testing.expect(!p.quit);
}
-test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const long = "a" ** 400;
- const pane = try p.setTestFile(long ++ "\nsecond\n");
- p.settings.wrap = true;
- const width = panes.File.wrapWidth(pane, true);
- try std.testing.expect(width > 4 and long.len > width * 3);
-
- // gj holds the column INSIDE the row and lands on the next break; the line
- // is one line the whole way down, which is the whole point.
- pane.cur_row = 0;
- pane.cur_col = 3;
- pane.sticky_col = -1;
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
- try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col);
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(i32, @intCast(width * 2 + 3)), pane.cur_col);
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col);
- // A count is counted in rows, not lines.
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
- try std.testing.expectEqual(@as(i32, @intCast(width * 3 + 3)), pane.cur_col);
-
- // Plain j/k are untouched: one FILE line each, whatever the body drew.
- pane.cur_col = 3;
- pane.sticky_col = -1;
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
-
- // The last row of a line steps into the next line's first row, and the
- // first row steps back onto the previous line's LAST row.
- pane.cur_row = 0;
- pane.cur_col = @intCast(long.len - 1);
- pane.sticky_col = -1;
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
- pane.cur_col = 0;
- pane.sticky_col = -1;
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
- try std.testing.expect(pane.cur_col >= @as(i32, @intCast(long.len - long.len % width)));
-
- // Wrap OFF: a line is one visual row, so gj/gk ARE j/k again.
- p.settings.wrap = false;
- pane.cur_row = 0;
- pane.cur_col = 3;
- pane.sticky_col = -1;
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
-}
-
test "the acme chords act once per selection, not once on the primary" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
@@ -2688,57 +2624,6 @@ test "every line of the board's boot buffer renders whole" {
// ---- the boundary types ----
-test "flat text movement and selection replay need no scratch rows" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 });
- defer p.deinit();
- const row_text = "one (two) λ\r\n";
- const text = try gpa.alloc(u8, 4096 * row_text.len);
- defer gpa.free(text);
- for (0..4096) |row| @memcpy(text[row * row_text.len ..][0..row_text.len], row_text);
- const pane = try p.setTestFile(text);
- _ = try panes.File.lineIndex(gpa, &pane.file.?);
- var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
- p.scratch.deinit();
- p.scratch = .init(denied.allocator());
-
- pane.cur_row = 4094;
- p.handleNormal(pane, .{ .cp = 'l' });
- try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
- p.handleNormal(pane, .{ .cp = 'j' });
- try std.testing.expectEqual(@as(i32, 4095), pane.cur_row);
- try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
- p.handleNormal(pane, .{ .cp = 'h' });
- try std.testing.expectEqual(@as(i32, 0), pane.cur_col);
- p.executeNormalAction(pane, .{ .textobject = .{ .char = 'w', .around = false } });
- try std.testing.expectEqual(@as(i32, 2), pane.cur_col);
- try std.testing.expectEqual(@as(i32, 0), pane.vsel.col);
-
- pane.vsel.active = false;
- pane.cur_row = 1024;
- pane.cur_col = 0;
- pane.nsel = 1;
- pane.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 };
- p.handleNormal(pane, .{ .cp = 'l' });
- p.handleNormal(pane, .{ .cp = 'j' });
- try std.testing.expectEqual(@as(u8, 1), pane.nsel);
- try std.testing.expectEqual(@as(i32, 1025), pane.cur_row);
- try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
- try std.testing.expectEqual(@as(i32, 3073), pane.sels[0].row);
- try std.testing.expectEqual(@as(i32, 1), pane.sels[0].col);
- try std.testing.expect(!denied.has_induced_failure);
- try std.testing.expectEqual(@as(usize, 0), p.scratch.queryCapacity());
-
- pane.nsel = 0;
- pane.cur_col = 4;
- p.executeNormalAction(pane, .match_bracket);
- try std.testing.expect(denied.has_induced_failure);
- try std.testing.expectEqual(@as(i32, 4), pane.cur_col);
- denied.fail_index = std.math.maxInt(usize);
- p.executeNormalAction(pane, .match_bracket);
- try std.testing.expectEqual(@as(i32, 8), pane.cur_col);
-}
-
test "Unicode display cells map back to body and tag byte cursors" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
@@ -3272,47 +3157,6 @@ pub fn hit(key: Key, binding: []const config.Chord) bool {
pub const sel_slot = @intFromEnum(config.select_button);
-fn isPrefix(key: Key, cp: u21) bool {
- return key.cp == cp and !key.ctrl and !key.alt;
-}
-fn roleBindingName(comptime role: modal.Normal.Role) []const u8 {
- return switch (role) {
- .prefix_goto => "goto_prefix",
- .prefix_view => "view_prefix",
- .prefix_match => "match_prefix",
- .prefix_find_fwd => "find_char_fwd",
- .prefix_find_back => "find_char_back",
- .prefix_till_fwd => "till_char_fwd",
- .prefix_till_back => "till_char_back",
- .prefix_replace => "replace_prefix",
- .prefix_next => "next_prefix",
- .prefix_prev => "prev_prefix",
- else => @tagName(role),
- };
-}
-
-fn normalInput(key: Key) modal.Normal.Input {
- var out: modal.Normal.Input = .{
- .cp = key.cp,
- .ctrl = key.ctrl,
- .alt = key.alt,
- };
- inline for (std.enums.values(modal.Normal.Role)) |role| {
- const name = comptime roleBindingName(role);
- if (!@hasDecl(config, name))
- @compileError("normal input role has no config binding: " ++ name);
- const binding = @field(config, name);
- const matched = if (@TypeOf(binding) == comptime_int)
- isPrefix(key, binding)
- else if (@TypeOf(binding) == []const config.Chord)
- hit(key, binding)
- else
- @compileError("normal input binding has unsupported type: " ++ name);
- if (matched) out.roles.insert(role);
- }
- return out;
-}
-
/// Pixel dimensions are meaningful only to the native PDF placement path.
/// Keep resize events layout-only when that path is not part of the build.
pub const CellPixels = if (pdf_enabled) struct {
@@ -5694,12 +5538,6 @@ pub const Pardes = struct {
}
}
- fn multiOnce(p: *Pardes) bool {
- if (!p.multi_on) return true;
- p.multi_stop = true;
- return p.multi_first;
- }
-
// ---- tag + selection text (chord sources) ----
pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
@@ -6573,8 +6411,8 @@ pub const Pardes = struct {
.search => |at| {
if (submit)
p.submitSearch(p.active)
- else if (selRegexArmed(pane)) |_|
- p.applySelRegex(pane, "", false);
+ else if (normal.selRegexArmed(pane)) |_|
+ normal.applySelRegex(p, pane, "", false);
pane.tag_tail_len = @min(at, pane.tag_tail_len);
},
.none, .del_side => unreachable,
@@ -6587,7 +6425,7 @@ pub const Pardes = struct {
if (pane.tag_edit) {
if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key);
const pn = p.panes[p.active] orelse return;
- if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split);
+ if (normal.selRegexArmed(pn)) |a| normal.applySelRegex(p, pn, a.pat, a.split);
return;
}
if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
@@ -6610,10 +6448,10 @@ pub const Pardes = struct {
p.runBuiltin(cmd, p.active, "", word);
return;
}
- if (pane.mode == .normal and pane.hasPdf()) return p.handlePdfNormal(pane, key);
+ if (pane.mode == .normal and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key);
switch (pane.mode) {
.normal => {
- p.handleNormal(pane, key);
+ normal.handleNormal(p, pane, key);
},
.insert => {
if (hit(key, config.escape)) return edit.exitInsert(p, pane);
@@ -6651,501 +6489,6 @@ pub const Pardes = struct {
// ---- helix-modal normal mode ----
- fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void {
- const text = edit.flatSurface(p, pane) catch return;
- const got = pane.ranges(text, 0, &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.startPrompt(pane, .{ .search = if (split) config.split_marker else config.select_marker });
- }
-
- pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } {
- const prompt_at = switch (pane.prompt) {
- .search => |at| at,
- else => return null,
- };
- if (pane.nsel_snap == 0) return null;
- const tail = pane.tagSlice();
- const armed = tail[@min(prompt_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 };
- }
-
- pub fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void {
- const text = edit.flatSurface(p, pane) catch return;
- const snap = pane.sel_snap[0..pane.nsel_snap];
- var out: [Pane.max_selections]modal.Selection = undefined;
- var m: usize = 0;
- if (pat.len > 0) if (mvzr.compile(pat)) |re| {
- 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 < Pane.max_selections) {
- 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) {
- 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 < Pane.max_selections) {
- 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 pane.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl);
- }
- pane.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO)
- }
-
- const KeyState = struct {
- mode: Pane.Mode,
- select: bool,
- normal: modal.Normal.State,
- find_op: u8,
- find_ch: u21,
- append_at: @FieldType(Pane, "append_at"),
-
- fn of(pane: *Pane) KeyState {
- return .{
- .mode = pane.mode,
- .select = pane.select,
- .normal = pane.normal,
- .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.normal = s.normal;
- 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 = union(enum) {
- normal: modal.Normal.Action,
- insert: Key,
- };
-
- pub fn replaySels(p: *Pardes, pane: *Pane, what: Replay) 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: [Pane.max_selections]Pane.SelRange = undefined;
- var n: usize = 0;
- var pri: usize = 0;
- const prim: Pane.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 = Pane.selStart(prim);
- var placed = false;
- for (pane.sels[0..pane.nsel]) |s| {
- const sr = Pane.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: [Pane.max_selections]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 => |normal_action| p.executeNormalAction(pane, normal_action),
- .insert => |insert_key| edit.insertKey(p, pane, insert_key),
- }
- if (p.multi_stop) break;
- if (i == pri) {
- after = KeyState.of(pane);
- after_expl = pane.vsel.explicit;
- }
- const text = edit.flatSurface(p, pane) catch {
- p.multi_stop = true;
- break;
- };
- const co = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) });
- const ao = if (pane.vsel.active)
- modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .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) {
- p.multi_stop = false;
- const pn = p.panes[id] orelse return;
- if (pn.serial == serial) pn.nsel = 0;
- return;
- }
- const text = edit.flatSurface(p, pane) catch return;
- var rs: [Pane.max_selections]modal.Selection = undefined;
- var st: [Pane.max_selections]i32 = undefined;
- for (res[0..n], 0..) |r, k| {
- rs[k] = Pane.cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len));
- st[k] = r.sticky;
- }
- pane.setRanges(text, rs[0..n], st[0..n], pri, after_expl);
- after.into(pane);
- pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass
- }
-
- fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void {
- const parsed = modal.Normal.parse(&pane.normal, normalInput(key));
- const semantic = switch (parsed) {
- .pending, .ignored, .unbound => return,
- .action => |value| value,
- };
- if (pane.nsel == 0 or semantic.scope() == .once)
- return p.executeNormalAction(pane, semantic);
- p.replaySels(pane, .{ .normal = semantic });
- }
-
- fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.Action) void {
- const raw_selection = pane.rawPointerText(pane.sel[sel_slot]) != null;
- const before = .{ pane.cur_row, pane.cur_col, pane.vsel, pane.msel, pane.select, pane.nsel, pane.mode };
- defer if (raw_selection and !std.meta.eql(before, .{ pane.cur_row, pane.cur_col, pane.vsel, pane.msel, pane.select, pane.nsel, pane.mode })) {
- pane.clearPointerSelection(sel_slot);
- pane.sel[sel_slot].state = .none;
- };
- if (raw_selection and semantic == .edit and semantic.edit.kind == .yank) return edit.normalYank(p, pane);
- edit.pinPaneCursor(p, pane);
- const text = edit.flatSurface(p, pane) catch return;
- const range = pane.primaryRange(text, 0);
- const cur = modal.selectionCursor(text, range);
-
- switch (semantic) {
- .escape => {
- pane.select = false;
- return p.runBuiltin(.Last, p.active, "", null);
- },
- .goto => |go| switch (go.target) {
- .file_start => {
- const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, Pane.maxLine(text)) else 0;
- return pane.pointMove(text, range, modal.lineStartOffset(text, line));
- },
- .last_line => return pane.pointMove(text, range, modal.lineStartOffset(text, Pane.maxLine(text))),
- .line_start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))),
- .line_end => {
- const line = modal.lineAtOffset(text, cur);
- const ls = modal.lineStartOffset(text, line);
- return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line))));
- },
- .first_nonws => {
- const line = modal.lineAtOffset(text, cur);
- const ls = modal.lineStartOffset(text, line);
- const slice = text[ls..modal.lineEndOffset(text, line)];
- const nw = modal.firstNonWs(slice);
- if (nw == slice.len) return;
- return pane.pointMove(text, range, ls + nw);
- },
- .line_down => return pane.visualMove(text, range, true, go.count, edit.paneWrapWidth(p, pane)),
- .line_up => return pane.visualMove(text, range, false, go.count, edit.paneWrapWidth(p, pane)),
- .column => {
- const line = modal.lineAtOffset(text, cur);
- const ls = modal.lineStartOffset(text, line);
- const slice = text[ls..modal.lineEndOffset(text, line)];
- return pane.pointMove(text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1));
- },
- .view_top => return pane.gotoWindow(text, range, .top, go.count),
- .view_center => return pane.gotoWindow(text, range, .center, go.count),
- .view_bottom => return pane.gotoWindow(text, range, .bottom, go.count),
- },
- .view => |view| switch (view) {
- .top => {
- pane.scrollBy(pane.cur_row - pane.scroll());
- pane.ensureCursorVisible();
- },
- .center => {
- pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1));
- pane.ensureCursorVisible();
- },
- .bottom => {
- pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1));
- pane.ensureCursorVisible();
- },
- .scroll_down => return pane.scrollViewMove(text, range, 1),
- .scroll_up => return pane.scrollViewMove(text, range, -1),
- },
- .find => |find| {
- const op: u8 = switch (find.kind) {
- .forward => @intCast(config.find_char_fwd),
- .backward => @intCast(config.find_char_back),
- .till_forward => @intCast(config.till_char_fwd),
- .till_backward => @intCast(config.till_char_back),
- };
- pane.find_op = op;
- pane.find_ch = find.char;
- return pane.findMove(
- text,
- range,
- find.char,
- find.kind == .forward or find.kind == .till_forward,
- find.kind == .till_forward or find.kind == .till_backward,
- find.count,
- );
- },
- .replace_char => |char| return edit.normalReplaceChar(p, pane, char),
- .match_bracket => {
- const lines = edit.paneCursorLines(p, pane) catch return;
- const mc = modal.matchBracket(lines, modal.positionAt(text, cur)) orelse return;
- return pane.pointMove(text, range, modal.offsetAt(text, mc));
- },
- .textobject => |object| return edit.textobjectSelect(p, pane, text, object.char, object.around),
- .surround_add => |char| return edit.surroundAdd(p, pane, char),
- .surround_delete => |char| return edit.surroundDelete(p, pane, char),
- .surround_replace => |replace| return edit.surroundReplace(p, pane, replace.from, replace.to),
- .paragraph => |paragraph| {
- const r2 = modal.moveParagraph(text, range, paragraph.count, paragraph.direction == .forward, pane.select);
- return pane.setRange(text, 0, r2, false);
- },
- .add_newline => |newline| return edit.addNewline(p, pane, newline.direction == .forward, newline.count),
- .diagnostic => |diagnostic| {
- const fwd = diagnostic.direction == .forward;
- if (!diagnostic.endpoint) {
- if (panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }) and
- p.searchStep(p.active, if (fwd) 1 else -1)) return;
- return p.lspRequest(p.active, .diagnostics, "");
- }
- if (!panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }))
- return p.lspRequest(p.active, .diagnostics, "");
- if (!fwd) {
- pane.search_row = null;
- _ = p.searchStep(p.active, 1);
- return;
- }
- const rp = p.panes[pane.search_pane orelse return] orelse return;
- const rf = rp.file orelse return;
- pane.search_row = std.mem.count(u8, rf.content, "\n");
- _ = p.searchStep(p.active, -1);
- },
- .move => |move| switch (move.motion) {
- .left => {
- var target = cur;
- for (0..move.count) |_| target = modal.prevGrapheme(text, target);
- return pane.pointMove(text, range, target);
- },
- .right => {
- var target = cur;
- for (0..move.count) |_| target = modal.nextGrapheme(text, target);
- return pane.pointMove(text, range, target);
- },
- .down => return pane.verticalMove(text, range, true, move.count),
- .up => return pane.verticalMove(text, range, false, move.count),
- .next_word_start => return pane.wordMove(text, range, move.count, .next_word_start),
- .prev_word_start => return pane.wordMove(text, range, move.count, .prev_word_start),
- .next_word_end => return pane.wordMove(text, range, move.count, .next_word_end),
- .next_long_word_start => return pane.wordMove(text, range, move.count, .next_long_word_start),
- .prev_long_word_start => return pane.wordMove(text, range, move.count, .prev_long_word_start),
- .next_long_word_end => return pane.wordMove(text, range, move.count, .next_long_word_end),
- },
- .repeat_find => |count| {
- if (pane.find_op == 0) return;
- const fwd = pane.find_op == config.find_char_fwd or pane.find_op == config.till_char_fwd;
- const till = pane.find_op == config.till_char_fwd or pane.find_op == config.till_char_back;
- var repeated = range;
- var moved = false;
- for (0..count) |_| {
- const cc = modal.selectionCursor(text, repeated);
- const target = modal.findTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break;
- repeated = if (pane.select)
- modal.moveSelectionCursor(text, repeated, target, true)
- else
- modal.moveSelectionCursor(text, .{ .anchor = cc, .head = cc }, target, true);
- moved = true;
- }
- if (!moved) return;
- return pane.setRange(text, 0, repeated, false);
- },
- .line => |line_kind| switch (line_kind) {
- .start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))),
- .end => {
- const line = modal.lineAtOffset(text, cur);
- const ls = modal.lineStartOffset(text, line);
- return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line))));
- },
- .first_nonws => {
- const line = modal.lineAtOffset(text, cur);
- const ls = modal.lineStartOffset(text, line);
- const slice = text[ls..modal.lineEndOffset(text, line)];
- const nw = modal.firstNonWs(slice);
- if (nw == slice.len) return;
- return pane.pointMove(text, range, ls + nw);
- },
- },
- .goto_line => |go| {
- if (!go.explicit) return;
- const line = @min(@as(usize, go.count) - 1, Pane.maxLine(text));
- return pane.pointMove(text, range, modal.lineStartOffset(text, line));
- },
- .page => |page| switch (page.kind) {
- // Counts were parsed historically but these four text view
- // actions intentionally move exactly one viewport unit.
- .half_down => return pane.halfPageMove(text, range, true),
- .half_up => return pane.halfPageMove(text, range, false),
- .down => return pane.scrollViewMove(text, range, @as(i32, pane.rows)),
- .up => return pane.scrollViewMove(text, range, -@as(i32, pane.rows)),
- },
- .insert => |insert| {
- const where: edit.InsertAt = switch (insert.kind) {
- .at => .at,
- .append => .append,
- .line_start => .line_start,
- .line_end => .line_end,
- .open_below => .open_below,
- .open_above => .open_above,
- };
- return edit.enterInsert(p, pane, where, insert.count);
- },
- .select => |select| switch (select.kind) {
- .mode => {
- if (pane.select) {
- pane.select = false;
- } else {
- pane.select = true;
- if (pane.msel.active) {
- pane.setRange(text, 0, range, true);
- } else if (!pane.vsel.active) {
- pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true };
- } else {
- pane.vsel.explicit = true;
- }
- }
- pane.cur_pinned = true;
- },
- .line => return pane.lineSelect(text, range, select.count),
- .line_bounds => return pane.lineBoundsSelect(text, range),
- .shrink_to_line_bounds => return pane.shrinkSelToLineBounds(text, range),
- .collapse => return pane.setRange(text, 0, .{ .anchor = cur, .head = cur }, false),
- .flip => {
- const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
- return pane.setRange(text, 0, .{ .anchor = range.head, .head = range.anchor }, explicit);
- },
- .all => return pane.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false),
- },
- .multi => |multi| {
- if (multi.kind == .keep_primary) {
- pane.nsel = 0;
- return;
- }
- return pane.multiSelAction(text, multi.kind, multi.count);
- },
- .select_regex => |split| return p.startSelRegex(pane, split),
- .edit => |op| switch (op.kind) {
- .delete => return edit.normalDelete(p, pane, true),
- .delete_noyank => return edit.normalDelete(p, pane, false),
- .change => return edit.normalChange(p, pane),
- .yank => return edit.normalYank(p, pane),
- .replace_with_yank => return edit.normalReplaceYank(p, pane),
- .paste_after => return edit.pasteText(p, pane, p.yank orelse return, false, op.count),
- .paste_before => return edit.pasteText(p, pane, p.yank orelse return, true, op.count),
- .switch_case => return edit.normalCase(p, pane, .toggle),
- .lowercase => return edit.normalCase(p, pane, .lower),
- .uppercase => return edit.normalCase(p, pane, .upper),
- .join_lines => return edit.normalJoin(p, pane),
- .indent => return edit.normalIndent(p, pane, op.count, true),
- .unindent => return edit.normalIndent(p, pane, op.count, false),
- .comment_toggle => return edit.normalToggleComment(p, pane),
- .undo => return edit.doUndo(p, pane),
- .redo => return edit.doRedo(p, pane),
- },
- .lsp => |request| return p.lspRequest(p.active, switch (request) {
- .definition => .definition,
- .declaration => .declaration,
- .type_definition => .type_definition,
- .implementation => .implementation,
- .references => .references,
- .format => .format,
- }, ""),
- .adjust_number => |delta| return edit.normalAdjustNumber(p, pane, delta),
- .leader => {
- p.leader_on = true;
- p.leader_n = 0;
- },
- .command_line => {
- p.enterTagEdit(pane, -1);
- if (pane.tag_edit) pane.mode = .normal;
- },
- .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }),
- .search => return p.startPrompt(pane, .{ .search = config.search_marker }),
- .search_step => |direction| return p.lookWalk(
- if (direction == .forward) @as(i32, 1) else -1,
- ),
- }
- }
-
// ---- selection pipe (`|`): visible prompt, async shell, atomic edit ----
// ---- search (`/`, Find, n/N): results into an output buffer, look the rows ----
@@ -7279,7 +6622,7 @@ pub const Pardes = struct {
fn submitSearch(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
- if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split);
+ if (normal.selRegexArmed(pane)) |a| return normal.applySelRegex(p, pane, a.pat, a.split);
const armed = pane.promptText() orelse return;
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return;
if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]);
@@ -7400,7 +6743,7 @@ pub const Pardes = struct {
try panes.Output.fillResults(p, id, dir, from, pat, content, anchor);
}
- fn searchStep(p: *Pardes, id: usize, delta: i32) bool {
+ pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool {
const pane = p.panes[id] orelse return false;
const rid = pane.search_pane orelse return false;
const rp = p.panes[rid] orelse return false;
@@ -7477,7 +6820,7 @@ pub const Pardes = struct {
}
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 (!normal.multiOnce(p)) return; // one question per keystroke, from the primary
if (!lsp.supports.contains(kind)) return;
const pane = p.panes[id] orelse return;
if (kind != .status) {
@@ -7803,7 +7146,7 @@ pub const Pardes = struct {
return null;
}
- fn lookWalk(p: *Pardes, delta: i32) void {
+ pub fn lookWalk(p: *Pardes, delta: i32) void {
var buf: [MAX_PANES]usize = undefined;
const order = p.lookWalkPanes(&buf);
if (order.len == 0) return;
@@ -8976,50 +8319,6 @@ pub const Pardes = struct {
return pane;
}
- fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void {
- if (comptime !pdf_enabled) return;
- if (pane.pdf == null) return;
- if (hit(key, config.leave_pane)) {
- pane.normal.clear();
- pane.select = false;
- return p.runBuiltin(.Last, p.active, "", null);
- }
- if (pane.normal.prefix == .none and isPrefix(key, 'f')) {
- pane.normal.clear();
- return p.runBuiltin(.PdfSections, p.active, "", null);
- }
- const parsed = modal.Normal.parse(&pane.normal, normalInput(key));
- switch (parsed) {
- .pending, .ignored, .unbound => {},
- .action => |semantic| {
- const result = panes.Pdf.applyNormal(
- &pane.pdf.?,
- p.pdf_gpa,
- semantic,
- p.native_images,
- .{ .w = p.cell_pixels.w, .h = p.cell_pixels.h },
- panes.Pdf.paneViewport(p, pane),
- panes.Pdf.paneGeometry(p, pane),
- );
- if (result.page_changed) panes.Pdf.resetPageChrome(pane);
- switch (result.host) {
- .none => {},
- .leader => {
- p.leader_on = true;
- p.leader_n = 0;
- },
- .command_line => {
- p.enterTagEdit(pane, -1);
- if (pane.tag_edit) pane.mode = .normal;
- },
- .search => p.startPrompt(pane, .{ .search = config.search_marker }),
- .search_forward => p.lookWalk(1),
- .search_backward => p.lookWalk(-1),
- }
- },
- }
- }
-
// ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ----
pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void {
@@ -9358,8 +8657,8 @@ pub const Pardes = struct {
while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line);
}
- 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
+ pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void {
+ if (!normal.multiOnce(p)) return; // a builtin is per-keystroke, never per-cursor
const pane = p.panes[id] orelse return;
// A builtin says its own name before it runs, so a tagline click is
// legible even when what it did is invisible. Whatever the builtin