summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/body_layer.zig5
-rw-r--r--src/builtins.zig11
-rw-r--r--src/edit.zig1692
-rw-r--r--src/fs.zig1
-rw-r--r--src/pardes.zig1874
-rw-r--r--src/selection_pipe.zig3
-rw-r--r--test/hxdiff.zig2
-rw-r--r--test/lspbench.zig2
-rw-r--r--test/perf.zig2
9 files changed, 1812 insertions, 1780 deletions
diff --git a/src/body_layer.zig b/src/body_layer.zig
index d7ddbfb5..8c345b97 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -5,6 +5,7 @@
//! and cursor.
const std = @import("std");
const pardes = @import("pardes.zig");
+const edit = @import("edit.zig");
const config = @import("config.zig");
const limits = @import("memory.zig").limits;
const layout = @import("layout.zig");
@@ -180,7 +181,7 @@ fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.Poi
var visible: i32 = 0;
while (visible < @as(i32, r.h -| BOX_H)) : (visible += 1) {
const source = pane.wrapAt(visible);
- const line = Pardes.pointerSourceLine(pane, terminal_lines, source.line);
+ const line = edit.pointerSourceLine(pane, terminal_lines, source.line);
const start = @min(line.len, @as(usize, @intCast(@max(0, source.at))));
const next = pane.wrapAt(visible + 1);
const end = if (next.line == source.line) @min(line.len, @as(usize, @intCast(@max(0, next.at)))) else line.len;
@@ -401,7 +402,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
.arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row,
.acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col,
} else pane.sels[si];
- const bnd = Pardes.cellBounds(sr);
+ const bnd = edit.cellBounds(sr);
const hover_only = primary and modal_hover and pane.mode != .normal and !preview;
const bg = if (hover_only)
colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg))
diff --git a/src/builtins.zig b/src/builtins.zig
index ad2c5f6a..87679f64 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -1,6 +1,7 @@
//! Command structs and runtime settings form the builtin registry; bindings live in config.zig.
const std = @import("std");
const pardes = @import("pardes.zig");
+const edit = @import("edit.zig");
const colors = @import("colors.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
@@ -431,7 +432,7 @@ pub const Ascii = struct {
pub const ClipYank = struct {
pub fn run(c: Ctx) void {
- c.p.clipYank(c.pane, false);
+ edit.clipYank(c.p, c.pane, false);
}
};
@@ -439,25 +440,25 @@ pub const ClipYank = struct {
/// cursor's. One cursor makes them the same word.
pub const ClipYankMain = struct {
pub fn run(c: Ctx) void {
- c.p.clipYank(c.pane, true);
+ edit.clipYank(c.p, c.pane, true);
}
};
pub const ClipPaste = struct {
pub fn run(c: Ctx) void {
- c.p.clipRequest(c.id, .after);
+ edit.clipRequest(c.p, c.id, .after);
}
};
pub const ClipPasteBefore = struct {
pub fn run(c: Ctx) void {
- c.p.clipRequest(c.id, .before);
+ edit.clipRequest(c.p, c.id, .before);
}
};
pub const ClipReplace = struct {
pub fn run(c: Ctx) void {
- c.p.clipRequest(c.id, .replace);
+ edit.clipRequest(c.p, c.id, .replace);
}
};
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);
+}
diff --git a/src/fs.zig b/src/fs.zig
index 4d8a5188..04a8ed36 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -40,6 +40,7 @@ const source_files = [_]Source{
.{ .path = "src/colors.zig", .contents = @embedFile("colors.zig") },
.{ .path = "src/Messages.zig", .contents = @embedFile("Messages.zig") },
.{ .path = "src/body_layer.zig", .contents = @embedFile("body_layer.zig") },
+ .{ .path = "src/edit.zig", .contents = @embedFile("edit.zig") },
.{ .path = "src/File.zig", .contents = @embedFile("File.zig") },
.{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") },
.{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
diff --git a/src/pardes.zig b/src/pardes.zig
index fe96a221..55e1dc7a 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -11,6 +11,7 @@ pub const syntax = @import("syntax.zig");
pub const locations_config = @import("locations_config.zig");
const tracy = @import("tracy.zig");
pub const panes = @import("panes.zig");
+pub const edit = @import("edit.zig");
pub const body_layer = @import("body_layer.zig");
pub const Messages = @import("Messages.zig");
pub const colors = @import("colors.zig");
@@ -432,6 +433,7 @@ pub const Loc = struct {
};
test {
+ _ = @import("edit.zig");
_ = @import("Messages.zig");
_ = @import("selection_pipe.zig");
_ = @import("colors.zig");
@@ -664,22 +666,6 @@ test "the acme chords act once per selection, not once on the primary" {
try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.nsel);
}
-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 "startup config runs builtin lines in order and isolates bad lines" {
const p = try Pardes.init(std.testing.allocator, .{
.startup_config =
@@ -2702,49 +2688,6 @@ test "every line of the board's boot buffer renders whole" {
// ---- the boundary types ----
-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
- p.insertKey(pane, .{ .cp = Key.left });
- try std.testing.expectEqual(@as(i32, 19), pane.cur_col);
- p.insertKey(pane, .{ .cp = Key.left });
- try std.testing.expectEqual(@as(i32, 4), pane.cur_col);
- p.insertKey(pane, .{ .cp = Key.right });
- try std.testing.expectEqual(@as(i32, 19), pane.cur_col);
- p.insertKey(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;
- p.normalDelete(pane, false);
- try std.testing.expectEqualStrings("a界z", pane.file.?.content);
- try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
- p.normalReplaceChar(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 };
- p.normalReplaceChar(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 };
- p.normalReplaceChar(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 movement and selection replay need no scratch rows" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 });
@@ -2796,31 +2739,6 @@ test "flat text movement and selection replay need no scratch rows" {
try std.testing.expectEqual(@as(i32, 8), pane.cur_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 };
- p.normalReplaceChar(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 p.paneCursorLines(pane);
- try std.testing.expectEqualStrings("first", lines[0]);
- try std.testing.expectEqualStrings("λλ", lines[3]);
- const flat = try p.flatSurface(pane);
- try std.testing.expect(std.mem.startsWith(u8, flat, "first\nsecond\nthird\nλλ\n"));
-}
-
test "Unicode display cells map back to body and tag byte cursors" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
@@ -3343,7 +3261,7 @@ pub const Key = struct {
pub const delete: u21 = 0xF0009;
};
-fn hit(key: Key, binding: []const config.Chord) bool {
+pub fn hit(key: Key, binding: []const config.Chord) bool {
for (binding) |c| {
if (key.cp != c.cp or key.ctrl != c.ctrl or key.alt != c.alt) continue;
if (c.shift and !key.shift) continue;
@@ -3352,7 +3270,7 @@ fn hit(key: Key, binding: []const config.Chord) bool {
return false;
}
-const sel_slot = @intFromEnum(config.select_button);
+pub const sel_slot = @intFromEnum(config.select_button);
fn isPrefix(key: Key, cp: u21) bool {
return key.cp == cp and !key.ctrl and !key.alt;
@@ -4777,7 +4695,7 @@ pub const Pardes = struct {
in_len: usize = 0,
yank: ?[]u8 = null,
- clip_pending: ?ClipRequest = null,
+ clip_pending: ?edit.ClipRequest = null,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
/// where the shell wrote the last dump (shell reports back after
@@ -5031,7 +4949,7 @@ pub const Pardes = struct {
}
}
- fn headerReplace(p: *Pardes, bytes: []const u8) void {
+ pub fn headerReplace(p: *Pardes, bytes: []const u8) void {
const cur = p.topbar_col orelse return;
const anchor = p.header_anchor orelse cur;
const lo = @min(cur, anchor);
@@ -5740,7 +5658,7 @@ pub const Pardes = struct {
}
}
},
- .paste => |bytes| p.applyPaste(bytes),
+ .paste => |bytes| edit.applyPaste(p, bytes),
.command => |line| _ = p.executeBuiltinLine(p.active, line),
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
@@ -5776,97 +5694,6 @@ pub const Pardes = struct {
}
}
- 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;
- }
-
- fn setClipboard(p: *Pardes, text: []const u8) void {
- p.setYank(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);
- }
-
- 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(tagSelBounds(pane)) = if (pane.tag_sel) 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 (selRegexArmed(pane)) |a| p.applySelRegex(pane, a.pat, a.split);
- return;
- }
- if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes);
- switch (if (req) |r| r.mode else .after) {
- .after => p.pasteText(pane, bytes, false, 1),
- .before => p.pasteText(pane, bytes, true, 1),
- .replace => p.replaceWithText(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) p.setClipboard(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;
- p.normalYank(pane);
- pane.nsel = others;
- }
- p.emit(.{ .set_clipboard = {} });
- }
-
fn multiOnce(p: *Pardes) bool {
if (!p.multi_on) return true;
p.multi_stop = true;
@@ -6004,7 +5831,7 @@ pub const Pardes = struct {
pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed
}
- fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } {
+ pub fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } {
const a: usize = pane.tag_anchor;
const c: usize = pane.tag_col;
return .{ .lo = @min(a, c), .hi = @max(a, c) };
@@ -6060,7 +5887,7 @@ pub const Pardes = struct {
/// Replace one range in the rendered tag. The file name and command tail
/// retain separate identities even when a selection crosses their boundary.
- fn replaceTagText(p: *Pardes, pane: *Pane, start: usize, finish: usize, input: []const u8) bool {
+ pub fn replaceTagText(p: *Pardes, pane: *Pane, start: usize, finish: usize, input: []const u8) bool {
const prefix_len = (p.tagPrefix(pane) catch return false).len;
const end = prefix_len + pane.tag_tail_len;
const lo = @min(start, end);
@@ -6183,7 +6010,7 @@ pub const Pardes = struct {
return;
}
if (hit(key, config.tag_yank)) {
- if (p.tagChordText(pane)) |txt| p.setClipboard(txt);
+ if (p.tagChordText(pane)) |txt| edit.setClipboard(p, txt);
return;
}
if (hit(key, config.insert) or hit(key, config.append) or
@@ -6314,7 +6141,7 @@ pub const Pardes = struct {
if (key.ctrl and (key.cp == 'c' or key.cp == 'x')) {
const anchor = p.header_anchor orelse @as(u16, @intCast(cur));
if (anchor != cur) {
- p.setClipboard(bar[@min(anchor, cur)..@max(anchor, cur)]);
+ edit.setClipboard(p, bar[@min(anchor, cur)..@max(anchor, cur)]);
if (key.cp == 'x') p.headerReplace("");
}
return;
@@ -6388,140 +6215,6 @@ pub const Pardes = struct {
if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len)));
}
- /// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
- 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 "";
- }
-
- 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 p.selectionText(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.
- 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 p.paneText(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;
- }
-
- 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));
- }
-
const ExpandedWord = struct { lo: usize, hi: usize };
fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
@@ -6538,7 +6231,7 @@ pub const Pardes = struct {
fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
var sl = at;
if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl;
- const text = p.paneText(pane) catch return null;
+ const text = edit.paneText(p, pane) catch return null;
var it = std.mem.splitAny(u8, text, "\n");
var v: i32 = 0;
while (it.next()) |line| : (v += 1) {
@@ -6561,7 +6254,7 @@ pub const Pardes = struct {
const vcol = if (pane.file != null)
panes.File.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, panes.File.gutterWidth(pane))
else blk: {
- const lines = p.paneCursorLines(pane) catch break :blk pane.cur_col;
+ const lines = edit.paneCursorLines(p, pane) catch break :blk pane.cur_col;
const local = pane.cur_row;
if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.cur_col;
break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col)));
@@ -6569,18 +6262,9 @@ pub const Pardes = struct {
return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
}
- /// the active modal selection as text (v range or x lines), if any
- 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 p.vselText(pane);
- if (!pane.msel.active) return null;
- return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1));
- }
-
fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool {
if (pane.nsel == 0) return false;
- const text = p.flatSurface(pane) catch return false;
+ const text = edit.flatSurface(p, pane) catch return false;
var ranges: [Pane.max_selections]modal.Selection = undefined;
const got = pane.ranges(text, 0, &ranges);
if (got.n < 2) return false;
@@ -6643,7 +6327,8 @@ pub const Pardes = struct {
const col = if (raw)
@as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0)))))
else if (clicked.r0 >= BOX_H)
- p.paneByteAtDisplay(
+ edit.paneByteAtDisplay(
+ p,
pane,
wrapped.line,
wrapped.at,
@@ -6677,22 +6362,22 @@ pub const Pardes = struct {
break :inside true;
}
break :inside false;
- } else spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1);
+ } else edit.spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1);
if (kept.state == .done and inside_kept) {
- result.text = p.selectionText(pane, kept) catch null;
+ result.text = edit.selectionText(p, pane, kept) catch null;
result.preview = kept;
return result;
}
if (pane.vsel.active and pane.vsel.explicit and
- spanHas(row, col, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col))
+ edit.spanHas(row, col, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col))
{
- result.text = p.currentSelText(pane);
+ result.text = edit.currentSelText(p, pane);
return result;
}
if (pane.msel.active and row >= @min(pane.msel.r0, pane.msel.r1) and
row <= @max(pane.msel.r0, pane.msel.r1))
{
- result.text = p.currentSelText(pane);
+ result.text = edit.currentSelText(p, pane);
return result;
}
if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) {
@@ -6719,7 +6404,7 @@ pub const Pardes = struct {
}
const expanded = p.expandedSel(pane, clicked) orelse return result;
- result.text = p.selectionText(pane, expanded) catch null;
+ result.text = edit.selectionText(p, pane, expanded) catch null;
if (result.text == null or result.text.?.len == 0) return result;
result.preview = expanded;
result.expanded = expanded;
@@ -6741,9 +6426,9 @@ pub const Pardes = struct {
return pv.selection_text;
};
if (t.sel[sel_slot].state == .done) {
- if (p.selectionText(t, t.sel[sel_slot]) catch null) |tx| return tx;
+ if (edit.selectionText(p, t, t.sel[sel_slot]) catch null) |tx| return tx;
} else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) {
- if (p.currentSelText(t)) |tx| return tx;
+ if (edit.currentSelText(p, t)) |tx| return tx;
}
}
return null;
@@ -6770,42 +6455,14 @@ pub const Pardes = struct {
pane.mode = .normal;
pane.normal.clear();
},
- .normal => p.enterInsert(pane, .at, 1),
+ .normal => edit.enterInsert(p, pane, .at, 1),
.insert => {
- p.exitInsert(pane);
+ edit.exitInsert(p, pane);
if (pane.isTerminal()) panes.Terminal.enterTty(p, id);
},
}
}
- 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 = p.flatSurface(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;
- }
- }
- }
-
/// One mode transition for the terminal tag command and the Ctrl-B chord.
pub fn toggleTty(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
@@ -6851,12 +6508,12 @@ pub const Pardes = struct {
// account instead — a coding agent goes there hunting for an image.
// A host that folds Shift into the letter says the same thing.
if (key.ctrl and !key.alt and (key.cp == 'v' or key.cp == 'V')) {
- if (key.shift or key.cp == 'V') return p.clipRequest(p.active, .after);
+ if (key.shift or key.cp == 'V') return edit.clipRequest(p, p.active, .after);
// With something in the register this is a paste. With nothing
// in it the chord is the program's -- vim's visual block,
// readline's quoted-insert -- and swallowing it would make
// Ctrl-V a black hole in every full-screen application.
- if (p.yank) |text| return p.typeToTty(p.active, pane, text);
+ if (p.yank) |text| return edit.typeToTty(p, p.active, pane, text);
}
return panes.Terminal.forwardKey(p, p.active, key);
}
@@ -6935,12 +6592,12 @@ pub const Pardes = struct {
}
if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd;
- p.pinPaneCursor(pane);
+ edit.pinPaneCursor(p, pane);
const explicit = (p.native_images and pane.hasPdfSelection()) or
(pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
if (explicit) {
if (p.chordEachSel(pane, cmd)) return;
- if (p.currentSelText(pane)) |txt| {
+ if (edit.currentSelText(p, pane)) |txt| {
pane.vsel.active = false;
pane.msel.active = false;
pane.select = false;
@@ -6949,7 +6606,7 @@ pub const Pardes = struct {
}
}
const sel = p.expandedSel(pane, p.cursorWordSel(pane)) orelse return;
- const word = p.selectionText(pane, sel) catch return;
+ const word = edit.selectionText(p, pane, sel) catch return;
p.runBuiltin(cmd, p.active, "", word);
return;
}
@@ -6959,8 +6616,8 @@ pub const Pardes = struct {
p.handleNormal(pane, key);
},
.insert => {
- if (hit(key, config.escape)) return p.exitInsert(pane);
- p.handleInsert(pane, key);
+ if (hit(key, config.escape)) return edit.exitInsert(p, pane);
+ edit.handleInsert(p, pane, key);
},
.tty => panes.Terminal.forwardKey(p, p.active, key),
}
@@ -6994,51 +6651,8 @@ pub const Pardes = struct {
// ---- helix-modal normal mode ----
- 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);
- }
-
- 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 = p.paneCursorLines(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.
- fn pinPaneCursor(p: *Pardes, pane: *Pane) void {
- if (pane.cur_pinned) return;
- pane.pinCursor();
- if (pane.file == null and !pane.hasPdf())
- pane.cur_col = p.paneByteAtDisplay(pane, pane.cur_row, 0, pane.cur_col);
- }
-
- 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);
- }
-
fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void {
- const text = p.flatSurface(pane) catch return;
+ const text = edit.flatSurface(p, pane) catch return;
const got = pane.ranges(text, 0, &pane.sel_snap);
pane.nsel_snap = @intCast(got.n);
pane.sel_snap_pri = @intCast(got.pri);
@@ -7060,8 +6674,8 @@ pub const Pardes = struct {
return .{ .pat = armed[slash + 1 ..], .split = split };
}
- fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void {
- const text = p.flatSurface(pane) catch return;
+ pub fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void {
+ const text = edit.flatSurface(p, pane) catch return;
const snap = pane.sel_snap[0..pane.nsel_snap];
var out: [Pane.max_selections]modal.Selection = undefined;
var m: usize = 0;
@@ -7147,7 +6761,7 @@ pub const Pardes = struct {
insert: Key,
};
- fn replaySels(p: *Pardes, pane: *Pane, what: Replay) void {
+ pub fn replaySels(p: *Pardes, pane: *Pane, what: Replay) void {
const id = p.active;
const serial = pane.serial;
// the whole selection in pane coordinates, document order. pane.sels
@@ -7210,14 +6824,14 @@ pub const Pardes = struct {
pane.sticky_col = list[i].sticky;
switch (what) {
.normal => |normal_action| p.executeNormalAction(pane, normal_action),
- .insert => |insert_key| p.insertKey(pane, insert_key),
+ .insert => |insert_key| edit.insertKey(p, pane, insert_key),
}
if (p.multi_stop) break;
if (i == pri) {
after = KeyState.of(pane);
after_expl = pane.vsel.explicit;
}
- const text = p.flatSurface(pane) catch {
+ const text = edit.flatSurface(p, pane) catch {
p.multi_stop = true;
break;
};
@@ -7236,7 +6850,7 @@ pub const Pardes = struct {
if (pn.serial == serial) pn.nsel = 0;
return;
}
- const text = p.flatSurface(pane) catch return;
+ const text = edit.flatSurface(p, pane) catch return;
var rs: [Pane.max_selections]modal.Selection = undefined;
var st: [Pane.max_selections]i32 = undefined;
for (res[0..n], 0..) |r, k| {
@@ -7248,11 +6862,6 @@ pub const Pardes = struct {
pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass
}
- fn paneWrapWidth(p: *const Pardes, pane: *const Pane) usize {
- if (pane.file == null) return 0;
- return panes.File.wrapWidth(pane, p.settings.wrap);
- }
-
fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void {
const parsed = modal.Normal.parse(&pane.normal, normalInput(key));
const semantic = switch (parsed) {
@@ -7271,9 +6880,9 @@ pub const Pardes = struct {
pane.clearPointerSelection(sel_slot);
pane.sel[sel_slot].state = .none;
};
- if (raw_selection and semantic == .edit and semantic.edit.kind == .yank) return p.normalYank(pane);
- p.pinPaneCursor(pane);
- const text = p.flatSurface(pane) catch return;
+ if (raw_selection and semantic == .edit and semantic.edit.kind == .yank) return edit.normalYank(p, pane);
+ edit.pinPaneCursor(p, pane);
+ const text = edit.flatSurface(p, pane) catch return;
const range = pane.primaryRange(text, 0);
const cur = modal.selectionCursor(text, range);
@@ -7302,8 +6911,8 @@ pub const Pardes = struct {
if (nw == slice.len) return;
return pane.pointMove(text, range, ls + nw);
},
- .line_down => return pane.visualMove(text, range, true, go.count, p.paneWrapWidth(pane)),
- .line_up => return pane.visualMove(text, range, false, go.count, p.paneWrapWidth(pane)),
+ .line_down => return pane.visualMove(text, range, true, go.count, edit.paneWrapWidth(p, pane)),
+ .line_up => return pane.visualMove(text, range, false, go.count, edit.paneWrapWidth(p, pane)),
.column => {
const line = modal.lineAtOffset(text, cur);
const ls = modal.lineStartOffset(text, line);
@@ -7348,21 +6957,21 @@ pub const Pardes = struct {
find.count,
);
},
- .replace_char => |char| return p.normalReplaceChar(pane, char),
+ .replace_char => |char| return edit.normalReplaceChar(p, pane, char),
.match_bracket => {
- const lines = p.paneCursorLines(pane) catch return;
+ const lines = edit.paneCursorLines(p, pane) catch return;
const mc = modal.matchBracket(lines, modal.positionAt(text, cur)) orelse return;
return pane.pointMove(text, range, modal.offsetAt(text, mc));
},
- .textobject => |object| return p.textobjectSelect(pane, text, object.char, object.around),
- .surround_add => |char| return p.surroundAdd(pane, char),
- .surround_delete => |char| return p.surroundDelete(pane, char),
- .surround_replace => |replace| return p.surroundReplace(pane, replace.from, replace.to),
+ .textobject => |object| return edit.textobjectSelect(p, pane, text, object.char, object.around),
+ .surround_add => |char| return edit.surroundAdd(p, pane, char),
+ .surround_delete => |char| return edit.surroundDelete(p, pane, char),
+ .surround_replace => |replace| return edit.surroundReplace(p, pane, replace.from, replace.to),
.paragraph => |paragraph| {
const r2 = modal.moveParagraph(text, range, paragraph.count, paragraph.direction == .forward, pane.select);
return pane.setRange(text, 0, r2, false);
},
- .add_newline => |newline| return p.addNewline(pane, newline.direction == .forward, newline.count),
+ .add_newline => |newline| return edit.addNewline(p, pane, newline.direction == .forward, newline.count),
.diagnostic => |diagnostic| {
const fwd = diagnostic.direction == .forward;
if (!diagnostic.endpoint) {
@@ -7450,7 +7059,7 @@ pub const Pardes = struct {
.up => return pane.scrollViewMove(text, range, -@as(i32, pane.rows)),
},
.insert => |insert| {
- const where: InsertAt = switch (insert.kind) {
+ const where: edit.InsertAt = switch (insert.kind) {
.at => .at,
.append => .append,
.line_start => .line_start,
@@ -7458,7 +7067,7 @@ pub const Pardes = struct {
.open_below => .open_below,
.open_above => .open_above,
};
- return p.enterInsert(pane, where, insert.count);
+ return edit.enterInsert(p, pane, where, insert.count);
},
.select => |select| switch (select.kind) {
.mode => {
@@ -7494,23 +7103,23 @@ pub const Pardes = struct {
return pane.multiSelAction(text, multi.kind, multi.count);
},
.select_regex => |split| return p.startSelRegex(pane, split),
- .edit => |edit| switch (edit.kind) {
- .delete => return p.normalDelete(pane, true),
- .delete_noyank => return p.normalDelete(pane, false),
- .change => return p.normalChange(pane),
- .yank => return p.normalYank(pane),
- .replace_with_yank => return p.normalReplaceYank(pane),
- .paste_after => return p.pasteText(pane, p.yank orelse return, false, edit.count),
- .paste_before => return p.pasteText(pane, p.yank orelse return, true, edit.count),
- .switch_case => return p.normalCase(pane, .toggle),
- .lowercase => return p.normalCase(pane, .lower),
- .uppercase => return p.normalCase(pane, .upper),
- .join_lines => return p.normalJoin(pane),
- .indent => return p.normalIndent(pane, edit.count, true),
- .unindent => return p.normalIndent(pane, edit.count, false),
- .comment_toggle => return p.normalToggleComment(pane),
- .undo => return p.doUndo(pane),
- .redo => return p.doRedo(pane),
+ .edit => |op| switch (op.kind) {
+ .delete => return edit.normalDelete(p, pane, true),
+ .delete_noyank => return edit.normalDelete(p, pane, false),
+ .change => return edit.normalChange(p, pane),
+ .yank => return edit.normalYank(p, pane),
+ .replace_with_yank => return edit.normalReplaceYank(p, pane),
+ .paste_after => return edit.pasteText(p, pane, p.yank orelse return, false, op.count),
+ .paste_before => return edit.pasteText(p, pane, p.yank orelse return, true, op.count),
+ .switch_case => return edit.normalCase(p, pane, .toggle),
+ .lowercase => return edit.normalCase(p, pane, .lower),
+ .uppercase => return edit.normalCase(p, pane, .upper),
+ .join_lines => return edit.normalJoin(p, pane),
+ .indent => return edit.normalIndent(p, pane, op.count, true),
+ .unindent => return edit.normalIndent(p, pane, op.count, false),
+ .comment_toggle => return edit.normalToggleComment(p, pane),
+ .undo => return edit.doUndo(p, pane),
+ .redo => return edit.doRedo(p, pane),
},
.lsp => |request| return p.lspRequest(p.active, switch (request) {
.definition => .definition,
@@ -7520,7 +7129,7 @@ pub const Pardes = struct {
.references => .references,
.format => .format,
}, ""),
- .adjust_number => |delta| return p.normalAdjustNumber(pane, delta),
+ .adjust_number => |delta| return edit.normalAdjustNumber(p, pane, delta),
.leader => {
p.leader_on = true;
p.leader_n = 0;
@@ -7693,7 +7302,7 @@ pub const Pardes = struct {
p.active = rid;
p.lookWalk(1);
if (p.active != rid) return; // walked out of the list; leave it there
- const txt = p.currentSelText(rp) orelse return;
+ const txt = edit.currentSelText(p, rp) orelse return;
// spent, exactly as the look chord spends it (handleKey)
rp.vsel.active = false;
rp.msel.active = false;
@@ -7756,7 +7365,7 @@ pub const Pardes = struct {
anchor = found.anchor;
}
} else {
- const lines = try p.paneCursorLines(pane);
+ const lines = try edit.paneCursorLines(p, pane);
// a real file names itself; a terminal or an output buffer has no path
const has_path = if (pane.file) |f| f.output == null else false;
var idbuf: [16]u8 = undefined;
@@ -8012,7 +7621,7 @@ pub const Pardes = struct {
}
@memcpy(replacement[write_at..], f.content[read_at..]);
- p.pushUndo(pane);
+ edit.pushUndo(p, pane);
panes.File.setContent(p, f, replacement);
const cursor = modal.positionAt(f.content, mapped_cursor);
pane.cur_row = @intCast(cursor.row);
@@ -8050,7 +7659,7 @@ pub const Pardes = struct {
}
if (rows.len == 0) {
if (w.kind == .completion and pane.mode == .insert and pane.nsel == 0 and
- pane.cur_row == w.row and pane.cur_col == w.col) p.insertTab(pane);
+ pane.cur_row == w.row and pane.cur_col == w.col) edit.insertTab(p, pane);
return;
}
@@ -8143,7 +7752,7 @@ pub const Pardes = struct {
}
fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?Pane.LookSpot {
- const lines = p.paneCursorLines(pane) catch return null;
+ const lines = edit.paneCursorLines(p, pane) catch return null;
const nrows: i32 = @intCast(lines.len);
if (nrows == 0) return null;
const grain: panes.Output.Grain = if (pane.file) |*f| panes.Output.grain(f.output) else .word;
@@ -8219,7 +7828,7 @@ pub const Pardes = struct {
}
fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: Pane.LookSpot) void {
- p.pinPaneCursor(pane); // fresh out of tty mode the cursor still tracks the shell
+ edit.pinPaneCursor(p, pane); // fresh out of tty mode the cursor still tracks the shell
pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true };
pane.msel.active = false;
pane.nsel = 0;
@@ -8232,1190 +7841,10 @@ pub const Pardes = struct {
pane.ensureCursorVisible();
}
- /// 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 = p.editText(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 p.editText(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);
- }
-
- 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.
- 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;
- }
-
- fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void {
- if (pane.hasPdf()) return;
- p.pinPaneCursor(pane);
- _ = clampFileCursor(pane);
- // snapshot once per insert session (WITH the pre-insert selection) so
- // `u` undoes the whole session and restores what was selected
- p.pushUndo(pane);
- pane.select = false;
- pane.append_at = null;
- pane.sticky_col = -1;
- const text = p.flatSurface(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 = p.editText(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;
- p.setEditText(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 = p.flatSurface(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();
- }
-
- fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void {
- if (pane.nsel == 0) return p.insertKey(pane, key);
- p.replaySels(pane, .{ .insert = key });
- }
-
- 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 p.insertKey(pane, .{ .cp = Key.backspace });
- if (hit(key, config.insert_enter_alias)) return p.insertKey(pane, .{ .cp = Key.enter });
- if (hit(key, config.insert_delete_alias)) return p.insertKey(pane, .{ .cp = Key.delete });
- pane.vsel.active = false;
- if (!pane.cur_pinned) p.pinPaneCursor(pane);
- _ = clampFileCursor(pane);
- switch (key.cp) {
- Key.left, Key.right, Key.up, Key.down => {
- const lines = p.paneCursorLines(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 = p.flatSurface(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 = p.flatSurface(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 = p.editText(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;
- p.setEditText(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)
- (p.editText(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;
- p.setEditText(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))
- (p.editText(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;
- p.setEditText(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;
- p.setEditText(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;
- p.setEditText(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;
- p.setEditText(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;
- p.setEditText(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 = p.editText(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;
- p.setEditText(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;
- p.setEditText(pane, new);
- } else {
- const e2 = p.editText(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;
- p.setEditText(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;
- }
- p.insertTab(pane);
- },
- else => {},
- }
- }
-
- fn insertTab(p: *Pardes, pane: *Pane) void {
- const eb = p.editText(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;
- p.setEditText(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 = p.flatSurface(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 = p.paneCursorLines(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;
- }
-
// ---- edit operations (d / c / y / p) ----
- 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) p.setYank(p.vselText(pane)); // read the range before the move
- p.pushUndo(pane);
- const eb = p.editTextEol(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;
- };
- p.setEditText(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);
- p.pushUndo(pane);
- // linewise: a line's newline lives on the row below it
- const eb = p.editText(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;
- };
- p.setEditText(pane, d.content);
- if (yank) p.setYank(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 {
- p.pushUndo(pane);
- const eb = p.editTextEol(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();
- p.setYank(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;
- p.setEditText(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)));
- }
- }
-
- fn normalYank(p: *Pardes, pane: *Pane) void {
- pane.select = false;
- if (pane.rawPointerText(pane.sel[sel_slot])) |text| {
- p.setYank(text);
- return;
- }
- if (pane.vsel.active) {
- p.setYank(p.vselText(pane));
- return;
- }
- if (pane.msel.active) {
- p.setYank(p.yankRows(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 };
- p.setYank(p.vselText(pane));
- pane.vsel = save;
- }
- }
-
- fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool, count: usize) void {
- if (y0.len == 0) return;
- p.pushUndo(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 = p.editText(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;
- }
- p.setEditText(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;
- p.setEditText(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();
- }
-
- fn normalChange(p: *Pardes, pane: *Pane) void {
- pane.select = false;
- pane.sticky_col = -1;
- if (pane.vsel.active) {
- const b = vselBounds(pane);
- const eb = p.editTextEol(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) {
- p.setYank(p.vselText(pane));
- p.pushUndo(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;
- p.setEditText(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;
- p.setEditText(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;
- }
- p.setYank(p.vselText(pane));
- p.pushUndo(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;
- };
- p.setEditText(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);
- p.pushUndo(pane);
- const eb = p.editText(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;
- }
- p.setYank(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;
- };
- p.setEditText(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;
- };
- p.setEditText(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 {
- p.pushUndo(pane);
- const eb = p.editTextEol(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();
- p.setYank(modal.rangeText(arena, eb.text, c, c) catch return);
- const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return;
- p.setEditText(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
- fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u21) void {
- pane.select = false;
- const eb = p.editTextEol(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);
- p.pushUndo(pane);
- const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return;
- p.setEditText(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.
- fn normalReplaceYank(p: *Pardes, pane: *Pane) void {
- p.replaceWithText(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 = p.editTextEol(pane, selRows(pane)) orelse return;
- const r = selRange(pane, eb.text, eb.row0);
- p.pushUndo(pane);
- const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return;
- p.setEditText(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)
- fn normalCase(p: *Pardes, pane: *Pane, op: modal.CaseOp) void {
- pane.select = false;
- const eb = p.editTextEol(pane, selRows(pane)) orelse return;
- const r = selRange(pane, eb.text, eb.row0);
- p.pushUndo(pane);
- const new = modal.changeCase(p.gpa, eb.text, r.a, r.b, op) catch return;
- p.setEditText(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;
- }
-
- 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 = p.editText(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;
- p.pushUndo(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 = p.applyChanges(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);
- p.setEditText(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();
- }
-
- fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void {
- pane.select = false;
- const sr = selRows(pane);
- const eb = p.editText(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;
- p.pushUndo(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 = p.applyChanges(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);
- p.setEditText(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();
- }
-
- 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 = p.flatSurface(pane) catch return;
- const last_row = @as(i32, @intCast(panes.File.textLineCount(pane, surface))) - 1;
- const eb = p.editText(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;
- }
- p.pushUndo(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 = p.applyChanges(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),
- };
- }
- p.setEditText(pane, new);
- const t2 = p.flatSurface(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);
- }
-
- fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void {
- const eb = p.editTextEol(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;
- p.pushUndo(pane);
- pane.select = false;
- const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, rep) catch return;
- p.setEditText(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)
- fn addNewline(p: *Pardes, pane: *Pane, below: bool, cnt: usize) void {
- const eb = p.editText(pane, pane.cur_row, pane.cur_row, -1) orelse return;
- p.pushUndo(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;
- p.setEditText(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,
- };
- }
-
- 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 = p.paneCursorLines(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
- fn surroundAdd(p: *Pardes, pane: *Pane, ch: u21) void {
- const pr = pairFor(ch) orelse return;
- pane.select = false;
- const eb = p.editTextEol(pane, selRows(pane)) orelse return;
- const r = selRange(pane, eb.text, eb.row0);
- p.pushUndo(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;
- p.setEditText(pane, new);
- new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return;
- p.setEditText(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
- fn surroundDelete(p: *Pardes, pane: *Pane, ch: u21) void {
- const pr = pairFor(ch) orelse return;
- const lines = p.paneCursorLines(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 = p.editText(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 };
- p.pushUndo(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;
- p.setEditText(pane, new);
- new = modal.deleteChar(p.gpa, new, ra) catch return;
- p.setEditText(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
- 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 = p.paneCursorLines(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 = p.editText(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);
- p.pushUndo(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;
- p.setEditText(pane, out);
- }
-
// ---- dumb undo/redo: whole-state snapshots, one per edit op ----
- 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);
- }
-
- fn doUndo(p: *Pardes, pane: *Pane) void {
- if (pane.file != null) return panes.File.undo(p, pane);
- panes.Terminal.undo(p, pane);
- }
-
- fn doRedo(p: *Pardes, pane: *Pane) void {
- if (pane.file != null) return panes.File.redo(p, pane);
- panes.Terminal.redo(p, pane);
- }
-
pub const ChromeTarget = struct { col: u16, row: u16 };
pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget {
@@ -9470,12 +7899,7 @@ pub const Pardes = struct {
return null;
}
- const PointerTextSelection = struct {
- sel: Pane.Sel,
- on_tag: bool,
- };
-
- fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clamp: bool) ?Mouse.BodyHit {
+ pub fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clamp: bool) ?Mouse.BodyHit {
const pane = p.panes[id] orelse return null;
if (pane.file == null or (!clamp and supplied.serial != pane.serial)) return null;
if (!std.math.isFinite(supplied.pixel_x) or !std.math.isFinite(supplied.pixel_y) or
@@ -9499,35 +7923,6 @@ pub const Pardes = struct {
return p.reprojectBodyHit(mapped_hit);
}
- 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,
- };
- }
-
fn cancelLookHover(p: *Pardes) void {
if (comptime pdf_enabled) if (p.pdf_hover_preview) |*preview| {
if (preview.pane < p.panes.len) if (p.panes[preview.pane]) |pane| {
@@ -9554,7 +7949,7 @@ pub const Pardes = struct {
return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.col == second.col;
}
- fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool) ?u16 {
+ pub fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool) ?u16 {
var point = supplied orelse return null;
if (point.kind != kind or point.id != id) return null;
if (clamp) for (p.surface.tagLayers()) |*layer| {
@@ -9581,7 +7976,7 @@ pub const Pardes = struct {
_ = delay;
const id = p.paneAt(col, row) orelse return p.cancelLookHover();
const pane = p.panes[id] orelse return p.cancelLookHover();
- _ = p.pointerTextSelection(id, col, row, body_hit, tag_hit) orelse return p.cancelLookHover();
+ _ = edit.pointerTextSelection(p, id, col, row, body_hit, tag_hit) orelse return p.cancelLookHover();
if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown|
if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
@@ -9614,7 +8009,7 @@ pub const Pardes = struct {
if (waiting.frames < delay) return;
const pane = p.panes[waiting.pane] orelse return p.cancelLookHover();
if (pane.serial != waiting.serial) return p.cancelLookHover();
- const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse
+ const pointed = edit.pointerTextSelection(p, waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse
return p.cancelLookHover();
if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) {
const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse
@@ -9921,7 +8316,7 @@ pub const Pardes = struct {
} else {
p.active = id;
if (pane.tag_edit) exitTagEdit(pane);
- const pointed = p.pointerTextSelection(id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return;
+ const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return;
pane.clearPointerSelection(sel_slot);
pane.sel[sel_slot] = pointed.sel;
p.drag = .{ .select = .{
@@ -9977,7 +8372,7 @@ pub const Pardes = struct {
const local = @as(i32, mrow) - @as(i32, body_y);
if (m.button == config.look_button) p.active = id;
pane.scrollBy(if (m.button == config.look_button) local else -local);
- } else if (p.pointerTextSelection(id, mcol, mrow, m.body_hit, p.pointer_tag_hit)) |pointed| {
+ } else if (edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit)) |pointed| {
if (m.button == config.look_button) p.active = id;
pane.clearPointerSelection(@intFromEnum(m.button));
pane.sel[@intFromEnum(m.button)] = pointed.sel;
@@ -10148,7 +8543,7 @@ pub const Pardes = struct {
const top = @min(sel.r0, sel.r1) - @as(i32, BOX_H);
if (top < 0) break :lead null;
const w = pane.wrapAt(top);
- const col = p.paneByteAtDisplay(pane, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane));
+ const col = edit.paneByteAtDisplay(p, pane, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane));
break :lead .{
.row = @intCast(@max(0, w.line)),
.col = @intCast(@max(0, col)),
@@ -10202,7 +8597,7 @@ pub const Pardes = struct {
pane.append_at = null;
pane.sticky_col = -1;
pane.normal.clear();
- p.setYank(text);
+ edit.setYank(p, text);
}
fn dragRelease(p: *Pardes, button: Mouse.Button) void {
@@ -10277,20 +8672,21 @@ pub const Pardes = struct {
const sl = pane.sel[sel_slot];
const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
if (!dragged) pane.sel[sel_slot].state = .none;
- if (dragged) p.capturePointerSelection(pane, sel_slot) catch {};
+ if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {};
if (dragged and pane.mode == .tty) p.mirrorTtySelection(pane);
const body_vis = sl.r1 - @as(i32, BOX_H);
if (body_vis >= 0 and pane.mode != .tty) {
const w = pane.wrapAt(body_vis);
pane.cur_row = w.line;
- pane.cur_col = p.paneByteAtDisplay(
+ pane.cur_col = edit.paneByteAtDisplay(
+ p,
pane,
w.line,
w.at,
sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0),
);
pane.cur_pinned = true;
- if (clampFileCursor(pane)) pane.ensureCursorVisible();
+ if (edit.clampFileCursor(pane)) pane.ensureCursorVisible();
pane.msel.active = false;
pane.normal.clear();
pane.vsel.active = false;
@@ -10325,7 +8721,7 @@ pub const Pardes = struct {
if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) {
pane.cur_row = operand.row;
pane.cur_col = operand.col;
- if (clampFileCursor(pane)) pane.ensureCursorVisible();
+ if (edit.clampFileCursor(pane)) pane.ensureCursorVisible();
}
const txt = operand.text;
const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd;
@@ -10370,7 +8766,7 @@ pub const Pardes = struct {
}
// A PDF is immutable: its Cut-side chord is Snarf (copy), while
// Paste has no document mutation to perform.
- if (cut and pv.selection_text.len > 0) p.setYank(pv.selection_text);
+ if (cut and pv.selection_text.len > 0) edit.setYank(p, pv.selection_text);
return;
};
if (pane.mode == .tty) {
@@ -10394,7 +8790,7 @@ pub const Pardes = struct {
p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb });
}
}
- p.typeToTty(s.id, pane, y);
+ edit.typeToTty(p, s.id, pane, y);
return;
}
if (!s.chorded) {
@@ -10408,8 +8804,8 @@ pub const Pardes = struct {
const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, BOX_H)));
const row0 = w0.line;
const row1 = w1.line;
- const col0 = p.paneByteAtDisplay(pane, w0.line, w0.at, sl.c0 - pfx);
- const col1 = p.paneByteAtDisplay(pane, w1.line, w1.at, sl.c1 - pfx);
+ const col0 = edit.paneByteAtDisplay(p, pane, w0.line, w0.at, sl.c0 - pfx);
+ const col1 = edit.paneByteAtDisplay(p, pane, w1.line, w1.at, sl.c1 - pfx);
pane.cur_row = row1;
pane.cur_col = col1;
pane.cur_pinned = true;
@@ -10420,16 +8816,16 @@ pub const Pardes = struct {
pane.nsel = 0;
}
if (cut) {
- if (pane.vsel.active) p.normalDelete(pane, true);
+ if (pane.vsel.active) edit.normalDelete(p, pane, true);
} else if (pane.vsel.active) {
- p.normalReplaceYank(pane);
+ edit.normalReplaceYank(p, pane);
} else if (pane.file) |*f| {
// bare cursor: splice the register in literally (no linewise
// magic) so a paste right after a cut restores the text exactly
const y = p.yank orelse return;
if (y.len == 0) return;
const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
- p.pushUndo(pane);
+ edit.pushUndo(p, pane);
const new = modal.insertAt(p.gpa, f.content, at, y) catch return;
panes.File.setContent(p, f, new);
pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false };
@@ -10445,7 +8841,7 @@ pub const Pardes = struct {
pane.cur_pinned = true;
pane.sticky_col = -1;
pane.ensureCursorVisible();
- } else p.pasteText(pane, p.yank orelse return, true, 1);
+ } else edit.pasteText(p, pane, p.yank orelse return, true, 1);
}
/// Tty: a shell in the caller's directory, raw from the first frame,
@@ -10658,7 +9054,7 @@ pub const Pardes = struct {
}
fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void {
- const text = p.flatSurface(pane) catch return;
+ const text = edit.flatSurface(p, pane) catch return;
// saturating, not `- 1`: `f.zig:0-5` is a legal thing to type and a
// 1-based zero is the same nothing an absent number is
const acol: i32 = @intCast(at.col -| 1);
@@ -13040,66 +11436,6 @@ test "hopping between two panes does not grow the jump stack" {
try std.testing.expectEqual(depth, p.njumps);
}
-/// 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 "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);
-}
-
/// Every `.write` a keystroke produced, joined — what the pane's program would
/// actually have read off its pty.
fn drainWrites(p: *Pardes, buf: []u8) []const u8 {
@@ -13122,7 +11458,7 @@ test "raw tty forwards Ctrl letters and former global shortcuts to the child" {
defer p.deinit();
var buf: [256]u8 = undefined;
_ = drainWrites(p, &buf);
- p.setYank("register text");
+ edit.setYank(p, "register text");
for ('a'..'z' + 1) |letter| {
if (letter == p.opts.tty_toggle) continue; // editor/TTY mode shortcut
if (letter == 'v') continue; // the window's paste, asserted just below
@@ -13507,7 +11843,7 @@ test "jump history clears selections and lands at the recorded cursor" {
for ([_]u21{ 'o', 'i' }, 0..) |key, index| {
pane.setRange(pane.file.?.content, 0, .{ .anchor = 1, .head = 5 }, true);
pane.sel[0] = .{ .state = .done, .c0 = 2, .c1 = 5, .r0 = 1, .r1 = 1 };
- try p.capturePointerSelection(pane, 0);
+ try edit.capturePointerSelection(p, pane, 0);
pane.select = true;
p.update(.{ .key = .{ .cp = key, .ctrl = true } });
try std.testing.expectEqual(index, p.jcur);
@@ -13542,10 +11878,10 @@ test "jump history terminal rectangle follows output and clears on reflow" {
const pane = p.panes[0].?;
p.update(.{ .output = .{ .pane = 0, .bytes = "alpha bravo\r\nsecond line\r\n" } });
pane.sel[0] = .{ .state = .done, .c0 = 0, .c1 = 4, .r0 = BOX_H, .r1 = BOX_H };
- try p.capturePointerSelection(pane, 0);
- try std.testing.expectEqualStrings("alpha", try p.selectionText(pane, pane.sel[0]));
+ try edit.capturePointerSelection(p, pane, 0);
+ try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0]));
for (0..40) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "more output\r\n" } });
- try std.testing.expectEqualStrings("alpha", try p.selectionText(pane, pane.sel[0]));
+ try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0]));
panes.Terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows);
try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null);
try std.testing.expectEqual(.none, pane.sel[0].state);
diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig
index ea4a4d5b..90c8e951 100644
--- a/src/selection_pipe.zig
+++ b/src/selection_pipe.zig
@@ -7,6 +7,7 @@
const std = @import("std");
const filesystem = @import("fs.zig");
const pardes = @import("pardes.zig");
+const edit = @import("edit.zig");
const modal = @import("modal.zig");
const panes = @import("panes.zig");
const config = @import("config.zig");
@@ -662,7 +663,7 @@ pub fn pipeResponse(
@memcpy(replacement[write_at..], f.content[read_at..]);
// One async request is one history transaction, even at 64 cursors.
- p.pushUndo(pane);
+ edit.pushUndo(p, pane);
panes.File.setContent(p, f, replacement);
pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit);
pane.select = false;
diff --git a/test/hxdiff.zig b/test/hxdiff.zig
index 22d8067d..e79a6bb6 100644
--- a/test/hxdiff.zig
+++ b/test/hxdiff.zig
@@ -488,7 +488,7 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result
// the cursor's empty row below the output (see Pardes.shellRows), so
// its line list is exactly a file content's — join it and you have the
// same bytes, trailing newline included, with nothing to patch up.
- const lines = try core.paneCursorLines(pane);
+ const lines = try pardes.edit.paneCursorLines(core, pane);
var text_len: usize = if (lines.len == 0) 0 else lines.len - 1;
for (lines) |ln| text_len = std.math.add(usize, text_len, ln.len) catch return error.OutOfMemory;
const joined = try arena.alloc(u8, text_len);
diff --git a/test/lspbench.zig b/test/lspbench.zig
index 59cac155..7c4e8753 100644
--- a/test/lspbench.zig
+++ b/test/lspbench.zig
@@ -22,7 +22,7 @@ const Anchor = struct {
};
const anchors = [_]Anchor{
- .{ .file = "src/pardes.zig", .needle = "modal.cursorLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" },
+ .{ .file = "src/edit.zig", .needle = "modal.cursorLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" },
.{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .definition, .expect = "lsp.zig" },
.{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 8, .kind = .definition, .expect = "mem.zig" },
.{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .hover, .expect = "" },
diff --git a/test/perf.zig b/test/perf.zig
index 057725fd..7d3b5e3d 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -527,7 +527,7 @@ fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell {
pump(core);
}
_ = try frame(core);
- const motion_rows = if (op == .key_down) (try core.paneCursorLines(pane)).len else 0;
+ const motion_rows = if (op == .key_down) (try pardes.edit.paneCursorLines(core, pane)).len else 0;
if (op == .key_down and motion_rows < 3) return error.EmptyTerminalFixture;
var cols = screen_cols;