summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/gui/gui.zig80
-rw-r--r--src/lsp/lsp.zig4
-rw-r--r--src/lsp/lsp_zls.zig32
-rw-r--r--src/macos.zig18
-rw-r--r--src/nested.zig59
-rw-r--r--src/output_pane.zig69
-rw-r--r--src/pardes.zig307
-rw-r--r--src/tty/tty.zig11
-rw-r--r--src/web.zig56
9 files changed, 235 insertions, 401 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 0cc5f520..39d8c575 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -461,7 +461,7 @@ fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, wi
const point = TouchNormPoint{ .x = f.x, .y = f.y };
const col = normCell(point.x, win_w, cell_w);
const row = normCell(point.y, win_h, cell_h);
- const chrome = webChromeTarget(core, col, row);
+ const chrome = core.chromeTarget(col, row);
t.beginSingle(f, chrome != null);
if (chrome) |target|
core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } });
@@ -496,73 +496,6 @@ fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, wi
}
}
-const WebChromeTarget = struct { col: u16, row: u16 };
-
-/// Fat-finger hit test for web layout chrome. A gesture is classified once at
-/// finger-down, then never changes into scrolling. Handle coordinates are
-/// snapped onto the core's one-cell resize edge; body rows remain untouched.
-///
-/// The tag row and the stacked-pair seam come from the core's own rule:
-/// Tagbottom puts a pane's tag on its LAST row, which makes its FIRST row an
-/// ordinary scrollable body row and moves the h-handle down to the lower
-/// pane's first. Kept a line-for-line clone of web.zig's chromeTarget — the
-/// two must classify a finger identically.
-fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget {
- if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row };
-
- // The move box wins over a horizontal handle when a pane is tag-only,
- // matching the core's own mouse hit-test priority.
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = core.rects[id];
- const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
- return .{ .col = col, .row = tag };
- }
-
- for (0..core.ncol -| 1) |column| {
- const handle = core.col_x[column] + core.col_w[column] -| 1;
- if (col == handle) return .{ .col = handle, .row = row };
- }
- for (0..core.ncol) |column| {
- if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
- for (0..core.col_n[column] -| 1) |index| {
- const rect = core.rects[core.col_terms[column][index]];
- const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (row == handle) return .{ .col = col, .row = handle };
- }
- }
-
- // The remainder of each tag row is tag editing, not a scrollable body.
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = core.rects[id];
- const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w)
- return .{ .col = col, .row = tag };
- }
-
- // One-cell tolerance around thin separators, after exact tag rows have had
- // first refusal so a fat-finger tag edit never turns into a resize.
- for (0..core.ncol -| 1) |column| {
- const handle = core.col_x[column] + core.col_w[column] -| 1;
- if (cellDistance(col, handle) == 1) return .{ .col = handle, .row = row };
- }
- for (0..core.ncol) |column| {
- if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
- for (0..core.col_n[column] -| 1) |index| {
- const rect = core.rects[core.col_terms[column][index]];
- const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle };
- }
- }
- return null;
-}
-
-fn cellDistance(a: u16, b: u16) u16 {
- return if (a > b) a - b else b - a;
-}
-
fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
switch (f.kind) {
.down => {
@@ -1336,15 +1269,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// --nested opted out of the whole mechanism, including being an outer
// instance; so does any failure to bind, and then children simply open
// their own session.
- var sock_buf: [108]u8 = undefined;
- const sock_path: ?[:0]const u8 = if (opts.nested) null else nested.socketPath(&sock_buf, libc.getpid());
- const sock_fd: c_int = if (sock_path) |sp| nested.listenAt(sp) else -1;
- // only on the fd, so a bind that FAILED cannot unlink a path this process
- // never created
- defer if (sock_fd >= 0) {
- _ = libc.close(sock_fd);
- _ = libc.unlink(sock_path.?);
- };
+ const sock_fd: c_int = if (opts.nested) -1 else nested.listen();
+ defer nested.unlisten(sock_fd);
// initial spawns BEFORE any worker thread exists: forkpty from a
// multithreaded process can wedge the child before exec (see tty.zig).
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index 0ce70869..a4489552 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -184,9 +184,7 @@ pub const supports: std.EnumSet(Kind) = backend.supports;
/// diverts to `completion` after a `.`, so in a README — or in any pane the
/// backend would refuse — it has to indent instead. The core asks rather than
/// knowing, so the list of extensions stays the backend's business.
-pub fn speaks(path: []const u8) bool {
- return backend.speaks(path);
-}
+pub const speaks = backend.speaks;
/// Name shown by the harness and in `SPC ?`. Each implementation renames it.
pub const backend_name = "zls-inproc";
diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig
index eeacb5e3..dfc2db90 100644
--- a/src/lsp/lsp_zls.zig
+++ b/src/lsp/lsp_zls.zig
@@ -681,32 +681,12 @@ fn goto(
/// No spelling may contain a NEWLINE: the rows depend on line numbers being
/// identical between the repaired copy and the user's buffer (see `completion`).
///
-/// Picking between them takes TWO signals, and getting that wrong was the first
-/// version's bug twice over.
-///
-/// The parse-error count alone does not work. A repair that supplies the
-/// identifier without closing the outer construct merely TIES with the broken
-/// buffer — measured, `const z: E = .` is one error either way — so demanding
-/// a strict improvement threw the repair away in exactly the cases it was
-/// written for, and the feature only worked where the syntax was already
-/// finished. Accepting ties does not save it either: for an unclosed
-/// `switch (e) {` the bare identifier and the arrow BOTH tie at one error and
-/// only the arrow produces a switch node, so the count cannot say which helped.
-///
-/// Reachability alone does not work either. "Is the dot now an `enum_literal`
-/// the tree can be walked down to from the root" is exactly the question the
-/// repair asks, and an orphaned node answers it correctly — but several
-/// spellings can answer yes while leaving trees of very different quality, and
-/// taking the first loses candidates the cleanest spelling would have found.
-///
-/// So: reachability is a hard FILTER, the error count RANKS what survives it,
-/// the earlier spelling wins a tie, and a candidate that reaches ZERO errors
-/// ends the search because nothing can beat it. That last clause is why six
-/// spellings cost less than the three that came before them: a switch arm —
-/// the case this feature exists for — is answered by the first candidate, and
-/// only a buffer nothing repairs pays for all six. A buffer that already
-/// parses is left alone, and a dot no spelling can reach is the seam's normal
-/// "no result".
+/// Choosing between them: reachability (is the dot now an `enum_literal` the
+/// tree can be walked down to from the root) is a hard FILTER, the parse-error
+/// count RANKS what survives it, the earlier spelling wins a tie, and a
+/// candidate at ZERO errors ends the search. A buffer that already parses is
+/// left alone, and a dot no spelling can reach is the seam's normal "no
+/// result".
/// ponytail: one parse per spelling; caching the base parse is the obvious
/// next step if it ever shows up.
fn completionSource(arena: std.mem.Allocator, req: lsp.Req, tr: *Trace) [:0]const u8 {
diff --git a/src/macos.zig b/src/macos.zig
index 8eb6179a..2e628d54 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -495,12 +495,11 @@ export fn pardes_cursor_bar() bool {
/// Perform the IO the core queued. `threads_ok` is false for the one drain
/// inside pardes_init, which runs before any reader task exists.
///
-/// ponytail: the lsp, pipe and watch effects are answered with nothing. Each
-/// wants real machinery — a worker plus a snapshot of the pane's file for lsp
+/// ponytail: the lsp, pipe and watch effects do no work here. Each wants real
+/// machinery — a worker plus a snapshot of the pane's file for lsp
/// (src/tty/tty.zig:919), a job copy for pipe, and FSEvents for watch, since
-/// inotify is Linux-only. The core is built to tolerate an unanswered effect:
-/// the browser answers none of these either. Lift tty.zig's implementations
-/// when the app is past first light.
+/// inotify is Linux-only. Lift tty.zig's implementations when the app is past
+/// first light. Pipe and watch may simply be dropped; lsp may NOT, see below.
fn drainEffects(st: *State, threads_ok: bool) bool {
const core = st.core;
var did = false;
@@ -589,7 +588,14 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
const y = core.yank orelse continue;
cb(st.runtime.userdata, y.ptr, y.len);
},
- .lsp, .pipe, .watch => {},
+ // 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
+ // comes back empty (lspResponse); drop the effect and lsp_wait
+ // stays armed, the retroactive indent never fires, and every
+ // dot-Tab for the rest of the session does nothing at all.
+ .lsp => |q| core.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }),
+ .pipe, .watch => {},
.quit => {},
}
}
diff --git a/src/nested.zig b/src/nested.zig
index 6e70bdc9..0c065d1d 100644
--- a/src/nested.zig
+++ b/src/nested.zig
@@ -17,15 +17,11 @@
//! socket sits at a path anyone can derive from a pid — `Exec …` arriving here
//! is not something this protocol is allowed to say.
//!
-//! Linux only. ponytail: darwin has no /proc, so the walk there is
-//! proc_pidinfo(PROC_PIDTBSDINFO) for `pbi_ppid` plus a `pbi_comm` compare —
-//! a 16-byte truncated name, which is a weaker identity than an exe path —
-//! and neither SOCK_CLOEXEC nor accept4 exists, so the socket half needs two
-//! extra fcntl(FD_CLOEXEC) calls. Its `sockaddr.un.path` is 104 bytes, not
-//! 108: the `[108]u8` buffers and the unguarded memcpys below are sized for
-//! linux and a port has to re-derive them from `@FieldType`. None of it is
-//! testable from here, so detection is simply off: a pardes inside a pardes on
-//! macOS opens a second session the way it always did.
+//! Linux only. ponytail: darwin has no /proc, no SOCK_CLOEXEC and no accept4,
+//! and its `sockaddr.un.path` is 104 bytes rather than the 108 every buffer
+//! and unguarded memcpy below assumes. None of that is testable from here, so
+//! detection is simply off: a pardes inside a pardes on macOS opens a second
+//! session the way it always did.
const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
@@ -69,7 +65,7 @@ pub fn socketPath(buf: *[108]u8, pid: libc.pid_t) ?[:0]const u8 {
/// link reads `/path/to/pardes (deleted)` while the freshly built child's
/// reads `/path/to/pardes`. Comparing them raw made every nested launch after
/// a rebuild open a second full-screen UI inside the pane.
-pub fn stripDeleted(link: []const u8) []const u8 {
+fn stripDeleted(link: []const u8) []const u8 {
const suffix = " (deleted)";
return if (std.mem.endsWith(u8, link, suffix)) link[0 .. link.len - suffix.len] else link;
}
@@ -78,7 +74,7 @@ pub fn stripDeleted(link: []const u8) []const u8 {
/// /proc/<pid>/stat: that field is positional after `comm`, and a comm may
/// contain spaces and parentheses — a process named `sh (a b)` shifts every
/// field after it and the parse silently reads the wrong number.
-pub fn parsePPid(status: []const u8) ?libc.pid_t {
+fn parsePPid(status: []const u8) ?libc.pid_t {
var lines = std.mem.splitScalar(u8, status, '\n');
while (lines.next()) |line| {
if (!std.mem.startsWith(u8, line, "PPid:")) continue;
@@ -90,7 +86,7 @@ pub fn parsePPid(status: []const u8) ?libc.pid_t {
/// The pid in a `pardes-<pid>.sock` filename, for the startup sweep. Strictly
/// digits: parseInt alone would take `pardes-+7.sock` and `pardes--7.sock`,
/// and the sweep unlinks what this answers about.
-pub fn sweepPid(name: []const u8) ?libc.pid_t {
+fn sweepPid(name: []const u8) ?libc.pid_t {
if (!std.mem.startsWith(u8, name, "pardes-") or !std.mem.endsWith(u8, name, ".sock")) return null;
const digits = name["pardes-".len .. name.len - ".sock".len];
if (digits.len == 0) return null;
@@ -218,17 +214,22 @@ fn sweep(dir: [:0]const u8) void {
/// Bind and listen so nested instances can find us; -1 if anything fails, and
/// a pardes without a socket is simply one whose children open their own UI.
+/// The path is always this process's own, so nobody outside holds a buffer of
+/// it — the shells each kept one and passed it back to be unlinked, which is a
+/// way for the two spellings to go out of step and for no other reason.
///
/// CLOEXEC matters more here than on any other fd in the program: pane shells
/// are forked with forkpty and inherit everything open, and an orphaned bash
/// holding this one would keep the socket bound long after we exit — the same
/// shape as the inherited lock fd that once held a flock forever.
-pub fn listenAt(path: [:0]const u8) c_int {
+pub fn listen() c_int {
if (comptime builtin.os.tag != .linux) return -1;
var dir_buf: [108:0]u8 = undefined;
const dir = socketDir(&dir_buf) orelse return -1;
if (!ensureSocketDir(dir)) return -1;
sweep(dir);
+ var path_buf: [108]u8 = undefined;
+ const path = socketPath(&path_buf, libc.getpid()) orelse return -1;
var addr: libc.sockaddr.un = .{ .path = @splat(0) };
if (path.len + 1 > addr.path.len) return -1;
@memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
@@ -249,6 +250,17 @@ pub fn listenAt(path: [:0]const u8) c_int {
return fd;
}
+/// Close the listener and take its file away. Guarded on the fd rather than on
+/// the path, so a bind that FAILED cannot unlink a path this process never
+/// created; anything else is a no-op, which is what --nested and every
+/// non-linux build hand it.
+pub fn unlisten(fd: c_int) void {
+ if (fd < 0) return;
+ _ = libc.close(fd);
+ var path_buf: [108]u8 = undefined;
+ if (socketPath(&path_buf, libc.getpid())) |path| _ = libc.unlink(path);
+}
+
/// Block until a nested instance sends a `Look` line, and return it inside
/// `buf`. Null only when the listening fd itself is gone — teardown closed it,
/// or it was never a socket — because anything else (EMFILE, ECONNABORTED)
@@ -282,11 +294,7 @@ pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 {
const tv: libc.timeval = .{ .sec = 1, .usec = 0 };
_ = libc.setsockopt(conn, libc.SOL.SOCKET, libc.SO.RCVTIMEO, &tv, @sizeOf(libc.timeval));
var len: usize = 0;
- // ...and a cap on the reads themselves, because the timeout is PER
- // read and a peer dribbling one byte under it would otherwise stretch
- // to buf.len seconds. One line is one or two reads.
- var reads: usize = 0;
- while (len < buf.len and reads < 64) : (reads += 1) {
+ while (len < buf.len) {
const n = libc.read(conn, buf.ptr + len, buf.len - len);
if (n < 0 and libc.errno(n) == .INTR) continue;
if (n <= 0) break; // EOF, or the receive timeout expired
@@ -307,6 +315,21 @@ pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 {
test "socket path: XDG first, then a private dir under HOME, never /tmp" {
var buf: [108]u8 = undefined;
+ // The environment is process-wide and every test in this binary shares it.
+ // The last case below reaches the "no directory at all" branch by blanking
+ // both variables, and without this every later test ran without a HOME.
+ var xdg_buf: [4096:0]u8 = undefined;
+ var home_buf: [4096:0]u8 = undefined;
+ const xdg0 = if (libc.getenv("XDG_RUNTIME_DIR")) |v| std.fmt.bufPrintSentinel(&xdg_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null;
+ const home0 = if (libc.getenv("HOME")) |v| std.fmt.bufPrintSentinel(&home_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null;
+ defer {
+ if (xdg0) |v| {
+ _ = setenv("XDG_RUNTIME_DIR", v, 1);
+ } else _ = unsetenv("XDG_RUNTIME_DIR");
+ if (home0) |v| {
+ _ = setenv("HOME", v, 1);
+ } else _ = unsetenv("HOME");
+ }
_ = setenv("XDG_RUNTIME_DIR", "/run/user/1000", 1);
try std.testing.expectEqualStrings("/run/user/1000/pardes-4242.sock", socketPath(&buf, 4242).?);
_ = unsetenv("XDG_RUNTIME_DIR");
diff --git a/src/output_pane.zig b/src/output_pane.zig
index bce3d503..1bf14b7c 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -236,6 +236,75 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u
return pane;
}
+/// Land a freshly produced list of rows in the buffer it belongs in — the one
+/// rule every results buffer follows, whichever side of the core made them.
+///
+/// The SAME command asked again REFILLS the list it already opened rather than
+/// stacking a byte-identical twin under the pane. That was runSearch's rule
+/// from the start (right-clicking a word in four places is one +Search walked
+/// four times) and language answers turned out to need it far more urgently:
+/// Tab after a dot makes a query an ordinary typing keystroke, and without the
+/// refill twenty of them fill every slot and the key is eaten for the rest of
+/// the session — see docs/lsp.md.
+///
+/// What "the same command" means comes off the ORIGIN. A search is identified
+/// by its PATTERN, so `foo`, `bar`, `foo` re-arms foo's own buffer and leaves
+/// bar's open; a language query is asked about a different symbol every time
+/// with the same (usually empty) arg, so the arg cannot tell two apart and the
+/// KIND is the natural unit — a second `gr` replaces the first list. Same
+/// directory only, because the rows are written relative to it, and never the
+/// asking pane itself (a `/` inside a +Search writes its own rows).
+///
+/// `content` is gpa-owned: adopted by the buffer, or freed here when there is
+/// nowhere to put it. `anchor` is the row n/N step from, null for the top.
+pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, anchor: ?usize) void {
+ const pane = p.panes[id] orelse {
+ p.gpa.free(content);
+ return;
+ };
+ const by_arg = std.meta.activeTag(from) != .query;
+ for (p.panes, 0..) |slot, i| {
+ if (i == id) continue;
+ const rp = slot orelse continue;
+ const rf = if (rp.file) |*f| f else continue;
+ const o = if (rf.output) |*x| x else continue;
+ if (!std.meta.eql(o.from, from)) continue;
+ if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue;
+ if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
+ setArg(o, arg);
+ // a refill that changes NOTHING keeps its place: a right click on an
+ // already-armed word is an `n`, and throwing the list back to the top
+ // only to scroll down to the stepped row is a jump with no information
+ // in it.
+ const same = std.mem.eql(u8, rf.content, content);
+ file_pane.setContent(p, rf, content);
+ if (!same) rf.scroll = 0;
+ p.active = id;
+ if (traits(from).steps) {
+ pane.search_pane = i;
+ pane.search_row = anchor;
+ }
+ return;
+ }
+ const free = p.freeSlot() orelse {
+ p.gpa.free(content);
+ return;
+ };
+ const np = open(p, free, dir, from, arg, content) catch {
+ p.gpa.free(content);
+ return;
+ };
+ p.placeDoc(id, free, np);
+ p.active = id;
+ // prose is not a list of locations: n/N over a hover blurb would step to
+ // nowhere, so only stepping buffers arm the stepper — and WHICH command
+ // filled it is the buffer's own record, not a field on the asking pane.
+ if (traits(from).steps) {
+ pane.search_pane = free;
+ pane.search_row = anchor;
+ }
+}
+
/// The Jumplist builtin: the focus history (Pardes.jumps) written out as text,
/// one row per location, oldest first — the same `location text` shape every
/// results buffer here has, which is what buys n/N stepping and Look-on-a-row
diff --git a/src/pardes.zig b/src/pardes.zig
index 0dba0abf..592dd059 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1919,75 +1919,6 @@ test "an untouched tagline ends where its layout column's widest one does" {
);
}
-test "tagPrefixLen agrees with tagPrefix for every pane kind" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
-
- const same = struct {
- fn f(pp: *Pardes, pane: *Pane) !void {
- const s = try pp.tagPrefix(pane);
- try std.testing.expectEqual(s.len, Pardes.tagPrefixLen(pane));
- }
- }.f;
-
- // terminal: whatever boot gave it, then empty, then a changed cwd
- const term = p.panes[0].?;
- try same(p, term);
- p.setCwd(0, "");
- try same(p, term);
- p.setCwd(0, "/some/where/deep/enough/to/matter");
- try same(p, term);
- p.setCwd(0, "/caf\u{e9}/\u{5b50}"); // multibyte: BYTES, both sides
- try same(p, term);
-
- // an output buffer (+Help) — a file pane whose path the table names
- const help_id = p.freeSlot().?;
- _ = try p.newDocPane(help_id);
- output_pane.openHelp(p, help_id, "");
- try same(p, p.panes[help_id].?);
-
- // an image
- const img_id = p.freeSlot().?;
- const img = try p.newDocPane(img_id);
- img.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-parity/pic.ppm") };
- try same(p, img);
-
- // an ordinary file pane (this one replaces pane 0, so it goes last)
- const file = try p.hxOpenFileContent("hello\n");
- try same(p, file);
-}
-
-test "tagPrefixLen agrees with tagPrefix for a pdf pane" {
- if (!pdf_enabled or platform == .web) return;
- const gpa = std.testing.allocator;
- var tmp = std.testing.tmpDir(.{});
- defer tmp.cleanup();
- const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
- defer gpa.free(fixture);
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
- var path_buf: [256]u8 = undefined;
- const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
-
- const p = try Pardes.init(gpa, .{ .file = path, .cols = 100, .rows = 30 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pdf_pane = p.panes[0].?;
- try std.testing.expect(pdf_pane.pdf != null);
- const s0 = try p.tagPrefix(pdf_pane);
- try std.testing.expectEqual(s0.len, Pardes.tagPrefixLen(pdf_pane));
- // ...and on a later page / another fit / another tint, where the digits
- // and the @tagName words change width
- pdf_pane.pdf.?.page = 9;
- pdf_pane.pdf.?.page_count = 100;
- const s1 = try p.tagPrefix(pdf_pane);
- try std.testing.expectEqual(s1.len, Pardes.tagPrefixLen(pdf_pane));
- pdf_pane.pdf.?.page = 99;
- const s2 = try p.tagPrefix(pdf_pane);
- try std.testing.expectEqual(s2.len, Pardes.tagPrefixLen(pdf_pane));
-}
-
test "legacy default tag tails upgrade while custom tails remain owned" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{});
@@ -3971,9 +3902,10 @@ test "a corner drag's two axes clamp independently" {
const ay: u16 = TOPBAR_H;
const ah: u16 = 15;
const bh: u16 = 14;
- // the walls, spelled out: the handle is the upper pane's LAST row, so the
- // upper pane bottoms out with its tag row alone at ay, and the lower pane
- // does the same one row above the pair's end
+ // the walls, spelled out for tags-on-top (the tag_bottom = false below),
+ // where the handle is the upper pane's LAST row: the upper pane bottoms out
+ // with its tag row alone at ay, and the lower pane does the same one row
+ // above the pair's end
const row_lo: u16 = ay + BOX_H - 1;
const row_hi: u16 = ay + ah + bh - (BOX_H + 1);
@@ -4662,9 +4594,6 @@ pub const Pardes = struct {
// ---- tag + selection text (chord sources) ----
- // shared by tagPrefix and tagPrefixLen below, which have to agree
- const pdf_tag_fmt = "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}";
-
/// the live tag prefix: the pane's cwd/path, and nothing else (an image
/// still names its kind — the renderer toggles it used to spell out are
/// builtins now, under SPC t). The mode used to lead this as a word; it is
@@ -4679,7 +4608,7 @@ pub const Pardes = struct {
const arena = p.scratch.allocator();
if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint(
arena,
- pdf_tag_fmt,
+ "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}",
.{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
);
if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
@@ -4687,20 +4616,6 @@ pub const Pardes = struct {
return arena.dupe(u8, pane.cwdSlice());
}
- /// the same prefix's LENGTH, without the allocation. tagGap measures every
- /// pane in a layout column, once per pane per frame, and has no arena —
- /// formatting a path just to ask how wide it is would put the whole column
- /// on the render hot path. Mirror any change to tagPrefix here.
- fn tagPrefixLen(pane: *const Pane) usize {
- if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.count(
- pdf_tag_fmt,
- .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
- );
- if (pane.image) |iv| return config.tag_image.len + 1 + iv.path.len;
- if (pane.file) |f| return f.path.len;
- return pane.cwdSlice().len;
- }
-
/// the editable tail: the user's edited buffer once touched, else defaults
/// (a buffer with nothing to Save gets the plain tail — the table decides)
fn curTail(pane: *Pane) []const u8 {
@@ -4766,7 +4681,14 @@ pub const Pardes = struct {
/// every keystroke. It still takes no gap of its own (above) — but it
/// has to keep voting, or clicking the widest tagline in a column would
/// snap every other one left, out from under the next click.
- fn tagGap(p: *const Pardes, pane: *const Pane, used: usize) usize {
+ ///
+ /// ponytail: every voter's prefix is FORMATTED to be measured, so a frame
+ /// costs up to MAX_PANES² path dupes — 256 bump allocations into the
+ /// scratch arena that renderPane resets anyway, at a realistic two to four
+ /// panes. The alternative was a second tagPrefix that only counted, and
+ /// keeping two spellings of one string in step by comment is the more
+ /// expensive kind of cost.
+ fn tagGap(p: *Pardes, pane: *const Pane, used: usize) usize {
if (pane.tag_init) return 0;
const id = p.paneIdOf(pane) orelse return 0;
const r = p.rects[id];
@@ -4785,7 +4707,7 @@ pub const Pardes = struct {
pane_tail;
const laid = if (q.tag_init) q.tag_tail.items else words;
const lead = laid.len - std.mem.trimStart(u8, laid, " ").len;
- const q_end = tagPrefixLen(q) + lead + std.mem.trimStart(u8, words, " ").len;
+ const q_end = (p.tagPrefix(q) catch continue).len + lead + std.mem.trimStart(u8, words, " ").len;
end = @max(end, @min(q_end, tw));
};
return end -| used;
@@ -7326,48 +7248,12 @@ pub const Pardes = struct {
.find => .{ .cmd = .Find },
.grep => .{ .cmd = .Grep },
};
- // The SAME search asked again REFILLS the list it already opened —
- // right-clicking a word in four places is one +Search walked four
- // times, not four +Searches over identical rows. A different pattern
- // still gets its own buffer, and that IS the old rule: two searches are
- // two lists, both stay open at their sizes, and the new one stacks
- // directly below this pane (placeDoc). Focus stays here either way.
- // ...and it is ANY open list this search already filled, not only the
- // one n/N are armed on: search `foo`, then `bar`, then `foo` again and
- // the third search re-arms foo's own buffer rather than opening its
- // identical twin below it. Same directory only — the rows are written
- // relative to it, so another dir's list is a different list. Never the
- // searching pane itself (a `/` inside a +Search writes its own rows).
- for (p.panes, 0..) |slot, i| {
- if (i == id) continue;
- const rp = slot orelse continue;
- const rf = if (rp.file) |*f| f else continue;
- const o = rf.output orelse continue;
- if (!std.meta.eql(o.from, from) or !std.mem.eql(u8, o.arg(), pat)) continue;
- if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
- // a refill that changes NOTHING keeps its place: a right click on
- // an already-armed word is an `n`, and throwing the list back to
- // the top only to scroll down to the stepped row is a jump with no
- // information in it.
- const same = std.mem.eql(u8, rf.content, content);
- file_pane.setContent(p, rf, content);
- if (!same) rf.scroll = 0;
- pane.search_pane = i;
- pane.search_row = anchor;
- return;
- }
- const free = p.freeSlot() orelse {
- p.gpa.free(content);
- return;
- };
- const np = output_pane.open(p, free, dir, from, pat, content) catch {
- p.gpa.free(content);
- return;
- };
- p.placeDoc(id, free, np);
- p.active = id;
- pane.search_pane = free;
- pane.search_row = anchor;
+ // A different pattern still gets its own buffer: two searches are two
+ // lists, both stay open at their sizes, and the new one stacks directly
+ // below this pane. Everything about landing the rows — which open
+ // buffer counts as this same search, keeping a refill's place, opening
+ // fresh when there is none — is output_pane.fillResults.
+ 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
@@ -7509,61 +7395,10 @@ pub const Pardes = struct {
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const content = p.gpa.dupe(u8, rows) catch return;
- // The same KIND asked again REFILLS the buffer it already opened, the
- // rule runSearch has always had. It was missing here and that was
- // survivable while every language query was a deliberate press: `gr`
- // twice left two identical lists and you closed one. Tab after a dot
- // is an ordinary typing keystroke, which turns the same bug fatal —
- // measured, twenty Tabs stacked fifteen byte-identical `+Search` panes
- // under the file, crushed it to one visible line, and from the
- // sixteenth on freeSlot returned null and the key was eaten for the
- // rest of the session with nothing said. One code path, so every kind
- // that lands in a results buffer gets the fix.
- //
- // Unlike runSearch this does NOT key on the ARGUMENT. A search is
- // identified by its pattern; a language query is asked about a
- // different symbol every time with the same (usually empty) arg, so
- // the arg cannot tell two lists apart and the KIND is the natural
- // unit: a second `gr` replaces the first list rather than growing a
- // stack of them.
- for (p.panes, 0..) |slot, i| {
- if (i == w.pane) continue;
- const rp = slot orelse continue;
- const rf = if (rp.file) |*f| f else continue;
- const o = rf.output orelse continue;
- if (!std.meta.eql(o.from, from)) continue;
- if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
- output_pane.setArg(&rf.output.?, w.arg.slice());
- // a refill that changes nothing keeps its place (runSearch's rule,
- // and the same reason): re-asking about a symbol you are already
- // stepping must not throw the list back to the top
- const same = std.mem.eql(u8, rf.content, content);
- file_pane.setContent(p, rf, content);
- if (!same) rf.scroll = 0;
- p.active = w.pane;
- if (output_pane.traits(from).steps) {
- pane.search_pane = i;
- pane.search_row = null;
- }
- return;
- }
- const free = p.freeSlot() orelse {
- p.gpa.free(content);
- return;
- };
- const np = output_pane.open(p, free, dir, from, w.arg.slice(), content) catch {
- p.gpa.free(content);
- return;
- };
- p.placeDoc(w.pane, free, np);
- p.active = w.pane;
- // prose is not a list of locations: n/N over a hover blurb would step
- // to nowhere, so only stepping buffers arm the stepper — and WHICH
- // query filled it is now the buffer's own record, not a field here.
- if (output_pane.traits(from).steps) {
- pane.search_pane = free;
- pane.search_row = null;
- }
+ // Landing the rows is runSearch's path exactly, keyed on the KIND
+ // rather than the argument (fillResults reads that off the origin).
+ // Why the refill is not optional here: docs/lsp.md.
+ output_pane.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null);
}
/// n/N on a terminal pane: a MOTION over lookable tokens. Select the
@@ -8024,7 +7859,14 @@ pub const Pardes = struct {
if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: {
const f = pane.file orelse break :dot;
if (f.output != null or !lsp.speaks(f.path)) break :dot;
- return p.lspRequest(p.active, .completion, "");
+ // speaks() is the fast path only — lspRequest has four
+ // bails of its own (unsupported kind, dead pane, output
+ // buffer, multiOnce) and each one would eat the Tab. The
+ // seq bump is the one honest "the question went out", so
+ // ask and fall through to the indent if it did not.
+ const seq = p.lsp_seq;
+ p.lspRequest(p.active, .completion, "");
+ if (p.lsp_seq != seq) return;
}
p.insertTab(pane);
},
@@ -8033,11 +7875,8 @@ pub const Pardes = struct {
}
/// helix insert_tab with a Spaces indent style: spaces to the next tab
- /// stop (smart-tab machinery skipped). It is a function rather than the
- /// five lines it used to be inside the Tab prong because Tab after a `.`
- /// asks the language backend FIRST and indents only if the answer comes
- /// back empty — which happens on another turn of the loop entirely, so
- /// lspResponse needs to be able to press the same key.
+ /// stop (smart-tab machinery skipped). A function because lspResponse
+ /// presses the same key, a turn of the loop later.
fn insertTab(p: *Pardes, pane: *Pane) void {
const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return;
const c: modal.Cursor = .{
@@ -8045,8 +7884,7 @@ pub const Pardes = struct {
.col = @intCast(@max(0, pane.cur_col)),
};
const pad = modal.INDENT_W - (c.col % modal.INDENT_W);
- const spaces = " ";
- const new = modal.insertAt(p.gpa, eb.text, c, spaces[0..pad]) catch return;
+ const new = modal.insertAt(p.gpa, eb.text, c, " "[0..pad]) catch return;
p.setEditText(pane, new);
pane.cur_col += @intCast(pad);
pane.cur_pinned = true;
@@ -9039,6 +8877,69 @@ pub const Pardes = struct {
});
}
+ pub const ChromeTarget = struct { col: u16, row: u16 };
+
+ /// Is this cell layout CHROME, and if so which cell should the press be
+ /// delivered at? For the touch shells: a finger on a tag row or a resize
+ /// handle latches a left-mouse drag, everything else is body text and gets
+ /// one-finger scrolling and tap-as-look. A gesture is classified once, at
+ /// finger-down, and never turns into a scroll afterwards.
+ ///
+ /// It lives here because it is a MIRROR of handleMouse's own hit test
+ /// below, in both the geometry and the ORDER: the move box beats a
+ /// horizontal handle on a tag-only pane, the rest of the tag row beats the
+ /// fat-finger tolerance around a separator, and Tagbottom moves both the
+ /// tag row and the h-handle together (a pane's tag on its LAST row makes
+ /// its first an ordinary body row and puts the seam on the lower pane's
+ /// first). It was a line-for-line clone in web.zig and gui.zig, kept in
+ /// step by a comment in each saying it was a clone of the other; the two
+ /// conditionals Tagbottom added went into both copies four times.
+ ///
+ /// The one-cell tolerance is the only thing here that is not handleMouse's
+ /// rule: a mouse is exact, a finger is not.
+ pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget {
+ if (row < TOPBAR_H) return .{ .col = col, .row = row };
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = p.rects[id];
+ const tag = if (p.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
+ return .{ .col = col, .row = tag };
+ }
+ for (0..p.ncol -| 1) |column| {
+ const handle = p.col_x[column] + p.col_w[column] -| 1;
+ if (col == handle) return .{ .col = handle, .row = row };
+ }
+ for (0..p.ncol) |column| {
+ if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
+ for (0..p.col_n[column] -| 1) |index| {
+ const rect = p.rects[p.col_terms[column][index]];
+ const handle = if (p.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ if (row == handle) return .{ .col = col, .row = handle };
+ }
+ }
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = p.rects[id];
+ const tag = if (p.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w)
+ return .{ .col = col, .row = tag };
+ }
+ for (0..p.ncol -| 1) |column| {
+ const handle = p.col_x[column] + p.col_w[column] -| 1;
+ if (@max(col, handle) - @min(col, handle) == 1) return .{ .col = handle, .row = row };
+ }
+ for (0..p.ncol) |column| {
+ if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
+ for (0..p.col_n[column] -| 1) |index| {
+ const rect = p.rects[p.col_terms[column][index]];
+ const handle = if (p.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle };
+ }
+ }
+ return null;
+ }
+
fn handleMouse(p: *Pardes, m: Mouse) void {
const mcol = @min(m.col, p.screen_w -| 1);
const mrow = @min(m.row, p.screen_h -| 1);
@@ -9135,12 +9036,6 @@ pub const Pardes = struct {
// pair that is a BODY row — the upper pane's last, or with
// Tagbottom, where that one is the upper pane's tag, the
// lower pane's first).
- // Either way the handle EATS one body row of one of the two
- // panes — a press there resizes instead of placing a cursor
- // — and it always has. Tagbottom does not change how many
- // rows that costs, only WHICH pane pays: the lower pane's
- // first row rather than the upper pane's last. A no-drag
- // click on it is a no-op in both orientations.
// The v test still wins outright, but it now also asks
// whether this same cell is one of ITS OWN column's h
// handles — that cell is the corner where the two lines
@@ -9359,8 +9254,6 @@ pub const Pardes = struct {
// .tag drag) stays on the tag, which is one line anyway.
// The cost is that a body sweep can no longer be extended
// onto the bottom tagline to pick up the tag text.
- // With the tag on TOP, Sel order IS screen order and the
- // gesture is contiguous and correct, so nothing is clamped.
if (p.tag_bottom) {
const body_h = r.h -| BOX_H;
pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0)
@@ -12191,7 +12084,7 @@ pub const Pardes = struct {
try p.renderPane(arena, pane, p.rects[id], id == p.active);
}
- // ---- the transient message row: a pane's LAST row, left-aligned ----
+ // ---- the transient message row: the end away from the tag ----
//
// An OVERLAY, not geometry: no rect moves, no pane shrinks, and a pane
// with neither a message nor an armed prompt is not touched at all.
@@ -12422,7 +12315,7 @@ pub const Pardes = struct {
// body's first. The Tagbottom builtin swaps which end each is at and
// NOTHING else in here reads r.y — that is the whole of the feature on
// the render side. r.h == 0 returned above, so the bottom row exists.
- const tag_y = if (p.tag_bottom) r.y + r.h - 1 else r.y;
+ const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
// the pane's own background, for everything that has to read as "no
// chrome here": the body text, and the blank right half of the
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 91213d9f..ad6f04b0 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -457,15 +457,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// --nested opted out of the whole mechanism, including being an outer
// instance; so does any failure to bind, and then children simply open
// their own session.
- var sock_buf: [108]u8 = undefined;
- const sock_path: ?[:0]const u8 = if (opts.nested) null else nested.socketPath(&sock_buf, libc.getpid());
- const sock_fd: c_int = if (sock_path) |sp| nested.listenAt(sp) else -1;
- // only on the fd, so a bind that FAILED cannot unlink a path this process
- // never created
- defer if (sock_fd >= 0) {
- _ = libc.close(sock_fd);
- _ = libc.unlink(sock_path.?);
- };
+ const sock_fd: c_int = if (opts.nested) -1 else nested.listen();
+ defer nested.unlisten(sock_fd);
// Perform the initial spawns BEFORE any worker thread exists: forkpty from
// a multithreaded process can wedge the child before exec.
diff --git a/src/web.zig b/src/web.zig
index b8abf27e..43b6c96d 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -344,7 +344,7 @@ export fn pardes_chrome_target(col_arg: u32, row_arg: u32) u32 {
const s = &(state orelse return std.math.maxInt(u32));
const col: u16 = @intCast(@min(col_arg, std.math.maxInt(u16)));
const row: u16 = @intCast(@min(row_arg, std.math.maxInt(u16)));
- const target = chromeTarget(s.core, col, row) orelse return std.math.maxInt(u32);
+ const target = s.core.chromeTarget(col, row) orelse return std.math.maxInt(u32);
return (@as(u32, target.row) << 16) | target.col;
}
@@ -486,57 +486,3 @@ fn putU32(s: *State, value: u32) void {
std.mem.writeInt(u32, &bytes, value, .little);
appendEffect(s, &bytes);
}
-
-const Target = struct { col: u16, row: u16 };
-
-/// The tag row and the stacked-pair seam, the way the core's own hit test
-/// derives them: Tagbottom puts a pane's tag on its LAST row, which makes its
-/// FIRST row an ordinary scrollable body row and moves the h-handle down to
-/// the lower pane's first. Kept a line-for-line clone of gui.zig's
-/// webChromeTarget — the two must classify a finger identically.
-fn chromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?Target {
- if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row };
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = core.rects[id];
- const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.config.GUTTER)
- return .{ .col = col, .row = tag };
- }
- for (0..core.ncol -| 1) |column| {
- const handle = core.col_x[column] + core.col_w[column] -| 1;
- if (col == handle) return .{ .col = handle, .row = row };
- }
- for (0..core.ncol) |column| {
- if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
- for (0..core.col_n[column] -| 1) |index| {
- const rect = core.rects[core.col_terms[column][index]];
- const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (row == handle) return .{ .col = col, .row = handle };
- }
- }
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = core.rects[id];
- const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w)
- return .{ .col = col, .row = tag };
- }
- for (0..core.ncol -| 1) |column| {
- const handle = core.col_x[column] + core.col_w[column] -| 1;
- if (distance(col, handle) == 1) return .{ .col = handle, .row = row };
- }
- for (0..core.ncol) |column| {
- if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
- for (0..core.col_n[column] -| 1) |index| {
- const rect = core.rects[core.col_terms[column][index]];
- const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (distance(row, handle) == 1) return .{ .col = col, .row = handle };
- }
- }
- return null;
-}
-
-fn distance(a: u16, b: u16) u16 {
- return if (a > b) a - b else b - a;
-}