diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 70 | ||||
| -rw-r--r-- | src/config.zig | 42 | ||||
| -rw-r--r-- | src/gui/gui.zig | 39 | ||||
| -rw-r--r-- | src/look.zig | 76 | ||||
| -rw-r--r-- | src/macos.zig | 15 | ||||
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 22 | ||||
| -rw-r--r-- | src/macos/pardes.h | 7 | ||||
| -rw-r--r-- | src/output_pane.zig | 36 | ||||
| -rw-r--r-- | src/pardes.zig | 993 | ||||
| -rw-r--r-- | src/term_pane.zig | 76 | ||||
| -rw-r--r-- | src/tty/tty.zig | 85 | ||||
| -rw-r--r-- | src/tutor.txt | 44 | ||||
| -rw-r--r-- | src/web.zig | 4 | ||||
| -rw-r--r-- | src/web/app.mjs | 5 |
14 files changed, 1366 insertions, 148 deletions
diff --git a/src/builtins.zig b/src/builtins.zig index f207ed9f..39a147fa 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -107,7 +107,7 @@ pub const OutputTraits = struct { name: []const u8, steps: bool = false, jumps: bool = false, - executes: bool = false, + commands: bool = false, }; /// The enum: field name = struct name, value = index into `all()`. Everything @@ -297,12 +297,13 @@ pub const Shell = struct { }; /// ...and the list of what Theme takes, as a buffer you walk. Its rows are -/// `Theme <name>` COMMANDS rather than locations, so n/N execute them instead -/// of looking them (output_pane.Traits.executes) and stepping the list wears -/// each theme in turn — the picker is the list, and there is nothing to -/// confirm because arriving already applied it. +/// `Theme <name>` COMMANDS rather than locations, which is one flag on the +/// buffer (output_pane.Traits.commands) and changes what a step SELECTS: the +/// whole line, since there is no path inside it to pick out. Tab on what n +/// selected wears that theme — the same middle click on the row is — so +/// walking the list with n/Tab is trying them on, and stopping is choosing. pub const ThemeSel = struct { - pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .executes = true }; + pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .commands = true }; pub fn run(c: Ctx) void { output_pane.openThemes(c.p, c.id) catch |err| c.p.reportError(c.id, "themes", err); } @@ -368,7 +369,7 @@ pub const Font = struct { /// unreadable one — and this picker EXECUTES what it steps onto, so listing /// them would mean the list wearing one on the way past. See fonts.monospaced. pub const FontSel = struct { - pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .executes = true }; + pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .commands = true }; pub const enabled = pardes.font_picker; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; @@ -455,6 +456,61 @@ pub const Ascii = struct { } }; +// ---- the system clipboard ---- + +// helix's `<space>` clipboard menu, and the ONLY five words in pardes that +// touch the desktop's clipboard. Everything else — `y`, `d`, `c`, `p`, `P`, +// `R`, the acme cut/paste chords — lives entirely in the internal register, +// which is helix's arrangement and, less abstractly, the reason deleting a +// character no longer throws away whatever you had copied from a browser. +// +// They are builtins rather than bare chords because the leader table is the +// remapping surface and a leader path names a builtin: spelling them here +// puts them in Help's index, makes them executable words like every other +// verb, and costs no second mechanism. Their paths ARE helix's letters, on +// the same leader helix uses — see config.leader_path. +// +// The two directions are not symmetric, and cannot be. Writing is a fire-off: +// the core owns the bytes and the shell copies them out. READING has to leave +// the core and come back — SDL and NSPasteboard answer inside the same drain, +// a browser answers a promise later, and a terminal answers over OSC 52 or, +// far more often, refuses outright. So a paste is a REQUEST (the +// read_clipboard effect) that may simply never be answered, and a `SPC p` +// that does nothing in a locked-down terminal is the honest outcome rather +// than a bug to paper over with the internal register. + +pub const ClipYank = struct { + pub fn run(c: Ctx) void { + c.p.clipYank(c.pane, false); + } +}; + +/// helix `<space>Y`: the PRIMARY selection alone, where `SPC y` joins every +/// cursor's. One cursor makes them the same word. +pub const ClipYankMain = struct { + pub fn run(c: Ctx) void { + c.p.clipYank(c.pane, true); + } +}; + +pub const ClipPaste = struct { + pub fn run(c: Ctx) void { + c.p.clipRequest(c.id, .after); + } +}; + +pub const ClipPasteBefore = struct { + pub fn run(c: Ctx) void { + c.p.clipRequest(c.id, .before); + } +}; + +pub const ClipReplace = struct { + pub fn run(c: Ctx) void { + c.p.clipRequest(c.id, .replace); + } +}; + // ---- panes and columns ---- pub const Save = struct { diff --git a/src/config.zig b/src/config.zig index 189d6a0e..d6d6813d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -109,6 +109,20 @@ pub const leader_path = paths: { .Lspwhy = "lw", .Del = "d", .Kill = "k", + // THE CLIPBOARD MENU, on helix's own five letters and nowhere else. + // These are the only paths in the table that keep their helix spelling + // unprefixed, and they can: `y` `Y` `p` `P` `R` were free at the top + // level, and moving them into a group would have made the one thing + // here that IS helix's leader stop looking like it. + // + // Bare `y`/`p`/`P`/`R` remain the DEFAULT register — that split is the + // whole design (see builtins.zig's clipboard section), and it is why + // an ordinary delete no longer reaches past the editor. + .ClipYank = "y", + .ClipYankMain = "Y", + .ClipPaste = "p", + .ClipPasteBefore = "P", + .ClipReplace = "R", // the `f` file group (spacemacs): Save left vim's `w` to join Find and // New here, which frees `w` for the window group (SPC w h/j/k/l). .Save = "fs", @@ -282,6 +296,27 @@ pub const tty_toggle_default: u21 = 'b'; /// means what Escape always means. pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; +/// What LEAVING raw tty mode hides on the shell's prompt rows. +/// +/// A prompt is CHROME. `user@host ~/src $` is redrawn on every keystroke, says +/// nothing a second time, and is never what you want to select, look at or +/// edit — so blanking it is most of what turns a scrollback into a readable +/// document, and pardes has always done it (OSC 133 is how it knows). +/// +/// What it USED to take with it was the command you had typed at that prompt, +/// because the two share a grid row and the row was the unit. That command is +/// content: the one thing on the row worth keeping, and the thing you reach +/// for `b` to get at in the first place. OSC 133 marks the two separately — +/// per CELL, not just per row — so `.prompt` blanks the prompt's own cells and +/// leaves the input sitting in the COLUMNS it really occupies. Those columns +/// are not cosmetic: clicking the command in normal mode and pressing the +/// toggle carries the click into the shell's own cursor (promptClickMove), +/// which counts them. +/// +/// `.prompt_and_input` is the older behaviour, kept for anyone who wants a +/// terminal to read as output and nothing else. +pub const tty_blank: enum { prompt, prompt_and_input } = .prompt; + // ---- the tag line and the topbar ---- // The topbar is a HAND-PICKED subset in a fixed order, not a derivation: row 0 @@ -801,14 +836,17 @@ pub const select_regex: []const Chord = &.{.{ .cp = 's' }}; pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; // ---- edits ---- +// +// Every one of these reads or writes the DEFAULT register and only that. +// The system clipboard is five separate words on `SPC y Y p P R`, which is +// helix's split and the reason `d` cannot silently eat what you copied out of +// a browser. See leader_path above and builtins.zig's clipboard section. pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; pub const change: []const Chord = &.{.{ .cp = 'c' }}; pub const yank: []const Chord = &.{.{ .cp = 'y' }}; pub const replace_with_yank: []const Chord = &.{.{ .cp = 'R' }}; -/// helix: the DEFAULT register, not the system clipboard — most terminals -/// refuse the OSC 52 read, so a round trip would never come back pub const paste_after: []const Chord = &.{.{ .cp = 'p' }}; pub const paste_before: []const Chord = &.{.{ .cp = 'P' }}; pub const switch_case: []const Chord = &.{.{ .cp = '~' }}; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 7e34a928..82866a30 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2473,10 +2473,19 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { while (tptr[tlen] != 0) : (tlen += 1) {} if (tlen == 0) return; const text: []const u8 = tptr[0..tlen]; - const cplen = std.unicode.utf8ByteSequenceLength(text[0]) catch return; - if (cplen > text.len) return; - const cp = std.unicode.utf8Decode(text[0..cplen]) catch return; - core.update(.{ .key = .{ .cp = cp, .text = text[0..cplen], .ctrl = g.live_ctrl, .alt = g.live_alt } }); + // ONE event is not one codepoint. An IME commit arrives whole — + // the entire phrase the candidate window was holding — and so does + // anything composed (dead keys, `Ctrl-Shift-u`, a compose-key + // sequence that resolves to more than one scalar). Decoding only + // text[0] dropped the rest on the floor, silently. Validate the + // whole string first so a truncated or malformed sequence costs + // nothing rather than half a phrase already forwarded. + const view = std.unicode.Utf8View.init(text) catch return; + var it = view.iterator(); + var at: usize = 0; + while (it.nextCodepoint()) |cp| : (at = it.i) { + core.update(.{ .key = .{ .cp = cp, .text = text[at..it.i], .ctrl = g.live_ctrl, .alt = g.live_alt } }); + } }, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { const b = sev.button; @@ -2959,6 +2968,19 @@ fn drainEffects( defer gpa.free(z); _ = c.SDL_SetClipboardText(z.ptr); }, + .read_clipboard => { + if (g == null) continue; + // SDL answers synchronously, so the paste the core is waiting on + // lands inside this same drain — nothing to remember, no reply + // path to plumb. SDL3 hands over an OWNED copy that is ours to + // SDL_free, and reports "no text" as an EMPTY string rather than + // null, so the length check is what actually rejects a miss. + const raw = c.SDL_GetClipboardText() orelse continue; + defer c.SDL_free(raw); + const text = std.mem.span(raw); + if (text.len == 0) continue; + core.update(.{ .paste = text }); + }, .lsp => |e| if (threads_ok) spawnLsp(core, queue, e), .pipe => |e| if (threads_ok) spawnPipe(core, io, gpa, queue, pipe_tasks, e), .watch => |w| { @@ -3010,6 +3032,15 @@ fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { defer gpa.free(z); _ = c.SDL_SetClipboardText(z.ptr); }, + .read_clipboard => { + // emscripten fills SDL's clipboard only from a real paste gesture, + // so an empty answer here is the ordinary case and not a failure. + const raw = c.SDL_GetClipboardText() orelse continue; + defer c.SDL_free(raw); + const text = std.mem.span(raw); + if (text.len == 0) continue; + core.update(.{ .paste = text }); + }, // look on a URL → a new tab .open_link => |url| openLinkWeb(gpa, url.slice()), // nothing to spawn/write/resize/save/dump into, and no filesystem to diff --git a/src/look.zig b/src/look.zig index 7169cf8b..36390ec2 100644 --- a/src/look.zig +++ b/src/look.zig @@ -225,6 +225,82 @@ test ".pdf Look paths are ordinary files when MuPDF is disabled" { } } +/// Where a look-able word actually SITS inside a run of non-whitespace. +pub const Span = struct { start: usize, end: usize }; + +/// The punctuation a path wears in prose and never owns. Two sets, because +/// the two ends are not alike: a directory may legally END in `/`, and the +/// `:` that closes `grep -n`'s `main.zig:100:` is junk on the right and +/// meaningful nowhere on the left. +const lead_trim = "([{<\"'`*"; +const trail_trim = ")]}>\"'`*,;:.!?"; + +/// The largest look-able span inside one whitespace-delimited `word`, or null +/// when nothing in it resolves. This is the whole heuristic behind n/N: split +/// on whitespace, and take the biggest piece of each run that Look can act on. +/// +/// TWO resolve attempts at most, which is what keeps a motion across a +/// screenful of prose from being a hundred realpaths: the run with every +/// wrapper character peeled off BOTH ends at once, then — only if that found +/// nothing — the run exactly as written. +/// +/// PEELED FIRST, which is the ordering that matters. `resolve` is lenient +/// about a tail it cannot parse (`main.zig:12:3,` yields the FILE and drops +/// the position, by design), so asking it about the raw run first would +/// happily answer yes and swallow the comma along with the `:3`. Peeling +/// first hands it `main.zig:12:3` and the look lands on the column. The raw +/// run stays as the fallback for the file genuinely named `foo,` or `..`, +/// where the peel eats something real. +/// +/// Deliberately NOT a search for the longest resolving substring: that costs +/// a syscall per prefix to find a path hiding inside a word nobody typed as +/// one. A run needing a cleverer peel is still one Enter away with the cursor +/// parked on it. +/// +/// Direction-free on purpose: n and N ask this the same question about the +/// same run and get the same span back, which is what lets the two motions be +/// exact inverses of each other. +pub fn lookableSpan(word: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { + if (word.len == 0) return null; + var lo: usize = 0; + var hi: usize = word.len; + while (lo < hi and std.mem.indexOfScalar(u8, lead_trim, word[lo]) != null) lo += 1; + while (hi > lo and std.mem.indexOfScalar(u8, trail_trim, word[hi - 1]) != null) hi -= 1; + if (lo < hi and resolve(word[lo..hi], cwd, realbuf) != .none) return .{ .start = lo, .end = hi }; + // nothing came off, so the peeled attempt WAS the raw one + if (lo == 0 and hi == word.len) return null; + if (resolve(word, cwd, realbuf) == .none) return null; + return .{ .start = 0, .end = word.len }; +} + +test "lookableSpan peels prose punctuation off a path, largest first" { + if (!platform_has_fs) return; + var realbuf: [4096]u8 = undefined; + // the bare run resolves whole, wrappers and all left alone + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 0, .end = "src/look.zig".len }), + lookableSpan("src/look.zig", ".", &realbuf), + ); + // ...and a wrapped one gives back the span INSIDE the wrappers + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 1, .end = 1 + "src/look.zig".len }), + lookableSpan("(src/look.zig),", ".", &realbuf), + ); + // the `:LINE:COL` tail is part of the span: it is what a look READS + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 0, .end = "src/look.zig:12:3".len }), + lookableSpan("src/look.zig:12:3,", ".", &realbuf), + ); + // grep -n's trailing delimiter comes off, the line number stays + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 0, .end = "src/look.zig:12".len }), + lookableSpan("src/look.zig:12:", ".", &realbuf), + ); + try std.testing.expectEqual(@as(?Span, null), lookableSpan("nothing-here", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), lookableSpan("", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), lookableSpan("((()))", ".", &realbuf)); +} + /// Resolve a looked-at word against the pane's directory. `realbuf` must /// outlive the returned Target (native paths point into it; web paths are /// process-lifetime slices in the embedded source archive). diff --git a/src/macos.zig b/src/macos.zig index ef68d7e7..dce136f4 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -122,14 +122,15 @@ pub const Image = extern struct { rgba: [*]const u8, }; -/// Sync with: pardes_runtime_s. Two callbacks, because everything else the +/// Sync with: pardes_runtime_s. Three callbacks, because everything else the /// core asks for it already does itself — it owns the ptys, and look.openLink -/// hands URLs to /usr/bin/open. Both are optional at the ABI level: a host that +/// hands URLs to /usr/bin/open. All optional at the ABI level: a host that /// passes null simply does without, rather than trapping inside the library. pub const Runtime = extern struct { userdata: ?*anyopaque = null, wakeup: ?*const fn (?*anyopaque) callconv(.c) void = null, set_clipboard: ?*const fn (?*anyopaque, [*]const u8, usize) callconv(.c) void = null, + read_clipboard: ?*const fn (?*anyopaque) callconv(.c) void = null, }; const color_default: u32 = 0x01000000; @@ -1127,6 +1128,16 @@ fn drainEffects(st: *State, threads_ok: bool) bool { const y = core.yank orelse continue; cb(st.runtime.userdata, y.ptr, y.len); }, + // The host answers with pardes_paste, which the AppDelegate calls + // straight back inside this call: NSPasteboard reads are + // synchronous, so the paste event lands here, mid-drain. That is + // safe and deliberate — pardes_paste only feeds core.update, and + // whatever that queues is picked up by this same loop rather than + // waiting a tick. A host with a null callback simply never pastes. + .read_clipboard => { + const cb = st.runtime.read_clipboard orelse continue; + cb(st.runtime.userdata); + }, // An empty answer, immediately: the honest reply from a shell with // no worker, and the only safe one. Tab after a `.` DIVERTS to the // backend instead of indenting and indents late, when the answer diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index cda35ff0..077367a4 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -162,6 +162,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { let pasteboard = NSPasteboard.general pasteboard.clearContents() _ = pasteboard.setString(yank, forType: .string) + }, + read_clipboard: { _ in + // Also the main thread, also inside pardes_tick — but this one + // answers immediately: NSPasteboard reads are synchronous, so + // the paste lands back in the core before this call returns. + // No pump, deliberately: the tick that emitted the effect is + // still draining and picks up whatever the paste queued. + guard let text = NSPasteboard.general.string(forType: .string) else { return } + AppDelegate.paste(text) }) // The real grid, never a placeholder: the core holds each shell's @@ -590,12 +599,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { func pardesViewRequestsPaste(_ view: PardesView) { guard let text = NSPasteboard.general.string(forType: .string) else { return } - // Swift lends a temporary NUL-terminated UTF-8 buffer for the duration - // of the call, which is exactly as long as the core borrows it. - pardes_paste(text, text.utf8.count) + AppDelegate.paste(text) pump() } + // Swift lends a temporary NUL-terminated UTF-8 buffer for the duration of + // the call, which is exactly as long as the core borrows it. Static, and + // pumping is the caller's business: the read_clipboard callback is a C + // function pointer that can capture nothing and is already inside a tick, + // where the gesture path is not. + private static func paste(_ text: String) { + pardes_paste(text, text.utf8.count) + } + // ponytail: one window, no tabs — the core has no multi-window notion, so // the last window closing really is the end of the process. func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 1591130e..9a64501a 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -129,7 +129,7 @@ typedef enum { // ---------------------------------------------------------------- runtime -// What the host lends the core. Two callbacks, because everything else the +// What the host lends the core. Three callbacks, because everything else the // core wants done it already does itself: it owns the ptys, and it opens URLs // through /usr/bin/open. Copied by value during pardes_init, so the struct // need only outlive that call. @@ -145,6 +145,11 @@ typedef struct { // The yank register changed. `text` is borrowed for the duration of the // call only. Main thread, inside pardes_tick. void (*set_clipboard)(void *userdata, const char *text, size_t len); + + // Ask the host for the system clipboard. The host answers by calling + // pardes_paste, which may be synchronous inside this call. Main thread, + // inside pardes_tick. + void (*read_clipboard)(void *userdata); } pardes_runtime_s; // ---------------------------------------------------------------- lifecycle diff --git a/src/output_pane.zig b/src/output_pane.zig index 8ff98845..37ec6033 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -83,19 +83,27 @@ pub const Traits = struct { /// look path resolves, so the buffer IS helix's picker. Prose (a hover /// blurb, a formatting diff) has nowhere to step to. steps: bool = false, - /// ...and what a step DOES with the row it lands on. Off, the row is a - /// LOCATION and its leading word is LOOKED. On, the row is a COMMAND LINE - /// and the whole of it is EXECUTED — so walking the list runs each row in - /// turn, which is what makes a picker over things that take effect - /// immediately (ThemeSel) a plain list of the words you would have typed. - /// Both go through the ordinary builtin (config.look_cmd / exec_cmd), so a - /// row does exactly what the matching mouse button on it would. + /// ...and WHAT THE ROWS ARE. Off, each is a LOCATION with a look-able + /// `path:LINE:COL` word inside it. On, each is a COMMAND LINE — the whole + /// row, exactly as you would have typed it (ThemeSel's `Theme gruvbox`) — + /// with no path in it to pick out. + /// + /// TWO readers, one fact, which is why the column is named for the fact: + /// n/N (lookWalk) select the look-able span inside a location row, and + /// THE WHOLE LINE of a command row, since the line is + /// the unit there. Either way they only select; Enter + /// looks what they left, Tab runs it. + /// searchStep Looks a location row's leading word and Execs a + /// command row whole — still how `]d`/`[d` and acme's + /// button-3 arrive somewhere in one gesture. + /// Both go through the ordinary builtins (config.look_cmd / exec_cmd), so + /// a row does exactly what the matching mouse button on it would. /// /// Nothing about this is output-pane specific, which is why it is a column - /// here and not a branch in searchStep: `file_row` answers it too, so a + /// here and not a branch in either reader: `file_row` answers it too, so a /// file pane whose lines happen to be commands is one word away from /// behaving the same. - executes: bool = false, + commands: bool = false, /// an answer of exactly ONE row jumps straight there instead of opening /// this buffer at all — helix: the gotos jump on a single location and /// show a picker on several, a symbol list is always a picker. @@ -129,7 +137,7 @@ pub fn traits(o: Origin) Traits { .name = meta.name, .steps = meta.steps, .jumps = meta.jumps, - .executes = meta.executes, + .commands = meta.commands, }; }, .query => |k| switch (k) { @@ -358,10 +366,10 @@ pub fn openJumps(p: *Pardes, id: usize) !void { /// The ThemeSel builtin: the theme ring written out as one `Theme <name>` row /// per theme — the ordinary builtin with its argument, exactly the line you -/// would type — into a buffer whose `executes` trait makes n/N RUN each row -/// rather than look it. So walking the list is trying the themes on, and -/// stopping on one is choosing it; there is no picker mode, no preview state -/// and nothing to commit or cancel, because every step already did the thing. +/// would type — into a buffer whose `commands` trait says the rows are words +/// and not places. n/N therefore select each row WHOLE and Tab runs it, so +/// walking the list is trying the themes on and stopping on one is choosing +/// it: no picker mode, no preview state, nothing to commit or cancel. /// /// The command's own name comes from the builtin rather than a literal: the /// word is derived from the struct in exactly one place (builtins.word), and a 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); +} diff --git a/src/term_pane.zig b/src/term_pane.zig index 9f13832f..1243dfa1 100644 --- a/src/term_pane.zig +++ b/src/term_pane.zig @@ -18,6 +18,7 @@ const Pane = pardes.Pane; const EditSnap = pardes.EditSnap; const Ovl = pardes.Ovl; const modal = @import("modal.zig"); +const config = @import("config.zig"); /// The memo behind `shellRows`. ONE entry for the editor, because the motion /// surface is built for the pane the cursor is in and a second pane asking @@ -78,6 +79,65 @@ const Rows = struct { rows: [][]const u8, }; +/// What LEAVING raw tty mode does to one prompt row, decided from its cells +/// alone. See config.tty_blank for why any of this happens. +pub const PromptCut = union(enum) { + /// show the row exactly as ghostty dumped it + keep, + /// show nothing at all + blank, + /// drop this many leading COLUMNS — the prompt — and keep the rest, which + /// is what was typed at it + cut: usize, +}; + +/// The prompt and the command typed at it share a grid row, and OSC 133 marks +/// them apart CELL by cell (`Cell.semantic_content` is output / input / +/// prompt). The row flag every caller tests first is only ghostty's "some cell +/// in here is a prompt cell" index; taking the row on that flag alone is what +/// used to throw the command away with the prompt. +pub fn promptCut(pin: ghostty_vt.Pin) PromptCut { + if (config.tty_blank == .prompt_and_input) return .blank; + const cells = pin.cells(.all); + var cols: usize = 0; + while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1; + // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or + // a repaint that has moved on. Nothing here is the prompt, so hide nothing. + if (cols == 0) return .keep; + // ...and all prompt, nothing typed yet: the row is chrome end to end. + if (cols >= cells.len) return .blank; + return .{ .cut = cols }; +} + +/// That decision applied to `raw`, the line ghostty dumped for `pin`'s row. +/// Always a slice OF `raw` — dropping the prompt is a left-hug, so the command +/// starts at column 0 with no run of blanks in front of it where the prompt +/// used to be, and there is nothing to allocate or copy anywhere. +/// +/// Walking the dump rather than rebuilding the row out of cells keeps ghostty +/// the single authority on how a cell spells itself — wide glyphs, combining +/// marks and all. One non-spacer cell is one dumped grapheme, and that is what +/// makes the cell walk and the byte walk stay in step. +pub fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { + const cols = switch (promptCut(pin)) { + .keep => return raw, + .blank => return "", + .cut => |n| n, + }; + const cells = pin.cells(.all); + var at: usize = 0; + var col: usize = 0; + while (col < cols and at < raw.len) { + const cell = &cells[col]; + var cps: usize = 1; + if (pin.grapheme(cell)) |extra| cps += extra.len; + for (0..cps) |_| at = modal.nextGrapheme(raw, at); + // the tail cell of a wide glyph spells nothing of its own + col += if (cell.wide == .wide) @as(usize, 2) else 1; + } + return std.mem.trimEnd(u8, raw[at..], " \t"); +} + /// A terminal's shell rows as the surface sees them: the WHOLE /// history+active grid, prompt rows blanked (OSC 133), absolute grid rows /// from 0. The raw material the motion surface is composed from — the @@ -130,8 +190,10 @@ fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { var n: usize = 0; var it = std.mem.splitScalar(u8, full, '\n'); while (it.next()) |raw| { - const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; - const shown = if (is_prompt) "" else raw; + const shown = if (pit.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; if (n > 0) { text[at] = '\n'; at += 1; @@ -159,11 +221,13 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { var lines = std.mem.splitScalar(u8, raw, '\n'); var n: usize = 0; while (lines.next()) |ln| { - const is_prompt = if (pane.mode != .tty) - if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false + vp[n] = if (pane.mode != .tty) + if (prompts.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln + else + ln else - false; - vp[n] = if (is_prompt) "" else ln; + ln; n += 1; } std.debug.assert(n == vp.len); diff --git a/src/tty/tty.zig b/src/tty/tty.zig index e8e3c1bd..eadc3a9d 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -37,6 +37,13 @@ pub const Command = struct { winsize: vaxis.Winsize, mouse: vaxis.Mouse, paste: []const u8, + /// The bracketed-paste brackets. vaxis posts them ONLY because this + /// union declares fields with these exact names — its Loop gates every + /// event on `@hasField` — and the pasted bytes themselves arrive + /// BETWEEN them as ordinary key presses, which the loop accumulates + /// into one `.paste` above instead of running as commands. + paste_start, + paste_end, /// a language query finished on a worker; rows are lsp-domain-owned lsp_done: struct { id: u32, rows: []u8 }, /// a selection-filter worker finished; every stdout is gpa-owned @@ -403,6 +410,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // gtk-enable-primary-paste=false — no mode we request can surface middle // clicks there (see test/snapshots/ghostty-mid.snap). try vx.setMouseMode(tty.writer(), true); + // Bracketed paste. Without it a paste into pardes-in-a-terminal is just a + // flood of key presses: plausible-looking in insert mode, and in normal + // mode every pasted character runs as a command. With it the terminal + // wraps the bytes in \x1b[200~ / \x1b[201~ and the loop coalesces them. + // No defer to switch it back off, for the same reason the mouse modes + // above have none: setBracketedPaste records state.bracketed_paste, and + // vaxis's resetState — reached from the `defer vx.deinit` above, while the + // tty is still open — sends the disable off that flag. + try vx.setBracketedPaste(tty.writer(), true); pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); @@ -569,6 +585,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var check_files = false; var pending: ?@TypeOf(Command.value) = .tick; + // Where a bracketed paste is assembled. It has to outlive one drain pass: + // the burst arrives over as many passes as the terminal takes to write it, + // and the markers are the only thing that says where it ends. + var paste_buf: std.Io.Writer.Allocating = .init(gpa); + defer paste_buf.deinit(); + var in_paste = false; + // 4 MiB ceiling, past which the tail is dropped rather than grown into. A + // paste that large is a mis-click on a file, not an edit, and the core + // would have to hold the whole of it as one undo entry. + const max_paste_bytes: usize = 4 << 20; while (!core.quit) { var event = if (pending) |ev| blk: { pending = null; @@ -610,7 +636,31 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); }, - .key_press => |key| core.update(.{ .key = .{ + .key_press => |key| if (in_paste) { + // Between the markers a key is DATA, never a command. Same + // two inputs as the dispatch below, so a pasted character + // is exactly the character the core would have been given. + const text = key.text orelse ""; + const cp = mapKey(effCp(key)); + const bytes: []const u8 = if (text.len > 0) + text + else if (cp == pardes.Key.tab) + "\t" + else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) + // vaxis gives control bytes no text at all: a line + // break inside a paste reaches the ground parser as a + // bare CR (-> Key.enter) or, from a terminal that does + // not translate them, a bare LF — which that parser + // reports as ctrl+j. Nothing in here is a real + // keypress, so both of them are just a newline. + "\n" + else + // arrows, F-keys, a stray escape: noise a paste has no + // business carrying, dropped rather than smuggled in. + ""; + const room = max_paste_bytes -| paste_buf.written().len; + paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; + } else core.update(.{ .key = .{ .cp = mapKey(effCp(key)), .text = key.text orelse "", .ctrl = key.mods.ctrl, @@ -653,6 +703,18 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { core.update(.{ .paste = bytes }); gpa.free(@constCast(bytes)); }, + .paste_start => { + in_paste = true; + paste_buf.clearRetainingCapacity(); + }, + .paste_end => { + in_paste = false; + // ONE event for the whole paste — the core borrows the + // bytes for the call, exactly like the OSC 52 arm above. + const pasted = paste_buf.written(); + if (pasted.len > 0) core.update(.{ .paste = pasted }); + paste_buf.clearRetainingCapacity(); + }, .command => |line| { core.update(.{ .command = line }); gpa.free(line); @@ -683,7 +745,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { }, } batch += 1; - if (stop or output or native_pdf_page_changed or batch >= 64) break; + // A paste in flight keeps draining WITHOUT rendering: a hundred + // thousand pasted characters are one edit, not a hundred thousand + // render-worthy events. That cannot spin — the drain still ends + // the moment the queue runs dry (tryEvent below) — so a terminal + // which sends paste_start and never paste_end costs one frame. + if (stop or output or native_pdf_page_changed or (!in_paste and batch >= 64)) break; event = (try loop.tryEvent()) orelse break; } tz_event.end(); @@ -983,6 +1050,20 @@ fn drainEffects( // mirror the core's yank register out via OSC 52 if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {}; }, + .read_clipboard => { + // ...and the other direction, OSC 52 read. The answer arrives on + // vaxis's reader thread as an ordinary `.paste` event and reaches + // the core through the same path an outer bracketed paste does — + // this request is the only wiring it needs. "The answer arrives" + // is the optimistic reading: a clipboard READ is an exfiltration + // primitive and terminals treat it as one (ghostty prompts by + // default, xterm ships it off, a multiplexer or ssh link may eat + // it), and a refusal looks exactly like silence. So the core's + // pending request is dropped by the next keystroke rather than + // pasting minutes late, and `SPC p` in a locked-down terminal + // honestly does nothing. + vx.requestSystemClipboard(tty.writer()) catch {}; + }, .lsp => |q| { if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet const pane = core.panes[q.pane] orelse continue; diff --git a/src/tutor.txt b/src/tutor.txt index 1d51a4ac..fe80c9e3 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -164,16 +164,25 @@ edit, select and look in. It is not a document, though, and never takes a column of its own: it opens BELOW the pane that asked for it, in that pane's column, be that a file or a shell — and a file you then open from - its rows goes where files go, not under the list. `n`/`N` step the rows - and look each one, so the view follows along. Right-clicking a word that names no file in ANY open - pane's directory searches for it — acme's button 3 — except in tty mode, - where the click belongs to the program on the other end. On a shell with no - search armed, n/N instead step the lookable tokens in its output. + its rows goes where files go, not under the list. `n`/`N` walk those + rows, and walking is ALL they do: a press moves the SELECTION to the + next look-able thing and opens nothing, so you can step past nine hits + to reach the tenth without opening the nine. Enter — the look chord — + opens the one you stopped on. The walk is a RING over PANES and not + over one buffer: the panes you have looked out of come first, most + recent first, then the output buffers you have not, newest first, and + only when there is neither does it step the pane in front of you — so + n/N work on a shell that never had a search armed, and off the end + they come round to the start. `N` is `n` backwards exactly: ten + forward and ten back is where you began. + Right-clicking a word that names no file in ANY open pane's + directory searches for it — acme's button 3 — except in tty mode, + where the click belongs to the program on the other end. A hit in a file reads `path:LINE:COL-ENDCOL`, the ordinary look target carrying the SPAN that matched. A hit in a shell or an output buffer has no file to name, so it reads `@pN:LINE:COL-ENDCOL` — pane N, then the place in - it. Looking either one goes there and SELECTS the span, which is why n/N - land ON a hit rather than beside it. + it. Looking either one goes there and SELECTS the span, which is why an + Enter on a row n/N stepped to lands ON the hit rather than beside it. The range is part of the PATH syntax and not part of search: type one anywhere text lives and a look on it selects. `main.zig:412-418` is whole lines, `main.zig:412:9-21` is columns on one line, `main.zig:412:9-418:1` @@ -182,8 +191,9 @@ FIND: the "Find" builtin (SPC f f) arms the same tag input, but Enter walks the pane's DIRECTORY instead of its text — `fd`, in-core — and writes one matching PATH per row into the same "+Search" buffer. Rows are look - targets like any other, so n/N step them and each found file opens; the - match is on the name, plain and case-insensitive, ".git" is skipped. + targets like any other, so n/N select them one at a time and Enter + opens the one you meant; the match is on the name, plain and + case-insensitive, ".git" is skipped. LAYOUT: columns split the screen; windows stack within a column. Panes abut with no wasted gap — a pane's own trailing edge (its last column, or @@ -519,6 +529,10 @@ SPC k Kill (quit) SPC d Del (close this pane) SPC f s/f f/f n Save / Find / New SPC h t Tutor (this file) SPC c n / c d Newcol / Delcol + SPC y / SPC Y the selection to the SYSTEM clipboard (every + cursor's text joined, or the main cursor's alone) + SPC p / SPC P paste the system clipboard after / before the + selection; SPC R replaces the selection with it SPC t d/c/n/r Debug / Colors / NextColor / Crt toggles SPC t p/l/a Petscii / Palette / Ascii: an image pane's renderer — glyph art instead of pixels, the C64 @@ -530,6 +544,14 @@ SPC j j Last: the pane you were in before this one — what body-normal ESC runs, so it alternates + Those five are helix's own clipboard letters, and the only words that + reach the desktop's clipboard at all: ordinary y d c p P R and the + 1-2 / 1-3 chords all stay in pardes' own register, so deleting a + character can never throw away what you copied from a browser. In a + terminal the copy goes out as OSC 52 and the paste asks for it back + the same way — plenty of terminals refuse that read, so there SPC y + works and SPC p may honestly do nothing. + `?` works at ANY depth: SPC ? lists everything, SPC h ? lists only what the "h" group holds. Help writes into a "+Help" OUTPUT BUFFER, the same kind of pane "/" search results land in — so it opens below @@ -565,9 +587,11 @@ k off the TOPMOST tagline = the top bar, j back down) zt zz zb Ctrl-d/u Ctrl-f Ctrl-w hjkl Alt-n/c Esc = last document <-> last terminal (body normal) + n/N = select the next/prev look-able text, across panes + (a ring); Enter opens what you landed on SPC = the leader: a key path runs a builtin (SPC ? lists them; SPC k Kill, SPC d Del, SPC f s Save, SPC f f Find, - SPC f n New, + SPC f n New, SPC y/Y/p/P/R the system clipboard, SPC w hjkl focus, SPC j j the pane before this one) vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars. diff --git a/src/web.zig b/src/web.zig index e17e147e..637fa56f 100644 --- a/src/web.zig +++ b/src/web.zig @@ -438,6 +438,10 @@ export fn pardes_effect_next() u32 { if (s.core.yank) |bytes| putEffect(s, bytes); break :blk 7; }, + // 13, and no payload either way: the browser cannot hand the clipboard + // over synchronously, so the answer arrives later as an ordinary + // pardes_paste — or never, if the permission prompt says no. + .read_clipboard => 13, .lsp => |e| blk: { effect_aux0 = e.id; effect_aux1 = e.pane; diff --git a/src/web/app.mjs b/src/web/app.mjs index 3d32a16b..d7a4b6f6 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -329,6 +329,11 @@ export class PardesRuntime { download(data, "pardes-dump.zon", "text/plain;charset=utf-8"); } else if (kind === 7) { navigator.clipboard?.writeText(decoder.decode(data)).catch(() => {}); + } else if (kind === 13) { + // Async and permission-gated, unlike every other effect here: a refused + // or unsupported read is simply a paste that never happens, which is + // what the core already tolerates from an empty clipboard. + navigator.clipboard?.readText().then((text) => this.wasm.pardes_paste(this.writeInput(text))).catch(() => {}); } else if (kind === 10) { this.running = false; } else { |
