diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 2268 |
1 files changed, 1232 insertions, 1036 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index e32e2869..c88687d0 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -47,35 +47,99 @@ const file_pane_builtins_str = "Save Del"; const pane_tail = " " ++ pane_builtins_str; const file_pane_tail = " " ++ file_pane_builtins_str; -// Builtins: executing the name (middle-click / Tab) runs the action through the -// ONE dispatcher (onSelect + actOnSelection), no matter where the name appears. -const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, crt, dump, restore }; -const Builtin = struct { name: []const u8, action: SelectAction }; -const builtins = [_]Builtin{ - .{ .name = "Kill", .action = .kill }, - .{ .name = "Newcol", .action = .newcol }, - .{ .name = "Tutor", .action = .tutor }, - .{ .name = "Debug", .action = .debug }, - .{ .name = "Colors", .action = .colors }, - .{ .name = "NextColor", .action = .nextcolor }, - .{ .name = "Crt", .action = .crt }, - .{ .name = "Dump", .action = .dump }, - .{ .name = "Restore", .action = .restore }, - .{ .name = "Save", .action = .save }, - .{ .name = "Del", .action = .del }, - .{ .name = "Delcol", .action = .delcol }, +// Builtins: executing the name (middle-click / Tab) runs it through the ONE +// dispatcher (runBuiltin, reached from actOnSelection), no matter where the +// name appears. The enum FIELD NAME is the user-visible word — the one in the +// topbar, the one sitting in a tag, the one Help prints, the one you execute +// — so `std.meta.stringToEnum` IS the lookup and there is no name table to +// keep in sync. Everything else about a builtin (its leader key path, its +// Help line) is folded out of this enum at comptime, below. A name that is +// not a legal identifier would be spelled `@"..."`; zig fmt strips the +// quotes from the ones that are. +const Builtin = enum { + Kill, + Newcol, + Tutor, + Debug, + Colors, + NextColor, + Crt, + Dump, + Restore, + Save, + Del, + Delcol, + Help, }; -fn onSelect(button: Mouse.Button, text: []const u8) SelectAction { - if (button != .middle) return .none; - const cmd = std.mem.trim(u8, text, " \t\r\n"); - if (cmd.len == 0) return .none; - inline for (builtins) |b| { - if (std.mem.eql(u8, cmd, b.name)) return b.action; +/// SPC leader: ONE key path per builtin, the whole remapping surface. An +/// EnumArray, so a new enum field is a compile error until it has a path. +/// Groups are just shared first letters (h docs, c columns, t toggles, s +/// session); `?` is Help's path and is honored at ANY depth — it lists what +/// the prefix typed so far can still reach. +const leader_path = std.EnumArray(Builtin, []const u8).init(.{ + .Help = "?", + .Del = "d", + .Kill = "k", + .Save = "w", // vim's :w — `s` is the session group + .Tutor = "ht", + .Newcol = "cn", + .Delcol = "cd", + .Debug = "td", + .Colors = "tc", + .NextColor = "tn", + .Crt = "tr", + .Dump = "sd", + .Restore = "sr", +}); + +/// The leader tree, flattened: one row per builtin SORTED BY PATH, so every +/// prefix's subtree is a contiguous run — which is all the two runtime +/// readers need. The matcher asks "exact hit? still a prefix of something?" +/// and Help filters the same rows by the same prefix. A node-and-pointer trie +/// for thirteen two-character paths would be ceremony. `line` is the Help +/// rendering, padded here so Help is a filter over an array of lines. +const Leader = struct { path: []const u8, cmd: Builtin, line: []const u8 }; +const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { + var rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = undefined; + // the widest spaced-out path ("h t" = 3), so the names line up + var keyw: usize = 0; + for (std.enums.values(Builtin)) |b| keyw = @max(keyw, 2 * leader_path.get(b).len - 1); + for (std.enums.values(Builtin), 0..) |b, i| { + const path = leader_path.get(b); + var keys: []const u8 = ""; + for (path, 0..) |c, k| { + if (k > 0) keys = keys ++ " "; + keys = keys ++ &[_]u8{c}; + } + rows[i] = .{ + .path = path, + .cmd = b, + .line = "SPC " ++ keys ++ (" " ** (keyw - keys.len)) ++ " " ++ @tagName(b), + }; + } + // insertion sort by path: a group sorts right before what extends it + for (1..rows.len) |i| { + var j = i; + while (j > 0 and std.mem.lessThan(u8, rows[j].path, rows[j - 1].path)) : (j -= 1) { + const t = rows[j]; + rows[j] = rows[j - 1]; + rows[j - 1] = t; + } + } + break :blk rows; +}; + +// The topbar is a HAND-PICKED subset in a fixed order, not a derivation: row 0 +// is where topbar clicks land, so its exact bytes are load-bearing (every +// snapshot golden records the column each word starts at). Comptime-checked +// against the enum so a rename cannot silently rot it. +comptime { + @setEvalBranchQuota(4000); // the tokenizer walks the string one branch per byte + var it = std.mem.tokenizeScalar(u8, topbar_str, ' '); + while (it.next()) |w| { + if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w); } - // Restore takes the dump path as its first argument - if (std.mem.startsWith(u8, cmd, "Restore ") or std.mem.startsWith(u8, cmd, "Restore\t")) return .restore; - return .send; } /// the whitespace-delimited word covering `col` in `str` (topbar dispatch) @@ -113,7 +177,7 @@ const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 }; const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e }; const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 }; const UNDO_MAX = 256; -const EDIT_UNDO_MAX = 64; // terminal snapshots are ~2KB each; cap tighter +const EDIT_UNDO_MAX = 64; // terminal snapshots copy the edit buffer; cap tighter const gray = struct { const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (overlay bg) const mid = [3]u8{ 0x52, 0x52, 0x52 }; // move preview @@ -304,6 +368,10 @@ pub const Key = struct { text: []const u8 = "", ctrl: bool = false, alt: bool = false, + /// Only consulted for keys whose codepoint does NOT already carry the + /// shift (Escape and friends) — `A` is `A`, not shift-`a`, so `is()` + /// ignores this field on purpose. + shift: bool = false, pub const enter: u21 = 0x0D; pub const escape: u21 = 0x1B; @@ -383,17 +451,22 @@ pub const Effect = union(enum) { pub const Mode = enum { normal, insert, tty }; -pub const EDIT_CAP = 128; // max chars in one insertion run -pub const MAX_EDITS = 16; // max concurrent insertion runs per pane - -/// A char-mode insertion run, anchored at an ABSOLUTE body row (rides the -/// scrollback, not the screen). Rendering splices it into the row, shifting -/// the shell's text right — typed text never overwrites output. -pub const Edit = struct { +/// A terminal's edit buffer: real gpa-owned text standing in for the `rows` +/// shell rows starting at ABSOLUTE body row `row`. It is edited by exactly +/// the machinery File.content is — that is the whole point, a terminal in +/// insert mode has to behave like a text file — and it rides the scrollback +/// the way the char-mode insertion runs it replaced did. The vt grid itself +/// is never written, so output keeps streaming underneath. +/// +/// Seeded from the rows it stands in for the first time you type, and grown +/// whenever an edit reaches past either end (absorbing the shell rows it +/// swallows). When it ends up holding MORE lines than the rows it covers — +/// which is what pressing Enter does — the surface below simply slides down +/// by the difference: see Pane.surfRow. +pub const Ovl = struct { row: i32 = 0, - col: i32 = 0, - len: u16 = 0, - text: [EDIT_CAP]u8 = undefined, + rows: i32 = 1, + text: []u8 = &.{}, }; /// One mouse selection (block-shaped), per button. c/r are text-area relative; @@ -425,13 +498,14 @@ pub const CharSel = struct { explicit: bool = false, }; -/// Undo snapshot of a terminal pane's edit state (whole-state, dumb on purpose). +/// Undo snapshot of a terminal pane's edit buffer (whole-state, dumb on +/// purpose). `ovl` is null for a pane nothing has been typed into yet, and +/// owns its text exactly like FileSnap does. pub const EditSnap = struct { - edits: [MAX_EDITS]Edit, - nedits: usize, + ovl: ?Ovl, cur_row: i32, cur_col: i32, - cur_off: u16, + vsel: CharSel, }; /// Undo snapshot of a file pane: content + the selection at commit time — @@ -447,6 +521,12 @@ pub const FileSnap = struct { pub const File = struct { path: []u8, content: []u8, + /// an OUTPUT buffer (acme's +Errors): a file pane with no file behind it, + /// where the core shows text it produced itself (search results). The ONLY + /// difference is that it cannot be saved — every mode, motion, chord and + /// look works because it IS a file pane. `path` still names it (and gives + /// its directory) so looks inside it resolve like anywhere else. + output: bool = false, scroll: usize = 0, /// one syntax.Syn byte per content byte in the highlighted window highlights: []u8 = &.{}, @@ -514,14 +594,15 @@ pub const Pane = struct { /// 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: the pattern is typed into the tag tail (no popup); /// search_at is the tail length before " /" was appended (submit/cancel - /// restore it). n/N walk the grep results in pane search_pane, starting - /// just below search_row (the results shell's prompt row at submit). + /// restore it). Enter fills the output buffer in pane search_pane with one + /// result per row; n/N walk it, search_row being the row they last landed + /// on (null = fresh results, nothing stepped to yet). search_edit: bool = false, search_at: u16 = 0, search_pane: ?usize = null, - search_row: i32 = 0, + search_row: ?usize = null, /// the editable tag tail: seeded with the default builtins on first touch; /// edited in place by a modal one-line editor sharing the pane's mode tag_tail: std.ArrayList(u8) = .empty, @@ -539,9 +620,9 @@ pub const Pane = struct { /// the base for look-resolution of relative paths. cwd: [1024]u8 = undefined, cwd_len: u16 = 0, - /// modal cursor, anchored at ABSOLUTE body rows. Tracks the shell cursor - /// until pinned by a click or a key. In normal mode cur_col is a visual - /// column; in insert mode it is the run anchor and cur_off the offset in it. + /// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines, + /// or the terminal's shell rows with its edit buffer standing in). Tracks + /// the shell cursor until pinned by a click or a key. cur_pinned: bool = false, cur_row: i32 = 0, cur_col: i32 = 0, @@ -551,10 +632,9 @@ pub const Pane = struct { /// the goal is just being able to read long lines. Byte columns, like the /// rest of the file-pane code. hscroll: i32 = 0, - cur_off: u16 = 0, sel: [3]Sel = @splat(.{}), - edits: [MAX_EDITS]Edit = @splat(.{}), - nedits: usize = 0, + /// terminals only: the typed-text buffer standing in for shell rows + ovl: ?Ovl = null, /// every raw pty byte, in order — the dump/replay record. tty_stream: std.ArrayList(u8) = .empty, /// query replies ghostty computed (DSR, DA, kitty); the stream handler has @@ -571,10 +651,31 @@ pub const Pane = struct { return pane.file == null and pane.image == null; } + /// Surface row of shell row `g`. The edit buffer's lines stand in for the + /// `rows` shell rows it covers, so everything below it slides by the + /// difference — the identity on files and on terminals nobody has typed + /// a newline into, which is why the rest of the row math can stay naive. + fn surfRow(pane: *const Pane, g: i32) i32 { + const o = pane.ovl orelse return g; + if (g <= o.row) return g; + const lines: i32 = @intCast(modal.lineCount(o.text)); + if (g >= o.row + o.rows) return g + lines - o.rows; + return @min(g, o.row + lines - 1); // inside the buffer: its own rows + } + + /// the inverse; every surface row inside the edit buffer maps to its anchor + fn gridRow(pane: *const Pane, s: i32) i32 { + const o = pane.ovl orelse return s; + if (s <= o.row) return s; + const lines: i32 = @intCast(modal.lineCount(o.text)); + if (s < o.row + lines) return o.row; + return s - lines + o.rows; + } + /// current scroll offset: file top line, or the scrollback offset fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); - return @intCast(pane.vt.screens.active.pages.scrollbar().offset); + return pane.surfRow(@intCast(pane.vt.screens.active.pages.scrollbar().offset)); } fn scrollBy(pane: *Pane, delta: i32) void { @@ -587,7 +688,9 @@ pub const Pane = struct { f.syntax_dirty = true; } } else { - pane.vt.screens.active.scroll(.{ .delta_row = @intCast(delta) }); + // the vt scrolls in SHELL rows; convert through the edit buffer + const off: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + pane.vt.screens.active.scroll(.{ .delta_row = pane.gridRow(pane.surfRow(off) + delta) - off }); } } @@ -624,37 +727,18 @@ pub const Pane = struct { return pane.scroll() + @as(i32, pane.rows) - 1; } - /// the insertion run anchored exactly at (row, col), if any - fn runAt(pane: *Pane, row: i32, col: i32) ?usize { - for (pane.edits[0..pane.nedits], 0..) |e, i| { - if (e.row == row and e.col == col) return i; - } - return null; - } - fn pinCursor(pane: *Pane) void { if (pane.cur_pinned) return; - const off = pane.scroll(); if (pane.file != null) { - pane.cur_row = off; + pane.cur_row = pane.scroll(); pane.cur_col = 0; } else { - pane.cur_row = @as(i32, @intCast(pane.vt.screens.active.cursor.y)) + off; + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + pane.cur_row = pane.surfRow(@as(i32, @intCast(pane.vt.screens.active.cursor.y)) + goff); pane.cur_col = @intCast(pane.vt.screens.active.cursor.x); } - pane.cur_off = 0; pane.cur_pinned = true; } - - /// rendered column of a position: original col + earlier-on-row insertions - /// + the offset into the run at this col. - fn renderedCol(pane: *Pane, row: i32, col: i32, off: u16) i32 { - var rc: i32 = col + @as(i32, off); - for (pane.edits[0..pane.nedits]) |e| { - if (e.row == row and e.col < col) rc += @as(i32, e.len); - } - return rc; - } }; const Drag = union(enum) { @@ -728,6 +812,12 @@ pub const Pardes = struct { hover_col: u16 = 0, hover_row: u16 = 0, ctrl_w_pending: bool = false, + /// SPC leader in flight, holding the key path typed so far (empty = just + /// SPC). Global like ctrl_w_pending — there is one leader and it acts on + /// the active pane, whose tag shows the pending path while it waits. + leader_on: bool = false, + leader_keys: [4]u8 = undefined, + leader_n: u8 = 0, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, /// middle+left chord argument: a kept left selection captured by a left @@ -860,8 +950,11 @@ pub const Pardes = struct { for (f.redo.items) |s| p.gpa.free(s.content); f.redo.deinit(p.gpa); } + if (pane.ovl) |o| p.gpa.free(o.text); pane.tag_tail.deinit(p.gpa); + for (pane.ed_undo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_undo.deinit(p.gpa); + for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_redo.deinit(p.gpa); pane.stream.deinit(); pane.vt.deinit(p.gpa); @@ -1019,10 +1112,12 @@ pub const Pardes = struct { } /// the editable tail: the user's edited buffer once touched, else defaults + /// (an output buffer has no file to Save, so it gets the plain tail) fn curTail(pane: *Pane) []const u8 { if (pane.image != null) return ""; if (pane.tag_init) return pane.tag_tail.items; - return if (pane.file != null) file_pane_tail else pane_tail; + if (pane.file) |f| if (!f.output) return file_pane_tail; + return pane_tail; } /// focus the tag for editing, seeding the tail on first touch and placing @@ -1031,7 +1126,7 @@ pub const Pardes = struct { if (pane.image != null) return; // images keep a live toggle tag const pfx = p.tagPrefix(pane) catch return; if (!pane.tag_init) { - pane.tag_tail.appendSlice(p.gpa, if (pane.file != null) file_pane_tail else pane_tail) catch return; + pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return; pane.tag_init = true; } if (!pane.tag_edit) pane.tag_mode = pane.mode; @@ -1211,7 +1306,7 @@ pub const Pardes = struct { } /// block-selected text, newline-joined per row; reads the rendered screen - /// so typed runs and tty output select identically. Scratch-owned. + /// so typed text and shell output select identically. Scratch-owned. fn selectionText(p: *Pardes, pane: *Pane, sl: Sel) ![]const u8 { const arena = p.scratch.allocator(); const r0 = @min(sl.r0, sl.r1); @@ -1268,25 +1363,15 @@ pub const Pardes = struct { fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 { if (pane.vsel.active) return p.vselText(pane); if (!pane.msel.active) return null; - const r0 = @min(pane.msel.r0, pane.msel.r1); - const r1 = @max(pane.msel.r0, pane.msel.r1); - if (pane.file) |f| { - const arena = p.scratch.allocator(); - var buf: std.ArrayList(u8) = .empty; - const lo: usize = @intCast(@max(0, r0)); - const hi: usize = @intCast(@max(0, r1)); - var r: usize = lo; - while (r <= hi) : (r += 1) { - if (r > lo) buf.append(arena, '\n') catch break; - buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break; - } - return buf.items; - } - return p.yankRows(pane, r0, r1); + return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } fn handleKey(p: *Pardes, key: Key) void { const pane = p.panes[p.active] orelse return; + // a SPC leader in flight swallows the next key, whatever it is — + // before Ctrl-w, so a modified key abandons the sequence instead of + // arming a second prefix on top of it + if (p.leader_on) return p.leaderKey(key); // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l) if (p.ctrl_w_pending) { p.ctrl_w_pending = false; @@ -1322,19 +1407,24 @@ pub const Pardes = struct { } return; } - // the configured Ctrl-key toggles raw tty mode in and out (terminals - // only); tty is deliberately off the normal editing path - if (pane.isTerminal() and key.ctrl and key.cp == p.opts.tty_toggle) { + // the configured Ctrl-key, or Shift-Esc, toggles raw tty mode in and + // out (terminals only); tty is deliberately off the normal editing + // path. Shift-Esc needs a host that reports modifiers on Escape (the + // kitty keyboard protocol); where it doesn't it arrives as a plain + // Escape and still means what Escape always means. + const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or + (key.shift and !key.ctrl and !key.alt and key.cp == Key.escape); + if (pane.isTerminal() and tty_toggle) { if (pane.mode == .tty) { pane.mode = .normal; pane.pending = 0; } else p.enterTty(p.active); return; } - // `/` search input in flight: Enter greps, Esc abandons; both restore - // the tag tail and hand focus back to the body. + // `/` search input in flight: Enter searches, Esc abandons; both + // restore the tag tail and hand focus back to the body. if (pane.search_edit and (is(key, Key.enter) or is(key, Key.escape))) { - if (is(key, Key.enter)) p.submitSearch(pane); + if (is(key, Key.enter)) p.submitSearch(p.active); pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); exitTagEdit(pane); pane.mode = .normal; @@ -1392,19 +1482,17 @@ pub const Pardes = struct { // 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(); - } + 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; } @@ -1414,6 +1502,33 @@ pub const Pardes = struct { } } + /// A key after SPC: walk the leader tree (leader_rows, the comptime table). + /// `?` at any depth opens Help scoped to the path typed so far; an exact + /// path runs its builtin with no arguments; a key that only extends a + /// group keeps waiting. ANYTHING else abandons the sequence — a typo must + /// not leave the next keystroke armed at a builtin that closes panes, and + /// the indicator vanishing is the receipt (vim and helix drop unmapped + /// leader keys the same way). Esc lands here as one of those. + fn leaderKey(p: *Pardes, key: Key) void { + p.leader_on = false; // only "still a prefix" below re-arms it + if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return; + const c: u8 = @intCast(key.cp); + if (c == '?') return p.openHelp(p.active, p.leader_keys[0..p.leader_n]); + if (p.leader_n >= p.leader_keys.len) return; + p.leader_keys[p.leader_n] = c; + p.leader_n += 1; + const pfx = p.leader_keys[0..p.leader_n]; + for (leader_rows) |row| { + if (std.mem.eql(u8, row.path, pfx)) return p.runBuiltin(row.cmd, p.active, ""); + } + for (leader_rows) |row| { + if (std.mem.startsWith(u8, row.path, pfx)) { + p.leader_on = true; + return; + } + } + } + /// Enter raw tty. If the shell is at a prompt and the modal cursor sits on /// the input line, move the shell's REAL cursor there via ghostty's /// promptClickMove (arrow keys the shell understands) — dropping into tty @@ -1422,8 +1537,10 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; const screen = pane.vt.screens.active; if (pane.vt.cursorIsAtPrompt()) { - const off = pane.scroll(); - const vp_row: i32 = pane.cur_row - off; + // the pin is a GRID position: convert the modal cursor's surface + // row back through the edit buffer + const goff: i32 = @intCast(screen.pages.scrollbar().offset); + const vp_row: i32 = pane.gridRow(pane.cur_row) - goff; if (vp_row >= 0) { if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, pane.cur_col)), .y = @intCast(vp_row) } })) |click_pin| { const cursor_pin = screen.cursor.page_pin.*; @@ -1596,9 +1713,36 @@ pub const Pardes = struct { row0: i32, // absolute row of lines[0] }; + /// A terminal's shell rows as the surface sees them: the WHOLE + /// history+active grid, prompt rows blanked (OSC 133), absolute grid rows + /// from 0. The raw material the motion surface is composed from — the + /// edit buffer is NOT applied here, so it is also what seeding the buffer + /// reads. Scratch-arena backed. + /// ghostty's dump trims the grid's trailing blank rows; ONE of them is + /// kept back, the row the cursor sits on below the last line of output. + /// That row is a file's final newline: without it the surface would have + /// one line fewer than the same text in a document, and every motion and + /// linewise edit at the bottom would diverge. + /// ponytail: O(scrollback) dump+scan per keystroke — window it around the + /// viewport if a multi-MB scrollback ever makes navigation lag. + fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { + const arena = p.scratch.allocator(); + const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); + var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var ls: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitAny(u8, full, "\n"); + while (it.next()) |raw| { + const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; + try ls.append(arena, if (is_prompt) "" else raw); + } + try ls.append(arena, ""); + return ls.items; + } + /// 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. + /// Terminal: the whole history+active grid with the edit buffer's lines + /// standing in for the rows it covers, so motions ride the scrollback and + /// the typed text alike. Scratch-arena backed. /// (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 { @@ -1609,21 +1753,22 @@ pub const Pardes = struct { while (it.next()) |ln| try ls.append(arena, ln); return .{ .lines = ls.items, .row0 = 0 }; } - // ponytail: O(scrollback) dump+scan per keystroke — window it around the - // viewport if a multi-MB scrollback ever makes navigation lag. - const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); - var out: std.ArrayList(u8) = .empty; - var it = std.mem.splitAny(u8, full, "\n"); - var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var i: usize = 0; - while (it.next()) |raw| : (i += 1) { - if (i > 0) try out.append(arena, '\n'); - const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; - try spliceRow(arena, &out, pane, @intCast(i), if (is_prompt) "" else raw); - } + const rows = try p.shellRows(pane); + // the buffer may sit below the dump's last row (the grid's trailing + // blanks are trimmed off), so run the loop to whichever ends later + var last = rows.len; + if (pane.ovl) |o| last = @max(last, @as(usize, @intCast(@max(0, o.row + o.rows)))); var ls: std.ArrayList([]const u8) = .empty; - var lit = std.mem.splitScalar(u8, out.items, '\n'); - while (lit.next()) |ln| try ls.append(arena, ln); + var g: usize = 0; + while (g < last) : (g += 1) { + if (pane.ovl) |o| if (o.row >= 0 and g == @as(usize, @intCast(o.row))) { + var bit = std.mem.splitScalar(u8, o.text, '\n'); + while (bit.next()) |ln| try ls.append(arena, ln); + g += @intCast(o.rows - 1); + continue; + }; + try ls.append(arena, if (g < rows.len) rows[g] else ""); + } return .{ .lines = ls.items, .row0 = 0 }; } @@ -1635,7 +1780,6 @@ pub const Pardes = struct { fn fromModalCursor(pane: *Pane, pl: PaneLines, c: modal.Cursor) void { pane.cur_row = @as(i32, @intCast(c.row)) + pl.row0; pane.cur_col = @intCast(c.col); - pane.cur_off = 0; pane.cur_pinned = true; } @@ -1660,19 +1804,21 @@ pub const Pardes = struct { 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)) }); + /// the current selection as a helix gap range over `text`, whose first + /// line is absolute row `row0` (0 for the motion surface and for file + /// content; a terminal's edit buffer starts wherever it was anchored) + fn paneRange(pane: *Pane, text: []const u8, row0: i32) modal.HxRange { + const c = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .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 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 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)) }); + const a = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - row0)), .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 }; } @@ -1708,7 +1854,6 @@ pub const Pardes = struct { .explicit = explicit or pane.select, }; pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.pending = 0; @@ -1881,7 +2026,7 @@ pub const Pardes = struct { const saved_count = pane.count; pane.count = 0; - const range = paneRange(pane, text); + const range = paneRange(pane, text, pl.row0); 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: { @@ -2133,6 +2278,17 @@ pub const Pardes = struct { 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); + // SPC — the leader: a key path from here runs a BUILTIN with no + // arguments, the same builtins the topbar and the tags hold (`?` at + // any depth lists what the prefix can still reach). Helix spends Space + // on pickers, which pardes does not have; acme's builtins are exactly + // what a leader is for. Body normal mode only — a tag is always + // insert, and a tty pane's keys belong to the program. + if (is(key, ' ')) { + p.leader_on = true; + p.leader_n = 0; + return; + } // `:` — vim's command line with acme's vocabulary: focus the pane's own // tag in normal mode, parked at the tail's start. Motions walk the // words and the execute chord runs the one under the cursor, then @@ -2143,23 +2299,26 @@ pub const Pardes = struct { if (pane.tag_edit) pane.mode = .normal; return; } - // file search: `/` types a pattern into the tag, n/N walk the results - if (pane.file != null and is(key, '/')) return p.startSearch(pane); - if (pane.file != null and is(key, 'n')) return p.searchStep(pane, 1); - if (pane.file != null and is(key, 'N')) return p.searchStep(pane, -1); - // terminal n/N: look the next/previous output row that looks somewhere - if (pane.isTerminal() and is(key, 'n')) return p.lookStep(pane, pl, 1); - if (pane.isTerminal() and is(key, 'N')) return p.lookStep(pane, pl, -1); + // search: `/` types a pattern into the tag, n/N walk the results. Same + // keys on every kind of pane; a terminal with no search armed falls + // back to n/N as a motion over the lookable tokens in its output. + if (is(key, '/')) return p.startSearch(pane); + if (is(key, 'n') or is(key, 'N')) { + const delta: i32 = if (is(key, 'n')) 1 else -1; + if (p.searchStep(p.active, delta)) return; + if (pane.isTerminal()) return p.lookStep(pane, pl, delta); + return; + } } - // ---- file search (`/` + n/N): grep into a shell pane, look the results ---- + // ---- search (`/` + n/N): results into an output buffer, look the rows ---- - /// `/` on a file pane: type the pattern into the tag tail after a " /" + /// `/` on any pane: type the pattern into the tag tail after a " /" /// marker — the existing modal one-line editor, visible while typing, /// nothing that disappears. Enter/Esc are intercepted in handleKey. fn startSearch(p: *Pardes, pane: *Pane) void { if (!pane.tag_init) { - pane.tag_tail.appendSlice(p.gpa, file_pane_tail) catch return; + pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return; pane.tag_init = true; } pane.search_at = @intCast(pane.tag_tail.items.len); @@ -2174,72 +2333,96 @@ pub const Pardes = struct { /// Enter on a `/` input: peel the typed pattern off the tag tail (past the /// " /" marker) and run it through the shared search. - fn submitSearch(p: *Pardes, pane: *Pane) void { + fn submitSearch(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; const tail = pane.tag_tail.items; - p.runSearch(pane, tail[@min(@as(usize, pane.search_at) + 2, tail.len)..]); + p.runSearch(id, tail[@min(@as(usize, pane.search_at) + 2, tail.len)..]); } - /// grep `pat_raw` (the `/` input, or a right-click word/selection) into a - /// shell in the file's directory and arm n/N to walk the results below the - /// prompt row — the shared body of `/`-submit and file-pane right-click. - fn runSearch(p: *Pardes, pane: *Pane, pat_raw: []const u8) void { - const f = pane.file orelse return; + /// Search a pane's own text for `pat_raw` (plain substring, case-insensitive + /// — the same flat view Look and Execute read, so a terminal searches its + /// scrollback exactly as a file searches its content) and write the hits + /// into this pane's output buffer, one `location text` row per hit, the + /// location a bare `LINE:COL` look target ended by a SPACE (a trailing `:` + /// would read as part of it). It is the pane's path when it has a real + /// file, else `@pN` — either way an ordinary look target, so n/N is just + /// "step a row and look it". No matches = an empty buffer. + fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8) void { + const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; const arena = p.scratch.allocator(); - var cmd: std.ArrayList(u8) = .empty; - cmd.appendSlice(arena, "grep -nH '") catch return; - for (pat) |ch| { - if (ch == '\'') cmd.appendSlice(arena, "'\\''") catch return else cmd.append(arena, ch) catch return; + const pl = p.paneCursorLines(pane) catch return; + // a real file names itself; a terminal or an output buffer has no path + const has_path = if (pane.file) |f| !f.output else false; + var idbuf: [16]u8 = undefined; + const loc: []const u8 = if (has_path) + pane.file.?.path + else + std.fmt.bufPrint(&idbuf, "@p{d}", .{id}) catch return; + var out: std.ArrayList(u8) = .empty; + for (pl.lines, 0..) |ln, i| { + const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; + const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ + loc, i + 1, at + 1, std.mem.trimEnd(u8, ln, " \t"), + }) catch return; + out.appendSlice(arena, row) catch return; } - cmd.appendSlice(arena, "' '") catch return; - cmd.appendSlice(arena, f.path) catch return; - cmd.append(arena, '\'') catch return; - const dst = p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") orelse return; - const rp = p.panes[dst] orelse return; - p.padOutputBelowEdits(dst); - p.emitWrite(dst, cmd.items); - p.emitWrite(dst, "\r"); - pane.search_pane = dst; - // absolute row of the shell's cursor (the prompt echoing the grep); - // results land below it, so n scans from there — older output and - // stale results of a previous search stay behind - const sb = rp.vt.screens.active.pages.scrollbar(); - pane.search_row = @intCast(sb.total -| rp.rows + rp.vt.screens.active.cursor.y); + const content = p.gpa.dupe(u8, out.items) catch return; + // reuse this pane's own results buffer while it lives, else open one + // next to the docs; focus stays here so n/N keeps stepping. + if (pane.search_pane) |sid| if (p.panes[sid]) |sp| if (sp.file) |*sf| if (sf.output) { + p.setFileContent(sf, content); + sf.scroll = 0; + sp.cur_row = 0; + sp.msel.active = false; + pane.search_row = null; + return; + }; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse { + p.gpa.free(content); + return; + }; + const np = p.openOutputView(free, dir, "+Search", content) catch { + p.gpa.free(content); + return; + }; + p.placeDoc(id, free, np); + p.active = id; + pane.search_pane = free; + pane.search_row = null; } - /// n/N: select the next/previous grep result row in the results pane and - /// look it. Result rows read `path:NNN:...` and are found by their path - /// prefix; prompt/echo rows are already blanked by paneCursorLines (the - /// OSC 133 semantic row marks), so only real output is walked. - fn searchStep(p: *Pardes, pane: *Pane, delta: i32) void { - const f = pane.file orelse return; - const rid = pane.search_pane orelse return; - const rp = p.panes[rid] orelse return; - if (!rp.isTerminal()) return; - const pl = p.paneCursorLines(rp) catch return; - const nrows: i32 = @intCast(pl.lines.len); - var r = pane.search_row + delta; - while (r >= 0 and r < nrows) : (r += delta) { - const ln = pl.lines[@intCast(r)]; - if (!std.mem.startsWith(u8, ln, f.path) or ln.len <= f.path.len or ln[f.path.len] != ':') continue; - pane.search_row = r; - // select the result line in the results pane and keep it in view - rp.msel = .{ .active = true, .r0 = r, .r1 = r }; - rp.vsel.active = false; - rp.cur_row = r; - rp.cur_col = 0; - rp.cur_off = 0; - rp.cur_pinned = true; - rp.ensureCursorVisible(); - // look the leading file-ish word (path:NNN:) — the ordinary look - // path scrolls this file pane to the line - var hi: usize = 0; - while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; - p.actOnSelection(.right, rid, ln[0..hi], null); - return; - } - // no result that way (or none at all): nowhere to go, stay put + /// n/N: step to the next/previous row of this pane's results buffer and + /// look it — every row there IS a result, so the leading file-ish word + /// (`path:LINE:COL` or `@pN:LINE:COL`) does the rest through the ordinary + /// look path. False = no live search: a terminal's n/N falls back to + /// lookStep, anything else stays put. + fn searchStep(p: *Pardes, id: usize, delta: i32) bool { + const pane = p.panes[id] orelse return false; + const rid = pane.search_pane orelse return false; + const rp = p.panes[rid] orelse return false; + const rf = if (rp.file) |*f| f else return false; + if (!rf.output) return false; + // fresh results: n starts at the first row, N has nothing behind it + const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n")); + const step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; + if (step < 0 or step >= nrows) return true; // armed, nowhere left to go + const r: i32 = @intCast(step); + pane.search_row = @intCast(step); + // select the result row in the results pane and keep it in view + rp.msel = .{ .active = true, .r0 = r, .r1 = r }; + rp.vsel.active = false; + rp.cur_row = r; + rp.cur_col = 0; + rp.cur_pinned = true; + rp.ensureCursorVisible(); + const ln = modal.lineSlice(rf.content, @intCast(step)); + var hi: usize = 0; + while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; + p.actOnSelection(.right, rid, ln[0..hi], null); + return true; } /// n/N on a terminal pane (normal mode): select the next/previous output @@ -2292,7 +2475,6 @@ pub const Pardes = struct { pane.msel.active = false; pane.cur_row = r; pane.cur_col = @intCast(hit_t0); - pane.cur_off = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; @@ -2300,6 +2482,87 @@ pub const Pardes = struct { // nothing lookable anywhere: stay put } + const EditText = struct { text: []u8, row0: i32 }; + + /// The pane's editable text and the absolute surface row its first line + /// sits at: a file's whole content at row 0, or a terminal's edit buffer + /// at its anchor. Terminals materialize the buffer here on first use and + /// grow it whenever an edit reaches past an end, seeding from — and from + /// then on standing in for — the shell rows it swallows. + /// `col` is the caller's cursor column: row `lo` is padded out to it with + /// spaces, because a terminal cursor may sit past the end of a row (acme: + /// you click anywhere and type there, which is what the insertion runs + /// did by padding at render time). Insert-mode callers pass their column; + /// everything else passes -1 and leaves the text alone. + /// Null if the pane holds no editable text (an image). + fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { + if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; + if (pane.image != null) return null; + const want_lo = @max(0, @min(lo, hi)); + const want_hi = @max(want_lo, @max(lo, hi)); + const fresh = pane.ovl == null; + const old: Ovl = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; + const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); + const up = old.row - want_lo; // shell rows to prepend + const down = want_hi - (old.row + lines - 1); // surface rows past its end + if (fresh or up > 0 or down > 0) { + const rows = p.shellRows(pane) catch return null; + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(p.gpa); + const row0 = old.row - @max(0, up); + var g: i32 = row0; + while (g < old.row) : (g += 1) { + buf.appendSlice(p.gpa, if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else "") catch return null; + buf.append(p.gpa, '\n') catch return null; + } + if (fresh) + buf.appendSlice(p.gpa, if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "") catch return null + else + buf.appendSlice(p.gpa, old.text) catch return null; + var k: i32 = 0; + while (k < down) : (k += 1) { + const src = old.row + old.rows + k; + buf.append(p.gpa, '\n') catch return null; + buf.appendSlice(p.gpa, if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)] else "") catch return null; + } + const text = buf.toOwnedSlice(p.gpa) catch return null; + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = .{ .row = row0, .rows = old.rows + @max(0, up) + @max(0, down), .text = text }; + } + const o = &pane.ovl.?; + const r: usize = @intCast(@max(0, want_lo - o.row)); + const llen: i32 = @intCast(modal.lineSlice(o.text, r).len); + if (col > llen) { + const spaces = p.gpa.alloc(u8, @intCast(col - llen)) catch return null; + defer p.gpa.free(spaces); + @memset(spaces, ' '); + const text = modal.insertAt(p.gpa, o.text, .{ .row = r, .col = @intCast(llen) }, spaces) catch return null; + p.gpa.free(o.text); + o.text = text; + } + return .{ .text = o.text, .row0 = o.row }; + } + + /// editText for an op whose selection can END on a line's newline cell: + /// eating that newline joins with the line BELOW, so a terminal's buffer + /// has to cover that row too (a file's content always already does). + fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?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 p.setFileContent(f, new); + if (pane.ovl) |*o| { + p.gpa.free(o.text); + o.text = new; + } else p.gpa.free(new); + } + const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { @@ -2358,57 +2621,44 @@ pub const Pardes = struct { .open_below, .open_above => { pane.vsel.active = false; const below = where == .open_below; - if (pane.file) |*f| { - // 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; - } + // helix `open`: the new line copies the current line's full + // indent LEVELS; <n>o opens n lines, cursor on the first + 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.hxIndentString(modal.lineSlice(eb.text, 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(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; - } else { - const top = pane.scroll(); - pane.cur_row = @max(top, pane.cur_row - 1); - pane.cur_col = 0; } - pane.cur_off = 0; + 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.items) 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; }, } - // `i` inserts before the run at the cursor; the others append after it - 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.pending = 0; pane.ensureCursorVisible(); } - /// insert mode: file panes get real content edits; terminals get run - /// splices — edits only ever touch YOUR runs, shell output is immutable. + /// insert mode. ONE path for both pane kinds: a file edits its content, a + /// terminal edits the buffer standing in for its shell rows (editText + /// materializes and grows it), so typing, Enter, joins and the kill runs + /// mean exactly the same thing in a shell pane as in a document. 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 }); @@ -2419,247 +2669,192 @@ pub const Pardes = struct { // 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 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| { - 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 (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; + if (!pane.cur_pinned) pane.pinCursor(); + // arrows and paging are pure motion over the WHOLE surface, so they + // run before editText — a terminal must not freeze shell rows into an + // edit buffer just because you walked across them + switch (key.cp) { + Key.left, Key.right, Key.up, Key.down => { + const pl = p.paneCursorLines(pane) catch return; + const cur0 = toModalCursor(pane, pl); + const nc = switch (key.cp) { + Key.left => modal.charLeft(cur0), + Key.right => modal.charRight(pl.lines, cur0), + Key.up => modal.lineUp(pl.lines, cur0), + Key.down => modal.lineDown(pl.lines, cur0), + else => cur0, + }; + fromModalCursor(pane, pl, nc); 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; + }, + 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 flat = p.flatSurface(pane, pl) catch return; + const range = paneRange(pane, flat, pl.row0); + const step: i32 = @intCast(@max(1, pane.rows)); + scrollViewMove(pane, pl, flat, range, if (key.cp == Key.page_down) step else -step); 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); + }, + Key.home => { 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); + }, + Key.end => { + // helix insert End: past the last char (goto_line_end_newline) + const pl = p.paneCursorLines(pane) catch return; + const cur0 = toModalCursor(pane, pl); + pane.cur_col = @intCast(if (cur0.row < pl.lines.len) pl.lines[cur0.row].len else 0); pane.cur_pinned = true; + pane.ensureCursorVisible(); return; - } - switch (key.cp) { - Key.enter => { - // 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); + }, + 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 = @intCast(ind.len); - pane.cur_pinned = true; - pane.ensureCursorVisible(); - }, - Key.backspace => { - if (pane.cur_col > 0) { - const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return; - 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.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); - } - pane.cur_pinned = true; - pane.ensureCursorVisible(); - }, - Key.left, Key.right, Key.up, Key.down => { - const pl = p.paneCursorLines(pane) catch return; - const cur0 = toModalCursor(pane, pl); - const nc = switch (key.cp) { - Key.left => modal.charLeft(cur0), - Key.right => modal.charRight(pl.lines, cur0), - Key.up => modal.lineUp(pl.lines, cur0), - Key.down => modal.lineDown(pl.lines, cur0), - else => cur0, - }; - 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 => {}, + } else pane.cur_col += 1; } + pane.cur_pinned = true; + pane.ensureCursorVisible(); return; } - - if (!pane.cur_pinned) pane.pinCursor(); - 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) + // helix insert-mode kills (word/line; deleteSpan is exclusive) 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); + // helix delete_word_backward: to the previous word start — + // crossing the newline at col 0, which takes the word before it + // too, so on the buffer's first line a terminal grows up one row + 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.hxOff(e2.text, c2); + if (g == 0) return; + const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); + const from = @min(wr.anchor, wr.head); + const fc = modal.hxPos(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 (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); + // helix delete_word_forward: to the next word END (trailing + // whitespace survives), crossing newlines at line ends — at the + // buffer's last line a terminal grows down one row to allow it + 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.hxOff(e2.text, c2); + const wr = modal.hxWordMove(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.hxPos(e2.text, to)) catch return; + p.setEditText(pane, new); + pane.cur_pinned = true; 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; + 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 (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); + 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 => { + // helix insert_newline: the new line copies the current + // line's full indent LEVELS (plain-text heuristic) + const ind = modal.hxIndentString(modal.lineSlice(text, c.row)); + const arena = p.scratch.allocator(); + const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return; + const new = modal.insertAt(p.gpa, text, c, block) catch return; + p.setEditText(pane, new); + pane.cur_row += 1; + pane.cur_col = @intCast(ind.len); + pane.cur_pinned = true; + pane.ensureCursorVisible(); + }, Key.backspace => { - const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; - const e = &pane.edits[ei]; - if (pane.cur_off > 0 and e.len > 0) { - var k: usize = pane.cur_off - 1; - while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1]; - e.len -= 1; - pane.cur_off -= 1; + if (pane.cur_col > 0) { + const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = c.col - 1 }) catch return; + p.setEditText(pane, new); + pane.cur_col -= 1; + 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 => { - 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; + // 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 => { + // 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, text, c, spaces[0..pad]) catch return; + p.setEditText(pane, new); + pane.cur_col += @intCast(pad); + pane.cur_pinned = true; + pane.ensureCursorVisible(); }, else => {}, } } - fn insertRunText(pane: *Pane, text: []const u8) void { - var idx: ?usize = pane.runAt(pane.cur_row, pane.cur_col); - if (idx == null and pane.nedits < MAX_EDITS) { - pane.edits[pane.nedits] = .{ .row = pane.cur_row, .col = pane.cur_col }; - pane.nedits += 1; - idx = pane.nedits - 1; - } - const ei = idx orelse return; - const e = &pane.edits[ei]; - for (text) |ch| { - if (e.len >= EDIT_CAP) break; - if (pane.cur_off > e.len) pane.cur_off = e.len; - var k: usize = e.len; - while (k > pane.cur_off) : (k -= 1) e.text[k] = e.text[k - 1]; - e.text[pane.cur_off] = ch; - e.len += 1; - pane.cur_off += 1; - } - } - const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; /// the char selection [anchor, cursor] normalized to document order @@ -2679,54 +2874,37 @@ pub const Pardes = struct { return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc }; } + /// the char selection as text. Read off the pane's SURFACE (file content / + /// terminal shell rows + edit buffer), not the rendered body: a yank of a + /// whole line has to carry its newline, the way a file's does, or p/P + /// paste it charwise. Scratch-owned. fn vselText(p: *Pardes, pane: *Pane) []const u8 { const arena = p.scratch.allocator(); const b = vselBounds(pane); - if (pane.file) |f| { - return modal.rangeText(arena, 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 ""; - } - const body = p.bodyText(arena, pane) catch return ""; - const off = pane.scroll(); - var out: std.ArrayList(u8) = .empty; - var it = std.mem.splitAny(u8, body, "\n"); - var i: i32 = 0; - while (it.next()) |ln| : (i += 1) { - const abs = i + off; - if (abs < b.lo_row or abs > b.hi_row) continue; - const lo: usize = if (abs == b.lo_row) @intCast(@max(0, b.lo_col)) else 0; - const hi_excl: usize = if (abs == b.hi_row) @as(usize, @intCast(@max(0, b.hi_col))) + 1 else ln.len; - if (abs > b.lo_row) out.append(arena, '\n') catch return ""; - out.appendSlice(arena, ln[@min(lo, ln.len)..@min(hi_excl, ln.len)]) catch return ""; - } - return out.items; + const text = p.surfaceText(pane) orelse 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 the rendered body rows [r0, r1] (absolute) with '\n'; scratch-owned + /// 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 body = p.bodyText(arena, pane) catch return ""; - const off = pane.scroll(); + const pl = p.paneCursorLines(pane) catch return ""; var out: std.ArrayList(u8) = .empty; - var it = std.mem.splitAny(u8, body, "\n"); - var i: i32 = 0; - while (it.next()) |ln| : (i += 1) { - const abs = i + off; - if (abs < r0 or abs > r1) continue; - if (abs > r0) out.append(arena, '\n') catch return ""; - out.appendSlice(arena, ln) catch return ""; + var i = r0; + while (i <= r1) : (i += 1) { + if (i > r0) out.append(arena, '\n') catch return ""; + if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) + out.appendSlice(arena, pl.lines[@intCast(i)]) catch return ""; } return out.items; } - /// drop all insertion runs whose row is in [r0, r1] - fn clearEditsInRange(pane: *Pane, r0: i32, r1: i32) void { - var w: usize = 0; - for (pane.edits[0..pane.nedits]) |e| { - if (e.row >= r0 and e.row <= r1) continue; - pane.edits[w] = e; - w += 1; - } - pane.nedits = w; + /// the whole pane surface as flat text, absolute rows from 0 (read-only: + /// unlike editText it never materializes a terminal's edit buffer) + fn surfaceText(p: *Pardes, pane: *Pane) ?[]const u8 { + if (pane.file) |f| return f.content; + const pl = p.paneCursorLines(pane) catch return null; + return p.flatSurface(pane, pl) catch null; } // ---- edit operations (d / c / y / p) ---- @@ -2738,82 +2916,58 @@ pub const Pardes = struct { const b = vselBounds(pane); 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 { - pane.vsel.active = false; - return; - }; - p.setFileContent(f, d.content); - p.gpa.free(d.deleted); - pane.cur_row = b.lo_row; - pane.cur_col = b.lo_col; - clampCursorToFile(pane, f); - } else { - // terminal: can't delete shell output — only drop typed runs - clearEditsInRange(pane, b.lo_row, b.hi_row); - pane.cur_row = b.lo_row; - pane.cur_col = b.lo_col; - pane.cur_off = 0; - pane.cur_pinned = true; + const eb = p.editTextEol(pane, b) orelse { pane.vsel.active = false; - pane.ensureCursorVisible(); - } + 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; + clampCursor(pane, 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); - if (pane.file) |*f| { - const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch { - pane.msel.active = false; - return; - }; - p.setFileContent(f, d.content); - 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); - if (yank) p.setYank(p.yankRows(pane, r0, r1)); - } + // 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_off = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); - } else if (pane.file) |*f| { + } else { p.pushUndo(pane); - const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; + 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, f.content, c, c) catch return); + 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, f.content, c, c) catch return; - p.setFileContent(f, d.content); + 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(f.content, c.row).len; + const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); - } else { - p.deleteRunCharAtCursor(pane); - } - } - - /// delete the inserted char under the (visual) cursor from its run, if any - fn deleteRunCharAtCursor(p: *Pardes, pane: *Pane) void { - for (pane.edits[0..pane.nedits]) |*e| { - if (e.row != pane.cur_row or e.len == 0) continue; - const vstart = pane.renderedCol(e.row, e.col, 0); - if (pane.cur_col < vstart or pane.cur_col >= vstart + @as(i32, e.len)) continue; - p.pushUndo(pane); - var k: usize = @intCast(pane.cur_col - vstart); - while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1]; - e.len -= 1; - pane.cur_pinned = true; - return; } } @@ -2825,22 +2979,7 @@ pub const Pardes = struct { return; } if (pane.msel.active) { - const r0 = @min(pane.msel.r0, pane.msel.r1); - const r1 = @max(pane.msel.r0, pane.msel.r1); - if (pane.file) |f| { - var buf: std.ArrayList(u8) = .empty; - const arena = p.scratch.allocator(); - var r: usize = @intCast(@max(0, r0)); - const hi: usize = @intCast(@max(0, r1)); - const lo: usize = @intCast(@max(0, r0)); - while (r <= hi) : (r += 1) { - if (r > lo) buf.append(arena, '\n') catch break; - buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break; - } - p.setYank(buf.items); - } else { - p.setYank(p.yankRows(pane, r0, r1)); - } + p.setYank(p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1))); } else { // helix: a bare cursor IS a 1-wide selection — yank the char // under it, file and terminal alike (the line-yank vim-ism died @@ -2856,8 +2995,7 @@ pub const Pardes = struct { /// 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). + /// last pasted line's newline). fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void { const y0 = p.yank orelse return; if (y0.len == 0) return; @@ -2873,59 +3011,53 @@ pub const Pardes = struct { for (0..cnt) |_| buf.appendSlice(arena, y0) catch return; y = buf.items; } - if (pane.file) |*f| { - 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); + 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 = 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 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); - } + 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 gcol = @as(usize, @intCast(@max(0, b.hi_col))) + 1; + if (gcol > modal.lineSlice(eb.text, hrow).len) break :blk .{ .row = hrow + 1, .col = 0 }; + 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 = y.len > 1, .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(end.col - 1); + } 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_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.msel.active = false; pane.cur_pinned = true; pane.ensureCursorVisible(); } @@ -2935,58 +3067,45 @@ pub const Pardes = struct { pane.sticky_col = -1; if (pane.vsel.active) { const b = vselBounds(pane); + const eb = p.editTextEol(pane, b) orelse return; // 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| { - 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 { - pane.vsel.active = false; - return; - }; - p.setFileContent(f, d.content); + 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); - pane.cur_row = b.lo_row; - pane.cur_col = b.lo_col; - clampCursorToFile(pane, f); - } else { - clearEditsInRange(pane, b.lo_row, b.hi_row); - pane.cur_row = b.lo_row; - pane.cur_col = b.lo_col; - pane.cur_off = 0; - pane.cur_pinned = true; + 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.hxIndentString(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.pending = 0; 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; + clampCursor(pane, d.content, eb.row0); pane.mode = .insert; pane.pending = 0; return; @@ -2995,114 +3114,103 @@ pub const Pardes = struct { const r0 = @min(pane.msel.r0, pane.msel.r1); const r1 = @max(pane.msel.r0, pane.msel.r1); p.pushUndo(pane); - if (pane.file) |*f| { - // keep one empty line at r0, drop the rest; yank the removed - var buf: std.ArrayList(u8) = .empty; - const arena = p.scratch.allocator(); - var r: usize = @intCast(@max(0, r0)); - const hi: usize = @intCast(@max(0, r1)); - const lo: usize = @intCast(@max(0, r0)); - while (r <= hi) : (r += 1) { - if (r > lo) buf.append(arena, '\n') catch break; - buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break; - } - p.setYank(buf.items); - if (r1 > r0) { - const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch { - pane.msel.active = false; - return; - }; - p.setFileContent(f, d.content); - p.gpa.free(d.deleted); - } - const cl = modal.clearLine(p.gpa, f.content, @intCast(@max(0, r0))) catch { + const eb = p.editText(pane, r0, r1 + 1, -1) orelse return; + // keep one empty line at r0, drop the rest; yank the removed + var buf: std.ArrayList(u8) = .empty; + const arena = p.scratch.allocator(); + const lo: usize = @intCast(@max(0, r0 - eb.row0)); + const hi: usize = @intCast(@max(0, r1 - eb.row0)); + var r: usize = lo; + while (r <= hi) : (r += 1) { + if (r > lo) buf.append(arena, '\n') catch break; + buf.appendSlice(arena, modal.lineSlice(eb.text, r)) catch break; + } + p.setYank(buf.items); + 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.setFileContent(f, cl); - pane.cur_row = r0; - pane.cur_col = 0; - } else { - clearEditsInRange(pane, r0, r1); - p.setYank(p.yankRows(pane, r0, r1)); - pane.cur_row = r0; - pane.cur_col = 0; + 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_off = 0; pane.cur_pinned = true; pane.mode = .insert; pane.pending = 0; pane.ensureCursorVisible(); - } else if (pane.file) |*f| { + } else { p.pushUndo(pane); - const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; + 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, f.content, c, c) catch return); - const d = modal.deleteRange(p.gpa, f.content, c, c) catch return; - p.setFileContent(f, d.content); + 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(f.content, c.row).len; + const llen = modal.lineSlice(d.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; - } else { - p.pushUndo(pane); - pane.mode = .insert; - pane.cur_off = 0; pane.pending = 0; } } // ---- 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; - } + // Terminals go through the same edit buffer as files: since editText + // materializes one over whatever rows the op names, every one of these + // works the same in a shell pane as in a document. - /// 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 { + /// the selection as an inclusive cursor range in `content`, whose first + /// line is absolute row `row0`: vsel span, msel line span, else the char + /// under the cursor (helix's implicit 1-wide selection) + 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)), .col = @intCast(@max(0, b.lo_col)) }, - .b = .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }, + .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))); - const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1))); + 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 = llen -| 1 } }; } - const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; + 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: 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; - } + const eb = p.editTextEol(pane, selRows(pane)) orelse 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); } /// `R`: replace the selection (or the cursor char) with the yank register; @@ -3111,25 +3219,23 @@ pub const Pardes = struct { 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); + 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, f.content, r.a, r.b, y) catch return; - p.setFileContent(f, new); + 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 = y.len > 1, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; + pane.vsel = .{ .active = y.len > 1, .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 = @intCast(end.row); + pane.cur_row = @as(i32, @intCast(end.row)) + eb.row0; 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_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; + pane.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len); } - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -3139,19 +3245,11 @@ pub const Pardes = struct { /// 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.*), - }; - } + 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` @@ -3197,10 +3295,12 @@ pub const Pardes = struct { /// 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); + // 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 = paneRange(pane, text, eb.row0); const span = rangeLineSpan(text, range); const nlines = modal.hxLineCount(text); var end = span.end; @@ -3218,25 +3318,24 @@ pub const Pardes = struct { } 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 cur_off = modal.hxOff(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.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.hxOff(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.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); + p.setEditText(pane, new); + const cc = modal.hxPos(new, nc); + const ac = modal.hxPos(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 = @intCast(ac.row); + pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -3248,11 +3347,11 @@ pub const Pardes = struct { /// 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 sr = selRows(pane); + const eb = p.editText(pane, sr.lo_row, sr.hi_row, -1) orelse return; + const text = eb.text; + const range = paneRange(pane, text, eb.row0); const span = rangeLineSpan(text, range); const arena = p.scratch.allocator(); var chs: std.ArrayList(TextChange) = .empty; @@ -3284,25 +3383,24 @@ pub const Pardes = struct { } 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 cur_off = modal.hxOff(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.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.hxOff(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.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); + p.setEditText(pane, new); + const cc = modal.hxPos(new, nc); + const ac = modal.hxPos(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 = @intCast(ac.row); + pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -3313,23 +3411,21 @@ pub const Pardes = struct { /// 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 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, f.content, r.a, r.b) catch return; + const frag = modal.rangeText(arena, eb.text, 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 }; + 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.hxPos(new, start + rep.len - 1); + pane.vsel = .{ .active = rep.len > 1, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.msel.active = false; - pane.cur_row = @intCast(cc.row); + pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -3338,16 +3434,15 @@ pub const Pardes = struct { /// `]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.?; + 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)); + 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(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); + 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); @@ -3376,7 +3471,7 @@ pub const Pardes = struct { /// 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); + const range = paneRange(pane, text, pl.row0); switch (obj) { 'w', 'W' => { const r = modal.hxTextobjectWord(text, range, around, obj == 'W'); @@ -3412,21 +3507,19 @@ pub const Pardes = struct { /// `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); + const eb = p.editTextEol(pane, selRows(pane)) orelse return; + const r = selRange(pane, eb.text, eb.row0); 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); + var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = r.b.col + 1 }, &[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 = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; - pane.cur_row = @intCast(r.b.row); + 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(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(); @@ -3434,53 +3527,61 @@ pub const Pardes = struct { /// `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; + // the pair is found over the motion surface, whose rows are absolute; + // the edit runs in the buffer covering those rows, and every offset + // below is taken in THAT text so the cursor maps through it 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; + 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 + pl.row0, hi + pl.row0, -1) orelse return; + const text = eb.text; + const drow = pl.row0 - 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.hxOff(text, r.a); - const b_off = modal.hxOff(text, r.b); - const cur_off = modal.hxOff(text, cur); + const a_off = modal.hxOff(text, ra); + const b_off = modal.hxOff(text, rb); + const cur_off = modal.hxOff(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, 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 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.hxPos(f.content, nc); - pane.cur_row = @intCast(cc.row); + const cc = modal.hxPos(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_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; + const eb = p.editText(pane, @as(i32, @intCast(r.a.row)) + pl.row0, @as(i32, @intCast(r.b.row)) + pl.row0, -1) orelse return; + const drow = pl.row0 - 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, 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); + 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 ---- @@ -3491,32 +3592,20 @@ pub const Pardes = struct { f.syntax_dirty = true; } - fn clampCursorToFile(pane: *Pane, f: *File) void { - const n = modal.lineCount(f.content); - const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row))), if (n == 0) 0 else n - 1); - const llen = modal.lineSlice(f.content, row).len; - pane.cur_row = @intCast(row); + /// pull the cursor back inside `text` after a rewrite; `row0` is the + /// absolute surface row of its first line (0 for a file) + fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { + const n = modal.lineCount(text); + const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); + const llen = modal.lineSlice(text, row).len; + pane.cur_row = @as(i32, @intCast(row)) + row0; pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); - pane.cur_off = 0; pane.cur_pinned = true; pane.vsel.active = false; pane.msel.active = false; pane.ensureCursorVisible(); } - fn snapTerm(pane: *Pane) EditSnap { - return .{ .edits = pane.edits, .nedits = pane.nedits, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .cur_off = pane.cur_off }; - } - - fn editSnapEq(a: EditSnap, b: EditSnap) bool { - if (a.nedits != b.nedits) return false; - for (a.edits[0..a.nedits], b.edits[0..b.nedits]) |x, y| { - if (x.row != y.row or x.col != y.col or x.len != y.len) return false; - if (!std.mem.eql(u8, x.text[0..x.len], y.text[0..y.len])) return false; - } - return true; - } - 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].content, f.content)) return; @@ -3534,10 +3623,22 @@ pub const Pardes = struct { for (f.redo.items) |s| p.gpa.free(s.content); f.redo.clearRetainingCapacity(); } else { - const snap = snapTerm(pane); - if (pane.ed_undo.items.len > 0 and editSnapEq(pane.ed_undo.items[pane.ed_undo.items.len - 1], snap)) return; - pane.ed_undo.append(p.gpa, snap) catch return; - if (pane.ed_undo.items.len > EDIT_UNDO_MAX) _ = pane.ed_undo.orderedRemove(0); + const cur = pane.ovl orelse Ovl{ .rows = 0 }; + if (pane.ed_undo.getLastOrNull()) |top| { + const same = if (top.ovl) |o| pane.ovl != null and o.row == cur.row and + o.rows == cur.rows and std.mem.eql(u8, o.text, cur.text) else pane.ovl == null; + if (same) return; + } + const snap = p.snapTerm(pane) orelse return; + pane.ed_undo.append(p.gpa, snap) catch { + if (snap.ovl) |o| p.gpa.free(o.text); + return; + }; + if (pane.ed_undo.items.len > EDIT_UNDO_MAX) { + const drop = pane.ed_undo.orderedRemove(0); + if (drop.ovl) |o| p.gpa.free(o.text); + } + for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_redo.clearRetainingCapacity(); } } @@ -3561,8 +3662,12 @@ pub const Pardes = struct { return; } if (pane.ed_undo.items.len == 0) return; - pane.ed_redo.append(p.gpa, snapTerm(pane)) catch return; - restoreTerm(pane, pane.ed_undo.pop() orelse return); + const cur = p.snapTerm(pane) orelse return; + pane.ed_redo.append(p.gpa, cur) catch { + if (cur.ovl) |o| p.gpa.free(o.text); + return; + }; + p.restoreTerm(pane, pane.ed_undo.pop() orelse return); } fn doRedo(p: *Pardes, pane: *Pane) void { @@ -3584,8 +3689,12 @@ pub const Pardes = struct { return; } if (pane.ed_redo.items.len == 0) return; - pane.ed_undo.append(p.gpa, snapTerm(pane)) catch return; - restoreTerm(pane, pane.ed_redo.pop() orelse return); + const cur = p.snapTerm(pane) orelse return; + pane.ed_undo.append(p.gpa, cur) catch { + if (cur.ovl) |o| p.gpa.free(o.text); + return; + }; + p.restoreTerm(pane, pane.ed_redo.pop() orelse return); } /// undo/redo restores the selection recorded with the snapshot (helix @@ -3598,20 +3707,25 @@ pub const Pardes = struct { 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; + /// the snapshot takes ownership of a COPY of the edit buffer's text + fn snapTerm(p: *Pardes, pane: *Pane) ?EditSnap { + var ovl: ?Ovl = null; + if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; + return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; + } + + fn restoreTerm(p: *Pardes, pane: *Pane, snap: EditSnap) void { + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = snap.ovl; pane.cur_row = snap.cur_row; pane.cur_col = snap.cur_col; - pane.cur_off = snap.cur_off; pane.cur_pinned = true; - pane.vsel.active = false; + pane.vsel = snap.vsel; pane.msel.active = false; pane.ensureCursorVisible(); } @@ -3901,7 +4015,6 @@ pub const Pardes = struct { if (body_vis >= 0 and pane.mode != .tty) { pane.cur_row = body_vis + pane.scroll(); pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) + pane.hscroll else sl.c1; - pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0; pane.cur_pinned = true; if (!pane.isTerminal()) pane.mode = .normal; pane.msel.active = false; @@ -3996,7 +4109,6 @@ pub const Pardes = struct { const col1 = @max(0, sl.c1 - pfx) + hs; pane.cur_row = row1; pane.cur_col = col1; - pane.cur_off = 0; pane.cur_pinned = true; pane.msel.active = false; pane.pending = 0; @@ -4026,7 +4138,6 @@ pub const Pardes = struct { 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(); @@ -4200,7 +4311,7 @@ pub const Pardes = struct { /// no scrollback — a throwaway placeholder a file may replace fn isPristineTty(pane: *Pane) bool { if (!pane.isTerminal()) return false; - if (pane.nedits != 0) return false; + if (pane.ovl != null) return false; if (pane.vt.screens.active.cursor.y != 0) return false; return pane.vt.screens.active.pages.scrollbar().total <= pane.rows; } @@ -4249,18 +4360,18 @@ pub const Pardes = struct { return free; } - /// executing at a prompt with typed runs below the prompt: pad the output - /// area with newlines so the command's output doesn't overwrite the runs + /// executing at a prompt with typed text below it: pad the output area + /// with newlines so the command's output doesn't overwrite the buffer fn padOutputBelowEdits(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - if (!pane.isTerminal() or pane.nedits == 0) return; + const o = pane.ovl orelse return; + if (!pane.isTerminal()) return; if (!pane.vt.cursorIsAtPrompt()) return; - var max_row: i32 = std.math.minInt(i32); - for (pane.edits[0..pane.nedits]) |e| { - if (e.len > 0 and e.row > max_row) max_row = e.row; - } - if (max_row == std.math.minInt(i32)) return; - const cursor_abs: i32 = pane.scroll() + @as(i32, @intCast(pane.vt.screens.active.cursor.y)); + // the buffer's LAST surface row: its lines may outnumber the shell + // rows it covers, and it is the bottom one output must clear + const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.vt.screens.active.cursor.y))); const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); var i: i32 = 0; while (i < pad) : (i += 1) p.emitWrite(id, "\r"); @@ -4282,6 +4393,18 @@ pub const Pardes = struct { return pane; } + /// An output buffer (acme's +Errors): a file pane holding text the core + /// produced, with no file behind it. `content` is gpa-owned and adopted. + /// The name carries `dir` so looks inside it resolve like anywhere else. + fn openOutputView(p: *Pardes, id: usize, dir: []const u8, name: []const u8, content: []u8) !*Pane { + const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), name }); + errdefer p.gpa.free(path); + const pane = try p.newDocPane(id); + pane.file = .{ .path = path, .content = content, .output = true }; + pane.cur_pinned = true; + return pane; + } + fn openTutorView(p: *Pardes, id: usize) !*Pane { const content = try p.gpa.dupe(u8, tutor_text); errdefer p.gpa.free(content); @@ -4341,10 +4464,36 @@ pub const Pardes = struct { // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- + /// Focus pane `id` and, for a nonzero 1-based `line`, put its modal cursor + /// there (`col` likewise, 0 = line start): files recenter the view on it, + /// terminals ride their scrollback to it. Both look targets that name a + /// live pane land here — a path a pane already holds, and `@pN:LINE:COL`. + fn focusPaneLine(p: *Pardes, id: usize, line: usize, col: usize) void { + if (id >= MAX_PANES) return; + const pane = p.panes[id] orelse return; + p.active = id; + if (line == 0) return; + if (pane.file) |*f| { + if (line > fileLineCount(f.content)) return; + const next = (line - 1) -| pane.rows / 2; // center, clamp at top + if (next != f.scroll) { + f.scroll = next; + f.syntax_dirty = true; + } + } + // land the modal cursor on the target line (and keep + // ensureCursorVisible agreeing with the recenter — a stale cursor + // would yank the view right back) + pane.cur_row = @intCast(line - 1); + pane.cur_col = if (col > 0) @intCast(col - 1) else 0; + pane.cur_pinned = true; + pane.ensureCursorVisible(); + } + /// focus the pane already loaded on `path` (exact match), if any: file /// panes recenter on a :NN line like the look dedup always has, image /// panes just focus. Returns false when no pane holds that path. - fn focusPaneByPath(p: *Pardes, path: []const u8, line: usize) bool { + fn focusPaneByPath(p: *Pardes, path: []const u8, line: usize, col: usize) bool { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) { @@ -4353,20 +4502,7 @@ pub const Pardes = struct { }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; - p.active = i; - if (line > 0 and line <= fileLineCount(ff.content)) { - const next = (line - 1) -| tt.rows / 2; // center, clamp at top - if (next != ff.scroll) { - ff.scroll = next; - ff.syntax_dirty = true; - } - // land the modal cursor on the target line (and keep - // ensureCursorVisible agreeing with the recenter — - // a stale cursor would yank the view right back) - tt.cur_row = @intCast(line - 1); - tt.cur_col = 0; - tt.cur_pinned = true; - } + p.focusPaneLine(i, line, col); return true; } return false; @@ -4409,21 +4545,30 @@ pub const Pardes = struct { { const pl = look.parsePathLine(std.mem.trim(u8, txt, " \t\r\n")); if (pl.path.len > 0) { - if (p.focusPaneByPath(pl.path, pl.line)) return; + if (p.focusPaneByPath(pl.path, pl.line, pl.col)) return; if (pl.path[0] != '/') { var joinbuf: [2048]u8 = undefined; if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ cwd, pl.path }) catch null) |j| - if (p.focusPaneByPath(j, pl.line)) return; + if (p.focusPaneByPath(j, pl.line, pl.col)) return; } } } switch (look.resolve(txt, cwd, &realbuf)) { - // acme button-3: a word that names no file/dir, in a text file, - // is a search — grep it into a shell exactly like `/` (n/N then + // acme button-3: a word that names no file/dir is a search of + // the pane it was clicked in — exactly what `/` runs (n/N then // walk the results). Paths (src/a/b.rs:100) still resolve above - // and open; only the non-file case falls through here. On a - // terminal a dead look stays inert. - .none => if (pane.file != null) p.runSearch(pane, txt), + // and open; only the non-file case falls through here. A shell + // searches its scrollback like anything else, EXCEPT in tty + // mode, where the click belongs to the program on the other + // end; an image pane has no text to search either way. + .none => { + const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; + if (pane.image == null and bmode != .tty) p.runSearch(id, txt); + }, + // `@p7:10:5`: pane 7, line 10, column 5 — how a search result + // points at a terminal or an output buffer, neither of which + // has a path. + .pane => |t| p.focusPaneLine(t.id, t.line, t.col), .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { // focus an existing terminal on this dir, else fork one below @@ -4445,9 +4590,10 @@ pub const Pardes = struct { }, .file => |target| { // focus an existing pane on this path (rescrolled), else open - if (p.focusPaneByPath(target.path, target.line)) return; + if (p.focusPaneByPath(target.path, target.line, target.col)) return; const free = p.freeSlot() orelse return; const nt = p.openFileView(free, target.path, target.line) catch return; + if (target.col > 0) nt.cur_col = @intCast(target.col - 1); p.placeDoc(id, free, nt); // center the target line: the pane's real body height only // exists after placement, so lay out now and pull the @@ -4456,7 +4602,7 @@ pub const Pardes = struct { nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; }, .image => |target| { - if (p.focusPaneByPath(target.path, 0)) return; + if (p.focusPaneByPath(target.path, 0, 0)) return; const free = p.freeSlot() orelse return; // petscii by default when the host has no kitty graphics const nt = p.openImageView(free, target.path, !p.kitty_ok) catch return; @@ -4465,52 +4611,69 @@ pub const Pardes = struct { } return; } - switch (onSelect(button, txt)) { - .none => {}, - .debug => p.show_debug = !p.show_debug, - .colors => p.colors_on = !p.colors_on, - .nextcolor => p.theme_idx = (p.theme_idx + 1) % themes.len, - .crt => p.crt_on = !p.crt_on, - .dump => p.dumpState() catch {}, - .restore => { - var it = std.mem.tokenizeAny(u8, std.mem.trim(u8, txt, " \t\r\n"), " \t"); + // execute (middle / Tab): a builtin's name runs the builtin, anything + // else is a command line for a shell. + if (button != .middle) return; + const cmd = std.mem.trim(u8, txt, " \t\r\n"); + if (cmd.len == 0) return; + const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse + // Restore is the ONE builtin that takes an argument (`Restore + // <path>`); every other name must match whole, so `Kill foo` is a + // shell command and not the Kill builtin. + if (std.mem.startsWith(u8, cmd, "Restore ") or std.mem.startsWith(u8, cmd, "Restore\t")) .Restore else null; + if (bi) |b| return p.runBuiltin(b, id, cmd); + // terminal: run in itself. file pane: run in a shell in the file's + // directory (found by cwd, else freshly forked). + const dst: ?usize = if (pane.file) |f| + p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") + else + id; + if (dst) |di| { + p.padOutputBelowEdits(di); + p.emitWrite(di, cmd); + // a chord argument (kept left selection) rides as a trailing CLI + // argument + if (arg) |a| { + const at = std.mem.trim(u8, a, " \t\r\n"); + if (at.len > 0) { + p.emitWrite(di, " "); + p.emitWrite(di, at); + } + } + p.emitWrite(di, "\r"); + } + } + + /// Run a builtin on pane `id`. `txt` is the executed text — only Restore + /// reads it (for its optional path argument), so the leader passes "" and + /// every builtin runs with zero arguments. The topbar ones are global; the + /// pane-scoped ones (Save, Del, Delcol) act on `id`. + fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8) void { + const pane = p.panes[id] orelse return; + switch (b) { + .Debug => p.show_debug = !p.show_debug, + .Colors => p.colors_on = !p.colors_on, + .NextColor => p.theme_idx = (p.theme_idx + 1) % themes.len, + .Crt => p.crt_on = !p.crt_on, + .Dump => p.dumpState() catch {}, + .Help => p.openHelp(id, ""), + .Restore => { + var it = std.mem.tokenizeAny(u8, txt, " \t"); _ = it.next(); // the word "Restore" const path = it.next() orelse (p.last_dump orelse return); if (path.len > p.restore_buf.len) return; @memcpy(p.restore_buf[0..path.len], path); p.restore_req = p.restore_buf[0..path.len]; }, - .kill => { + .Kill => { p.quit = true; p.emit(.quit); }, - .save => { - if (pane.file != null) p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); + .Save => { + // an output buffer has no file behind it — nothing to write + if (pane.file) |f| if (!f.output) p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); }, - .send => { - // terminal: run in itself. file pane: run in a shell in the - // file's directory (found by cwd, else freshly forked). - const cmd = std.mem.trim(u8, txt, " \t\r\n"); - const dst: ?usize = if (pane.file) |f| - p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") - else - id; - if (dst) |di| { - p.padOutputBelowEdits(di); - p.emitWrite(di, cmd); - // a chord argument (kept left selection) rides as a - // trailing CLI argument - if (arg) |a| { - const at = std.mem.trim(u8, a, " \t\r\n"); - if (at.len > 0) { - p.emitWrite(di, " "); - p.emitWrite(di, at); - } - } - p.emitWrite(di, "\r"); - } - }, - .newcol => { + .Newcol => { const free = p.freeSlot() orelse return; if (p.ncol >= MAX_COLS) return; const nt = p.newShell(free, "") catch return; @@ -4518,7 +4681,7 @@ pub const Pardes = struct { p.layoutAppendColumn(free); p.active = free; }, - .del => { + .Del => { p.absorbVWeight(id); p.layoutRemove(id); p.deinitPane(pane); @@ -4529,7 +4692,7 @@ pub const Pardes = struct { return; }; }, - .delcol => { + .Delcol => { const f = p.layoutFindTerm(id) orelse return; var ids: [MAX_PANES]usize = undefined; const nids = p.col_n[f.col]; @@ -4547,7 +4710,7 @@ pub const Pardes = struct { return; }; }, - .tutor => { + .Tutor => { const free = p.freeSlot() orelse return; const nt = p.openTutorView(free) catch return; p.placeDoc(id, free, nt); // a doc like any other @@ -4555,6 +4718,51 @@ pub const Pardes = struct { } } + /// The Help builtin: every builtin reachable from `prefix` (empty = all + /// of them) with the keys that run it, written into an output buffer + /// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle- + /// click `Tutor` there and the tutor opens. Reuses the open +Help buffer + /// instead of piling panes up, and focus follows: you asked to read it. + fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void { + const pane = p.panes[id] orelse return; + const arena = p.scratch.allocator(); + var out: std.ArrayList(u8) = .empty; + if (prefix.len == 0) { + out.appendSlice(arena, "pardes builtins - SPC then the keys,\nor execute the name anywhere\n\n") catch return; + } else { + out.appendSlice(arena, "pardes builtins under SPC") catch return; + for (prefix) |c| out.appendSlice(arena, &[_]u8{ ' ', c }) catch return; + out.appendSlice(arena, "\n\n") catch return; + } + for (leader_rows) |row| { + if (!std.mem.startsWith(u8, row.path, prefix)) continue; + out.appendSlice(arena, row.line) catch return; + out.append(arena, '\n') catch return; + } + const content = p.gpa.dupe(u8, out.items) catch return; + for (p.panes, 0..) |slot, i| { + const hp = slot orelse continue; + const hf = if (hp.file) |*f| f else continue; + if (!hf.output or !std.mem.endsWith(u8, hf.path, "/+Help")) continue; + p.setFileContent(hf, content); + hf.scroll = 0; + hp.cur_row = 0; + hp.msel.active = false; + p.active = i; + return; + } + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse { + p.gpa.free(content); + return; + }; + const np = p.openOutputView(free, dir, "+Help", content) catch { + p.gpa.free(content); + return; + }; + p.placeDoc(id, free, np); + } + /// place a fresh doc pane. Any doc already open is the split parent (the /// source if it IS one, else the first found) and the newcomer lands right /// below it, so all docs share one column. The FIRST doc of the session @@ -4613,6 +4821,7 @@ pub const Pardes = struct { .path = f.path, .content = f.content, .content_b64 = try dump.encodeBytes(arena, f.content), + .output = f.output, }, } else if (pane.image) |iv| blk: { const bytes = look.readFile(arena, iv.path) catch ""; @@ -4630,24 +4839,30 @@ pub const Pardes = struct { }, }; } else blk: { - // the stream text: whole history, prompts hidden, runs spliced + // the stream text: whole history, prompts hidden outside tty + // mode, the edit buffer standing in for the rows it covers const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); var stream_text: std.ArrayList(u8) = .empty; var lines = std.mem.splitAny(u8, full, "\n"); var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var row: usize = 0; + var row: i32 = 0; + var skip: i32 = 0; while (lines.next()) |raw| : (row += 1) { - if (row > 0) try stream_text.append(arena, '\n'); const is_prompt = if (pane.mode != .tty) if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false else false; - const line = if (is_prompt) "" else raw; - if (pane.mode == .tty) { - try stream_text.appendSlice(arena, line); - } else { - try spliceRow(arena, &stream_text, pane, @intCast(row), line); + if (skip > 0) { + skip -= 1; + continue; } + if (row > 0) try stream_text.append(arena, '\n'); + if (pane.mode != .tty) if (pane.ovl) |o| if (row == o.row) { + try stream_text.appendSlice(arena, o.text); + skip = o.rows - 1; + continue; + }; + try stream_text.appendSlice(arena, if (is_prompt) "" else raw); } break :blk .{ .kind = .terminal, @@ -4752,7 +4967,7 @@ pub const Pardes = struct { const path = try gpa.dupe(u8, f.path); errdefer gpa.free(path); const pane = try p.newDocPane(i); - pane.file = .{ .path = path, .content = content, .scroll = src.scroll }; + pane.file = .{ .path = path, .content = content, .output = f.output, .scroll = src.scroll }; pane.cur_pinned = true; pane.cur_row = @intCast(src.scroll); pane.cols = @max(1, src.cols); @@ -4792,8 +5007,7 @@ pub const Pardes = struct { const pfx = p.tagPrefix(pane) catch return; if (!std.mem.startsWith(u8, saved_tag, pfx)) return; const rest = saved_tag[pfx.len..]; - const default_tail: []const u8 = if (pane.file != null) file_pane_tail else pane_tail; - if (std.mem.eql(u8, rest, default_tail)) return; + if (std.mem.eql(u8, rest, curTail(pane))) return; pane.tag_tail.appendSlice(p.gpa, rest) catch return; pane.tag_init = true; } @@ -5101,6 +5315,23 @@ pub const Pardes = struct { const pfx = try p.tagPrefix(pane); const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) }); _ = s.print(tx, r.y, tw, tag, tag_style); + // pending SPC leader path, on the ACTIVE pane's tag at its RIGHT edge: + // the same rule as the `/` input — state you can see, nothing that + // disappears — but right-aligned, so it never collides with the + // builtins in the tail. Printed AFTER the tag, so a tag long enough to + // reach it loses its last columns instead: what you are about to press + // beats the path, which is still readable in the prefix. + if (active and p.leader_on) { + var ibuf: [16]u8 = @splat(' '); + @memcpy(ibuf[1..4], "SPC"); + var iw: usize = 4; + for (p.leader_keys[0..p.leader_n]) |ch| { + ibuf[iw + 1] = ch; + iw += 2; + } + const w: u16 = @intCast(iw); + if (w < tw) _ = s.print(tx + tw - w, r.y, w, ibuf[0..iw], tag_style); + } // tag-tail char selection highlight (helix v/x), inclusive [lo, hi] if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); @@ -5203,12 +5434,22 @@ pub const Pardes = struct { }; // tty colors: recolor each body cell from the emulator's own style so // shell output keeps its ansi colors (gated on the Colors toggle). + // Rows the edit buffer stands in for are OURS, not the emulator's: + // they keep the plain body style, and the rows under them are read + // from the shell row the surface actually shows there. + const off = pane.scroll(); if (p.colors_on and pane.isTerminal()) { + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); var vr: u16 = 0; while (vr < body_h and vr < pane.rows) : (vr += 1) { + const grid = pane.gridRow(off + @as(i32, vr)) - goff; + if (pane.mode != .tty) if (pane.ovl) |o| { + if (off + @as(i32, vr) >= o.row and off + @as(i32, vr) < o.row + @as(i32, @intCast(modal.lineCount(o.text)))) continue; + }; + if (grid < 0) continue; var c: u16 = 0; while (c < tw) : (c += 1) { - const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue; + const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue; if (ci.cell.wide == .spacer_tail) continue; const cell = s.at(tx + c, r.y + BOX_H + vr); if (cell.default) continue; @@ -5217,24 +5458,6 @@ pub const Pardes = struct { } } - // repaint inserted cells with the plain body style so typed text reads - // exactly like normal text: the tty recolor above painted cell (c,vr) - // from emulator column c, but runs shifted the row right. - const off = pane.scroll(); - if (pane.mode != .tty) for (pane.edits[0..pane.nedits]) |*e| { - if (e.len == 0) continue; - const prow = e.row - off + @as(i32, BOX_H); - if (prow < BOX_H or prow >= r.h) continue; - const start = pane.renderedCol(e.row, e.col, 0); - var k: i32 = 0; - while (k < e.len) : (k += 1) { - const cx = start + k; - if (cx < 0 or cx >= tw) continue; - const cell = s.at(tx + @as(u16, @intCast(cx)), r.y + @as(u16, @intCast(prow))); - if (!cell.default) cell.style = body_style; - } - }; - // mouse selections (pane-local coords), one pass per button — later // buttons win on overlap. A left .done stays highlighted after release; // middle/right .done are transient (they fire their action on release). @@ -5299,17 +5522,13 @@ pub const Pardes = struct { if (active and !pane.tag_edit) { const cur = pane.vt.screens.active.cursor; if (pane.mode != .tty) { - const crow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(cur.y)) + off; + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + const crow = if (pane.cur_pinned) pane.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x)); - const coff = if (pane.cur_pinned) pane.cur_off else 0; const prow = crow - off + @as(i32, BOX_H); - // files: cur_col is content-relative, add the lineno prefix; - // insert mode maps the run anchor to its visual column; normal - // mode keeps cur_col already-visual. + // files: cur_col is content-relative, add the lineno prefix const cx = if (pane.file != null) - @as(i32, PREFIX_W) + ccol + @as(i32, coff) - pane.hscroll - else if (pane.mode == .insert) - pane.renderedCol(crow, ccol, coff) + @as(i32, PREFIX_W) + ccol - pane.hscroll else ccol; if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw) @@ -5421,9 +5640,11 @@ pub const Pardes = struct { } /// The pane body as text. Image: blank rows (the picture draws over it). - /// File: line-numbered content from f.scroll, edits spliced like the tty. + /// File: line-numbered content from f.scroll. /// Terminal: viewport rows, padded to the grid height, prompt rows blanked - /// outside tty mode (OSC 133), typed insertion runs spliced in. + /// outside tty mode (OSC 133), the edit buffer standing in for the shell + /// rows it covers — which is where a buffer holding more lines than those + /// rows pushes the output below it down the screen. fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { _ = p; if (pane.image != null) { @@ -5451,10 +5672,9 @@ pub const Pardes = struct { var base: std.ArrayList(u8) = .empty; try base.appendSlice(arena, prefix); try base.appendSlice(arena, text); - try spliceRow(arena, &out, pane, abs, base.items); - } else try spliceRow(arena, &out, pane, abs, ""); - // horizontal scroll: cut hscroll columns after the gutter, - // from the fully composed row (edit splices shift with it) + try out.appendSlice(arena, base.items); + } + // horizontal scroll: cut hscroll columns after the gutter if (pane.hscroll > 0) { const cut_at = row_start + PREFIX_W; if (out.items.len > cut_at) { @@ -5466,29 +5686,44 @@ pub const Pardes = struct { return out.items; } const raw = try pane.vt.plainString(arena); - var flags: [512]bool = @splat(false); - const nrows: usize = @min(pane.rows, flags.len); - if (pane.mode != .tty) { - var it = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); - var i: usize = 0; - while (it.next()) |pin| : (i += 1) { - if (i >= nrows) break; - flags[i] = pin.rowAndCell().row.semantic_prompt != .none; - } + // the viewport's shell rows, prompt rows blanked outside tty mode + var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); + var vp: std.ArrayList([]const u8) = .empty; + var lines = std.mem.splitScalar(u8, raw, '\n'); + while (lines.next()) |ln| { + const is_prompt = if (pane.mode != .tty) + if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false + else + false; + try vp.append(arena, if (is_prompt) "" else ln); } + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); const off = pane.scroll(); + var g: i32 = pane.gridRow(off); + // the buffer can start above the viewport: drop the lines scrolled past + var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0; var out: std.ArrayList(u8) = .empty; - var lines = std.mem.splitScalar(u8, raw, '\n'); - var i: usize = 0; - while (i < nrows) : (i += 1) { - if (i > 0) try out.append(arena, '\n'); - const line = if (flags[i]) "" else lines.next() orelse ""; - if (flags[i]) _ = lines.next(); - if (pane.mode == .tty) { - try out.appendSlice(arena, line); - } else { - try spliceRow(arena, &out, pane, @as(i32, @intCast(i)) + off, line); - } + var n: usize = 0; + while (n < pane.rows) { + if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) { + var bit = std.mem.splitScalar(u8, o.text, '\n'); + var k: usize = 0; + while (bit.next()) |ln| : (k += 1) { + if (k < skip) continue; + if (n >= pane.rows) break; + if (n > 0) try out.append(arena, '\n'); + try out.appendSlice(arena, ln); + n += 1; + } + skip = 0; + g += o.rows; + continue; + }; + if (n > 0) try out.append(arena, '\n'); + const vi = g - goff; + if (vi >= 0 and @as(usize, @intCast(vi)) < vp.items.len) try out.appendSlice(arena, vp.items[@intCast(vi)]); + n += 1; + g += 1; } return out.items; } @@ -5566,45 +5801,6 @@ fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) vo pane.reply_len += @intCast(n); } -/// Splice this row's insertion runs into `line` (the raw tty text for absolute -/// row `abs_row`), shifting the underlying text right rather than overwriting. -fn spliceRow(arena: std.mem.Allocator, out: *std.ArrayList(u8), pane: *Pane, abs_row: i32, line: []const u8) !void { - var idxs: [MAX_EDITS]usize = undefined; - var n: usize = 0; - for (pane.edits[0..pane.nedits], 0..) |e, i| { - if (e.row == abs_row and e.len > 0) { - idxs[n] = i; - n += 1; - } - } - // insertion-sort the runs by column (n is tiny) - var a: usize = 1; - while (a < n) : (a += 1) { - const key = idxs[a]; - var b = a; - while (b > 0 and pane.edits[idxs[b - 1]].col > pane.edits[key].col) : (b -= 1) idxs[b] = idxs[b - 1]; - idxs[b] = key; - } - var vpos: usize = 0; // original-line columns consumed so far - for (idxs[0..n]) |i| { - const e = &pane.edits[i]; - const c: usize = @intCast(@max(0, e.col)); - if (c <= line.len) { - if (c > vpos) try out.appendSlice(arena, line[vpos..c]); - vpos = c; - } else { - // run anchored past the text: emit the rest, then pad with spaces - if (vpos < line.len) { - try out.appendSlice(arena, line[vpos..]); - vpos = line.len; - } - while (vpos < c) : (vpos += 1) try out.append(arena, ' '); - } - try out.appendSlice(arena, e.text[0..e.len]); - } - if (vpos < line.len) try out.appendSlice(arena, line[vpos..]); -} - const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), ).optional.child).pointer.child).@"fn".return_type.?; |
