From ff953a6236ebdf97c73deda86fd9005906a2071b Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 22:09:27 -0300 Subject: Move text editing out of pardes.zig into edit.zig Pure move, no behaviour change (acme keeps this in text.c): which text a pane edits and how it maps to the screen (editText, editTextEol, setEditText, paneCursorLines, paneByteAtDisplay, pinPaneCursor, flatSurface, paneWrapWidth), insert mode (enterInsert, handleInsert, insertKey, insertTab, exitInsert, clampFileCursor), the d/c/y/p edit operations with replace, case, join, indent, comment, number, textobjects and surround, undo and redo, yank/clipboard/paste (setYank, setClipboard, ClipRequest, clipRequest, typeToTty, applyPaste, clipYank), and the pointer selections as text (PointerTextSelection, pointerTextSelection, capturePointerSelection, paneText, pointerSourceLine, selectionText, spanHas, currentSelText), with four tests, go verbatim to edit.zig. The methods become free functions taking `p: *Pardes`; calls change from `p.insertKey(..)` to `edit.insertKey(p, ..)` (pardes.zig, body_layer.zig, selection_pipe.zig, builtins.zig, test/hxdiff.zig, test/perf.zig). In executeNormalAction the `.edit => |edit|` capture becomes `|op|`, since it would now shadow the edit import. test/lspbench.zig's first anchor follows its needle into src/edit.zig. Co-Authored-By: Claude Opus 5.5 --- src/edit.zig | 1692 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1692 insertions(+) create mode 100644 src/edit.zig (limited to 'src/edit.zig') 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`: 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`: 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`: 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`: swap the enclosing pair's chars for '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); +} -- cgit v1.3