summaryrefslogtreecommitdiff
path: root/src/normal.zig
diff options
context:
space:
mode:
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);
+}