summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 18:58:37 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 (patch)
tree6629cc215d6953090f6b29a7414b28cb9990e105 /src/pardes.zig
parent11f380f6d7222f2cad93c2cdf13701ea1f903d47 (diff)
downloadpardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.tar.gz
pardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.zip
An edited row keeps its colours, four copies of forkShell become one, and Esc stops recentring
## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig464
1 files changed, 444 insertions, 20 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index db20ff32..55461c5d 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -82,6 +82,22 @@ pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config"
/// target - see `build.zig`.
pub const theme_animation = @import("pardes_config").theme_animation;
+/// WHICH BUILD THIS IS, for `--version` and for any bug report that follows it.
+///
+/// `version` is `build.zig.zon`'s `.version`, read from the manifest by
+/// `build.zig` rather than copied beside it, so there is exactly one place to
+/// bump. `commit` is the git revision it was built from, and it is OPTIONAL
+/// because a source drop is not a repository: a tarball, a container with no
+/// `git`, or any checkout outside version control all yield null, and a
+/// frontend must say the version happily without one.
+///
+/// Both are strings the build baked in, never questions asked at runtime. A
+/// binary that shelled out to `git` would describe whatever tree it was
+/// standing in rather than the one it came from — and on the board there is
+/// neither a `git` nor a process to run it with.
+pub const version = @import("pardes_config").version;
+pub const commit: ?[]const u8 = @import("pardes_config").commit;
+
/// A build with no host but its display: the embedded source filesystem, the
/// in-process clipboard, silent ptys. Comptime, and its own option module
/// rather than a `pardes_config` field, because it is the one setting that
@@ -100,6 +116,22 @@ pub const font_picker = platform == .gui or platform == .macos;
/// reads this instead so a third such platform cannot forget one of them.
pub const hosted = platform == .tty or platform == .gui or platform == .macos;
+/// Builds whose frontend can hand its screen to a detached core — which is
+/// narrower than `hosted`, and the gap is a bug this predicate exists to close.
+///
+/// macOS is hosted, has a unix socket, and compiles `detached/`; what it does
+/// not do is POLL. `takeAttach` is a poll rather than a host method precisely
+/// because attaching replaces the core the call is running inside (see
+/// `Effect.attach`), and `src/macos.zig` never calls it. Gated on `hosted`, the
+/// `Attach` word therefore parsed, queued an effect, stored a request in
+/// `attach_buf` — and did nothing at all, for ever, silently. That is the
+/// failure this codebase refuses everywhere else, so the word does not exist
+/// on a frontend that cannot serve it.
+///
+/// The two here are exactly the two `main.zig` accepts `--attach` for, which is
+/// the same question asked at the command line instead of in a tag.
+pub const can_attach = platform == .tty or platform == .gui;
+
/// Builds that HAVE terminal panes: a pane whose content is a live ghostty-vt
/// emulator being fed pty bytes. The P4 firmware has no processes, no ptys and
/// nothing that could produce a VT byte, so there the emulator is ~400 KiB of
@@ -288,6 +320,67 @@ fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 {
return if (len) |n| buf[0..n] else null;
}
+/// Drain the queue through the in-process host — what a shell's pump does with
+/// it — and report the Attach among those effects. Going through `perform` is
+/// the point: what a frontend acts on is what `takeAttach` hands back AFTER the
+/// drain, not the effect value, which dies in the loop that read it.
+fn drainForAttach(p: *Pardes) ?AttachRequest {
+ while (p.nextEffect()) |effect| p.perform(effect);
+ return p.takeAttach();
+}
+
+test "Attach asks for a session and tears nothing down" {
+ if (comptime !hosted) return; // no unix socket on this platform, so no word
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ // Bare means whichever session is there, and that is an EMPTY name rather
+ // than an absent request: the frontend still has to go and look.
+ try std.testing.expect(p.executeBuiltinLine(0, "Attach"));
+ const bare = drainForAttach(p) orelse return error.NoAttachAsked;
+ try std.testing.expectEqualStrings("", bare.name);
+ try std.testing.expectEqual(@as(u8, 0), bare.pane);
+
+ // ...and the named form carries its tail, the way `Theme <name>` does.
+ try std.testing.expect(p.executeBuiltinLine(0, "Attach work"));
+ const named = drainForAttach(p) orelse return error.NoAttachAsked;
+ try std.testing.expectEqualStrings("work", named.name);
+ try std.testing.expect(p.takeAttach() == null); // taken once, then gone
+
+ // The word only ASKS. A core that tore itself down here could not be handed
+ // back intact when the connect fails, and that is the entire guarantee.
+ try std.testing.expect(!p.quit);
+ try std.testing.expect(p.panes[0] != null);
+}
+
+test "Detach asks the frontend to leave, and says so when there is nothing to leave" {
+ if (comptime !hosted) return; // no unix socket on this platform, so no word
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ // Whole-word only, like Kill: the effect names the pane that ran it and
+ // carries nothing else, because the daemon that serves it already knows
+ // which frontend's keystroke arrived.
+ try std.testing.expect(p.executeBuiltinLine(0, "Detach"));
+ const asked = while (p.nextEffect()) |effect| switch (effect) {
+ .detach => |d| break d,
+ else => {},
+ } else return error.NoDetachAsked;
+ try std.testing.expectEqual(@as(u8, 0), asked.pane);
+
+ // ...and this core is a LOCAL shell — a bare `Host{}` fills in no
+ // `push_detach` — so performing it reports on that pane instead of
+ // dismissing a session this process is not part of.
+ p.perform(.{ .detach = asked });
+ const pane = p.panes[0].?;
+ try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]);
+ try std.testing.expect(!p.quit);
+}
+
test "selection pipe prompt submits exact request and Escape cancels" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
@@ -3697,6 +3790,25 @@ pub const Event = union(enum) {
fs_req: acmefs.Req,
};
+/// The longest session name `Effect.attach` can carry. A name is ONE path
+/// component under the runtime socket directory (detached/server.zig
+/// `socketPath`), so `sun_path`'s 108 bytes cap a usable one far below this;
+/// 256 is the width `spawn`'s cwd and `open_link` already reserve, and reusing
+/// it is why the new arm costs the effect ring nothing — `save_text`'s
+/// {pane, serial, Buf(256)} is still the widest thing in the union.
+pub const attach_name_max = 256;
+
+/// What `takeAttach` hands the shell: the session to reach for (empty means
+/// "whichever one is there") and the pane whose message row a failed connect
+/// is reported on, the way `Effect.dump_themes` carries the pane that receives
+/// the native host's answer.
+pub const AttachRequest = struct { pane: u8, name: []const u8 };
+
+/// A pane's pty bytes waiting for room in the effect ring. Core-owned (the
+/// `Event.paste` slice they came from is borrowed for one `update` only), and
+/// freed the moment `off` reaches the end or the pane goes away.
+pub const PendingWrite = struct { bytes: []u8, off: usize = 0 };
+
/// IO the core wants done. Payloads are inline (fixed buffers): effects are
/// queued values with no lifetime ties back into the core.
pub const Effect = union(enum) {
@@ -3758,6 +3870,27 @@ pub const Effect = union(enum) {
/// blocking `event` read, with the waiting left where the kernel's
/// request already is.
fs_reply: acmefs.Reply,
+ /// `Attach [name]` — hand this frontend's screen to a detached core, the
+ /// one `pardes --detach [name]` left running; an empty name means "the
+ /// session that is there". `pane` is where a failed connect is reported.
+ ///
+ /// CONNECT FIRST, SWAP SECOND is what the shell owes this, and it is the
+ /// whole point of the word: the session's socket must be open before
+ /// anything local is torn down, so an attach that fails leaves this
+ /// instance running with every pane and every undo intact instead of half
+ /// dead. Which is also why no host method performs it — see `perform`.
+ attach: struct { pane: u8, name: Buf(attach_name_max) },
+ /// `Detach` — this frontend leaves; the session and every other frontend
+ /// carry on. tmux's detach-client, and deliberately NOT the inverse of
+ /// `attach`: turning a live LOCAL session into a daemon needs setsid and a
+ /// fork, or closing the terminal takes the session with it.
+ ///
+ /// No name travels because there is nobody to name. The word is typed in a
+ /// frontend that has no core of its own, reaches the daemon as an ordinary
+ /// `Event.command`, and the daemon routes the effect back to the frontend
+ /// whose keystroke caused it. `pane` is only for the report a local shell
+ /// gets instead — `perform`'s null-method arm.
+ detach: struct { pane: u8 },
quit,
fn Buf(comptime n: usize) type {
@@ -5834,6 +5967,19 @@ pub const Pardes = struct {
effects_head: usize = 0,
effects_len: usize = 0,
+ /// Pty bytes that did not fit the ring, per pane, kept because refusing a
+ /// `write` TRUNCATES a byte stream rather than merely delaying it: a 1 MiB
+ /// paste used to reach a program as its first 256 KiB, silently. `emitWrite`
+ /// parks the tail here and `nextEffect` refills the ring from it as the host
+ /// drains, so the stream is delayed and never cut. See `limits.pending_write_cap`.
+ ///
+ /// Per pane because two ptys are independent streams: only order WITHIN one
+ /// matters, so a pane whose tail is waiting never delays another's writes.
+ pending_write: [MAX_PANES]?PendingWrite = @splat(null),
+ /// Total bytes parked above, so the drain path costs one comparison when
+ /// nothing is waiting — which is every frame that is not a large paste.
+ pending_write_bytes: usize = 0,
+
/// WHO SERVES THIS CORE. Every method optional; a null one is answered by
/// `fallback` below, so a `Host{}` is a complete in-process pardes.
host: Host = .{},
@@ -5864,6 +6010,14 @@ pub const Pardes = struct {
/// shell consumes it via takeRestore each frame (restore contents stay host-fed)
restore_req: ?[]const u8 = null,
restore_buf: [1024]u8 = undefined,
+ /// An Attach builtin wants this frontend's screen handed to a detached
+ /// core; the shell consumes it via takeAttach from its OUTER loop, beside
+ /// takeRestore and for the same reason — both END this core, and nothing
+ /// running inside `pump` may destroy the core it is running in. `name`
+ /// points into `attach_buf`, which the effect's inline copy is unpacked
+ /// into: the effect is a value the drain loop owns and dies with it.
+ attach_req: ?AttachRequest = null,
+ attach_buf: [attach_name_max]u8 = undefined,
surface: Surface = .{},
/// per-update scratch (paneCursorLines, selection text); reset each update
@@ -5987,6 +6141,7 @@ pub const Pardes = struct {
pub fn deinit(p: *Pardes) void {
p.cancelLookHover();
+ for (0..MAX_PANES) |id| p.dropPendingWrite(id);
for (&p.panes) |*slot| if (slot.*) |pane| {
p.teardownPane(pane);
slot.* = null;
@@ -6033,6 +6188,14 @@ pub const Pardes = struct {
return r;
}
+ /// the shell polls this each frame beside takeRestore: a pending Attach's
+ /// session and reporting pane, or null
+ pub fn takeAttach(p: *Pardes) ?AttachRequest {
+ const a = p.attach_req;
+ p.attach_req = null;
+ return a;
+ }
+
/// the topbar line: the fixed builtins, plus `Restore <path>` once a dump
/// exists — render and click dispatch must agree on this exact string
fn topbar(p: *Pardes, buf: []u8) []const u8 {
@@ -6056,6 +6219,8 @@ pub const Pardes = struct {
// `event` file open leaving the editor suppressing button actions
// for whatever pane lands in this slot next.
p.fs.forget(p.gpa, id);
+ // ...and so do bytes still queued for the pty it no longer has.
+ p.dropPendingWrite(id);
};
if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover();
p.pane_alloc.doom(pane);
@@ -6221,14 +6386,47 @@ pub const Pardes = struct {
p.look_walk_owner = pane.serial;
}
- /// chunk arbitrary-length bytes into fixed-size write effects (order kept)
+ /// Chunk arbitrary-length bytes into fixed-size write effects, order kept.
+ ///
+ /// The ring refuses when full rather than evicting, which is right for every
+ /// other effect and WRONG for a byte stream: the tail of a large paste was
+ /// dropped where the program needed it whole (and, under bracketed paste, the
+ /// closing marker went with it, leaving the program in paste mode). What does
+ /// not fit parks in `pending_write` and `nextEffect` feeds it back as the host
+ /// drains, so this never truncates while `pending_write_cap` has room.
pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void {
var off: usize = 0;
- while (off < bytes.len) {
- const n = @min(bytes.len - off, 64);
- p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } });
- off += n;
+ // Anything already parked for this pane owns the stream's position, so
+ // new bytes queue BEHIND it — emitting them now would reorder the pty.
+ if (p.pending_write[id] == null) {
+ while (off < bytes.len and p.effects_len < p.effects.len) {
+ const n = @min(bytes.len - off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } });
+ off += n;
+ }
}
+ if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]);
+ }
+
+ /// Take ownership of bytes the ring had no room for. A failed allocation or
+ /// an exhausted cap degrades to the old behaviour — dropping the tail — because
+ /// the alternative on a full heap is refusing to run at all.
+ fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void {
+ if (comptime limits.pending_write_cap == 0) return;
+ if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return;
+ if (p.pending_write[id]) |*pw| {
+ // Compact what the drain already sent before growing: a burst that
+ // arrives in pieces must not keep re-copying bytes nobody wants.
+ const keep = pw.bytes.len - pw.off;
+ const grown = p.gpa.alloc(u8, keep + bytes.len) catch return;
+ @memcpy(grown[0..keep], pw.bytes[pw.off..]);
+ @memcpy(grown[keep..], bytes);
+ p.gpa.free(pw.bytes);
+ pw.* = .{ .bytes = grown };
+ } else {
+ p.pending_write[id] = .{ .bytes = p.gpa.dupe(u8, bytes) catch return };
+ }
+ p.pending_write_bytes += bytes.len;
}
/// The shell reports the spawned pane's working directory (and later cwd
@@ -6533,6 +6731,10 @@ pub const Pardes = struct {
}
pub fn nextEffect(p: *Pardes) ?Effect {
+ // Before the emptiness test, not after: a pane whose tail is parked
+ // must not read as "no effects left" while the host's drain loop is
+ // still asking. This is what turns a truncated paste into a delayed one.
+ p.refillPendingWrites();
if (p.effects_len == 0) {
p.effects_head = 0;
return null;
@@ -6543,6 +6745,38 @@ pub const Pardes = struct {
return e;
}
+ /// Move parked pty bytes into whatever room the ring now has, oldest pane
+ /// slot first. Costs one comparison when nothing is parked.
+ fn refillPendingWrites(p: *Pardes) void {
+ if (p.pending_write_bytes == 0) return;
+ for (&p.pending_write, 0..) |*slot, id| {
+ while (p.effects_len < p.effects.len) {
+ // `|*pw|` points INTO the slot: capturing by value would advance
+ // a copy's cursor and re-send the same chunk for ever.
+ const pw = if (slot.*) |*live| live else break;
+ const n = @min(pw.bytes.len - pw.off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pw.bytes[pw.off..][0..n]) } });
+ pw.off += n;
+ p.pending_write_bytes -= n;
+ if (pw.off == pw.bytes.len) {
+ p.gpa.free(pw.bytes);
+ slot.* = null;
+ break;
+ }
+ }
+ if (p.effects_len == p.effects.len) return;
+ }
+ }
+
+ /// Drop a pane's parked bytes: its pty is gone, and the slot it occupied
+ /// may be handed to a different pane next frame.
+ fn dropPendingWrite(p: *Pardes, id: usize) void {
+ const pw = p.pending_write[id] orelse return;
+ p.pending_write_bytes -= pw.bytes.len - pw.off;
+ p.gpa.free(pw.bytes);
+ p.pending_write[id] = null;
+ }
+
/// Queue input for the next `pump`. Single-threaded, and a VALUE queue: an
/// event that carries a borrowed slice cannot survive the trip, so this
/// asserts rather than documents it. Hand those to `update` directly inside
@@ -6683,6 +6917,33 @@ pub const Pardes = struct {
// this is the borrow window. A host with no filesystem serving
// cannot have asked, so a null method is not a dropped answer.
.fs_reply => |r| if (v.push_fs_reply) |f| f(p.host.ctx, &r, p.fsPayload(r)),
+ // THE ONE EFFECT NO HOST METHOD CAN SERVE: attaching REPLACES the
+ // core this call is running inside — `pump` is two frames up the
+ // stack — so all it may do here is record the request where the
+ // shell's outer loop finds it, which is Restore's shape exactly.
+ // Reaching it through the ring rather than straight from the
+ // builtin is what ORDERS it: a `Save` queued by the same update is
+ // performed first, so nothing you typed is still unwritten when
+ // the screen changes owners. A shell that never polls (the
+ // browser, the board) simply cannot attach, which is the truth
+ // about a machine with no unix socket to attach to.
+ .attach => |a| {
+ const name = a.name.slice();
+ @memcpy(p.attach_buf[0..name.len], name);
+ p.attach_req = .{ .pane = a.pane, .name = p.attach_buf[0..name.len] };
+ },
+ // ...and its counterpart, which a host CAN serve and usually does
+ // not. Only a detached core's host fills `push_detach` in; a local
+ // tty or SDL shell leaves it null, and the honest answer there is
+ // a message row rather than a frontend that quits or a word that
+ // silently does nothing. A null-method fallback and not a comptime
+ // gate, because whether there is a session to leave is a fact
+ // about this RUN — the same binary attaches one minute and does
+ // not the next.
+ .detach => |d| if (v.push_detach) |f|
+ f(p.host.ctx)
+ else
+ p.reportError(d.pane, "detach", error.NotAttached),
// the loop's own condition; a host tears down after its own loop
.quit => p.quit = true,
}
@@ -13423,15 +13684,37 @@ pub const Pardes = struct {
// ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ----
/// Focus pane `id` and, for a nonzero 1-based `at.line`, put its modal
- /// cursor there (`at.col` likewise, 0 = line start): files recenter the
- /// view on it, terminals ride their scrollback to it. Both look targets
+ /// cursor there (`at.col` likewise, 0 = line start). `landing` says how far
+ /// the view may MOVE to show it: `.center` recenters a file on the line and
+ /// reveals a PDF's page — a look target, a `:NN`, a search hit, where the
+ /// context around the destination is the whole point of going there — while
+ /// `.keep` leaves the view alone and lets `ensureCursorVisible` do the least
+ /// that shows the cursor, usually nothing at all. A terminal has no recenter
+ /// to skip, so `landing` does not gate it — but it is not therefore free of
+ /// movement: a modal cursor PINNED high in the scrollback still pulls the
+ /// view up to it, which is `ensureCursorVisible` keeping its promise and the
+ /// reason `Last` records `line = 0` for an unpinned shell. Both look targets
/// that name a live pane land here — a path a pane already holds, and
/// `@pN:LINE:COL`. A RANGED spot selects (selectSpan below).
- pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot) void {
+ pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void {
if (id >= MAX_PANES) return;
const pane = p.panes[id] orelse return;
p.active = id;
if (hasPdf(pane)) {
+ // A page reveal IS this pane's view, so `.keep` is simply not doing
+ // it. Not a formality: a reveal of the page you are already on still
+ // sets `document_scroll_y` to `page_starts[page]`, so returning to a
+ // PDF threw away the offset WITHIN the page you were reading.
+ //
+ // Read that literally — under `.keep` a PDF's page is not restored
+ // AT ALL, and the spot's line is a page. That only shows when
+ // something moved the pane while you were away, and something can:
+ // the wheel scrolls the pane under the POINTER, not the active one.
+ // Then Esc leaves the PDF on the page the wheel reached rather than
+ // the one the jumplist recorded, which is the answer a RETURN wants
+ // and not the answer a jump wants — so Ctrl-o and Ctrl-i, which
+ // centre, are still how you reach the recorded page.
+ if (landing == .keep) return;
if (comptime pdf_enabled) {
const pv = &pane.pdf.?;
if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) pdf_pane.resetPageChrome(pane);
@@ -13440,16 +13723,23 @@ pub const Pardes = struct {
}
if (at.line == 0) return;
if (pane.file) |*f| {
+ // The clamp holds either way: a stale jump naming a line past the
+ // end must not land the cursor there just because the view is not
+ // moving.
if (at.line > file_pane.nlines(p.gpa, f)) return;
- const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
- if (next != f.scroll) {
- f.scroll = next;
- f.syntax_dirty = true;
+ if (landing == .center) {
+ const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
+ if (next != f.scroll) {
+ f.scroll = next;
+ f.syntax_dirty = true;
+ }
}
}
- // land the modal cursor on the target line (and keep
- // ensureCursorVisible agreeing with the recenter — a stale cursor
- // would yank the view right back)
+ // Land the modal cursor on the target line. Under `.center` that keeps
+ // ensureCursorVisible agreeing with the recenter — a stale cursor would
+ // yank the view right back. Under `.keep` it IS the whole policy: no
+ // recenter ran, so the nudge below is the only thing that can move the
+ // view, and it moves it only far enough to show the cursor.
pane.cur_row = @intCast(at.line - 1);
pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0;
pane.cur_pinned = true;
@@ -13495,12 +13785,12 @@ pub const Pardes = struct {
return true;
};
if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) {
- p.focusPaneLine(i, at);
+ p.focusPaneLine(i, at, .center);
return true;
};
const ff = if (tt.file) |*f| f else continue;
if (!std.mem.eql(u8, ff.path, path)) continue;
- p.focusPaneLine(i, at);
+ p.focusPaneLine(i, at, .center);
return true;
}
return false;
@@ -13606,7 +13896,7 @@ pub const Pardes = struct {
// `@p7:10:5`: pane 7, line 10, column 5 — how a search result
// points at a terminal or an output buffer, neither of which
// has a path.
- .pane => |t| p.focusPaneLine(t.id, t.at),
+ .pane => |t| p.focusPaneLine(t.id, t.at, .center),
.url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }),
.dir => |dir| {
// focus an existing terminal on this dir, else fork one below.
@@ -14303,12 +14593,20 @@ pub const Pardes = struct {
/// the stack and go to what it names. Nothing is pushed and nothing is
/// dropped — walking history is not making it — and trackJump agrees,
/// because after the move the live spot IS `jumps[jcur]` again.
+ ///
+ /// `.center` where `Last` keeps the view, and the asymmetry is structural
+ /// rather than arbitrary: `Last` only ever CROSSES panes, so the pane it
+ /// lands on already holds the view you left it with. This may land in the
+ /// SAME pane, where there is no such view to keep — a long in-file jump
+ /// would arrive on the very top or bottom row with `scroll_off` lines of
+ /// context on one side. Helix splits the same pair the same way: its
+ /// jumplist centres, its buffer switch does not.
pub fn jumpBy(p: *Pardes, delta: i32) void {
const next = @as(i64, @intCast(p.jcur)) + delta;
if (p.njumps == 0 or next < 0 or next >= p.njumps) return;
p.jcur = @intCast(next);
const j = p.jumps[p.jcur];
- p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col });
+ p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .center);
}
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
@@ -15418,7 +15716,16 @@ pub const Pardes = struct {
// this same choice named. Order is load-bearing — gutter, recolor, then
// wrap markers; the selection/cursor passes below win over all three.
switch (pane.colorAlgo()) {
- .tty => if (p.settings.colors and pane.mode == .tty) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h),
+ // Every mode, not just `.tty`: `recolorAnsi` translates a row's
+ // colour anchor through the same slide the edit buffer applied to
+ // its text, so leaving a shell for normal mode no longer drains
+ // the screen of colour. A row the user typed has no ANSI and is
+ // skipped there, which is why this needs no mode test.
+ // `body` is the very text printed above: `recolorAnsi` pairs its
+ // graphemes with the cells that spelled them, which is the only way
+ // to stay on the right glyph when the emulator and this surface
+ // disagree about how many columns a cluster is worth.
+ .tty => if (p.settings.colors) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, body),
.source, .diff => {
const f = &pane.file.?;
file_pane.drawGutter(p, pane, r, tx, tw, body_h, active);
@@ -15792,6 +16099,50 @@ test "Esc back into a tty leaves its view at the prompt" {
try std.testing.expectEqual(live, sp.vt.screens.active.pages.scrollbar().offset);
}
+test "Esc back into a file leaves its view where it was" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/allocators.zig" });
+ 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;
+ p.update(.{ .key = .{ .cp = Key.escape } }); // back onto the doc
+ p.sync();
+ const doc = p.active;
+ try std.testing.expect(doc != shell);
+ const dp = p.panes[doc].?;
+ try std.testing.expect(dp.file != null);
+
+ // Ride the cursor down until the view has scrolled: it now sits in the
+ // bottom band, which is exactly where a recenter is visible.
+ for (0..20) |_| p.update(.{ .key = .{ .cp = 'j' } });
+ p.sync();
+ const view = dp.file.?.scroll;
+ const row = dp.cur_row;
+ try std.testing.expect(view > 0);
+
+ p.update(.{ .key = .{ .cp = Key.escape } }); // out to the shell
+ p.sync();
+ try std.testing.expectEqual(shell, p.active);
+ p.update(.{ .key = .{ .cp = Key.escape } }); // ...and back
+ p.sync();
+ try std.testing.expectEqual(doc, p.active);
+
+ // Nothing moved. The old landing recentred the line under the cursor, so
+ // coming back repainted the whole screen to show what was already on it.
+ try std.testing.expectEqual(view, dp.file.?.scroll);
+ try std.testing.expectEqual(row, dp.cur_row);
+ // ...and the cursor is still on screen, which is all `.nearest` promises.
+ try std.testing.expect(dp.cur_row >= @as(i32, @intCast(view)));
+ try std.testing.expect(dp.cur_row < @as(i32, @intCast(view)) + @as(i32, dp.rows));
+
+ // The other arm still centres: a look target, a `:NN`, a search hit.
+ p.focusPaneLine(doc, .{ .line = @intCast(row + 1), .col = 1 }, .center);
+ try std.testing.expect(dp.file.?.scroll != view);
+ try std.testing.expectEqual(@as(usize, @intCast(row)) -| @as(usize, dp.rows) / 2, dp.file.?.scroll);
+}
+
test "Shift-Esc in tty hops to the doc and leaves the shell in tty" {
if (platform == .web) return;
const gpa = std.testing.allocator;
@@ -16017,6 +16368,79 @@ test "an unasked desktop paste reaches a tty pane's program, not its buffer" {
try std.testing.expect(pane.ovl == null);
}
+test "a paste larger than the effect ring reaches the program whole and in order" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ const buf = try gpa.alloc(u8, 1 << 20);
+ defer gpa.free(buf);
+ _ = drainWrites(p, buf);
+ term_pane.enterTty(p, 0);
+
+ // Bigger than `effect_cap * 64` (256 KiB), which is where the ring stops
+ // taking chunks: the tail used to be refused and the program saw 256 KiB of
+ // a 300 KiB paste with nothing said. Position-dependent bytes, so a
+ // reordered or duplicated chunk fails as loudly as a missing one.
+ const text = try gpa.alloc(u8, 300 * 1024);
+ defer gpa.free(text);
+ for (text, 0..) |*c, i| c.* = 'a' + @as(u8, @intCast(i % 26));
+ p.update(.{ .paste = text });
+ try std.testing.expectEqualSlices(u8, text, drainWrites(p, buf));
+ // ...and the core is not still holding a copy afterwards
+ try std.testing.expectEqual(@as(usize, 0), p.pending_write_bytes);
+}
+
+test "a bracketed paste larger than the ring still closes its bracket" {
+ if (platform == .web) return;
+ if (comptime !term_pane.enabled) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ const buf = try gpa.alloc(u8, 1 << 20);
+ defer gpa.free(buf);
+ _ = drainWrites(p, buf);
+ term_pane.enterTty(p, 0);
+
+ // The program asks for brackets, so `typeToTty` emits marker, text, marker.
+ // The CLOSING one is queued last and was therefore the first casualty of a
+ // full ring: the program stayed in paste mode and read every later
+ // keystroke as pasted text. Worse than losing the bytes.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } });
+ try std.testing.expect(term_pane.bracketedPaste(p.panes[0].?));
+ const text = try gpa.alloc(u8, 300 * 1024);
+ defer gpa.free(text);
+ @memset(text, 'z');
+ p.update(.{ .paste = text });
+ const got = drainWrites(p, buf);
+ try std.testing.expect(std.mem.startsWith(u8, got, "\x1b[200~"));
+ try std.testing.expect(std.mem.endsWith(u8, got, "\x1b[201~"));
+ try std.testing.expectEqualSlices(u8, text, got["\x1b[200~".len .. got.len - "\x1b[201~".len]);
+}
+
+test "pasted bytes still queued at shutdown are freed, not leaked" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ const buf = try gpa.alloc(u8, 1 << 20);
+ defer gpa.free(buf);
+ _ = drainWrites(p, buf);
+ term_pane.enterTty(p, 0);
+
+ const text = try gpa.alloc(u8, 300 * 1024);
+ defer gpa.free(text);
+ @memset(text, 'q');
+ p.update(.{ .paste = text });
+ // Parked and deliberately NOT drained — a session killed mid-paste. The
+ // copy is the core's, so `std.testing.allocator` fails this test through
+ // the `deinit` above if shutdown forgets it.
+ try std.testing.expect(p.pending_write_bytes > 0);
+}
+
test "leaving tty hides the prompt and keeps the command typed at it" {
if (platform == .web) return;
const gpa = std.testing.allocator;