//! 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 mouse = @import("mouse.zig"); const normal = @import("normal.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 Text = panes.Text; 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.prompt != .none and pane.prompt != .del_side) { // an answer is one line: a newline in it would only be submitting it const line = p.scratch.allocator().alloc(u8, bytes.len) catch return; var n: usize = 0; for (bytes) |byte| if (byte != '\n' and byte != '\r') { line[n] = byte; n += 1; }; if (n > 0) insertKey(p, &pane.input, .{ .cp = line[0], .text = line[0..n] }); if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split); return; } const t = if (pane.focus == .tag) &pane.tag else &pane.body; if (t.what == .body and pane.isTerminal() and pane.body.mode == .tty) return typeToTty(p, id, pane, bytes); switch (if (req) |r| r.mode else .after) { .after => pasteText(p, t, bytes, false, 1), .before => pasteText(p, t, bytes, true, 1), .replace => replaceWithText(p, t, 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.body.nsel > 0 and !main_only) { normal.replaySels(p, &pane.body, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } }); } else { const others = pane.body.nsel; pane.body.nsel = 0; normalYank(p, &pane.body); pane.body.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.body.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.body.vsel.active) return vselText(p, &pane.body); if (!pane.body.msel.active) return null; return yankRows(p, &pane.body, @min(pane.body.msel.r0, pane.body.msel.r1), @max(pane.body.msel.r0, pane.body.msel.r1)); } pub fn exitInsert(p: *Pardes, t: *Text) void { t.mode = .normal; t.msel.active = false; t.normal.clear(); if (t.append_at) |aa| { t.append_at = null; const text = flatSurface(p, t) catch return; const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.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); t.cur_row = @intCast(bc.row); t.cur_col = @intCast(bc.col); t.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; t.cur_pinned = true; t.show(); for (t.sels[0..t.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, t: *Text) ![]const []const u8 { const arena = p.scratch.allocator(); if (t.what != .body) { const text = try flatSurface(p, t); const lines = try arena.alloc([]const u8, std.mem.count(u8, text, "\n") + 1); var it = std.mem.splitScalar(u8, text, '\n'); for (lines) |*line| line.* = it.next().?; return lines; } const pane = t.pane(); 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, t: *Text, row: i32, from_raw: i32, display_col: i32) i32 { if (t.what == .body and t.pane().file != null) return panes.File.byteAtRowDisplay(t.pane(), row, from_raw, display_col); const lines = paneCursorLines(p, t) 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, t: *Text) void { if (t.cur_pinned) return; // Only a body has a live cursor of someone else's to freeze. if (t.what != .body) { t.cur_pinned = true; return; } const pane = t.pane(); pane.pinCursor(); if (pane.file == null and !pane.hasPdf()) t.cur_col = paneByteAtDisplay(p, t, t.cur_row, 0, t.cur_col); } pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 { if (t.what == .tag) return Pardes.curTail(t.pane()); if (t.what != .body) return t.own orelse ""; const pane = t.pane(); 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, t: *Text) usize { if (t.what != .body) return 0; const pane = t.pane(); if (pane.file == null) return 0; return panes.File.wrapWidth(pane, p.settings.wrap); } /// Route shared edit operations to the characters `t` edits: its own, or /// for a body its file's content or its terminal's overlay. fn editText(p: *Pardes, t: *Text, lo: i32, hi: i32, col: i32) ?panes.EditText { // A tag nobody has changed shows its default; the first edit makes that // default its own text. if (t.what == .tag and t.own == null) t.own = p.gpa.dupe(u8, Pardes.defaultTail(t.pane())) catch return null; if (t.what != .body) return .{ .text = t.own orelse &.{}, .row0 = 0 }; const pane = t.pane(); 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, t: *Text, b: Bounds) ?panes.EditText { const eb = editText(p, t, 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, t, b.lo_row, b.hi_row + 1, -1); } /// install a rewritten editable text (frees the old one) fn setEditText(p: *Pardes, t: *Text, new: []u8) void { if (t.what != .body) { if (t.own) |old| p.gpa.free(old); t.own = new; return; } const pane = t.pane(); 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(t: *Text) bool { if (t.what != .body) return false; const pane = t.pane(); const file = if (pane.file) |*file| file else return false; const old_row = t.cur_row; const old_col = t.cur_col; const last = panes.File.nlines(pane.gpa, file) -| 1; t.cur_row = @intCast(@min(last, @as(usize, @intCast(@max(0, t.cur_row))))); const line = panes.File.sourceLine(pane, t.cur_row); t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col)))))); return old_row != t.cur_row or old_col != t.cur_col; } pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { if (t.what == .body and t.pane().hasPdf()) return; pinPaneCursor(p, t); _ = clampFileCursor(t); // snapshot once per insert session (WITH the pre-insert selection) so // `u` undoes the whole session and restores what was selected pushUndo(p, t); t.select = false; t.append_at = null; t.sticky_col = -1; const text = flatSurface(p, t) catch { t.mode = .insert; t.msel.active = false; t.vsel.active = false; t.normal.clear(); return; }; const cur = t.toModalCursor(); const line = panes.File.textLine(t, text, cur.row); const b: ?Bounds = if (t.vsel.active) vselBounds(t) else null; switch (where) { .at => { if (b) |bb| { t.cur_row = bb.lo_row; t.cur_col = bb.lo_col; t.vsel = .{ .active = true, .row = bb.hi_row, .col = bb.hi_col, .explicit = false }; t.cur_pinned = true; } }, .append => { const lo_row = if (b) |bb| bb.lo_row else t.cur_row; const lo_col = if (b) |bb| bb.lo_col else t.cur_col; const hi_row = if (b) |bb| bb.hi_row else t.cur_row; const hi_col = if (b) |bb| bb.hi_col else t.cur_col; t.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); t.cur_row = @intCast(gc.row); t.cur_col = @intCast(gc.col); t.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false }; t.cur_pinned = true; }, .line_start => { t.fromModalCursor(.{ .row = cur.row, .col = modal.firstNonWs(line) }); t.vsel.active = false; }, .line_end => { t.cur_col = @intCast(line.len); t.cur_pinned = true; t.vsel.active = false; }, .open_below, .open_above => { t.vsel.active = false; const below = where == .open_below; const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else t.cur_row; const eb = editText(p, t, 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, t, new); t.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0; t.cur_col = @intCast(ind.len); t.cur_pinned = true; if (cnt > 1 and !p.multi_on) opened: { const t2 = flatSurface(p, t) catch break :opened; var rs: [Text.max_selections]modal.Selection = undefined; const m = @min(cnt, Text.max_selections); for (0..m) |k| { const o = modal.offsetAt(t2, .{ .row = @intCast(@max(0, t.cur_row) + @as(i32, @intCast(k))), .col = ind.len }); rs[k] = .{ .anchor = o, .head = o }; } t.setRanges(t2, rs[0..m], &.{}, 0, false); } }, } t.mode = .insert; t.msel.active = false; t.normal.clear(); t.show(); } pub fn handleInsert(p: *Pardes, t: *Text, key: Key) void { if (t.nsel == 0) return insertKey(p, t, key); normal.replaySels(p, t, .{ .insert = key }); } pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { // helix aliases: normalize to the base key and re-dispatch if (hit(key, config.insert_backspace_alias)) return insertKey(p, t, .{ .cp = Key.backspace }); if (hit(key, config.insert_enter_alias)) return insertKey(p, t, .{ .cp = Key.enter }); if (hit(key, config.insert_delete_alias)) return insertKey(p, t, .{ .cp = Key.delete }); t.vsel.active = false; if (!t.cur_pinned) pinPaneCursor(p, t); _ = clampFileCursor(t); switch (key.cp) { Key.left, Key.right, Key.up, Key.down => { const lines = paneCursorLines(p, t) catch return; const cur0 = t.toModalCursor(); const nc = switch (key.cp) { Key.left => modal.charLeft(lines, cur0), Key.right => modal.charRight(lines, cur0), Key.up => Text.insertVerticalCursor(lines, cur0, false), Key.down => Text.insertVerticalCursor(lines, cur0, true), else => cur0, }; t.fromModalCursor(nc); t.show(); 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, t) catch return; const range = t.primaryRange(flat, 0); if (t.what != .body) return; const step: i32 = @intCast(@max(1, t.pane().rows)); t.scrollViewMove(flat, range, if (key.cp == Key.page_down) step else -step); return; }, Key.home => { t.cur_col = 0; t.cur_pinned = true; t.show(); return; }, Key.end => { // helix insert End: past the last char (goto_line_end_newline) const text = flatSurface(p, t) catch return; const cur0 = t.toModalCursor(); t.cur_col = @intCast(panes.File.textLine(t, text, cur0.row).len); t.cur_pinned = true; t.show(); return; }, else => {}, } const eb = editText(p, t, t.cur_row, t.cur_row, t.cur_col) orelse return; const text = eb.text; const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.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, t, new); for (key.text) |ch| { if (ch == '\n') { t.cur_row += 1; t.cur_col = 0; } else t.cur_col += 1; } t.cur_pinned = true; t.show(); 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, t, t.cur_row - 1, t.cur_row, 0) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, t.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, t, new); t.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; t.cur_col = @intCast(fc.col); t.cur_pinned = true; t.show(); 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, t, t.cur_row, t.cur_row + 1, t.cur_col) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, t.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, t, new); t.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, t, new); t.cur_col = 0; t.cur_pinned = true; t.show(); 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, t, new); t.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, t, new); t.cur_row += 1; t.cur_col = @intCast(indent); t.cur_pinned = true; t.show(); }, Key.backspace => { if (t.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, t, new); t.cur_col = @intCast(prev); t.cur_pinned = true; t.show(); return; } // at col 0: eat the newline above (join up) — a terminal grows // its buffer over the row it is joining with if (t.cur_row <= 0) return; const e2 = editText(p, t, t.cur_row - 1, t.cur_row, 0) orelse return; const row: usize = @intCast(@max(0, t.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, t, new); t.cur_row -= 1; t.cur_col = @intCast(prevlen); t.cur_pinned = true; t.show(); }, 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, t, new); } else { const e2 = editText(p, t, t.cur_row, t.cur_row + 1, t.cur_col) orelse return; const row: usize = @intCast(@max(0, t.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, t, new); } t.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: { if (t.what != .body) break :dot; const f = t.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, t); }, else => {}, } } pub fn insertTab(p: *Pardes, t: *Text) void { const eb = editText(p, t, t.cur_row, t.cur_row, t.cur_col) orelse return; const c: modal.Cursor = .{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.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, t, new); t.cur_col += @intCast(pad); t.cur_pinned = true; t.show(); } 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: Text.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(t: *Text) Bounds { return cellBounds(.{ .row = t.cur_row, .col = t.cur_col, .arow = t.vsel.row, .acol = t.vsel.col }); } fn vselText(p: *Pardes, t: *Text) []const u8 { const arena = p.scratch.allocator(); const b = vselBounds(t); const text = flatSurface(p, t) 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, t: *Text, r0: i32, r1: i32) []const u8 { const arena = p.scratch.allocator(); const lines = paneCursorLines(p, t) 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, t: *Text, yank: bool) void { t.select = false; // helix delete exits select mode t.sticky_col = -1; if (t.vsel.active) { const b = vselBounds(t); if (yank) setYank(p, vselText(p, t)); // read the range before the move pushUndo(p, t); const eb = editTextEol(p, t, b) orelse { t.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 { t.vsel.active = false; return; }; setEditText(p, t, d.content); p.gpa.free(d.deleted); t.cur_row = b.lo_row; t.cur_col = b.lo_col; t.clampCursor(d.content, eb.row0); return; } if (t.msel.active) { const r0 = @min(t.msel.r0, t.msel.r1); const r1 = @max(t.msel.r0, t.msel.r1); pushUndo(p, t); // linewise: a line's newline lives on the row below it const eb = editText(p, t, r0, r1 + 1, -1) orelse { t.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 { t.msel.active = false; return; }; setEditText(p, t, d.content); if (yank) setYank(p, d.deleted); p.gpa.free(d.deleted); const n = modal.lineCount(d.content); t.cur_row = @as(i32, @intCast(@min(@as(usize, @intCast(@max(0, r0 - eb.row0))), if (n == 0) 0 else n - 1))) + eb.row0; t.cur_col = 0; t.msel.active = false; t.cur_pinned = true; t.show(); } else { pushUndo(p, t); const eb = editTextEol(p, t, .{ .lo_row = t.cur_row, .lo_col = t.cur_col, .hi_row = t.cur_row, .hi_col = t.cur_col }) orelse return; const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.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, t, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(d.content, c.row).len; t.cur_col = @min(t.cur_col, @as(i32, @intCast(llen))); } } pub fn normalYank(p: *Pardes, t: *Text) void { t.select = false; if (t.what == .body) if (t.pane().rawPointerText(t.pane().sel[sel_slot])) |text| { setYank(p, text); return; }; if (t.vsel.active) { setYank(p, vselText(p, t)); return; } if (t.msel.active) { setYank(p, yankRows(p, t, @min(t.msel.r0, t.msel.r1), @max(t.msel.r0, t.msel.r1))); } else { const save = t.vsel; t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col, .explicit = false }; setYank(p, vselText(p, t)); t.vsel = save; } } pub fn pasteText(p: *Pardes, t: *Text, y0: []const u8, before: bool, count: usize) void { if (y0.len == 0) return; pushUndo(p, t); t.select = false; t.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 (t.vsel.active) vselBounds(t) else .{ .lo_row = t.cur_row, .lo_col = t.cur_col, .hi_row = t.cur_row, .hi_col = t.cur_col }; const eb = editText(p, t, 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; t.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; t.cur_row = @as(i32, @intCast(row + n)) + row0; } setEditText(p, t, out); t.vsel = .{ .active = true, .row = t.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, t.cur_row - row0))).len; t.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, t, out); t.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) { t.cur_row = @as(i32, @intCast(end.row)) + row0; t.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(out, end.row), end.col)); } else { t.cur_row = @as(i32, @intCast(end.row -| 1)) + row0; t.cur_col = @intCast(modal.lineSlice(out, end.row -| 1).len); } } t.msel.active = false; t.cur_pinned = true; t.show(); } pub fn normalChange(p: *Pardes, t: *Text) void { t.select = false; t.sticky_col = -1; if (t.vsel.active) { const b = vselBounds(t); const eb = editTextEol(p, t, 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, t)); pushUndo(p, t); 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, t, 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, t, new); t.cur_row = @as(i32, @intCast(row)) + eb.row0; t.cur_col = @intCast(ind.len); t.vsel.active = false; t.msel.active = false; t.cur_pinned = true; t.mode = .insert; t.normal.clear(); t.show(); return; } setYank(p, vselText(p, t)); pushUndo(p, t); 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 { t.vsel.active = false; return; }; setEditText(p, t, d.content); p.gpa.free(d.deleted); t.cur_row = b.lo_row; t.cur_col = b.lo_col; t.clampCursor(d.content, eb.row0); t.mode = .insert; t.normal.clear(); return; } if (t.msel.active) { const r0 = @min(t.msel.r0, t.msel.r1); const r1 = @max(t.msel.r0, t.msel.r1); pushUndo(p, t); const eb = editText(p, t, 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 { t.msel.active = false; return; }; setEditText(p, t, d.content); p.gpa.free(d.deleted); body = d.content; } const cl = modal.clearLine(p.gpa, body, lo) catch { t.msel.active = false; return; }; setEditText(p, t, cl); t.cur_row = r0; t.cur_col = 0; t.msel.active = false; t.cur_pinned = true; t.mode = .insert; t.normal.clear(); t.show(); } else { pushUndo(p, t); const eb = editTextEol(p, t, .{ .lo_row = t.cur_row, .lo_col = t.cur_col, .hi_row = t.cur_row, .hi_col = t.cur_col }) orelse return; const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.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, t, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(d.content, c.row).len; t.cur_col = @min(t.cur_col, @as(i32, @intCast(llen))); t.mode = .insert; t.normal.clear(); } } fn selRange(t: *Text, content: []const u8, row0: i32) modal.Range { if (t.vsel.active) { const b = vselBounds(t); 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 (t.msel.active) { const r0: usize = @intCast(@max(0, @min(t.msel.r0, t.msel.r1) - row0)); const r1: usize = @intCast(@max(0, @max(t.msel.r0, t.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, t.cur_row - row0)), .col = @intCast(@max(0, t.cur_col)) }; return .{ .a = c, .b = c }; } /// the rows an op's selection spans, absolute (what editText must cover) fn selRows(t: *Text) Bounds { if (t.vsel.active) return vselBounds(t); if (t.msel.active) return .{ .lo_row = @min(t.msel.r0, t.msel.r1), .lo_col = 0, .hi_row = @max(t.msel.r0, t.msel.r1), .hi_col = 0, }; return .{ .lo_row = t.cur_row, .lo_col = t.cur_col, .hi_row = t.cur_row, .hi_col = t.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, t: *Text, ch: u21) void { t.select = false; const eb = editTextEol(p, t, selRows(t)) orelse return; const before = t.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(t, eb.text, eb.row0); pushUndo(p, t); const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return; setEditText(p, t, 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 }; t.setRange(new, eb.row0, mapped, t.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, t: *Text) void { replaceWithText(p, t, 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, t: *Text, y: []const u8) void { if (y.len == 0) return; t.select = false; const eb = editTextEol(p, t, selRows(t)) orelse return; const r = selRange(t, eb.text, eb.row0); pushUndo(p, t); const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return; setEditText(p, t, new); t.msel.active = false; t.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) { t.cur_row = @as(i32, @intCast(end.row)) + eb.row0; t.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col)); } else { // the yank ended in '\n': the cursor lands ON that newline t.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; t.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len); } t.cur_pinned = true; t.sticky_col = -1; t.show(); } /// `~` / `` ` `` / ``Alt-` ``: case-map the selection (or the cursor char), /// keeping the selection (helix) pub fn normalCase(p: *Pardes, t: *Text, op: modal.CaseOp) void { t.select = false; const eb = editTextEol(p, t, selRows(t)) orelse return; const r = selRange(t, eb.text, eb.row0); pushUndo(p, t); const new = modal.changeCase(p.gpa, eb.text, r.a, r.b, op) catch return; setEditText(p, t, 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, t: *Text) 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(t); const eb = editText(p, t, sr.lo_row, sr.hi_row + 1, -1) orelse return; const text = eb.text; const range = t.primaryRange(text, eb.row0); const span = Text.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, t); const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.cur_col)) }); const anc_off = if (t.vsel.active) modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row - eb.row0)), .col = @intCast(@max(0, t.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, t, new); const cc = modal.positionAt(new, nc); const ac = modal.positionAt(new, na); t.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; t.cur_col = @intCast(cc.col); if (t.vsel.active) { t.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; t.vsel.col = @intCast(ac.col); } t.msel.active = false; t.cur_pinned = true; t.sticky_col = -1; t.show(); } pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { t.select = false; const sr = selRows(t); const eb = editText(p, t, sr.lo_row, sr.hi_row, -1) orelse return; const text = eb.text; const range = t.primaryRange(text, eb.row0); const span = Text.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, t); const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.cur_col)) }); const anc_off = if (t.vsel.active) modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row - eb.row0)), .col = @intCast(@max(0, t.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, t, new); const cc = modal.positionAt(new, nc); const ac = modal.positionAt(new, na); t.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; t.cur_col = @intCast(cc.col); if (t.vsel.active) { t.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; t.vsel.col = @intCast(ac.col); } t.msel.active = false; t.cur_pinned = true; t.sticky_col = -1; t.show(); } pub fn normalToggleComment(p: *Pardes, t: *Text) void { const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; t.select = false; // helix exit_select_mode // the buffer must cover every cursor's rows, not just the primary's var b = selRows(t); for (t.sels[0..t.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, t) catch return; const last_row = @as(i32, @intCast(panes.File.textLineCount(t, surface))) - 1; const eb = editText(p, t, b.lo_row, @min(b.hi_row + 1, last_row), -1) orelse return; const text = eb.text; var rs: [Text.max_selections]modal.Selection = undefined; const got = t.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 = Text.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 (t.what != .body) "" else if (t.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, t); // one edit, and the WHOLE selection rides through it (helix maps the // selection with the transaction) var cells: [Text.max_selections]Text.SelRange = undefined; const new = applyChanges(p, text, chs[0..chs_len]) catch return; for (rs[0..got.n], 0..) |r, i| { const c = Text.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, t, new); const t2 = flatSurface(p, t) catch return; for (cells[0..got.n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col); t.setRanges(t2, rs[0..got.n], &.{}, got.pri, expl); } pub fn normalAdjustNumber(p: *Pardes, t: *Text, delta: i64) void { const eb = editTextEol(p, t, selRows(t)) orelse return; const r = selRange(t, 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, t); t.select = false; const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, rep) catch return; setEditText(p, t, new); const start = modal.lineStartOffset(new, r.a.row) + r.a.col; const cc = modal.positionAt(new, modal.prevGrapheme(new, start + rep.len)); t.vsel = .{ .active = modal.nextGrapheme(rep, 0) < rep.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; t.msel.active = false; t.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; t.cur_col = @intCast(cc.col); t.cur_pinned = true; t.sticky_col = -1; t.show(); } /// `]Space` / `[Space`: add blank lines below/above, the cursor staying /// on its text line (the selection rides along) pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void { const eb = editText(p, t, t.cur_row, t.cur_row, -1) orelse return; pushUndo(p, t); const row: usize = @intCast(@max(0, t.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, t, new); if (!below) { t.cur_row += @intCast(cnt); // the line moved down; stay on it if (t.vsel.active and t.vsel.row >= @as(i32, @intCast(row))) t.vsel.row += @intCast(cnt); } else if (t.vsel.active and t.vsel.row > @as(i32, @intCast(row))) { t.vsel.row += @intCast(cnt); } t.cur_pinned = true; t.show(); } /// 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, t: *Text, text: []const u8, obj: u21, around: bool) void { const range = t.primaryRange(text, 0); switch (obj) { 'w', 'W' => { const r = modal.selectWord(text, range, around, obj == 'W'); return t.setRange(text, 0, r, false); }, 'p' => { const r = modal.selectParagraph(text, range, around, 1); return t.setRange(text, 0, r, false); }, else => {}, } const lines = paneCursorLines(p, t) 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); } t.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, t: *Text, ch: u21) void { const pr = pairFor(ch) orelse return; t.select = false; const eb = editTextEol(p, t, selRows(t)) orelse return; const r = selRange(t, eb.text, eb.row0); pushUndo(p, t); 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, t, new); new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; setEditText(p, t, new); t.msel.active = false; t.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; t.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0; t.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0)); t.cur_pinned = true; t.sticky_col = -1; t.show(); } /// `md`: delete the enclosing pair's chars; the cursor maps through pub fn surroundDelete(p: *Pardes, t: *Text, ch: u21) void { const pr = pairFor(ch) orelse return; const lines = paneCursorLines(p, t) catch return; const cur = t.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, t, 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, t); 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, t, new); new = modal.deleteChar(p.gpa, new, ra) catch return; setEditText(p, t, new); var nc = cur_off; if (nc > b_off) nc -= 1; if (nc > a_off) nc -= 1; const cc = modal.positionAt(new, nc); t.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; t.cur_col = @intCast(cc.col); t.vsel.active = false; t.msel.active = false; t.cur_pinned = true; t.show(); } /// `mr`: swap the enclosing pair's chars for 's pub fn surroundReplace(p: *Pardes, t: *Text, from: u21, to: u21) void { const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; const lines = paneCursorLines(p, t) catch return; const cur = t.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, t, @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, t); 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, t, out); } pub fn pushUndo(p: *Pardes, t: *Text) void { // one keystroke, one undo step — even when it edited at ten cursors. if (p.multi_on and !p.multi_first) return; switch (t.what) { .body => {}, .tag => return t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null), // a prompt's answer is typed once and gone; it keeps no history .input => return, } const pane = t.pane(); if (pane.file != null) return panes.File.pushUndo(p, pane); panes.Terminal.pushUndo(p, pane); } pub fn doUndo(p: *Pardes, t: *Text) void { if (t.what == .tag) return stepOwn(p, t, true); if (t.what != .body) return; const pane = t.pane(); if (pane.file != null) return panes.File.undo(p, pane); panes.Terminal.undo(p, pane); } pub fn doRedo(p: *Pardes, t: *Text) void { if (t.what == .tag) return stepOwn(p, t, false); if (t.what != .body) return; const pane = t.pane(); if (pane.file != null) return panes.File.redo(p, pane); panes.Terminal.redo(p, pane); } /// Undo or redo an edit of a text's own characters; stepping back past its /// first edit gives a tag its default again. fn stepOwn(p: *Pardes, t: *Text, back: bool) void { const to = t.step(p.gpa, if (t.own) |own| .{ .text = own } else null, back) orelse return; if (t.own) |own| p.gpa.free(own); t.own = if (to.ovl) |buffer| buffer.text else null; } 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 = mouse.bodyHitForPane(p, 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) mouse.tagColumn(p, .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.body.mode = .insert; pane.body.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.body.cur_row); try std.testing.expectEqual(@as(i32, 8), pane.body.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.body.mode = .insert; pane.body.cur_col = 22; // on z, after the CJK grapheme insertKey(p, &pane.body, .{ .cp = Key.left }); try std.testing.expectEqual(@as(i32, 19), pane.body.cur_col); insertKey(p, &pane.body, .{ .cp = Key.left }); try std.testing.expectEqual(@as(i32, 4), pane.body.cur_col); insertKey(p, &pane.body, .{ .cp = Key.right }); try std.testing.expectEqual(@as(i32, 19), pane.body.cur_col); insertKey(p, &pane.body, .{ .cp = Key.backspace }); try std.testing.expectEqualStrings("ae\u{301}界z", pane.file.?.content); try std.testing.expectEqual(@as(i32, 4), pane.body.cur_col); pane.body.mode = .normal; pane.body.cur_col = 1; normalDelete(p, &pane.body, false); try std.testing.expectEqualStrings("a界z", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); normalReplaceChar(p, &pane.body, 'λ'); try std.testing.expectEqualStrings("aλz", pane.file.?.content); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.body.cur_col = 3; pane.body.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; normalReplaceChar(p, &pane.body, 'λ'); try std.testing.expectEqualStrings("λλ", pane.file.?.content); try std.testing.expectEqual(@as(i32, 2), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.body.vsel.col); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.body.cur_col = 0; pane.body.vsel = .{ .active = true, .row = 0, .col = 3, .explicit = true }; normalReplaceChar(p, &pane.body, 'λ'); try std.testing.expectEqualStrings("λλ", pane.file.?.content); try std.testing.expectEqual(@as(i32, 0), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 2), pane.body.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.body.mode = .normal; pane.body.cur_pinned = true; pane.body.cur_row = 3; pane.body.cur_col = 0; pane.body.vsel = .{ .active = true, .row = 3, .col = 3, .explicit = true }; normalReplaceChar(p, &pane.body, 'λ'); 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.body.cur_row); try std.testing.expectEqual(@as(i32, 0), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 3), pane.body.vsel.row); try std.testing.expectEqual(@as(i32, 2), pane.body.vsel.col); try std.testing.expect(pane.body.vsel.active and pane.body.vsel.explicit); const lines = try paneCursorLines(p, &pane.body); try std.testing.expectEqualStrings("first", lines[0]); try std.testing.expectEqualStrings("λλ", lines[3]); const flat = try flatSurface(p, &pane.body); 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); }