summaryrefslogtreecommitdiff
path: root/src/edit.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/edit.zig')
-rw-r--r--src/edit.zig1692
1 files changed, 1692 insertions, 0 deletions
diff --git a/src/edit.zig b/src/edit.zig
new file mode 100644
index 00000000..e44a3364
--- /dev/null
+++ b/src/edit.zig
@@ -0,0 +1,1692 @@
+//! Editing a pane's text (acme's text.c): which text a pane edits and how
+//! its lines and cursor map to the screen, insert mode, the d/c/y/p edit
+//! 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 std = @import("std");
+const modal = @import("modal.zig");
+const panes = @import("panes.zig");
+const body_layer = @import("body_layer.zig");
+const config = @import("config.zig");
+const pdf_enabled = panes.Pdf.enabled;
+const lsp = @import("lsp/lsp.zig");
+const platform = pardes.platform;
+const Pane = panes.Pane;
+const TagHit = @import("tag_layer.zig").Hit;
+const BOX_H = pardes.BOX_H;
+const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
+const Mouse = pardes.Mouse;
+const Key = pardes.Key;
+const hit = pardes.hit;
+const sel_slot = pardes.sel_slot;
+const Effect = pardes.Effect;
+const Pardes = pardes.Pardes;
+
+pub fn setYank(p: *Pardes, text: []const u8) void {
+ if (p.multi_on and !p.multi_first) {
+ const old = p.yank orelse "";
+ const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n";
+ const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return;
+ if (p.yank) |y| p.gpa.free(y);
+ p.yank = joined;
+ return;
+ }
+ if (p.yank) |y| p.gpa.free(y);
+ p.yank = p.gpa.dupe(u8, text) catch null;
+}
+
+pub fn setClipboard(p: *Pardes, text: []const u8) void {
+ setYank(p, text);
+ if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} });
+}
+
+/// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers.
+pub const ClipRequest = struct {
+ pane: usize,
+ serial: u32,
+ mode: enum { after, before, replace },
+};
+
+pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode")) void {
+ const pane = p.panes[id] orelse return;
+ p.clip_pending = .{ .pane = id, .serial = pane.serial, .mode = mode };
+ p.emit(.read_clipboard);
+}
+
+pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void {
+ if (text.len == 0) return;
+ if (panes.Terminal.bracketedPaste(pane)) {
+ p.emitWrite(id, "\x1b[200~");
+ p.emitWrite(id, text);
+ p.emitWrite(id, "\x1b[201~");
+ return;
+ }
+ const cp = p.scratch.allocator().dupe(u8, text) catch return;
+ for (cp) |*ch| {
+ if (ch.* == '\n') ch.* = '\r';
+ }
+ p.emitWrite(id, cp);
+}
+
+pub fn applyPaste(p: *Pardes, bytes: []const u8) void {
+ if (p.topbar_col != null) {
+ p.clip_pending = null;
+ p.headerReplace(bytes);
+ return;
+ }
+ const req = p.clip_pending;
+ p.clip_pending = null;
+ if (bytes.len == 0) return;
+ const id = if (req) |r| r.pane else p.active;
+ const pane = p.panes[id] orelse return;
+ if (req) |r| if (pane.serial != r.serial) return;
+ p.active = id;
+ if (pane.tag_edit) {
+ const text = p.tagText(p.scratch.allocator(), pane) catch return;
+ 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);
+ return;
+ }
+ if (pane.isTerminal() and pane.mode == .tty) return typeToTty(p, id, pane, bytes);
+ switch (if (req) |r| r.mode else .after) {
+ .after => pasteText(p, pane, bytes, false, 1),
+ .before => pasteText(p, pane, bytes, true, 1),
+ .replace => replaceWithText(p, pane, bytes),
+ }
+}
+
+pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
+ if (comptime pdf_enabled) if (pane.pdf) |pv| {
+ if (pv.selection_text.len > 0) setClipboard(p, pv.selection_text);
+ return;
+ };
+ if (pane.nsel > 0 and !main_only) {
+ p.replaySels(pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } });
+ } else {
+ const others = pane.nsel;
+ pane.nsel = 0;
+ normalYank(p, pane);
+ pane.nsel = others;
+ }
+ p.emit(.{ .set_clipboard = {} });
+}
+
+/// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
+pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
+ const arena = p.scratch.allocator();
+ return std.fmt.allocPrint(arena, "{s}\n{s}", .{ try p.tagText(arena, pane), try body_layer.bodyText(p, arena, pane) });
+}
+
+pub fn pointerSourceLine(pane: *Pane, terminal_lines: []const []const u8, row: i32) []const u8 {
+ if (row < 0) return "";
+ if (pane.file != null) return panes.File.sourceLine(pane, row);
+ const index: usize = @intCast(row);
+ return if (index < terminal_lines.len) terminal_lines[index] else "";
+}
+
+pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
+ var gesture = pane.sel[slot];
+ if (pane.hasPdf()) return;
+ if (gesture.state != .done or @min(gesture.r0, gesture.r1) < BOX_H) return;
+ if (pane.pointerSelection(gesture) != null) return;
+ pane.clearPointerSelection(slot);
+ // Refresh the source map before translating the rendered rectangle.
+ const raw = pane.isTerminal() and pane.mode == .tty;
+ const body = try body_layer.bodyText(p, p.scratch.allocator(), pane);
+ const terminal_lines = if (pane.file == null and !raw) try panes.Terminal.cursorLines(p, pane) else &.{};
+ const first = @max(0, @min(gesture.r0, gesture.r1) - BOX_H);
+ const logical_rows = if (pane.body_rows > 0) pane.body_rows else pane.rows;
+ const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - BOX_H);
+ if (first > last) return;
+ const rows = try p.gpa.alloc(Pane.PointerRow, @intCast(last - first + 1));
+ errdefer p.gpa.free(rows);
+ const raw_text = if (raw) try p.gpa.dupe(u8, try selectionText(p, pane, gesture)) else null;
+ const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
+ const left = @max(0, @min(gesture.c0, gesture.c1));
+ const right = @max(0, @max(gesture.c0, gesture.c1));
+ for (rows, 0..) |*row, index| {
+ const visible = first + @as(i32, @intCast(index));
+ var source = pane.wrapAt(visible);
+ if (raw) source = .{ .line = panes.Terminal.gridOffset(pane) + visible, .at = 0 };
+ const line = if (raw) modal.lineSlice(body, @intCast(visible)) else pointerSourceLine(pane, terminal_lines, source.line);
+ var next = pane.wrapAt(visible + 1);
+ if (raw) next = .{ .line = source.line + 1, .at = 0 };
+ const end = if (next.line == source.line) @min(line.len, @as(usize, @intCast(@max(0, next.at)))) else line.len;
+ const start: usize = @intCast(@max(0, source.at));
+ const lo = @min(end, panes.File.byteAtDisplayFrom(line, start, @intCast(@max(0, left - prefix))));
+ const at = @min(end, panes.File.byteAtDisplayFrom(line, start, @intCast(@max(0, right - prefix))));
+ row.* = .{
+ .row = source.line,
+ .lo = lo,
+ .hi = if (right < prefix) lo else @min(end, modal.nextGrapheme(line, at)),
+ .raw_terminal = raw,
+ .prompt_bytes = if (raw) panes.Terminal.promptPrefixBytes(pane, source.line, line) else 0,
+ };
+ const rendered = modal.lineSlice(body, @intCast(visible));
+ const prefix_end = @min(rendered.len, @as(usize, @intCast(@min(prefix, right + 1))));
+ const prefix_start = @min(prefix_end, @as(usize, @intCast(left)));
+ row.prefix_len = @intCast(prefix_end - prefix_start);
+ @memcpy(row.prefix[0..row.prefix_len], rendered[prefix_start..prefix_end]);
+ }
+ pane.next_pointer_selection += 1;
+ gesture.source_id = pane.next_pointer_selection;
+ pane.sel[slot] = gesture;
+ pane.pointer_selections[slot] = .{ .gesture = gesture, .rows = rows, .raw_text = raw_text };
+}
+
+/// block-selected text, newline-joined per row; reads the rendered screen
+/// so typed text and shell output select identically. Scratch-owned.
+pub fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 {
+ const arena = p.scratch.allocator();
+ if (pane.rawPointerText(sl)) |text| return arena.dupe(u8, text);
+ if (pane.pointerSelection(sl)) |rows| {
+ const terminal_lines = if (pane.file == null) try panes.Terminal.cursorLines(p, pane) else &.{};
+ var total: usize = rows.len -| 1;
+ for (rows) |row| {
+ const line = pointerSourceLine(pane, terminal_lines, row.row);
+ total += row.prefix_len + @min(row.hi, line.len) - @min(row.lo, line.len);
+ }
+ const result = try arena.alloc(u8, total);
+ var at: usize = 0;
+ for (rows, 0..) |row, index| {
+ if (index > 0) {
+ result[at] = '\n';
+ at += 1;
+ }
+ @memcpy(result[at..][0..row.prefix_len], row.prefix[0..row.prefix_len]);
+ at += row.prefix_len;
+ const line = pointerSourceLine(pane, terminal_lines, row.row);
+ const bytes = line[@min(row.lo, line.len)..@min(row.hi, line.len)];
+ @memcpy(result[at..][0..bytes.len], bytes);
+ at += bytes.len;
+ }
+ return result;
+ }
+ const r0 = @min(sl.r0, sl.r1);
+ const r1 = @max(sl.r0, sl.r1);
+ const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
+ const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
+ const text = try paneText(p, pane);
+ var total: usize = 0;
+ var selected: usize = 0;
+ var count_it = std.mem.splitAny(u8, text, "\n");
+ var count_row: i32 = 0;
+ while (count_it.next()) |line| : (count_row += 1) {
+ if (count_row < r0 or count_row > r1) continue;
+ const b0 = @min(panes.File.renderedLineByteCol(pane, count_row, line, c0), line.len);
+ const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, count_row, line, c1), line.len));
+ total += b1 - b0 + @intFromBool(selected > 0);
+ selected += 1;
+ }
+ const out = try arena.alloc(u8, total);
+ var at: usize = 0;
+ var it = std.mem.splitAny(u8, text, "\n");
+ var v: i32 = 0;
+ var first = true;
+ while (it.next()) |line| : (v += 1) {
+ if (v < r0 or v > r1) continue;
+ if (!first) {
+ out[at] = '\n';
+ at += 1;
+ }
+ first = false;
+ const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, c0), line.len);
+ const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, c1), line.len));
+ @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]);
+ at += b1 - b0;
+ }
+ return out;
+}
+
+pub fn spanHas(r: i32, c: i32, ar: i32, ac: i32, br: i32, bc: i32) bool {
+ const fwd = ar < br or (ar == br and ac <= bc);
+ const sr, const sc = if (fwd) .{ ar, ac } else .{ br, bc };
+ const er, const ec = if (fwd) .{ br, bc } else .{ ar, ac };
+ return (r > sr or (r == sr and c >= sc)) and (r < er or (r == er and c <= ec));
+}
+
+/// the active modal selection as text (v range or x lines), if any
+pub fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 {
+ if (comptime pdf_enabled) if (p.native_images) if (pane.pdf) |pv|
+ if (pv.selection != null and pv.selection_text.len > 0) return pv.selection_text;
+ if (pane.vsel.active) return vselText(p, pane);
+ if (!pane.msel.active) return null;
+ return yankRows(p, pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1));
+}
+
+pub fn exitInsert(p: *Pardes, pane: *Pane) void {
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.normal.clear();
+ if (pane.append_at) |aa| {
+ pane.append_at = null;
+ const text = flatSurface(p, pane) catch return;
+ const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) });
+ const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) });
+ const back = @max(a_off, modal.prevGrapheme(text, gap));
+ const bc = modal.positionAt(text, back);
+ pane.cur_row = @intCast(bc.row);
+ pane.cur_col = @intCast(bc.col);
+ pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false };
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ for (pane.sels[0..pane.nsel]) |*s| {
+ const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) });
+ const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap);
+ const bp = modal.positionAt(text, b2);
+ s.row = @intCast(bp.row);
+ s.col = @intCast(bp.col);
+ s.arow = s.row;
+ s.acol = s.col;
+ }
+ }
+}
+
+pub fn paneCursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 {
+ const arena = p.scratch.allocator();
+ if (pane.file) |*f| return panes.File.cursorLines(arena, pane, f);
+ if (pane.hasPdf()) {
+ if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena);
+ unreachable;
+ }
+ return panes.Terminal.cursorLines(p, pane);
+}
+
+pub fn paneByteAtDisplay(p: *Pardes, pane: *Pane, row: i32, from_raw: i32, display_col: i32) i32 {
+ if (pane.file != null)
+ return panes.File.byteAtRowDisplay(pane, row, from_raw, display_col);
+ const lines = paneCursorLines(p, pane) catch return @max(0, from_raw + display_col);
+ const local = row;
+ if (local < 0 or @as(usize, @intCast(local)) >= lines.len)
+ return @max(0, from_raw + display_col);
+ return @intCast(panes.File.byteAtDisplayFrom(
+ lines[@intCast(local)],
+ @intCast(@max(0, from_raw)),
+ @intCast(@max(0, display_col)),
+ ));
+}
+
+/// Freeze the live terminal cursor into the modal coordinate space. The
+/// emulator reports screen cells; editing state stores UTF-8 byte offsets.
+pub fn pinPaneCursor(p: *Pardes, pane: *Pane) void {
+ if (pane.cur_pinned) return;
+ pane.pinCursor();
+ if (pane.file == null and !pane.hasPdf())
+ pane.cur_col = paneByteAtDisplay(p, pane, pane.cur_row, 0, pane.cur_col);
+}
+
+pub fn flatSurface(p: *Pardes, pane: *Pane) ![]const u8 {
+ if (pane.file) |f| return f.content;
+ if (pane.hasPdf()) {
+ if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa);
+ unreachable;
+ }
+ const lines = try panes.Terminal.cursorLines(p, pane);
+ return panes.Terminal.flatSurface(p, pane, lines);
+}
+
+pub fn paneWrapWidth(p: *const Pardes, pane: *const Pane) usize {
+ if (pane.file == null) return 0;
+ return panes.File.wrapWidth(pane, p.settings.wrap);
+}
+
+/// Route shared edit operations to a file's content or a terminal overlay.
+fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?panes.EditText {
+ if (pane.file) |f| return .{ .text = f.content, .row0 = 0 };
+ if (pane.image != null or pane.hasPdf()) return null;
+ return panes.Terminal.editText(p, pane, lo, hi, col);
+}
+
+fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?panes.EditText {
+ const eb = editText(p, pane, b.lo_row, b.hi_row, -1) orelse return null;
+ const r: usize = @intCast(@max(0, b.hi_row - eb.row0));
+ if (r + 1 < modal.lineCount(eb.text)) return eb;
+ if (b.hi_col < @as(i32, @intCast(modal.lineSlice(eb.text, r).len))) return eb;
+ return editText(p, pane, b.lo_row, b.hi_row + 1, -1);
+}
+
+/// install a rewritten editable text (frees the old one)
+fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void {
+ if (pane.file) |*f| return panes.File.setContent(p, f, new);
+ panes.Terminal.setEditText(p, pane, new);
+}
+
+pub const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
+
+/// Files insert only inside existing rows and at grapheme boundaries.
+/// Terminal overlays can materialize virtual rows/columns, so keep those.
+pub fn clampFileCursor(pane: *Pane) bool {
+ const file = if (pane.file) |*file| file else return false;
+ const old_row = pane.cur_row;
+ const old_col = pane.cur_col;
+ const last = panes.File.nlines(pane.gpa, file) -| 1;
+ pane.cur_row = @intCast(@min(last, @as(usize, @intCast(@max(0, pane.cur_row)))));
+ const line = panes.File.sourceLine(pane, pane.cur_row);
+ pane.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, pane.cur_col))))));
+ return old_row != pane.cur_row or old_col != pane.cur_col;
+}
+
+pub fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void {
+ if (pane.hasPdf()) return;
+ pinPaneCursor(p, pane);
+ _ = clampFileCursor(pane);
+ // snapshot once per insert session (WITH the pre-insert selection) so
+ // `u` undoes the whole session and restores what was selected
+ pushUndo(p, pane);
+ pane.select = false;
+ pane.append_at = null;
+ pane.sticky_col = -1;
+ const text = flatSurface(p, pane) catch {
+ pane.mode = .insert;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.normal.clear();
+ return;
+ };
+ const cur = pane.toModalCursor();
+ const line = panes.File.textLine(pane, text, cur.row);
+ const b: ?Bounds = if (pane.vsel.active) vselBounds(pane) else null;
+ switch (where) {
+ .at => {
+ if (b) |bb| {
+ pane.cur_row = bb.lo_row;
+ pane.cur_col = bb.lo_col;
+ pane.vsel = .{ .active = true, .row = bb.hi_row, .col = bb.hi_col, .explicit = false };
+ pane.cur_pinned = true;
+ }
+ },
+ .append => {
+ const lo_row = if (b) |bb| bb.lo_row else pane.cur_row;
+ const lo_col = if (b) |bb| bb.lo_col else pane.cur_col;
+ const hi_row = if (b) |bb| bb.hi_row else pane.cur_row;
+ const hi_col = if (b) |bb| bb.hi_col else pane.cur_col;
+ pane.append_at = .{ .row = lo_row, .col = lo_col };
+ const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) }));
+ const gc = modal.positionAt(text, gap);
+ pane.cur_row = @intCast(gc.row);
+ pane.cur_col = @intCast(gc.col);
+ pane.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false };
+ pane.cur_pinned = true;
+ },
+ .line_start => {
+ pane.fromModalCursor(.{ .row = cur.row, .col = modal.firstNonWs(line) });
+ pane.vsel.active = false;
+ },
+ .line_end => {
+ pane.cur_col = @intCast(line.len);
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ },
+ .open_below, .open_above => {
+ pane.vsel.active = false;
+ const below = where == .open_below;
+ const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else pane.cur_row;
+ const eb = editText(p, pane, abs, abs, -1) orelse return;
+ const row: usize = @intCast(@max(0, abs - eb.row0));
+ const ind = modal.indentText(modal.lineSlice(eb.text, row));
+ const arena = p.scratch.allocator();
+ const block_len = std.math.mul(usize, cnt, ind.len + 1) catch return;
+ const block = arena.alloc(u8, block_len) catch return;
+ var block_at: usize = 0;
+ for (0..cnt) |_| {
+ if (below) {
+ block[block_at] = '\n';
+ block_at += 1;
+ }
+ @memcpy(block[block_at..][0..ind.len], ind);
+ block_at += ind.len;
+ if (!below) {
+ block[block_at] = '\n';
+ block_at += 1;
+ }
+ }
+ const at: modal.Cursor = if (below)
+ .{ .row = row, .col = modal.lineSlice(eb.text, row).len }
+ else
+ .{ .row = row, .col = 0 };
+ const new = modal.insertAt(p.gpa, eb.text, at, block) catch return;
+ setEditText(p, pane, new);
+ pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0;
+ pane.cur_col = @intCast(ind.len);
+ pane.cur_pinned = true;
+ if (cnt > 1 and !p.multi_on) opened: {
+ const t2 = flatSurface(p, pane) catch break :opened;
+ var rs: [Pane.max_selections]modal.Selection = undefined;
+ const m = @min(cnt, Pane.max_selections);
+ for (0..m) |k| {
+ const o = modal.offsetAt(t2, .{ .row = @intCast(@max(0, pane.cur_row) + @as(i32, @intCast(k))), .col = ind.len });
+ rs[k] = .{ .anchor = o, .head = o };
+ }
+ pane.setRanges(t2, rs[0..m], &.{}, 0, false);
+ }
+ },
+ }
+ pane.mode = .insert;
+ pane.msel.active = false;
+ pane.normal.clear();
+ pane.ensureCursorVisible();
+}
+
+pub fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void {
+ if (pane.nsel == 0) return insertKey(p, pane, key);
+ p.replaySels(pane, .{ .insert = key });
+}
+
+pub fn insertKey(p: *Pardes, pane: *Pane, key: Key) void {
+ // helix aliases: normalize to the base key and re-dispatch
+ if (hit(key, config.insert_backspace_alias)) return insertKey(p, pane, .{ .cp = Key.backspace });
+ if (hit(key, config.insert_enter_alias)) return insertKey(p, pane, .{ .cp = Key.enter });
+ if (hit(key, config.insert_delete_alias)) return insertKey(p, pane, .{ .cp = Key.delete });
+ pane.vsel.active = false;
+ if (!pane.cur_pinned) pinPaneCursor(p, pane);
+ _ = clampFileCursor(pane);
+ switch (key.cp) {
+ Key.left, Key.right, Key.up, Key.down => {
+ const lines = paneCursorLines(p, pane) catch return;
+ const cur0 = pane.toModalCursor();
+ const nc = switch (key.cp) {
+ Key.left => modal.charLeft(lines, cur0),
+ Key.right => modal.charRight(lines, cur0),
+ Key.up => Pane.insertVerticalCursor(lines, cur0, false),
+ Key.down => Pane.insertVerticalCursor(lines, cur0, true),
+ else => cur0,
+ };
+ pane.fromModalCursor(nc);
+ pane.ensureCursorVisible();
+ return;
+ },
+ Key.page_up, Key.page_down => {
+ // helix binds insert pageup/pagedown to the same view
+ // scroll + cursor snap as normal mode
+ const flat = flatSurface(p, pane) catch return;
+ const range = pane.primaryRange(flat, 0);
+ const step: i32 = @intCast(@max(1, pane.rows));
+ pane.scrollViewMove(flat, range, if (key.cp == Key.page_down) step else -step);
+ return;
+ },
+ Key.home => {
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ },
+ Key.end => {
+ // helix insert End: past the last char (goto_line_end_newline)
+ const text = flatSurface(p, pane) catch return;
+ const cur0 = pane.toModalCursor();
+ pane.cur_col = @intCast(panes.File.textLine(pane, text, cur0.row).len);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ },
+ else => {},
+ }
+ const eb = editText(p, pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return;
+ const text = eb.text;
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) };
+ if (key.text.len > 0 and !key.ctrl and !key.alt) {
+ const new = modal.insertAt(p.gpa, text, c, key.text) catch return;
+ setEditText(p, pane, new);
+ for (key.text) |ch| {
+ if (ch == '\n') {
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ } else pane.cur_col += 1;
+ }
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ }
+ // helix insert-mode kills (word/line; deleteSpan is exclusive)
+ if (hit(key, config.delete_word_backward)) {
+ const e2 = if (c.row == 0 and c.col == 0)
+ (editText(p, pane, pane.cur_row - 1, pane.cur_row, 0) orelse return)
+ else
+ eb;
+ const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col };
+ const g = modal.offsetAt(e2.text, c2);
+ if (g == 0) return;
+ const wr = modal.moveWord(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start);
+ const from = @min(wr.anchor, wr.head);
+ const fc = modal.positionAt(e2.text, from);
+ const new = modal.deleteSpan(p.gpa, e2.text, fc, c2) catch return;
+ setEditText(p, pane, new);
+ pane.cur_row = @as(i32, @intCast(fc.row)) + e2.row0;
+ pane.cur_col = @intCast(fc.col);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (hit(key, config.delete_word_forward)) {
+ const e2 = if (c.col >= modal.lineSlice(text, c.row).len and c.row + 1 >= modal.lineCount(text))
+ (editText(p, pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return)
+ else
+ eb;
+ const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col };
+ const g = modal.offsetAt(e2.text, c2);
+ const wr = modal.moveWord(e2.text, .{ .anchor = g, .head = g }, 1, .next_word_end);
+ const to = @max(wr.anchor, wr.head);
+ if (to <= g) return;
+ const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.positionAt(e2.text, to)) catch return;
+ setEditText(p, pane, new);
+ pane.cur_pinned = true;
+ return;
+ }
+ if (hit(key, config.kill_to_line_start)) {
+ const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = 0 }, c) catch return;
+ setEditText(p, pane, new);
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (hit(key, config.kill_to_line_end)) {
+ const llen = modal.lineSlice(text, c.row).len;
+ const new = modal.deleteSpan(p.gpa, text, c, .{ .row = c.row, .col = llen }) catch return;
+ setEditText(p, pane, new);
+ pane.cur_pinned = true;
+ return;
+ }
+ switch (key.cp) {
+ Key.enter => {
+ const line = modal.lineSlice(text, c.row);
+ const indent = modal.newlineIndentWidth(line, c.col);
+ const arena = p.scratch.allocator();
+ const block = arena.alloc(u8, 1 + indent) catch return;
+ block[0] = '\n';
+ @memset(block[1..], ' ');
+ const new = modal.insertAt(p.gpa, text, c, block) catch return;
+ setEditText(p, pane, new);
+ pane.cur_row += 1;
+ pane.cur_col = @intCast(indent);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.backspace => {
+ if (pane.cur_col > 0) {
+ const line = modal.lineSlice(text, c.row);
+ const prev = modal.prevGrapheme(line, c.col);
+ const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = prev }) catch return;
+ setEditText(p, pane, new);
+ pane.cur_col = @intCast(prev);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ }
+ // at col 0: eat the newline above (join up) — a terminal grows
+ // its buffer over the row it is joining with
+ if (pane.cur_row <= 0) return;
+ const e2 = editText(p, pane, pane.cur_row - 1, pane.cur_row, 0) orelse return;
+ const row: usize = @intCast(@max(0, pane.cur_row - e2.row0));
+ if (row == 0) return;
+ const prevlen = modal.lineSlice(e2.text, row - 1).len;
+ const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row - 1, .col = prevlen }, .{ .row = row, .col = 0 }) catch return;
+ setEditText(p, pane, new);
+ pane.cur_row -= 1;
+ pane.cur_col = @intCast(prevlen);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.delete => {
+ // delete forward; at line end eat the newline (join), which on
+ // a terminal means the buffer swallowing the row below
+ const line = modal.lineSlice(text, c.row);
+ if (c.col < line.len) {
+ const new = modal.deleteChar(p.gpa, text, c) catch return;
+ setEditText(p, pane, new);
+ } else {
+ const e2 = editText(p, pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return;
+ const row: usize = @intCast(@max(0, pane.cur_row - e2.row0));
+ if (row + 1 >= modal.lineCount(e2.text)) return;
+ const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row, .col = c.col }, .{ .row = row + 1, .col = 0 }) catch return;
+ setEditText(p, pane, new);
+ }
+ pane.cur_pinned = true;
+ },
+ Key.tab => {
+ const ln = modal.lineSlice(text, c.row);
+ if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: {
+ const f = pane.file orelse break :dot;
+ if (f.output != null or !lsp.speaks(f.path)) break :dot;
+ const seq = p.lsp_seq;
+ p.lspRequest(p.active, .completion, "");
+ if (p.lsp_seq != seq) return;
+ }
+ insertTab(p, pane);
+ },
+ else => {},
+ }
+}
+
+pub fn insertTab(p: *Pardes, pane: *Pane) void {
+ const eb = editText(p, pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return;
+ const c: modal.Cursor = .{
+ .row = @intCast(@max(0, pane.cur_row - eb.row0)),
+ .col = @intCast(@max(0, pane.cur_col)),
+ };
+ const pad = modal.INDENT_W - (c.col % modal.INDENT_W);
+ const new = modal.insertAt(p.gpa, eb.text, c, " "[0..pad]) catch return;
+ setEditText(p, pane, new);
+ pane.cur_col += @intCast(pad);
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+}
+
+const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
+
+/// a range's two cells, normalized to document order
+pub fn cellBounds(s: Pane.SelRange) Bounds {
+ if (s.row < s.arow or (s.row == s.arow and s.col < s.acol))
+ return .{ .lo_row = s.row, .lo_col = s.col, .hi_row = s.arow, .hi_col = s.acol };
+ return .{ .lo_row = s.arow, .lo_col = s.acol, .hi_row = s.row, .hi_col = s.col };
+}
+
+/// the char selection [anchor, cursor] normalized to document order
+fn vselBounds(pane: *Pane) Bounds {
+ return cellBounds(.{ .row = pane.cur_row, .col = pane.cur_col, .arow = pane.vsel.row, .acol = pane.vsel.col });
+}
+
+fn vselText(p: *Pardes, pane: *Pane) []const u8 {
+ const arena = p.scratch.allocator();
+ const b = vselBounds(pane);
+ const text = flatSurface(p, pane) catch return "";
+ return modal.rangeText(arena, text, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch "";
+}
+
+/// join surface rows [r0, r1] (absolute) with '\n'; scratch-owned
+fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 {
+ const arena = p.scratch.allocator();
+ const lines = paneCursorLines(p, pane) catch return "";
+ const rows_count: usize = @intCast(@max(0, r1 - r0 + 1));
+ var total: usize = rows_count -| 1;
+ var i = r0;
+ while (i <= r1) : (i += 1) {
+ if (i >= 0 and @as(usize, @intCast(i)) < lines.len)
+ total += lines[@intCast(i)].len;
+ }
+ const out = arena.alloc(u8, total) catch return "";
+ var at: usize = 0;
+ i = r0;
+ while (i <= r1) : (i += 1) {
+ if (i > r0) {
+ out[at] = '\n';
+ at += 1;
+ }
+ if (i >= 0 and @as(usize, @intCast(i)) < lines.len) {
+ const line = lines[@intCast(i)];
+ @memcpy(out[at..][0..line.len], line);
+ at += line.len;
+ }
+ }
+ return out;
+}
+
+pub fn normalDelete(p: *Pardes, pane: *Pane, yank: bool) void {
+ pane.select = false; // helix delete exits select mode
+ pane.sticky_col = -1;
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ if (yank) setYank(p, vselText(p, pane)); // read the range before the move
+ pushUndo(p, pane);
+ const eb = editTextEol(p, pane, b) orelse {
+ pane.vsel.active = false;
+ return;
+ };
+ const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch {
+ pane.vsel.active = false;
+ return;
+ };
+ setEditText(p, pane, d.content);
+ p.gpa.free(d.deleted);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.clampCursor(d.content, eb.row0);
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ pushUndo(p, pane);
+ // linewise: a line's newline lives on the row below it
+ const eb = editText(p, pane, r0, r1 + 1, -1) orelse {
+ pane.msel.active = false;
+ return;
+ };
+ const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, r0 - eb.row0)), @intCast(@max(0, r1 - eb.row0))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ setEditText(p, pane, d.content);
+ if (yank) setYank(p, d.deleted);
+ p.gpa.free(d.deleted);
+ const n = modal.lineCount(d.content);
+ pane.cur_row = @as(i32, @intCast(@min(@as(usize, @intCast(@max(0, r0 - eb.row0))), if (n == 0) 0 else n - 1))) + eb.row0;
+ pane.cur_col = 0;
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ } else {
+ pushUndo(p, pane);
+ const eb = editTextEol(p, pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return;
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) };
+ if (yank) {
+ const arena = p.scratch.allocator();
+ setYank(p, modal.rangeText(arena, eb.text, c, c) catch return);
+ }
+ // deleteRange (not deleteChar): the cursor may sit ON a '\n'
+ // cell, where a delete joins the lines (helix 1-wide selection)
+ const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return;
+ setEditText(p, pane, d.content);
+ p.gpa.free(d.deleted);
+ const llen = modal.lineSlice(d.content, c.row).len;
+ pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen)));
+ }
+}
+
+pub fn normalYank(p: *Pardes, pane: *Pane) void {
+ pane.select = false;
+ if (pane.rawPointerText(pane.sel[sel_slot])) |text| {
+ setYank(p, text);
+ return;
+ }
+ if (pane.vsel.active) {
+ setYank(p, vselText(p, pane));
+ return;
+ }
+ if (pane.msel.active) {
+ setYank(p, yankRows(p, pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)));
+ } else {
+ const save = pane.vsel;
+ pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = false };
+ setYank(p, vselText(p, pane));
+ pane.vsel = save;
+ }
+}
+
+pub fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool, count: usize) void {
+ if (y0.len == 0) return;
+ pushUndo(p, pane);
+ pane.select = false;
+ pane.sticky_col = -1;
+ const arena = p.scratch.allocator();
+ var y: []const u8 = y0;
+ if (count > 1) {
+ const total = std.math.mul(usize, count, y0.len) catch return;
+ const buf = arena.alloc(u8, total) catch return;
+ for (0..count) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0);
+ y = buf;
+ }
+ const b: Bounds = if (pane.vsel.active)
+ vselBounds(pane)
+ else
+ .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col };
+ const eb = editText(p, pane, b.lo_row, b.hi_row, -1) orelse return;
+ const row0 = eb.row0;
+ if (y[y.len - 1] == '\n') {
+ const block_text = y[0 .. y.len - 1];
+ const n = @max(1, modal.lineCount(block_text));
+ var out: []u8 = undefined;
+ if (before) {
+ const row: usize = @intCast(@max(0, b.lo_row - row0));
+ const block = std.fmt.allocPrint(arena, "{s}\n", .{block_text}) catch return;
+ out = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = 0 }, block) catch return;
+ pane.cur_row = @as(i32, @intCast(row + n - 1)) + row0;
+ } else {
+ const row: usize = @intCast(@max(0, b.hi_row - row0));
+ out = modal.pasteLineBelow(p.gpa, eb.text, row, block_text) catch return;
+ pane.cur_row = @as(i32, @intCast(row + n)) + row0;
+ }
+ setEditText(p, pane, out);
+ pane.vsel = .{ .active = true, .row = pane.cur_row - @as(i32, @intCast(n - 1)), .col = 0, .explicit = false };
+ // cursor ON the last pasted line's '\n' (helix)
+ const llen = modal.lineSlice(out, @intCast(@max(0, pane.cur_row - row0))).len;
+ pane.cur_col = @intCast(llen);
+ } else {
+ const at: modal.Cursor = if (before)
+ .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) }
+ else blk: {
+ const hrow: usize = @intCast(@max(0, b.hi_row - row0));
+ const hi_col: usize = @intCast(@max(0, b.hi_col));
+ const high_line = modal.lineSlice(eb.text, hrow);
+ if (hi_col >= high_line.len) break :blk .{ .row = hrow + 1, .col = 0 };
+ const gcol = modal.nextGrapheme(high_line, hi_col);
+ break :blk .{ .row = hrow, .col = gcol };
+ };
+ const out = modal.insertAt(p.gpa, eb.text, at, y) catch return;
+ setEditText(p, pane, out);
+ pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(at.row)) + row0, .col = @intCast(at.col), .explicit = false };
+ const end = modal.advanceBy(at, y);
+ if (end.col > 0) {
+ pane.cur_row = @as(i32, @intCast(end.row)) + row0;
+ pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(out, end.row), end.col));
+ } else {
+ pane.cur_row = @as(i32, @intCast(end.row -| 1)) + row0;
+ pane.cur_col = @intCast(modal.lineSlice(out, end.row -| 1).len);
+ }
+ }
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+}
+
+pub fn normalChange(p: *Pardes, pane: *Pane) void {
+ pane.select = false;
+ pane.sticky_col = -1;
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ const eb = editTextEol(p, pane, b) orelse return;
+ const hi_len = modal.lineSlice(eb.text, @intCast(@max(0, b.hi_row - eb.row0))).len;
+ if (b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len) {
+ setYank(p, vselText(p, pane));
+ pushUndo(p, pane);
+ const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, b.lo_row - eb.row0)), @intCast(@max(0, b.hi_row - eb.row0))) catch return;
+ setEditText(p, pane, d.content);
+ p.gpa.free(d.deleted);
+ const n = modal.lineCount(d.content);
+ const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row - eb.row0))), if (n == 0) 0 else n - 1);
+ const ind = modal.indentText(modal.lineSlice(d.content, row));
+ const arena = p.scratch.allocator();
+ const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return;
+ const new = modal.insertAt(p.gpa, d.content, .{ .row = row, .col = 0 }, block) catch return;
+ setEditText(p, pane, new);
+ pane.cur_row = @as(i32, @intCast(row)) + eb.row0;
+ pane.cur_col = @intCast(ind.len);
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.mode = .insert;
+ pane.normal.clear();
+ pane.ensureCursorVisible();
+ return;
+ }
+ setYank(p, vselText(p, pane));
+ pushUndo(p, pane);
+ const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch {
+ pane.vsel.active = false;
+ return;
+ };
+ setEditText(p, pane, d.content);
+ p.gpa.free(d.deleted);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.clampCursor(d.content, eb.row0);
+ pane.mode = .insert;
+ pane.normal.clear();
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ pushUndo(p, pane);
+ const eb = editText(p, pane, r0, r1 + 1, -1) orelse return;
+ const arena = p.scratch.allocator();
+ const lo: usize = @intCast(@max(0, r0 - eb.row0));
+ const hi: usize = @intCast(@max(0, r1 - eb.row0));
+ var total: usize = hi - lo;
+ for (lo..hi + 1) |r| total += modal.lineSlice(eb.text, r).len;
+ const buf = arena.alloc(u8, total) catch return;
+ var at: usize = 0;
+ for (lo..hi + 1) |r| {
+ if (r > lo) {
+ buf[at] = '\n';
+ at += 1;
+ }
+ const line = modal.lineSlice(eb.text, r);
+ @memcpy(buf[at..][0..line.len], line);
+ at += line.len;
+ }
+ setYank(p, buf);
+ var body: []u8 = eb.text;
+ if (hi > lo) {
+ const d = modal.deleteLines(p.gpa, body, lo + 1, hi) catch {
+ pane.msel.active = false;
+ return;
+ };
+ setEditText(p, pane, d.content);
+ p.gpa.free(d.deleted);
+ body = d.content;
+ }
+ const cl = modal.clearLine(p.gpa, body, lo) catch {
+ pane.msel.active = false;
+ return;
+ };
+ setEditText(p, pane, cl);
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.mode = .insert;
+ pane.normal.clear();
+ pane.ensureCursorVisible();
+ } else {
+ pushUndo(p, pane);
+ const eb = editTextEol(p, pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return;
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) };
+ const arena = p.scratch.allocator();
+ setYank(p, modal.rangeText(arena, eb.text, c, c) catch return);
+ const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return;
+ setEditText(p, pane, d.content);
+ p.gpa.free(d.deleted);
+ const llen = modal.lineSlice(d.content, c.row).len;
+ pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen)));
+ pane.mode = .insert;
+ pane.normal.clear();
+ }
+}
+
+fn selRange(pane: *Pane, content: []const u8, row0: i32) modal.Range {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ return .{
+ .a = .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) },
+ .b = .{ .row = @intCast(@max(0, b.hi_row - row0)), .col = @intCast(@max(0, b.hi_col)) },
+ };
+ }
+ if (pane.msel.active) {
+ const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0));
+ const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0));
+ const llen = modal.lineSlice(content, r1).len;
+ return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = if (llen == 0) 0 else modal.prevGrapheme(modal.lineSlice(content, r1), llen) } };
+ }
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) };
+ return .{ .a = c, .b = c };
+}
+
+/// the rows an op's selection spans, absolute (what editText must cover)
+fn selRows(pane: *Pane) Bounds {
+ if (pane.vsel.active) return vselBounds(pane);
+ if (pane.msel.active) return .{
+ .lo_row = @min(pane.msel.r0, pane.msel.r1),
+ .lo_col = 0,
+ .hi_row = @max(pane.msel.r0, pane.msel.r1),
+ .hi_col = 0,
+ };
+ return .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col };
+}
+
+/// `r<ch>`: overwrite the selection (or the cursor char) with ch —
+/// newlines included (helix), so `xrz` joins the selected lines
+pub fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u21) void {
+ pane.select = false;
+ const eb = editTextEol(p, pane, selRows(pane)) orelse return;
+ const before = pane.primaryRange(eb.text, eb.row0);
+ const lo = @min(before.anchor, before.head);
+ const hi = @max(before.anchor, before.head);
+ var graphemes: usize = 0;
+ var at = lo;
+ while (at < hi) : (graphemes += 1) at = modal.nextGrapheme(eb.text, at);
+ var encoded: [4]u8 = undefined;
+ const encoded_len = std.unicode.utf8Encode(ch, &encoded) catch return;
+ const r = selRange(pane, eb.text, eb.row0);
+ pushUndo(p, pane);
+ const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return;
+ setEditText(p, pane, new);
+ const end = lo + graphemes * encoded_len;
+ const mapped: modal.Selection = if (before.head < before.anchor)
+ .{ .anchor = end, .head = lo }
+ else
+ .{ .anchor = lo, .head = end };
+ pane.setRange(new, eb.row0, mapped, pane.vsel.explicit);
+}
+
+/// `R`: replace the selection (or the cursor char) with the DEFAULT
+/// register. `SPC R` is the same verb over the system clipboard.
+pub fn normalReplaceYank(p: *Pardes, pane: *Pane) void {
+ replaceWithText(p, pane, p.yank orelse return);
+}
+
+/// The pasted text becomes the selection, head on its last char (which for
+/// a trailing-newline `y` is the '\n' cell of the last full line).
+fn replaceWithText(p: *Pardes, pane: *Pane, y: []const u8) void {
+ if (y.len == 0) return;
+ pane.select = false;
+ const eb = editTextEol(p, pane, selRows(pane)) orelse return;
+ const r = selRange(pane, eb.text, eb.row0);
+ pushUndo(p, pane);
+ const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return;
+ setEditText(p, pane, new);
+ pane.msel.active = false;
+ pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
+ const end = modal.advanceBy(r.a, y);
+ if (end.col > 0) {
+ pane.cur_row = @as(i32, @intCast(end.row)) + eb.row0;
+ pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col));
+ } else {
+ // the yank ended in '\n': the cursor lands ON that newline
+ pane.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0;
+ pane.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len);
+ }
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+}
+
+/// `~` / `` ` `` / ``Alt-` ``: case-map the selection (or the cursor char),
+/// keeping the selection (helix)
+pub fn normalCase(p: *Pardes, pane: *Pane, op: modal.CaseOp) void {
+ pane.select = false;
+ const eb = editTextEol(p, pane, selRows(pane)) orelse return;
+ const r = selRange(pane, eb.text, eb.row0);
+ pushUndo(p, pane);
+ const new = modal.changeCase(p.gpa, eb.text, r.a, r.b, op) catch return;
+ setEditText(p, pane, new);
+}
+
+/// a change to apply to file content: delete [from, to), insert `ins` at
+/// `from`. Ascending and disjoint.
+const TextChange = struct { from: usize, to: usize, ins: []const u8 };
+
+fn mapThroughChanges(chs: []const TextChange, pos: usize) usize {
+ var delta: i64 = 0;
+ for (chs) |ch| {
+ if (pos < ch.from) break;
+ if (ch.from == ch.to) {
+ delta += @intCast(ch.ins.len);
+ continue;
+ }
+ if (pos == ch.from) break;
+ if (pos < ch.to) return @intCast(@as(i64, @intCast(ch.from)) + delta);
+ delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from));
+ }
+ return @intCast(@as(i64, @intCast(pos)) + delta);
+}
+
+/// apply a change list. gpa-owned result.
+fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 {
+ var total = text.len;
+ for (chs) |ch| total = total - (ch.to - ch.from) + ch.ins.len;
+ const out = try p.gpa.alloc(u8, total);
+ errdefer p.gpa.free(out);
+ var copied: usize = 0;
+ var at: usize = 0;
+ for (chs) |ch| {
+ const unchanged = text[copied..ch.from];
+ @memcpy(out[at..][0..unchanged.len], unchanged);
+ at += unchanged.len;
+ @memcpy(out[at..][0..ch.ins.len], ch.ins);
+ at += ch.ins.len;
+ copied = ch.to;
+ }
+ @memcpy(out[at..], text[copied..]);
+ return out;
+}
+
+pub fn normalJoin(p: *Pardes, pane: *Pane) void {
+ // a join always eats the newline of its last line, so the buffer has
+ // to reach one row PAST the selection
+ const sr = selRows(pane);
+ const eb = editText(p, pane, sr.lo_row, sr.hi_row + 1, -1) orelse return;
+ const text = eb.text;
+ const range = pane.primaryRange(text, eb.row0);
+ const span = Pane.rangeLineSpan(text, range);
+ const nlines = modal.cursorLineCount(text);
+ var end = span.end;
+ if (span.start == end) end = @min(end + 1, nlines - 1);
+ if (end <= span.start) return;
+ const arena = p.scratch.allocator();
+ const chs = arena.alloc(TextChange, end - span.start) catch return;
+ var chs_len: usize = 0;
+ var l = span.start;
+ while (l < end) : (l += 1) {
+ const from = modal.lineEndOffset(text, l);
+ var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1);
+ while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1;
+ const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " ";
+ chs[chs_len] = .{ .from = from, .to = to, .ins = sep };
+ chs_len += 1;
+ }
+ if (chs_len == 0) return;
+ pushUndo(p, pane);
+ const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) });
+ const anc_off = if (pane.vsel.active)
+ modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) })
+ else
+ cur_off;
+ const new = applyChanges(p, text, chs[0..chs_len]) catch return;
+ const nc = mapThroughChanges(chs[0..chs_len], cur_off);
+ const na = mapThroughChanges(chs[0..chs_len], anc_off);
+ setEditText(p, pane, new);
+ const cc = modal.positionAt(new, nc);
+ const ac = modal.positionAt(new, na);
+ pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0;
+ pane.cur_col = @intCast(cc.col);
+ if (pane.vsel.active) {
+ pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0;
+ pane.vsel.col = @intCast(ac.col);
+ }
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+}
+
+pub fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void {
+ pane.select = false;
+ const sr = selRows(pane);
+ const eb = editText(p, pane, sr.lo_row, sr.hi_row, -1) orelse return;
+ const text = eb.text;
+ const range = pane.primaryRange(text, eb.row0);
+ const span = Pane.rangeLineSpan(text, range);
+ const arena = p.scratch.allocator();
+ // one run of spaces, sliced per line: `>` never inserts more than this
+ const pad = arena.alloc(u8, modal.INDENT_W * cnt) catch return;
+ @memset(pad, ' ');
+ const chs = arena.alloc(TextChange, span.end - span.start + 1) catch return;
+ var chs_len: usize = 0;
+ var l = span.start;
+ while (l <= span.end) : (l += 1) {
+ const ls = modal.lineStartOffset(text, l);
+ const le = modal.lineEndOffset(text, l);
+ const line = text[ls..le];
+ const nw = modal.firstNonWs(line);
+ if (nw == line.len) continue; // blank lines stay blank (helix)
+ if (add) {
+ const ins = modal.INDENT_W * cnt - (nw % modal.INDENT_W);
+ chs[chs_len] = .{ .from = ls, .to = ls, .ins = pad[0..ins] };
+ chs_len += 1;
+ } else {
+ const want = modal.INDENT_W * cnt;
+ var w: usize = 0;
+ var pos: usize = 0;
+ while (pos < line.len) {
+ if (line[pos] == ' ') {
+ w += 1;
+ } else if (line[pos] == '\t') {
+ w = (w / modal.INDENT_W + 1) * modal.INDENT_W;
+ } else break;
+ pos += 1;
+ if (w >= want) break;
+ }
+ if (pos > 0) {
+ chs[chs_len] = .{ .from = ls, .to = ls + pos, .ins = "" };
+ chs_len += 1;
+ }
+ }
+ }
+ if (chs_len == 0) return;
+ pushUndo(p, pane);
+ const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) });
+ const anc_off = if (pane.vsel.active)
+ modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) })
+ else
+ cur_off;
+ const new = applyChanges(p, text, chs[0..chs_len]) catch return;
+ const nc = mapThroughChanges(chs[0..chs_len], cur_off);
+ const na = mapThroughChanges(chs[0..chs_len], anc_off);
+ setEditText(p, pane, new);
+ const cc = modal.positionAt(new, nc);
+ const ac = modal.positionAt(new, na);
+ pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0;
+ pane.cur_col = @intCast(cc.col);
+ if (pane.vsel.active) {
+ pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0;
+ pane.vsel.col = @intCast(ac.col);
+ }
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+}
+
+pub fn normalToggleComment(p: *Pardes, pane: *Pane) void {
+ const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ pane.select = false; // helix exit_select_mode
+ // the buffer must cover every cursor's rows, not just the primary's
+ var b = selRows(pane);
+ for (pane.sels[0..pane.nsel]) |s| {
+ b.lo_row = @min(b.lo_row, @min(s.row, s.arow));
+ b.hi_row = @max(b.hi_row, @max(s.row, s.arow));
+ }
+ const surface = flatSurface(p, pane) catch return;
+ const last_row = @as(i32, @intCast(panes.File.textLineCount(pane, surface))) - 1;
+ const eb = editText(p, pane, b.lo_row, @min(b.hi_row + 1, last_row), -1) orelse return;
+ const text = eb.text;
+ var rs: [Pane.max_selections]modal.Selection = undefined;
+ const got = pane.ranges(text, eb.row0, &rs);
+ const nlines = modal.cursorLineCount(text);
+ const arena = p.scratch.allocator();
+ // the lines the ranges cover, each ONE ONCE and in order (helix's
+ // min_next_line: two cursors on one line comment it once)
+ const lines = arena.alloc(usize, nlines) catch return;
+ var lines_len: usize = 0;
+ var next: usize = 0;
+ for (rs[0..got.n]) |r| {
+ const span = Pane.rangeLineSpan(text, r);
+ var l = @max(span.start, next);
+ const end = @min(span.end + 1, nlines);
+ while (l < end) : (l += 1) {
+ lines[lines_len] = l;
+ lines_len += 1;
+ }
+ next = @max(next, end);
+ }
+ const ext = if (pane.file) |f| std.fs.path.extension(f.path) else "";
+ var token: []const u8 = config.comment_token_default;
+ lang: for (config.comment_tokens) |row| {
+ for (row.exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) {
+ token = row.token;
+ break :lang;
+ };
+ }
+ var commented = true;
+ var indent: usize = std.math.maxInt(usize);
+ var margin: usize = 1;
+ var live: usize = 0;
+ for (lines[0..lines_len]) |l| {
+ const line = text[modal.lineStartOffset(text, l)..modal.lineEndOffset(text, l)];
+ const nw = modal.firstNonWs(line);
+ if (nw == line.len) continue;
+ indent = @min(indent, nw);
+ if (!std.mem.startsWith(u8, line[nw..], token)) commented = false;
+ if (nw + token.len >= line.len or line[nw + token.len] != ' ') margin = 0;
+ live += 1;
+ }
+ if (live == 0) return; // nothing but blank lines
+ const ins = std.fmt.allocPrint(arena, "{s} ", .{token}) catch return;
+ const chs = arena.alloc(TextChange, lines_len) catch return;
+ var chs_len: usize = 0;
+ for (lines[0..lines_len]) |l| {
+ const ls = modal.lineStartOffset(text, l);
+ const le = modal.lineEndOffset(text, l);
+ const line = text[ls..le];
+ if (modal.firstNonWs(line) == line.len) continue; // blank lines untouched
+ const at = ls + indent;
+ // the @min can never bind: margin is 1 only when every line has a
+ // space after its own token, which is a byte past `at + token.len`
+ chs[chs_len] = if (commented)
+ .{ .from = at, .to = @min(at + token.len + margin, le), .ins = "" }
+ else
+ .{ .from = at, .to = at, .ins = ins };
+ chs_len += 1;
+ }
+ pushUndo(p, pane);
+ // one edit, and the WHOLE selection rides through it (helix maps the
+ // selection with the transaction)
+ var cells: [Pane.max_selections]Pane.SelRange = undefined;
+ const new = applyChanges(p, text, chs[0..chs_len]) catch return;
+ for (rs[0..got.n], 0..) |r, i| {
+ const c = Pane.rangeCells(new, .{
+ .anchor = mapThroughChanges(chs[0..chs_len], r.anchor),
+ .head = mapThroughChanges(chs[0..chs_len], r.head),
+ });
+ const cc = modal.positionAt(new, c.cur);
+ const ac = modal.positionAt(new, c.anc);
+ cells[i] = .{
+ .row = @as(i32, @intCast(cc.row)) + eb.row0,
+ .col = @intCast(cc.col),
+ .arow = @as(i32, @intCast(ac.row)) + eb.row0,
+ .acol = @intCast(ac.col),
+ };
+ }
+ setEditText(p, pane, new);
+ const t2 = flatSurface(p, pane) catch return;
+ for (cells[0..got.n], 0..) |s, i| rs[i] = Pane.cellRange(t2, s.arow, s.acol, s.row, s.col);
+ pane.setRanges(t2, rs[0..got.n], &.{}, got.pri, expl);
+}
+
+pub fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void {
+ const eb = editTextEol(p, pane, selRows(pane)) orelse return;
+ const r = selRange(pane, eb.text, eb.row0);
+ const arena = p.scratch.allocator();
+ const frag = modal.rangeText(arena, eb.text, r.a, r.b) catch return;
+ const rep = (modal.incrementDecimal(arena, frag, delta) catch null) orelse return;
+ pushUndo(p, pane);
+ pane.select = false;
+ const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, rep) catch return;
+ setEditText(p, pane, new);
+ const start = modal.lineStartOffset(new, r.a.row) + r.a.col;
+ const cc = modal.positionAt(new, modal.prevGrapheme(new, start + rep.len));
+ pane.vsel = .{ .active = modal.nextGrapheme(rep, 0) < rep.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
+ pane.msel.active = false;
+ pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0;
+ pane.cur_col = @intCast(cc.col);
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+}
+
+/// `]Space` / `[Space`: add blank lines below/above, the cursor staying
+/// on its text line (the selection rides along)
+pub fn addNewline(p: *Pardes, pane: *Pane, below: bool, cnt: usize) void {
+ const eb = editText(p, pane, pane.cur_row, pane.cur_row, -1) orelse return;
+ pushUndo(p, pane);
+ const row: usize = @intCast(@max(0, pane.cur_row - eb.row0));
+ const arena = p.scratch.allocator();
+ const nl = arena.alloc(u8, cnt) catch return;
+ @memset(nl, '\n');
+ const llen = modal.lineSlice(eb.text, row).len;
+ const new = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return;
+ setEditText(p, pane, new);
+ if (!below) {
+ pane.cur_row += @intCast(cnt); // the line moved down; stay on it
+ if (pane.vsel.active and pane.vsel.row >= @as(i32, @intCast(row))) pane.vsel.row += @intCast(cnt);
+ } else if (pane.vsel.active and pane.vsel.row > @as(i32, @intCast(row))) {
+ pane.vsel.row += @intCast(cnt);
+ }
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+}
+
+/// pair mapping for m-mode: either bracket names the pair; anything else
+/// surrounds with itself (quotes)
+fn pairFor(ch: u21) ?struct { o: u8, c: u8 } {
+ return switch (ch) {
+ '(', ')' => .{ .o = '(', .c = ')' },
+ '[', ']' => .{ .o = '[', .c = ']' },
+ '{', '}' => .{ .o = '{', .c = '}' },
+ '<', '>' => .{ .o = '<', .c = '>' },
+ else => if (ch <= 0x7f) .{ .o = @intCast(ch), .c = @intCast(ch) } else null,
+ };
+}
+
+pub fn textobjectSelect(p: *Pardes, pane: *Pane, text: []const u8, obj: u21, around: bool) void {
+ const range = pane.primaryRange(text, 0);
+ switch (obj) {
+ 'w', 'W' => {
+ const r = modal.selectWord(text, range, around, obj == 'W');
+ return pane.setRange(text, 0, r, false);
+ },
+ 'p' => {
+ const r = modal.selectParagraph(text, range, around, 1);
+ return pane.setRange(text, 0, r, false);
+ },
+ else => {},
+ }
+ const lines = paneCursorLines(p, pane) catch return;
+ const cur = modal.positionAt(text, modal.selectionCursor(text, range));
+ const pair: ?modal.Range = switch (obj) {
+ '\'', '"', '`' => modal.enclosingQuote(lines, cur, @intCast(obj)),
+ '(', ')' => modal.enclosingPair(lines, cur, '(', ')'),
+ '[', ']' => modal.enclosingPair(lines, cur, '[', ']'),
+ '{', '}' => modal.enclosingPair(lines, cur, '{', '}'),
+ '<', '>' => modal.enclosingPair(lines, cur, '<', '>'),
+ else => null,
+ };
+ const pr = pair orelse return;
+ var a = modal.offsetAt(text, pr.a);
+ var head = modal.nextGrapheme(text, modal.offsetAt(text, pr.b));
+ if (!around) {
+ // inside: shrink off the delimiters; an EMPTY pair collapses to
+ // a 1-wide cursor on the closing char (helix)
+ a = modal.nextGrapheme(text, a);
+ head = modal.prevGrapheme(text, head);
+ }
+ pane.setRange(text, 0, .{ .anchor = a, .head = head }, false);
+}
+
+/// `ms<ch>`: wrap the selection (or the cursor char) in a pair; the wrap
+/// including the pair becomes the selection
+pub fn surroundAdd(p: *Pardes, pane: *Pane, ch: u21) void {
+ const pr = pairFor(ch) orelse return;
+ pane.select = false;
+ const eb = editTextEol(p, pane, selRows(pane)) orelse return;
+ const r = selRange(pane, eb.text, eb.row0);
+ pushUndo(p, pane);
+ const close_col = modal.nextGrapheme(modal.lineSlice(eb.text, r.b.row), r.b.col);
+ var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = close_col }, &[1]u8{pr.c}) catch return;
+ setEditText(p, pane, new);
+ new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return;
+ setEditText(p, pane, new);
+ pane.msel.active = false;
+ pane.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
+ pane.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0;
+ pane.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0));
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+}
+
+/// `md<ch>`: delete the enclosing pair's chars; the cursor maps through
+pub fn surroundDelete(p: *Pardes, pane: *Pane, ch: u21) void {
+ const pr = pairFor(ch) orelse return;
+ const lines = paneCursorLines(p, pane) catch return;
+ const cur = pane.toModalCursor();
+ const r = (if (pr.o == pr.c)
+ modal.enclosingQuote(lines, cur, pr.o)
+ else
+ modal.enclosingPair(lines, cur, pr.o, pr.c)) orelse return;
+ const lo: i32 = @intCast(@min(r.a.row, cur.row));
+ const hi: i32 = @intCast(@max(r.b.row, cur.row));
+ const eb = editText(p, pane, lo, hi, -1) orelse return;
+ const text = eb.text;
+ const drow = -eb.row0; // surface row -> buffer row
+ const ra: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.a.row)) + drow), .col = r.a.col };
+ const rb: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.b.row)) + drow), .col = r.b.col };
+ pushUndo(p, pane);
+ const a_off = modal.offsetAt(text, ra);
+ const b_off = modal.offsetAt(text, rb);
+ const cur_off = modal.offsetAt(text, .{ .row = @intCast(@as(i32, @intCast(cur.row)) + drow), .col = cur.col });
+ // the close first, so the open's position stays valid
+ var new = modal.deleteChar(p.gpa, text, rb) catch return;
+ setEditText(p, pane, new);
+ new = modal.deleteChar(p.gpa, new, ra) catch return;
+ setEditText(p, pane, new);
+ var nc = cur_off;
+ if (nc > b_off) nc -= 1;
+ if (nc > a_off) nc -= 1;
+ const cc = modal.positionAt(new, nc);
+ pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0;
+ pane.cur_col = @intCast(cc.col);
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+}
+
+/// `mr<from><to>`: swap the enclosing <from> pair's chars for <to>'s
+pub fn surroundReplace(p: *Pardes, pane: *Pane, from: u21, to: u21) void {
+ const fp = pairFor(from) orelse return;
+ const tp = pairFor(to) orelse return;
+ const lines = paneCursorLines(p, pane) catch return;
+ const cur = pane.toModalCursor();
+ const r = (if (fp.o == fp.c)
+ modal.enclosingQuote(lines, cur, fp.o)
+ else
+ modal.enclosingPair(lines, cur, fp.o, fp.c)) orelse return;
+ const eb = editText(p, pane, @as(i32, @intCast(r.a.row)), @as(i32, @intCast(r.b.row)), -1) orelse return;
+ const drow = -eb.row0; // surface row -> buffer row
+ const ar: usize = @intCast(@as(i32, @intCast(r.a.row)) + drow);
+ const br: usize = @intCast(@as(i32, @intCast(r.b.row)) + drow);
+ pushUndo(p, pane);
+ const out = p.gpa.dupe(u8, eb.text) catch return;
+ out[modal.lineStartOffset(out, ar) + r.a.col] = tp.o;
+ out[modal.lineStartOffset(out, br) + r.b.col] = tp.c;
+ setEditText(p, pane, out);
+}
+
+pub fn pushUndo(p: *Pardes, pane: *Pane) void {
+ // one keystroke, one undo step — even when it edited at ten cursors.
+ if (p.multi_on and !p.multi_first) return;
+ if (pane.file != null) return panes.File.pushUndo(p, pane);
+ panes.Terminal.pushUndo(p, pane);
+}
+
+pub fn doUndo(p: *Pardes, pane: *Pane) void {
+ if (pane.file != null) return panes.File.undo(p, pane);
+ panes.Terminal.undo(p, pane);
+}
+
+pub fn doRedo(p: *Pardes, pane: *Pane) void {
+ if (pane.file != null) return panes.File.redo(p, pane);
+ panes.Terminal.redo(p, pane);
+}
+
+const PointerTextSelection = struct {
+ sel: Pane.Sel,
+ on_tag: bool,
+};
+
+pub fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) ?PointerTextSelection {
+ if (p.panes[id] == null) return null;
+ const r = p.rects[id];
+ if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = p.bodyTop(r);
+ const on_tag = row >= tag_y and row < tag_y + BOX_H;
+ if (on_tag and col < r.x + TAG_TEXT_INSET) return null;
+ // A notice chip is chrome: it sits OVER the body's top rows, so a
+ // click inside one belongs to nobody. The rest of those rows is
+ // ordinary body text and still answers normally.
+ if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.span() and
+ p.panes[id].?.notices.covers(row - body_y, col)) return null;
+ if (!on_tag) if (body_hit) |supplied| {
+ const mapped_hit = p.bodyHitForPane(id, supplied, false) orelse return null;
+ const c = @as(i32, mapped_hit.col) - config.GUTTER;
+ const v = @as(i32, mapped_hit.row) + BOX_H;
+ return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false };
+ };
+ const tag_col = if (on_tag) p.tagColumn(.pane, id, tag_hit, false) else null;
+ const c: i32 = (if (tag_col) |value| @as(i32, value) else @as(i32, col) - @as(i32, r.x + (if (on_tag) TAG_TEXT_INSET else config.GUTTER))) +
+ (if (on_tag) @as(i32, p.panes[id].?.tag_scroll) else 0);
+ const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H);
+ return .{
+ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v },
+ .on_tag = on_tag,
+ };
+}
+
+/// Drain the queue and say whether the shell was asked to do `want`.
+fn drainedEffect(p: *Pardes, want: std.meta.Tag(Effect)) bool {
+ var seen = false;
+ while (p.nextEffect()) |effect| {
+ if (std.meta.activeTag(effect) == want) seen = true;
+ }
+ return seen;
+}
+
+test "insert newline adds one indent level after a closing call" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile(" callback({})\n");
+ pane.mode = .insert;
+ pane.cur_col = 16;
+
+ p.update(.{ .key = .{ .cp = Key.enter } });
+
+ try std.testing.expectEqualSlices(u8, " callback({})\n \n", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 8), pane.cur_col);
+}
+
+test "insert and normal modes edit complete Unicode graphemes" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "界" ++ "z");
+
+ pane.mode = .insert;
+ pane.cur_col = 22; // on z, after the CJK grapheme
+ insertKey(p, pane, .{ .cp = Key.left });
+ try std.testing.expectEqual(@as(i32, 19), pane.cur_col);
+ insertKey(p, pane, .{ .cp = Key.left });
+ try std.testing.expectEqual(@as(i32, 4), pane.cur_col);
+ insertKey(p, pane, .{ .cp = Key.right });
+ try std.testing.expectEqual(@as(i32, 19), pane.cur_col);
+ insertKey(p, pane, .{ .cp = Key.backspace });
+ try std.testing.expectEqualStrings("ae\u{301}界z", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 4), pane.cur_col);
+
+ pane.mode = .normal;
+ pane.cur_col = 1;
+ normalDelete(p, pane, false);
+ try std.testing.expectEqualStrings("a界z", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
+ normalReplaceChar(p, pane, 'λ');
+ try std.testing.expectEqualStrings("aλz", pane.file.?.content);
+
+ panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a"));
+ pane.cur_col = 3;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+ normalReplaceChar(p, pane, 'λ');
+ try std.testing.expectEqualStrings("λλ", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 2), pane.cur_col);
+ try std.testing.expectEqual(@as(i32, 0), pane.vsel.col);
+
+ panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a"));
+ pane.cur_col = 0;
+ pane.vsel = .{ .active = true, .row = 0, .col = 3, .explicit = true };
+ normalReplaceChar(p, pane, 'λ');
+ try std.testing.expectEqualStrings("λλ", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_col);
+ try std.testing.expectEqual(@as(i32, 2), pane.vsel.col);
+}
+
+test "flat text replacement preserves a terminal fragment origin and direction" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 });
+ defer p.deinit();
+ p.update(.{ .output = .{ .pane = 0, .bytes = "first\r\nsecond\r\nthird\r\n界a\r\n" } });
+ const pane = p.panes[0].?;
+ pane.mode = .normal;
+ pane.cur_pinned = true;
+ pane.cur_row = 3;
+ pane.cur_col = 0;
+ pane.vsel = .{ .active = true, .row = 3, .col = 3, .explicit = true };
+ normalReplaceChar(p, pane, 'λ');
+ try std.testing.expectEqual(@as(i32, 3), pane.ovl.?.row);
+ try std.testing.expectEqualStrings("λλ", pane.ovl.?.text);
+ try std.testing.expectEqual(@as(i32, 3), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_col);
+ try std.testing.expectEqual(@as(i32, 3), pane.vsel.row);
+ try std.testing.expectEqual(@as(i32, 2), pane.vsel.col);
+ try std.testing.expect(pane.vsel.active and pane.vsel.explicit);
+ const lines = try paneCursorLines(p, pane);
+ try std.testing.expectEqualStrings("first", lines[0]);
+ try std.testing.expectEqualStrings("λλ", lines[3]);
+ const flat = try flatSurface(p, pane);
+ try std.testing.expect(std.mem.startsWith(u8, flat, "first\nsecond\nthird\nλλ\n"));
+}
+
+test "only the SPC clipboard commands cross to the system clipboard" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ const pane = p.panes[0].?;
+
+ _ = drainedEffect(p, .set_clipboard);
+ p.update(.{ .key = .{ .cp = 'y' } });
+ try std.testing.expect(p.yank != null and p.yank.?.len > 0);
+ try std.testing.expect(!drainedEffect(p, .set_clipboard));
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expect(!drainedEffect(p, .set_clipboard));
+
+ // `SPC y` is the one that does, and it puts the SAME text there that `y`
+ // put in the register — it is the ordinary yank path plus the mirror.
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'y' } });
+ const yanked = p.yank orelse return error.MissingYank;
+ try std.testing.expect(drainedEffect(p, .set_clipboard));
+
+ const before = pane.file.?.content.len;
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'p' } });
+ try std.testing.expect(p.clip_pending != null);
+ try std.testing.expect(drainedEffect(p, .read_clipboard));
+ try std.testing.expectEqual(before, pane.file.?.content.len); // nothing yet
+ p.update(.{ .paste = "PASTED" });
+ try std.testing.expect(p.clip_pending == null);
+ try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "PASTED") != null);
+ // ...and it did NOT land in the register on its way past, nor echo back
+ // out to the clipboard it came from.
+ try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+ try std.testing.expect(!drainedEffect(p, .set_clipboard));
+
+ // An UNSOLICITED paste — the outer terminal's bracketed paste, a Cmd-V —
+ // is the same event with no request behind it, and means paste after.
+ p.update(.{ .paste = "UNASKED" });
+ try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "UNASKED") != null);
+ try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+
+ // A request the shell never answers dies at the next keystroke rather
+ // than firing late into whatever pane is focused by then.
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'P' } });
+ try std.testing.expect(p.clip_pending != null);
+ p.update(.{ .key = .{ .cp = 'l' } });
+ try std.testing.expect(p.clip_pending == null);
+}