diff options
Diffstat (limited to 'src/normal.zig')
| -rw-r--r-- | src/normal.zig | 720 |
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); +} |
