diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/body_layer.zig | 3 | ||||
| -rw-r--r-- | src/edit.zig | 7 | ||||
| -rw-r--r-- | src/fs.zig | 1 | ||||
| -rw-r--r-- | src/normal.zig | 720 | ||||
| -rw-r--r-- | src/pardes.zig | 727 |
5 files changed, 740 insertions, 718 deletions
diff --git a/src/body_layer.zig b/src/body_layer.zig index 8c345b97..6bac7018 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -5,6 +5,7 @@ //! and cursor. const std = @import("std"); const pardes = @import("pardes.zig"); +const normal = @import("normal.zig"); const edit = @import("edit.zig"); const config = @import("config.zig"); const limits = @import("memory.zig").limits; @@ -386,7 +387,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id } } } - const preview = Pardes.selRegexArmed(pane) != null; + const preview = normal.selRegexArmed(pane) != null; const show_prim = (pane.mode == .normal and pane.vsel.active) or preview or (modal_hover and pane.vsel.active); const show_extra = pane.mode != .tty and pane.nsel > 0; diff --git a/src/edit.zig b/src/edit.zig index e44a3364..f829decc 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -3,6 +3,7 @@ //! operations, replace, case, join, indent, comments, numbers, textobjects //! and surround, undo, yank and paste, and the pointer selections as text. const pardes = @import("pardes.zig"); +const normal = @import("normal.zig"); const std = @import("std"); const modal = @import("modal.zig"); const panes = @import("panes.zig"); @@ -86,7 +87,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { const range: @TypeOf(Pardes.tagSelBounds(pane)) = if (pane.tag_sel) Pardes.tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col }; const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi; _ = p.replaceTagText(pane, range.lo, hi, bytes); - if (Pardes.selRegexArmed(pane)) |a| p.applySelRegex(pane, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split); return; } if (pane.isTerminal() and pane.mode == .tty) return typeToTty(p, id, pane, bytes); @@ -103,7 +104,7 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { return; }; if (pane.nsel > 0 and !main_only) { - p.replaySels(pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } }); + normal.replaySels(p, pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } }); } else { const others = pane.nsel; pane.nsel = 0; @@ -471,7 +472,7 @@ pub fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { pub fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void { if (pane.nsel == 0) return insertKey(p, pane, key); - p.replaySels(pane, .{ .insert = key }); + normal.replaySels(p, pane, .{ .insert = key }); } pub fn insertKey(p: *Pardes, pane: *Pane, key: Key) void { @@ -41,6 +41,7 @@ const source_files = [_]Source{ .{ .path = "src/Messages.zig", .contents = @embedFile("Messages.zig") }, .{ .path = "src/body_layer.zig", .contents = @embedFile("body_layer.zig") }, .{ .path = "src/edit.zig", .contents = @embedFile("edit.zig") }, + .{ .path = "src/normal.zig", .contents = @embedFile("normal.zig") }, .{ .path = "src/File.zig", .contents = @embedFile("File.zig") }, .{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") }, .{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") }, 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); +} 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 |
