diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-07 09:48:05 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 9da20917b6f4cf98fd9b5c2c0a904e4f6a8380a9 (patch) | |
| tree | c7c8c4d1126def565eb0b1d20ad29782eb49c5e7 /src/pardes.zig | |
| parent | b2cdd8ee719961b2f037f20bf0f88fad94ca73b4 (diff) | |
| download | pardes-9da20917b6f4cf98fd9b5c2c0a904e4f6a8380a9.tar.gz pardes-9da20917b6f4cf98fd9b5c2c0a904e4f6a8380a9.zip | |
helix diff testing and feature parity
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1598 |
1 files changed, 1421 insertions, 177 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 51965bd4..53ec4172 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -312,6 +312,7 @@ pub const Key = struct { pub const end: u21 = 0xF0006; pub const page_up: u21 = 0xF0007; pub const page_down: u21 = 0xF0008; + pub const delete: u21 = 0xF0009; }; fn is(key: Key, cp: u21) bool { @@ -322,6 +323,10 @@ fn isC(key: Key, cp: u21) bool { return key.cp == cp and key.ctrl and !key.alt; } +fn isA(key: Key, cp: u21) bool { + return key.cp == cp and !key.ctrl and key.alt; +} + pub const Event = union(enum) { key: Key, mouse: Mouse, @@ -405,12 +410,16 @@ pub const LineSel = struct { r1: i32 = 0, }; -/// A modal char-range selection (helix `v`): the anchor lives here, the head is -/// the pane cursor. Mutually exclusive with LineSel. +/// A modal char-range selection: the anchor lives here, the head is the pane +/// cursor. Since the helix motion model landed, EVERY motion leaves one of +/// these — `explicit` separates user-intent selections (v / x / X / terminal +/// n/N / file-search n/N) from bare motion residue: the acme Enter/Tab chords +/// only act on explicit ones. Mutually exclusive with LineSel. pub const CharSel = struct { active: bool = false, row: i32 = 0, col: i32 = 0, + explicit: bool = false, }; /// Undo snapshot of a terminal pane's edit state (whole-state, dumb on purpose). @@ -422,7 +431,16 @@ pub const EditSnap = struct { cur_off: u16, }; -/// A file pane's backing: content is gpa-owned; undo/redo are content snapshots. +/// Undo snapshot of a file pane: content + the selection at commit time — +/// helix undo restores the selection along with the text. +pub const FileSnap = struct { + content: []u8, + cur_row: i32, + cur_col: i32, + vsel: CharSel, +}; + +/// A file pane's backing: content is gpa-owned; undo/redo are whole snapshots. pub const File = struct { path: []u8, content: []u8, @@ -431,8 +449,8 @@ pub const File = struct { highlights: []u8 = &.{}, highlight_start: usize = 0, syntax_dirty: bool = true, - undo: std.ArrayList([]u8) = .empty, - redo: std.ArrayList([]u8) = .empty, + undo: std.ArrayList(FileSnap) = .empty, + redo: std.ArrayList(FileSnap) = .empty, }; /// An image pane's backing: the decoded+downscaled RGBA (kept so the petscii @@ -469,7 +487,29 @@ pub const Pane = struct { image: ?Image = null, msel: LineSel = .{}, vsel: CharSel = .{}, - /// one pending prefix codepoint (0 = none): `g` and `z` + /// helix select/extend mode (`v`): motions extend the selection from its + /// fixed anchor instead of replacing it. Reported as mode "select"; + /// pane.mode stays .normal (insert/tty transitions drop it). + select: bool = false, + /// sticky goal column for j/k runs (helix old_visual_position): any + /// non-vertical range write resets it to -1. + sticky_col: i32 = -1, + /// an `a` append session's original block-cursor cell: Esc backs the + /// cursor up one grapheme and rebuilds the appended-over selection from + /// here (helix doc.restore_cursor). Null outside `a` sessions. + append_at: ?struct { row: i32, col: i32 } = null, + /// match-mode second key in flight (`m` + i/a/s/r/d awaiting its char) + pending2: u21 = 0, + /// captured first char argument (`mr<from><to>` holds <from> here) + pending_ch: u21 = 0, + /// count prefix accumulator (0 = none). ponytail: capped at 0xffff — + /// nobody types a bigger count, and it keeps the row math in i32 range. + count: u32 = 0, + /// last f/F/t/T motion, for Alt-. repeat + find_op: u8 = 0, + find_ch: u21 = 0, + /// one pending prefix codepoint (0 = none): `g` `z` `m` `[` `]`, or an + /// op waiting for its char argument (`f` `F` `t` `T` `r`) pending: u21 = 0, /// `/` file search: the pattern is typed into the tag tail (no popup); /// search_at is the tail length before " /" was appended (submit/cancel @@ -683,6 +723,9 @@ pub const Pardes = struct { /// modal yank register (gpa-owned); a yank also mirrors out to the system /// clipboard via the set_clipboard effect. yank: ?[]u8 = null, + /// a normal-mode `P` is in flight: the next Event.paste pastes BEFORE + /// (the get_clipboard round trip carries no argument) + paste_before: bool = false, /// 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 @@ -781,9 +824,9 @@ pub const Pardes = struct { p.gpa.free(f.path); p.gpa.free(f.content); if (f.highlights.len > 0) p.gpa.free(f.highlights); - for (f.undo.items) |s| p.gpa.free(s); + for (f.undo.items) |s| p.gpa.free(s.content); f.undo.deinit(p.gpa); - for (f.redo.items) |s| p.gpa.free(s); + for (f.redo.items) |s| p.gpa.free(s.content); f.redo.deinit(p.gpa); } pane.tag_tail.deinit(p.gpa); @@ -902,9 +945,10 @@ pub const Pardes = struct { .key => |key| p.handleKey(key), .mouse => |m| p.handleMouse(m), .paste => |bytes| { - // the OSC 52 system-clipboard reply for a normal-mode `p` + // the OSC 52 system-clipboard reply for a normal-mode `p`/`P` p.setYank(bytes); - if (p.panes[p.active]) |pane| p.normalPaste(pane); + if (p.panes[p.active]) |pane| p.normalPaste(pane, p.paste_before); + p.paste_before = false; }, .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, @@ -1133,7 +1177,9 @@ pub const Pardes = struct { if (p.panes[p.active]) |nt| nt.pending = 0; return; } - if (isC(key, 'w')) { + // insert mode owns Ctrl-w (delete word backward, helix); the focus + // prefix keeps normal/tty + if (isC(key, 'w') and pane.mode != .insert) { p.ctrl_w_pending = true; return; } @@ -1178,21 +1224,26 @@ pub const Pardes = struct { return; } // normal-mode Enter/Tab: the acme chords — look at (Enter) or execute - // (Tab) the modal selection, else the file-ish word under the cursor - // (on the tag tail when it's focused for editing, else the body). + // (Tab) the EXPLICIT modal selection (v/x/X, terminal n/N, search + // n/N); implicit motion residue falls back to the file-ish word + // under the cursor (on the tag tail when it's focused, else the body). if (pane.mode == .normal and (is(key, Key.enter) or is(key, Key.tab))) { const btn: Mouse.Button = if (is(key, Key.tab)) .middle else .right; pane.pinCursor(); - if (p.currentSelText(pane)) |txt| { - pane.vsel.active = false; - pane.msel.active = false; - p.actOnSelection(btn, p.active, txt, null); - } else { - var sel = cursorWordSel(pane); - p.expandSel(pane, &sel); - const word = p.selectionText(pane, sel) catch return; - p.actOnSelection(btn, p.active, word, null); + const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + if (explicit) { + if (p.currentSelText(pane)) |txt| { + pane.vsel.active = false; + pane.msel.active = false; + pane.select = false; + p.actOnSelection(btn, p.active, txt, null); + return; + } } + var sel = cursorWordSel(pane); + p.expandSel(pane, &sel); + const word = p.selectionText(pane, sel) catch return; + p.actOnSelection(btn, p.active, word, null); return; } // tag editing intercepts every other key: a modal one-line editor @@ -1201,9 +1252,12 @@ pub const Pardes = struct { p.tagInsertKey(pane, key); return; } - // normal-mode `p` pastes the SYSTEM clipboard: an async OSC 52 round - // trip — request here, the reply arrives later as Event.paste. - if (pane.mode == .normal and is(key, 'p')) { + // normal-mode `p`/`P` paste the SYSTEM clipboard: an async OSC 52 + // round trip — request here, the reply arrives later as Event.paste. + // Skipped while a prefix waits for its char argument (f/r/m and + // friends take `p` literally). + if (pane.mode == .normal and pane.pending == 0 and (is(key, 'p') or is(key, 'P'))) { + p.paste_before = is(key, 'P'); p.emit(.get_clipboard); return; } @@ -1214,6 +1268,25 @@ pub const Pardes = struct { pane.mode = .normal; pane.msel.active = false; pane.pending = 0; + // leaving an `a` append session: the cursor backs up one + // grapheme and the appended-over span becomes the + // implicit selection (helix doc.restore_cursor) + if (pane.append_at) |aa| { + pane.append_at = null; + if (pane.file != null) { + const pl = p.paneCursorLines(pane) catch return; + const text = p.flatSurface(pane, pl) catch return; + const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + const back = modal.prevGrapheme(text, gap); + const bc = modal.hxPos(text, back); + pane.cur_row = @intCast(bc.row); + pane.cur_col = @intCast(bc.col); + const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); + pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; + pane.cur_pinned = true; + pane.ensureCursorVisible(); + } + } return; } p.handleInsert(pane, key); @@ -1251,6 +1324,9 @@ pub const Pardes = struct { pane.mode = .tty; pane.msel.active = false; pane.vsel.active = false; + pane.select = false; + pane.append_at = null; + pane.sticky_col = -1; pane.pending = 0; } @@ -1404,7 +1480,9 @@ pub const Pardes = struct { /// The lines the cursor moves over, absolute rows. File: all content lines. /// Terminal: the WHOLE history+active grid (prompt rows hidden, runs /// spliced) so motions ride the scrollback. Scratch-arena backed. - fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines { + /// (pub only for test/hxdiff.zig — the helix differential harness dumps + /// this surface as a tty case's final text.) + pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines { const arena = p.scratch.allocator(); if (pane.file) |f| { var ls: std.ArrayList([]const u8) = .empty; @@ -1442,89 +1520,498 @@ pub const Pardes = struct { pane.cur_pinned = true; } - /// apply a motion: move the cursor, keep it visible. A line selection - /// collapses on motion; a char selection extends (its head is the cursor). - fn applyMove(pane: *Pane, pl: PaneLines, c: modal.Cursor) void { - fromModalCursor(pane, pl, c); + // ---- helix range plumbing (see modal.zig "helix range engine") ---- + // The pane's cursor + vsel cells render ONE helix gap range over the flat + // motion surface. Every motion builds the current range, transforms it the + // way the helix command would, and writes it back: normal mode REPLACES + // the selection with the motion's range, select mode (v) extends it via + // put_cursor. The differential suite (zig build hxdiff) pins all of this + // against a real helix, key for key. + + /// the flat motion surface: file content as-is; terminals join the + /// cursor-lines dump (scratch-arena backed, same lifetime as pl) + fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 { + if (pane.file) |f| return f.content; + const arena = p.scratch.allocator(); + var buf: std.ArrayList(u8) = .empty; + for (pl.lines, 0..) |ln, i| { + if (i > 0) try buf.append(arena, '\n'); + try buf.appendSlice(arena, ln); + } + return buf.items; + } + + /// the current selection as a helix gap range over `text` + fn paneRange(pane: *Pane, text: []const u8) modal.HxRange { + const c = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + if (pane.msel.active) { + // legacy line selection (file-search results highlight): linewise + const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1))); + const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1))); + const s = modal.lineStartOffset(text, r0); + const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); + return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; + } + if (pane.vsel.active) { + const a = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }); + if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; + return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; + } + return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; + } + + /// write a helix range back into pane state. `explicit` marks user-intent + /// selections (v/x/X, terminal n/N, file-search n/N) — the acme chords + /// act only on those; motion residue stays implicit. + fn setPaneRange(pane: *Pane, pl: PaneLines, text: []const u8, r0: modal.HxRange, explicit: bool) void { + var r = r0; + if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 + var cur_off: usize = undefined; + var anc_off: usize = undefined; + if (r.head > r.anchor) { + cur_off = modal.prevGrapheme(text, r.head); + anc_off = r.anchor; + } else if (r.head < r.anchor) { + cur_off = r.head; + anc_off = modal.prevGrapheme(text, r.anchor); + } else { + cur_off = r.head; + anc_off = r.head; + } + const cc = modal.hxPos(text, cur_off); + const ac = modal.hxPos(text, anc_off); + pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0; + pane.cur_col = @intCast(cc.col); + pane.vsel = .{ + .active = anc_off != cur_off or pane.select, + .row = @as(i32, @intCast(ac.row)) + pl.row0, + .col = @intCast(ac.col), + .explicit = explicit or pane.select, + }; pane.msel.active = false; + pane.cur_off = 0; + pane.cur_pinned = true; + pane.sticky_col = -1; pane.pending = 0; pane.ensureCursorVisible(); } + /// point-target motion: collapse there (extend in select mode) + fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void { + setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false); + } + + /// word motions select their traversed span (extend mode: head only) + fn wordMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize, target: modal.WordTarget) void { + const wr = modal.hxWordMove(text, range, cnt, target); + const res = if (pane.select) modal.hxPutCursor(text, range, modal.hxCursor(text, wr), true) else wr; + setPaneRange(pane, pl, text, res, false); + } + + /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move) + fn findMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, ch: u21, fwd: bool, till: bool, cnt: usize) void { + if (ch > 0x7f) return; // ponytail: ASCII targets only (byte columns) + const cur = modal.hxCursor(text, range); + const t = modal.hxFindTarget(text, cur, @intCast(ch), fwd, till, cnt) orelse return; + const res = if (pane.select) + modal.hxPutCursor(text, range, t, true) + else + modal.hxPutCursor(text, .{ .anchor = cur, .head = cur }, t, true); + setPaneRange(pane, pl, text, res, false); + } + + /// j/k and friends: sticky goal column, clamped onto short lines' newline + fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void { + const cur = modal.hxCursor(text, range); + const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else modal.hxPos(text, cur).col; + const t = modal.hxVertTarget(text, cur, down, cnt, goal); + // extend mode never walks onto the empty trailing line (helix) + if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; + setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); + pane.sticky_col = @intCast(goal); + } + + /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows + fn halfPageMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool) void { + const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2)); + pane.scrollBy(if (down) half else -half); + verticalMove(pane, pl, text, range, down, @intCast(half)); + } + + /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift + /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0 + fn scrollViewMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, delta: i32) void { + const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); + pane.scrollBy(delta); + const top = pane.scroll(); + const last_row: i32 = @intCast(modal.hxLineCount(text) - 1); + const cur = modal.hxCursor(text, range); + if (delta > 0) { + const snap: i32 = @max(0, @min(top + margin, last_row)); + const head = modal.lineStartOffset(text, @intCast(snap)); + if (head <= cur) return; + const anchor = if (pane.select) range.anchor else head; + setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); + } else { + const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); + const head = modal.lineStartOffset(text, @intCast(snap)); + if (head >= cur) return; + const anchor = if (pane.select) range.anchor else head; + setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); + } + } + + /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) + fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: u21, cnt: usize) void { + const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); + const top = pane.scroll(); + const last_row: i32 = @intCast(modal.hxLineCount(text) - 1); + const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top); + const n: i32 = @intCast(cnt - 1); + var vline: i32 = switch (which) { + 't' => top + margin + n, + 'c' => top + @divTrunc(last_vis, 2), + else => top + last_vis - (margin + n), + }; + vline = @max(vline, top + margin); + vline = @min(vline, top + last_vis - margin); + const row: i32 = std.math.clamp(vline, 0, last_row); + pointMove(pane, pl, text, range, modal.lineStartOffset(text, @intCast(row))); + } + + /// helix Range::line_range — the inclusive line span a range covers + fn rangeLineSpan(text: []const u8, r: modal.HxRange) struct { start: usize, end: usize } { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from); + return .{ .start = modal.hxLineOf(text, from), .end = modal.hxLineOf(text, to_adj) }; + } + + fn lineStartOrEof(text: []const u8, line: usize) usize { + if (line >= modal.hxLineCount(text)) return text.len; + return modal.lineStartOffset(text, line); + } + + /// helix `x` extend_line_below: full lines incl. the newline, cursor ON + /// the last one's '\n'; an already-line-bounded selection grows downward + fn lineSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize) void { + const span = rangeLineSpan(text, range); + const start = modal.lineStartOffset(text, span.start); + const end = lineStartOrEof(text, span.end + 1); + const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end; + const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full)); + setPaneRange(pane, pl, text, .{ .anchor = start, .head = head }, true); + } + + /// helix `X` extend_to_line_bounds (direction kept) + fn lineBoundsSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { + const span = rangeLineSpan(text, range); + const start = modal.lineStartOffset(text, span.start); + const end = lineStartOrEof(text, span.end + 1); + const r: modal.HxRange = if (range.head < range.anchor) + .{ .anchor = end, .head = start } + else + .{ .anchor = start, .head = end }; + setPaneRange(pane, pl, text, r, true); + } + + /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) + fn shrinkSelToLineBounds(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { + const span = rangeLineSpan(text, range); + if (span.start == span.end) return; + const from = @min(range.anchor, range.head); + const to = @max(range.anchor, range.head); + var start = modal.lineStartOffset(text, span.start); + var end = lineStartOrEof(text, span.end + 1); + if (start != from) start = lineStartOrEof(text, span.start + 1); + if (end != to) end = modal.lineStartOffset(text, span.end); + const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + const r: modal.HxRange = if (range.head < range.anchor) + .{ .anchor = end, .head = start } + else + .{ .anchor = start, .head = end }; + setPaneRange(pane, pl, text, r, expl); + } + fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { pane.pinCursor(); const pl = p.paneCursorLines(pane) catch return; - const cur = toModalCursor(pane, pl); + const text = p.flatSurface(pane, pl) catch return; const lines = pl.lines; - const half: usize = @max(1, pane.rows / 2); - // Esc collapses any selection / pending prefix; it does NOT drop to tty + // Esc: exit select mode / drop pending prefixes + count. The + // selection itself SURVIVES (helix; `;` collapses it) — motions + // replace it anyway. if (is(key, Key.escape)) { - pane.vsel.active = false; - pane.msel.active = false; + pane.select = false; pane.pending = 0; + pane.pending2 = 0; + pane.pending_ch = 0; + pane.count = 0; + return; + } + // count prefix: digits accumulate while no prefix op is in flight + // (`0` stays line-start until a count begins) + if (pane.pending == 0 and !key.ctrl and !key.alt and key.cp >= '0' and key.cp <= '9' and !(key.cp == '0' and pane.count == 0)) { + if (pane.count < 0xffff) pane.count = pane.count * 10 + (key.cp - '0'); return; } + // any key but a prefix setter consumes the count (setters re-arm it) + const cnt: usize = @max(1, pane.count); + const has_count = pane.count != 0; + const saved_count = pane.count; + pane.count = 0; + + const range = paneRange(pane, text); + const cur = modal.hxCursor(text, range); // block-cursor gap offset + // the last line a goto may land on: helix skips the empty trailing line + const max_line = blk: { + const nl = modal.hxLineCount(text); + break :blk if (modal.lineStartOffset(text, nl - 1) >= text.len) nl -| 2 else nl - 1; + }; + // prefix continuations if (pane.pending == 'g') { pane.pending = 0; - if (is(key, 'g')) return applyMove(pane, pl, modal.gotoFirst()); - if (is(key, 'e')) return applyMove(pane, pl, modal.gotoLast(lines)); - if (is(key, 'h')) return applyMove(pane, pl, modal.lineStart(cur)); - if (is(key, 'l')) return applyMove(pane, pl, modal.lineEnd(lines, cur)); + if (is(key, 'g')) { + const line = if (has_count) @min(cnt - 1, max_line) else 0; + return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); + } + if (is(key, 'e')) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, max_line)); + if (is(key, 'h')) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); + if (is(key, 'l')) { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + } + if (is(key, 's')) { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const nw = modal.firstNonWs(slice); + if (nw == slice.len) return; // all-whitespace line: no move (helix) + return pointMove(pane, pl, text, range, ls + nw); + } + if (is(key, 'j')) return verticalMove(pane, pl, text, range, true, cnt); + if (is(key, 'k')) return verticalMove(pane, pl, text, range, false, cnt); + if (is(key, '|')) { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + return pointMove(pane, pl, text, range, @min(ls + (cnt - 1), modal.hxLineEndIdx(text, line))); + } + if (is(key, 't') or is(key, 'c') or is(key, 'b')) return gotoWindow(pane, pl, text, range, key.cp, cnt); return; } if (pane.pending == 'z') { pane.pending = 0; - if (is(key, 't')) return pane.scrollBy(pane.cur_row - pane.scroll()); - if (is(key, 'z')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows / 2))); - if (is(key, 'b')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); + if (is(key, 't')) { + pane.scrollBy(pane.cur_row - pane.scroll()); + pane.ensureCursorVisible(); + return; + } + if (is(key, 'z') or is(key, 'c')) { + pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1)); + pane.ensureCursorVisible(); + return; + } + if (is(key, 'b')) { + pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); + pane.ensureCursorVisible(); + return; + } + if (is(key, 'j') or is(key, Key.down)) return scrollViewMove(pane, pl, text, range, 1); + if (is(key, 'k') or is(key, Key.up)) return scrollViewMove(pane, pl, text, range, -1); + if (isC(key, 'd')) return halfPageMove(pane, pl, text, range, true); + if (isC(key, 'u')) return halfPageMove(pane, pl, text, range, false); + if (isC(key, 'f') or is(key, Key.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); + if (isC(key, 'b') or is(key, Key.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); return; } - if (is(key, 'g')) { - pane.pending = 'g'; + if (pane.pending == 'f' or pane.pending == 'F' or pane.pending == 't' or pane.pending == 'T') { + const op: u8 = @intCast(pane.pending); + pane.pending = 0; + if (key.ctrl or key.alt or key.cp >= 0xF0000) return; // not a char + pane.find_op = op; + pane.find_ch = key.cp; + return findMove(pane, pl, text, range, key.cp, op == 'f' or op == 't', op == 't' or op == 'T', cnt); + } + if (pane.pending == 'r') { + pane.pending = 0; + if (key.ctrl or key.alt or key.cp >= 0xF0000 or key.cp > 0x7f) return; + return p.normalReplaceChar(pane, @intCast(key.cp)); + } + if (pane.pending == 'm') { + if (pane.pending2 == 0) { + if (is(key, 'm')) { + pane.pending = 0; + const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return; + return pointMove(pane, pl, text, range, modal.hxOff(text, mc)); + } + if (is(key, 'i') or is(key, 'a') or is(key, 's') or is(key, 'r') or is(key, 'd')) { + pane.pending2 = key.cp; // pending stays 'm': the char arg is next + return; + } + pane.pending = 0; + return; + } + const sub = pane.pending2; + if (key.ctrl or key.alt or key.cp >= 0xF0000) { + pane.pending = 0; + pane.pending2 = 0; + pane.pending_ch = 0; + return; + } + if (sub == 'r' and pane.pending_ch == 0) { + pane.pending_ch = key.cp; // mr<from><to>: hold <from>, wait for <to> + return; + } + pane.pending = 0; + pane.pending2 = 0; + const from = pane.pending_ch; + pane.pending_ch = 0; + switch (sub) { + 'i', 'a' => return p.textobjectSelect(pane, pl, key.cp, sub == 'a'), + 's' => return p.surroundAdd(pane, key.cp), + 'd' => return p.surroundDelete(pane, pl, key.cp), + 'r' => return p.surroundReplace(pane, pl, from, key.cp), + else => return, + } + } + if (pane.pending == ']' or pane.pending == '[') { + const fwd = pane.pending == ']'; + pane.pending = 0; + if (is(key, 'p')) { + const r2 = modal.hxParaMove(text, range, cnt, fwd, pane.select); + return setPaneRange(pane, pl, text, r2, false); + } + if (is(key, ' ')) return p.addNewline(pane, fwd, cnt); return; } - if (is(key, 'z')) { - pane.pending = 'z'; + // prefix setters (the count survives into the continuation) + if (is(key, 'g') or is(key, 'z') or is(key, 'f') or is(key, 'F') or is(key, 't') or is(key, 'T') or is(key, 'r') or is(key, ']') or is(key, '[')) { + pane.pending = key.cp; + pane.count = saved_count; + return; + } + if (is(key, 'm')) { + pane.pending = 'm'; + pane.pending2 = 0; + pane.pending_ch = 0; return; } // movement - if (is(key, 'h') or is(key, Key.left)) return applyMove(pane, pl, modal.charLeft(cur)); - if (is(key, 'l') or is(key, Key.right)) return applyMove(pane, pl, modal.charRight(lines, cur)); - if (is(key, 'j') or is(key, Key.down)) return applyMove(pane, pl, modal.lineDown(lines, cur)); - if (is(key, 'k') or is(key, Key.up)) return applyMove(pane, pl, modal.lineUp(lines, cur)); - if (is(key, 'w')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, false)); - if (is(key, 'b')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, false)); - if (is(key, 'e')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, false)); - if (is(key, 'W')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, true)); - if (is(key, 'B')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, true)); - if (is(key, 'E')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, true)); - if (is(key, '0') or is(key, Key.home)) return applyMove(pane, pl, modal.lineStart(cur)); - if (is(key, '$') or is(key, Key.end)) return applyMove(pane, pl, modal.lineEnd(lines, cur)); - if (is(key, '^')) return applyMove(pane, pl, modal.firstNonWsOf(lines, cur)); - if (is(key, 'G')) return applyMove(pane, pl, modal.gotoLast(lines)); - if (isC(key, 'd') or is(key, Key.page_down)) - return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, half))); - if (isC(key, 'u') or is(key, Key.page_up)) - return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, half))); - if (isC(key, 'f')) - return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, pane.rows)))); - if (isC(key, 'b')) - return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, pane.rows)))); + if (is(key, 'h') or is(key, Key.left)) { + var t = cur; + for (0..cnt) |_| t = modal.prevGrapheme(text, t); + return pointMove(pane, pl, text, range, t); + } + if (is(key, 'l') or is(key, Key.right)) { + var t = cur; + for (0..cnt) |_| t = modal.nextGrapheme(text, t); + return pointMove(pane, pl, text, range, t); + } + if (is(key, 'j') or is(key, Key.down)) return verticalMove(pane, pl, text, range, true, cnt); + if (is(key, 'k') or is(key, Key.up)) return verticalMove(pane, pl, text, range, false, cnt); + if (is(key, 'w')) return wordMove(pane, pl, text, range, cnt, .next_word_start); + if (is(key, 'b')) return wordMove(pane, pl, text, range, cnt, .prev_word_start); + if (is(key, 'e')) return wordMove(pane, pl, text, range, cnt, .next_word_end); + if (is(key, 'W')) return wordMove(pane, pl, text, range, cnt, .next_long_word_start); + if (is(key, 'B')) return wordMove(pane, pl, text, range, cnt, .prev_long_word_start); + if (is(key, 'E')) return wordMove(pane, pl, text, range, cnt, .next_long_word_end); + if (isA(key, '.')) { + // repeat the last f/F/t/T; a count repeats the motion N times + if (pane.find_op == 0 or pane.find_ch > 0x7f) return; + const fwd = pane.find_op == 'f' or pane.find_op == 't'; + const till = pane.find_op == 't' or pane.find_op == 'T'; + var r = range; + var moved = false; + for (0..cnt) |_| { + const cc = modal.hxCursor(text, r); + const t = modal.hxFindTarget(text, cc, @intCast(pane.find_ch), fwd, till, 1) orelse break; + r = if (pane.select) + modal.hxPutCursor(text, r, t, true) + else + modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, t, true); + moved = true; + } + if (!moved) return; + return setPaneRange(pane, pl, text, r, false); + } + if (is(key, '0') or is(key, Key.home)) + return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); + if (is(key, '$') or is(key, Key.end)) { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + } + if (is(key, '^')) { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const nw = modal.firstNonWs(slice); + if (nw == slice.len) return; + return pointMove(pane, pl, text, range, ls + nw); + } + if (is(key, 'G')) { + // helix goto_line: only acts WITH a count (bare G is a no-op; + // ge is goto-last-line) + if (!has_count) return; + const line = @min(cnt - 1, max_line); + return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); + } + if (isC(key, 'd')) return halfPageMove(pane, pl, text, range, true); + if (isC(key, 'u')) return halfPageMove(pane, pl, text, range, false); + if (isC(key, 'f') or is(key, Key.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); + if (isC(key, 'b') or is(key, Key.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); // insert entry - if (is(key, 'i')) return p.enterInsert(pane, .at); - if (is(key, 'a')) return p.enterInsert(pane, .append); - if (is(key, 'I')) return p.enterInsert(pane, .line_start); - if (is(key, 'A')) return p.enterInsert(pane, .line_end); - if (is(key, 'o')) return p.enterInsert(pane, .open_below); - if (is(key, 'O')) return p.enterInsert(pane, .open_above); - // selection + edit - if (is(key, 'v')) return charSelect(pane); - if (is(key, 'x')) return lineSelect(pane); - if (is(key, 'd')) return p.normalDelete(pane); + if (is(key, 'i')) return p.enterInsert(pane, .at, cnt); + if (is(key, 'a')) return p.enterInsert(pane, .append, cnt); + if (is(key, 'I')) return p.enterInsert(pane, .line_start, cnt); + if (is(key, 'A')) return p.enterInsert(pane, .line_end, cnt); + if (is(key, 'o')) return p.enterInsert(pane, .open_below, cnt); + if (is(key, 'O')) return p.enterInsert(pane, .open_above, cnt); + // selections + if (is(key, 'v')) { + if (pane.select) { + pane.select = false; // exit select mode, selection kept (helix) + } else { + pane.select = true; + if (pane.msel.active) { + // adopt the legacy line highlight as a char range + setPaneRange(pane, pl, text, range, true); + } else if (!pane.vsel.active) { + pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true }; + } else { + pane.vsel.explicit = true; + } + } + pane.cur_pinned = true; + pane.pending = 0; + return; + } + if (is(key, 'x')) return lineSelect(pane, pl, text, range, cnt); + if (is(key, 'X')) return lineBoundsSelect(pane, pl, text, range); + if (isA(key, 'x')) return shrinkSelToLineBounds(pane, pl, text, range); + if (is(key, ';')) return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false); + if (isA(key, ';')) { + const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, expl); + } + if (is(key, '%')) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false); + // edits + if (is(key, 'd')) return p.normalDelete(pane, true); + if (isA(key, 'd')) return p.normalDelete(pane, false); if (is(key, 'c')) return p.normalChange(pane); if (is(key, 'y')) return p.normalYank(pane); + if (is(key, 'R')) return p.normalReplaceYank(pane); + if (is(key, '~')) return p.normalCase(pane, .toggle); + if (is(key, '`')) return p.normalCase(pane, .lower); + if (isA(key, '`')) return p.normalCase(pane, .upper); + if (is(key, 'J')) return p.normalJoin(pane); + if (is(key, '>')) return p.normalIndent(pane, cnt, true); + if (is(key, '<')) return p.normalIndent(pane, cnt, false); + if (isC(key, 'a')) return p.normalAdjustNumber(pane, @intCast(cnt)); + if (isC(key, 'x')) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt))); if (is(key, 'u')) return p.doUndo(pane); if (is(key, 'U')) return p.doRedo(pane); // file search: `/` types a pattern into the tag, n/N walk the results @@ -1665,8 +2152,9 @@ pub const Pardes = struct { if (delta > 0) break; // first token forward; keep the last one backward } if (hit_t1 == 0) continue; - // anchor the selection at the token's end, cursor at its START - pane.vsel = .{ .active = true, .row = r, .col = @intCast(hit_t1 - 1) }; + // anchor the selection at the token's end, cursor at its START; + // EXPLICIT: Enter's look chord acts on it + pane.vsel = .{ .active = true, .row = r, .col = @intCast(hit_t1 - 1), .explicit = true }; pane.msel.active = false; pane.cur_row = r; pane.cur_col = @intCast(hit_t0); @@ -1680,10 +2168,14 @@ pub const Pardes = struct { const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; - fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt) void { + fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { pane.pinCursor(); - // snapshot once per insert session so `u` undoes the whole session + // 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 pl = p.paneCursorLines(pane) catch { pane.mode = .insert; pane.msel.active = false; @@ -1691,35 +2183,79 @@ pub const Pardes = struct { pane.pending = 0; return; }; + const text = p.flatSurface(pane, pl) catch return; const cur = toModalCursor(pane, pl); const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0; + // helix selection-aware entry: `i` to the selection's START (the + // selection flips and survives until the first edit); `a` one past + // its END, remembering the origin cell for the Esc restore + const b: ?Bounds = if (pane.vsel.active) vselBounds(pane) else null; switch (where) { - .at => {}, - .append => fromModalCursor(pane, pl, modal.charRight(pl.lines, cur)), - .line_start => fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)), + .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.hxOff(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); + const gc = modal.hxPos(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 => { + fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)); + pane.vsel.active = false; + }, .line_end => { pane.cur_col = @intCast(llen); pane.cur_pinned = true; + pane.vsel.active = false; }, - .open_below => { + .open_below, .open_above => { + pane.vsel.active = false; + const below = where == .open_below; if (pane.file) |*f| { - const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return; + // helix `open`: the new line copies the current line's + // full indent LEVELS; <n>o opens n lines, cursor on the + // first + const row: usize = if (b) |bb| + @intCast(@max(0, if (below) bb.hi_row else bb.lo_row)) + else + cur.row; + const ind = modal.hxIndentString(modal.lineSlice(f.content, row)); + const arena = p.scratch.allocator(); + var block: std.ArrayList(u8) = .empty; + for (0..cnt) |_| { + if (below) { + block.append(arena, '\n') catch return; + block.appendSlice(arena, ind) catch return; + } else { + block.appendSlice(arena, ind) catch return; + block.append(arena, '\n') catch return; + } + } + const at: modal.Cursor = if (below) + .{ .row = row, .col = modal.lineSlice(f.content, row).len } + else + .{ .row = row, .col = 0 }; + const new = modal.insertAt(p.gpa, f.content, at, block.items) catch return; p.setFileContent(f, new); - pane.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0; - pane.cur_col = 0; - } else { + pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + pl.row0; + pane.cur_col = @intCast(ind.len); + } else if (below) { const bottom = pane.scroll() + @as(i32, pane.rows) - 1; pane.cur_row = @min(bottom, pane.cur_row + 1); pane.cur_col = 0; - } - pane.cur_off = 0; - pane.cur_pinned = true; - }, - .open_above => { - if (pane.file) |*f| { - const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return; - p.setFileContent(f, new); - pane.cur_col = 0; } else { const top = pane.scroll(); pane.cur_row = @max(top, pane.cur_row - 1); @@ -1733,7 +2269,6 @@ pub const Pardes = struct { pane.cur_off = if (where == .at) 0 else if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0; pane.mode = .insert; pane.msel.active = false; - pane.vsel.active = false; pane.pending = 0; pane.ensureCursorVisible(); } @@ -1741,9 +2276,18 @@ pub const Pardes = struct { /// insert mode: file panes get real content edits; terminals get run /// splices — edits only ever touch YOUR runs, shell output is immutable. fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void { + // helix aliases: normalize to the base key and re-dispatch + if (isC(key, 'h')) return p.handleInsert(pane, .{ .cp = Key.backspace }); + if (isC(key, 'j')) return p.handleInsert(pane, .{ .cp = Key.enter }); + if (isC(key, 'd')) return p.handleInsert(pane, .{ .cp = Key.delete }); + // a selection carried into insert (i/a) survives only until the next + // key: helix maps it through every edit, pardes drops it instead — + // its only pardes use (the acme chords) needs explicit selections + // anyway, and those never enter insert mode + pane.vsel.active = false; if (pane.file) |*f| { const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; - if (key.text.len > 0) { + if (key.text.len > 0 and !key.ctrl and !key.alt) { const new = modal.insertAt(p.gpa, f.content, c, key.text) catch return; p.setFileContent(f, new); for (key.text) |ch| { @@ -1756,12 +2300,61 @@ pub const Pardes = struct { pane.ensureCursorVisible(); return; } + // helix insert-mode kills (word/line; deleteSpan is exclusive) + if (isC(key, 'w') or (key.alt and !key.ctrl and key.cp == Key.backspace)) { + // helix delete_word_backward: to the previous word start — + // crossing the newline at col 0 (the word before it goes too) + const g = modal.hxOff(f.content, c); + if (g == 0) return; + const wr = modal.hxWordMove(f.content, .{ .anchor = g, .head = g }, 1, .prev_word_start); + const from = @min(wr.anchor, wr.head); + const fc = modal.hxPos(f.content, from); + const new = modal.deleteSpan(p.gpa, f.content, fc, c) catch return; + p.setFileContent(f, new); + pane.cur_row = @intCast(fc.row); + pane.cur_col = @intCast(fc.col); + pane.cur_pinned = true; + pane.ensureCursorVisible(); + return; + } + if (key.alt and !key.ctrl and (key.cp == 'd' or key.cp == Key.delete)) { + // helix delete_word_forward: to the next word END (trailing + // whitespace survives), crossing newlines at line ends + const g = modal.hxOff(f.content, c); + const wr = modal.hxWordMove(f.content, .{ .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, f.content, c, modal.hxPos(f.content, to)) catch return; + p.setFileContent(f, new); + pane.cur_pinned = true; + return; + } + if (isC(key, 'u')) { + const new = modal.deleteSpan(p.gpa, f.content, .{ .row = c.row, .col = 0 }, c) catch return; + p.setFileContent(f, new); + pane.cur_col = 0; + pane.cur_pinned = true; + pane.ensureCursorVisible(); + return; + } + if (isC(key, 'k')) { + const llen = modal.lineSlice(f.content, c.row).len; + const new = modal.deleteSpan(p.gpa, f.content, c, .{ .row = c.row, .col = llen }) catch return; + p.setFileContent(f, new); + pane.cur_pinned = true; + return; + } switch (key.cp) { Key.enter => { - const new = modal.insertAt(p.gpa, f.content, c, "\n") catch return; + // helix insert_newline: the new line copies the current + // line's full indent LEVELS (plain-text heuristic) + const ind = modal.hxIndentString(modal.lineSlice(f.content, c.row)); + const arena = p.scratch.allocator(); + const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return; + const new = modal.insertAt(p.gpa, f.content, c, block) catch return; p.setFileContent(f, new); pane.cur_row += 1; - pane.cur_col = 0; + pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; pane.ensureCursorVisible(); }, @@ -1771,8 +2364,9 @@ pub const Pardes = struct { p.setFileContent(f, new); pane.cur_col -= 1; } else if (pane.cur_row > 0) { + // at col 0: eat the previous newline (join up) const prevlen = modal.lineSlice(f.content, c.row - 1).len; - const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return; + const new = modal.deleteSpan(p.gpa, f.content, .{ .row = c.row - 1, .col = prevlen }, .{ .row = c.row, .col = 0 }) catch return; p.setFileContent(f, new); pane.cur_row -= 1; pane.cur_col = @intCast(prevlen); @@ -1793,16 +2387,101 @@ pub const Pardes = struct { fromModalCursor(pane, pl, nc); pane.ensureCursorVisible(); }, + Key.delete => { + // delete forward; at line end eat the newline (join) + const line = modal.lineSlice(f.content, c.row); + if (c.col < line.len) { + const new = modal.deleteChar(p.gpa, f.content, c) catch return; + p.setFileContent(f, new); + } else if (c.row + 1 < modal.lineCount(f.content)) { + const new = modal.deleteSpan(p.gpa, f.content, c, .{ .row = c.row + 1, .col = 0 }) catch return; + p.setFileContent(f, new); + } + pane.cur_pinned = true; + }, + Key.tab => { + // helix insert_tab with a Spaces indent style: spaces to + // the next tab stop (smart-tab machinery skipped) + const pad = modal.INDENT_W - (c.col % modal.INDENT_W); + const spaces = " "; + const new = modal.insertAt(p.gpa, f.content, c, spaces[0..pad]) catch return; + p.setFileContent(f, new); + pane.cur_col += @intCast(pad); + pane.cur_pinned = true; + pane.ensureCursorVisible(); + }, + Key.home => { + pane.cur_col = 0; + pane.cur_pinned = true; + pane.ensureCursorVisible(); + }, + Key.end => { + // helix insert End: past the last char (goto_line_end_newline) + pane.cur_col = @intCast(modal.lineSlice(f.content, c.row).len); + pane.cur_pinned = true; + pane.ensureCursorVisible(); + }, + Key.page_up, Key.page_down => { + // helix binds insert pageup/pagedown to the same view + // scroll + cursor snap as normal mode + const pl = p.paneCursorLines(pane) catch return; + const text = p.flatSurface(pane, pl) catch return; + const range = paneRange(pane, text); + const step: i32 = @intCast(@max(1, pane.rows)); + scrollViewMove(pane, pl, text, range, if (key.cp == Key.page_down) step else -step); + }, else => {}, } return; } if (!pane.cur_pinned) pane.pinCursor(); - if (key.text.len > 0) { + if (key.text.len > 0 and !key.ctrl and !key.alt) { insertRunText(pane, key.text); return; } + // kills confined to the typed run at the cursor (output is immutable) + if (isC(key, 'w') or (key.alt and !key.ctrl and key.cp == Key.backspace)) { + const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; + const e = &pane.edits[ei]; + var t: usize = @min(pane.cur_off, e.len); + while (t > 0 and e.text[t - 1] == ' ') t -= 1; + while (t > 0 and e.text[t - 1] != ' ') t -= 1; + const cut = @min(pane.cur_off, e.len) - t; + var k: usize = t; + while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut]; + e.len -= @intCast(cut); + pane.cur_off = @intCast(t); + return; + } + if (key.alt and !key.ctrl and (key.cp == 'd' or key.cp == Key.delete)) { + const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; + const e = &pane.edits[ei]; + var t: usize = @min(pane.cur_off, e.len); + while (t < e.len and e.text[t] != ' ') t += 1; + while (t < e.len and e.text[t] == ' ') t += 1; + const cut = t - @min(pane.cur_off, e.len); + var k: usize = @min(pane.cur_off, e.len); + while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut]; + e.len -= @intCast(cut); + return; + } + if (isC(key, 'u')) { + const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; + const e = &pane.edits[ei]; + const cut = @min(pane.cur_off, e.len); + var k: usize = 0; + while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut]; + e.len -= @intCast(cut); + pane.cur_off = 0; + return; + } + if (isC(key, 'k')) { + const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; + const e = &pane.edits[ei]; + e.len = @min(pane.cur_off, e.len); + return; + } switch (key.cp) { Key.backspace => { const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; @@ -1814,6 +2493,15 @@ pub const Pardes = struct { pane.cur_off -= 1; } }, + Key.delete => { + const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; + const e = &pane.edits[ei]; + if (pane.cur_off < e.len) { + var k: usize = pane.cur_off; + while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1]; + e.len -= 1; + } + }, else => {}, } } @@ -1838,36 +2526,6 @@ pub const Pardes = struct { } } - // ---- selections ---- - - /// helix `v`: toggle char-range select, anchored at the cursor - fn charSelect(pane: *Pane) void { - if (pane.vsel.active) { - pane.vsel.active = false; - } else { - pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col }; - } - pane.msel.active = false; - pane.cur_pinned = true; - pane.pending = 0; - } - - /// helix `x`: extend the line selection down by one (or start it) - fn lineSelect(pane: *Pane) void { - if (!pane.msel.active) { - pane.msel = .{ .active = true, .r0 = pane.cur_row, .r1 = pane.cur_row }; - } else { - const last = pane.lastNavRow(); - pane.msel.r1 = @min(pane.msel.r1 + 1, last); - pane.cur_row = pane.msel.r1; - } - pane.vsel.active = false; - pane.cur_col = 0; - pane.cur_off = 0; - pane.cur_pinned = true; - pane.pending = 0; - } - const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; /// the char selection [anchor, cursor] normalized to document order @@ -1939,10 +2597,12 @@ pub const Pardes = struct { // ---- edit operations (d / c / y / p) ---- - fn normalDelete(p: *Pardes, pane: *Pane) void { + 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); - p.setYank(p.vselText(pane)); // read the range before the move + if (yank) p.setYank(p.vselText(pane)); // read the range before the move p.pushUndo(pane); if (pane.file) |*f| { const d = modal.deleteRange(p.gpa, f.content, .{ .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 { @@ -1976,14 +2636,14 @@ pub const Pardes = struct { return; }; p.setFileContent(f, d.content); - p.setYank(d.deleted); + if (yank) p.setYank(d.deleted); p.gpa.free(d.deleted); const n = modal.lineCount(f.content); pane.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1)); pane.cur_col = 0; } else { clearEditsInRange(pane, r0, r1); - p.setYank(p.yankRows(pane, r0, r1)); + if (yank) p.setYank(p.yankRows(pane, r0, r1)); } pane.msel.active = false; pane.cur_off = 0; @@ -1992,8 +2652,17 @@ pub const Pardes = struct { } else if (pane.file) |*f| { p.pushUndo(pane); const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; - const new = modal.deleteChar(p.gpa, f.content, c) catch return; - p.setFileContent(f, new); + if (yank) { + const arena = p.scratch.allocator(); + p.setYank(modal.rangeText(arena, f.content, 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, f.content, c, c) catch return; + p.setFileContent(f, d.content); + p.gpa.free(d.deleted); + const llen = modal.lineSlice(f.content, c.row).len; + pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); } else { p.deleteRunCharAtCursor(pane); } @@ -2015,15 +2684,10 @@ pub const Pardes = struct { } fn normalYank(p: *Pardes, pane: *Pane) void { + pane.select = false; // helix yank exits select mode, selection KEPT if (pane.vsel.active) { - const b = vselBounds(pane); + // helix: yank moves neither the cursor nor the selection p.setYank(p.vselText(pane)); - pane.cur_row = b.lo_row; - pane.cur_col = b.lo_col; - pane.cur_off = 0; - pane.cur_pinned = true; - pane.vsel.active = false; - pane.ensureCursorVisible(); return; } if (pane.msel.active) { @@ -2043,35 +2707,131 @@ pub const Pardes = struct { } else { p.setYank(p.yankRows(pane, r0, r1)); } - pane.cur_row = r0; - pane.cur_col = 0; - pane.msel.active = false; - } else if (pane.file) |f| { - p.setYank(modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row)))); } else { - p.setYank(p.yankRows(pane, pane.cur_row, pane.cur_row)); + // helix: a bare cursor IS a 1-wide selection — yank the char + // under it, file and terminal alike (the line-yank vim-ism died + // with the motion model). vselText already speaks both. + 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 normalPaste(p: *Pardes, pane: *Pane) void { - const y = p.yank orelse return; + /// helix p/P: a yank ending in '\n' pastes as whole lines below/above + /// the SELECTION's line span; anything else splices inline at the + /// selection's outer edge. The paste (repeated <count> times) becomes + /// the implicit selection, cursor on its last char (linewise: ON the + /// last pasted line's newline). Terminals ignore `before` (runs splice + /// at the cursor either way). + fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void { + const y0 = p.yank orelse return; + if (y0.len == 0) return; p.pushUndo(pane); + pane.select = false; + pane.sticky_col = -1; + const cnt: usize = @max(1, pane.count); + pane.count = 0; + const arena = p.scratch.allocator(); + var y: []const u8 = y0; + if (cnt > 1) { + var buf: std.ArrayList(u8) = .empty; + for (0..cnt) |_| buf.appendSlice(arena, y0) catch return; + y = buf.items; + } if (pane.file) |*f| { - const new = modal.pasteLineBelow(p.gpa, f.content, @intCast(@max(0, pane.cur_row)), y) catch return; - p.setFileContent(f, new); - pane.cur_row += 1; - pane.cur_col = 0; - } else { - insertRunText(pane, y); + 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 }; + if (y[y.len - 1] == '\n') { + const block_text = y[0 .. y.len - 1]; + const n = modal.lineCount(block_text); + if (before) { + const row: usize = @intCast(@max(0, b.lo_row)); + const block = std.fmt.allocPrint(arena, "{s}\n", .{block_text}) catch return; + const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = 0 }, block) catch return; + p.setFileContent(f, new); + pane.cur_row = @intCast(row + n - 1); + } else { + const row: usize = @intCast(@max(0, b.hi_row)); + const new = modal.pasteLineBelow(p.gpa, f.content, row, block_text) catch return; + p.setFileContent(f, new); + pane.cur_row = @intCast(row + n); + } + 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(f.content, @intCast(pane.cur_row)).len; + pane.cur_col = @intCast(llen); + } else { + const at: modal.Cursor = if (before) + .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) } + else blk: { + const hrow: usize = @intCast(@max(0, b.hi_row)); + const gcol = @as(usize, @intCast(@max(0, b.hi_col))) + 1; + if (gcol > modal.lineSlice(f.content, hrow).len) break :blk .{ .row = hrow + 1, .col = 0 }; + break :blk .{ .row = hrow, .col = gcol }; + }; + const new = modal.insertAt(p.gpa, f.content, at, y) catch return; + p.setFileContent(f, new); + pane.vsel = .{ .active = y.len > 1, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; + const end = modal.advanceBy(at, y); + if (end.col > 0) { + pane.cur_row = @intCast(end.row); + pane.cur_col = @intCast(end.col - 1); + } else { + pane.cur_row = @intCast(end.row -| 1); + pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); + } + } + pane.msel.active = false; + pane.cur_off = 0; + pane.cur_pinned = true; + pane.ensureCursorVisible(); + return; } + insertRunText(pane, y); pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0; 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); + // helix: a LINEWISE selection (whole lines incl. their final + // newline) deletes the lines and opens a fresh one above, + // copying the indent of the line that moves up + if (pane.file) |*f| { + const hi_len = modal.lineSlice(f.content, @intCast(@max(0, b.hi_row))).len; + const linewise = b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len; + if (linewise) { + p.setYank(p.vselText(pane)); + p.pushUndo(pane); + const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, b.lo_row)), @intCast(@max(0, b.hi_row))) catch return; + p.setFileContent(f, d.content); + p.gpa.free(d.deleted); + const n = modal.lineCount(f.content); + const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row))), if (n == 0) 0 else n - 1); + const ind = modal.hxIndentString(modal.lineSlice(f.content, row)); + const arena = p.scratch.allocator(); + const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return; + const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = 0 }, block) catch return; + p.setFileContent(f, new); + pane.cur_row = @intCast(row); + pane.cur_col = @intCast(ind.len); + pane.vsel.active = false; + pane.msel.active = false; + pane.cur_off = 0; + pane.cur_pinned = true; + pane.mode = .insert; + pane.pending = 0; + pane.ensureCursorVisible(); + return; + } + } p.setYank(p.vselText(pane)); p.pushUndo(pane); if (pane.file) |*f| { @@ -2143,8 +2903,13 @@ pub const Pardes = struct { } else if (pane.file) |*f| { p.pushUndo(pane); const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; - const new = modal.deleteChar(p.gpa, f.content, c) catch return; - p.setFileContent(f, new); + const arena = p.scratch.allocator(); + p.setYank(modal.rangeText(arena, f.content, c, c) catch return); + const d = modal.deleteRange(p.gpa, f.content, c, c) catch return; + p.setFileContent(f, d.content); + p.gpa.free(d.deleted); + const llen = modal.lineSlice(f.content, c.row).len; + pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); pane.mode = .insert; pane.cur_off = 0; pane.pending = 0; @@ -2156,6 +2921,434 @@ pub const Pardes = struct { } } + // ---- helix change ops (r R ~ ` J > < Ctrl-a m-mode ]space) ---- + // Terminals follow the immutable-output rule: r/~/`/Alt-` touch the typed + // run byte under the cursor if there is one; the rest are clean no-ops. + + /// the typed-run byte under the (visual) cursor, if any + fn runByteAt(pane: *Pane, row: i32, col: i32) ?*u8 { + for (pane.edits[0..pane.nedits]) |*e| { + if (e.row != row or e.len == 0) continue; + const vstart = pane.renderedCol(e.row, e.col, 0); + if (col < vstart or col >= vstart + @as(i32, e.len)) continue; + return &e.text[@intCast(col - vstart)]; + } + return null; + } + + /// the selection as an inclusive cursor range: vsel span, msel line span, + /// else the char under the cursor (helix's implicit 1-wide selection) + fn selRange(pane: *Pane, content: []const u8) modal.Range { + if (pane.vsel.active) { + const b = vselBounds(pane); + return .{ + .a = .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, + .b = .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }, + }; + } + if (pane.msel.active) { + const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1))); + const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1))); + const llen = modal.lineSlice(content, r1).len; + return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = llen -| 1 } }; + } + const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; + return .{ .a = c, .b = c }; + } + + /// `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: u8) void { + pane.select = false; + if (pane.file) |*f| { + const r = selRange(pane, f.content); + p.pushUndo(pane); + const new = modal.replaceChars(p.gpa, f.content, r.a, r.b, ch) catch return; + p.setFileContent(f, new); + } else if (runByteAt(pane, pane.cur_row, pane.cur_col)) |b| { + p.pushUndo(pane); + b.* = ch; + } + } + + /// `R`: replace the selection (or the cursor char) with the yank register; + /// the pasted text becomes the selection, head on its last char (which + /// for a trailing-newline yank is the '\n' cell of the last full line) + fn normalReplaceYank(p: *Pardes, pane: *Pane) void { + const y = p.yank orelse return; + if (y.len == 0) return; + if (pane.file == null) return; // shell output is immutable + pane.select = false; + const f = &pane.file.?; + const r = selRange(pane, f.content); + p.pushUndo(pane); + const new = modal.replaceRange(p.gpa, f.content, r.a, r.b, y) catch return; + p.setFileContent(f, new); + pane.msel.active = false; + pane.vsel = .{ .active = y.len > 1, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; + const end = modal.advanceBy(r.a, y); + if (end.col > 0) { + pane.cur_row = @intCast(end.row); + pane.cur_col = @intCast(end.col - 1); + } else { + // the yank ended in '\n': the cursor lands ON that newline + pane.cur_row = @intCast(end.row -| 1); + pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); + } + pane.cur_off = 0; + 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; + if (pane.file) |*f| { + const r = selRange(pane, f.content); + p.pushUndo(pane); + const new = modal.changeCase(p.gpa, f.content, r.a, r.b, op) catch return; + p.setFileContent(f, new); + } else if (runByteAt(pane, pane.cur_row, pane.cur_col)) |b| { + p.pushUndo(pane); + b.* = switch (op) { + .toggle => if (std.ascii.isUpper(b.*)) std.ascii.toLower(b.*) else std.ascii.toUpper(b.*), + .lower => std.ascii.toLower(b.*), + .upper => std.ascii.toUpper(b.*), + }; + } + } + + /// a change to apply to file content: delete [from, to), insert `ins` + /// spaces at `from`. Ascending and disjoint. + const TextChange = struct { from: usize, to: usize, ins: usize }; + + /// map an original-text offset through a change list (insertions AT a + /// position push it right — helix Assoc::After; positions inside a + /// deleted span collapse to its start) + 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); + continue; + } + if (pos == ch.from) break; + if (pos < ch.to) return @intCast(@as(i64, @intCast(ch.from)) + delta); + delta += @as(i64, @intCast(ch.ins)) - @as(i64, @intCast(ch.to - ch.from)); + } + return @intCast(@as(i64, @intCast(pos)) + delta); + } + + /// apply a change list; the insertions are spaces (join/indent only ever + /// insert those). gpa-owned result. + fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(p.gpa); + var copied: usize = 0; + for (chs) |ch| { + try out.appendSlice(p.gpa, text[copied..ch.from]); + for (0..ch.ins) |_| try out.append(p.gpa, ' '); + copied = ch.to; + } + try out.appendSlice(p.gpa, text[copied..]); + return out.toOwnedSlice(p.gpa); + } + + /// `J`: helix join_selections — join the selection's line span (a bare + /// cursor joins with the next line): each '\n' + following indent become + /// one space, EXCEPT before content-less lines (no space) — and on the + /// buffer's last line the trailing newline is deleted. The selection and + /// cursor map through the edit; the count is ignored (helix). + fn normalJoin(p: *Pardes, pane: *Pane) void { + if (pane.file == null) return; // joining shell output would edit it + const f = &pane.file.?; + const text = f.content; + const range = paneRange(pane, text); + const span = rangeLineSpan(text, range); + const nlines = modal.hxLineCount(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(); + var chs: std.ArrayList(TextChange) = .empty; + var l = span.start; + while (l < end) : (l += 1) { + const from = modal.hxLineEndIdx(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: usize = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) 0 else 1; + chs.append(arena, .{ .from = from, .to = to, .ins = sep }) catch return; + } + if (chs.items.len == 0) return; + p.pushUndo(pane); + const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + const anc_off = if (pane.vsel.active) + modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) + else + cur_off; + const new = p.applyChanges(text, chs.items) catch return; + const nc = mapThroughChanges(chs.items, cur_off); + const na = mapThroughChanges(chs.items, anc_off); + p.setFileContent(f, new); + const cc = modal.hxPos(f.content, nc); + const ac = modal.hxPos(f.content, na); + pane.cur_row = @intCast(cc.row); + pane.cur_col = @intCast(cc.col); + if (pane.vsel.active) { + pane.vsel.row = @intCast(ac.row); + pane.vsel.col = @intCast(ac.col); + } + pane.msel.active = false; + pane.cur_off = 0; + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); + } + + /// `>` / `<`: helix indent/unindent over the selection's line span. + /// Blank (all-whitespace) lines are skipped; `>` inserts count levels + /// realigned to the next INDENT_W stop; `<` removes up to count levels of + /// leading whitespace (a tab advances to the next stop). Cursor and + /// selection map through the edit. + fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void { + if (pane.file == null) return; // shell output is immutable + pane.select = false; + const f = &pane.file.?; + const text = f.content; + const range = paneRange(pane, text); + const span = rangeLineSpan(text, range); + const arena = p.scratch.allocator(); + var chs: std.ArrayList(TextChange) = .empty; + var l = span.start; + while (l <= span.end) : (l += 1) { + const ls = modal.lineStartOffset(text, l); + const le = modal.hxLineEndIdx(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.append(arena, .{ .from = ls, .to = ls, .ins = ins }) catch return; + } 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.append(arena, .{ .from = ls, .to = ls + pos, .ins = 0 }) catch return; + } + } + if (chs.items.len == 0) return; + p.pushUndo(pane); + const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + const anc_off = if (pane.vsel.active) + modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) + else + cur_off; + const new = p.applyChanges(text, chs.items) catch return; + const nc = mapThroughChanges(chs.items, cur_off); + const na = mapThroughChanges(chs.items, anc_off); + p.setFileContent(f, new); + const cc = modal.hxPos(f.content, nc); + const ac = modal.hxPos(f.content, na); + pane.cur_row = @intCast(cc.row); + pane.cur_col = @intCast(cc.col); + if (pane.vsel.active) { + pane.vsel.row = @intCast(ac.row); + pane.vsel.col = @intCast(ac.col); + } + pane.msel.active = false; + pane.cur_off = 0; + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); + } + + /// `Ctrl-a` / `Ctrl-x`: increment/decrement the SELECTION as a decimal + /// integer (helix: the selected fragment itself, no number scan around + /// the cursor); a fragment that isn't an integer is a no-op. The new + /// number becomes the selection, cursor on its last char. + fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void { + if (pane.file == null) return; // shell output is immutable + const f = &pane.file.?; + const r = selRange(pane, f.content); + const arena = p.scratch.allocator(); + const frag = modal.rangeText(arena, f.content, r.a, r.b) catch return; + const rep = (modal.hxIncrement(arena, frag, delta) catch null) orelse return; + p.pushUndo(pane); + pane.select = false; + const new = modal.replaceRange(p.gpa, f.content, r.a, r.b, rep) catch return; + p.setFileContent(f, new); + const start = modal.lineStartOffset(f.content, r.a.row) + r.a.col; + const cc = modal.hxPos(f.content, start + rep.len - 1); + pane.vsel = .{ .active = rep.len > 1, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; + pane.msel.active = false; + pane.cur_row = @intCast(cc.row); + pane.cur_col = @intCast(cc.col); + pane.cur_off = 0; + 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 { + if (pane.file == null) return; // shell output is immutable + const f = &pane.file.?; + p.pushUndo(pane); + const row: usize = @intCast(@max(0, pane.cur_row)); + const arena = p.scratch.allocator(); + const nl = arena.alloc(u8, cnt) catch return; + @memset(nl, '\n'); + const llen = modal.lineSlice(f.content, row).len; + const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return; + p.setFileContent(f, 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, + }; + } + + /// `mi<obj>` / `ma<obj>`: select inside/around a textobject (pure range + /// math over the motion surface — works on terminals too). Word and + /// paragraph objects are helix textobject.rs ports; pairs/quotes are the + /// plain-text scans (quotes line-scoped — ponytail). + fn textobjectSelect(p: *Pardes, pane: *Pane, pl: PaneLines, obj: u21, around: bool) void { + const text = p.flatSurface(pane, pl) catch return; + const range = paneRange(pane, text); + switch (obj) { + 'w', 'W' => { + const r = modal.hxTextobjectWord(text, range, around, obj == 'W'); + return setPaneRange(pane, pl, text, r, false); + }, + 'p' => { + const r = modal.hxTextobjectParagraph(text, range, around, 1); + return setPaneRange(pane, pl, text, r, false); + }, + else => {}, + } + const cur = modal.hxPos(text, modal.hxCursor(text, range)); + const pair: ?modal.Range = switch (obj) { + '\'', '"', '`' => modal.enclosingQuote(pl.lines, cur, @intCast(obj)), + '(', ')' => modal.enclosingPair(pl.lines, cur, '(', ')'), + '[', ']' => modal.enclosingPair(pl.lines, cur, '[', ']'), + '{', '}' => modal.enclosingPair(pl.lines, cur, '{', '}'), + '<', '>' => modal.enclosingPair(pl.lines, cur, '<', '>'), + else => null, + }; + const pr = pair orelse return; + var a = modal.hxOff(text, pr.a); + var head = modal.nextGrapheme(text, modal.hxOff(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); + } + setPaneRange(pane, pl, text, .{ .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 { + if (pane.file == null) return; // shell output is immutable + const pr = pairFor(ch) orelse return; + pane.select = false; + const f = &pane.file.?; + const r = selRange(pane, f.content); + p.pushUndo(pane); + var new = modal.insertAt(p.gpa, f.content, .{ .row = r.b.row, .col = r.b.col + 1 }, &[1]u8{pr.c}) catch return; + p.setFileContent(f, new); + new = modal.insertAt(p.gpa, f.content, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; + p.setFileContent(f, new); + pane.msel.active = false; + pane.vsel = .{ .active = true, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; + pane.cur_row = @intCast(r.b.row); + pane.cur_col = @intCast(r.b.col + 1 + @as(usize, if (r.a.row == r.b.row) 1 else 0)); + pane.cur_off = 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, pl: PaneLines, ch: u21) void { + if (pane.file == null) return; // shell output is immutable + const pr = pairFor(ch) orelse return; + const f = &pane.file.?; + const text = p.flatSurface(pane, pl) catch return; + const cur = toModalCursor(pane, pl); + const r = (if (pr.o == pr.c) + modal.enclosingQuote(pl.lines, cur, pr.o) + else + modal.enclosingPair(pl.lines, cur, pr.o, pr.c)) orelse return; + p.pushUndo(pane); + const a_off = modal.hxOff(text, r.a); + const b_off = modal.hxOff(text, r.b); + const cur_off = modal.hxOff(text, cur); + // the close first, so the open's position stays valid + var new = modal.deleteChar(p.gpa, f.content, r.b) catch return; + p.setFileContent(f, new); + new = modal.deleteChar(p.gpa, f.content, r.a) catch return; + p.setFileContent(f, new); + var nc = cur_off; + if (nc > b_off) nc -= 1; + if (nc > a_off) nc -= 1; + const cc = modal.hxPos(f.content, nc); + pane.cur_row = @intCast(cc.row); + pane.cur_col = @intCast(cc.col); + pane.vsel.active = false; + pane.msel.active = false; + pane.cur_off = 0; + 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, pl: PaneLines, from: u21, to: u21) void { + if (pane.file == null) return; // shell output is immutable + const fp = pairFor(from) orelse return; + const tp = pairFor(to) orelse return; + const f = &pane.file.?; + const cur = toModalCursor(pane, pl); + const r = (if (fp.o == fp.c) + modal.enclosingQuote(pl.lines, cur, fp.o) + else + modal.enclosingPair(pl.lines, cur, fp.o, fp.c)) orelse return; + p.pushUndo(pane); + const out = p.gpa.dupe(u8, f.content) catch return; + out[modal.lineStartOffset(out, r.a.row) + r.a.col] = tp.o; + out[modal.lineStartOffset(out, r.b.row) + r.b.col] = tp.c; + p.setFileContent(f, out); + } + // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- fn setFileContent(p: *Pardes, f: *File, new: []u8) void { @@ -2192,14 +3385,19 @@ pub const Pardes = struct { fn pushUndo(p: *Pardes, pane: *Pane) void { if (pane.file) |*f| { - if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1], f.content)) return; - const snap = p.gpa.dupe(u8, f.content) catch return; + if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1].content, f.content)) return; + const snap: FileSnap = .{ + .content = p.gpa.dupe(u8, f.content) catch return, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }; f.undo.append(p.gpa, snap) catch { - p.gpa.free(snap); + p.gpa.free(snap.content); return; }; - if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0)); - for (f.redo.items) |s| p.gpa.free(s); + if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0).content); + for (f.redo.items) |s| p.gpa.free(s.content); f.redo.clearRetainingCapacity(); } else { const snap = snapTerm(pane); @@ -2213,14 +3411,19 @@ pub const Pardes = struct { fn doUndo(p: *Pardes, pane: *Pane) void { if (pane.file) |*f| { if (f.undo.items.len == 0) return; - const cur = p.gpa.dupe(u8, f.content) catch return; + const cur: FileSnap = .{ + .content = p.gpa.dupe(u8, f.content) catch return, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }; f.redo.append(p.gpa, cur) catch { - p.gpa.free(cur); + p.gpa.free(cur.content); return; }; const prev = f.undo.pop() orelse return; - p.setFileContent(f, prev); - clampCursorToFile(pane, f); + p.setFileContent(f, prev.content); + restoreFileSnap(pane, f, prev); return; } if (pane.ed_undo.items.len == 0) return; @@ -2231,14 +3434,19 @@ pub const Pardes = struct { fn doRedo(p: *Pardes, pane: *Pane) void { if (pane.file) |*f| { if (f.redo.items.len == 0) return; - const cur = p.gpa.dupe(u8, f.content) catch return; + const cur: FileSnap = .{ + .content = p.gpa.dupe(u8, f.content) catch return, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }; f.undo.append(p.gpa, cur) catch { - p.gpa.free(cur); + p.gpa.free(cur.content); return; }; const next = f.redo.pop() orelse return; - p.setFileContent(f, next); - clampCursorToFile(pane, f); + p.setFileContent(f, next.content); + restoreFileSnap(pane, f, next); return; } if (pane.ed_redo.items.len == 0) return; @@ -2246,6 +3454,22 @@ pub const Pardes = struct { restoreTerm(pane, pane.ed_redo.pop() orelse return); } + /// undo/redo restores the selection recorded with the snapshot (helix + /// keeps selections in its history transactions) + fn restoreFileSnap(pane: *Pane, f: *File, snap: FileSnap) void { + const n = modal.lineCount(f.content); + const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), if (n == 0) 0 else n - 1); + const llen = modal.lineSlice(f.content, row).len; + pane.cur_row = @intCast(row); + pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); + pane.vsel = snap.vsel; + pane.msel.active = false; + pane.cur_off = 0; + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); + } + fn restoreTerm(pane: *Pane, snap: EditSnap) void { pane.edits = snap.edits; pane.nedits = snap.nedits; @@ -2544,6 +3768,7 @@ pub const Pardes = struct { Key.down => "\x1b[B", Key.right => "\x1b[C", Key.left => "\x1b[D", + Key.delete => "\x1b[3~", else => null, }; }; @@ -2735,6 +3960,25 @@ pub const Pardes = struct { return pane; } + /// TEST-ONLY, called by test/hxdiff.zig (the helix differential harness): + /// swap the tty_only boot pane for a file pane holding `content` verbatim + /// — openFileView minus the disk read (cases carry their buffer inline). + /// Unreachable from any shell; keep it dumb. + pub fn hxOpenFileContent(p: *Pardes, content: []const u8) !*Pane { + const copy = try p.gpa.dupe(u8, content); + errdefer p.gpa.free(copy); + const path = try p.gpa.dupe(u8, "/hxcase.txt"); + errdefer p.gpa.free(path); + p.deinitPane(p.panes[0].?); + p.panes[0] = null; + const pane = try p.newDocPane(0); + pane.file = .{ .path = path, .content = copy }; + pane.cur_pinned = true; + p.active = 0; + p.sync(); + return pane; + } + fn openImageView(p: *Pardes, id: usize, path: []const u8, petscii: bool) !*Pane { const path_copy = try p.gpa.dupe(u8, path); errdefer p.gpa.free(path_copy); |
