From f1025c18a78c02ebf157f0e3a5d9552605c082e8 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 22:13:34 -0300 Subject: Move helix normal mode out of pardes.zig into normal.zig Pure move, no behaviour change: the key-to-role matching (isPrefix, roleBindingName, normalInput), multiOnce, the selection regex prompt (startSelRegex, selRegexArmed, applySelRegex), KeyState, Replay and replaySels, handleNormal, executeNormalAction and handlePdfNormal, with two tests, go verbatim to normal.zig. The methods become free functions taking `p: *Pardes`; their 22 call sites (handleKey, the mouse, look walk, lsp, runBuiltin, body drawing and edit.zig) change from `p.handleNormal(..)` to `normal.handleNormal(p, ..)`. test/lspbench.zig's second anchor follows its needle into src/normal.zig. Co-Authored-By: Claude Opus 5.5 --- src/pardes.zig | 727 ++------------------------------------------------------- 1 file changed, 13 insertions(+), 714 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index 55e1dc7a..e001aa41 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -11,6 +11,7 @@ pub const syntax = @import("syntax.zig"); pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); +pub const normal = @import("normal.zig"); pub const edit = @import("edit.zig"); pub const body_layer = @import("body_layer.zig"); pub const Messages = @import("Messages.zig"); @@ -433,6 +434,7 @@ pub const Loc = struct { }; test { + _ = @import("normal.zig"); _ = @import("edit.zig"); _ = @import("Messages.zig"); _ = @import("selection_pipe.zig"); @@ -576,72 +578,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le try std.testing.expect(!p.quit); } -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 "the acme chords act once per selection, not once on the primary" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); @@ -2688,57 +2624,6 @@ test "every line of the board's boot buffer renders whole" { // ---- the boundary types ---- -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; - p.handleNormal(pane, .{ .cp = 'l' }); - try std.testing.expectEqual(@as(i32, 1), pane.cur_col); - p.handleNormal(pane, .{ .cp = 'j' }); - try std.testing.expectEqual(@as(i32, 4095), pane.cur_row); - try std.testing.expectEqual(@as(i32, 1), pane.cur_col); - p.handleNormal(pane, .{ .cp = 'h' }); - try std.testing.expectEqual(@as(i32, 0), pane.cur_col); - p.executeNormalAction(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 }; - p.handleNormal(pane, .{ .cp = 'l' }); - p.handleNormal(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; - p.executeNormalAction(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); - p.executeNormalAction(pane, .match_bracket); - try std.testing.expectEqual(@as(i32, 8), pane.cur_col); -} - test "Unicode display cells map back to body and tag byte cursors" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); @@ -3272,47 +3157,6 @@ pub fn hit(key: Key, binding: []const config.Chord) bool { pub const sel_slot = @intFromEnum(config.select_button); -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; -} - /// Pixel dimensions are meaningful only to the native PDF placement path. /// Keep resize events layout-only when that path is not part of the build. pub const CellPixels = if (pdf_enabled) struct { @@ -5694,12 +5538,6 @@ pub const Pardes = struct { } } - fn multiOnce(p: *Pardes) bool { - if (!p.multi_on) return true; - p.multi_stop = true; - return p.multi_first; - } - // ---- tag + selection text (chord sources) ---- pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { @@ -6573,8 +6411,8 @@ pub const Pardes = struct { .search => |at| { if (submit) p.submitSearch(p.active) - else if (selRegexArmed(pane)) |_| - p.applySelRegex(pane, "", false); + else if (normal.selRegexArmed(pane)) |_| + normal.applySelRegex(p, pane, "", false); pane.tag_tail_len = @min(at, pane.tag_tail_len); }, .none, .del_side => unreachable, @@ -6587,7 +6425,7 @@ pub const Pardes = struct { if (pane.tag_edit) { if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); const pn = p.panes[p.active] orelse return; - if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split); + if (normal.selRegexArmed(pn)) |a| normal.applySelRegex(p, pn, a.pat, a.split); return; } if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { @@ -6610,10 +6448,10 @@ pub const Pardes = struct { p.runBuiltin(cmd, p.active, "", word); return; } - if (pane.mode == .normal and pane.hasPdf()) return p.handlePdfNormal(pane, key); + if (pane.mode == .normal and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key); switch (pane.mode) { .normal => { - p.handleNormal(pane, key); + normal.handleNormal(p, pane, key); }, .insert => { if (hit(key, config.escape)) return edit.exitInsert(p, pane); @@ -6651,501 +6489,6 @@ pub const Pardes = struct { // ---- helix-modal normal mode ---- - 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| p.executeNormalAction(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 - } - - 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 p.executeNormalAction(pane, semantic); - p.replaySels(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 p.startSelRegex(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, - ), - } - } - // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ---- // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- @@ -7279,7 +6622,7 @@ pub const Pardes = struct { fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| return normal.applySelRegex(p, pane, a.pat, a.split); const armed = pane.promptText() orelse return; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); @@ -7400,7 +6743,7 @@ pub const Pardes = struct { try panes.Output.fillResults(p, id, dir, from, pat, content, anchor); } - fn searchStep(p: *Pardes, id: usize, delta: i32) bool { + pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool { const pane = p.panes[id] orelse return false; const rid = pane.search_pane orelse return false; const rp = p.panes[rid] orelse return false; @@ -7477,7 +6820,7 @@ pub const Pardes = struct { } pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { - if (!p.multiOnce()) return; // one question per keystroke, from the primary + if (!normal.multiOnce(p)) return; // one question per keystroke, from the primary if (!lsp.supports.contains(kind)) return; const pane = p.panes[id] orelse return; if (kind != .status) { @@ -7803,7 +7146,7 @@ pub const Pardes = struct { return null; } - fn lookWalk(p: *Pardes, delta: i32) void { + pub fn lookWalk(p: *Pardes, delta: i32) void { var buf: [MAX_PANES]usize = undefined; const order = p.lookWalkPanes(&buf); if (order.len == 0) return; @@ -8976,50 +8319,6 @@ pub const Pardes = struct { return pane; } - 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), - } - }, - } - } - // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void { @@ -9358,8 +8657,8 @@ pub const Pardes = struct { while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line); } - fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { - if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor + pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { + if (!normal.multiOnce(p)) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse return; // A builtin says its own name before it runs, so a tagline click is // legible even when what it did is invisible. Whatever the builtin -- cgit v1.3