summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-26 18:36:08 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commite381c96a08f08e03a340a24acb3fe5fe1fca51be (patch)
treedb0ef0cc2652d2797b9e2f73709db601816436d1 /src
parenta4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60 (diff)
downloadpardes-e381c96a08f08e03a340a24acb3fe5fe1fca51be.tar.gz
pardes-e381c96a08f08e03a340a24acb3fe5fe1fca51be.zip
adding better search to tty mode
Diffstat (limited to 'src')
-rw-r--r--src/dump.zig2
-rw-r--r--src/gui.zig4
-rw-r--r--src/look.zig37
-rw-r--r--src/pardes.zig2268
-rw-r--r--src/tty.zig1
-rw-r--r--src/tutor.txt67
6 files changed, 1325 insertions, 1054 deletions
diff --git a/src/dump.zig b/src/dump.zig
index bff8af21..06b9cec6 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -63,6 +63,8 @@ pub const File = struct {
path: []const u8 = "",
content: []const u8 = "",
content_b64: []const u8 = "",
+ /// an output buffer (no file behind the name) — see pardes.File.output
+ output: bool = false,
};
pub const Image = struct {
diff --git a/src/gui.zig b/src/gui.zig
index 1947cd75..e2c39ac1 100644
--- a/src/gui.zig
+++ b/src/gui.zig
@@ -1446,6 +1446,7 @@ const StdinFeed = struct {
.text = text,
.ctrl = key.mods.ctrl,
.alt = key.mods.alt,
+ .shift = key.mods.shift,
} });
out.n_events += 1;
},
@@ -1830,6 +1831,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
const ctrl = (mod & c.SDL_KMOD_CTRL) != 0;
const alt = (mod & c.SDL_KMOD_ALT) != 0;
+ const shift = (mod & c.SDL_KMOD_SHIFT) != 0;
const gui_mod = (mod & c.SDL_KMOD_GUI) != 0;
const cp: u21 = switch (sym) {
c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter,
@@ -1854,7 +1856,7 @@ fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
},
};
if (cp == 0) return;
- core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt } });
+ core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } });
}
fn pixelCell(px: f32, cell: u32) u16 {
diff --git a/src/look.zig b/src/look.zig
index fb74353f..f34fa367 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -26,8 +26,11 @@ pub fn isFileChar(c: u8) bool {
};
}
-/// peel a trailing :NNN (or :NNN:) line suffix: main.zig:100 -> {main.zig, 100}
-pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } {
+/// peel a trailing :LINE[:COL] suffix (both 1-based, 0 = absent):
+/// main.zig:100 -> {main.zig, 100, 0}
+/// main.zig:100:7 -> {main.zig, 100, 7}
+/// main.zig:100: -> {main.zig, 100, 0} grep -n's trailing delimiter
+pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize, col: usize } {
var sep: usize = 0;
while (sep < tok.len) : (sep += 1) {
if (tok[sep] != ':') continue;
@@ -36,17 +39,28 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } {
while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0');
if (j == sep + 1) continue; // no digits after ':'
if (j < tok.len and tok[j] != ':') continue; // junk after the number
- return .{ .path = tok[0..sep], .line = line };
+ var col: usize = 0;
+ if (j < tok.len) {
+ var k = j + 1;
+ while (k < tok.len and std.ascii.isDigit(tok[k])) : (k += 1) col = col * 10 + (tok[k] - '0');
+ // digits, and nothing but a delimiter after them, or no column
+ if (k == j + 1 or (k < tok.len and tok[k] != ':')) col = 0;
+ }
+ return .{ .path = tok[0..sep], .line = line, .col = col };
}
- return .{ .path = tok, .line = 0 };
+ return .{ .path = tok, .line = 0, .col = 0 };
}
pub const Target = union(enum) {
none,
dir: []const u8, // resolved absolute path, in caller's buf
- file: struct { path: []const u8, line: usize },
+ file: struct { path: []const u8, line: usize, col: usize },
image: struct { path: []const u8 },
url: []const u8,
+ /// `@p7:10:5` — pane 7, line 10, column 5 (0 = unspecified). The one
+ /// target that names a live pane instead of a path, because terminals and
+ /// output buffers have no file for a location to point at.
+ pane: struct { id: usize, line: usize, col: usize },
};
const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" };
@@ -67,6 +81,15 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target
const word = pl.path;
if (word.len == 0) return .none;
+ // `@pN` addresses a pane, not a path: every platform, before the fs.
+ if (word.len > 2 and word[0] == '@' and word[1] == 'p') {
+ var id: usize = 0;
+ for (word[2..]) |c| {
+ if (!std.ascii.isDigit(c)) break;
+ id = id * 10 + (c - '0');
+ } else return .{ .pane = .{ .id = id, .line = pl.line, .col = pl.col } };
+ }
+
if (platform_has_fs) {
var joinbuf: [2048]u8 = undefined;
const joined: ?[:0]u8 = if (word[0] == '/')
@@ -78,14 +101,14 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target
const resolved = std.mem.span(rp);
if (isDir(rp)) return .{ .dir = resolved };
if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } };
- return .{ .file = .{ .path = resolved, .line = pl.line } };
+ return .{ .file = .{ .path = resolved, .line = pl.line, .col = pl.col } };
} else {
// web: URLs leave the app; tracked Zig sources resolve inside the
// build-generated, read-only source filesystem.
if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://"))
return .{ .url = trimmed };
if (resolveEmbedded(word, cwd, realbuf)) |source|
- return .{ .file = .{ .path = source.path, .line = pl.line } };
+ return .{ .file = .{ .path = source.path, .line = pl.line, .col = pl.col } };
return .none;
}
}
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.?;
diff --git a/src/tty.zig b/src/tty.zig
index 3ff714bf..2326924c 100644
--- a/src/tty.zig
+++ b/src/tty.zig
@@ -175,6 +175,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.text = key.text orelse "",
.ctrl = key.mods.ctrl,
.alt = key.mods.alt,
+ .shift = key.mods.shift,
} }),
.mouse => |m| {
const button: ?pardes.Mouse.Button = switch (m.button) {
diff --git a/src/tutor.txt b/src/tutor.txt
index cf9ecab0..65788c70 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -25,8 +25,8 @@
This tutor is in THREE parts, ordered by what's most different from
editors you may know:
PART 1 — the MOUSE. Acme's three buttons; nothing like Vim/Helix.
- PART 2 — the TTY. A terminal is just a pane; Ctrl-b drops into it
- by default.
+ PART 2 — the TTY. A terminal is just a pane; Ctrl-b (or Shift-Esc)
+ drops into it.
PART 3 — the KEYS. Helix-style modal (with the Pardes differences).
PRACTICE BLOCKS (part 3): the "# keys:" line lists keystrokes
@@ -121,9 +121,27 @@
Kill Newcol Tutor Debug Colors NextColor
Middle-click "Newcol" for a new column, "Tutor" to spawn this tutor,
"Kill" to quit; Debug/Colors/NextColor toggle the stats overlay, the
- syntax/ansi recolor, and the theme.
+ syntax/ansi recolor, and the theme. Every one of them also has a KEY
+ PATH under SPC (part 3.7): SPC ? lists the lot, "Help" included.
Right-click a directory in any body to open a terminal there.
+ SEARCH: `/` in normal mode types a pattern into that pane's own tag (it
+ stays visible while you type; Esc abandons it). Enter searches the pane
+ — a file's text, a shell's scrollback, anything — for the pattern, plain
+ and case-insensitive, and writes one row per hit into a "+Search" pane.
+ That is an OUTPUT BUFFER: a file pane with no file behind it, so it has
+ no Save, but everything else about it is an ordinary buffer you can read,
+ edit, select and look in. `n`/`N` step the rows and look each one, so the
+ view follows along. Right-clicking a word that names no file searches for
+ it the same way — acme's button 3 — everywhere except a shell in tty mode,
+ where the click belongs to the program on the other end. On a shell with no
+ search armed, n/N instead step the lookable tokens in its output.
+ A hit in a file reads `path:LINE:COL`, the ordinary look target. A hit in
+ a shell or an output buffer has no file to name, so it reads `@pN:LINE:COL`
+ — pane N, line LINE, column COL. Looking either one goes there; the column
+ is optional (`main.zig:100`, `@p3:12`) and you can type one yourself
+ anywhere text lives.
+
LAYOUT: columns split the screen; windows stack within a column. Panes
abut with no wasted gap — a pane's own trailing edge (its last column, or
last row above the next tag) IS the resize handle: hover it and a dashed
@@ -165,8 +183,10 @@
TTY (TTY) the REAL shell — prompts + typed input shown, and keys
go straight to the pty as terminal input.
- ESC: insert -> normal. (Esc never reaches tty.)
- Ctrl-b: toggles TTY on a terminal by default — the ONLY way in or out.
+ ESC: insert -> normal. (A plain Esc never reaches tty.)
+ Ctrl-b: toggles TTY on a terminal by default; SHIFT-ESC is the same
+ toggle, where the host reports modifiers on Escape. Those are
+ the ONLY ways in or out.
Use `pardes --tty-toggle=g` (or another letter) to make Ctrl-g
the toggle instead. Entering is tty-native: if the shell is at
a prompt and your modal cursor sits on the input line, it first
@@ -201,9 +221,9 @@
on whichever selection is active (else the current line).
- NO verb+noun (Vim's dw, cw). Motions only MOVE. To delete a word,
select it (`v` then motions, or `x` for whole lines) then `d`.
- - ESC never reaches tty; it only does insert -> normal. Dropping a
- terminal into the live shell is the tty toggle (Ctrl-b by default;
- see Part 2).
+ - A plain ESC never reaches tty; it only does insert -> normal.
+ Dropping a terminal into the live shell is the tty toggle (Ctrl-b
+ by default, or Shift-Esc; see Part 2).
- Enter / Tab in normal mirror the mouse: Enter = look (open the path
under the cursor), Tab = execute. `:` runs a command from the tag
(see Part 1). `u` undo, `U` redo.
@@ -432,6 +452,31 @@
(No practice blocks: these are viewport/layout, not text edits.)
+-----------------------------------------------------------------
+= 3.7 SPC — THE LEADER =
+-----------------------------------------------------------------
+
+ Every builtin has a NAME you can execute anywhere text lives, and a
+ KEY PATH you can press. SPC in normal mode starts the path; the keys
+ you have typed so far show at the right edge of the pane's tag until
+ the sequence fires. Esc abandons it — so does any key that leads
+ nowhere, rather than leaving the next keystroke armed.
+
+ SPC ? Help: every builtin and the keys that run it
+ SPC k Kill (quit) SPC d Del (close this pane)
+ SPC w Save (vim's :w) SPC h t Tutor (this file)
+ SPC c n / c d Newcol / Delcol
+ SPC t d/c/n/r Debug / Colors / NextColor / Crt toggles
+ SPC s d / s r Dump / Restore the session
+
+ `?` works at ANY depth: SPC ? lists everything, SPC h ? lists only
+ what the "h" group holds. Help writes into a "+Help" OUTPUT BUFFER,
+ the same kind of pane "/" search results land in — ordinary text, so
+ the names in it are live: middle-click "Tutor" there and it opens.
+
+ (No practice blocks: these run builtins, not text edits.)
+
+
=================================================================
= SUMMARY =
=================================================================
@@ -446,13 +491,15 @@
LOOK opens URLs or embedded tracked .zig source (read-only)
published TTY dump lists every source; tree-sitter colors it
tag/separator start = left drag; touch HUD requires Debug
- TTY: a terminal IS a pane; Ctrl-b toggles the live shell by default,
+ TTY: a terminal IS a pane; Ctrl-b (or Shift-Esc) toggles the shell,
landing its cursor where you navigated (prompt start if
the input is empty, mid-text otherwise)
KEYS (helix): h j k l w b e 0 $ ^ gg G v x d c y p i a I A o O
u undo U redo Enter=look Tab=execute
: = the tag as a command line (motions, Enter, back)
zt zz zb Ctrl-d/u Ctrl-f Ctrl-w hjkl Alt-n/c
+ SPC = the leader: a key path runs a builtin (SPC ? lists
+ them; SPC k Kill, SPC d Del, SPC w Save, SPC h t Tutor)
vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars.
vs Vim: no verb+noun (dw); motions only move; Esc never reaches tty.
@@ -461,7 +508,7 @@
shell's own prompt to position you precisely.
To spawn THIS tutor again from anywhere: middle-click "Tutor" in the
- top bar (next to Kill / Newcol).
+ top bar (next to Kill / Newcol), or press SPC h t.
Quit the tutor: this is a file pane — `:q` isn't wired; close the
window (middle-click "Del" in its tag), "Kill" (top bar) to quit