summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig993
1 files changed, 896 insertions, 97 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index f95b4e69..ae6c4cbc 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -373,8 +373,17 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
const results = p.panes[results_id].?.file.?.content;
try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null);
+ // n SELECTS the first result row and opens nothing: the walk's only list
+ // here is the unlooked +Search buffer, so focus lands THERE. Enter is what
+ // jumps, and the document query survives both.
p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expect(pane.search_row != null);
+ try std.testing.expectEqual(results_id, p.active);
+ const results_pane = p.panes[results_id].?;
+ try std.testing.expect(results_pane.vsel.active and results_pane.vsel.explicit);
+ try std.testing.expectEqual(@as(i32, 0), results_pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), results_pane.cur_col);
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
// Search state is owned and untruncated, and changing pages invalidates
@@ -1255,7 +1264,7 @@ test "PDF builtins and leader paths follow the MuPDF feature gate" {
const traits = output_pane.traits(.{ .cmd = maybe_sections.? });
try std.testing.expectEqualStrings(config.pdf_sections_buffer, traits.name);
try std.testing.expect(traits.steps);
- try std.testing.expect(!traits.executes);
+ try std.testing.expect(!traits.commands);
const help_row = for (builtin_rows) |row| {
if (row.cmd == maybe_sections.?) break row;
} else return error.MissingPdfSectionsHelpRow;
@@ -1431,11 +1440,17 @@ test "PdfSections output, Look, and n/N share exact cached outline destinations"
try std.testing.expectEqual(outline_ptr, pdf_pane.pdf.?.outline.?.entries.ptr);
try std.testing.expectEqual(output_revision, p.panes[output_id].?.file.?.revision);
- // n and N execute the generated row through ordinary Look. The root and
- // child intentionally resolve to the same exact XYZ destination.
+ // n SELECTS the generated row and Enter runs it through ordinary Look, so
+ // the pair is one step of what used to be one keypress. The look is made
+ // FROM the sections buffer, which is what keeps that buffer at the head of
+ // the walk: the next n resumes there even though the jump left focus on
+ // the PDF. The root and child intentionally resolve to the same exact XYZ
+ // destination.
p.active = 0;
p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(?usize, 0), pdf_pane.search_row);
+ try std.testing.expectEqual(output_id, p.active);
+ try std.testing.expectEqual(@as(i32, 0), output.cur_row);
+ p.update(.{ .key = .{ .cp = Key.enter } });
try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.page);
const child_destination = pdf_pane.pdf.?.outline.?.entries[1].destination.internal;
const expected_y = @as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])) +
@@ -1443,12 +1458,17 @@ test "PdfSections output, Look, and n/N share exact cached outline destinations"
@as(f64, @floatFromInt(pdf_pane.pdf.?.page_heights[1]));
try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(?usize, 1), pdf_pane.search_row);
+ try std.testing.expectEqual(@as(i32, 1), output.cur_row);
+ p.update(.{ .key = .{ .cp = Key.enter } });
try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
p.update(.{ .key = .{ .cp = 'n' } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
try std.testing.expectEqual(@as(usize, 2), pdf_pane.pdf.?.page);
+ // ...and N is the exact inverse of n: the row it walks back onto is the
+ // one n came from, and looking it lands on the same destination again.
p.update(.{ .key = .{ .cp = 'N' } });
- try std.testing.expectEqual(@as(?usize, 1), pdf_pane.search_row);
+ try std.testing.expectEqual(@as(i32, 1), output.cur_row);
+ p.update(.{ .key = .{ .cp = Key.enter } });
try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.page);
try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
@@ -1474,18 +1494,25 @@ test "PdfSections output, Look, and n/N share exact cached outline destinations"
try std.testing.expect(pdf_pane.pdf.?.outline_reveal_pending == null);
try p.setPdfSearchQuery(&pdf_pane.pdf.?, "");
- // Step forward onto the external row: it uses the existing open_link
- // effect and searchStep restores focus to the owning PDF.
- p.active = 0;
+ // Step forward onto the external row: two n's reach it, Enter takes the
+ // existing open_link effect, and a URL look moves focus nowhere — so the
+ // walk is still standing in the buffer, ready for the next n.
+ //
+ // Asked FROM the sections buffer rather than from the PDF, because the two
+ // direct lookAt calls just above put the shell at the head of the ring and
+ // this is a test about the rows, not about the ordering.
+ p.active = output_id;
p.update(.{ .key = .{ .cp = 'n' } });
p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(i32, 3), output.cur_row);
+ p.update(.{ .key = .{ .cp = Key.enter } });
var opened = false;
while (p.nextEffect()) |effect| switch (effect) {
.open_link => |uri| opened = std.mem.eql(u8, uri.slice(), "https://example.com/manual"),
else => {},
};
try std.testing.expect(opened);
- try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expectEqual(output_id, p.active);
// Missing/hostile coordinates clamp to page space. x affects only a
// layout with horizontal overflow and still does not touch raster state.
@@ -3099,6 +3126,12 @@ pub const Event = union(enum) {
/// with no filesystem (the browser) or no watcher simply never sends one —
/// nothing in the core waits for it.
file_changed: struct { pane: u8, bytes: []const u8 },
+ /// Text from the SYSTEM clipboard, borrowed for this call. Two ways in,
+ /// one handler (applyPaste): SOLICITED, the answer to a `read_clipboard`
+ /// the core emitted for `SPC p` / `SPC P` / `SPC R`, which decides where
+ /// it lands; and UNSOLICITED — an outer terminal's bracketed paste, a
+ /// Cmd-V, the browser's paste event — which means `SPC p`, paste after.
+ /// Neither touches the default register: that is `y`'s alone (helix).
paste: []const u8,
/// One builtin command line, handed to the shell by a pardes launched
/// INSIDE this one (see nested.zig) — `Look /abs/path` and nothing else
@@ -3134,8 +3167,15 @@ pub const Effect = union(enum) {
/// path dump.outPath resolves (acme-style: another instance loads it
/// with -l, or the Restore builtin loads it into this one)
write_dump,
- /// the yank register changed; the shell reads it off the core (OSC 52 out)
+ /// mirror the yank register OUT to the system clipboard; the shell reads
+ /// it off the core (OSC 52 out, SDL_SetClipboardText, NSPasteboard).
+ /// Emitted ONLY by the explicit clipboard commands — see setClipboard.
set_clipboard,
+ /// ...and the other direction: ask the shell to READ the system clipboard.
+ /// The answer comes back as an ordinary `Event.paste`, which the core
+ /// routes to whichever of `SPC p` / `SPC P` / `SPC R` asked for it
+ /// (Pardes.clip_pending). No payload: the bytes travel in the event.
+ read_clipboard,
/// answer a language query OFF the event loop and post the rows back as an
/// `lsp_resp` Event. The shell reads the file's path and content off the
/// core (like save_file) and must SNAPSHOT them before the worker starts —
@@ -3219,6 +3259,15 @@ pub const CharSel = struct {
explicit: bool = false,
};
+/// One position in the n/N walk: a look-able span on one row of one pane,
+/// inclusive of both columns. Also what the walk REMEMBERS having stood on
+/// (Pane.look_at) — see lookStand for why the cursor alone cannot say.
+pub const LookSpot = struct {
+ row: i32,
+ col0: i32,
+ col1: i32,
+};
+
/// ponytail: at most this many cursors at once. helix's `Selection.ranges` is
/// an unbounded Vec; a fixed array keeps a Pane trivially copyable (the undo
/// snapshots memcpy it) and costs nothing at one cursor. The ceiling only
@@ -3718,6 +3767,11 @@ pub const Pane = struct {
prompt: Prompt = .none,
search_pane: ?usize = null,
search_row: ?usize = null,
+ /// Where n/N last stood in this pane, or null when the walk has not been
+ /// here. Distinct from `search_row`, which points into the RESULTS BUFFER
+ /// a search armed on this pane; this one is a place in the pane's own
+ /// text, and n/N step it in every kind of pane.
+ look_at: ?LookSpot = null,
/// The selection an `s`/`S` input was armed on, as gap offsets over the
/// motion surface. Every keystroke re-derives the preview FROM here rather
/// than from the previous preview — which is what helix's regex_prompt
@@ -4346,6 +4400,19 @@ pub const Pardes = struct {
jumps: [MAX_JUMPS]Loc = undefined,
njumps: usize = 0,
jcur: usize = 0,
+ /// THE PANES THAT HAVE LOOKED, oldest first, at most one entry each — the
+ /// spine n/N walks (lookWalkPanes). Serials rather than slots, for the
+ /// same reason the jumplist stores them: a freed slot is reused, and an
+ /// entry naming a dead pane must not resolve to the newcomer sitting in
+ /// its place.
+ ///
+ /// Not the jumplist, though it looks like one. `jumps` records where FOCUS
+ /// has been, and a look moves focus to what it OPENED; this records where
+ /// the look was made FROM, which is the pane holding the list you are
+ /// working through. The two answer different questions and would only
+ /// coincide by accident.
+ look_src: [MAX_PANES]u32 = undefined,
+ n_look_src: usize = 0,
/// hands out Pane.serial; monotonic, never reused
next_serial: u32 = 0,
theme_idx: usize = 0,
@@ -4468,9 +4535,14 @@ pub const Pardes = struct {
effects_head: usize = 0,
effects_len: usize = 0,
- /// modal yank register (gpa-owned); a yank also mirrors out to the system
- /// clipboard via the set_clipboard effect.
+ /// helix's DEFAULT register (gpa-owned): what `y`/`d`/`c` write and
+ /// `p`/`P`/`R` read. Never the system clipboard — `SPC y`/`SPC p` are the
+ /// two commands that cross that line.
yank: ?[]u8 = null,
+ /// a `SPC p`/`SPC P`/`SPC R` waiting on the shell's clipboard read, or
+ /// null. At most one: a second request replaces the first, and any
+ /// keystroke abandons it (update).
+ clip_pending: ?ClipRequest = null,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
/// where the shell wrote the last dump (shell reports back after
@@ -4709,6 +4781,37 @@ pub const Pardes = struct {
} else null;
}
+ /// The slot holding the pane with this serial, if it is still open.
+ pub fn paneBySerial(p: *Pardes, serial: u32) ?usize {
+ return for (p.panes, 0..) |slot, i| {
+ if (slot) |pane| if (pane.serial == serial) break i;
+ } else null;
+ }
+
+ /// `id` just performed a look: put it on top of the walk's spine.
+ ///
+ /// MOVE to the top rather than push, so a pane you keep looking out of
+ /// stays one entry instead of filling the list with itself — the walk's
+ /// order is "which panes, most recent first", not "how many times".
+ /// Dropping the oldest when full can only ever discard a DEAD pane's
+ /// serial: MAX_PANES entries with no duplicates already names every slot
+ /// there is.
+ fn noteLookSource(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ var w: usize = 0;
+ for (p.look_src[0..p.n_look_src]) |s| {
+ if (s == pane.serial) continue;
+ p.look_src[w] = s;
+ w += 1;
+ }
+ if (w == p.look_src.len) {
+ std.mem.copyForwards(u32, p.look_src[0 .. w - 1], p.look_src[1..w]);
+ w -= 1;
+ }
+ p.look_src[w] = pane.serial;
+ p.n_look_src = w + 1;
+ }
+
/// chunk arbitrary-length bytes into fixed-size write effects (order kept)
pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void {
var off: usize = 0;
@@ -4830,8 +4933,18 @@ pub const Pardes = struct {
// fight one — at worst it clears something the prompt was already
// hiding.
switch (ev) {
- .key, .mouse => for (p.panes) |slot| {
- if (slot) |pane| pane.msg_len = 0;
+ .key, .mouse => {
+ for (p.panes) |slot| {
+ if (slot) |pane| pane.msg_len = 0;
+ }
+ // ...and the same reasoning bounds a clipboard read in flight.
+ // A terminal that gates or refuses the OSC 52 request never
+ // answers at all, so the request cannot be allowed to sit and
+ // then fire minutes later into whatever pane is focused by
+ // then: it lives exactly until your next keystroke, and a
+ // shell that answers within one round trip (every one but a
+ // refusing tty) is unaffected.
+ p.clip_pending = null;
},
else => {},
}
@@ -4896,11 +5009,7 @@ pub const Pardes = struct {
}
}
},
- .paste => |bytes| {
- // a shell-level paste (bracketed/SDL): load the register, paste
- p.setYank(bytes);
- if (p.panes[p.active]) |pane| p.normalPaste(pane, false);
- },
+ .paste => |bytes| p.applyPaste(bytes),
.command => |line| _ = p.executeBuiltinLine(p.active, line),
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
@@ -4910,6 +5019,11 @@ pub const Pardes = struct {
p.sync();
}
+ /// THE DEFAULT REGISTER, and nothing else. helix: an ordinary `y`/`d`/`c`
+ /// writes here and the system clipboard never hears about it — which is
+ /// also the bug this spelling fixes, because a mirror on every write made
+ /// deleting one character clobber whatever the desktop was holding.
+ /// `SPC y` is the command that crosses over (setClipboard below).
fn setYank(p: *Pardes, text: []const u8) void {
// Inside a multi-selection replay the register collects EVERY range's
// text, in document order — the passes run last-range-first, so each
@@ -4929,6 +5043,84 @@ pub const Pardes = struct {
}
if (p.yank) |y| p.gpa.free(y);
p.yank = p.gpa.dupe(u8, text) catch null;
+ }
+
+ /// ...and the register PLUS the system clipboard, which is the whole
+ /// difference between `y` and `SPC y`.
+ ///
+ /// One mirror per KEYSTROKE rather than per cursor: a multi-selection
+ /// replay runs last-range-first and `multi_first` marks its first pass, so
+ /// emitting there queues exactly one effect — and the shell reads
+ /// `core.yank` when it DRAINS, by which time every later pass has folded
+ /// its range in. That asymmetry used to be a silent hole: the old mirror
+ /// sat past the join's early return, so a multi-cursor yank reached the
+ /// clipboard on one cursor and not on two.
+ fn setClipboard(p: *Pardes, text: []const u8) void {
+ p.setYank(text);
+ if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} });
+ }
+
+ /// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers.
+ pub const ClipRequest = struct {
+ pane: usize,
+ /// the pane's identity, not its slot: the answer can arrive whole
+ /// keystrokes later (a tty's OSC 52 round trip) and a freed slot is
+ /// reused by an unrelated pane.
+ serial: u32,
+ mode: enum { after, before, replace },
+ };
+
+ /// `SPC p` / `SPC P` / `SPC R`: ask the shell for the system clipboard and
+ /// remember what to do with it. The request is deliberately fire-and-hope
+ /// — a terminal that refuses the OSC 52 read simply never answers, and the
+ /// next keystroke drops the request (see update) rather than letting a
+ /// paste land minutes late in whatever pane is focused by then.
+ pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode")) void {
+ const pane = p.panes[id] orelse return;
+ p.clip_pending = .{ .pane = id, .serial = pane.serial, .mode = mode };
+ p.emit(.read_clipboard);
+ }
+
+ /// The shell answered with system-clipboard text — or the desktop pasted
+ /// into us unasked. Either way the bytes are pasted WITHOUT going through
+ /// the register: helix's clipboard commands and the default register are
+ /// separate stores, and a paste that quietly overwrote your `y` would be
+ /// the same clobbering bug in the other direction.
+ fn applyPaste(p: *Pardes, bytes: []const u8) void {
+ const req = p.clip_pending;
+ p.clip_pending = null;
+ if (bytes.len == 0) return;
+ const id = if (req) |r| r.pane else p.active;
+ const pane = p.panes[id] orelse return;
+ if (req) |r| if (pane.serial != r.serial) return;
+ p.active = id;
+ switch (if (req) |r| r.mode else .after) {
+ .after => p.pasteText(pane, bytes, false),
+ .before => p.pasteText(pane, bytes, true),
+ .replace => p.replaceWithText(pane, bytes),
+ }
+ }
+
+ /// `SPC y` / `SPC Y`: the selection to the system clipboard. `main_only`
+ /// is helix's capital — every cursor's text joined, versus the primary
+ /// selection's alone. A PDF has no editable buffer to replay over, so its
+ /// own selection answers directly.
+ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
+ if (comptime pdf_enabled) if (pane.pdf) |pv| {
+ if (pv.selection_text.len > 0) p.setClipboard(pv.selection_text);
+ return;
+ };
+ // the ordinary `y` path, so what reaches the clipboard is exactly what
+ // the key would have put in the register — including the multi-cursor
+ // join, which is replaySels' business and not a second implementation
+ if (pane.nsel > 0 and !main_only) {
+ p.replaySels(pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } });
+ } else {
+ const others = pane.nsel;
+ pane.nsel = 0;
+ p.normalYank(pane);
+ pane.nsel = others;
+ }
p.emit(.{ .set_clipboard = {} });
}
@@ -5238,8 +5430,16 @@ pub const Pardes = struct {
}
// y — yank what the chord would run: the selection, else the word under
// the cursor. The path is selectable, so this is how you copy it out.
+ //
+ // The ONE register write that still mirrors to the system clipboard
+ // without `SPC` in front of it, and it is not an exception so much as
+ // the only spelling available: a tag is always in insert mode, the
+ // leader is body-normal only, so `SPC y` cannot be pressed here — and
+ // "copy this path somewhere else" is the entire reason the chord
+ // exists. A path that only reached the internal register would be a
+ // key that does nothing you can observe.
if (hit(key, config.tag_yank)) {
- if (p.tagChordText(pane)) |txt| p.setYank(txt);
+ if (p.tagChordText(pane)) |txt| p.setClipboard(txt);
return;
}
// insert entry (one line, so I/A are the tail's ends). The prefix is
@@ -5831,7 +6031,19 @@ pub const Pardes = struct {
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| {
+ // ...and its COLUMN back through the hidden prompt. Outside
+ // tty the prompt row shows the typed command left-hugged at
+ // column 0 (term_pane.promptRow), so a cursor three cells into
+ // what you can see is three cells past the prompt's END on the
+ // real grid — and it is the real grid this pin addresses.
+ var grid_col: i32 = @max(0, pane.cur_col);
+ if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| {
+ if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (term_pane.promptCut(row_pin)) {
+ .cut => |cols| grid_col += @intCast(cols),
+ .keep, .blank => {},
+ };
+ }
+ if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) } })) |click_pin| {
const cursor_pin = screen.cursor.page_pin.*;
var pit = cursor_pin.promptIterator(.left_up, null);
if (pit.next()) |prompt_pin| {
@@ -7343,11 +7555,9 @@ pub const Pardes = struct {
},
.pipe_selection => return p.startPipe(pane),
.search => return p.startSearch(pane, config.search_marker),
- .search_step => |direction| {
- const delta: i32 = if (direction == .forward) 1 else -1;
- if (p.searchStep(p.active, delta)) return;
- if (pane.isTerminal()) return p.lookStep(pane, pl, delta);
- },
+ .search_step => |direction| return p.lookWalk(
+ if (direction == .forward) @as(i32, 1) else -1,
+ ),
}
}
@@ -7731,13 +7941,18 @@ pub const Pardes = struct {
try output_pane.fillResults(p, id, dir, from, pat, content, anchor);
}
- /// n/N: step to the next/previous row of this pane's results buffer and
- /// ACT on it — which of the two acme verbs that is comes from the buffer's
- /// own traits, so this is one motion over "a list of things you can run",
- /// not two kinds of stepping. A location list (every search, the jumplist,
- /// every language answer) Looks the leading `path:LINE:COL` word; a command
- /// list (ThemeSel) Execs the whole row. False = no live search: a
- /// terminal's n/N falls back to lookStep, anything else stays put.
+ /// Step to the next/previous row of this pane's results buffer and ACT on
+ /// it — which of the two acme verbs that is comes from the buffer's own
+ /// traits. A location list (every search, every language answer) Looks the
+ /// leading `path:LINE:COL` word; a command list (ThemeSel, FontSel) Execs
+ /// the whole row. False = no live results to step.
+ ///
+ /// n/N used to BE this, and are not any more (lookWalk): stepping a list
+ /// of places now selects and stops, because a step that also opened meant
+ /// you could not walk past a hit without landing on it. What still comes
+ /// through here is what is not n/N at all: `]d`/`[d`, whose whole job is
+ /// to GO to the next diagnostic, and acme's button-3, where clicking a
+ /// word that names nothing searches for it and goes to the first hit.
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;
@@ -7772,7 +7987,7 @@ pub const Pardes = struct {
// clicked row can never drift apart. A command row goes whole (its
// argument is the tail after the name); a location row is cut to the
// leading file-ish word, since the rest of it is the matched text.
- if (tr.executes) {
+ if (tr.commands) {
p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r"));
} else {
var hi: usize = 0;
@@ -7964,59 +8179,260 @@ pub const Pardes = struct {
p.reportError(w.pane, "language response", err);
}
- /// n/N on a terminal pane: a MOTION over lookable tokens. Select the
- /// next/prev whitespace-separated token that look.resolve can turn into a
- /// file/dir (several per line: an ls row hops big.txt -> plain.txt), park
- /// the cursor at its start, open NOTHING — Enter's normal-mode handler
- /// looks the selection. Wraps around when nothing lies in the direction
- /// (fresh out of tty mode the cursor sits below the output, so the first
- /// n lands on the first token). Deliberately only THIS pane's directory,
- /// where the look itself walks every pane's: the motion resolves every
- /// token it steps over, so the other directories would cost tokens ×
- /// panes realpaths on a single keystroke — and a token the motion skips
- /// is still openable by looking it, which is all n/N is a shortcut for.
- fn lookStep(p: *Pardes, pane: *Pane, pl: PaneLines, delta: i32) void {
- _ = p; // a pure motion now: Enter's normal-mode handler does the look
- pane.pinCursor(); // fresh out of tty mode the cursor still tracks the shell
+ // ---- n/N: the walk over look-able text ----
+
+ /// How many rows ONE PRESS may scan, across every pane it visits. A
+ /// shell's motion surface is its whole scrollback and every whitespace run
+ /// on it costs a realpath, so the walk is bounded.
+ ///
+ /// Running out STOPS the walk where it stands rather than treating the
+ /// pane as exhausted and moving on, and that distinction is load-bearing:
+ /// giving up in the middle of a pane and hopping to the next one would
+ /// make the two directions disagree about where a pane ENDS, and n/N have
+ /// to be exact inverses. Not moving is the one failure that always is.
+ /// A pane whose next look-able text is eight thousand rows away is a pane
+ /// to scroll, not to step.
+ const max_look_rows = 8192;
+
+ /// Where a step STARTS inside a pane. `col` null enters the pane at the
+ /// row's edge — every span on it is ahead of you — which is what a hop
+ /// from a neighbouring pane does. `strict` says the column is a position
+ /// the walk itself established, so the span sitting ON it is the one you
+ /// are already at and the step must go past it.
+ const LookFrom = struct { row: i32, col: ?i32, strict: bool = false };
+
+ /// Entering a pane from a neighbour: the first row going forward, the last
+ /// going back. Spelled once because it is exactly what makes the two
+ /// directions inverses across a pane boundary.
+ fn lookEdge(delta: i32) LookFrom {
+ return .{ .row = if (delta > 0) 0 else std.math.maxInt(i32), .col = null };
+ }
+
+ /// Where the walk currently stands in `pane`.
+ ///
+ /// `Pane.look_at` and not the cursor alone, because the cursor cannot
+ /// answer the question. A cursor parked on the first look-able span may
+ /// mean the walk put it there — so the next step is the SECOND span — or
+ /// that the pane simply opened that way, which is every fresh +Search, and
+ /// there the next step must be the FIRST. `search_row` answered the same
+ /// question the same way for the same reason. When the recorded stand no
+ /// longer matches the cursor you have moved it yourself since, and the
+ /// cursor wins: the walk continues from where you are looking.
+ fn lookStand(pane: *Pane) LookFrom {
+ if (pane.look_at) |s| if (s.row == pane.cur_row and s.col0 == pane.cur_col)
+ return .{ .row = s.row, .col = s.col0, .strict = true };
+ return .{ .row = pane.cur_row, .col = pane.cur_col };
+ }
+
+ /// The panes n/N walk, in the order it walks them: every pane that has
+ /// performed a LOOK, most recent first, then the OUTPUT buffers none has,
+ /// newest first — and, only when that comes to nothing at all, the pane
+ /// you are in.
+ ///
+ /// The look history is the spine because looking is what marks a pane as
+ /// the one you are reading things OUT of — the +Search you are stepping,
+ /// the diagnostics list, the shell whose `ls` rows you keep opening. The
+ /// unlooked output buffers come after it so a fresh `/`, which has looked
+ /// at nothing yet, still has somewhere for the first `n` to go: its own
+ /// results. FILO among them, so two searches step the newer list first.
+ ///
+ /// The ACTIVE pane is the fallback and NOT a member, which is the
+ /// difference between n continuing a list and n wandering off it. Look a
+ /// row out of a +Search and focus lands in the file that opened; the next
+ /// n has to go back to the +Search, not start walking the paths that
+ /// happen to be in the source you just opened. Only when nothing has
+ /// looked and no buffer has answered — a shell one minute into a session,
+ /// which is where n/N started life — is the pane in front of you the list.
+ fn lookWalkPanes(p: *Pardes, out: *[MAX_PANES]usize) []const usize {
+ var n: usize = 0;
+ var i = p.n_look_src;
+ while (i > 0) {
+ i -= 1;
+ const id = p.paneBySerial(p.look_src[i]) orelse continue;
+ out[n] = id;
+ n += 1;
+ }
+ const looked = n;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const f = pane.file orelse continue;
+ if (f.output == null) continue;
+ for (out[0..looked]) |k| {
+ if (k == id) break;
+ } else {
+ // insertion by serial descending — at most MAX_PANES
+ // comparisons, which is not a sort worth naming
+ var at = n;
+ while (at > looked and p.panes[out[at - 1]].?.serial < pane.serial) : (at -= 1)
+ out[at] = out[at - 1];
+ out[at] = id;
+ n += 1;
+ }
+ }
+ if (n == 0) {
+ if (p.panes[p.active] == null) return out[0..0];
+ out[0] = p.active;
+ n = 1;
+ }
+ return out[0..n];
+ }
+
+ /// The next STEPPABLE span in `pane` from `from`, in `delta`'s direction,
+ /// or null when the pane has none left that way. `budget` is the caller's
+ /// remaining row allowance and is spent here; a null return with a budget
+ /// of zero means GAVE UP, not exhausted (see max_look_rows).
+ ///
+ /// One flag decides what a span IS. In an ordinary pane it is the largest
+ /// look-able run on the row (look.lookableSpan) and a row may hold several
+ /// — an `ls` line hops big.txt -> plain.txt -> sub. In a buffer whose rows
+ /// are COMMANDS (output_pane.Traits.commands: ThemeSel, FontSel) it is the
+ /// WHOLE LINE, because `Theme gruvbox` has no path inside it to pick out
+ /// and the line is the unit you would run. Same motion, same selection,
+ /// same Enter/Tab afterwards; only the grain differs.
+ ///
+ /// Symmetric by construction either way, and that is the whole point: both
+ /// directions ask the same question about the same rows, and both compare
+ /// against `col0` — the column the walk parks the cursor on. So a step
+ /// forward off a span and a step back onto it are the same two positions
+ /// read in the two orders.
+ fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?LookSpot {
+ const pl = p.paneCursorLines(pane) catch return null;
const nrows: i32 = @intCast(pl.lines.len);
- if (nrows == 0) return;
+ if (nrows == 0) return null;
+ const whole_row = if (pane.file) |*f| output_pane.fileTraits(f.output).commands else false;
+ const dir = paneDir(pane);
var realbuf: [4096]u8 = undefined;
- var k: i32 = 0;
- while (k < nrows) : (k += 1) {
- const r = @mod(pane.cur_row + delta * k, nrows);
+ const start = std.math.clamp(from.row, 0, nrows - 1);
+ var r = start;
+ while (r >= 0 and r < nrows) : (r += delta) {
+ if (budget.* == 0) return null;
+ budget.* -= 1;
const ln = pl.lines[@intCast(r)];
- var hit_t0: usize = 0;
- var hit_t1: usize = 0;
+ var best: ?LookSpot = null;
var i: usize = 0;
while (i < ln.len) {
while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1;
const t0 = i;
- while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1;
- if (i == t0) break;
- // on the cursor's own row (no wrap yet) only tokens strictly
- // past the cursor count, in the motion's direction
- if (k == 0) {
- if (delta > 0 and @as(i32, @intCast(t0)) <= pane.cur_col) continue;
- if (delta < 0 and @as(i32, @intCast(t0)) >= pane.cur_col) continue;
+ var spot: LookSpot = undefined;
+ if (whole_row) {
+ // one span per row, from its first non-blank cell to its
+ // last: the trailing trim keeps a padded row selecting the
+ // command and not the padding
+ const end = std.mem.trimEnd(u8, ln, " \t\r").len;
+ if (end <= t0) break;
+ i = end;
+ spot = .{ .row = r, .col0 = @intCast(t0), .col1 = @intCast(end - 1) };
+ } else {
+ while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1;
+ if (i == t0) break;
+ const sp = look.lookableSpan(ln[t0..i], dir, &realbuf) orelse continue;
+ spot = .{
+ .row = r,
+ .col0 = @intCast(t0 + sp.start),
+ .col1 = @intCast(t0 + sp.end - 1),
+ };
}
- if (look.resolve(ln[t0..i], pane.cwdSlice(), &realbuf) == .none) continue;
- hit_t0 = t0;
- hit_t1 = i;
- if (delta > 0) break; // first token forward; keep the last one backward
+ // AFTER the span, never before it, and against `col0` rather
+ // than the whitespace run's start. Those are different columns
+ // the moment a wrapper is peeled: `(mise.toml)` is a run
+ // starting at 0 and a span starting at 1, and a backward step
+ // filtered on the run would find the span it is standing on
+ // still ahead of it and never leave the row. Only the start row
+ // is filtered at all, so the resolves this costs are one row's.
+ if (r == start) if (from.col) |c| {
+ if (delta > 0 and (if (from.strict) spot.col0 <= c else spot.col0 < c)) continue;
+ if (delta < 0 and (if (from.strict) spot.col0 >= c else spot.col0 > c)) continue;
+ };
+ best = spot;
+ if (delta > 0) break; // first one forward; keep the last one back
}
- if (hit_t1 == 0) continue;
- // anchor the selection at the token's end, cursor at its START;
- // EXPLICIT: Enter's look chord acts on it
- pane.vsel = .{ .active = true, .row = r, .col = @intCast(hit_t1 - 1), .explicit = true };
- pane.msel.active = false;
- pane.nsel = 0;
- pane.cur_row = r;
- pane.cur_col = @intCast(hit_t0);
- pane.cur_pinned = true;
- pane.ensureCursorVisible();
- return;
+ if (best) |b| return b;
+ }
+ return null;
+ }
+
+ /// n/N: move the SELECTION to the next/previous look-able text and open
+ /// NOTHING. Enter looks what this leaves selected, and that separation is
+ /// the change: a step is a motion you can take twenty of and then decide,
+ /// where it used to be twenty panes.
+ ///
+ /// The sequence stepped is the concatenation, in lookWalkPanes' order, of
+ /// each pane's look-able spans in document order, AND IT IS A RING. `n` is
+ /// the next position on that ring and `N` the previous one, computed the
+ /// same way from the same state — so x presses one way and x back land
+ /// exactly where you started, across pane boundaries included: a pane
+ /// entered forward is entered at its FIRST span, and leaving it backward
+ /// from that span drops into the previous pane's LAST.
+ ///
+ /// A ring rather than a list with two ends, for two reasons that turn out
+ /// to be one. A shell's cursor sits at the PROMPT, below everything it has
+ /// printed, so a walk that could not come round would have nowhere to go
+ /// on the very first press — which is the case n/N was written for. And a
+ /// ring is still exactly reversible, so nothing is given up for it: acme's
+ /// search has always been one, and this is that.
+ ///
+ /// (One press is not symmetric, and cannot be: from a cursor the walk has
+ /// never stood on, the first step ACQUIRES a position rather than moving
+ /// one — see lookStand. Every press after that is exact.)
+ ///
+ /// No walk state outside the panes themselves, which is what makes this
+ /// robust where the old stepper was not: nothing to go stale when a search
+ /// refills, a pane closes, or you move the cursor and step on from there.
+ ///
+ /// ONE MOTION, EVERYWHERE. Not a pane kind, not a buffer kind, not a mode:
+ /// n/N are this walk in all of them, which is the other half of making
+ /// them trustworthy. A PDF used to step its results buffer and jump; it
+ /// steps the same ring now, which IS that buffer, and Enter does the
+ /// jumping. The single thing any buffer gets to change is the GRAIN of
+ /// what a step selects, and it changes it with one flag rather than a
+ /// branch here: `Traits.commands` makes a row select WHOLE, because a
+ /// ThemeSel line is a word to run and not a place to go (lookSpanIn).
+ ///
+ /// `]d`/`[d` are not n/N. They are helix's diagnostic motions, their job
+ /// is to ARRIVE at the next diagnostic, and they still reach searchStep.
+ fn lookWalk(p: *Pardes, delta: i32) void {
+ var buf: [MAX_PANES]usize = undefined;
+ const order = p.lookWalkPanes(&buf);
+ if (order.len == 0) return;
+ // Start where you ARE when that is somewhere the walk goes; otherwise
+ // at its head, which after a look is the pane you looked FROM — the
+ // look focused what it OPENED, and that is not a walk pane.
+ const at: usize = for (order, 0..) |id, i| {
+ if (id == p.active) break i;
+ } else 0;
+ var from = lookStand(p.panes[order[at]] orelse return);
+ // ...then every OTHER pane once, in the direction of travel, entered
+ // at its edge — and `k == order.len` brings the starting pane round a
+ // second time, from ITS edge, which is the wrap. That bound is also
+ // what makes a screen with nothing look-able on it terminate.
+ var budget: usize = max_look_rows;
+ var k: usize = 0;
+ while (k <= order.len) : (k += 1) {
+ // `+ 2 * len` only so the backward subtraction stays unsigned
+ const idx = (if (delta > 0) at + k else at + 2 * order.len - k) % order.len;
+ const pane = p.panes[order[idx]] orelse continue;
+ if (p.lookSpanIn(pane, from, delta, &budget)) |spot|
+ return p.landLookSpot(order[idx], pane, spot);
+ if (budget == 0) return; // gave up mid-pane: stay put, stay reversible
+ from = lookEdge(delta);
}
- // nothing lookable anywhere: stay put
+ }
+
+ /// Select `spot` and focus its pane. The selection is EXPLICIT so Enter's
+ /// look chord acts on it, with the anchor on the span's last cell and the
+ /// cursor on its FIRST — the same shape the old terminal stepper left, and
+ /// the reason `col0` is the position the walk compares against.
+ fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: LookSpot) void {
+ pane.pinCursor(); // fresh out of tty mode the cursor still tracks the shell
+ pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true };
+ pane.msel.active = false;
+ pane.nsel = 0;
+ pane.cur_row = spot.row;
+ pane.cur_col = spot.col0;
+ pane.cur_pinned = true;
+ pane.look_at = spot;
+ p.active = id;
+ pane.ensureCursorVisible();
}
const EditText = struct { text: []u8, row0: i32 };
@@ -8601,13 +9017,20 @@ pub const Pardes = struct {
}
}
- /// helix p/P: a yank ending in '\n' pastes as whole lines below/above
- /// the SELECTION's line span; anything else splices inline at the
- /// selection's outer edge. The paste (repeated <count> times) becomes
- /// the implicit selection, cursor on its last char (linewise: ON the
- /// last pasted line's newline).
+ /// helix p/P: the DEFAULT register, after/before the selection.
fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void {
- const y0 = p.yank orelse return;
+ p.pasteText(pane, p.yank orelse return, before);
+ }
+
+ /// ...and the paste itself, over text from wherever: the register above,
+ /// or the system clipboard `SPC p` asked the shell for, which deliberately
+ /// never passes through the register on its way here.
+ ///
+ /// Text ending in '\n' pastes as whole lines below/above the SELECTION's
+ /// line span; anything else splices inline at the selection's outer edge.
+ /// The paste (repeated <count> times) becomes the implicit selection,
+ /// cursor on its last char (linewise: ON the last pasted line's newline).
+ fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool) void {
if (y0.len == 0) return;
p.pushUndo(pane);
pane.select = false;
@@ -8830,11 +9253,15 @@ pub const Pardes = struct {
p.setEditText(pane, new);
}
- /// `R`: replace the selection (or the cursor char) with the yank register;
- /// the pasted text becomes the selection, head on its last char (which
- /// for a trailing-newline yank is the '\n' cell of the last full line)
+ /// `R`: replace the selection (or the cursor char) with the DEFAULT
+ /// register. `SPC R` is the same verb over the system clipboard.
fn normalReplaceYank(p: *Pardes, pane: *Pane) void {
- const y = p.yank orelse return;
+ p.replaceWithText(pane, p.yank orelse return);
+ }
+
+ /// The pasted text becomes the selection, head on its last char (which for
+ /// a trailing-newline `y` is the '\n' cell of the last full line).
+ fn replaceWithText(p: *Pardes, pane: *Pane, y: []const u8) void {
if (y.len == 0) return;
pane.select = false;
const eb = p.editTextEol(pane, selRows(pane)) orelse return;
@@ -11566,9 +11993,13 @@ pub const Pardes = struct {
if (pane.tag_edit) pane.mode = .normal;
},
.search => p.startSearch(pane, config.search_marker),
- .search_step => |direction| {
- _ = p.searchStep(p.active, if (direction == .forward) 1 else -1);
- },
+ // A PDF is not a special case: it steps the same ring every other
+ // pane does, which in practice is the +Search or +PdfSections
+ // buffer its own search filled. n selects the row, Enter jumps to
+ // the page.
+ .search_step => |direction| return p.lookWalk(
+ if (direction == .forward) @as(i32, 1) else -1,
+ ),
else => {},
}
}
@@ -11760,6 +12191,10 @@ pub const Pardes = struct {
pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void {
const pane = p.panes[id] orelse return;
p.noteHaptic(.look);
+ // ...and this pane is now the head of the n/N walk. Recorded HERE, at
+ // the one dispatcher every look reaches, so a right click, an Enter, a
+ // stepped result row and the word `Look` all count alike.
+ p.noteLookSource(id);
const trimmed = std.mem.trim(u8, txt, " \t\r\n");
// `` @`ls -la` `` names a COMMAND, not a path: run it, and land in the
// pane that answers — looking at a thing means being SHOWN it, and a
@@ -12184,10 +12619,9 @@ pub const Pardes = struct {
var row: i32 = 0;
var skip: i32 = 0;
while (lines.next()) |raw| : (row += 1) {
- const is_prompt = if (pane.mode != .tty)
- if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false
- else
- false;
+ // stepped for EVERY line, before the skip below, or the
+ // row iterator falls out of step with the dump's lines
+ const prompt_pin = if (pane.mode != .tty) prompts.next() else null;
if (skip > 0) {
skip -= 1;
continue;
@@ -12202,7 +12636,15 @@ pub const Pardes = struct {
skip = o.rows - 1;
continue;
};
- const shown = if (is_prompt) "" else raw;
+ // same rule the motion surface and the body use, so a dump
+ // reloads showing exactly what the pane showed
+ const shown = if (prompt_pin) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none)
+ term_pane.promptRow(pin, raw)
+ else
+ raw
+ else
+ raw;
@memcpy(stream_buf[stream_len..][0..shown.len], shown);
stream_len += shown.len;
}
@@ -13814,3 +14256,360 @@ test "hopping between two panes does not grow the jump stack" {
try std.testing.expect(p.active != before);
try std.testing.expectEqual(depth, p.njumps);
}
+
+/// A results buffer with rows we control: three look-able locations and one
+/// row with nothing look-able on it at all. Returns its slot. `pat` is the
+/// recorded pattern and is what keeps two of these APART — fillResults refills
+/// a buffer whose origin, argument and directory all match.
+fn walkFixture(p: *Pardes, id: usize, cwd: []const u8, pat: []const u8) !usize {
+ const rows = try std.fmt.allocPrint(p.gpa,
+ \\build.zig:1:1 first
+ \\(mise.toml) and build.zig.zon:3:2-9 two on one row
+ \\nothing look-able on this row at all
+ \\uucode_config.zig:7:1 last
+ \\
+ , .{});
+ try output_pane.fillResults(p, id, cwd, .search, pat, rows, null);
+ return p.panes[id].?.search_pane orelse error.MissingResults;
+}
+
+test "n/N select look-able text and open nothing" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ var cwdbuf: [4096]u8 = undefined;
+ const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
+ var pathbuf: [4096]u8 = undefined;
+ const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
+
+ const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 40, .file = boot });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
+ const rid = try walkFixture(p, p.active, cwd, "one");
+ const rp = p.panes[rid].?;
+ const panes_before = p.freeSlot();
+
+ // Nothing has looked yet, so the walk's list is the one unvisited output
+ // buffer. The first n lands on row 0's leading token, FOCUSES that buffer,
+ // and opens nothing whatsoever.
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(rid, p.active);
+ try std.testing.expectEqual(panes_before, p.freeSlot());
+ try std.testing.expectEqual(@as(i32, 0), rp.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), rp.cur_col);
+ // EXPLICIT, anchored on the span's last cell: what Enter's look acts on
+ try std.testing.expect(rp.vsel.active and rp.vsel.explicit);
+ try std.testing.expectEqualStrings("build.zig:1:1", p.currentSelText(rp) orelse "");
+
+ // Row 1 holds TWO: a parenthesised path, whose wrappers are peeled off the
+ // selection, and a `path:LINE:COL-END` whose position tail is kept.
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(i32, 1), rp.cur_row);
+ try std.testing.expectEqualStrings("mise.toml", p.currentSelText(rp) orelse "");
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(i32, 1), rp.cur_row);
+ try std.testing.expectEqualStrings("build.zig.zon:3:2-9", p.currentSelText(rp) orelse "");
+
+ // Row 2 has nothing to step to and is skipped entirely.
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(i32, 3), rp.cur_row);
+ try std.testing.expectEqualStrings("uucode_config.zig:7:1", p.currentSelText(rp) orelse "");
+
+ // Past the last span the ring comes round to the first.
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(i32, 0), rp.cur_row);
+
+ // ...and Enter on a selection is what finally opens something.
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expect(p.freeSlot() != panes_before);
+ try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/build.zig"));
+}
+
+test "N is the exact inverse of n, across panes and the ring's seam" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ var cwdbuf: [4096]u8 = undefined;
+ const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
+ var pathbuf: [4096]u8 = undefined;
+ const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
+
+ const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 40, .file = boot });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
+
+ // TWO buffers, so the walk has a pane boundary to cross and a seam to wrap
+ // over — the two places a hand-rolled inverse gets it wrong.
+ const first = try walkFixture(p, p.active, cwd, "one");
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell to hang the second off
+ p.sync();
+ const second = try walkFixture(p, p.active, cwd, "two");
+ try std.testing.expect(first != second);
+
+ const Mark = struct { pane: usize, row: i32, col: i32 };
+ const here = struct {
+ fn at(pp: *Pardes) Mark {
+ const pane = pp.panes[pp.active].?;
+ return .{ .pane = pp.active, .row = pane.cur_row, .col = pane.cur_col };
+ }
+ }.at;
+
+ // The FIRST press only acquires a position — before it the cursor is
+ // wherever the shell left it, which is not a place on the ring — so the
+ // symmetry claim starts one step in. Everything after that is exact.
+ p.update(.{ .key = .{ .cp = 'n' } });
+ const base = here(p);
+
+ // Twelve more forward is further than either buffer holds, so the trail
+ // crosses both pane boundaries and wraps over the seam; record every
+ // position it passes through.
+ var trail: [12]Mark = undefined;
+ for (&trail) |*m| {
+ p.update(.{ .key = .{ .cp = 'n' } });
+ m.* = here(p);
+ }
+ // ...and twelve back must retrace it exactly, position by position, ending
+ // on the one the count started from.
+ var i = trail.len;
+ while (i > 0) {
+ i -= 1;
+ p.update(.{ .key = .{ .cp = 'N' } });
+ const want = if (i == 0) base else trail[i - 1];
+ try std.testing.expectEqual(want.pane, p.active);
+ try std.testing.expectEqual(want.row, p.panes[p.active].?.cur_row);
+ try std.testing.expectEqual(want.col, p.panes[p.active].?.cur_col);
+ }
+
+ // The walk visited both buffers rather than circling inside one.
+ var saw_first = false;
+ var saw_second = false;
+ for (trail) |m| {
+ if (m.pane == first) saw_first = true;
+ if (m.pane == second) saw_second = true;
+ }
+ try std.testing.expect(saw_first and saw_second);
+}
+
+test "n/N select a command row whole, and a look puts its pane at the head" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ var cwdbuf: [4096]u8 = undefined;
+ const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
+ var pathbuf: [4096]u8 = undefined;
+ const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
+
+ const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 40, .file = boot });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
+
+ // A COMMAND list: its rows are words to run, so a step takes the whole
+ // line — there is no path inside `Theme acme` to pick out.
+ p.runBuiltin(.ThemeSel, p.active, "", null);
+ const tid = p.panes[p.active].?.search_pane orelse return error.MissingThemeList;
+ const tp = p.panes[tid].?;
+ try std.testing.expect(output_pane.fileTraits(tp.file.?.output).commands);
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(tid, p.active);
+ const row0 = std.mem.trimEnd(u8, modal.lineSlice(tp.file.?.content, 0), " \t\r");
+ try std.testing.expectEqualStrings(row0, p.currentSelText(tp) orelse "");
+ // ...and Tab RUNS what n selected, which is how a theme is worn now.
+ const theme_before = p.theme_idx;
+ p.update(.{ .key = .{ .cp = 'n' } });
+ p.update(.{ .key = .{ .cp = Key.tab } });
+ try std.testing.expect(p.theme_idx != theme_before);
+
+ // A LOOK is what puts a pane at the head of the walk. Look from the boot
+ // pane and the next n steps THERE, not in the theme list, even though the
+ // look moved focus into whatever it opened.
+ p.runBuiltin(.Look, 0, "", "build.zig");
+ p.sync();
+ try std.testing.expect(p.active != 0);
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+}
+
+/// Drain the queue and say whether the shell was asked to do `want`.
+fn drainedEffect(p: *Pardes, want: std.meta.Tag(Effect)) bool {
+ var seen = false;
+ while (p.nextEffect()) |effect| {
+ if (std.meta.activeTag(effect) == want) seen = true;
+ }
+ return seen;
+}
+
+test "only the SPC clipboard commands cross to the system clipboard" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ const pane = p.panes[0].?;
+
+ // An ordinary yank fills the DEFAULT REGISTER and asks the shell for
+ // nothing. This is the whole helix split, and the bug it closes: before
+ // it, every y/d/c mirrored out, so deleting one character threw away
+ // whatever the desktop was holding.
+ _ = drainedEffect(p, .set_clipboard);
+ p.update(.{ .key = .{ .cp = 'y' } });
+ try std.testing.expect(p.yank != null and p.yank.?.len > 0);
+ try std.testing.expect(!drainedEffect(p, .set_clipboard));
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expect(!drainedEffect(p, .set_clipboard));
+
+ // `SPC y` is the one that does, and it puts the SAME text there that `y`
+ // put in the register — it is the ordinary yank path plus the mirror.
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'y' } });
+ const yanked = p.yank orelse return error.MissingYank;
+ try std.testing.expect(drainedEffect(p, .set_clipboard));
+
+ // `SPC p` cannot read the clipboard itself: it ASKS, and the answer comes
+ // back as an ordinary paste event whenever (or never — a terminal may
+ // refuse the OSC 52 read, which is a no-op and not a hang).
+ const before = pane.file.?.content.len;
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'p' } });
+ try std.testing.expect(p.clip_pending != null);
+ try std.testing.expect(drainedEffect(p, .read_clipboard));
+ try std.testing.expectEqual(before, pane.file.?.content.len); // nothing yet
+ p.update(.{ .paste = "PASTED" });
+ try std.testing.expect(p.clip_pending == null);
+ try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "PASTED") != null);
+ // ...and it did NOT land in the register on its way past, nor echo back
+ // out to the clipboard it came from.
+ try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+ try std.testing.expect(!drainedEffect(p, .set_clipboard));
+
+ // An UNSOLICITED paste — the outer terminal's bracketed paste, a Cmd-V —
+ // is the same event with no request behind it, and means paste after.
+ p.update(.{ .paste = "UNASKED" });
+ try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "UNASKED") != null);
+ try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+
+ // A request the shell never answers dies at the next keystroke rather
+ // than firing late into whatever pane is focused by then.
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'P' } });
+ try std.testing.expect(p.clip_pending != null);
+ p.update(.{ .key = .{ .cp = 'l' } });
+ try std.testing.expect(p.clip_pending == null);
+}
+
+test "leaving tty hides the prompt and keeps the command typed at it" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
+ const shell = p.active;
+ const sp = p.panes[shell].?;
+ try std.testing.expect(sp.isTerminal());
+
+ // Exactly what an OSC 133 shell draws: prompt, then the marker that says
+ // the rest of this row is the user's, then what they typed. One grid row.
+ p.update(.{ .output = .{
+ .pane = @intCast(shell),
+ .bytes = "\x1b]133;A\x1b\\user@box ~/src $ \x1b]133;B\x1b\\grep -rn TODO src/",
+ } });
+ p.sync();
+
+ const rowOf = struct {
+ fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 {
+ const rows = term_pane.shellRows(pp, pane) catch return null;
+ for (rows) |r| if (std.mem.indexOf(u8, r, needle) != null) return r;
+ return null;
+ }
+ }.at;
+ const bodyRowOf = struct {
+ fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 {
+ const body = term_pane.bodyText(pp.scratch.allocator(), pane) catch return null;
+ var it = std.mem.splitScalar(u8, body, '\n');
+ while (it.next()) |r| if (std.mem.indexOf(u8, r, needle) != null) return r;
+ return null;
+ }
+ }.at;
+
+ // The BODY is what you look at, and it follows the mode: in tty the pane
+ // is the program's own screen, so the prompt is there.
+ sp.mode = .tty;
+ try std.testing.expectEqualStrings(
+ "user@box ~/src $ grep -rn TODO src/",
+ bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow,
+ );
+
+ // Out of tty the prompt goes and the command stays — LEFT-HUGGED, so it
+ // lines up with the output below instead of sitting in a bay of blanks
+ // where the prompt used to be.
+ sp.mode = .normal;
+ try std.testing.expectEqualStrings(
+ "grep -rn TODO src/",
+ bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow,
+ );
+
+ // The MOTION SURFACE cuts either way, and deliberately: it is what the
+ // cursor moves over, and in tty mode nothing moves over it — the keys all
+ // belong to the program.
+ p.shell_rows.stale = true;
+ try std.testing.expectEqualStrings(
+ "grep -rn TODO src/",
+ rowOf(p, sp, "grep") orelse return error.MissingPromptRow,
+ );
+
+ // ...and a prompt with nothing typed at it yet is all chrome, so the whole
+ // row goes, which is what it has always done.
+ p.update(.{ .output = .{
+ .pane = @intCast(shell),
+ .bytes = "\r\n\x1b]133;A\x1b\\user@box ~/src $ \x1b]133;B\x1b\\",
+ } });
+ p.sync();
+ p.shell_rows.stale = true;
+ try std.testing.expect(rowOf(p, sp, "user@box") == null);
+}
+
+test "entering tty walks the shell cursor to the column clicked past the prompt" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } });
+ const shell = p.active;
+ const sp = p.panes[shell].?;
+ p.update(.{ .output = .{
+ .pane = @intCast(shell),
+ .bytes = "\x1b]133;A;cl=line\x1b\\prompt> \x1b]133;B\x1b\\0123456789",
+ } });
+ p.sync();
+ sp.mode = .normal;
+ p.shell_rows.stale = true;
+
+ // The command shows LEFT-HUGGED, so its column 3 is the '3'...
+ const rows = try term_pane.shellRows(p, sp);
+ var row: i32 = 0;
+ const at = for (rows, 0..) |r, i| {
+ if (std.mem.indexOf(u8, r, "0123456789") != null) break i;
+ } else return error.MissingInputRow;
+ row = @intCast(at);
+ try std.testing.expectEqualStrings("0123456789", rows[at]);
+
+ // ...but the shell's own cursor lives on the real grid, eight cells
+ // further right, behind the prompt this pane is not showing.
+ sp.cur_row = row;
+ sp.cur_col = 3;
+ sp.cur_pinned = true;
+ while (p.nextEffect()) |_| {}
+ p.enterTty(shell);
+
+ // The walk is arrow keys the shell understands. Seven lefts: readline's
+ // cursor sits past the '9' and the click was on the '3'.
+ var lefts: usize = 0;
+ var rights: usize = 0;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |wr| {
+ if (std.mem.eql(u8, wr.bytes.slice(), "\x1b[D")) lefts += 1;
+ if (std.mem.eql(u8, wr.bytes.slice(), "\x1b[C")) rights += 1;
+ },
+ else => {},
+ };
+ try std.testing.expectEqual(@as(usize, 0), rights);
+ try std.testing.expectEqual(@as(usize, 7), lefts);
+}