summaryrefslogtreecommitdiff
path: root/src/normal.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 22:13:34 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commitf1025c18a78c02ebf157f0e3a5d9552605c082e8 (patch)
treed5e394baa9b74ed20b2e5549fbbc7f24917caa2a /src/normal.zig
parentff953a6236ebdf97c73deda86fd9005906a2071b (diff)
downloadpardes-f1025c18a78c02ebf157f0e3a5d9552605c082e8.tar.gz
pardes-f1025c18a78c02ebf157f0e3a5d9552605c082e8.zip
Move helix normal mode out of pardes.zig into normal.zig
Pure move, no behaviour change: the key-to-role matching (isPrefix, roleBindingName, normalInput), multiOnce, the selection regex prompt (startSelRegex, selRegexArmed, applySelRegex), KeyState, Replay and replaySels, handleNormal, executeNormalAction and handlePdfNormal, with two tests, go verbatim to normal.zig. The methods become free functions taking `p: *Pardes`; their 22 call sites (handleKey, the mouse, look walk, lsp, runBuiltin, body drawing and edit.zig) change from `p.handleNormal(..)` to `normal.handleNormal(p, ..)`. test/lspbench.zig's second anchor follows its needle into src/normal.zig. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/normal.zig')
-rw-r--r--src/normal.zig720
1 files changed, 720 insertions, 0 deletions
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);
+}