diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-09 06:54:27 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 599dd82f96b9d091aae78300aa6c3fbc81f9eb69 (patch) | |
| tree | 532d29eb0d79896cd730fb324df4866056306506 /src | |
| parent | 628aa40f13e9bbd313b51ab625f193110aad8dd0 (diff) | |
| download | pardes-599dd82f96b9d091aae78300aa6c3fbc81f9eb69.tar.gz pardes-599dd82f96b9d091aae78300aa6c3fbc81f9eb69.zip | |
review pass: fix the eaten Tab, drop the duplicated code, cover the gaps
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui/gui.zig | 80 | ||||
| -rw-r--r-- | src/lsp/lsp.zig | 4 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 32 | ||||
| -rw-r--r-- | src/macos.zig | 18 | ||||
| -rw-r--r-- | src/nested.zig | 59 | ||||
| -rw-r--r-- | src/output_pane.zig | 69 | ||||
| -rw-r--r-- | src/pardes.zig | 307 | ||||
| -rw-r--r-- | src/tty/tty.zig | 11 | ||||
| -rw-r--r-- | src/web.zig | 56 |
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; -} |
