//! 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 tagline = @import("tagline.zig"); const exec = @import("exec.zig"); const look = @import("look.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 Text = panes.Text; 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.body) catch return; const got = pane.body.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.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 } { if (pane.prompt != .search or pane.nsel_snap == 0) return null; const armed = pane.input.own orelse ""; 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.body) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [Text.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 < Text.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 < Text.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.body.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); } pane.body.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } const KeyState = struct { mode: Text.Mode, select: bool, normal: modal.Normal.State, find_op: u8, find_ch: u21, append_at: @FieldType(Text, "append_at"), fn of(t: *Text) KeyState { return .{ .mode = t.mode, .select = t.select, .normal = t.normal, .find_op = t.find_op, .find_ch = t.find_ch, .append_at = t.append_at, }; } fn into(s: KeyState, t: *Text) void { t.mode = s.mode; t.select = s.select; t.normal = s.normal; t.find_op = s.find_op; t.find_ch = s.find_ch; t.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, t: *Text, what: Replay) void { const id = p.active; // a header belongs to no pane and cannot go away under the replay const owner: ?*Pane = if (t.what == .column or t.what == .workspace) null else t.pane(); const serial = if (owner) |pane| pane.serial else 0; // the whole selection in text coordinates, document order. t.sels // is already ordered, so this only slots the primary into place. var list: [Text.max_selections]Text.SelRange = undefined; var n: usize = 0; var pri: usize = 0; const prim: Text.SelRange = .{ .row = t.cur_row, .col = t.cur_col, .arow = if (t.vsel.active) t.vsel.row else t.cur_row, .acol = if (t.vsel.active) t.vsel.col else t.cur_col, .sticky = t.sticky_col, }; const pr = Text.selStart(prim); var placed = false; for (t.sels[0..t.nsel]) |s| { const sr = Text.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(t); const explicit = t.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: [Text.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(t); t.nsel = 0; // the handler underneath sees ONE selection t.cur_row = list[i].row; t.cur_col = list[i].col; t.vsel = .{ .active = list[i].arow != list[i].row or list[i].acol != list[i].col or t.select, .row = list[i].arow, .col = list[i].acol, .explicit = explicit, }; t.msel.active = false; t.sticky_col = list[i].sticky; switch (what) { .normal => |normal_action| executeNormalAction(p, t, normal_action), .insert => |insert_key| edit.insertKey(p, t, insert_key), } if (p.multi_stop) break; if (i == pri) { after = KeyState.of(t); after_expl = t.vsel.explicit; } const text = edit.flatSurface(p, t) catch { p.multi_stop = true; break; }; const co = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); const ao = if (t.vsel.active) modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) }) else co; res[i] = .{ .cur = text.len - @min(co, text.len), .anc = text.len - @min(ao, text.len), .sticky = t.sticky_col }; } p.multi_on = false; p.multi_first = false; if (p.multi_stop) { p.multi_stop = false; if (owner != null) { const pn = p.panes[id] orelse return; if (pn.serial != serial) return; } t.nsel = 0; return; } const text = edit.flatSurface(p, t) catch return; var rs: [Text.max_selections]modal.Selection = undefined; var st: [Text.max_selections]i32 = undefined; for (res[0..n], 0..) |r, k| { rs[k] = Text.cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); st[k] = r.sticky; } t.setRanges(text, rs[0..n], st[0..n], pri, after_expl); after.into(t); t.show(); // the view follows the PRIMARY, not the last pass } pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void { const parsed = modal.Normal.parse(&t.normal, normalInput(key)); const semantic = switch (parsed) { .pending, .ignored, .unbound => return, .action => |value| value, }; if (t.nsel == 0 or semantic.scope() == .once) return executeNormalAction(p, t, semantic); replaySels(p, t, .{ .normal = semantic }); } fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void { // What reaches past the text -- a builtin, a search, a language query -- // acts for the pane the text belongs to; a header's is the active one. const pane = if (t.what == .column or t.what == .workspace) p.panes[p.active] orelse return else t.pane(); const raw_selection = t.what == .body and pane.rawPointerText(pane.sel[sel_slot]) != null; const before = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.select, t.nsel, t.mode }; defer if (raw_selection and !std.meta.eql(before, .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.select, t.nsel, t.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, t); edit.pinPaneCursor(p, t); const text = edit.flatSurface(p, t) catch return; const range = t.primaryRange(text, 0); const cur = modal.selectionCursor(text, range); switch (semantic) { .escape => { t.select = false; return exec.runBuiltin(p, .Last, p.active, "", null); }, .goto => |go| switch (go.target) { .file_start => { const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, Text.maxLine(text)) else 0; return t.pointMove(text, range, modal.lineStartOffset(text, line)); }, .last_line => return t.pointMove(text, range, modal.lineStartOffset(text, Text.maxLine(text))), .line_start => return t.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 t.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 t.pointMove(text, range, ls + nw); }, .line_down => return t.visualMove(text, range, true, go.count, edit.paneWrapWidth(p, t)), .line_up => return t.visualMove(text, range, false, go.count, edit.paneWrapWidth(p, t)), .column => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.lineEndOffset(text, line)]; return t.pointMove(text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1)); }, .view_top => return t.gotoWindow(text, range, .top, go.count), .view_center => return t.gotoWindow(text, range, .center, go.count), .view_bottom => return t.gotoWindow(text, range, .bottom, go.count), }, .view => |view| if (t.what == .body) switch (view) { .top => { pane.scrollBy(t.cur_row - pane.scroll()); pane.ensureCursorVisible(); }, .center => { pane.scrollBy(t.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1)); pane.ensureCursorVisible(); }, .bottom => { pane.scrollBy(t.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); }, .scroll_down => return t.scrollViewMove(text, range, 1), .scroll_up => return t.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), }; t.find_op = op; t.find_ch = find.char; return t.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, t, char), .match_bracket => { const lines = edit.paneCursorLines(p, t) catch return; const mc = modal.matchBracket(lines, modal.positionAt(text, cur)) orelse return; return t.pointMove(text, range, modal.offsetAt(text, mc)); }, .textobject => |object| return edit.textobjectSelect(p, t, text, object.char, object.around), .surround_add => |char| return edit.surroundAdd(p, t, char), .surround_delete => |char| return edit.surroundDelete(p, t, char), .surround_replace => |replace| return edit.surroundReplace(p, t, replace.from, replace.to), .paragraph => |paragraph| { const r2 = modal.moveParagraph(text, range, paragraph.count, paragraph.direction == .forward, t.select); return t.setRange(text, 0, r2, false); }, .add_newline => |newline| return edit.addNewline(p, t, newline.direction == .forward, newline.count), .diagnostic => |diagnostic| { const fwd = diagnostic.direction == .forward; if (!diagnostic.endpoint) { if (panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }) and look.searchStep(p, 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; _ = look.searchStep(p, 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"); _ = look.searchStep(p, p.active, -1); }, .move => |move| switch (move.motion) { .left => { var target = cur; for (0..move.count) |_| target = modal.prevGrapheme(text, target); return t.pointMove(text, range, target); }, .right => { var target = cur; for (0..move.count) |_| target = modal.nextGrapheme(text, target); return t.pointMove(text, range, target); }, .down => return t.verticalMove(text, range, true, move.count), .up => return t.verticalMove(text, range, false, move.count), .next_word_start => return t.wordMove(text, range, move.count, .next_word_start), .prev_word_start => return t.wordMove(text, range, move.count, .prev_word_start), .next_word_end => return t.wordMove(text, range, move.count, .next_word_end), .next_long_word_start => return t.wordMove(text, range, move.count, .next_long_word_start), .prev_long_word_start => return t.wordMove(text, range, move.count, .prev_long_word_start), .next_long_word_end => return t.wordMove(text, range, move.count, .next_long_word_end), }, .repeat_find => |count| { if (t.find_op == 0) return; const fwd = t.find_op == config.find_char_fwd or t.find_op == config.till_char_fwd; const till = t.find_op == config.till_char_fwd or t.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, t.find_ch, fwd, till, 1) orelse break; repeated = if (t.select) modal.moveSelectionCursor(text, repeated, target, true) else modal.moveSelectionCursor(text, .{ .anchor = cc, .head = cc }, target, true); moved = true; } if (!moved) return; return t.setRange(text, 0, repeated, false); }, .line => |line_kind| switch (line_kind) { .start => return t.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .end => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); return t.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 t.pointMove(text, range, ls + nw); }, }, .goto_line => |go| { if (!go.explicit) return; const line = @min(@as(usize, go.count) - 1, Text.maxLine(text)); return t.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 t.halfPageMove(text, range, true), .half_up => return t.halfPageMove(text, range, false), .down => return t.scrollViewMove(text, range, @as(i32, pane.rows)), .up => return t.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, t, where, insert.count); }, .select => |select| switch (select.kind) { .mode => { if (t.select) { t.select = false; } else { t.select = true; if (t.msel.active) { t.setRange(text, 0, range, true); } else if (!t.vsel.active) { t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col, .explicit = true }; } else { t.vsel.explicit = true; } } t.cur_pinned = true; }, .line => return t.lineSelect(text, range, select.count), .line_bounds => return t.lineBoundsSelect(text, range), .shrink_to_line_bounds => return t.shrinkSelToLineBounds(text, range), .collapse => return t.setRange(text, 0, .{ .anchor = cur, .head = cur }, false), .flip => { const explicit = (t.vsel.active and t.vsel.explicit) or t.msel.active; return t.setRange(text, 0, .{ .anchor = range.head, .head = range.anchor }, explicit); }, .all => return t.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { if (multi.kind == .keep_primary) { t.nsel = 0; return; } return t.multiSelAction(text, multi.kind, multi.count); }, .select_regex => |split| return startSelRegex(p, pane, split), .edit => |op| switch (op.kind) { .delete => return edit.normalDelete(p, t, true), .delete_noyank => return edit.normalDelete(p, t, false), .change => return edit.normalChange(p, t), .yank => return edit.normalYank(p, t), .replace_with_yank => return edit.normalReplaceYank(p, t), .paste_after => return edit.pasteText(p, t, p.yank orelse return, false, op.count), .paste_before => return edit.pasteText(p, t, p.yank orelse return, true, op.count), .switch_case => return edit.normalCase(p, t, .toggle), .lowercase => return edit.normalCase(p, t, .lower), .uppercase => return edit.normalCase(p, t, .upper), .join_lines => return edit.normalJoin(p, t), .indent => return edit.normalIndent(p, t, op.count, true), .unindent => return edit.normalIndent(p, t, op.count, false), .comment_toggle => return edit.normalToggleComment(p, t), .undo => return edit.doUndo(p, t), .redo => return edit.doRedo(p, t), }, .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, t, delta), .leader => { p.leader_on = true; p.leader_n = 0; }, // `:` is the one key a tag and a body do not share: it moves the // keyboard between them. .command_line => switch (t.what) { .tag => pane.focus = .body, .column, .workspace => tagline.exitHeader(p), .body, .input => tagline.enterTag(p, pane), }, .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), .search => return p.startPrompt(pane, .{ .search = config.search_marker }), .search_step => |direction| return look.lookWalk( p, 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.body.normal.clear(); pane.body.select = false; return exec.runBuiltin(p, .Last, p.active, "", null); } if (pane.body.normal.prefix == .none and isPrefix(key, 'f')) { pane.body.normal.clear(); return exec.runBuiltin(p, .PdfSections, p.active, "", null); } const parsed = modal.Normal.parse(&pane.body.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 => tagline.enterTag(p, pane), .search => p.startPrompt(pane, .{ .search = config.search_marker }), .search_forward => look.lookWalk(p, 1), .search_backward => look.lookWalk(p, -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.body.cur_row = 0; pane.body.cur_col = 3; pane.body.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 0), pane.body.cur_row); try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.body.cur_col); p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, @intCast(width * 2 + 3)), pane.body.cur_col); p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.body.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.body.cur_row); try std.testing.expectEqual(@as(i32, @intCast(width * 3 + 3)), pane.body.cur_col); // Plain j/k are untouched: one FILE line each, whatever the body drew. pane.body.cur_col = 3; pane.body.sticky_col = -1; p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_row); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, 0), pane.body.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.body.cur_row = 0; pane.body.cur_col = @intCast(long.len - 1); pane.body.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_row); pane.body.cur_col = 0; pane.body.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, 0), pane.body.cur_row); try std.testing.expect(pane.body.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.body.cur_row = 0; pane.body.cur_col = 3; pane.body.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 1), pane.body.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.body.cur_row = 4094; handleNormal(p, &pane.body, .{ .cp = 'l' }); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); handleNormal(p, &pane.body, .{ .cp = 'j' }); try std.testing.expectEqual(@as(i32, 4095), pane.body.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); handleNormal(p, &pane.body, .{ .cp = 'h' }); try std.testing.expectEqual(@as(i32, 0), pane.body.cur_col); executeNormalAction(p, &pane.body, .{ .textobject = .{ .char = 'w', .around = false } }); try std.testing.expectEqual(@as(i32, 2), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.body.vsel.col); pane.body.vsel.active = false; pane.body.cur_row = 1024; pane.body.cur_col = 0; pane.body.nsel = 1; pane.body.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; handleNormal(p, &pane.body, .{ .cp = 'l' }); handleNormal(p, &pane.body, .{ .cp = 'j' }); try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); try std.testing.expectEqual(@as(i32, 1025), pane.body.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 3073), pane.body.sels[0].row); try std.testing.expectEqual(@as(i32, 1), pane.body.sels[0].col); try std.testing.expect(!denied.has_induced_failure); try std.testing.expectEqual(@as(usize, 0), p.scratch.queryCapacity()); pane.body.nsel = 0; pane.body.cur_col = 4; executeNormalAction(p, &pane.body, .match_bracket); try std.testing.expect(denied.has_induced_failure); try std.testing.expectEqual(@as(i32, 4), pane.body.cur_col); denied.fail_index = std.math.maxInt(usize); executeNormalAction(p, &pane.body, .match_bracket); try std.testing.expectEqual(@as(i32, 8), pane.body.cur_col); }