summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
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 {