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