diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-11 18:30:13 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-12 13:04:32 -0300 |
| commit | b424164922842796619cb6894ec46d729a8a6826 (patch) | |
| tree | bd4653accfaeb67012f4e7257c61eaf4fb12c892 /src/pardes.zig | |
| parent | 08f32cdde672740b71608f3dfccd629dcbac78f7 (diff) | |
| download | pardes-b424164922842796619cb6894ec46d729a8a6826.tar.gz pardes-b424164922842796619cb6894ec46d729a8a6826.zip | |
clipboard, n/N and the tty prompt: three things that were half-wired
Three changes that all turned out to be the same shape -- a feature that
worked in one direction, or for one pane kind, and quietly did not in the
others.
CLIPBOARD. Every register write emitted set_clipboard, so deleting one
character threw away whatever the desktop was holding; multi-cursor yank took
the join's early return and emitted nothing at all, so the same key reached
the clipboard on one cursor and not on two. Nothing could READ the clipboard:
the SDL shell had no SDL_GetClipboardText anywhere in it, and the tty shell
never asked for OSC 52, so `p` from another application was dead in both.
Now it is helix's split. y/d/c/p/P/R and the acme chords are the DEFAULT
REGISTER and nothing else; the system clipboard is five words on helix's own
letters -- SPC y, SPC Y, SPC p, SPC P, SPC R -- spelled as builtins so they
land in Help and are executable like every other verb. The one exception is
the tag `y` chord, which still mirrors out because a tag is always insert, so
SPC cannot be pressed there, and copying the path out is the whole point of
the chord.
Reading is a new read_clipboard effect answered by an ordinary Event.paste, so
the round trip is honest about being one: SDL and NSPasteboard answer inside
the same drain, the browser answers a promise, and a terminal answers over
OSC 52 or -- far more often -- refuses. A refused read is a paste that does
not happen, and the request dies at the next keystroke rather than landing
minutes late in whatever pane is focused by then.
The tty shell also enables BRACKETED PASTE now and coalesces
paste_start..paste_end into one event. Before this a paste arrived as a flood
of individual key presses: plausible in insert mode, and in normal mode every
pasted character ran as a command.
n/N. They stepped the armed results buffer and immediately Looked each row, so
you could not walk past a hit without opening it. They are a MOTION now:
select the next look-able text, open nothing, and let Enter decide. What they
step is the largest whitespace-delimited run look.resolve can act on
(look.lookableSpan, wrapper punctuation peeled), over a RING of panes -- every
pane that has performed a look, most recent first, then the output buffers
that have not, newest first, and only if both are empty the pane in front of
you. N is the exact inverse of n, computed rather than remembered: both
directions ask the same question about the same spans and compare against the
column the walk parks on, so x presses one way and x back land exactly where
you started, pane boundaries and the ring's seam included.
A ring rather than a list with two ends because 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 were
written for.
One motion everywhere, no pane-kind or buffer-kind special case. The only
thing a buffer may change is the GRAIN of what a step selects, and it does it
with one flag rather than a branch: output_pane.Traits.commands (renamed from
`executes`, which named one reader's behaviour rather than the fact) makes a
row select WHOLE, because a ThemeSel line is a word to run and has no path
inside it to pick out. `]d`/`[d` are not n/N -- they are helix's diagnostic
motions, their job is to ARRIVE, and they still reach searchStep.
THE TTY PROMPT. Leaving raw tty blanked the prompt row, and the command you
had typed at that prompt shares the row, so it went too -- a shell out of tty
read as output only. OSC 133 marks the row CELL by cell, so the two are
separable: config.tty_blank = .prompt cuts the prompt's own columns and leaves
the command, left-hugged at column 0 in line with the output under it rather
than in a bay of blanks. .prompt_and_input is the old behaviour, kept.
Because the row is now something you can put a cursor in, enterTty adds the
hidden prompt width back before asking ghostty to walk the shell's own cursor
to it -- the modal column on a cut row is short by exactly that much.
Verified: unit-test 186/186 (nine new), snap 87/87 (new ttyprompt.snap),
hxdiff 481 and hxparity 561 with 0 mismatches, tty and gui both build. And
against the real binaries rather than the harness: in a pty, SPC y emits OSC
52 carrying exactly the selection while plain y emits nothing, SPC p issues
the read and pastes the reply, and a bracketed paste of "dd..." inserts text
instead of deleting two lines. In a real SDL window, SPC y then SPC p round
trips through the system clipboard while the default register holds different
text. Setting tty_blank back to .prompt_and_input reproduces all 86 old
goldens byte for byte.
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 993 |
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); +} |
