//! 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 regexp = @import("regexp.zig"); const syntax = @import("syntax.zig"); 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; const Macro = pardes.Macro; 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", .prefix_register => "register_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, t: *Text, mode: modal.Normal.RegexMode) void { const text = edit.flatSurface(p, t) catch return; const got = t.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; p.startPrompt(pane, t, .{ .search = regexMarker(mode) }); } fn regexMarker(mode: modal.Normal.RegexMode) []const u8 { return switch (mode) { .select => config.select_marker, .split => config.split_marker, .keep => config.keep_marker, .remove => config.remove_marker, }; } pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, mode: modal.Normal.RegexMode } { if (pane.prompt != .search or pane.nsel_snap == 0) return null; const armed = pane.input.own orelse ""; const mode: modal.Normal.RegexMode = for (std.enums.values(modal.Normal.RegexMode)) |m| { if (std.mem.startsWith(u8, armed, regexMarker(m))) break m; } else return null; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null; return .{ .pat = armed[slash + 1 ..], .mode = mode }; } /// Select (or split on, keep or remove by) `pat` inside the selections `t` /// had when s/S/K/Alt-K was pressed; the prompt's snapshot of them lives on /// `pane`. pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: modal.Normal.RegexMode) void { const text = edit.flatSurface(p, t) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [Text.max_selections]modal.Selection = undefined; var m: usize = 0; if (regexp.Regex.compile(pat)) |compiled| { var re = compiled; 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; if (mode == .keep or mode == .remove) { // helix keep_or_remove_matches: the range stays when a match // starts inside it (K), or when none does (Alt-K) const hit_at = re.find(hay_all, from, to, to) catch null; const found = if (hit_at) |h| h.start < to else false; if (found == (mode == .keep) and m < Text.max_selections) { out[m] = r; m += 1; } continue; } // Searched as sam searches, the way addr does (regexp.zig): the // text around the selection still says where its lines begin. var at = from; var piece = from; // split: where the next piece begins while (at < to and m < Text.max_selections) { // A search too slow to finish keeps what it found so far. const hit_at = (re.find(hay_all, at, to, to) catch break) orelse break; if (mode == .split) { out[m] = .{ .anchor = piece, .head = hit_at.start }; m += 1; piece = hit_at.end; } else if (hit_at.start != to) { out[m] = .{ .anchor = hit_at.start, .head = 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 (mode == .split and piece < to and m < Text.max_selections) { out[m] = .{ .anchor = piece, .head = to }; m += 1; } } } else |_| {} 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 t.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); } t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } /// helix's tree-sitter selection keys: every range walks the file's parse, /// which is made on the first of these keys after an edit and kept, never /// made again for a key that changed nothing. A range the tree has nothing /// to say about stays as it was; a text without a grammar, all of them. fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { if (t.what != .body) return; const f = if (pane.file) |*file| file else return; if (f.node_tree == null or f.node_tree_revision != f.revision) { if (f.node_tree) |old| syntax.freeTree(old); f.node_tree = syntax.parseTree(f.path, f.content); f.node_tree_revision = f.revision; for (f.node_history.items) |step| p.gpa.free(step.ranges); f.node_history.clearRetainingCapacity(); } const tree = f.node_tree orelse return; const text = f.content; const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; const arena = p.scratch.allocator(); const rs = arena.alloc(modal.Selection, Text.max_selections) catch return; const got = t.ranges(text, 0, rs[0..Text.max_selections]); if (walk == .shrink) if (f.node_history.pop()) |prev| { defer p.gpa.free(prev.ranges); // back to what Alt-o grew from, while the selection still holds it const holds = for (prev.ranges) |pr| { const pf = @min(pr.anchor, pr.head); const pt = @max(pr.anchor, pr.head); if (for (rs[0..got.n]) |r| { if (@min(r.anchor, r.head) <= pf and pt <= @max(r.anchor, r.head)) break true; } else false) continue; break false; } else true; if (holds) return t.setRanges(text, prev.ranges, &.{}, prev.primary, expl); for (f.node_history.items) |step| p.gpa.free(step.ranges); f.node_history.clearRetainingCapacity(); }; const out = arena.alloc(modal.Selection, Text.max_selections) catch return; const spans = arena.alloc(syntax.Span, Text.max_selections) catch return; var m: usize = 0; for (rs[0..got.n]) |r| { if (m == out.len) break; const from = @min(r.anchor, r.head); const to = @max(r.anchor, r.head); const back = r.head < r.anchor; const n = syntax.walkNodes(tree, walk, from, to, modal.selectionCursor(text, r), spans[0 .. out.len - m]); if (n == 0) { out[m] = r; m += 1; continue; } for (spans[0..n]) |s| { out[m] = switch (walk) { .next_sibling => .{ .anchor = s.from, .head = s.to }, .prev_sibling => .{ .anchor = s.to, .head = s.from }, // helix move_parent_node_end: a one-character range on the // bound, or stretched to it in select mode .parent_end, .parent_start => bound: { const head = s.from; if (t.select) { const anchor = r.anchor; break :bound .{ .anchor = anchor, .head = if (head >= anchor) modal.nextGrapheme(text, head) else head }; } const next = modal.nextGrapheme(text, head); break :bound if (back) .{ .anchor = next, .head = head } else .{ .anchor = head, .head = next }; }, else => if (back) .{ .anchor = s.to, .head = s.from } else .{ .anchor = s.from, .head = s.to }, }; m += 1; } } const same = m == got.n and std.mem.eql(u8, std.mem.sliceAsBytes(out[0..m]), std.mem.sliceAsBytes(rs[0..m])); if (walk == .expand and !same) remember: { const ranges = p.gpa.dupe(modal.Selection, rs[0..got.n]) catch break :remember; f.node_history.append(p.gpa, .{ .ranges = ranges, .primary = got.pri }) catch p.gpa.free(ranges); } t.setRanges(text, out[0..m], &.{}, @min(got.pri, m - 1), expl); } const KeyState = struct { mode: Text.Mode, select: bool, normal: modal.Normal.State, find_op: u8, find_ch: u21, restore_cursor: bool, reg_wait: bool, 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, .restore_cursor = t.restore_cursor, .reg_wait = t.reg_wait, }; } 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.restore_cursor = s.restore_cursor; t.reg_wait = s.reg_wait; } }; /// 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 = 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; p.multi_index = i; p.multi_count = n; 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 { // `"` is held by the parser until the command it names arrives const named = t.normal.register; const parsed = modal.Normal.parse(&t.normal, normalInput(key)); const semantic = switch (parsed) { .pending, .ignored, .unbound => return, .action => |value| value, }; t.normal.register = 0; p.register = named; defer p.register = 0; 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 = t.pane() orelse p.panes[p.active] orelse return; 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; // typed again by a macro, Esc is only helix's if (p.macro.replaying > 0) return; 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); }, // s, S and | answer for the text they are pressed in; `/` searches // the body wherever it is pressed, as acme's Look from a tag does. .select_regex => |mode| return startSelRegex(p, pane, t, mode), .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.pasteRegister(p, t, .replace, op.count), .paste_after => return edit.pasteRegister(p, t, .after, op.count), .paste_before => return edit.pasteRegister(p, t, .before, 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, false), .join_select_space => return edit.normalJoin(p, t, true), .rotate_contents_forward => return edit.normalRotateContents(p, t, true, op.count), .rotate_contents_backward => return edit.normalRotateContents(p, t, false, op.count), .align_selections => return edit.normalAlign(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 => if (p.macro.replaying == 0) { 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, t, .{ .pipe = how }), .search => return p.startPrompt(pane, &pane.body, .{ .search = config.search_marker }), .search_step => |direction| return look.lookWalk( p, if (direction == .forward) @as(i32, 1) else -1, ), .node => |walk| return nodeSelect(p, pane, t, walk), .macro_record => { const m = &p.macro; if (m.recording == 0) { m.recording = if (p.register != 0) p.register else '@'; m.recorded.clearRetainingCapacity(); return; } // the keys of this command (a count, `"`, the `Q`) are not // the macro's m.recorded.shrinkRetainingCapacity(m.recorded.items.len -| (m.command.items.len + 1)); const keys = Macro.encode(p.gpa, m.recorded.items) catch return; defer p.gpa.free(keys); p.registers.put(p.gpa, m.recording, keys, 0, 1, true); m.recording = 0; }, .macro_replay => |count| { const name: u21 = if (p.register != 0) p.register else '@'; const notation = p.registers.value(name, 0) orelse return; const keys = Macro.decode(p.scratch.allocator(), notation) catch return; p.register = 0; for (0..count) |_| p.replayKeys(keys); }, .repeat_insert => |count| { const m = &p.macro; if (m.last_insert.items.len == 0 or m.repeating) return; if (m.inserting) return; // the session is still open const keys = p.scratch.allocator().dupe(Macro.Stored, m.last_insert.items) catch return; m.repeating = true; defer m.repeating = false; // helix repeat_last_insert: the command once, what was typed // count times, and out of insert mode the way it was left const typed = @min(m.typed_from, keys.len); p.replayKeys(keys[0..typed]); if (keys.len == typed) return; for (0..count) |_| p.replayKeys(keys[typed .. keys.len - 1]); p.replayKeys(keys[keys.len - 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, &pane.body, .{ .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(u16, 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); } test "s and S search a multi-line selection as addr does: ^ and $ per line, . within one" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); defer p.deinit(); const text = "alpha x\nbeta alpha\nalphabet\n"; const pane = try p.setTestFile(text); const Case = struct { pat: []const u8, mode: modal.Normal.RegexMode, want: []const [2]usize }; for ([_]Case{ // ^ at each line's start, not only the selection's .{ .pat = "^alpha", .mode = .select, .want = &.{ .{ 0, 5 }, .{ 19, 24 } } }, // $ at each line's end .{ .pat = "alpha$", .mode = .select, .want = &.{.{ 13, 18 }} }, // . stops at the newline: one selection per line, not one for all .{ .pat = "a.*", .mode = .select, .want = &.{ .{ 0, 7 }, .{ 11, 18 }, .{ 19, 27 } } }, // S splits on a line's end .{ .pat = "$", .mode = .split, .want = &.{ .{ 0, 7 }, .{ 7, 18 }, .{ 18, 27 }, .{ 27, 28 } } }, }) |c| { pane.sel_snap[0] = .{ .anchor = 0, .head = text.len }; pane.nsel_snap = 1; applySelRegex(p, pane, &pane.body, c.pat, c.mode); var out: [Text.max_selections]modal.Selection = undefined; const got = pane.body.ranges(text, 0, &out); try std.testing.expectEqual(c.want.len, got.n); for (c.want, out[0..got.n]) |w, r| { try std.testing.expectEqual(w[0], @min(r.anchor, r.head)); try std.testing.expectEqual(w[1], @max(r.anchor, r.head)); } } } test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { if (!syntax.supportsPath("a.json")) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); const text = "{\"a\": [1, 2, 3], \"b\": true}\n"; // each checked against the installed hx through a pty: the keys, then // the text it saved const Case = struct { keys: []const u8, want: []const u8 }; for ([_]Case{ .{ .keys = "10l\x1bo" ++ "d", .want = "{\"a\": , \"b\": true}\n" }, .{ .keys = "10l\x1bo\x1bod", .want = "{, \"b\": true}\n" }, .{ .keys = "10l\x1bo\x1bo\x1bod", .want = "\n" }, .{ .keys = "10l\x1bo\x1bIix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" }, .{ .keys = "10l\x1bo\x1bo\x1b>d", .want = "{\"a\": [1, 2, 3] \"b\": true}\n" }, .{ .keys = "10l\x1bpd", .want = "{\"a\": [1 2, 3], \"b\": true}\n" }, .{ .keys = "10l\x1baix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" }, .{ .keys = "10l\x1bo\x1bo\x1bid", .want = "{\"a\": , \"b\": true}\n" }, .{ .keys = "6lx\x1bid", .want = "\n" }, .{ .keys = "10l\x1beiX\x1b", .want = "{\"a\": [1, 2X, 3], \"b\": true}\n" }, .{ .keys = "10l\x1bb\x1bbiX\x1b", .want = "{X\"a\": [1, 2, 3], \"b\": true}\n" }, }) |c| { const pane = try p.setTestFile(text); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/test.json"); // ESC before a key is Alt on it; a lone ESC is Esc; `>` is Alt-right var i: usize = 0; while (i < c.keys.len) : (i += 1) { const ch = c.keys[i]; if (ch == 0x1b and i + 1 < c.keys.len) { i += 1; const k = c.keys[i]; p.update(.{ .key = if (k == '>') .{ .cp = Key.right, .alt = true } else .{ .cp = k, .alt = true } }); } else if (ch == 0x1b) { p.update(.{ .key = .{ .cp = Key.escape } }); } else p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); while (p.nextEffect()) |_| {} } std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| { std.debug.print("keys {s}\n", .{c.keys}); return err; }; } }