diff options
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); +} |
