diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 22:09:27 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | ff953a6236ebdf97c73deda86fd9005906a2071b (patch) | |
| tree | 9871a9aa6b562f6a2fa9f01219a70b52a1e474c1 /src/pardes.zig | |
| parent | 08942ef68b10d4ce478bd3c62d306f2a633d51cb (diff) | |
| download | pardes-ff953a6236ebdf97c73deda86fd9005906a2071b.tar.gz pardes-ff953a6236ebdf97c73deda86fd9005906a2071b.zip | |
Move text editing out of pardes.zig into edit.zig
Pure move, no behaviour change (acme keeps this in text.c): which text a
pane edits and how it maps to the screen (editText, editTextEol,
setEditText, paneCursorLines, paneByteAtDisplay, pinPaneCursor,
flatSurface, paneWrapWidth), insert mode (enterInsert, handleInsert,
insertKey, insertTab, exitInsert, clampFileCursor), the d/c/y/p edit
operations with replace, case, join, indent, comment, number, textobjects
and surround, undo and redo, yank/clipboard/paste (setYank, setClipboard,
ClipRequest, clipRequest, typeToTty, applyPaste, clipYank), and the pointer
selections as text (PointerTextSelection, pointerTextSelection,
capturePointerSelection, paneText, pointerSourceLine, selectionText,
spanHas, currentSelText), with four tests, go verbatim to edit.zig.
The methods become free functions taking `p: *Pardes`; calls change from
`p.insertKey(..)` to `edit.insertKey(p, ..)` (pardes.zig, body_layer.zig,
selection_pipe.zig, builtins.zig, test/hxdiff.zig, test/perf.zig). In
executeNormalAction the `.edit => |edit|` capture becomes `|op|`, since it
would now shadow the edit import. test/lspbench.zig's first anchor follows
its needle into src/edit.zig.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1874 |
1 files changed, 105 insertions, 1769 deletions
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); |
