diff options
Diffstat (limited to 'src/edit.zig')
| -rw-r--r-- | src/edit.zig | 1692 |
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); +} |
