diff options
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); |
