From 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 18:11:36 -0300 Subject: Refactor panes and filesystem; replace FUSE with 9P Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill. --- src/9p.zig | 2166 +-------- src/9p_io.zig | 1454 ++++++ src/9p_quic.zig | 557 +++ src/CHANGELOG.md | 2 +- src/acmefs.zig | 3782 --------------- src/allocators.zig | 104 - src/animation.zig | 189 - src/board9p.zig | 874 ---- src/board_memory.zig | 465 -- src/board_pins.zig | 186 - src/builtins.zig | 871 ++-- src/config.zig | 1435 +++--- src/crash.zig | 2 +- src/detached/client.zig | 143 +- src/detached/server.zig | 1845 ++------ src/detached/wire.zig | 243 +- src/dump.zig | 223 +- src/effect_sources.zig | 273 +- src/esp32p4.zig | 11 +- src/esp32p4/app.zig | 5 +- src/esp32p4/input_rescue.zig | 6 +- src/esp32p4/selftest.zig | 10 +- src/esp32p4/uart.zig | 11 +- src/esp32p4_9p.zig | 295 +- src/esp32p4_gpio.zig | 576 +++ src/file_pane.zig | 1059 ----- src/file_watch.zig | 167 +- src/fs.zig | 4677 +++++++++++++++++++ src/fs9_client.zig | 695 --- src/fs9_service.zig | 618 --- src/fs_service.zig | 324 -- src/fuse.zig | 2749 ----------- src/gui/gui.zig | 2434 ++++------ src/host.zig | 345 -- src/host_io.zig | 1572 ++++++- src/image.zig | 456 +- src/image_pane.zig | 262 -- src/layout.zig | 1706 +++++++ src/limits.zig | 237 - src/look.zig | 1472 +----- src/lsp/lsp.zig | 314 +- src/lsp/lsp_client.zig | 156 +- src/lsp/lsp_zls.zig | 138 +- src/lsp_host.zig | 206 - src/macos.zig | 1602 +++---- src/macos/build-e2e.sh | 59 - src/main.zig | 459 +- src/memory.zig | 148 + src/message.zig | 88 - src/modal.zig | 1598 ++++--- src/nested.zig | 743 --- src/normal_input.zig | 659 --- src/output_pane.zig | 695 --- src/output_pane_integration_test.zig | 338 -- src/panel_animation.zig | 662 --- src/panes.zig | 7681 ++++++++++++++++++++++++++++++ src/pardes.zig | 8549 +++++++--------------------------- src/pdf.zig | 7 + src/pdf_bridge.c | 35 +- src/pdf_bridge.h | 1 + src/pdf_pane.zig | 2763 ----------- src/pdf_pane_integration_test.zig | 1819 -------- src/petscii.zig | 446 -- src/runtime_config.zig | 579 --- src/selection_pipe.zig | 26 +- src/shell_bin.zig | 567 --- src/source_manifest.zig | 50 - src/syntax.zig | 653 ++- src/temp_file.zig | 84 - src/term_pane.zig | 3525 -------------- src/tty/panel_compositor.zig | 28 +- src/tty/tty.zig | 1362 ++---- src/tutor.txt | 47 +- src/user_config.zig | 188 - src/web.zig | 18 +- 75 files changed, 26414 insertions(+), 44380 deletions(-) create mode 100644 src/9p_io.zig create mode 100644 src/9p_quic.zig delete mode 100644 src/acmefs.zig delete mode 100644 src/allocators.zig delete mode 100644 src/animation.zig delete mode 100644 src/board9p.zig delete mode 100644 src/board_memory.zig delete mode 100644 src/board_pins.zig create mode 100644 src/esp32p4_gpio.zig delete mode 100644 src/file_pane.zig create mode 100644 src/fs.zig delete mode 100644 src/fs9_client.zig delete mode 100644 src/fs9_service.zig delete mode 100644 src/fs_service.zig delete mode 100644 src/fuse.zig delete mode 100644 src/host.zig delete mode 100644 src/image_pane.zig create mode 100644 src/layout.zig delete mode 100644 src/limits.zig delete mode 100644 src/lsp_host.zig delete mode 100755 src/macos/build-e2e.sh create mode 100644 src/memory.zig delete mode 100644 src/message.zig delete mode 100644 src/nested.zig delete mode 100644 src/normal_input.zig delete mode 100644 src/output_pane.zig delete mode 100644 src/output_pane_integration_test.zig delete mode 100644 src/panel_animation.zig create mode 100644 src/panes.zig delete mode 100644 src/pdf_pane.zig delete mode 100644 src/pdf_pane_integration_test.zig delete mode 100644 src/petscii.zig delete mode 100644 src/runtime_config.zig delete mode 100644 src/shell_bin.zig delete mode 100644 src/source_manifest.zig delete mode 100644 src/temp_file.zig delete mode 100644 src/term_pane.zig delete mode 100644 src/user_config.zig (limited to 'src') diff --git a/src/9p.zig b/src/9p.zig index 068d0740..61a08b84 100644 --- a/src/9p.zig +++ b/src/9p.zig @@ -1,120 +1,15 @@ -//! BASE 9P2000, ON THE WIRE AND NOTHING ELSE: the twenty-seven message types -//! of the original protocol, encoded into a caller's buffer and decoded back -//! out of one, with no allocator, no descriptor and no opinion about what any -//! message means. -//! -//! WHY A SECOND CODEC in a tree that already has `src/detached/wire.zig`. That -//! one is ours on both ends and can be renumbered by editing one file. This one -//! is somebody else's: plan9port's `9p` command, Plan 9's own `mount`, Linux's -//! v9fs and `ad` will all be talking to it, and not one of them will be -//! rebuilt to suit us. So every number below is copied from a primary source -//! with the file and line named, and the tests at the bottom assert LITERAL -//! BYTES against `u9fs/convS2M.c` rather than only round-tripping — a codec -//! that agrees with itself has proved nothing about interoperability. -//! -//! WHAT THIS DELIBERATELY IS NOT. There are three dialects; this is the first. -//! * 9P2000.u adds a numeric errno to `Rerror`, `n_uid`/`extension` to -//! `stat`, and Unix-flavoured `Tcreate`. We do not serve it. The tree is -//! acme's and it is INVENTED — every error in it is one we chose the -//! wording of, so a string is the whole error ABI and a number beside it -//! would be a second spelling of a decision we already made -//! (docs/registry.typ `9P-4`). It also costs a second dialect inside every -//! one of the parsers below, because `.u` changes the LAYOUT of `Rerror` -//! and `stat` rather than adding messages. -//! * 9P2000.L replaces most of the protocol: `Tstatfs`, `Tlopen`, `Tgetattr`, -//! `Tsymlink`, `Trename`, thirty-odd types and a POSIX file model. Our tree -//! has no symlinks, no hard links, no device nodes and no block counts to -//! report, so there is nothing on the other side of those messages to -//! answer them with. -//! * `Tsession`/`Tattach`-with-auth-blob from the 9P1 era, which u9fs still -//! carries commented out (`convS2M.c:60-65,140-148`) and which no client -//! built this century sends. -//! -//! TWO FACTS A READER MUST NOT GET WRONG, because both are silent when wrong: -//! -//! 1. `size[4]` INCLUDES ITSELF. `convS2M.c:216-224` computes `size` from -//! `sizeS2M`, whose first line is `n += BIT32SZ; /* size */`, and then -//! writes that number into the first four bytes. A reader that treats it -//! as a payload length is four bytes out of step on every message and -//! resynchronises never. -//! -//! 2. A `stat` HAS TWO LENGTHS IN FRONT OF IT. The record itself begins with -//! `size[2]` which counts everything AFTER itself — `convD2M.c:48-51`, -//! «note that length excludes count field itself», `PBIT16(p, ss-BIT16SZ)` -//! — and `Rstat`/`Twstat` then wrap the whole record in ANOTHER `[2]` -//! count, which is why Linux reads `Rstat` with the format string `"wS"` -//! and throws the first `w` away into a variable literally named `ignored` -//! (`linux/net/9p/client.c:1617,1633`), and writes `Twstat` as `"dwS"` -//! (`client.c:1776`). So the outer count is `Stat.size() + 2`, never -//! `Stat.size()`. `Stat` below owns both numbers and the test -//! "9p: the stat double length" is the one that would catch it. -//! -//! FREESTANDING. No libc, no OS, no allocator, no threads: this file imports -//! `std` for `mem.readInt`/`writeInt` and `debug.assert` and nothing more, so -//! it compiles for `wasm32-freestanding` and for the board's -//! `riscv32-freestanding` exactly as `wire.zig` does. Every integer on the wire -//! has an explicit width and is little-endian; no `usize` reaches it, and no -//! Zig struct is ever `@bitCast` onto it. Decoding BORROWS: every `[]const u8` -//! in a decoded `Msg` points into the caller's buffer, which stays alive until -//! the reply is written. -//! -//! MALFORMED INPUT IS REFUSED. This parser is fed by a socket, and a message -//! misread rather than refused is an out-of-bounds index. Nothing below indexes -//! without first checking; `Error` names every way a stream can be wrong. -//! -//! THE SERVER HALF IS APPENDED TO THIS FILE, the way `fuse.zig` keeps its wire -//! structs and its transport together: a fid table, the dispatch onto -//! `acmefs.zig`'s nine operations, and the msize handshake. Codec first, then -//! the seam. Keeping them in one file is what makes it possible to change a -//! layout and its only caller in one diff. -//! -//! Verified against `u9fs` (`fcall.h`, `convS2M.c`, `convM2S.c`, `convD2M.c`, -//! `convM2D.c`), `linux/net/9p/{protocol,client}.c`, and -//! `ad/crates/ninep/src/sansio/protocol.rs`. const std = @import("std"); const assert = std.debug.assert; pub const Error = error{ - /// The message ended inside a field, or `size` claims more bytes than the - /// caller handed over. Both are "not all of it has arrived", which is what - /// a stream reader wants to hear: buffer more and ask again. Truncated, - /// A count larger than this protocol admits: `nwname > MAXWELEM`, a string - /// past 64 KiB, a `stat` past 64 KiB. Refused before anything is indexed. Overlong, - /// A type byte 9P2000 does not define, or defines as illegal (`Terror`). BadTag, - /// A field carrying a value it cannot mean — a `size` smaller than a - /// header, which is a number no encoder can have produced. BadValue, - /// The message was decoded and bytes were left over, either inside `size` - /// or after it. A message that says more than its layout has room for is - /// not this message. Trailing, - /// The encoder ran out of caller-supplied buffer. Nothing was written. NoSpace, }; -// --------------------------------------------------------------------------- -// message types -// --------------------------------------------------------------------------- - -/// The type byte. Numbers from `u9fs/fcall.h:74-105`, which is the definitive -/// list: `Tversion = 100` and every name after it takes the next value, so the -/// gap at 106 is load-bearing and the enum below spells it rather than skipping -/// it silently. -/// -/// Non-exhaustive for the same reason `fuse.zig`'s `Opcode` is: `@enumFromInt` -/// of an unlisted value into an exhaustive enum is undefined behaviour, which -/// is the one bug in a protocol decoder that cannot be diagnosed from outside. -/// A `.u` or `.L` client will hand us `Tstatfs = 8` or `Tlopen = 12`; that must -/// arrive as a value we can refuse (`decode` returns `error.BadTag`) rather -/// than as UB. -/// -/// TWENTY-SEVEN REAL TYPES: thirteen T/R pairs, plus `Rerror`, which is a reply -/// with no request. `Terror = 106` is the twenty-eighth number and is defined -/// as illegal by the protocol — a client cannot ask for an error — so it is -/// listed to keep the numbering honest and refused by name in `decode`. pub const Type = enum(u8) { tversion = 100, rversion = 101, @@ -122,8 +17,6 @@ pub const Type = enum(u8) { rauth = 103, tattach = 104, rattach = 105, - /// «Terror = 106, /* illegal */» — `fcall.h:82`. Never sent, never - /// accepted; here so that nobody re-derives 107 for `Rerror` by counting. terror = 106, rerror = 107, tflush = 108, @@ -149,112 +42,48 @@ pub const Type = enum(u8) { _, }; -/// T-messages are EVEN, R-messages are ODD, all the way from `Tversion = 100` -/// to `Rwstat = 127` (`fcall.h:74-105`), because the enum assigns each T an -/// even number and lets its R take the next. So one byte tells a reader which -/// direction a message is travelling, which makes a stream carrying both -/// SELF-DEMUXING: `drawterm`'s single descriptor has requests going one way and -/// replies coming back on it, and the parity alone separates them. -/// -/// PaRDeS does not rely on that today — a connection has one role per side -/// (docs/9p.typ §"Layering"), so a server only ever reads T and a client only -/// ever reads R, and each refuses the other by name. This is here because the -/// ENCODING GUARANTEES it and a future 9P-inside-the-wire arrangement over the -/// board's UART would want it, and because a hand-typed number that breaks the -/// parity is a bug the test at the bottom catches for free. pub fn isT(t: Type) bool { return @intFromEnum(t) % 2 == 0; } -// --------------------------------------------------------------------------- -// constants -// --------------------------------------------------------------------------- - -/// `size[4] type[1] tag[2]`, and `size` counts these seven bytes too. Public -/// because the server half sizes its reply payloads against it: the largest -/// `Rread` that fits an msize is `msize - header_len - 4`. pub const header_len: usize = 4 + 1 + 2; -/// `QIDSZ` — `fcall.h:64`, `BIT8SZ+BIT32SZ+BIT64SZ`. pub const qid_len: usize = 1 + 4 + 8; -/// `STATFIXLEN` — `fcall.h:66-68`. The fixed part of a `stat` INCLUDING its own -/// leading `size[2]` and the four string count prefixes, excluding the string -/// bytes. `BIT16SZ + QIDSZ + 5*BIT16SZ + 4*BIT32SZ + BIT64SZ` = 49. pub const stat_fixed: usize = 2 + qid_len + 5 * 2 + 4 * 4 + 8; -/// `NOTAG` — `fcall.h:71`. The tag on `Tversion`/`Rversion`, which is the one -/// exchange that happens before tags mean anything. Note that `fcall.h` writes -/// it `~0U` and the wire field is two bytes, so it is 0xFFFF and not 0xFFFFFFFF. pub const notag: u16 = 0xFFFF; -/// `NOFID` — `fcall.h:121-123`. `Tattach.afid` when no authentication fid was -/// established, which is our only use of it: we serve `Tauth` a refusal. pub const nofid: u32 = 0xFFFF_FFFF; -/// `MAXWELEM` — `fcall.h:2`. The most path elements one `Twalk` may carry, and -/// a hard protocol bound rather than a buffer size: `convS2M.c:279-280` and -/// `convM2S.c:170-171` both return failure above it, so a 17-element walk is -/// refused by every implementation and must be split by the client. pub const max_welem: usize = 16; -/// The smallest msize we may agree to. NOT from the protocol — 9P has no floor, -/// and Plan 9's devmnt, plan9port's `9p` and our own client all accept 512 -/// (docs/registry.typ, `linux/net/9p/client.c:840-843`). This number exists -/// because the LINUX KERNEL refuses to mount below it, and a mount that fails -/// with `EINVAL` and no message is the worst diagnostic in the set. pub const min_msize: u32 = 4096; -/// `IOHDRSZ` — `fcall.h:72`, «ample room for Twrite/Rread header (iounit)». -/// The real `Rread` header is 11 bytes (`size[4] type[1] tag[2] count[4]`) and -/// `Twrite`'s is 23; 24 is the slack both ends have agreed to reserve for -/// thirty years, and `iounit` is quoted to clients as `msize - iohdrsz`. pub const iohdrsz: u32 = 24; -/// `ERRMAX` — Plan 9's `libc.h:146`. The buffer a Plan 9 client has for an -/// error string. ADVISORY here: `decode` does not refuse a longer `Rerror`, -/// because refusing a peer's error message is the least useful moment to -/// discover a length limit. The server half truncates its own to this. pub const errmax: usize = 128; -// Qid type bits — `u9fs/plan9.h:156-161`, cross-checked against -// `linux/include/net/9p/9p.h:344-352` which adds QTTMP = 0x04. These are the -// top five bits of `Stat.mode` shifted down 24; see `dmdir` below. pub const qtdir: u8 = 0x80; pub const qtappend: u8 = 0x40; pub const qtexcl: u8 = 0x20; -/// 0x10 is `QTMOUNT`, a mounted channel — a thing only a Plan 9 kernel has, and -/// the reason the mode bits below have a gap at bit 28. pub const qtmount: u8 = 0x10; pub const qtauth: u8 = 0x08; pub const qttmp: u8 = 0x04; -/// «plain file» — `plan9.h:161`. Zero, so a `Qid.type` of 0 is not "unset". pub const qtfile: u8 = 0x00; -// Mode bits — `u9fs/plan9.h:164-170`, with DMAUTH and DMTMP from -// `ad/crates/ninep/src/sansio/protocol.rs:486-495`: «bit 27 (DMAUTH) ... bit 26 -// (DMTMP) ... (Bit 28 is skipped for historical reasons)». That skipped bit is -// `DMMOUNT`, which is why the top five type bits are not the top five mode -// bits: they are DMDIR, DMAPPEND, DMEXCL, (gap), DMAUTH, DMTMP reproduced from -// the top down into `Qid.type` as QTDIR, QTAPPEND, QTEXCL, QTAUTH, QTTMP. pub const dmdir: u32 = 0x8000_0000; pub const dmappend: u32 = 0x4000_0000; pub const dmexcl: u32 = 0x2000_0000; pub const dmmount: u32 = 0x1000_0000; pub const dmauth: u32 = 0x0800_0000; pub const dmtmp: u32 = 0x0400_0000; -/// The rwx triples, and the ONLY part of `mode` that is a Unix permission. A -/// server that hands the high bits to `chmod`, or a client that hands the low -/// nine to a type test, has confused the two halves of one word. pub const dmperm: u32 = 0o777; comptime { - // The three widths every offset below is derived from. A drifted number - // here is a codec that agrees with nothing, so make it a compile error. assert(header_len == 7); assert(qid_len == 13); assert(stat_fixed == 49); - // Parity is the protocol's, not a convention we maintain by hand. for (std.enums.values(Type)) |t| { const even = @intFromEnum(t) % 2 == 0; assert(isT(t) == even); @@ -262,23 +91,11 @@ comptime { } } -// --------------------------------------------------------------------------- -// qid -// --------------------------------------------------------------------------- - -/// The server's name for a file: `type[1] version[4] path[8]`, thirteen bytes, -/// `convS2M.c:18-29`. Two files are the same file if and only if their qids -/// are equal, which is the whole contract — `path` identifies the file for the -/// life of the connection and `version` changes on every write, so a client -/// caches against the pair and never against a pathname. pub const Qid = struct { - /// `qt*` bits. The high bits of `Stat.mode` shifted down 24. type: u8, version: u32, path: u64, - /// Writes thirteen bytes and returns them. Takes the whole buffer and - /// returns the used slice, so a caller can chain without arithmetic. pub fn encode(self: Qid, buf: []u8) Error![]u8 { if (buf.len < qid_len) return error.NoSpace; buf[0] = self.type; @@ -287,8 +104,6 @@ pub const Qid = struct { return buf[0..qid_len]; } - /// Reads thirteen bytes. Refuses a shorter buffer rather than reading one: - /// `gqid` in `convM2S.c:26-37` returns nil for exactly this case. pub fn decode(bytes: []const u8) Error!Qid { if (bytes.len < qid_len) return error.Truncated; return .{ @@ -299,30 +114,6 @@ pub const Qid = struct { } }; -// --------------------------------------------------------------------------- -// stat -// --------------------------------------------------------------------------- - -/// One directory entry, and the payload of `Rstat` and `Twstat`. Layout from -/// `convD2M.c:56-83`: -/// -/// ``` -/// size[2] type[2] dev[4] qid[13] mode[4] atime[4] mtime[4] length[8] -/// name[s] uid[s] gid[s] muid[s] -/// ``` -/// -/// where `[s]` is `n[2]` plus n bytes of UTF-8, NOT NUL-terminated. `size` -/// counts everything after itself, so the record occupies `size() + 2` bytes; -/// see the module header for why that matters twice over. -/// -/// `type` and `dev` are Plan 9 kernel device identifiers and are meaningless -/// off Plan 9 — u9fs sends zeros and so do we, but they are on the wire because -/// the layout is fixed. `muid` is the uid of the last modifier; for a synthetic -/// tree it is whoever attached. -/// -/// A `Twstat` uses the sentinel "don't touch" values that acme(4) and -/// `stat(5)` specify: an empty string, an all-ones integer. Nothing here -/// interprets them; that is the server half's job. pub const Stat = struct { type: u16, dev: u32, @@ -336,14 +127,6 @@ pub const Stat = struct { gid: []const u8, muid: []const u8, - /// The value that goes in the leading `size[2]`: every byte of the record - /// EXCEPT those two. `convD2M.c:46-50` computes `ss = STATFIXLEN + ns` and - /// then writes `ss - BIT16SZ`, so this is `stat_fixed - 2` plus the four - /// string bodies. Written out field by field rather than as 47, because a - /// number nobody can check against a layout is a comment that rots. - /// - /// Fallible: the prefix is two bytes, so a record whose strings do not fit - /// a u16 has no legal encoding and must be refused rather than wrapped. pub fn size(self: Stat) Error!u16 { const n = 2 + // type @@ -362,9 +145,6 @@ pub const Stat = struct { return @intCast(n); } - /// Writes `size[2]` and the record, and returns the `size() + 2` bytes of - /// it. `assert` at the end is `convD2M.c:85-86`'s `if(ss != p - buf)`: the - /// two arithmetics are written separately and must agree. pub fn encode(self: Stat, buf: []u8) Error![]u8 { const n = try self.size(); const total = @as(usize, n) + 2; @@ -386,15 +166,6 @@ pub const Stat = struct { return buf[0..total]; } - /// Decodes exactly one record from `bytes`, which must be the whole of it — - /// prefix included — and nothing more. The strings BORROW from `bytes`. - /// - /// The equality check on the prefix is the second half of `statcheck` - /// (`convM2D.c:14-22`: walk the four counts, then `if(buf != ebuf) return - /// -1`). It is what makes the double length safe: the caller has already - /// bounded `bytes` by the OUTER count, so demanding that the INNER count - /// agree refuses the classic off-by-two in both directions instead of - /// trusting whichever one the sender got right. pub fn decode(bytes: []const u8) Error!Stat { var r: Reader = .init(bytes); const n = try r.getU16(); @@ -419,95 +190,42 @@ pub const Stat = struct { } }; -// --------------------------------------------------------------------------- -// messages -// --------------------------------------------------------------------------- - -/// Every message base 9P2000 defines, with the fields it actually carries. -/// Layouts from `convS2M.c:231-419` and `convM2S.c:73-375`, which are the two -/// halves of the same table and disagree nowhere. -/// -/// The tag names are the type names, so `msgType` is a mechanical mapping and -/// not a table somebody maintains; a variant added here without a `Type` is a -/// compile error. -/// -/// NOT IN HERE: the message tag. A `Msg` is a message's CONTENT, and the tag is -/// the transport's matching of a reply to a request — it is a parameter of -/// `encode` and a field of `Decoded`. Putting it in the union would mean every -/// server handler that builds a reply has to remember to copy it. -/// -/// `Twalk` is the large variant at sixteen slices, so `Msg` is around 280 bytes -/// on a 64-bit host. That is a value passed by const pointer in practice and it -/// buys the thing that matters: a walk decodes with no allocator and no bound -/// the caller has to have guessed. pub const Msg = union(enum) { - /// The first exchange, tagged `notag`. `msize` is the largest message - /// either end will send, INCLUDING the seven-byte header; `version` is - /// "9P2000" or a string starting with it. tversion: struct { msize: u32, version: []const u8 }, - /// The server's answer: `msize` no larger than the client's, and `version` - /// either "9P2000" or the literal "unknown" — which is a successful reply - /// meaning "no dialect in common", not an `Rerror`. rversion: struct { msize: u32, version: []const u8 }, tauth: struct { afid: u32, uname: []const u8, aname: []const u8 }, - /// `aqid` and not `qid`: `fcall.h:44` gives `Rauth` its own field, and - /// `convS2M.c:368-370` writes it. Same thirteen bytes, different meaning — - /// the qid of the auth FILE, not of the tree. rauth: struct { aqid: Qid }, - /// `afid` is `nofid` when the client did not authenticate. tattach: struct { fid: u32, afid: u32, uname: []const u8, aname: []const u8 }, rattach: struct { qid: Qid }, - /// A STRING and nothing else. Base 9P2000 has no numeric error code; the - /// `errno` field is 9P2000.u's, which this file does not serve. See the - /// module header. rerror: struct { ename: []const u8 }, - /// `oldtag` is a u16 like every tag, even though `fcall.h:14` declares - /// `oldtag` as u32 — `convS2M.c:251-253` writes it with `PBIT16`. tflush: struct { oldtag: u16 }, rflush: void, - /// `wname[0..nwname]` are the path elements; anything past `nwname` is - /// undefined and neither encoded nor compared. A zero-element walk is - /// legal and means "clone `fid` into `newfid`". twalk: struct { fid: u32, newfid: u32, nwname: u16, wname: [max_welem][]const u8 = @splat(""), }, - /// `nwqid` may be SHORTER than the request's `nwname`: a partial walk is a - /// successful `Rwalk` with fewer qids, and only a failure on the FIRST - /// element is an `Rerror`. rwalk: struct { nwqid: u16, wqid: [max_welem]Qid = @splat(.{ .type = 0, .version = 0, .path = 0 }), }, - /// `mode` is OREAD/OWRITE/ORDWR/OEXEC plus OTRUNC/ORCLOSE, one byte. topen: struct { fid: u32, mode: u8 }, - /// `iounit`: the largest atomic read or write, or 0 for "no promise". We - /// quote `msize - iohdrsz`. ropen: struct { qid: Qid, iounit: u32 }, - /// `perm` is the full mode word — `dmdir` and friends in the high bits, - /// `dmperm` in the low nine. tcreate: struct { fid: u32, name: []const u8, perm: u32, mode: u8 }, rcreate: struct { qid: Qid, iounit: u32 }, tread: struct { fid: u32, offset: u64, count: u32 }, - /// `count[4]` then the bytes, held as one slice because the count is the - /// slice's length and two ways to say one number is one way to disagree. - /// A reply longer than the request's `count` is a hard `-EIO` to Linux - /// (`net/9p/client.c:1475-1479`), so the server half clamps and this codec - /// carries whatever it is given. rread: struct { data: []const u8 }, twrite: struct { fid: u32, offset: u64, data: []const u8 }, - /// The count actually written, which may be short. rwrite: struct { count: u32 }, tclunk: struct { fid: u32 }, @@ -516,17 +234,10 @@ pub const Msg = union(enum) { rremove: void, tstat: struct { fid: u32 }, - /// Carries a decoded `Stat`, not a blob, so the double length is computed - /// in one place — `encode` derives the outer count from `stat.size() + 2` - /// and `decode` demands they agree. u9fs keeps `nstat` and a `uchar*` here - /// (`fcall.h:40-41`) and pays for it with `statcheck` as a separate call - /// every caller must remember. rstat: struct { stat: Stat }, twstat: struct { fid: u32, stat: Stat }, rwstat: void, - /// The type byte this message travels as. Mechanical, by name, so a - /// variant cannot acquire the wrong number. pub fn msgType(msg: Msg) Type { return switch (msg) { inline else => |_, t| @field(Type, @tagName(t)), @@ -534,35 +245,16 @@ pub const Msg = union(enum) { } }; -/// One complete message off the wire: its tag and its content. The tag is -/// separate for the reason `Msg`'s doc gives — a reply reuses the request's tag -/// and never looks inside it. pub const Decoded = struct { tag: u16, msg: Msg, }; -/// How many bytes this message will be, once the caller has enough of it. -/// `null` when there are fewer than four, which is the only answer a stream -/// reader can act on: read more. -/// -/// Deliberately UNVALIDATED. It is the raw `size` field, and it is peeked -/// before the type byte has necessarily arrived, so there is nothing here to -/// check it against. `decode` does the refusing; this only says how much to -/// buffer, and a caller that compares the answer to its negotiated msize -/// refuses an absurd claim before growing anything. pub fn frameLen(prefix: []const u8) ?u32 { if (prefix.len < 4) return null; return std.mem.readInt(u32, prefix[0..4], .little); } -/// The whole message's byte count, `size` included, which IS the value of the -/// `size` field. `sizeS2M` in `convS2M.c:38-208`, in the same order, so the two -/// can be read side by side. -/// -/// Computed before a single byte is written, which is what makes `encode`'s -/// `NoSpace` clean: a caller whose buffer is one byte short gets an error and -/// an untouched buffer, not a half-written message. fn totalLen(msg: Msg) Error!usize { const body: usize = switch (msg) { .tversion => |m| 4 + try stringLen(m.version), @@ -575,9 +267,6 @@ fn totalLen(msg: Msg) Error!usize { .tflush => 2, .rflush => 0, .twalk => |m| blk: { - // The bound is the protocol's, and both halves of u9fs return - // failure above it (`convS2M.c:279`, `convM2S.c:170`). On this side - // it is a caller bug — the array is sixteen long — so it asserts. assert(m.nwname <= max_welem); var n: usize = 4 + 4 + 2; for (m.wname[0..m.nwname]) |name| n += try stringLen(name); @@ -600,8 +289,6 @@ fn totalLen(msg: Msg) Error!usize { .tremove => 4, .rremove => 0, .tstat => 4, - // THE DOUBLE LENGTH, in the one place it is computed: the outer count, - // then the record, whose own prefix is inside `size() + 2`. .rstat => |m| 2 + 2 + @as(usize, try m.stat.size()), .twstat => |m| 4 + 2 + 2 + @as(usize, try m.stat.size()), .rwstat => 0, @@ -611,32 +298,20 @@ fn totalLen(msg: Msg) Error!usize { return total; } -/// `stringsz` — `convS2M.c:31-36`. The count is two bytes, so a longer string -/// has no encoding and is refused here rather than truncated silently. fn stringLen(s: []const u8) Error!usize { if (s.len > std.math.maxInt(u16)) return error.Overlong; return 2 + s.len; } -/// The same for a `count[4]` payload: `Rread`'s and `Twrite`'s data. fn dataLen(d: []const u8) Error!usize { if (d.len > std.math.maxInt(u32)) return error.Overlong; return 4 + d.len; } -/// Encodes one message into `buf` and returns the bytes of it, which start at -/// `buf[0]` and are exactly `size` long. -/// -/// `tag` is a parameter and not a field of `Msg`: a server handler builds a -/// reply and the transport supplies the request's tag, so the two cannot drift. -/// `notag` on anything but `Tversion`/`Rversion` is the caller's business. pub fn encode(msg: Msg, tag: u16, buf: []u8) Error![]u8 { const total = try totalLen(msg); if (total > buf.len) return error.NoSpace; - // The writer is bounded to `total` and not to `buf`, so a disagreement - // between `totalLen` and the field walk below cannot scribble past the - // message — it becomes `NoSpace` here or the assert at the end. var w: Writer = .init(buf[0..total]); try w.putU32(@intCast(total)); try w.putByte(@intFromEnum(msg.msgType())); @@ -717,7 +392,6 @@ pub fn encode(msg: Msg, tag: u16, buf: []u8) Error![]u8 { .rremove => {}, .tstat => |m| try w.putU32(m.fid), .rstat => |m| { - // Outer count first: the whole record, its own prefix included. try w.putU16(try m.stat.size() + 2); try w.putStat(m.stat); }, @@ -729,30 +403,13 @@ pub fn encode(msg: Msg, tag: u16, buf: []u8) Error![]u8 { .rwstat => {}, } - // `convS2M.c:420-421`: `if(size != p-ap) return 0`. The two arithmetics are - // deliberately separate and this is the only thing that keeps them honest. assert(w.n == total); return buf[0..total]; } -/// Decodes exactly one complete message. `bytes` must be the message and -/// nothing else — `frameLen` is how a reader knows where that ends — and every -/// slice in the result BORROWS from it. -/// -/// Four ways this refuses, in the order the checks run, because the order is -/// what makes a stream reader's life simple: -/// * fewer than seven bytes, or `size` past the end -> `Truncated`, meaning -/// "come back with more". -/// * `size` short of the end -> `Trailing`. Two messages were handed over as -/// one, which is a framing bug in the caller, not a short read. -/// * a `size` that cannot hold a header -> `BadValue`. No encoder produced it. -/// * a type byte 9P2000 does not define, or defines illegal -> `BadTag`. pub fn decode(bytes: []const u8) Error!Decoded { if (bytes.len < header_len) return error.Truncated; const size = std.mem.readInt(u32, bytes[0..4], .little); - // `convM2S.c:65-66` refuses this too, and it must be refused BEFORE the - // comparison against `bytes.len`: a size of 3 on a 3-byte buffer would - // otherwise slice a header out of nothing. if (size < header_len) return error.BadValue; if (size > bytes.len) return error.Truncated; if (size < bytes.len) return error.Trailing; @@ -760,9 +417,6 @@ pub fn decode(bytes: []const u8) Error!Decoded { const t: Type = @enumFromInt(bytes[4]); const tag = std.mem.readInt(u16, bytes[5..7], .little); - // Bounded by `size` and not by `bytes`, which is the same thing here only - // because of the two checks above; keep it explicit so it stays true if a - // caller is ever allowed to pass a longer buffer. var r: Reader = .init(bytes[header_len..size]); const msg: Msg = switch (t) { @@ -790,8 +444,6 @@ pub fn decode(bytes: []const u8) Error!Decoded { .newfid = try r.getU32(), .nwname = try r.getU16(), } }; - // Checked before the loop, so a hostile 65535 never reaches the - // array. `convM2S.c:170-171` does the same and for the same reason. if (m.twalk.nwname > max_welem) return error.Overlong; for (m.twalk.wname[0..m.twalk.nwname]) |*name| name.* = try r.getString(); break :blk m; @@ -834,10 +486,6 @@ pub fn decode(bytes: []const u8) Error!Decoded { .stat = try Stat.decode(try r.getBlob16()), } }, .rwstat => .rwstat, - // «Terror = 106, /* illegal */». A peer that sent one is not speaking - // 9P2000, and every other unlisted byte is a `.u`/`.L` message or - // noise. Both are refused here, which is also why `Type` is - // non-exhaustive: this switch is reachable with any byte. .terror, _ => return error.BadTag, }; @@ -845,15 +493,6 @@ pub fn decode(bytes: []const u8) Error!Decoded { return .{ .tag = tag, .msg = msg }; } -// --------------------------------------------------------------------------- -// primitives -// --------------------------------------------------------------------------- -// -// Explicit widths, little-endian, one field at a time. `GBIT*`/`PBIT*` in -// `fcall.h:48-58` are the reference, and they are byte-at-a-time shifts for -// exactly the reason this file does not blit a struct: the sender's word order -// and padding are not the protocol. - const Writer = struct { buf: []u8, n: usize = 0, @@ -862,8 +501,6 @@ const Writer = struct { return .{ .buf = buf }; } - /// `n <= buf.len` is the invariant every putter preserves, which is what - /// makes the subtraction safe. fn room(w: *Writer, k: usize) Error![]u8 { if (w.buf.len - w.n < k) return error.NoSpace; defer w.n += k; @@ -890,10 +527,6 @@ const Writer = struct { @memcpy(try w.room(v.len), v); } - /// `n[2]` then the bytes, NOT NUL-terminated — `pstring`, `convS2M.c:4-16`. - /// The length was already refused by `stringLen` before anything was - /// written, so this asserts rather than erroring: reaching it with a longer - /// string means `totalLen` and this switch disagree. fn putString(w: *Writer, v: []const u8) Error!void { assert(v.len <= std.math.maxInt(u16)); try w.putU16(@intCast(v.len)); @@ -918,8 +551,6 @@ const Reader = struct { return .{ .bytes = bytes }; } - /// The one place this file indexes, and the one place it can refuse to. - /// `i <= bytes.len` always, so the subtraction cannot wrap. fn take(r: *Reader, n: usize) Error![]const u8 { if (r.bytes.len - r.i < n) return error.Truncated; defer r.i += n; @@ -942,25 +573,14 @@ const Reader = struct { return std.mem.readInt(u64, (try r.take(8))[0..8], .little); } - /// `gstring`, `convM2S.c:4-22`, minus the memmove: u9fs shuffles the bytes - /// down over the count to make room for a '\0' because its callers are C - /// string functions. Ours borrow, so the slice IS the string and the buffer - /// is untouched. fn getString(r: *Reader) Error![]const u8 { return r.take(try r.getU16()); } - /// `count[4]` then the bytes: `Rread`'s and `Twrite`'s payload. A count - /// past the message is `Truncated` and not a clamp — Linux clamps here - /// (`protocol.c:386-388`) and then has to catch the lie again in - /// `client.c:1475-1479`. Refusing once is cheaper and says more. fn getData(r: *Reader) Error![]const u8 { return r.take(try r.getU32()); } - /// `count[2]` then the bytes: the OUTER count of an `Rstat`/`Twstat` stat. - /// Slicing exactly here is what lets `Stat.decode` insist that the record's - /// own prefix agrees, which is the whole defence against the double length. fn getBlob16(r: *Reader) Error![]const u8 { return r.take(try r.getU16()); } @@ -974,29 +594,10 @@ const Reader = struct { } }; -// --------------------------------------------------------------------------- -// tests -// --------------------------------------------------------------------------- -// -// Three obligations, and the third is the one that is usually skipped. -// -// 1. every message round-trips to an equal value, because a hand-written -// codec is a codec whose two halves drift; -// 2. every malformed shape is REFUSED and none of them panics, because this -// parser is fed by a socket; -// 3. the bytes are the RIGHT bytes. A round-trip test proves the encoder and -// the decoder agree with each other and nothing about whether they agree -// with plan9port's `9p`, which is who will actually be on the far end. So -// four messages are hand-verified against `u9fs/convS2M.c` as literal -// arrays with the line numbers attached. - const testing = std.testing; fn roundTrip(buf: []u8, tag: u16, msg: Msg) !Msg { const bytes = try encode(msg, tag, buf); - // The framing has to agree with the encoder before anything else is worth - // checking: `size` includes itself, so this is also the regression test for - // the first of the module header's two facts. try testing.expectEqual(bytes.len, frameLen(bytes).?); const got = try decode(bytes); try testing.expectEqual(tag, got.tag); @@ -1019,9 +620,6 @@ fn expectStatEqual(want: Stat, have: Stat) !void { try testing.expectEqualStrings(want.muid, have.muid); } -/// Field by field, because `std.meta.eql` is wrong here twice: a decoded slice -/// points into the wire buffer and never compares equal by pointer, and -/// `Twalk.wname` past `nwname` is scratch the decoder does not invent. fn expectMsgEqual(want: Msg, have: Msg) !void { switch (want) { .tversion => |w| { @@ -1118,16 +716,12 @@ const sample_stat: Stat = .{ }; test "9p: the type numbers and their parity are the protocol's own" { - // Copied from `u9fs/fcall.h:74-105`. Asserted as literals because a - // renumbering here is a codec that talks to nothing, and it must be a diff - // somebody reads rather than a silent change. try testing.expectEqual(@as(u8, 100), @intFromEnum(Type.tversion)); try testing.expectEqual(@as(u8, 106), @intFromEnum(Type.terror)); try testing.expectEqual(@as(u8, 107), @intFromEnum(Type.rerror)); try testing.expectEqual(@as(u8, 126), @intFromEnum(Type.twstat)); try testing.expectEqual(@as(u8, 127), @intFromEnum(Type.rwstat)); - // Twenty-eight numbers, 100..127 inclusive, no gaps and no strays. try testing.expectEqual(@as(usize, 28), std.enums.values(Type).len); for (std.enums.values(Type), 100..) |t, want| try testing.expectEqual(@as(u8, @intCast(want)), @intFromEnum(t)); @@ -1136,7 +730,6 @@ test "9p: the type numbers and their parity are the protocol's own" { try testing.expect(isT(.twstat)); try testing.expect(!isT(.rwstat)); - // `notag` is two bytes wide even though `fcall.h:71` writes `~0U`. try testing.expectEqual(@as(u16, 0xFFFF), notag); try testing.expectEqual(@as(u32, 0xFFFF_FFFF), nofid); try testing.expectEqual(@as(usize, 16), max_welem); @@ -1148,8 +741,6 @@ test "9p: a qid is thirteen bytes" { try testing.expectEqual(qid_len, bytes.len); try testing.expectEqual(@as(usize, 13), bytes.len); try testing.expectEqual(sample_qid, try Qid.decode(bytes)); - // Twelve bytes is not a qid, and a decoder that read one anyway would be - // reading the next field's first byte as the top of `path`. try testing.expectError(error.Truncated, Qid.decode(bytes[0..12])); try testing.expectError(error.NoSpace, sample_qid.encode(buf[0..12])); } @@ -1159,14 +750,11 @@ test "9p: an encoded stat is size() + 2 bytes" { const bytes = try sample_stat.encode(&buf); const n = try sample_stat.size(); try testing.expectEqual(@as(usize, n) + 2, bytes.len); - // `STATFIXLEN - BIT16SZ` plus the four string bodies: 47 + 4 + 6 + 6 + 6. try testing.expectEqual(@as(u16, 69), n); try testing.expectEqual(stat_fixed - 2 + 22, n); - // The prefix on the wire is the count EXCLUDING itself — `convD2M.c:48-51`. try testing.expectEqual(n, std.mem.readInt(u16, bytes[0..2], .little)); try expectStatEqual(sample_stat, try Stat.decode(bytes)); - // Empty strings still cost their counts: 47 and nothing more. const bare: Stat = .{ .type = 0, .dev = 0, @@ -1189,9 +777,6 @@ test "9p: every message round-trips" { _ = try roundTrip(&buf, notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }); _ = try roundTrip(&buf, notag, .{ .rversion = .{ .msize = 8192, .version = "9P2000" } }); - // "unknown" is a SUCCESSFUL Rversion meaning no dialect in common, and the - // codec must carry it like any other string rather than treat it as an - // error path. _ = try roundTrip(&buf, notag, .{ .rversion = .{ .msize = min_msize, .version = "unknown" } }); _ = try roundTrip(&buf, 1, .{ .tauth = .{ .afid = 1, .uname = "goblin", .aname = "" } }); _ = try roundTrip(&buf, 1, .{ .rauth = .{ .aqid = .{ .type = qtauth, .version = 0, .path = 9 } } }); @@ -1208,8 +793,6 @@ test "9p: every message round-trips" { _ = try roundTrip(&buf, 7, .{ .rcreate = .{ .qid = sample_qid, .iounit = 0 } }); _ = try roundTrip(&buf, 8, .{ .tread = .{ .fid = 1, .offset = 0xdead_beef_cafe, .count = 4096 } }); _ = try roundTrip(&buf, 8, .{ .rread = .{ .data = "hello" } }); - // A zero-byte Rread is end of file and not an error, which is exactly what - // docs/9p.typ promises a pty reader on exit. _ = try roundTrip(&buf, 8, .{ .rread = .{ .data = "" } }); _ = try roundTrip(&buf, 9, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "Edit ,d" } }); _ = try roundTrip(&buf, 9, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "" } }); @@ -1223,9 +806,6 @@ test "9p: every message round-trips" { _ = try roundTrip(&buf, 13, .{ .twstat = .{ .fid = 1, .stat = sample_stat } }); _ = try roundTrip(&buf, 13, .rwstat); - // Every type that has a message got one. The count is the thirteen pairs - // plus Rerror; `Terror` is illegal and has no variant, which is what the - // arithmetic below is really asserting. try testing.expectEqual(@as(usize, 27), @typeInfo(Msg).@"union".fields.len); try testing.expectEqual(std.enums.values(Type).len - 1, @typeInfo(Msg).@"union".fields.len); } @@ -1233,12 +813,10 @@ test "9p: every message round-trips" { test "9p: empty and maximum-length strings survive the trip" { var buf: [70_000]u8 = undefined; - // Empty is not absent: the count is still two bytes. const empty = try roundTrip(&buf, 1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "", .aname = "" } }); try testing.expectEqual(@as(usize, 0), empty.tattach.uname.len); try testing.expectEqual(@as(usize, header_len + 4 + 4 + 2 + 2), (try encode(empty, 1, &buf)).len); - // The largest string a `n[2]` count can describe, and the one past it. var big: [65_536]u8 = undefined; @memset(&big, 'x'); const max = big[0..std.math.maxInt(u16)]; @@ -1246,8 +824,6 @@ test "9p: empty and maximum-length strings survive the trip" { try testing.expectEqual(@as(usize, 65_535), got.rerror.ename.len); try testing.expectError(error.Overlong, encode(.{ .rerror = .{ .ename = &big } }, 1, &buf)); - // ...and a stat whose strings overflow its own two-byte prefix. Refused by - // `size()`, which is the only place that arithmetic happens. var wide = sample_stat; wide.name = max; try testing.expectError(error.Overlong, wide.size()); @@ -1257,7 +833,6 @@ test "9p: empty and maximum-length strings survive the trip" { test "9p: Twalk carries 0, 1 and 16 elements and refuses 17" { var buf: [512]u8 = undefined; - // Zero elements is a legal walk and means "clone the fid". const zero = try roundTrip(&buf, 1, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 0 } }); try testing.expectEqual(@as(u16, 0), zero.twalk.nwname); try testing.expectEqual(@as(usize, header_len + 4 + 4 + 2), (try encode(zero, 1, &buf)).len); @@ -1269,17 +844,12 @@ test "9p: Twalk carries 0, 1 and 16 elements and refuses 17" { .wname = .{"body"} ++ @as([max_welem - 1][]const u8, @splat("")), } }); - // MAXWELEM exactly, all distinct so a swapped index cannot pass. const names: [max_welem][]const u8 = .{ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p" }; const full = try roundTrip(&buf, 1, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = max_welem, .wname = names } }); try testing.expectEqual(@as(u16, 16), full.twalk.nwname); for (names, full.twalk.wname[0..max_welem]) |a, b| try testing.expectEqualStrings(a, b); - // Rwalk's bound is the same and its own. _ = try roundTrip(&buf, 1, .{ .rwalk = .{ .nwqid = max_welem, .wqid = @splat(sample_qid) } }); - // Seventeen. Hand-built, because the encoder's array cannot hold one — the - // point is that a PEER can send it and must be refused before the count - // reaches an array of sixteen. var raw: [256]u8 = undefined; const bad = blk: { var w: Writer = .init(&raw); @@ -1296,7 +866,6 @@ test "9p: Twalk carries 0, 1 and 16 elements and refuses 17" { try testing.expectEqual(@as(usize, header_len + 4 + 4 + 2 + 17 * 3), bad.len); try testing.expectError(error.Overlong, decode(bad)); - // Same for Rwalk: seventeen qids is 221 bytes of legal-looking message. const bad_r = blk: { var w: Writer = .init(&raw); try w.putU32(0); @@ -1313,9 +882,6 @@ test "9p: Twalk carries 0, 1 and 16 elements and refuses 17" { test "9p: the stat double length" { var buf: [512]u8 = undefined; - // THE fact. Rstat is `count[2]` then a record that begins with its own - // `size[2]`, and the outer number is the inner one plus two — - // `linux/net/9p/client.c:1633` reads it as "wS" and drops the first w. var good: [512]u8 = undefined; const n = blk: { const bytes = try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, &buf); @@ -1327,29 +893,20 @@ test "9p: the stat double length" { try testing.expectEqual(inner, std.mem.readInt(u16, good[header_len + 2 ..][0..2], .little)); try testing.expectEqual(header_len + 2 + @as(usize, inner) + 2, n); - // Twstat wraps the same pair behind a fid — `client.c:1776`, "dwS". const w_bytes = try encode(.{ .twstat = .{ .fid = 7, .stat = sample_stat } }, 1, &buf); try testing.expectEqual(inner + 2, std.mem.readInt(u16, w_bytes[header_len + 4 ..][0..2], .little)); try testing.expectEqual(inner, std.mem.readInt(u16, w_bytes[header_len + 6 ..][0..2], .little)); - // Now three ways to get it wrong, which is the whole reason `Stat.decode` - // is handed an exact slice instead of a cursor. Each is two bytes of edit - // on a message that is otherwise perfect, and each is refused. var off: [512]u8 = undefined; - // THE CLASSIC: the outer count written without the +2, so the record's own - // prefix then claims two bytes more than the outer count allowed. @memcpy(off[0..n], good[0..n]); std.mem.writeInt(u16, off[header_len..][0..2], inner, .little); try testing.expectError(error.Truncated, decode(off[0..n])); - // The outer count too large, which is the same mistake made twice. @memcpy(off[0..n], good[0..n]); std.mem.writeInt(u16, off[header_len..][0..2], inner + 4, .little); try testing.expectError(error.Truncated, decode(off[0..n])); - // The INNER count wrong instead, in both directions: a record that claims - // more than the outer count fits, and one that leaves bytes over inside it. @memcpy(off[0..n], good[0..n]); std.mem.writeInt(u16, off[header_len + 2 ..][0..2], inner + 2, .little); try testing.expectError(error.Truncated, decode(off[0..n])); @@ -1359,14 +916,6 @@ test "9p: the stat double length" { try testing.expectError(error.Trailing, decode(off[0..n])); } -/// Every prefix of a complete message must be refused, in both of the two -/// shapes a short message arrives in: -/// * off a socket, where `size` still claims the whole thing and the header -/// check catches it; -/// * as a message that LIES about being complete, where `size` agrees with -/// the buffer and only the per-field walk can catch it. This is the one -/// that exercises every field boundary, and the one an attacker sends. -/// Neither may panic and neither may parse. fn expectTruncatedAtEveryBoundary(full: []const u8) !void { var scratch: [1024]u8 = undefined; var n: usize = 0; @@ -1377,46 +926,35 @@ fn expectTruncatedAtEveryBoundary(full: []const u8) !void { std.mem.writeInt(u32, scratch[0..4], @intCast(n), .little); try testing.expectError(error.Truncated, decode(scratch[0..n])); } - // The complete message, by contrast, is fine — otherwise the loop above - // would pass for a message that never decodes at all. _ = try decode(full); } test "9p: truncation at every field boundary is refused" { var buf: [512]u8 = undefined; - // Tversion: size, type, tag, msize, a count, a string. try expectTruncatedAtEveryBoundary(try encode( .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }, notag, &buf, )); - // Twalk: two fids, a count, and then a loop of counted strings, which is - // the only variable-arity field in the protocol. try expectTruncatedAtEveryBoundary(try encode(.{ .twalk = .{ .fid = 1, .newfid = 2, .nwname = 3, .wname = .{ "usr", "", "bin" } ++ @as([max_welem - 3][]const u8, @splat("")), } }, 1, &buf)); - // Tread: the widest fixed body, and the one whose 8-byte offset a - // native-struct blit would misalign. try expectTruncatedAtEveryBoundary(try encode( .{ .tread = .{ .fid = 1, .offset = 0x0102_0304_0506_0708, .count = 8168 } }, 1, &buf, )); - // Rstat: both lengths, and every field of the record behind them. try expectTruncatedAtEveryBoundary(try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, &buf)); - // Rread, whose count is a u32 and whose payload is the message's tail. try expectTruncatedAtEveryBoundary(try encode(.{ .rread = .{ .data = "12345678" } }, 1, &buf)); - // Rwalk, the other variable-arity body. try expectTruncatedAtEveryBoundary(try encode( .{ .rwalk = .{ .nwqid = 3, .wqid = @splat(sample_qid) } }, 1, &buf, )); - // Twstat: a fid in front of the double length. try expectTruncatedAtEveryBoundary(try encode(.{ .twstat = .{ .fid = 1, .stat = sample_stat } }, 1, &buf)); } @@ -1428,21 +966,14 @@ test "9p: a size field that disagrees with the buffer is refused" { var raw: [64]u8 = undefined; @memcpy(raw[0..bytes.len], bytes); - // Larger than the buffer: not all of it has arrived. Every value up to a - // hostile 4 GiB claim, which must not be believed for one instruction. for ([_]u32{ 12, 13, 64, 1 << 20, std.math.maxInt(u32) }) |claim| { std.mem.writeInt(u32, raw[0..4], claim, .little); try testing.expectError(error.Truncated, decode(raw[0..bytes.len])); } - // Smaller than the buffer: two messages handed over as one. The caller's - // framing is wrong, and silently decoding the first would hide it. std.mem.writeInt(u32, raw[0..4], 10, .little); try testing.expectError(error.Trailing, decode(raw[0..bytes.len])); - // Smaller than a header at all: a number no encoder produced. Refused - // before it is compared against the buffer, or a size of 3 on a 3-byte - // buffer would slice a header out of nothing. for ([_]u32{ 0, 1, 6 }) |claim| { std.mem.writeInt(u32, raw[0..4], claim, .little); try testing.expectError(error.BadValue, decode(raw[0..bytes.len])); @@ -1456,35 +987,22 @@ test "9p: an unknown or illegal type byte is refused" { var raw: [64]u8 = undefined; @memcpy(raw[0..bytes.len], bytes); - // 106 is `Terror`, defined and illegal. 8 is 9P2000.L's `Tstatfs`, 12 is - // its `Tlopen`: dialects we do not serve, arriving as bytes we must refuse - // rather than `@enumFromInt` into an exhaustive enum. for ([_]u8{ 0, 1, 8, 12, 99, 106, 128, 255 }) |t| { raw[4] = t; try testing.expectError(error.BadTag, decode(raw[0..bytes.len])); } - // ...and the whole byte space, because the guarantee is total: a byte is - // either a type we decode into a message of exactly that type, or an - // error. Never a panic, and never a message of some OTHER type. var t: u16 = 0; while (t <= 255) : (t += 1) { raw[4] = @intCast(t); const defined = t >= 100 and t <= 127 and t != @intFromEnum(Type.terror); if (decode(raw[0..bytes.len])) |got| { try testing.expectEqual(@as(u8, @intCast(t)), @intFromEnum(got.msg.msgType())); - // Exactly four types have a four-byte body: `fid[4]` for the three - // T-messages and `count[4]` for Rwrite. Nothing else may decode - // out of these bytes, and a fifth name here would mean a layout - // above is wrong. try testing.expect(t == @intFromEnum(Type.tclunk) or t == @intFromEnum(Type.tremove) or t == @intFromEnum(Type.tstat) or t == @intFromEnum(Type.rwrite)); } else |err| { - // An undefined byte, or the illegal 106, is ALWAYS BadTag: it must - // never be diagnosed as a short body, because "read more" is the - // wrong advice for a peer speaking another dialect. if (!defined) try testing.expectEqual(Error.BadTag, err); } } @@ -1493,9 +1011,6 @@ test "9p: an unknown or illegal type byte is refused" { test "9p: trailing bytes inside the size are refused" { var raw: [64]u8 = undefined; - // A Tclunk whose `size` says twelve and whose body is five bytes: the fid - // decodes, and one byte is left over. `convM2S.c:377-381` refuses the same - // shape with `if(ap+size == p) return size; return 0;`. var w: Writer = .init(&raw); try w.putU32(12); try w.putByte(@intFromEnum(Type.tclunk)); @@ -1505,8 +1020,6 @@ test "9p: trailing bytes inside the size are refused" { try testing.expectEqual(@as(usize, 12), w.n); try testing.expectError(error.Trailing, decode(raw[0..12])); - // Same for a body with room for a second copy of itself, which is how a - // 9P2000.u message with an extra field would arrive. w = .init(&raw); try w.putU32(header_len + 2 + 2); try w.putByte(@intFromEnum(Type.tflush)); @@ -1521,14 +1034,10 @@ test "9p: frameLen needs four bytes" { const bytes = try encode(.{ .tread = .{ .fid = 1, .offset = 0, .count = 8168 } }, 1, &buf); try testing.expectEqual(@as(usize, 23), bytes.len); - // Zero through three: the reader has nothing to act on but "read more". for (0..4) |n| try testing.expectEqual(@as(?u32, null), frameLen(bytes[0..n])); - // Four is enough, and the answer is the whole message including the four. try testing.expectEqual(@as(?u32, 23), frameLen(bytes[0..4])); try testing.expectEqual(@as(?u32, 23), frameLen(bytes)); - // Unvalidated on purpose: the type byte may not have arrived yet, so there - // is nothing to check the claim against. A caller compares it to its msize. var raw: [4]u8 = .{ 0xFF, 0xFF, 0xFF, 0xFF }; try testing.expectEqual(@as(?u32, std.math.maxInt(u32)), frameLen(&raw)); raw = .{ 0, 0, 0, 0 }; @@ -1539,15 +1048,10 @@ test "9p: encode refuses a short buffer and writes nothing" { var buf: [512]u8 = undefined; const want = (try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, &buf)).len; - // Every buffer from empty to one byte short, because the interesting one is - // not always the last: the message is sized before a byte is written, so - // all of them must leave the buffer untouched. var n: usize = 0; while (n < want) : (n += 1) { var scratch: [512]u8 = @splat(0xAA); try testing.expectError(error.NoSpace, encode(.{ .rstat = .{ .stat = sample_stat } }, 1, scratch[0..n])); - // NOTHING written, not even the size prefix — including past the end of - // the slice it was given, which is the byte a length bug would reach. for (scratch) |b| try testing.expectEqual(@as(u8, 0xAA), b); } @@ -1559,15 +1063,6 @@ test "9p: encode refuses a short buffer and writes nothing" { test "9p: byte for byte against u9fs convS2M" { var buf: [512]u8 = undefined; - // A round-trip test proves the two halves of THIS file agree. These four - // prove they agree with the reference implementation, which is what - // plan9port's `9p`, Plan 9's mount driver and Linux's v9fs are all - // compatible with. Each array was written out by hand from `convS2M.c` and - // the line is named. - - // Tversion, `convS2M.c:236-240` with the header at :224-229. - // size[4]=19 type[1]=100 tag[2]=NOTAG msize[4]=8192 version[2+6] - // 19, not 12: `size` counts itself and the type and the tag. try testing.expectEqualSlices(u8, &.{ 0x13, 0x00, 0x00, 0x00, // size = 19 0x64, // Tversion = 100 @@ -1579,8 +1074,6 @@ test "9p: byte for byte against u9fs convS2M" { '0', '0', }, try encode(.{ .tversion = .{ .msize = 8192, .version = "9P2000" } }, notag, &buf)); - // Twalk, `convS2M.c:272-283`: fid, newfid, nwname, then `pstring` each, - // and `pstring` (:4-16) writes `n[2]` with NO terminator. try testing.expectEqualSlices(u8, &.{ 0x1b, 0x00, 0x00, 0x00, // size = 27 0x6e, // Twalk = 110 @@ -1600,8 +1093,6 @@ test "9p: byte for byte against u9fs convS2M" { .wname = .{ "usr", "bin" } ++ @as([max_welem - 2][]const u8, @splat("")), } }, 1, &buf)); - // Rread, `convS2M.c:392-397`: count[4] then the bytes. The 11-byte header - // this implies is where `msize - 11` comes from (docs/registry.typ). try testing.expectEqualSlices(u8, &.{ 0x0e, 0x00, 0x00, 0x00, // size = 14 0x75, // Rread = 117 @@ -1610,9 +1101,6 @@ test "9p: byte for byte against u9fs convS2M" { 'a', 'b', 'c', }, try encode(.{ .rread = .{ .data = "abc" } }, 9, &buf)); - // Rstat, `convS2M.c:410-415` (`PBIT16(p, f->nstat)` then the blob) around - // `convD2M.c:50-83` (the record, whose own prefix is `ss - BIT16SZ`). THE - // double length, in bytes: 53 outside, 51 inside, 55 of body, 62 total. const one: Stat = .{ .type = 0, .dev = 0, @@ -1648,9 +1136,6 @@ test "9p: byte for byte against u9fs convS2M" { 0x01, 0x00, 'm', // muid }, try encode(.{ .rstat = .{ .stat = one } }, 7, &buf)); - // The high five mode bits ARE the qid type bits, shifted down 24 - // (`protocol.rs:486-495`). Asserted here rather than implemented, because - // this codec carries both fields and the server half sets them. try testing.expectEqual(qtdir, @as(u8, @intCast(dmdir >> 24))); try testing.expectEqual(qtappend, @as(u8, @intCast(dmappend >> 24))); try testing.expectEqual(qtexcl, @as(u8, @intCast(dmexcl >> 24))); @@ -1659,135 +1144,25 @@ test "9p: byte for byte against u9fs convS2M" { try testing.expectEqual(@as(u32, 0o777), dmperm); } -// =========================================================================== -// THE SERVER HALF -// =========================================================================== -// -// Everything above is the wire. Everything below turns a stream of those -// messages into `acmefs.Req` and back. -// -// IT IS A SANS-IO STATE MACHINE and it never touches a descriptor: the caller -// pushes bytes in with `push`, pumps requests through the core with -// `retry`/`next`/`reply`, and takes bytes out with `output`/`wrote`. There is -// no socket here, no poll, no thread and no allocator, which is the whole -// point — the same code serves a unix socket on Linux, a TCP connection from -// another machine, and the board's UART, and the transport-specific part is -// two syscalls in the caller. -// -// WHAT IT IS NOT. It is not a filesystem: every question about what a file -// MEANS belongs to `acmefs.zig`, and this half knows only that a node id is a -// u64, that some nodes are directories, and that a reply may say "ask me -// later". There are exactly two exceptions, both named and both forced by the -// absence of a kernel: the root's node id, which arrives as a parameter to -// `init`, and `parentOf`, which is where `..` goes. -// -// Verified against `u9fs/u9fs.c` (the tree and the offset rules), -// `linux/net/9p/{client,error}.c` (what a real client does with our answers), -// `principia-softwarica/lib_networking/lib9p/srv.c` (flush ordering) and -// `ad/crates/ninep/src/sansio/server.rs` (the scars in its git history). - -// --------------------------------------------------------------------------- -// the error ABI -// --------------------------------------------------------------------------- -// -// `Rerror` carries a STRING and base 9P2000 has no number beside it, so the -// WORDING IS THIS SERVER'S ERROR ABI. Linux recovers an errno by exact match -// against a fixed table and a miss is not `EIO` but `ESERVERFAULT`, which -// userspace prints as "Unknown error 526" — so every string below is copied -// character for character out of `linux/net/9p/error.c:41-171`, with the errno -// it maps to named beside it. -// -// THIS WAS A CHOICE and `docs/registry.typ` `9P-4` left it open with three -// candidates. This is OPTION A. Option B was to serve 9P2000.u and send the -// number, which also restores `Tstatfs` — the one `acmefs.Op` with no -// base-9P2000 message — and keeps a human-readable string for Plan 9 clients; -// it costs a second dialect inside every parser above, because `.u` changes -// the LAYOUT of `Rerror` and `stat` rather than adding messages. Option C was -// acme's own wording (`Ebadctl`, `Ebadaddr`, `Ebadevent`), which is a table -// miss for every one of them; that is what `ad` shipped, so under -// `mount -t 9p` every error it can produce arrives as 526. -// -// The cost of option A is stated plainly: English kernel strings become this -// project's error ABI, and a script reading `event` sees "Invalid argument" -// where acme would have said something about a read too small. If `.u` is ever -// served this table stays as it is — `.u`'s `Rerror` carries the string too. - -/// EBADF. The fid a message names was never walked to, or has been clunked. -/// u9fs spells it `Ebadfid` (`u9fs.c:119`). pub const e_unknown_fid = "fid unknown or out of range"; -/// EBADF. `Tattach` or `Twalk` named a `newfid` that is already bound. u9fs -/// `Efidactive` (`u9fs.c:124`). pub const e_fid_in_use = "fid already in use"; -/// EBADF. A fid used for something its state does not allow: read on a fid -/// that was never opened, write on one opened `OREAD`, walk from an open one. -/// u9fs `Ebadusefid` (`u9fs.c:121`), whose five uses are ours. pub const e_bad_use = "bad use of fid"; -/// ESPIPE. A directory read at an offset that is neither zero nor exactly -/// where the last one ended. u9fs `Ebadoffset` (`u9fs.c:763`). pub const e_bad_offset = "bad offset in directory read"; -/// EACCES. The permission bits the core reported do not admit this open, or -/// the message asked for something a generated tree cannot do: create, remove, -/// remove-on-close, execute. pub const e_perm = "permission denied"; -/// ENOTDIR. A walk with names from a fid that is not a directory. pub const e_not_dir = "not a directory"; -/// ETXTBSY. A second `Topen` on one fid. The fid IS the open, so there is -/// nothing for the second one to mean. pub const e_already_open = "file already open for I/O"; -/// ENAMETOOLONG. A walk element longer than `name_max`. No name in this tree -/// is, so this is a client asking for something that cannot exist — refused on -/// its length rather than looked up, because the fid has to be able to hold -/// the name it lands on (`Rstat` carries it). pub const e_illegal_name = "illegal name"; -/// ENFILE. The fid table is full. Thirty-two is a lot of scripts. pub const e_too_many_fids = "Too many open files in system"; -/// EPROTO. Not 9P2000 on this connection: an R-message arriving at a server, a -/// type byte no dialect we serve defines, a body that does not parse, a -/// message larger than the negotiated msize, or anything at all before -/// `Tversion`. pub const e_botch = "protocol botch"; -/// EINTR. What a flushed request is answered with, immediately before its -/// `Rflush`. The same answer `fuse.zig:1338` gives a `FUSE_INTERRUPT`. pub const e_interrupted = "Interrupted system call"; -/// EPERM. A `Twstat` carrying a non-zero length. The core honours exactly one -/// field and only the value zero (`acmefs.zig:1096-1104`), and this string -/// says so in a wording Linux already knows. pub const e_trunc_only = "only support truncation to zero length"; -/// EPERM. A `Twstat` that would rename, or otherwise change the shape of a -/// tree that follows the pane list. pub const e_wstat = "wstat prohibited"; -/// EAGAIN. The park table is full, or the core parked a request whose payload -/// is too large to copy into a slot. Both are honest to a client: retry. pub const e_again = "Resource temporarily unavailable"; -/// EINVAL. A read whose count cannot hold the first thing the answer consists -/// of — one directory entry. Refused rather than answered short, because a -/// `Tread` returning zero bytes is END OF DIRECTORY and a client that believes -/// it stops asking. The same rule `acmefs` already applies to an `event` read -/// too small for one record, and `9P-17` records that this is what makes the -/// clamp to `count` safe. pub const e_count_small = "Invalid argument"; -/// ENOENT. `Tattach` named an `aname`. There is one tree here and it has no -/// name; a client that asked for a different one should learn that now rather -/// than be handed this one (docs/9p.typ §12.4: "no `aname`"). pub const e_no_tree = "No such file or directory"; -/// Not in Linux's table, and deliberately: Linux's client never sends `Tauth` -/// at all, and Plan 9's `mount` treats an error here as "no authentication -/// needed" and carries on. So this string is chosen for the human reading a -/// Plan 9 error message rather than for `p9_errstr2errno`. u9fs says the same -/// thing in a string that maps to zero — "not an error" — which is a subtlety -/// we do not need. Real authentication is `Tauth` or a tunnel, and neither is -/// ours (docs/9p.typ §10). pub const e_no_auth = "authentication not required"; -/// EINVAL. `Tversion` offered an msize too small to serve (see `msize_min`). -/// `Rversion` has no way to say this — its `version` field means "no dialect -/// in common", which is a different fact — and `Rerror` is a legal reply to -/// any T-message, so this is the honest channel. pub const e_small_msize = "Invalid argument"; -/// The core's numeric errno as the string Linux turns back into that same -/// number. Every value `acmefs.E` defines is here by name; anything else -/// becomes EIO, because a number we did not choose to emit is a bug in this -/// file and "Input/output error" is the one answer that is never misleading. pub fn errString(errno: u16) []const u8 { return switch (errno) { 1 => "Operation not permitted", // E.PERM, EPERM @@ -1803,258 +1178,52 @@ pub fn errString(errno: u16) []const u8 { }; } -// --------------------------------------------------------------------------- -// open modes -// --------------------------------------------------------------------------- -// -// `Topen.mode`, from `u9fs/plan9.h:146-153`. The codec above carries the byte -// and has no opinion about it; these are what the byte MEANS, which is the -// server's business. - -/// The low two bits, which are a VALUE and not a mask: 0, 1, 2, 3. pub const oread: u8 = 0; pub const owrite: u8 = 1; pub const ordwr: u8 = 2; -/// «execute, == read but check execute permission». Nothing in this tree is a -/// program, so it is refused rather than treated as a read. pub const oexec: u8 = 3; -/// Or'ed in. Truncate first — this is how a shell's `>` reaches a 9P server, -/// and it maps onto `acmefs.Req.truncate` exactly as a `Twstat` with a zero -/// length does (docs/registry.typ `FIX-1`). pub const otrunc: u8 = 16; -/// Or'ed in, close on exec. A CLIENT-SIDE flag: Plan 9's kernel consumes it -/// and never sends it, so a server that sees it may ignore it, and we do. pub const ocexec: u8 = 32; -/// Or'ed in, remove on close. Refused: this tree's shape follows the pane list -/// and there is nothing in it a client may remove. pub const orclose: u8 = 64; -// --------------------------------------------------------------------------- -// sizes -// --------------------------------------------------------------------------- - -/// Fids one connection may hold at once. A FIXED ARRAY and not a map, costed -/// in `docs/registry.typ` `9P-11`: a linear scan is far cheaper than the wire, -/// so the map would buy nothing and cost an allocator this file does not have. -/// -/// 256 AND NOT 32, which is what it was, and the difference is a MOUNT. A -/// script that opens one file at a time never needs more than a handful; a -/// mounting client keeps one fid per cached inode, and this tree is three -/// top-level entries plus fourteen files per pane, so seven panes already pass -/// thirty-two and a full sixteen-pane session wants over two hundred. At the -/// old number `find` over a `9pfuse` mount failed with fifty-seven consecutive -/// `Rerror`s once the table filled — and `9pfuse` is the proof clause -/// `docs/9p.typ` §12.4 sets for this step, so the number was refuting its own -/// acceptance test. -/// -/// A `Fid` is about 64 bytes, so this is ≈16 KiB per connection against the -/// ≈34 KiB `fs9_service.zig` already budgets for one. The BOARD keeps thirty-two -/// by passing its own value: see `board_fids`, and `9P-11`'s RAM line, which is -/// costed for a microcontroller serving its own small tree and nothing else. -/// -/// Overflow is a refusal (`e_too_many_fids`), not a queue. pub const max_fids: usize = 256; -/// What a microcontroller uses instead. Named here rather than spelled at the -/// call site so that the two numbers, and the reason they differ, stay next to -/// each other. pub const board_fids: usize = 32; -/// Requests that may be outstanding at once — in flight, or parked because the -/// core answered `.again`. In practice this counts BLOCKED READERS: one slot -/// per process sitting on `event`. The number is `src/fuse.zig:793`'s, -/// unchanged, because `Status.again` means the same thing to both transports. pub const max_slots: usize = 32; -/// Bytes of request payload a park slot owns. A parked request's `data` cannot -/// go on borrowing the input buffer — the next message overwrites it — so it -/// is copied in when it fits. -/// -/// Smaller than `fuse.zig`'s 512, for a reason specific to this file: under -/// FUSE a LOOKUP name is a payload and may be 255 bytes, while a 9P walk -/// element is consumed inside the walk and never parks. What is left is a -/// write, and the only writes that could conceivably block are a `ctl` verb -/// line and an event write-back, both a few dozen bytes. A larger write that -/// the core tries to park is answered `e_again` — honest, and by construction -/// unreachable, since the core answers writes as transactions. pub const park_data_max: usize = 128; -/// The longest name a fid may land on, and the longest `uname` we keep. -/// -/// A BOUND rather than a buffer size: `Rstat` carries the file's name, so a -/// fid has to hold the name it walked to, and this is the number that makes -/// `msize_min` provable. Every name in the tree fits with room over — the -/// longest are a pane's decimal serial and `errors` — so a walk element longer -/// than this is refused as `e_illegal_name` rather than looked up and then -/// truncated, which would make `Rstat` lie. -pub const name_max: usize = 28; - -/// The smallest msize this server will agree to serve. DERIVED, not chosen: -/// `Rwalk` with the protocol's sixteen qids is the largest reply whose size -/// the client cannot influence after the handshake, so a connection that -/// cannot hold one cannot be served at all. -/// -/// Deliberately NOT `min_msize` (4096), which is the LINUX KERNEL's floor and -/// nobody else's: Plan 9's devmnt, plan9port's `9p` and pardes's own client all -/// accept 512, and the board would rather have the kilobytes back. +pub const board_name_capacity: usize = 28; +const username_capacity: usize = 28; + pub const msize_min: u32 = header_len + 2 + max_welem * qid_len; comptime { assert(msize_min == 217); - // The other two replies whose size the client does not choose: `Rstat` - // carries one record with four strings, and a directory read must fit at - // least one such record or it can never make progress. Both must clear - // `msize_min`, or the floor above is not a floor. - assert(header_len + 2 + stat_fixed + 4 * name_max <= msize_min); - assert(header_len + 4 + stat_fixed + 4 * name_max <= msize_min); - // A pane serial is a u60, so its decimal name is at most twenty digits and - // `parentOf` cannot overflow the buffer it formats into. - assert(name_max >= 20); - // Modes are a value in the low two bits with flags above them. + assert(header_len + 2 + stat_fixed + board_name_capacity + 3 * username_capacity <= msize_min); + assert(header_len + 4 + stat_fixed + board_name_capacity + 3 * username_capacity <= msize_min); + assert(username_capacity >= 20); assert(oread | owrite | ordwr | oexec == 3); assert(otrunc | ocexec | orclose == 112); } -/// The server's name for a node. -/// -/// `qid.version` IS ALWAYS ZERO, and this is a policy rather than a -/// translation. It is the 9P equivalent of the `FOPEN_DIRECT_IO` that -/// `src/fuse.zig:172-176` relies on, and it is server-side rather than advice -/// to whoever mounts: Linux's client sets `P9L_DIRECT` — «no read or write -/// cache» — for any file whose qid version is zero, whatever the cache mode, -/// unless `ignoreqv` is passed explicitly (`linux/fs/9p/fid.h:52-53`, -/// `v9fs.c:93`). A synthetic tree of live editor state has no business being -/// cached: `body` changes under the reader's feet, `event` is a queue, and a -/// cached lookup under `new/` would create one pane and then serve the same -/// answer forever. Plan 9 needs nothing said to it — its cache is opt-in via -/// `mount -c` (docs/registry.typ `9P-3`). -/// -/// `qid.path` is the core's node id UNCHANGED, which is what makes the two -/// transports agree: one integer is a FUSE nodeid, a `d_ino` and a qid path at -/// once, and it never comes to mean a different file because pane serials are -/// never reused (`acmefs.zig:231-237`). fn qidOf(node: u64, dir: bool) Qid { return .{ .type = if (dir) qtdir else qtfile, .version = 0, .path = node }; } -/// Where `..` goes, and the ONE place in this file that decodes a node id. -/// -/// WHY THIS IS HERE AT ALL, because it is the fact that gets lost: under FUSE -/// the kernel resolves `.` and `..` in the pathname before a request is ever -/// sent, which is what lets `acmefs.zig:840` say they «are the kernel's -/// business, never ours». Under 9P THERE IS NO KERNEL. `Twalk` carries `..` as -/// an ordinary name element, and a client that normalises a path, or walks up -/// before walking down, sends it. Forwarding it to the core as a lookup would -/// answer `ENOENT` and break `cd ..`, so the server answers it. -/// -/// It can, without asking anything, because the tree has fixed depth and the -/// node id says where you are. `acmefs.Node` is -/// `packed struct(u64){ file: u4, serial: u60 }` (`acmefs.zig:238-240`), so -/// `file` is the low four bits and `serial` is everything above them, and: -/// -/// * at the root, `..` is the root. POSIX's rule and `intro(5)`'s: the root -/// is its own parent, and this is not an error. -/// * `serial == 0` is a top-level file (`index`, `cons`, `new`), whose -/// parent is the root. -/// * `file == 0` is `PaneFile.dir`, a pane's own directory, whose parent is -/// the root. -/// * anything else is a file inside a pane's directory, and its parent is -/// that directory: the same serial with `file` cleared. Its NAME is the -/// serial in decimal, which is how `acmefs`'s root lists it -/// (`acmefs.zig:992-994`). -/// -/// A well-behaved client only walks between directories, so the last case -/// should never arrive; it is answered correctly rather than trusted away. -/// -/// THE DEPTH ASSUMPTION IS THE WHOLE OF WHAT COULD ROT, and it is checked by -/// the shape of the node id rather than by hope: `Node` has ONE `file: u4`, so -/// a level below a pane's directory — `docs/9p.typ`'s `pty/` — cannot be -/// encoded in a node id at all today. If that changes, this function is the -/// one place that has to learn about it. -fn parentOf(node: u64, root: u64, buf: *[name_max]u8) struct { node: u64, name_len: u8 } { - const serial = node >> 4; - const file = node & 0xF; - if (node == root or serial == 0 or file == 0) { - buf[0] = '/'; - return .{ .node = root, .name_len = 1 }; - } - // A u60 is twenty decimal digits at most and `name_max` is checked against - // that above, so the format cannot fail. - const name = std.fmt.bufPrint(buf, "{d}", .{serial}) catch unreachable; - return .{ .node = serial << 4, .name_len = @intCast(name.len) }; -} - -/// The permission bits a DIRECTORY ENTRY reports, and the only place in this -/// file that reports a mode it was not told. -/// -/// `acmefs`'s staging format for a readdir is `node[8] dir[1] namelen[1] -/// name[]` (`acmefs.zig:942-957`) — the same record the FUSE transport decodes -/// — and it carries no mode and no length, because under FUSE the kernel asks -/// for those separately, with a `getattr` per entry it decides it wants. 9P -/// puts a whole `stat` in a directory read, so the choice is between a -/// `getattr` per entry — an extra round trip each, and a third msize buffer to -/// hold the entries across it — and reporting the tree's own defaults here. -/// -/// We report the defaults. `0o500` is what `TopFile.mode` and `PaneFile.mode` -/// give every directory in the tree without exception; `0o600` is what -/// `PaneFile.mode` gives every file but three; a length of zero is the true -/// length of every file here but `body`, `tag` and `index`. -/// -/// WHO SEES THE DIFFERENCE: only a client that reads permissions and sizes out -/// of a DIRECTORY READ, which is Plan 9's `ls -l` and nothing else. Linux's -/// v9fs takes names and qids from the read and stats each file separately, -/// 9pfuse does the same, and `Tstat` here answers out of the core's own -/// `getattr` — so `ls -l` through either of those is exact. pub const dirent_dir_perm: u16 = 0o500; pub const dirent_file_perm: u16 = 0o600; -/// A 9P2000 server for one connection, over the filesystem ABI `fs`. -/// -/// WHY THIS IS A GENERIC and not a plain struct that imports `acmefs.zig`: -/// this file is freestanding-safe and must stay so — it compiles for -/// `wasm32-freestanding` and the board's `riscv32-freestanding`, and -/// `acmefs.zig` reaches `pardes.zig`, which reaches the build's generated -/// modules. Importing it would also drag every test in that graph into -/// `zig test src/9p.zig`. So the ABI arrives as a type parameter and the -/// coupling is exactly three declarations: -/// -/// * `fs.Req` with `tag, op, node, handle, off, size, data, truncate` -/// * `fs.Reply` with `tag, status, errno, attr, handle, written` -/// * `fs.Reply.Attr` with `node, dir, size, mode` -/// -/// which is `acmefs`'s ABI verbatim, so the real instantiation is -/// `Server(acmefs)` and it needs no translation layer at all. The `Op` and -/// `Status` values are reached as enum literals (`.lookup`, `.again`), so they -/// are checked against the real enums at that instantiation. The tests below -/// instantiate it on a stub filesystem, which is how they run with no core. -/// -/// THE THREE METHODS `src/fs_service.zig`'s `Transport` wants — `retry`, -/// `next` and `reply` — are here with those names and those shapes, and the -/// order contract is that file's: `retry()` to null first, then `next()` to -/// null. `fs_service` is deliberately NOT imported (it is `std.c` and -/// `pardes.zig` deep); the adapter that fills in a vtable is three functions -/// in whoever owns the socket. -/// -/// MEMORY, all of it caller-supplied or fixed: the two buffers, a fid table of -/// `max_fids` and a park table of `max_slots`. No allocator, and nothing here -/// grows. -pub fn Server(comptime fs: type) type { +pub fn Server(comptime fs: type, comptime fid_capacity: usize) type { + if (fid_capacity == 0) @compileError("9P server needs at least one fid"); + const name_capacity = if (@hasDecl(fs, "name_capacity")) fs.name_capacity else board_name_capacity; + if (name_capacity == 0 or name_capacity > 255) @compileError("9P backend name capacity must fit a directory entry"); return struct { const Self = @This(); - /// Bytes the caller has pushed and we have not finished with. - /// `in[0..frame]` is the message being served when `frame != 0`, and - /// every slice a decoded `Msg` holds points into it — which is why - /// nothing compacts this buffer until that message is done with. in: []u8, - /// Encoded replies, oldest first, as a byte FIFO. Every 9P message - /// carries its own length, so the queue needs no side table: the - /// caller writes `output()` and tells us how much went. out: []u8, - /// The node id of the tree's root — `@intFromEnum(acmefs.TopFile.root)` - /// — and the one fact about the tree this file is told rather than - /// deriving. `Tattach` needs somewhere to start and 9P has no way to - /// ask for it. root: u64, in_len: usize = 0, @@ -2062,108 +1231,53 @@ pub fn Server(comptime fs: type) type { out_len: usize = 0, out_off: usize = 0, - /// Negotiated by `Tversion`; ZERO means not yet, and nothing but - /// `Tversion` is served in that state. msize: u32 = 0, - /// The stream is not 9P and there is no resynchronising from it: stop - /// serving and let the caller close. Write-once, like `fuse.Fs.dead`. dead: bool = false, - /// Whoever attached, for `Rstat`'s three name fields. The tree is - /// synthetic and has one owner: the client that opened the connection. - uname: [name_max]u8 = @splat(0), + uname: [username_capacity]u8 = @splat(0), uname_len: u8 = 0, - fids: [max_fids]Fid = @splat(.{}), + fids: [fid_capacity]Fid = @splat(.{}), slots: [max_slots]Slot = @splat(.{}), - /// The message being served. At most one, which is what keeps the - /// walk's accumulated qids and the borrowed names in one place instead - /// of in thirty-two slots. job: Job = .{}, - /// Hands out `fs.Req.tag`s, and orders the park table. Never zero, so - /// that zero can mean "no request outstanding". seq: u64 = 0, - /// What a 9P message is being turned into. The reply's SHAPE, which is - /// what `reply` needs and what `Op` alone does not say: a `getattr` is - /// a step of `Rattach`, of `Rwalk` and of `Rstat`. const Kind = enum { none, attach, walk, open, read, readdir, write, clunk, remove, stat, wstat }; - /// One fid: a name the client gave a place in the tree. - /// - /// `perm` and `dir` are cached from the attributes the walk that landed - /// here already answered, because `Topen` has to check permission - /// itself — there is no kernel above us doing it, and `acmefs.open` - /// deliberately does not (`acmefs.zig:1023-1031`). `name` is cached - /// because `Rstat` carries it and a node id does not. const Fid = struct { used: bool = false, - /// The client's number. `nofid` is never one. fid: u32 = 0, node: u64 = 0, dir: bool = false, - /// Permission bits as the core last reported them, which is what - /// `Topen` is checked against. perm: u16 = 0, open: bool = false, - /// The `Topen` mode, valid when `open`. omode: u8 = 0, - /// `acmefs`'s open handle, repeated on every read, write and - /// release. handle: u32 = 0, - /// THE DIRECTORY CURSOR, in the two coordinate systems it has to - /// live in at once: `diroff` is the BYTE offset 9P requires the - /// next read to carry, and `dirindex` is the ENTRY INDEX `acmefs` - /// counts in (`acmefs.zig:972`, `var skip = req.off;`). diroff: u64 = 0, dirindex: u32 = 0, - /// This fid has no client any more and still owes the core a - /// `release`. See `orphan`. orphan: bool = false, - name: [name_max]u8 = @splat(0), + name: [name_capacity]u8 = @splat(0), name_len: u8 = 0, }; - /// A request the core would not answer yet. Lifted from - /// `src/fuse.zig:806-822` with the FUSE opcode replaced by the 9P tag - /// and the reply shape, because `Status.again` means the same thing to - /// both transports and this is where `docs/registry.typ` `9P-16` says - /// we beat the prior art. const Slot = struct { used: bool = false, - /// The core answered `.again`; `retry()` will offer it back. parked: bool = false, - /// Already offered in this retry round. Reset when a round finds - /// nothing, which gives every parked request exactly one attempt - /// per frame instead of letting the oldest starve the rest. retried: bool = false, - /// `req.data` points into `data` below rather than into `in`. copied: bool = false, - /// Arrival order, so retries are FIFO: the reader that blocked - /// first is offered first. seq: u64 = 0, - /// The client's tag, which is what `Tflush` names. tag: u16 = 0, kind: Kind = .none, - /// The client's fid NUMBER and not an index: the fid may be - /// clunked while this is parked, and a stale index would be a - /// stale pointer. fid: u32 = 0, - /// What the client asked for, which is what the answer is clamped - /// to (`docs/registry.typ` `9P-17`). count: u32 = 0, req: fs.Req = undefined, data: [park_data_max]u8 = undefined, }; - /// The message in flight, and the accumulated answer. const Job = struct { kind: Kind = .none, tag: u16 = 0, - /// The `fs.Req.tag` of the step the core is holding, or zero. req_tag: u64 = 0, - /// The step itself, kept so that a park has something to copy and - /// a retry has something to re-offer. req: fs.Req = undefined, step: u8 = 0, fid: u32 = 0, @@ -2171,54 +1285,37 @@ pub fn Server(comptime fs: type) type { count: u32 = 0, offset: u64 = 0, omode: u8 = 0, - /// Where the walk has got to: the node, its attributes and its - /// name, all of which `Rwalk`'s last qid and the bound fid need. node: u64 = 0, dir: bool = false, perm: u16 = 0, - name: [name_max]u8 = @splat(0), + name: [name_capacity]u8 = @splat(0), name_len: u8 = 0, nwname: u8 = 0, nwqid: u8 = 0, wqid: [max_welem]Qid = @splat(.{ .type = 0, .version = 0, .path = 0 }), - /// The decoded T-message, BORROWING `in[0..frame]`: a walk's names - /// and a write's bytes live here and nowhere else. msg: Msg = .rflush, }; pub const Options = struct { - /// Room for one whole T-message. Caps the msize we will agree to, - /// with `out`. in: []u8, - /// Room for two: one being written out and one being built. That - /// is what lets a reply be encoded the moment the core answers, - /// with no "can I write yet" question anywhere in this file. out: []u8, - /// `@intFromEnum(acmefs.TopFile.root)`. root: u64, }; - /// The buffers are the caller's, which is what "no allocator" means - /// here: the board hands over two static arrays, a desktop host hands - /// over two heap slices sized for a 128 KiB msize, and this file cannot - /// tell the difference. The msize follows from them and from the - /// client's `Tversion`; see `version`. pub fn init(opts: Options) Self { assert(opts.in.len >= msize_min); assert(opts.out.len >= 2 * msize_min); - // Node zero is `acmefs.Node{}` — no file, no pane — and cannot be - // a root. A zero here would make every `..` land on nothing. assert(opts.root != 0); return .{ .in = opts.in, .out = opts.out, .root = opts.root }; } - /// The connection went away. Every open fid still owes the core a - /// `release`, and that debt outlives the connection: an `event` fid - /// dropped without one leaves the pane's reader count high forever, - /// which leaves the editor reporting button actions to a script that - /// is no longer there (`acmefs.zig:1053-1061`). So the fids are - /// ORPHANED rather than forgotten, and the caller keeps pumping - /// `next()` until it answers null. + pub fn references(s: *const Self, node: u64) bool { + for (s.fids) |fid| if (fid.used and fid.node == node) return true; + if (s.job.kind != .none and s.job.node == node) return true; + for (s.slots) |slot| if (slot.used and slot.req.node == node) return true; + return false; + } + pub fn hangup(s: *Self) void { s.reset(); s.dead = true; @@ -2228,12 +1325,6 @@ pub fn Server(comptime fs: type) type { s.out_off = 0; } - /// What `Tversion` does to the connection, and what `hangup` does - /// first: «all fids are clunked and any outstanding I/O is abandoned» - /// (`version(5)`). The parked requests go without an answer, which is - /// exactly what abandoned means; the fids that are open become - /// orphans, because the core's side of an open is not the client's to - /// abandon. fn reset(s: *Self) void { for (&s.fids) |*f| { if (!f.used) continue; @@ -2243,13 +1334,6 @@ pub fn Server(comptime fs: type) type { s.job = .{}; } - // -- bytes in, bytes out --------------------------------------------- - - /// Take as much of `bytes` as there is room for, and answer how much. - /// A short answer is not an error and not a loss: it is the only - /// back-pressure a sans-io server has, and the caller re-offers the - /// tail after pumping. Bytes are APPENDED, so a message already being - /// served does not move. pub fn push(s: *Self, bytes: []const u8) usize { if (s.dead) return 0; const n = @min(bytes.len, s.in.len - s.in_len); @@ -2258,14 +1342,10 @@ pub fn Server(comptime fs: type) type { return n; } - /// The replies waiting to go, oldest first, as one contiguous run of - /// whole 9P messages. Valid until the next call to anything else here. pub fn output(s: *const Self) []const u8 { return s.out[s.out_off..s.out_len]; } - /// How many of `output()`'s bytes actually left. A partial write is - /// normal on a UART and on a full socket, and the remainder stays put. pub fn wrote(s: *Self, n: usize) void { assert(n <= s.out_len - s.out_off); s.out_off += n; @@ -2275,9 +1355,6 @@ pub fn Server(comptime fs: type) type { } } - /// Slide the unwritten tail down. Called only when room is wanted, so - /// the common case — a fully written queue, reset to empty by `wrote` — - /// never moves a byte. fn compact(s: *Self) void { assert(s.out_off <= s.out_len); const n = s.out_len - s.out_off; @@ -2286,22 +1363,11 @@ pub fn Server(comptime fs: type) type { s.out_len = n; } - /// THE RESERVATION RULE, and the reason no reply in this file can ever - /// fail to be written: a request is not handed to the core unless the - /// out queue already has room for the largest answer it could produce, - /// which is one msize. So `emit` cannot run out, a parked read that - /// completes cannot be dropped, and back-pressure lands where it can - /// be dealt with — `next()` and `retry()` answer null, the caller - /// writes some bytes, and the pump continues. fn hasRoom(s: *Self) bool { if (s.out_off != 0) s.compact(); return s.out.len - s.out_len >= @max(s.msize, msize_min); } - /// Queue one reply. Infallible by the reservation rule above; if it - /// ever is not, the connection dies rather than the stream growing a - /// half-written message — a dropped reply hangs a client forever, - /// while a closed connection makes it fail and say so. fn emit(s: *Self, tag: u16, msg: Msg) void { const bytes = encode(msg, tag, s.out[s.out_len..]) catch { s.dead = true; @@ -2315,9 +1381,6 @@ pub fn Server(comptime fs: type) type { s.emit(tag, .{ .rerror = .{ .ename = ename } }); } - /// The next `fs.Req.tag`. Unique for the life of the connection, which - /// is what lets `reply` find its target with no cooperation from the - /// core, and never zero. fn tick(s: *Self) u64 { s.seq += 1; return s.seq; @@ -2347,27 +1410,17 @@ pub fn Server(comptime fs: type) type { return null; } - /// A parked request by the tag the CLIENT gave it, which is what - /// `Tflush` names. fn findTag(s: *Self, tag: u16) ?usize { for (&s.slots, 0..) |*sl, i| if (sl.used and sl.tag == tag) return i; return null; } fn setUname(s: *Self, uname: []const u8) void { - const n = @min(uname.len, name_max); + const n = @min(uname.len, username_capacity); @memcpy(s.uname[0..n], uname[0..n]); s.uname_len = @intCast(n); } - // -- the transport seam ---------------------------------------------- - - /// Offer parked requests back, one per call, in arrival order. Call in - /// a loop until null, once per frame, BEFORE `next()`: the null both - /// ends the round and resets it, so every parked request gets exactly - /// one attempt per frame and a permanently blocked reader cannot - /// starve the others. `src/fs_service.zig:196-208` is the contract and - /// `src/fuse.zig:1166` is the other implementation of it. pub fn retry(s: *Self) ?fs.Req { var best: ?usize = null; for (&s.slots, 0..) |*sl, i| { @@ -2378,40 +1431,20 @@ pub fn Server(comptime fs: type) type { for (&s.slots) |*sl| sl.retried = false; return null; }; - // No room for the answer is the end of the round too, and it must - // reset it: leaving the flags set would make the next frame skip - // the requests this one never reached. if (!s.hasRoom()) { for (&s.slots) |*sl| sl.retried = false; return null; } s.slots[i].retried = true; - // In flight again: `reply` re-parks it if the core still has - // nothing to say. s.slots[i].parked = false; return s.slots[i].req; } - /// The next request off the wire, or null when there is nothing more to - /// do with the bytes pushed so far. Call in a loop until null. - /// - /// ONE 9P MESSAGE IS NOT ONE REQUEST, which is the whole reason this is - /// a state machine: a three-element `Twalk` is three lookups, a - /// `Topen` with `OTRUNC` is a truncate and then an open, and a - /// `Tversion` is none at all. So this pump decodes a message when it - /// needs one, hands out its steps as the core answers them, and - /// answers null only when the input is exhausted, the queue is full, or - /// the core is holding a step. pub fn next(s: *Self) ?fs.Req { while (true) { if (s.job.kind != .none) { - // A step is out with the core; the caller owes us a - // `reply` before there is anything else to ask. if (s.job.req_tag != 0) return null; if (s.stepJob()) |req| return req; - // The job answered itself — a walk that finished, an error - // — and `stepJob` cleared it. Round again for the next - // message. assert(s.job.kind == .none); continue; } @@ -2421,28 +1454,11 @@ pub fn Server(comptime fs: type) type { } } - /// Answer one request: queue the 9P reply it completes, advance the - /// message it is a step of, or park it. `bytes` is the payload the - /// core resolved and is borrowed for the duration of this call only — - /// the same rule `src/fs_service.zig:224-229` states for the FUSE - /// transport. pub fn reply(s: *Self, r: *const fs.Reply, bytes: []const u8) void { if (s.job.kind != .none and s.job.req_tag == r.tag) return s.jobReply(r, bytes); if (s.findSlot(r.tag)) |i| return s.slotReply(i, r, bytes); - // An orphan's release, a park `Tversion` abandoned, or a request - // `Tflush` already answered. Nothing to say and nobody to say it - // to; `fuse.zig:1189` drops the same case for the same reason. } - /// A `release` nobody is waiting for: the fid it belonged to is gone - /// (the connection dropped, or `Tversion` reset it) but the core's - /// open is not. - /// - /// The slot is freed HERE rather than when the answer lands, because - /// nothing in the answer is wanted and `reply` already ignores a tag it - /// no longer holds. That also means a release the core parks is - /// dropped, which is the same trade `fuse.zig` makes for a write: a - /// release is a transaction in this design and does not block. fn orphan(s: *Self) ?fs.Req { for (&s.fids) |*f| { if (!f.used or !f.orphan) continue; @@ -2459,23 +1475,11 @@ pub fn Server(comptime fs: type) type { return null; } - /// Decode the message at the head of `in` and start serving it. False - /// when there is not a whole one there yet. - /// - /// The frame stays in `in` for as long as the message is being served, - /// because every string in a decoded `Msg` points into it. The `defer` - /// is what makes that airtight: a message that answered itself here - /// releases the frame immediately, and one that became a job hands the - /// frame to the job, which releases it in `finishJob` or copies what it - /// needs in `parkJob`. fn startFrame(s: *Self) bool { assert(s.job.kind == .none); assert(s.frame == 0); if (s.dead) return false; const len = frameLen(s.in[0..s.in_len]) orelse return false; - // A `size` no encoder produced, or one this connection could never - // buffer: either way the stream is not 9P and waiting for more of - // it is waiting forever. if (len < header_len or len > s.in.len) { s.dead = true; return false; @@ -2485,22 +1489,13 @@ pub fn Server(comptime fs: type) type { defer if (s.job.kind == .none) s.dropFrame(); const got = decode(s.in[0..len]) catch { - // The tag sits at a fixed offset and survives every way the - // body can be wrong, so the client still gets an answer rather - // than a hang. `len >= header_len` was checked above. s.fail(std.mem.readInt(u16, s.in[5..7], .little), e_botch); return true; }; - // A server reads T-messages. An R-message here is a client on the - // wrong end of the connection, or the double-role link - // docs/9p.typ §7 tells us not to build. if (!isT(got.msg.msgType())) { s.fail(got.tag, e_botch); return true; } - // «The client must communicate the version before any other - // messages» — and until it has, there is no msize to bound - // anything by. if (s.msize == 0 and got.msg != .tversion) { s.fail(got.tag, e_botch); return true; @@ -2513,10 +1508,6 @@ pub fn Server(comptime fs: type) type { return true; } - /// Release the served frame and slide the rest of the input down. The - /// move is one message long and happens once per message; the - /// alternative is a ring buffer, which would mean a decoded `Msg` - /// could straddle the wrap and no longer be one slice. fn dropFrame(s: *Self) void { assert(s.frame != 0); assert(s.frame <= s.in_len); @@ -2526,27 +1517,9 @@ pub fn Server(comptime fs: type) type { s.frame = 0; } - // -- the messages ---------------------------------------------------- - - /// One T-message onto its handler. Every message either answers itself - /// here or becomes `job`. fn dispatch(s: *Self, got: Decoded) void { switch (got.msg) { .tversion => |m| s.version(got.tag, m.msize, m.version), - // REFUSED, all three, and each for its own reason. - // - // `Tauth`: there is no authentication here and there is not - // going to be one in this file. The socket's permissions are - // the protection and a network is tunnelled (docs/9p.typ §10). - // - // `Tcreate` and `Tremove`: the shape of this tree follows the - // pane list, so there is nothing in it for a client to make or - // unmake. The one place a client DOES create something is - // `new/`, where walking to a name is what creates a pane - // (`acmefs.zig:901-919`) — so the capability is there and it - // is not spelled `Tcreate`. That answers the open question in - // `docs/registry.typ` `9P-18`, and it takes most of `ad`'s - // shipped-and-fixed bug list off the table with it. .tauth => s.fail(got.tag, e_no_auth), .tcreate => s.fail(got.tag, e_perm), .tattach => |m| s.attach(got.tag, m.fid, m.uname, m.aname), @@ -2556,80 +1529,46 @@ pub fn Server(comptime fs: type) type { .tread => |m| s.read(got.tag, m.fid, m.offset, m.count), .twrite => |m| s.write(got, m.fid, m.offset, m.data.len), .tclunk => |m| s.clunk(got.tag, m.fid, .clunk), - // A remove clunks the fid too — see `clunk` — which is the - // half of `remove(5)` that is easy to miss. .tremove => |m| s.clunk(got.tag, m.fid, .remove), .tstat => |m| s.stat(got.tag, m.fid), .twstat => |m| s.wstat(got.tag, m.fid, m.stat), - // The R-variants, which `startFrame` already refused by - // parity. Answered rather than `unreachable`, because the cost - // of being wrong about that is a panic in a server. else => s.fail(got.tag, e_botch), } } - /// `Tversion`: the msize handshake, and a connection reset. fn version(s: *Self, tag: u16, want: u32, ver: []const u8) void { - // Three ceilings and the smallest wins: what the client will - // accept, what one input buffer holds, and half of what the output - // queue holds (`Options.out`). const cap: u32 = @intCast(@min(s.in.len, s.out.len / 2)); const m = @min(want, cap); if (m < msize_min) return s.fail(tag, e_small_msize); - // u9fs `rversion`: any version string that STARTS with "9P" is - // answered "9P2000", which is how a `.u` or `.L` client is told to - // fall back to the base protocol. Anything else has no dialect in - // common with us, and that is a SUCCESSFUL `Rversion` carrying the - // literal "unknown" rather than an `Rerror`. const known = std.mem.startsWith(u8, ver, "9P"); s.reset(); - // The msize only becomes real once a version is agreed: after - // "unknown" the client must negotiate again, and `startFrame` - // serves nothing else until it does. s.msize = if (known) m else 0; s.emit(tag, .{ .rversion = .{ .msize = m, .version = if (known) "9P2000" else "unknown" } }); } fn attach(s: *Self, tag: u16, fid: u32, uname: []const u8, aname: []const u8) void { - // No `aname`. There is one tree here and it has no name; a client - // that asked for another one is told so rather than handed this. if (aname.len != 0) return s.fail(tag, e_no_tree); if (fid == nofid) return s.fail(tag, e_unknown_fid); if (s.findFid(fid) != null) return s.fail(tag, e_fid_in_use); if (s.freeFid() == null) return s.fail(tag, e_too_many_fids); s.setUname(uname); - // The root's attributes come from the core like every other node's. - // Its node id is the only thing we were told (see `root`). s.job = .{ .kind = .attach, .tag = tag, .fid = fid, .node = s.root }; } fn walk(s: *Self, got: Decoded, fid: u32, newfid: u32, nwname: u8) void { const tag = got.tag; const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid); - // «must not have been opened for I/O» — walk(5). The fid IS the - // open, so a walk would move the file out from under it. if (s.fids[i].open) return s.fail(tag, e_bad_use); if (newfid == nofid) return s.fail(tag, e_unknown_fid); if (newfid != fid) { if (s.findFid(newfid) != null) return s.fail(tag, e_fid_in_use); - // Checked BEFORE any lookup, because a lookup under `new/` - // creates a pane and a walk that then failed for want of a fid - // slot would leave one behind. if (s.freeFid() == null) return s.fail(tag, e_too_many_fids); } if (nwname == 0) { - // THE CLONE. No names, no lookups, no qids: `Rwalk` with - // `nwqid == 0`, and it is a success — which is exactly why a - // failure on the first element may not be spelled that way. if (newfid != fid) { const j = s.freeFid().?; s.fids[j] = s.fids[i]; s.fids[j].fid = newfid; - // A clone shares the file and NOT the directory cursor: - // two fids on one directory each keep their own place, - // which is what a duplicated descriptor means everywhere - // else. The open state is not shared either, and cannot - // be — an open fid was refused above. s.fids[j].diroff = 0; s.fids[j].dirindex = 0; } @@ -2656,22 +1595,13 @@ pub fn Server(comptime fs: type) type { const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid); const f = &s.fids[i]; if (f.open) return s.fail(tag, e_already_open); - // Nothing in a generated tree can be removed, so nothing in it can - // be opened remove-on-close either. if (mode & orclose != 0) return s.fail(tag, e_perm); const rw = mode & 3; if (rw == oexec) return s.fail(tag, e_perm); - // A directory is read, and only read: 9P has no other verb for one, - // and truncating a pane list is not a thing to mean. if (f.dir and (rw != oread or mode & otrunc != 0)) return s.fail(tag, e_perm); var need: u16 = 0; if (rw == oread or rw == ordwr) need |= 0o400; if (rw == owrite or rw == ordwr or mode & otrunc != 0) need |= 0o200; - // THE PERMISSION CHECK IS OURS. Under FUSE the kernel does it, - // against the mode a `getattr` reported, and `acmefs.open` never - // sees a mode at all (`acmefs.zig:1023-1031`). Over 9P there is - // nobody above us, so this is what stops `errors` and `wrsel` — - // write-only in acme's own dirtab — from being readable. if (f.perm & need != need) return s.fail(tag, e_perm); s.job = .{ .kind = .open, .tag = tag, .fid = fid, .omode = mode }; } @@ -2679,28 +1609,12 @@ pub fn Server(comptime fs: type) type { fn read(s: *Self, tag: u16, fid: u32, offset: u64, count: u32) void { const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid); const f = &s.fids[i]; - // The two conditions `u9fs.c:755-758` refuses, and the same - // answer: a fid that was never opened, or one opened write-only. if (!f.open or (f.omode & 3) == owrite) return s.fail(tag, e_bad_use); - // THE CLAMP, `min(count, msize - 11)`. An `Rread` longer than the - // count asked for is a hard `-EIO` in Linux rather than a - // truncation (`net/9p/client.c:1475-1479`, `9P-17`), and one - // longer than the msize is a message the client cannot read at - // all. Eleven is `Rread`'s header: `size[4] type[1] tag[2] - // count[4]`. It is applied to the request as well as to the - // answer, so the core is never asked to produce bytes that would - // have to be thrown away. const want = @min(count, s.msize - header_len - 4); if (!f.dir) { s.job = .{ .kind = .read, .tag = tag, .fid = fid, .offset = offset, .count = want }; return; } - // THE DIRECTORY RULE: offset zero, or exactly where the last read - // ended, and nothing else (`u9fs.c:760-769`, `lib9p/srv.c:473`). A - // client that seeks inside a directory is refused rather than - // served a listing that tears — which is the bug `ad` has, where an - // arbitrary offset that happens to land on an entry boundary is - // silently accepted (`9P-5`). if (offset != f.diroff) { if (offset != 0) return s.fail(tag, e_bad_offset); f.diroff = 0; @@ -2727,10 +1641,6 @@ pub fn Server(comptime fs: type) type { fn clunk(s: *Self, tag: u16, fid: u32, kind: Kind) void { assert(kind == .clunk or kind == .remove); const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid); - // An open fid owes the core a `release` before it goes. That is - // what decrements a pane's `event` reader count, and losing it - // leaves the editor reporting button actions to a script that has - // gone (`acmefs.zig:1069-1093`). if (s.fids[i].open) { s.job = .{ .kind = kind, .tag = tag, .fid = fid }; return; @@ -2746,61 +1656,28 @@ pub fn Server(comptime fs: type) type { fn wstat(s: *Self, tag: u16, fid: u32, st: Stat) void { if (s.findFid(fid) == null) return s.fail(tag, e_unknown_fid); - // The sentinels `stat(5)` specifies: an empty string and an - // all-ones integer mean "do not touch". The codec above carries - // them and has no opinion; deciding is this file's job. - if (st.name.len != 0) return s.fail(tag, e_wstat); + if (st.type != std.math.maxInt(u16) or st.dev != std.math.maxInt(u32) or + st.qid.type != std.math.maxInt(u8) or st.qid.version != std.math.maxInt(u32) or + st.qid.path != std.math.maxInt(u64) or st.mode != std.math.maxInt(u32) or + st.atime != std.math.maxInt(u32) or st.mtime != std.math.maxInt(u32) or + st.name.len != 0 or st.uid.len != 0 or st.gid.len != 0 or st.muid.len != 0) + return s.fail(tag, e_wstat); if (st.length == std.math.maxInt(u64)) { - // Nothing left that we honour. Mode, owner, group and the two - // times are ACCEPTED AND IGNORED, which is what a filesystem - // of live editor state has to do with them - // (`acmefs.zig:1096-1104`): refusing would make `touch` and - // `chmod` fail on a tree where they mean nothing anyway. s.emit(tag, .rwstat); return; } - // A length that is neither the sentinel nor zero. The core honours - // exactly one value, so name it — and this string is one Linux - // already knows, so `truncate` gets EPERM rather than 526. if (st.length != 0) return s.fail(tag, e_trunc_only); - // ...and zero IS the truncate, which is the same `Req.truncate` - // that `Topen` with `OTRUNC` produces (`FIX-1`). s.job = .{ .kind = .wstat, .tag = tag, .fid = fid }; } - /// `Tflush`: a park-table lookup, and THE ORDER IS THE POINT. - /// - /// The original is answered first and the `Rflush` second. That is what - /// `lib9p/srv.c:241-266` does with its chained flush list, and what - /// `srv.c:810-827` does when the original finally responds: write the - /// original's reply, then respond to every flush waiting on it. A - /// client that sees `Rflush` may reuse the tag, so a reply arriving - /// after it would be a reply to whatever the tag names NEXT. - /// - /// `docs/registry.typ` `9P-16` says this is where we beat the prior - /// art, and the reason is structural rather than clever: the park table - /// is already keyed per outstanding request, so this is a lookup and - /// two replies. `ad` gets the ordering right in thirty-nine lines and - /// then defaults its filesystem's `flush` hook to doing nothing, so a - /// client flushing a blocked `event` read waits for an unrelated editor - /// event to arrive. There is no hook here to forget to implement. fn flush(s: *Self, tag: u16, oldtag: u16) void { if (s.findTag(oldtag)) |i| { - // EINTR and drop it, which is exactly what `fuse.zig:1334-1341` - // answers a `FUSE_INTERRUPT` naming a parked request. s.fail(s.slots[i].tag, e_interrupted); s.slots[i] = .{}; } - // A tag we do not hold was already answered or never existed. - // `Rflush` either way: after it the client may reuse the tag, and - // that is the only promise `flush(5)` makes. s.emit(tag, .rflush); } - // -- steps and answers ----------------------------------------------- - - /// Record the step being handed to the core, so that a park has - /// something to copy and `reply` has something to match. fn ask(s: *Self, req: fs.Req) fs.Req { assert(req.tag != 0); s.job.req = req; @@ -2808,9 +1685,6 @@ pub fn Server(comptime fs: type) type { return req; } - /// The fid the message in flight names. Null cannot happen — nothing - /// else runs while a job does — and is answered rather than asserted, - /// because the cost of being wrong is a corrupted table. fn jobFid(s: *Self) ?*Fid { const i = s.findFid(s.job.fid) orelse { s.fail(s.job.tag, e_unknown_fid); @@ -2820,8 +1694,6 @@ pub fn Server(comptime fs: type) type { return &s.fids[i]; } - /// The next core request the message in flight needs, or null when it - /// has just answered itself. fn stepJob(s: *Self) ?fs.Req { const j = &s.job; assert(j.kind != .none); @@ -2832,7 +1704,6 @@ pub fn Server(comptime fs: type) type { .walk => return s.stepWalk(), .open => { const f = s.jobFid() orelse return null; - // `OTRUNC` is a truncate and THEN an open, in that order. if (j.step == 0 and j.omode & otrunc != 0) return s.ask(.{ .tag = s.tick(), .op = .setattr, @@ -2854,10 +1725,6 @@ pub fn Server(comptime fs: type) type { }, .readdir => { const f = s.jobFid() orelse return null; - // THE COORDINATE CHANGE. 9P counts bytes and `acmefs` - // counts entries (`acmefs.zig:972`), so the request carries - // the entry index this fid's byte cursor stands at, and - // `emitDirRead` advances both. return s.ask(.{ .tag = s.tick(), .op = .readdir, @@ -2899,42 +1766,24 @@ pub fn Server(comptime fs: type) type { } } - /// One walk element at a time, and the local ones without asking. fn stepWalk(s: *Self) ?fs.Req { const j = &s.job; while (j.step < j.nwname) { const name = j.msg.twalk.wname[j.step]; - // A name the fid could not hold cannot be a name in this tree, - // and refusing it on its length is what keeps `Rstat` honest. - if (name.len > name_max) { + if (name.len > name_capacity) { s.stopWalk(e_illegal_name); return null; } - // `.` is the fid where it already stands, and costs nothing. if (std.mem.eql(u8, name, ".")) { j.wqid[j.nwqid] = qidOf(j.node, j.dir); j.nwqid += 1; j.step += 1; continue; } - if (std.mem.eql(u8, name, "..")) { - const p = parentOf(j.node, s.root, &j.name); - j.name_len = p.name_len; - j.node = p.node; - // WHICH node the parent is, is ours to work out; what it - // LOOKS like is not. A `getattr` keeps `perm`, `dir` and - // the qid the core's answer rather than this file's - // invention, and reports ENOENT if the pane closed - // underneath us. - return s.ask(.{ .tag = s.tick(), .op = .getattr, .node = p.node }); - } @memcpy(j.name[0..name.len], name); j.name_len = @intCast(name.len); return s.ask(.{ .tag = s.tick(), .op = .lookup, .node = j.node, .data = name }); } - // Every element resolved, so `newfid` is bound — and only now. A - // partial walk binds NOTHING, which is `ad`'s «new_fid is only - // bound when all elements were walked successfully» and the spec's. const dst = pick: { if (j.newfid == j.fid) break :pick s.findFid(j.fid) orelse { s.fail(j.tag, e_unknown_fid); @@ -2961,16 +1810,6 @@ pub fn Server(comptime fs: type) type { return null; } - /// A walk that could not finish, and THE SCAR that says how to answer - /// it: «Spec: first element failure must be Rerror, not Rwalk with zero - /// qids» — `ad/crates/ninep/src/sansio/server.rs:335-338`, left in - /// their source after they shipped it the other way. Zero qids already - /// means the clone, so it cannot also mean a failure. - /// - /// A failure at any LATER element is a successful short `Rwalk`, and - /// the client is expected to notice that it got fewer qids than it - /// asked for. It gets no error string at all, which is the protocol's - /// choice and not ours. fn stopWalk(s: *Self, ename: []const u8) void { const j = &s.job; if (j.nwqid == 0) @@ -2985,18 +1824,12 @@ pub fn Server(comptime fs: type) type { if (s.frame != 0) s.dropFrame(); } - /// The core answered a step of the message in flight. fn jobReply(s: *Self, r: *const fs.Reply, bytes: []const u8) void { const j = &s.job; assert(j.kind != .none); assert(j.req_tag == r.tag); j.req_tag = 0; - // A CLUNK CANNOT FAIL. «even if the clunk fails, the fid is no - // longer valid» — clunk(5) — and `remove(5)` says the same of - // remove, so the core's answer to the release is not consulted at - // all. That also means a release the core tried to park is dropped - // rather than leaving behind a fid the client can no longer reach. if (j.kind == .clunk or j.kind == .remove) { s.dropFid(j.fid); if (j.kind == .remove) s.fail(j.tag, e_perm) else s.emit(j.tag, .rclunk); @@ -3028,8 +1861,6 @@ pub fn Server(comptime fs: type) type { .dir = r.attr.dir, .perm = r.attr.mode, }; - // The root's name is "/" — one of the bugs `ad` shipped - // and then fixed (`9P-18`, commit `64f2f4b`). s.fids[i].name[0] = '/'; s.fids[i].name_len = 1; s.emit(j.tag, .{ .rattach = .{ .qid = qidOf(node, r.attr.dir) } }); @@ -3037,17 +1868,20 @@ pub fn Server(comptime fs: type) type { }, .walk => { if (r.attr.node != 0) j.node = r.attr.node; + if (comptime @hasField(@TypeOf(r.attr), "name")) { + if (r.attr.name.len != 0) { + j.name_len = @intCast(@min(r.attr.name.len, j.name.len)); + @memcpy(j.name[0..j.name_len], r.attr.name[0..j.name_len]); + } + } j.dir = r.attr.dir; j.perm = r.attr.mode; j.wqid[j.nwqid] = qidOf(j.node, j.dir); j.nwqid += 1; j.step += 1; - // The job STAYS: `next()` asks `stepWalk` for the next - // element, or lets it bind the fid and answer. }, .open => { if (j.step == 0 and j.omode & otrunc != 0) { - // The truncate landed; the open is the next step. j.step = 1; return; } @@ -3055,12 +1889,8 @@ pub fn Server(comptime fs: type) type { f.open = true; f.omode = j.omode; f.handle = r.handle; - // A fresh open starts a directory at the beginning. f.diroff = 0; f.dirindex = 0; - // `iounit` is the largest atomic read or write: one message - // less the slack `fcall.h:72` has reserved for a `Twrite` - // header for thirty years. s.emit(j.tag, .{ .ropen = .{ .qid = qidOf(f.node, f.dir), .iounit = s.msize - iohdrsz, @@ -3068,10 +1898,6 @@ pub fn Server(comptime fs: type) type { s.finishJob(); }, .read => { - // Clamped a second time, against the bytes that actually - // came back: the request already carried the count, and a - // core that answered with more would otherwise become an - // `-EIO` in the client rather than a bug here. s.emit(j.tag, .{ .rread = .{ .data = bytes[0..@min(bytes.len, j.count)] } }); s.finishJob(); }, @@ -3080,21 +1906,18 @@ pub fn Server(comptime fs: type) type { s.finishJob(); }, .write => { - // The core's own count and not the request's: `data` - // refusing a partial grapheme is a real short write, and - // claiming the whole request would tell the writer that - // its trailing bytes landed when they did not. s.emit(j.tag, .{ .rwrite = .{ .count = @min(r.written, j.count) } }); s.finishJob(); }, .stat => { const f = s.jobFid() orelse return; - // The core is authoritative about size and mode, and the - // fid's cache follows: `body` grows between stats, and - // `Topen` is checked against `perm`. f.perm = r.attr.mode; f.dir = r.attr.dir; - s.emit(j.tag, .{ .rstat = .{ .stat = s.statOf(f, r.attr) } }); + const response: Msg = .{ .rstat = .{ .stat = s.statOf(f, r.attr) } }; + if ((totalLen(response) catch unreachable) > s.msize) + s.fail(j.tag, e_small_msize) + else + s.emit(j.tag, response); s.finishJob(); }, .wstat => { @@ -3104,18 +1927,8 @@ pub fn Server(comptime fs: type) type { } } - /// The core said `.again`: nothing consumed, ask me later. The request - /// moves into a park slot and the 9P tag goes with it, so the client - /// hears nothing at all until the core has something to say — which is - /// what makes a blocking `event` read work on a single-threaded core - /// with no waiter list anywhere. fn parkJob(s: *Self) void { const j = &s.job; - // Only these three can park, and only because the state a retry - // needs is scalars. A walk cannot: its names borrow the input - // buffer, which the next message overwrites. `e_again` is honest - // (the client may retry) and by construction unreachable — the - // core parks reads of `event` and nothing else. switch (j.kind) { .read, .readdir, .write => {}, else => { @@ -3125,8 +1938,6 @@ pub fn Server(comptime fs: type) type { }, } const i = s.freeSlot() orelse { - // Overflow is a refusal, not a queue: thirty-two blocked - // readers is thirty-two scripts watching one session. s.fail(j.tag, e_again); s.finishJob(); return; @@ -3144,8 +1955,6 @@ pub fn Server(comptime fs: type) type { }; if (j.req.data.len != 0) { if (j.req.data.len > park_data_max) { - // A payload too large to copy would go on borrowing the - // input buffer, so parking it would park a dangling slice. sl.* = .{}; s.fail(j.tag, e_again); s.finishJob(); @@ -3155,12 +1964,9 @@ pub fn Server(comptime fs: type) type { sl.copied = true; sl.req.data = sl.data[0..j.req.data.len]; } - // The frame is nobody's now: everything the retry needs has been - // copied, so the next message may take its place. s.finishJob(); } - /// The core answered a request that had been parked. fn slotReply(s: *Self, i: usize, r: *const fs.Reply, bytes: []const u8) void { const sl = &s.slots[i]; if (r.status == .again) { @@ -3176,25 +1982,11 @@ pub fn Server(comptime fs: type) type { .read => s.emit(sl.tag, .{ .rread = .{ .data = bytes[0..@min(bytes.len, sl.count)] } }), .readdir => s.emitDirRead(sl.tag, sl.fid, bytes, sl.count), .write => s.emit(sl.tag, .{ .rwrite = .{ .count = @min(r.written, sl.count) } }), - // `parkJob` admits no other kind. else => s.fail(sl.tag, e_botch), } sl.* = .{}; } - /// A directory read: `acmefs`'s staged entries become 9P `stat` - /// records, in place, and the fid's cursor advances by exactly what - /// was sent. - /// - /// THE ONE ENTRY THAT DID NOT FIT needs no buffer here, and that is - /// worth saying because every reference server has one: u9fs caches a - /// `dirent` per fid «for when convD2M fails» (`u9fs.c:780`) because - /// `readdir(3)` has already consumed it. `acmefs` re-stages the whole - /// listing from an entry index on every call and says why — - /// «re-staging from scratch on every call is what makes a partially - /// consumed answer safe to ask for again at a higher cookie» - /// (`acmefs.zig:1013-1016`) — so an entry that does not fit is simply - /// not counted, and the next read asks for it by index. fn emitDirRead(s: *Self, tag: u16, fid: u32, staging: []const u8, count: u32) void { const buf = s.out[s.out_len..]; assert(buf.len > header_len + 4); @@ -3203,9 +1995,8 @@ pub fn Server(comptime fs: type) type { var n: usize = header_len + 4; var entries: u32 = 0; var i: usize = 0; - // `node[8] dir[1] namelen[1] name[]`, repeated — `acmefs.zig:942`. while (i + 10 <= staging.len) { - const nlen = staging[i + 9]; + const nlen: usize = staging[i + 9]; if (i + 10 + nlen > staging.len) break; const dir = staging[i + 8] != 0; const rec: Stat = .{ @@ -3222,60 +2013,32 @@ pub fn Server(comptime fs: type) type { .muid = who, }; const size = @as(usize, rec.size() catch break) + 2; - // WHOLE RECORDS ONLY. `read(5)`: a directory read returns an - // integral number of entries, so the first one that does not - // fit ends the reply and the cursor stops in front of it. if (n - header_len - 4 + size > cap) break; _ = rec.encode(buf[n..]) catch break; n += size; entries += 1; i += 10 + nlen; } - // A count that cannot hold the FIRST entry is refused rather than - // answered with zero bytes, because zero bytes is end of directory - // and a client that believes it stops asking. `entries == 0` with - // nothing staged is the real end. if (entries == 0 and staging.len != 0) return s.fail(tag, e_count_small); const payload: u32 = @intCast(n - header_len - 4); - // The header goes on LAST, over bytes reserved for it, because the - // payload's length is only known once the entries are encoded — - // `u9fs.c:775-806` builds it the same way and for the same reason. - // Written by hand rather than through `encode`, which would want - // the payload contiguous somewhere else first, and this file will - // not carry a third msize buffer to make that true. The test "a - // directory read is whole stat records" decodes the result with - // `decode`, which is what keeps these four lines honest. comptime assert(header_len == 7); std.mem.writeInt(u32, buf[0..4], @intCast(n), .little); buf[4] = @intFromEnum(Type.rread); std.mem.writeInt(u16, buf[5..7], tag, .little); std.mem.writeInt(u32, buf[7..11], payload, .little); s.out_len += n; - // BOTH cursors, together, or the next read is refused: bytes for - // the client's offset rule, entries for `acmefs`'s index. if (s.findFid(fid)) |k| { s.fids[k].diroff += payload; s.fids[k].dirindex += entries; } } - /// One `stat` record for a file the core has just described. - /// - /// `type` and `dev` are Plan 9 kernel device identifiers, meaningless - /// off Plan 9, and zero — as u9fs sends them. `atime` and `mtime` are - /// zero because this tree has no times to report and the FUSE - /// transport already reports none (`fuse.zig:1544-1546`); an invented - /// time is one `make` would believe. The three name fields are whoever - /// attached: the tree is synthetic and has exactly one owner. fn statOf(s: *const Self, f: *const Fid, a: fs.Reply.Attr) Stat { const who = s.uname[0..s.uname_len]; return .{ .type = 0, .dev = 0, .qid = qidOf(if (a.node != 0) a.node else f.node, a.dir), - // The high bits are the type and the low nine are the - // permission: `dmdir` is `qtdir` shifted up 24, which the - // codec's last test asserts rather than assumes. .mode = (if (a.dir) dmdir else 0) | @as(u32, a.mode), .atime = 0, .mtime = 0, @@ -3289,28 +2052,8 @@ pub fn Server(comptime fs: type) type { }; } -// --------------------------------------------------------------------------- -// server tests -// --------------------------------------------------------------------------- -// -// Driven with BYTE ARRAYS and a STUB FILESYSTEM, so there is no `Pardes` here -// and no transport either: `push` takes encoded messages, `pump` is -// `fs_service.drain` written out, and `reap` decodes what came back with the -// codec above. A test that fails is a message a real client would have been -// sent, byte for byte. - -/// Everything `Server` asks of a filesystem, plus a tree small enough to check -/// by eye. The three types are `acmefs`'s ABI verbatim — that is the whole -/// contract, and `Server(acmefs)` is the instantiation that matters — so this -/// is a MIRROR and not a redefinition: a field that drifts is a compile error -/// the moment the real adapter is built. -/// -/// THE NODE IDS ARE `acmefs.Node`'s PACKING, `{ file: u4, serial: u60 }`, and -/// they have to be: `parentOf` reads them. So pane 1's directory is `1 << 4`, -/// its `body` is that plus `PaneFile.body` (2), and the top-level files are -/// `TopFile`'s own 1..4 with a zero serial. const StubFs = struct { - pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir, statfs }; + pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir }; pub const Status = enum(u8) { ok, again, err }; pub const Req = struct { @@ -3333,6 +2076,7 @@ const StubFs = struct { written: u32 = 0, pub const Attr = struct { + name: []const u8 = "", node: u64 = 0, dir: bool = false, size: u64 = 0, @@ -3342,9 +2086,6 @@ const StubFs = struct { const Entry = struct { node: u64, parent: u64, name: []const u8, dir: bool, mode: u16 }; - /// `acmefs`'s tree, cut down: the root's four names, two panes, and six of - /// a pane's files including the two that matter most here — `event`, which - /// blocks, and `errors`, which is write-only. const tree = [_]Entry{ .{ .node = 1, .parent = 1, .name = "/", .dir = true, .mode = 0o500 }, .{ .node = 2, .parent = 1, .name = "index", .dir = false, .mode = 0o400 }, @@ -3365,11 +2106,8 @@ const StubFs = struct { const event_node = 22; body: []const u8 = "hello, body\n", - /// `index`, and long enough that a read of it has to be clamped. filler: [1024]u8 = @splat('x'), - /// One event record, or nothing — which is `Status.again`. event: ?[]const u8 = null, - /// Set to make every write park, so the copy into a slot is exercised. park_writes: bool = false, releases: u32 = 0, calls: u32 = 0, @@ -3401,11 +2139,9 @@ const StubFs = struct { } fn attrOf(st: *const StubFs, e: Entry) Reply.Attr { - return .{ .node = e.node, .dir = e.dir, .mode = e.mode, .size = st.sizeOf(e.node) }; + return .{ .name = e.name, .node = e.node, .dir = e.dir, .mode = e.mode, .size = st.sizeOf(e.node) }; } - /// `acmefs`'s staging format, which is what the 9P server decodes: - /// `node[8] dir[1] namelen[1] name[]`, repeated, from an ENTRY INDEX. fn stageDir(st: *StubFs, node: u64, skip: u64) []const u8 { var n: usize = 0; var seen: u64 = 0; @@ -3430,6 +2166,8 @@ const StubFs = struct { const i = find(req.node) orelse return fail; switch (req.op) { .lookup => { + if (std.mem.eql(u8, req.data, "..")) + return .{ .reply = .{ .tag = req.tag, .attr = st.attrOf(tree[find(tree[i].parent).?]) } }; for (tree) |e| { if (e.parent != req.node or e.node == req.node) continue; if (!std.mem.eql(u8, e.name, req.data)) continue; @@ -3452,8 +2190,6 @@ const StubFs = struct { return .{ .reply = .{ .tag = req.tag }, .bytes = st.stageDir(req.node, req.off) }; }, .read => { - // `event`: one record per read, and `.again` when there is - // none — `acmefs.zig:1358-1367` exactly. if (req.node == event_node) { const rec = st.event orelse return .{ .reply = .{ .tag = req.tag, .status = .again } }; st.event = null; @@ -3471,22 +2207,18 @@ const StubFs = struct { st.writes_len += n; return .{ .reply = .{ .tag = req.tag, .written = @intCast(n) } }; }, - .statfs => return .{ .reply = .{ .tag = req.tag } }, } } }; -const Srv = Server(StubFs); +const Srv = Server(StubFs, max_fids); -/// One connection: two buffers, a stub filesystem and the server between them. const Harness = struct { in: [4096]u8 = undefined, out: [8192]u8 = undefined, fsys: StubFs = .{}, srv: Srv = undefined, - /// The buffers are fields, so the server can only be built once the - /// harness has an address. fn start(h: *Harness) void { h.srv = Srv.init(.{ .in = &h.in, .out = &h.out, .root = 1 }); } @@ -3496,11 +2228,6 @@ const Harness = struct { h.srv.reply(&a.reply, a.bytes); } - /// THE TRANSPORT CONTRACT, in the shape `src/fs_service.zig:209-222` - /// requires it: every parked request offered once, then everything the - /// wire has, both loops to null. Written out rather than imported — - /// `fs_service` is `pardes.zig` deep — and writing it out is how these - /// tests document what they are testing against. fn pump(h: *Harness) void { while (h.srv.retry()) |req| h.answer(req); while (h.srv.next()) |req| h.answer(req); @@ -3513,8 +2240,6 @@ const Harness = struct { h.pump(); } - /// One reply off the queue. Borrows the out buffer, so a caller checks it - /// before sending anything else. fn reap(h: *Harness) !Decoded { const out = h.srv.output(); const len = frameLen(out) orelse return error.NoReply; @@ -3528,8 +2253,6 @@ const Harness = struct { try testing.expectEqual(@as(usize, 0), h.srv.output().len); } - /// `Tversion` and `Tattach`, which every test but the handshake ones want, - /// leaving the root on fid 0. fn handshake(h: *Harness, msize: u32) !void { h.start(); try h.send(notag, .{ .tversion = .{ .msize = msize, .version = "9P2000" } }); @@ -3540,7 +2263,6 @@ const Harness = struct { try testing.expectEqual(@as(u64, 1), a.msg.rattach.qid.path); } - /// A walk from the root to one name, landing on `newfid`. fn walkTo(h: *Harness, tag: u16, newfid: u32, list: []const []const u8) !Decoded { try h.send(tag, .{ .twalk = .{ .fid = 0, @@ -3558,9 +2280,6 @@ fn wnames(list: []const []const u8) [max_welem][]const u8 { return out; } -/// The names in a directory read, decoded as whole `stat` records. Which is -/// also the proof that `emitDirRead`'s hand-written header is right: this goes -/// through `Stat.decode`, which refuses anything that does not add up. fn dirNames(data: []const u8, out: [][]const u8) !usize { var n: usize = 0; var i: usize = 0; @@ -3578,40 +2297,27 @@ test "9p server: the version handshake clamps, falls back, and refuses" { var h: Harness = .{}; h.start(); - // A client offering a megabyte gets what the buffers hold: one input - // buffer, or half the output queue, whichever is smaller. try h.send(notag, .{ .tversion = .{ .msize = 1 << 20, .version = "9P2000" } }); var got = try h.reap(); try testing.expectEqual(notag, got.tag); try testing.expectEqual(@as(u32, 4096), got.msg.rversion.msize); try testing.expectEqualStrings("9P2000", got.msg.rversion.version); - // BELOW LINUX'S FLOOR IS FINE. 4096 is the kernel's number and nobody - // else's: Plan 9's devmnt, plan9port's `9p` and our own client all accept - // 512, and refusing it would cost the board a kilobyte for nothing. try h.send(notag, .{ .tversion = .{ .msize = 512, .version = "9P2000" } }); got = try h.reap(); try testing.expectEqual(@as(u32, 512), got.msg.rversion.msize); - // A `.u` client is told to fall back rather than refused: u9fs answers - // "9P2000" to any version starting with "9P". try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "9P2000.u" } }); got = try h.reap(); try testing.expectEqualStrings("9P2000", got.msg.rversion.version); - // Something that is not 9P at all: the literal "unknown", in a SUCCESSFUL - // Rversion and not an Rerror. try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "TCP/IP" } }); got = try h.reap(); try testing.expectEqualStrings("unknown", got.msg.rversion.version); - // ...and nothing else is served until a version is agreed. try h.send(1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } }); got = try h.reap(); try testing.expectEqualStrings(e_botch, got.msg.rerror.ename); - // An msize too small to hold one `Rwalk` cannot be served at all, and - // `Rversion` has no field that means "too small" — so `Rerror`, which is a - // legal reply to any T-message. try h.send(notag, .{ .tversion = .{ .msize = 64, .version = "9P2000" } }); got = try h.reap(); try testing.expectEqualStrings(e_small_msize, got.msg.rerror.ename); @@ -3624,8 +2330,6 @@ test "9p server: attach names the root, and the only tree there is" { try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "9P2000" } }); _ = try h.reap(); - // An `aname` names a tree we do not have, and saying so beats handing over - // the one we do. try h.send(1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "work" } }); var got = try h.reap(); try testing.expectEqualStrings(e_no_tree, got.msg.rerror.ename); @@ -3634,22 +2338,16 @@ test "9p server: attach names the root, and the only tree there is" { got = try h.reap(); try testing.expectEqual(@as(u64, 1), got.msg.rattach.qid.path); try testing.expectEqual(qtdir, got.msg.rattach.qid.type); - // ALWAYS ZERO, which is what makes Linux's client skip its cache. try testing.expectEqual(@as(u32, 0), got.msg.rattach.qid.version); - // The same fid twice is a client bug, refused rather than rebound. try h.send(3, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } }); got = try h.reap(); try testing.expectEqualStrings(e_fid_in_use, got.msg.rerror.ename); - // `Tauth` is refused, which is how a Plan 9 mount learns there is no - // authentication here and carries on without it. try h.send(4, .{ .tauth = .{ .afid = 1, .uname = "goblin", .aname = "" } }); got = try h.reap(); try testing.expectEqualStrings(e_no_auth, got.msg.rerror.ename); - // The attacher's name is what `Rstat` reports as owner, group and last - // modifier: the tree is synthetic and has exactly one owner. try h.send(5, .{ .tstat = .{ .fid = 0 } }); got = try h.reap(); try testing.expectEqualStrings("/", got.msg.rstat.stat.name); @@ -3662,8 +2360,6 @@ test "9p server: a three-element walk, and `..` with no kernel to resolve it" { var h: Harness = .{}; try h.handshake(4096); - // Three elements in one message, on a tree that is three deep: `..` at the - // root is the root, which is POSIX's rule and intro(5)'s, and NOT an error. var got = try h.walkTo(5, 1, &.{ "..", "1", "body" }); try testing.expectEqual(@as(u16, 3), got.msg.rwalk.nwqid); try testing.expectEqual(@as(u64, 1), got.msg.rwalk.wqid[0].path); @@ -3673,21 +2369,15 @@ test "9p server: a three-element walk, and `..` with no kernel to resolve it" { try testing.expectEqual(qtfile, got.msg.rwalk.wqid[2].type); for (got.msg.rwalk.wqid[0..3]) |q| try testing.expectEqual(@as(u32, 0), q.version); - // Up and down and up and down. `..` from a pane's directory is the root - // too, and five elements land where two would have. got = try h.walkTo(6, 2, &.{ "..", "1", "..", "1", "body" }); try testing.expectEqual(@as(u16, 5), got.msg.rwalk.nwqid); try testing.expectEqual(@as(u64, 1), got.msg.rwalk.wqid[2].path); try testing.expectEqual(@as(u64, 18), got.msg.rwalk.wqid[4].path); - // ...and the fid really is the body. try h.send(7, .{ .tstat = .{ .fid = 2 } }); got = try h.reap(); try testing.expectEqualStrings("body", got.msg.rstat.stat.name); try testing.expectEqual(@as(u64, 12), got.msg.rstat.stat.length); - // `..` after an element that landed on a FILE is that pane's directory — - // `parentOf`'s third case — and the name comes out of the node id rather - // than out of the element, because "1" is not what the client typed. got = try h.walkTo(8, 3, &.{ "1", "body", ".." }); try testing.expectEqual(@as(u16, 3), got.msg.rwalk.nwqid); try testing.expectEqual(@as(u64, 16), got.msg.rwalk.wqid[2].path); @@ -3696,8 +2386,6 @@ test "9p server: a three-element walk, and `..` with no kernel to resolve it" { got = try h.reap(); try testing.expectEqualStrings("1", got.msg.rstat.stat.name); - // `.` is where the fid already stands, and it costs the core NOTHING: no - // request is made for it at all. const before = h.fsys.calls; got = try h.walkTo(10, 4, &.{ ".", "." }); try testing.expectEqual(@as(u16, 2), got.msg.rwalk.nwqid); @@ -3709,23 +2397,17 @@ test "9p server: a walk failing on the first element is Rerror, on the second a var h: Harness = .{}; try h.handshake(4096); - // THE SCAR (`ad/.../sansio/server.rs:335-338`): zero qids already means - // the clone, so a failure on the first element cannot be spelled that way. var got = try h.walkTo(5, 1, &.{ "nope", "body" }); try testing.expectEqualStrings("No such file or directory", got.msg.rerror.ename); - // A failure LATER is a successful short `Rwalk` with no error string at - // all, which is the protocol's choice and not ours. got = try h.walkTo(6, 1, &.{ "1", "nope" }); try testing.expectEqual(@as(u16, 1), got.msg.rwalk.nwqid); try testing.expectEqual(@as(u64, 16), got.msg.rwalk.wqid[0].path); - // ...and `newfid` is NOT bound by a partial walk. try h.send(7, .{ .tstat = .{ .fid = 1 } }); got = try h.reap(); try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename); - // The clone does bind it, with zero qids and no lookups. try h.send(8, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 0 } }); got = try h.reap(); try testing.expectEqual(@as(u16, 0), got.msg.rwalk.nwqid); @@ -3733,24 +2415,84 @@ test "9p server: a walk failing on the first element is Rerror, on the second a got = try h.reap(); try testing.expectEqualStrings("/", got.msg.rstat.stat.name); - // A walk with names from something that is not a directory is not a walk. got = try h.walkTo(10, 2, &.{"index"}); try testing.expectEqual(@as(u16, 1), got.msg.rwalk.nwqid); try h.send(11, .{ .twalk = .{ .fid = 2, .newfid = 3, .nwname = 1, .wname = wnames(&.{"body"}) } }); got = try h.reap(); try testing.expectEqualStrings(e_not_dir, got.msg.rerror.ename); - // A name no fid could hold is refused on its length rather than looked up, - // which is what keeps `Rstat`'s name field honest. got = try h.walkTo(12, 4, &.{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}); try testing.expectEqualStrings(e_illegal_name, got.msg.rerror.ename); - // An in-use `newfid` is refused before anything is walked. try h.send(13, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 1, .wname = wnames(&.{"1"}) } }); got = try h.reap(); try testing.expectEqualStrings(e_fid_in_use, got.msg.rerror.ename); } +test "9p server: backend-sized filenames survive walk and stat within negotiated msize" { + const NativeFs = struct { + pub const Req = StubFs.Req; + pub const Reply = StubFs.Reply; + pub const name_capacity: usize = 255; + }; + const Native = Server(NativeFs, 2); + try testing.expectEqual(@as(usize, 28), @sizeOf(@FieldType(Srv.Fid, "name"))); + try testing.expectEqual(@as(usize, 255), @sizeOf(@FieldType(Native.Fid, "name"))); + + const filename: [255]u8 = @splat('f'); + for ([_]struct { length: usize, msize: u32 }{ + .{ .length = 29, .msize = 512 }, + .{ .length = 128, .msize = 512 }, + .{ .length = 255, .msize = 512 }, + .{ .length = 200, .msize = 256 }, + }) |case| { + var in: [1024]u8 = undefined; + var out: [2048]u8 = undefined; + var encoded: [1024]u8 = undefined; + var server = Native.init(.{ .in = &in, .out = &out, .root = 1 }); + server.msize = case.msize; + server.setUname("u" ** username_capacity); + server.fids[0] = .{ .used = true, .fid = 0, .node = 1, .dir = true, .perm = 0o500 }; + const name = filename[0..case.length]; + const walk = try encode(.{ .twalk = .{ + .fid = 0, + .newfid = 1, + .nwname = 1, + .wname = wnames(&.{name}), + } }, 1, &encoded); + try testing.expectEqual(walk.len, server.push(walk)); + const lookup = server.next() orelse return error.MissingLookup; + try testing.expectEqual(.lookup, lookup.op); + try testing.expectEqualStrings(name, lookup.data); + server.reply(&.{ .tag = lookup.tag, .attr = .{ .node = 2, .name = name, .size = 12 } }, ""); + try testing.expectEqual(null, server.next()); + var response = try decode(server.output()); + try testing.expectEqual(@as(u16, 1), response.msg.rwalk.nwqid); + server.wrote(server.output().len); + + const stat = try encode(.{ .tstat = .{ .fid = 1 } }, 2, &encoded); + try testing.expectEqual(stat.len, server.push(stat)); + const getattr = server.next() orelse return error.MissingGetattr; + try testing.expectEqual(.getattr, getattr.op); + server.reply(&.{ .tag = getattr.tag, .attr = .{ .node = 2, .name = name, .size = 12 } }, ""); + try testing.expect(server.output().len <= case.msize); + response = try decode(server.output()); + if (case.msize == 256) { + try testing.expectEqualStrings(e_small_msize, response.msg.rerror.ename); + } else { + try testing.expectEqualStrings(name, response.msg.rstat.stat.name); + try testing.expectEqual(@as(u64, 12), response.msg.rstat.stat.length); + } + server.wrote(server.output().len); + const clunk = try encode(.{ .tclunk = .{ .fid = 1 } }, 3, &encoded); + try testing.expectEqual(clunk.len, server.push(clunk)); + try testing.expectEqual(null, server.next()); + response = try decode(server.output()); + try testing.expect(response.msg == .rclunk); + try testing.expect(!server.dead); + } +} + test "9p server: open then read then clunk, and the release a clunk owes the core" { var h: Harness = .{}; try h.handshake(4096); @@ -3759,10 +2501,8 @@ test "9p server: open then read then clunk, and the release a clunk owes the cor try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } }); var got = try h.reap(); try testing.expectEqual(@as(u64, 18), got.msg.ropen.qid.path); - // `iounit` is one message less the slack `fcall.h:72` reserves. try testing.expectEqual(@as(u32, 4096 - iohdrsz), got.msg.ropen.iounit); - // The fid IS the open, so a second one has nothing to mean. try h.send(7, .{ .topen = .{ .fid = 1, .mode = oread } }); got = try h.reap(); try testing.expectEqualStrings(e_already_open, got.msg.rerror.ename); @@ -3771,26 +2511,20 @@ test "9p server: open then read then clunk, and the release a clunk owes the cor got = try h.reap(); try testing.expectEqualStrings("hello, body\n", got.msg.rread.data); - // Offsets are honoured on `body`, which is the whole reason `cat`, `wc` - // and `tail` work against this tree (docs/9p.typ §4). try h.send(9, .{ .tread = .{ .fid = 1, .offset = 7, .count = 4096 } }); got = try h.reap(); try testing.expectEqualStrings("body\n", got.msg.rread.data); - // Past the end is zero bytes, which is end of file and not an error. try h.send(10, .{ .tread = .{ .fid = 1, .offset = 99, .count = 16 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 0), got.msg.rread.data.len); - // THE RELEASE. Without it a pane's `event` reader count never comes back - // down and the editor answers to a script that has gone. try testing.expectEqual(@as(u32, 0), h.fsys.releases); try h.send(11, .{ .tclunk = .{ .fid = 1 } }); got = try h.reap(); try testing.expect(got.msg == .rclunk); try testing.expectEqual(@as(u32, 1), h.fsys.releases); - // A FID USED AFTER CLUNK is a fid nobody knows. try h.send(12, .{ .tread = .{ .fid = 1, .offset = 0, .count = 16 } }); got = try h.reap(); try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename); @@ -3798,7 +2532,6 @@ test "9p server: open then read then clunk, and the release a clunk owes the cor got = try h.reap(); try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename); - // A clunk of a fid that was never opened needs no release at all. _ = try h.walkTo(14, 2, &.{"index"}); try h.send(15, .{ .tclunk = .{ .fid = 2 } }); got = try h.reap(); @@ -3808,30 +2541,21 @@ test "9p server: open then read then clunk, and the release a clunk owes the cor test "9p server: every Rread is clamped to the client's count and to the msize" { var h: Harness = .{}; - // A small msize on purpose: `index` is a kilobyte and one message cannot - // carry it. try h.handshake(512); _ = try h.walkTo(5, 1, &.{"index"}); try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } }); _ = try h.reap(); - // The count, when the count is the smaller. try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 5 } }); var got = try h.reap(); try testing.expectEqual(@as(usize, 5), got.msg.rread.data.len); - // The MSIZE, when the client asks for more than one message can hold. An - // `Rread` longer than the count is a hard -EIO in Linux - // (`client.c:1475-1479`) and one longer than the msize is unreadable, so - // the answer is `msize - 11` exactly and the whole message is `msize`. try h.send(8, .{ .tread = .{ .fid = 1, .offset = 0, .count = 1 << 20 } }); const out = h.srv.output(); try testing.expectEqual(@as(?u32, 512), frameLen(out)); got = try h.reap(); try testing.expectEqual(@as(usize, 512 - header_len - 4), got.msg.rread.data.len); - // And the core was never asked for bytes that would have been thrown - // away: the clamp is on the request too. try h.send(9, .{ .tread = .{ .fid = 1, .offset = 0, .count = 1 << 20 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 501), got.msg.rread.data.len); @@ -3840,17 +2564,12 @@ test "9p server: every Rread is clamped to the client's count and to the msize" test "9p server: a directory read is whole stat records at a cursor the client cannot invent" { var h: Harness = .{}; try h.handshake(4096); - // Walked before the root is opened, because an open fid cannot be walked - // (walk(5)) and the mode assertion at the bottom of this test needs it. _ = try h.walkTo(4, 1, &.{"index"}); try h.send(5, .{ .topen = .{ .fid = 0, .mode = oread } }); _ = try h.reap(); var found: [8][]const u8 = undefined; - // Two entries fit in 150 bytes; the third does not, so it is not counted - // and the next read asks for it by index. No cached entry anywhere, which - // is what `acmefs`'s re-staging buys (`acmefs.zig:1013-1016`). try h.send(6, .{ .tread = .{ .fid = 0, .offset = 0, .count = 150 } }); var got = try h.reap(); const first = got.msg.rread.data.len; @@ -3859,8 +2578,6 @@ test "9p server: a directory read is whole stat records at a cursor the client c try testing.expectEqualStrings("cons", found[1]); try testing.expect(first <= 150); - // THE RULE: the next read carries exactly the byte offset where the last - // one ended. Not the entry count, and not anything the client chose. try h.send(7, .{ .tread = .{ .fid = 0, .offset = first, .count = 150 } }); got = try h.reap(); const second = got.msg.rread.data.len; @@ -3868,8 +2585,6 @@ test "9p server: a directory read is whole stat records at a cursor the client c try testing.expectEqualStrings("new", found[0]); try testing.expectEqualStrings("1", found[1]); - // An arbitrary offset is refused — even one that would land on an entry - // boundary, which is exactly the case `ad` accepts silently (`9P-5`). try h.send(8, .{ .tread = .{ .fid = 0, .offset = first + second + 1, .count = 150 } }); got = try h.reap(); try testing.expectEqualStrings(e_bad_offset, got.msg.rerror.ename); @@ -3877,32 +2592,24 @@ test "9p server: a directory read is whole stat records at a cursor the client c got = try h.reap(); try testing.expectEqualStrings(e_bad_offset, got.msg.rerror.ename); - // The refusal did not move the cursor: the listing carries on. try h.send(10, .{ .tread = .{ .fid = 0, .offset = first + second, .count = 150 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 1), try dirNames(got.msg.rread.data, &found)); try testing.expectEqualStrings("2", found[0]); - // Zero bytes is END OF DIRECTORY, and it is not an error. try h.send(11, .{ .tread = .{ .fid = 0, .offset = first + second + got.msg.rread.data.len, .count = 150 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 0), got.msg.rread.data.len); - // Offset zero REWINDS, which is the only seek 9P allows in a directory. try h.send(12, .{ .tread = .{ .fid = 0, .offset = 0, .count = 150 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 2), try dirNames(got.msg.rread.data, &found)); try testing.expectEqualStrings("index", found[0]); - // A count too small for ONE entry is refused rather than answered with - // zero bytes, because zero bytes means end of directory and a client that - // believes it stops asking. try h.send(13, .{ .tread = .{ .fid = 0, .offset = 0, .count = 40 } }); got = try h.reap(); try testing.expectEqualStrings(e_count_small, got.msg.rerror.ename); - // A directory's entries carry the tree's default mode and a zero length; - // the exact bits come from `Tstat`, which asks the core. try h.send(14, .{ .tread = .{ .fid = 0, .offset = 0, .count = 150 } }); got = try h.reap(); const one = try Stat.decode(got.msg.rread.data[0 .. std.mem.readInt(u16, got.msg.rread.data[0..2], .little) + 2]); @@ -3917,6 +2624,39 @@ test "9p server: a directory read is whole stat records at a cursor the client c try testing.expectEqual(@as(u64, 1024), got.msg.rstat.stat.length); } +test "9p server: long directory names remain whole across pages" { + var h: Harness = .{}; + try h.handshake(4096); + var entries: [10 + 255 + 10 + 4]u8 = @splat(0); + std.mem.writeInt(u64, entries[0..8], 41, .little); + entries[9] = 255; + @memset(entries[10..265], 'f'); + std.mem.writeInt(u64, entries[265..273], 42, .little); + entries[274] = 4; + @memcpy(entries[275..], "next"); + var found: [2][]const u8 = undefined; + + h.srv.emitDirRead(5, 0, &entries, 330); + var got = try h.reap(); + const first = got.msg.rread.data.len; + try testing.expectEqual(@as(usize, 1), try dirNames(got.msg.rread.data, &found)); + try testing.expectEqualStrings(entries[10..265], found[0]); + try testing.expectEqual(@as(u64, 1), h.srv.fids[0].dirindex); + + h.srv.emitDirRead(6, 0, entries[265..], 330); + got = try h.reap(); + try testing.expectEqual(@as(usize, 1), try dirNames(got.msg.rread.data, &found)); + try testing.expectEqualStrings("next", found[0]); + try testing.expectEqual(@as(u64, 2), h.srv.fids[0].dirindex); + try testing.expectEqual(first + got.msg.rread.data.len, h.srv.fids[0].diroff); + + h.srv.emitDirRead(7, 0, &entries, 4096); + got = try h.reap(); + try testing.expectEqual(@as(usize, 2), try dirNames(got.msg.rread.data, &found)); + try testing.expectEqualStrings(entries[10..265], found[0]); + try testing.expectEqualStrings("next", found[1]); +} + test "9p server: a blocked read parks, and the connection keeps working" { var h: Harness = .{}; try h.handshake(4096); @@ -3924,25 +2664,18 @@ test "9p server: a blocked read parks, and the connection keeps working" { try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } }); _ = try h.reap(); - // `Status.again`: nothing consumed, ask me later. NOT an error and NOT an - // empty read — the client hears nothing at all. try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); try h.quiet(); - // A retry round finds it, the core still has nothing, and it goes back. h.pump(); h.pump(); try h.quiet(); - // Meanwhile the connection is not blocked: another tag is served while the - // read waits, which is the entire point of parking rather than waiting. _ = try h.walkTo(8, 2, &.{ "1", "body" }); try h.send(9, .{ .tstat = .{ .fid = 2 } }); var got = try h.reap(); try testing.expectEqualStrings("body", got.msg.rstat.stat.name); - // The core has something now, and the retry round is what delivers it — - // with the tag the client used seven messages ago. h.fsys.event = "Kli7 7 0 0 hello\n"; h.pump(); got = try h.reap(); @@ -3950,8 +2683,6 @@ test "9p server: a blocked read parks, and the connection keeps working" { try testing.expectEqualStrings("Kli7 7 0 0 hello\n", got.msg.rread.data); try h.quiet(); - // A write the core parks is copied out of the input buffer, so the next - // message may overwrite it and the retry still has its bytes. _ = try h.walkTo(10, 3, &.{ "1", "ctl" }); try h.send(11, .{ .topen = .{ .fid = 3, .mode = owrite } }); _ = try h.reap(); @@ -3966,9 +2697,6 @@ test "9p server: a blocked read parks, and the connection keeps working" { try testing.expectEqual(@as(u16, 12), got.tag); try testing.expectEqual(@as(u32, 6), got.msg.rwrite.count); try testing.expectEqualStrings("clean\n", h.fsys.writes[0..h.fsys.writes_len]); - // Overflow is a refusal and not a queue. Thirty-two blocked readers is - // thirty-two scripts watching one session; the thirty-third is told to - // retry, which is honest, rather than dropped, which would hang it. for (0..max_slots) |k| { try h.send(@intCast(100 + k), .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); try h.quiet(); @@ -3982,8 +2710,6 @@ test "9p server: the reply queue is a FIFO that survives a partial write" { var h: Harness = .{}; try h.handshake(4096); - // A UART writes what it can. What is left stays, in order, and the next - // reply lands behind it rather than on top of it. try h.send(5, .{ .tstat = .{ .fid = 0 } }); var saved: [256]u8 = undefined; const one = h.srv.output(); @@ -3999,10 +2725,6 @@ test "9p server: the reply queue is a FIFO that survives a partial write" { const second = try decode(tail[0..frameLen(tail).?]); try testing.expectEqual(@as(u16, 6), second.tag); - // `push` takes what there is room for and says how much, which is the only - // back-pressure a server with no descriptor has. (A buffer of zeros is - // also a `size` no encoder produced, so the connection dies on it — which - // is the other half of what a caller has to handle.) var flood: [8192]u8 = @splat(0); try testing.expectEqual(@as(usize, 4096), h.srv.push(&flood)); h.pump(); @@ -4019,9 +2741,6 @@ test "9p server: Tflush answers the original first and the Rflush second" { try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); try h.quiet(); - // TWO messages, in this order and no other. A client that sees `Rflush` - // may reuse the tag, so a reply arriving after it would be a reply to - // whatever that tag names next. try h.send(8, .{ .tflush = .{ .oldtag = 7 } }); var got = try h.reap(); try testing.expectEqual(@as(u16, 7), got.tag); @@ -4031,14 +2750,10 @@ test "9p server: Tflush answers the original first and the Rflush second" { try testing.expect(got.msg == .rflush); try h.quiet(); - // The park slot is gone with it: the core producing an event now sends - // nothing, rather than a second answer to a tag the client has reused. h.fsys.event = "Kli7 7 0 0 hello\n"; h.pump(); try h.quiet(); - // A flush of a tag we do not hold is an `Rflush` and nothing else, which - // is the only promise flush(5) makes. try h.send(9, .{ .tflush = .{ .oldtag = 99 } }); got = try h.reap(); try testing.expectEqual(@as(u16, 9), got.tag); @@ -4050,7 +2765,6 @@ test "9p server: the fid and permission refusals, each in a string Linux knows" var h: Harness = .{}; try h.handshake(4096); - // A fid nobody walked to. for ([_]Msg{ .{ .tread = .{ .fid = 99, .offset = 0, .count = 16 } }, .{ .tstat = .{ .fid = 99 } }, @@ -4063,8 +2777,6 @@ test "9p server: the fid and permission refusals, each in a string Linux knows" try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename); } - // A fid that was never opened, and one opened the other way round. Both - // are `u9fs.c:755-758`'s two conditions. _ = try h.walkTo(5, 1, &.{ "1", "body" }); try h.send(6, .{ .tread = .{ .fid = 1, .offset = 0, .count = 16 } }); var got = try h.reap(); @@ -4075,13 +2787,10 @@ test "9p server: the fid and permission refusals, each in a string Linux knows" got = try h.reap(); try testing.expectEqualStrings(e_bad_use, got.msg.rerror.ename); - // «must not have been opened for I/O» — walk(5). The fid IS the open. try h.send(9, .{ .twalk = .{ .fid = 1, .newfid = 2, .nwname = 0 } }); got = try h.reap(); try testing.expectEqualStrings(e_bad_use, got.msg.rerror.ename); - // THE PERMISSION CHECK IS OURS: there is no kernel above us to do it, and - // `errors` is write-only in acme's own dirtab. _ = try h.walkTo(10, 3, &.{ "1", "errors" }); try h.send(11, .{ .topen = .{ .fid = 3, .mode = oread } }); got = try h.reap(); @@ -4090,8 +2799,6 @@ test "9p server: the fid and permission refusals, each in a string Linux knows" got = try h.reap(); try testing.expect(got.msg == .ropen); - // A directory is read and only read; and nothing here can be removed, so - // nothing can be opened remove-on-close or executed either. try h.send(13, .{ .topen = .{ .fid = 0, .mode = ordwr } }); got = try h.reap(); try testing.expectEqualStrings(e_perm, got.msg.rerror.ename); @@ -4108,7 +2815,6 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { try h.handshake(4096); _ = try h.walkTo(5, 1, &.{ "1", "body" }); - // The sentinels stat(5) specifies: an empty string, an all-ones integer. const sentinel: Stat = .{ .type = std.math.maxInt(u16), .dev = std.math.maxInt(u32), @@ -4123,22 +2829,17 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { .muid = "", }; - // Nothing to do: accepted and ignored, which is what a filesystem of live - // editor state has to do with a mode, an owner and two times. try h.send(6, .{ .twstat = .{ .fid = 1, .stat = sentinel } }); var got = try h.reap(); try testing.expect(got.msg == .rwstat); try testing.expectEqualStrings("hello, body\n", h.fsys.body); - // A length that is neither the sentinel nor zero. The core honours exactly - // one value, and this string is one Linux maps to EPERM rather than 526. var five = sentinel; five.length = 5; try h.send(7, .{ .twstat = .{ .fid = 1, .stat = five } }); got = try h.reap(); try testing.expectEqualStrings(e_trunc_only, got.msg.rerror.ename); - // A rename would change the shape of a tree that follows the pane list. var renamed = sentinel; renamed.name = "other"; try h.send(8, .{ .twstat = .{ .fid = 1, .stat = renamed } }); @@ -4146,7 +2847,33 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { try testing.expectEqualStrings(e_wstat, got.msg.rerror.ename); try testing.expectEqualStrings("hello, body\n", h.fsys.body); - // ...and zero IS the truncate. + var changes: [12]Stat = @splat(sentinel); + changes[0].type = 0; + changes[1].dev = 0; + changes[2].qid.type = 0; + changes[3].qid.version = 0; + changes[4].qid.path = 0; + changes[5].mode = 0o644; + changes[6].atime = 0; + changes[7].mtime = 0; + changes[8].name = "renamed"; + changes[9].uid = "owner"; + changes[10].gid = "group"; + changes[11].muid = "writer"; + for (changes) |change| { + for ([_]u64{ std.math.maxInt(u64), 0 }) |length| { + var attributes = change; + attributes.length = length; + const calls = h.fsys.calls; + try h.send(20, .{ .twstat = .{ .fid = 1, .stat = attributes } }); + got = try h.reap(); + try testing.expect(got.msg == .rerror); + try testing.expectEqualStrings(e_wstat, got.msg.rerror.ename); + try testing.expectEqual(calls, h.fsys.calls); + try testing.expectEqualStrings("hello, body\n", h.fsys.body); + } + } + var zero = sentinel; zero.length = 0; try h.send(9, .{ .twstat = .{ .fid = 1, .stat = zero } }); @@ -4154,9 +2881,6 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { try testing.expect(got.msg == .rwstat); try testing.expectEqualStrings("", h.fsys.body); - // The other spelling of the same thing, and the one a shell's `>` - // produces: `Topen` with `OTRUNC` is a truncate and then an open, in that - // order, and it is refused on a fid with no write permission. h.fsys.body = "hello, body\n"; _ = try h.walkTo(10, 2, &.{"index"}); try h.send(11, .{ .topen = .{ .fid = 2, .mode = oread | otrunc } }); @@ -4172,16 +2896,10 @@ test "9p server: create and remove are refused, and a remove clunks the fid anyw var h: Harness = .{}; try h.handshake(4096); - // Nothing in a generated tree is a client's to make. The one place a - // client DOES create something is `new/`, where the WALK creates a pane — - // so the capability exists and is not spelled `Tcreate` (`9P-18`). try h.send(5, .{ .tcreate = .{ .fid = 0, .name = "thing", .perm = 0o600, .mode = owrite } }); var got = try h.reap(); try testing.expectEqualStrings(e_perm, got.msg.rerror.ename); - // A remove is refused too — but «the fid is clunked even if the remove - // fails», which is the half of remove(5) that is easy to miss, and the - // release still goes to the core. _ = try h.walkTo(6, 1, &.{ "1", "body" }); try h.send(7, .{ .topen = .{ .fid = 1, .mode = ordwr } }); _ = try h.reap(); @@ -4198,8 +2916,6 @@ test "9p server: a message arriving a byte at a time is served when its last byt var h: Harness = .{}; try h.handshake(4096); - // The board's UART, and a socket that happened to split a write. Framing - // is `size[4]` and nothing may be served until all of it is in. var buf: [64]u8 = undefined; const bytes = try encode(.{ .tstat = .{ .fid = 0 } }, 5, &buf); for (bytes[0 .. bytes.len - 1]) |b| { @@ -4212,8 +2928,6 @@ test "9p server: a message arriving a byte at a time is served when its last byt const got = try h.reap(); try testing.expectEqualStrings("/", got.msg.rstat.stat.name); - // TWO messages in one push are two replies, in order, and the input buffer - // ends up empty. var pair: [128]u8 = undefined; const a = try encode(.{ .tstat = .{ .fid = 0 } }, 6, &pair); const b = try encode(.{ .tstat = .{ .fid = 0 } }, 7, pair[a.len..]); @@ -4231,8 +2945,6 @@ test "9p server: what is not 9P2000 on this connection is refused, not guessed" var buf: [64]u8 = undefined; const good = try encode(.{ .tstat = .{ .fid = 0 } }, 5, &buf); - // An R-message: a client on the wrong end of the connection, or the - // double-role link docs/9p.typ §7 says not to build. var raw: [64]u8 = undefined; @memcpy(raw[0..good.len], good); raw[4] = @intFromEnum(Type.rstat); @@ -4242,9 +2954,6 @@ test "9p server: what is not 9P2000 on this connection is refused, not guessed" try testing.expectEqual(@as(u16, 5), got.tag); try testing.expectEqualStrings(e_botch, got.msg.rerror.ename); - // A type byte no dialect we serve defines — 8 is 9P2000.L's `Tstatfs` — - // still gets an answer, because the tag is at a fixed offset and a client - // that gets no reply hangs. @memcpy(raw[0..good.len], good); raw[4] = 8; _ = h.srv.push(raw[0..good.len]); @@ -4253,8 +2962,6 @@ test "9p server: what is not 9P2000 on this connection is refused, not guessed" try testing.expectEqual(@as(u16, 5), got.tag); try testing.expectEqualStrings(e_botch, got.msg.rerror.ename); - // A `size` no encoder could have produced is not a message to answer: the - // stream is not 9P and there is no resynchronising from it. @memcpy(raw[0..good.len], good); std.mem.writeInt(u32, raw[0..4], 3, .little); _ = h.srv.push(raw[0..good.len]); @@ -4272,21 +2979,14 @@ test "9p server: a connection that drops still pays the core its releases" { try h.send(@intCast(tag), .{ .topen = .{ .fid = fid, .mode = oread } }); _ = try h.reap(); } - // One blocked reader, so there is a parked request to abandon as well. try h.send(9, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); try h.quiet(); - // The socket died. Every open fid still owes the core a release, and that - // debt outlives the connection — losing it leaves the editor reporting - // button actions to a script that is gone. h.srv.hangup(); h.pump(); try testing.expectEqual(@as(u32, 2), h.fsys.releases); - // ...and nothing is written to a socket that has gone. try h.quiet(); - // `Tversion` is the same reset on a live connection: fids clunked, - // outstanding I/O abandoned, releases still paid (version(5)). var g: Harness = .{}; try g.handshake(4096); _ = try g.walkTo(5, 1, &.{ "1", "event" }); @@ -4298,7 +2998,6 @@ test "9p server: a connection that drops still pays the core its releases" { const v = try g.reap(); try testing.expectEqualStrings("9P2000", v.msg.rversion.version); try testing.expectEqual(@as(u32, 1), g.fsys.releases); - // The abandoned read is never answered, and the fid is gone. g.fsys.event = "Kli7 7 0 0 hello\n"; g.pump(); try g.quiet(); @@ -4308,10 +3007,6 @@ test "9p server: a connection that drops still pays the core its releases" { } test "9p server: every errno the core can answer is a string Linux knows" { - // The nine values `acmefs.E` defines, spelled exactly as - // `linux/net/9p/error.c:41-171` holds them. A typo in any of these is - // "Unknown error 526" on every `mount -t 9p`, which is why they are - // asserted as literals rather than derived from anything. try testing.expectEqualStrings("Operation not permitted", errString(1)); try testing.expectEqualStrings("No such file or directory", errString(2)); try testing.expectEqualStrings("Input/output error", errString(5)); @@ -4321,13 +3016,9 @@ test "9p server: every errno the core can answer is a string Linux knows" { try testing.expectEqualStrings("Too many open files in system", errString(23)); try testing.expectEqualStrings("No space left on device", errString(28)); try testing.expectEqualStrings("Function not implemented", errString(38)); - // A number this file never emits is EIO, not a table miss. try testing.expectEqualStrings("Input/output error", errString(0)); try testing.expectEqualStrings("Input/output error", errString(999)); - // The server's own strings, from the same table and the fossil/u9fs half - // of it. Every one of these is a line in `error.c`, which is the whole - // difference between an errno and 526. try testing.expectEqualStrings("fid unknown or out of range", e_unknown_fid); try testing.expectEqualStrings("fid already in use", e_fid_in_use); try testing.expectEqualStrings("bad use of fid", e_bad_use); @@ -4342,7 +3033,6 @@ test "9p server: every errno the core can answer is a string Linux knows" { try testing.expectEqualStrings("only support truncation to zero length", e_trunc_only); try testing.expectEqualStrings("wstat prohibited", e_wstat); try testing.expectEqualStrings("Resource temporarily unavailable", e_again); - // Every one of them fits the buffer a Plan 9 client has for it. for ([_][]const u8{ e_unknown_fid, e_fid_in_use, e_bad_use, e_bad_offset, e_perm, e_not_dir, e_already_open, e_botch, e_interrupted, e_trunc_only, @@ -4351,166 +3041,50 @@ test "9p server: every errno the core can answer is a string Linux knows" { }) |s| try testing.expect(s.len <= errmax); } -test "9p server: the fid table and the park table are what the board was costed for" { - // `docs/registry.typ` `9P-11` costed a fid table at 32 x 16 bytes. The - // real entry is larger, and the difference is not a mistake in either - // place: it is the entry NAME, which `Rstat` carries and a node id does - // not, plus the open handle and the two-coordinate directory cursor. The - // numbers are asserted here so that a change to `Fid` shows up as a diff - // in the board's budget rather than as a surprise on the board. - // - // TWO budgets, because there are now two numbers. `max_fids` is the desktop - // one and it is sized for a MOUNT, which keeps a fid per cached inode; - // `board_fids` is what a microcontroller serving its own small tree uses, - // and it is the one `9P-11` costed. - const S = Server(StubFs); - const entry = @sizeOf(S.Fid); - const slots = @sizeOf(S.Slot) * max_slots; - try testing.expect(slots <= 8 * 1024); - - // The board: two buffers at a 4,096-byte msize — `in` and `out`'s two — - // plus the two tables, against 336 KB of free heap on the P4. - const board = entry * board_fids; - try testing.expect(board <= 3 * 1024); - try testing.expect(3 * 4096 + board + slots <= 24 * 1024); - - // The desktop, against the ≈34 KiB per connection `fs9_service` budgets. - // Eight times the fids is ≈16 KiB, and it is the price of `find` working - // over a `9pfuse` mount — see `max_fids`. - try testing.expect(entry * max_fids <= 24 * 1024); +test "9p server: board and native capacities size the actual fid storage" { + const Board = Server(StubFs, board_fids); + const Native = Server(StubFs, max_fids); + const BoardFids = @FieldType(Board, "fids"); + const NativeFids = @FieldType(Native, "fids"); + try testing.expectEqual(32, @typeInfo(BoardFids).array.len); + try testing.expectEqual(256, @typeInfo(NativeFids).array.len); + try testing.expectEqual(32 * @sizeOf(Board.Fid), @sizeOf(BoardFids)); + try testing.expectEqual(256 * @sizeOf(Native.Fid), @sizeOf(NativeFids)); + try testing.expectEqual( + @sizeOf(NativeFids) - @sizeOf(BoardFids), + @sizeOf(Native) - @sizeOf(Board), + ); + try testing.expect(@sizeOf(BoardFids) <= 3 * 1024); + try testing.expect(@sizeOf(NativeFids) <= 24 * 1024); + try testing.expect(@sizeOf(@FieldType(Board, "slots")) <= 8 * 1024); + try testing.expect(3 * 4096 + @sizeOf(Board) <= 24 * 1024); } -// --------------------------------------------------------------------------- -// the client -// --------------------------------------------------------------------------- - -/// Tags one client may have outstanding at once. -/// -/// SIXTEEN, and the reasoning is `max_fids`': a fixed array with no allocator, -/// scanned rather than mapped, and a refusal rather than a queue when it fills. -/// The protocol's tag space is 0..0xFFFE — `notag` is 0xFFFF and belongs to the -/// handshake — so this uses the bottom sixteen of sixty-five thousand and never -/// a number above them. THE TAG IS ITS OWN INDEX, which is what makes matching -/// a reply O(1) with no search and no bookkeeping: see `tags`. -/// -/// MANY OUTSTANDING REQUESTS ARE LEGAL and the prior art imposes no bound at -/// all: Linux's client takes a tag per request out of an IDR -/// (`net/9p/client.c:194-199`) and Plan 9's devmnt keeps an `Mntrpc` per -/// request on a free list (`devmnt.c:783-800`), because on both the outstanding -/// count is «one per process blocked in an I/O», which the kernel already -/// bounds elsewhere. Here the count is "one per thing pardes is fetching", and -/// a screen does not hold sixteen remote panes. Overflow is `error.NoTags` at -/// the moment of asking — the caller collects an answer and asks again — and -/// never a silent wait, because a client that blocks is the one thing this -/// design does not have anywhere to put. pub const max_tags: usize = 16; -/// `Twrite`'s own header: `size[4] type[1] tag[2] fid[4] offset[8] count[4]`. -/// What `maxWrite` subtracts from the msize. -/// -/// NOT `iohdrsz`. That number (24) is the slack a SERVER quotes in `iounit` and -/// is deliberately larger than any real header; a client sizing its own request -/// against it leaves a byte on the table on every write forever. const twrite_header: usize = header_len + 4 + 8 + 4; -/// `Rread`'s header: `size[4] type[1] tag[2] count[4]`. What `maxRead` -/// subtracts, and the reason a client's `count` is not simply the msize — the -/// reply has to carry a header too, and a `count` of msize is a reply eleven -/// bytes too long for the connection that asked for it. const rread_header: usize = header_len + 4; comptime { assert(twrite_header == 23); assert(rread_header == 11); - // The tag IS the index, so the table's length is the tag space in use, and - // the handshake's `notag` must fall outside it or a `Rversion` would land - // on somebody's slot. assert(max_tags <= notag); - // At the smallest msize this file will agree to, a read and a write must - // both still be able to carry a byte, or a connection could be negotiated - // that cannot do any I/O at all. assert(msize_min > rread_header); assert(msize_min > twrite_header); } -/// Every way `submit` can refuse, and each is a different thing for the caller -/// to do about it. pub const ClientError = error{ - /// All `max_tags` are outstanding. Collect an answer and ask again. NoTags, - /// The out queue has no room for this request. Write `output()` out and - /// ask again. THE ONLY BACK-PRESSURE a sans-io client has. NoSpace, - /// The request cannot fit the negotiated msize: a `Twrite` past - /// `maxWrite()`, a `Tread` asking past `maxRead()`, a sixteen-element walk - /// of long names. REFUSED AND NOT CLAMPED, because `submit` answers with a - /// tag and nothing else — a silent clamp would leave the caller to guess - /// how much of its buffer went, and guess wrong about the offset to - /// continue from. `maxRead` and `maxWrite` are how a caller chunks first. TooLarge, - /// A request before `Rversion` has landed, or a second `Tversion` while - /// requests are outstanding. Handshake, - /// The stream is not 9P any more and this connection is finished. See - /// `dead`. Dead, - /// A request no encoding of 9P admits: `nofid` as a fid, an empty walk - /// element or one with a separator in it, more than `max_welem` elements. - /// A bug in the caller, caught here rather than spent as a round trip. BadRequest, }; -/// A 9P2000 client for one connection. -/// -/// THE MIRROR OF `Server`, and deliberately the same shape: caller-owned `in` -/// and `out` buffers, no allocator, no threads, no descriptor, `std` for -/// `readInt`/`writeInt` and nothing else. So it compiles for the board's -/// `riscv32-freestanding` and runs over `src/esp32p4/uart.zig`'s non-blocking -/// receive and bounded-spin transmit exactly as it runs over a unix socket in -/// `src/fs9_client.zig` — which is the whole reason for the shape, because a -/// client that owned its descriptor would be a client that could not. -/// -/// THE API IS A STATE MACHINE AND NOT `fn read() []u8`, because the core is -/// single-threaded and never blocks (docs/9p.typ §12.5). The three moving parts -/// are: -/// -/// 1. `submit(Request)` ENCODES a T-message into the out queue and hands -/// back its tag. It never waits and never touches a descriptor; a full -/// queue or a full tag table is a refusal the caller can act on. -/// 2. `push`/`output`/`wrote` move bytes, in whatever sizes the transport -/// manages, in whatever order they arrive. -/// 3. `take()` answers with the next COMPLETED operation, or null when there -/// is not a whole reply buffered yet. A caller's frame is -/// `while (client.take()) |done| ...`, which is `Server.next()`'s own -/// loop-until-null contract read from the other side. -/// -/// WHY COMPLETION IS A PULL AND NOT A CALLBACK: a callback would run inside -/// `push`, which is inside the transport's read, which is inside the host's -/// poll dispatch — and `src/fs9_service.zig` already states why filesystem -/// work must not happen there. Pulling puts the caller's own code back on the -/// caller's own stack. -/// -/// WHY THERE IS NO PER-TAG RESULT QUEUE: one frame completes exactly one -/// operation, and `take` returns it immediately, so there is never a completed -/// answer nobody has collected. That is what keeps a tag slot two bytes wide -/// instead of an msize wide, and it is why `Done` may borrow `in` (see there). -/// -/// REPLIES MAY ARRIVE IN ANY ORDER and this client does not care: the tag is -/// its own index into `tags`, so attribution is one bounds check and one -/// array read, with no assumption about arrival order anywhere in the file. -/// The reply's TYPE is checked against the request's `Op` as well, because a -/// tag is only as good as the table behind it. -/// -/// MEMORY: the two buffers, and `@sizeOf(Client)` for everything else — a -/// sixteen-entry tag table of eight-byte entries plus nine scalars, asserted at -/// the bottom of this file. Nothing here grows and nothing here is allocated. pub const Client = struct { - /// Reply bytes the caller has pushed. `in[0..frame]` is the reply most - /// recently returned by `take`, and every slice a `Done` holds points into - /// it — which is why nothing compacts this buffer until the next `take`. in: []u8, - /// Encoded requests, oldest first, as a byte FIFO. Every 9P message - /// carries its own length, so the queue needs no side table. out: []u8, in_len: usize = 0, @@ -4518,179 +3092,60 @@ pub const Client = struct { out_len: usize = 0, out_off: usize = 0, - /// Negotiated by the handshake; ZERO means "not on a protocol yet", and - /// nothing but `version` may be submitted in that state. It is also zero - /// after an `Rversion` of "unknown", which is a completed handshake with - /// no dialect in common. msize: u32 = 0, - /// What our own `Tversion` offered, kept only so that `Rversion` can be - /// checked against it: «the server responds with its own maximum, which - /// must be less than or equal to the client's». asked: u32 = 0, - /// A `Tversion` is outstanding. Its tag is `notag`, so it cannot live in - /// the table below — and it does not need to, because the protocol allows - /// nothing else to be outstanding beside it. versioning: bool = false, - /// The stream is not 9P and there is no resynchronising from it. Write-once, - /// like `Server.dead`: a reply that cannot be attributed is worse than a - /// closed connection, because the caller would wait on it forever. dead: bool = false, - /// THE TAG TABLE, indexed BY THE TAG. `tags[t].op` is null when tag `t` is - /// free, which makes claiming a tag a scan of sixteen and matching a reply - /// a single index — and it means a caller may keep its own per-request - /// state in a plain sixteen-entry array of its own, keyed the same way, - /// with no map on either side. tags: [max_tags]Slot = @splat(.{}), - /// What is remembered about one outstanding request, which is as little as - /// the protocol lets us get away with: what it was, and — for a read — - /// what it asked for, because `read(5)` bounds the reply by it and a - /// server that ignores that bound is handing back bytes at offsets we - /// never asked about. const Slot = struct { op: ?Op = null, count: u32 = 0, }; - /// What a client asked for. The tag names of `Request` and of `Result`'s - /// answers are these, so nothing maps one to the other by hand. - /// - /// EIGHT OPERATIONS AND NOT THIRTEEN, and the five absences are decisions: - /// - /// * `Tauth`: there is no authentication in this design and the server half - /// refuses it by name (`e_no_auth`). The socket's permissions are the - /// protection. - /// * `Tcreate`/`Tremove`: the server refuses both, because the shape of the - /// tree follows the pane list. Walking into `new/` is how a client creates - /// a pane, and that is a `walk`. - /// * `Twstat`: the one wstat the tree honours is a truncate, and a client - /// that wants to empty a file opens it `OTRUNC` in the same round trip. - /// * `Tflush`: nothing here has a cancel button. A flush costs a second tag - /// and brings a reply-ORDER rule with it — «the Rflush must come after the - /// original reply» — which is a rule nobody exercises if no caller can - /// change its mind, and an unexercised ordering rule in a protocol client - /// is a bug waiting for its first user. pub const Op = enum { version, attach, walk, open, read, write, clunk, stat }; - /// One request, as its caller states it. A `union(Op)` rather than eight - /// functions so that `submit` is one entry point with one refusal path: every - /// bound this client has — the tag table, the out queue, the msize — applies to - /// all eight identically, and a ninth operation cannot forget one of them. - /// - /// NO TAG FIELD: the tag is what `submit` HANDS BACK. A caller that chose its - /// own tags would be maintaining the table this file already maintains. pub const Request = union(Op) { - /// The handshake. `msize` is the largest message this client will send or - /// accept, and ZERO means "as much as my buffers hold", which is the - /// answer a caller with no opinion wants. Clamped to the buffers either - /// way; see `beginVersion`. version: struct { msize: u32 = 0 }, - /// `afid` is not a parameter: it is always `nofid`, because this client - /// never sends `Tauth`. attach: struct { fid: u32, uname: []const u8, aname: []const u8 = "" }, - /// The path elements, already split. A SLICE OF SLICES rather than the - /// codec's fixed `[max_welem]` array, because a caller has a path and not - /// an array: the copy into the fixed array happens once, in `submit`, - /// where the `nwname` bound is checked anyway. An empty list is the legal - /// zero-element walk, which clones `fid` onto `newfid`. walk: struct { fid: u32, newfid: u32, names: []const []const u8 }, open: struct { fid: u32, mode: u8 }, - /// `count` is refused rather than clamped above `maxRead()`; see there. read: struct { fid: u32, offset: u64, count: u32 }, - /// `data` is COPIED into the out queue by `submit` and is not borrowed - /// afterwards, which is what lets a caller write out of a buffer it is - /// about to reuse. write: struct { fid: u32, offset: u64, data: []const u8 }, clunk: struct { fid: u32 }, stat: struct { fid: u32 }, }; - /// What one request came to. The answer's SHAPE, which is what a caller acts - /// on; `Done.op` says which request it belongs to and `Done.tag` says which - /// one of several. pub const Result = union(enum) { - /// The server said no: `Rerror`'s string, and the only variant that can - /// answer ANY of the eight. Borrows the input buffer — see `Done`. fail: []const u8, - /// `version` is "9P2000", or the literal "unknown", which is a SUCCESSFUL - /// reply meaning no dialect in common. `Client.msize` is nonzero only in - /// the first case, so the second leaves a connection on which nothing can - /// be submitted and the caller hangs up. version: struct { msize: u32, version: []const u8 }, attach: Qid, - /// `nwqid` may be SHORTER than the walk's element count: a partial walk is - /// a success with fewer qids, and only a failure on the FIRST element is - /// an `Rerror`. So a caller MUST compare `nwqid` against what it asked for - /// before believing its fid landed anywhere. - /// - /// The whole array is carried rather than only the last qid, because the - /// last one is the only thing THIS tree's clients want and the - /// intermediate ones are what a caching client caches against - /// (`Qid.version`). Two hundred and eight bytes, on a value the caller - /// consumes and drops. walk: struct { nwqid: u16, wqid: [max_welem]Qid }, open: struct { qid: Qid, iounit: u32 }, - /// The bytes, borrowing the input buffer — see `Done`. SHORTER than the - /// requested count is normal and is not the end of the file; ZERO bytes is - /// the end of the file. read: []const u8, - /// The count actually written, which may be short — the caller advances - /// its offset by this and not by what it asked. write: u32, clunk: void, - /// Borrows the input buffer for its four strings — see `Done`. stat: Stat, }; - /// One completed operation. - /// - /// BORROWS THE INPUT BUFFER, and this is the whole lifetime rule: a `Done` is - /// valid until the next call to anything on the `Client` that produced it. The - /// `fail` string, the `read` bytes and the `stat` strings all point into - /// `Client.in`, exactly as `decode`'s do and for the same reason — the - /// alternative is a per-tag copy of every payload, which on the board is - /// sixteen msizes of static RAM to save a caller one `@memcpy` it may not even - /// want. `take` releases the previous answer's frame on entry, so the rule is - /// enforced by construction rather than by hope: a caller that keeps a `Done` - /// across a second `take` is reading bytes the next reply has been decoded - /// into. pub const Done = struct { - /// The tag `submit` handed out, or `notag` for the handshake. FREE again - /// the moment this is returned, so a caller that indexes its own - /// sixteen-entry table by tag must read this entry out before submitting - /// anything else. tag: u16, - /// Which of the eight this answers. Needed beside `result` because - /// `Rerror` answers all of them and carries no hint of which. op: Op, result: Result, }; pub const Options = struct { - /// Room for one whole reply. Caps the msize with `out`. in: []u8, - /// Room for one whole request, at least. MORE room is what buys - /// pipelining: sixteen outstanding `Tread`s are sixteen small messages - /// that all have to fit here at once, and `submit` answers - /// `error.NoSpace` rather than blocking when they do not. out: []u8, }; - /// The buffers are the caller's, which is what "no allocator" means from - /// this side: the board hands over two static arrays, a host hands over - /// two heap slices, and this file cannot tell the difference. The msize - /// follows from them and from the server's `Rversion`. pub fn init(opts: Options) Client { assert(opts.in.len >= msize_min); assert(opts.out.len >= msize_min); return .{ .in = opts.in, .out = opts.out }; } - /// The connection went away, or the caller is done with it. Unlike - /// `Server.hangup` there is no debt to pay: a client owes the far end - /// nothing on the way out — its fids are the server's to clean up when the - /// stream closes, which is exactly what `Server.hangup` is for. pub fn hangup(c: *Client) void { c.dead = true; c.tags = @splat(.{}); @@ -4703,20 +3158,6 @@ pub const Client = struct { c.out_off = 0; } - // -- bytes in, bytes out --------------------------------------------- - // - // The four `Server` has, written out again rather than shared. They look - // identical and they are not the same three lines: `Server.push` refuses - // once dead, and `Server.hasRoom` reserves a whole msize before a request - // is handed to the core so that no reply can fail to be written. A client - // reserves nothing — it refuses at `submit`, where the caller is standing - // right there — so a shared FIFO would be one struct with two callers and - // two exceptions, which is more to read than this is. - - /// Take as much of `bytes` as there is room for, and answer how much. A - /// short answer is not a loss: it is back-pressure, and the caller - /// re-offers the tail after `take`ing what it can. Bytes are APPENDED, so - /// the reply currently being borrowed by a `Done` does not move. pub fn push(c: *Client, bytes: []const u8) usize { if (c.dead) return 0; const n = @min(bytes.len, c.in.len - c.in_len); @@ -4725,14 +3166,10 @@ pub const Client = struct { return n; } - /// The requests waiting to go, oldest first, as one contiguous run of - /// whole 9P messages. Valid until the next call to anything else here. pub fn output(c: *const Client) []const u8 { return c.out[c.out_off..c.out_len]; } - /// How many of `output()`'s bytes actually left. A partial write is normal - /// on a UART and on a full socket, and the remainder stays put. pub fn wrote(c: *Client, n: usize) void { assert(n <= c.out_len - c.out_off); c.out_off += n; @@ -4742,9 +3179,6 @@ pub const Client = struct { } } - /// Slide the unwritten tail down. Called only when room is wanted, so the - /// common case — a fully written queue, reset to empty by `wrote` — never - /// moves a byte. fn compact(c: *Client) void { assert(c.out_off <= c.out_len); const n = c.out_len - c.out_off; @@ -4753,9 +3187,6 @@ pub const Client = struct { c.out_len = n; } - /// Release the reply `take` last returned and slide the rest of the input - /// down. One message-long move per message; a ring buffer would let a - /// decoded reply straddle the wrap and stop being one slice. fn dropFrame(c: *Client) void { assert(c.frame != 0); assert(c.frame <= c.in_len); @@ -4765,46 +3196,25 @@ pub const Client = struct { c.frame = 0; } - // -- what a caller may ask for --------------------------------------- - - /// The largest `Tread.count` this connection can answer, which is the - /// msize less `Rread`'s own header. Zero before the handshake. pub fn maxRead(c: *const Client) u32 { if (c.msize == 0) return 0; return c.msize - @as(u32, @intCast(rread_header)); } - /// The most bytes one `Twrite` can carry, which is the msize less - /// `Twrite`'s own header. Zero before the handshake. A caller with more - /// than this chunks; see `ClientError.TooLarge` for why it is not clamped. pub fn maxWrite(c: *const Client) u32 { if (c.msize == 0) return 0; return c.msize - @as(u32, @intCast(twrite_header)); } - /// Requests outstanding, the handshake included. What a caller's loop - /// tests to know whether there is anything left to wait for. pub fn pending(c: *const Client) usize { var n: usize = @intFromBool(c.versioning); for (c.tags) |t| n += @intFromBool(t.op != null); return n; } - // -- asking ------------------------------------------------------------ - - /// Encode one request into the out queue and hand back its tag. Never - /// blocks, never waits, never touches a descriptor. - /// - /// The refusals are in one order on purpose: what is wrong with the - /// REQUEST first, then what is wrong with this client's tables, so a - /// caller's bad argument never costs a tag and never half-fills the queue. pub fn submit(c: *Client, req: Request) ClientError!u16 { if (c.dead) return error.Dead; if (req == .version) return c.beginVersion(req.version.msize); - // «The client must communicate the version before any other messages» - // — and until `Rversion` has landed there is no msize to bound - // anything by, which is the same gate `Server.startFrame` applies from - // the other side. if (c.msize == 0 or c.versioning) return error.Handshake; const msg: Msg = switch (req) { @@ -4820,17 +3230,9 @@ pub const Client = struct { }, .walk => |m| blk: { if (m.fid == nofid or m.newfid == nofid) return error.BadRequest; - // `MAXWELEM` is a hard protocol bound and not a buffer size: - // every implementation refuses a seventeen-element walk, so a - // caller with a deeper path splits it into two walks. if (m.names.len > max_welem) return error.BadRequest; var w: [max_welem][]const u8 = @splat(""); for (m.names, 0..) |n, i| { - // An empty element, or one with a separator in it, is a - // caller that has not split its path. `Twalk` has no - // encoding for either and a server answers the first one - // `illegal name` — a round trip spent on a bug that was - // visible from here. if (n.len == 0) return error.BadRequest; if (std.mem.indexOfAny(u8, n, "/\x00") != null) return error.BadRequest; w[i] = n; @@ -4848,8 +3250,6 @@ pub const Client = struct { }, .read => |m| blk: { if (m.fid == nofid) return error.BadRequest; - // The one bound the request's own length does not express: - // what comes BACK has to fit the connection too. if (m.count > c.maxRead()) return error.TooLarge; break :blk .{ .tread = .{ .fid = m.fid, .offset = m.offset, .count = m.count } }; }, @@ -4866,10 +3266,6 @@ pub const Client = struct { break :blk .{ .tstat = .{ .fid = m.fid } }; }, }; - // ONE ceiling for every request, which is what makes a `Twrite` and a - // sixteen-element `Twalk` obey the same rule: the msize is «the - // maximum length, in bytes, ... including the size field», and a - // client that sends more is a client the server closes on. const need = totalLen(msg) catch return error.TooLarge; if (need > c.msize) return error.TooLarge; @@ -4881,24 +3277,10 @@ pub const Client = struct { return tag; } - /// `Tversion`, which is the one exchange with no tag and no msize behind - /// it. - /// - /// A SECOND ONE IS A CONNECTION RESET — «all fids are clunked and any - /// outstanding I/O is abandoned» (`version(5)`) — and abandoning somebody - /// else's request is not this function's decision to make. So it is - /// refused while anything is outstanding, and a caller that means to reset - /// collects its answers or hangs up first. fn beginVersion(c: *Client, want: u32) ClientError!u16 { if (c.pending() != 0) return error.Handshake; - // Two ceilings and the smaller wins: what one reply buffer holds, and - // what one request buffer holds. A caller with no opinion passes zero - // and gets both. const cap: u32 = @intCast(@min(c.in.len, c.out.len, std.math.maxInt(u32))); const m = @min(if (want == 0) cap else want, cap); - // Below the floor there is a connection that cannot carry an `Rwalk`, - // which is to say no connection at all. `init` asserts the buffers - // clear it, so this can only be a `want` the caller chose. if (m < msize_min) return error.BadRequest; try c.emit(notag, .{ .tversion = .{ .msize = m, .version = "9P2000" } }); c.msize = 0; @@ -4907,9 +3289,6 @@ pub const Client = struct { return notag; } - /// The lowest free tag, marked used. Lowest rather than round-robin so - /// that a client with one request outstanding always uses tag 0, which - /// makes a wire trace readable by eye. fn claim(c: *Client, op: Op) ?u16 { for (&c.tags, 0..) |*t, i| { if (t.op != null) continue; @@ -4919,67 +3298,37 @@ pub const Client = struct { return null; } - /// Queue one request. The only way this fails is room: `totalLen` has - /// already refused every other way `encode` can, which is why the error - /// set collapses to one value here. fn emit(c: *Client, tag: u16, msg: Msg) ClientError!void { if (c.out_off != 0) c.compact(); const bytes = encode(msg, tag, c.out[c.out_len..]) catch return error.NoSpace; c.out_len += bytes.len; } - // -- collecting -------------------------------------------------------- - - /// The next completed operation, or null when there is not a whole reply - /// buffered yet. Call in a loop until null, once per frame. - /// - /// A `Done` BORROWS the input buffer and is valid until the next call - /// here: the previous reply's frame is released on entry, which is what - /// makes that rule mechanical instead of a note somebody has to remember. pub fn take(c: *Client) ?Done { if (c.frame != 0) c.dropFrame(); if (c.dead) return null; const len = frameLen(c.in[0..c.in_len]) orelse return null; - // A `size` no encoder produced, or one this connection could never - // buffer: either way the stream is not 9P and waiting for the rest of - // it is waiting forever. if (len < header_len or len > c.in.len) return c.die(); - // And a server that sends past the msize it agreed to has stopped - // speaking the protocol it agreed to. if (c.msize != 0 and len > c.msize) return c.die(); if (len > c.in_len) return null; c.frame = len; - // A body this codec refuses is not a message we can attribute to a - // tag, so there is nobody to report it to. The connection ends. const got = decode(c.in[0..len]) catch return c.die(); return c.consume(got); } - /// The stream is finished. Returns null so that every refusal in `take` - /// and `consume` is one expression. fn die(c: *Client) ?Done { c.dead = true; return null; } - /// One decoded reply onto the request it answers. fn consume(c: *Client, got: Decoded) ?Done { - // A CLIENT READS R-MESSAGES. A T-message here is the other end of the - // connection talking, or the double-role link docs/9p.typ §7 tells us - // not to build — the exact mirror of `Server.startFrame`'s refusal, - // and the encoding's own parity does the work in both directions. if (isT(got.msg.msgType())) return c.die(); if (got.msg == .rversion) return c.version(got); - // Nothing may arrive before a `Tversion` has been answered, and - // nothing but the `Rversion` while one is outstanding. if (c.versioning or c.msize == 0) return c.die(); - // The tag is its own index, so this bounds check IS the lookup. if (got.tag >= max_tags) return c.die(); const slot = &c.tags[got.tag]; const op = slot.op orelse return c.die(); const result: Result = switch (got.msg) { - // `Rerror` answers ANY of the eight, which is exactly why `Done` - // reports the op beside it: the string does not say what failed. .rerror => |m| .{ .fail = m.ename }, .rattach => |m| if (op != .attach) return c.die() else .{ .attach = m.qid }, .rwalk => |m| if (op != .walk) return c.die() else .{ @@ -4990,78 +3339,35 @@ pub const Client = struct { }, .rread => |m| blk: { if (op != .read) return c.die(); - // «count ... indicates the number of bytes returned», and - // read(5) makes it no more than what was asked. Linux calls a - // longer one a hard `-EIO` (`net/9p/client.c:1475-1479`); here - // it ends the connection, because the byte after the ones we - // asked for is a byte we have no offset to put anywhere. if (m.data.len > slot.count) return c.die(); break :blk .{ .read = m.data }; }, .rwrite => |m| if (op != .write) return c.die() else .{ .write = m.count }, .rclunk => if (op != .clunk) return c.die() else .clunk, .rstat => |m| if (op != .stat) return c.die() else .{ .stat = m.stat }, - // The rest are replies to requests this client does not send — - // `Rauth`, `Rcreate`, `Rremove`, `Rwstat`, `Rflush` — and one is - // not an answer at all (`Rerror`'s illegal twin `Terror`, already - // refused by parity above). A reply to a request nobody made means - // the tag space is not what we think it is. else => return c.die(), }; slot.* = .{}; return .{ .tag = got.tag, .op = op, .result = result }; } - /// `Rversion`: the msize handshake, from the client's side. fn version(c: *Client, got: Decoded) ?Done { if (!c.versioning) return c.die(); - // «Rversion ... carries the same tag», and that tag is `notag`, - // because tags do not mean anything yet. if (got.tag != notag) return c.die(); const m = got.msg.rversion; - // «The server responds with its own maximum, which must be less than - // or equal to the client's» — `version(5)`. A larger one is a message - // we cannot buffer, and Linux refuses it for that reason - // (`net/9p/client.c:840-843`). if (m.msize > c.asked or m.msize < msize_min) return c.die(); c.versioning = false; if (std.mem.eql(u8, m.version, "9P2000")) { c.msize = m.msize; } else if (!std.mem.eql(u8, m.version, "unknown")) { - // The reply must be a version the client offered, or "unknown". - // Anything else — "9P2000.u", "9P2000.L", a typo — is a server - // answering a question we did not ask, and agreeing to a dialect - // this file does not implement is how a client sends a `Tattach` - // whose layout the other end reads differently. return c.die(); } - // "unknown" leaves `msize` at zero: a completed handshake with no - // dialect in common, on which nothing can be submitted. The CALLER - // decides whether that is worth hanging up over, which is the honest - // place for it — a fallback ladder of dialects is a policy and this is - // a codec. return .{ .tag = notag, .op = .version, .result = .{ .version = .{ .msize = m.msize, .version = m.version }, } }; } }; -// --------------------------------------------------------------------------- -// client tests -// --------------------------------------------------------------------------- -// -// Driven against the SERVER IN THIS FILE, in process, over two pairs of -// buffers. That is the strongest test available here and it needs no socket: -// every byte the client encodes is a byte the server decodes and vice versa, -// so a disagreement about a layout, a length or a tag fails a test rather than -// waiting for a live daemon. The stub filesystem is the server tests' own, so -// the tree the client walks is the tree those tests already pin down. - -/// One connection with a client at each end of it. Four buffers, because each -/// side owns its own two and neither may see the other's. -/// -/// `srv.out` is twice the msize because `Server` requires it; `cli.out` is not, -/// because a client reserves nothing — see `Client.Options`. const Pair = struct { srv_in: [4096]u8 = undefined, srv_out: [8192]u8 = undefined, @@ -5071,8 +3377,6 @@ const Pair = struct { srv: Srv = undefined, cli: Client = undefined, - /// The buffers are fields, so neither end can be built until the pair has - /// an address. fn start(p: *Pair) void { p.srv = Srv.init(.{ .in = &p.srv_in, .out = &p.srv_out, .root = 1 }); p.cli = Client.init(.{ .in = &p.cli_in, .out = &p.cli_out }); @@ -5083,9 +3387,6 @@ const Pair = struct { p.srv.reply(&a.reply, a.bytes); } - /// THE WIRE: every byte both ways, and each side given every chance to - /// work, until nothing moves. A real transport does this a chunk at a time - /// in a poll loop; the tests that care about that drip bytes by hand. fn wire(p: *Pair) void { var moved = true; while (moved) { @@ -5113,23 +3414,16 @@ const Pair = struct { } } - /// Submit one request, run the wire, and collect the one answer it - /// produced. The tag and the op are checked here so that no test below has - /// to repeat it. fn one(p: *Pair, req: Client.Request) !Client.Done { const tag = try p.cli.submit(req); p.wire(); const done = p.cli.take() orelse return error.NoReply; try testing.expectEqual(tag, done.tag); try testing.expectEqual(std.meta.activeTag(req), done.op); - // One request, one reply, and nothing left outstanding: the invariant - // that makes `pending()` usable as a loop condition. try testing.expectEqual(@as(usize, 0), p.cli.pending()); return done; } - /// `Tversion` and `Tattach`, leaving the root on fid 0 — the client-side - /// twin of `Harness.handshake`. fn handshake(p: *Pair) !void { p.start(); const v = try p.one(.{ .version = .{} }); @@ -5144,14 +3438,10 @@ const Pair = struct { test "9p client: a whole session against the server in this file" { var p: Pair = .{}; try p.handshake(); - // Both ends agreed the same number, and it came off the buffers rather - // than out of the air. try testing.expectEqual(@as(u32, 4096), p.cli.msize); try testing.expectEqual(@as(u32, 4096 - 11), p.cli.maxRead()); try testing.expectEqual(@as(u32, 4096 - 23), p.cli.maxWrite()); - // A two-element walk onto pane 1's `body`. `nwqid` equals what was asked, - // which is the only thing that says the fid landed where we wanted. const w = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); try testing.expectEqual(@as(u16, 2), w.result.walk.nwqid); try testing.expectEqual(@as(u64, 16), w.result.walk.wqid[0].path); @@ -5164,8 +3454,6 @@ test "9p client: a whole session against the server in this file" { const r = try p.one(.{ .read = .{ .fid = 1, .offset = 0, .count = 64 } }); try testing.expectEqualStrings("hello, body\n", r.result.read); - // Past the end is zero bytes and not an error: 9P has no EOF flag, and a - // short read is how a client learns it is done. const eof = try p.one(.{ .read = .{ .fid = 1, .offset = 12, .count = 64 } }); try testing.expectEqual(@as(usize, 0), eof.result.read.len); @@ -5179,22 +3467,13 @@ test "9p client: a whole session against the server in this file" { try testing.expectEqualStrings("goblin", st.result.stat.uid); _ = try p.one(.{ .clunk = .{ .fid = 1 } }); - // The clunk paid the core its release, which is the half of a clunk a - // client cannot see and the server tests pin down from the other side. try testing.expectEqual(@as(u32, 1), p.fsys.releases); - // Nothing outstanding, nothing buffered, nothing owed. try testing.expectEqual(@as(usize, 0), p.cli.pending()); try testing.expectEqual(@as(usize, 0), p.cli.output().len); try testing.expect(p.cli.take() == null); try testing.expect(!p.cli.dead); } -/// Move the server's queued replies to the client LAST FIRST. 9P permits it — -/// nothing in the protocol orders replies against each other — and both -/// reference clients allocate a tag per outstanding request with no in-order -/// assumption anywhere (`linux/net/9p/client.c:194-199`, -/// `plan9/devmnt.c:783-800`). A client that quietly relies on order works -/// until the day the server answers a cached stat before a blocked read. fn deliverReversed(p: *Pair) !void { var scratch: [4096]u8 = undefined; const out = p.srv.output(); @@ -5229,15 +3508,12 @@ test "9p client: replies out of order are matched by tag and not by arrival" { const w = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"index"} } }); try testing.expectEqual(@as(u16, 1), w.result.walk.nwqid); - // Two stats outstanding at once, on two different files. const root_tag = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); const index_tag = try p.cli.submit(.{ .stat = .{ .fid = 1 } }); try testing.expectEqual(@as(u16, 0), root_tag); try testing.expectEqual(@as(u16, 1), index_tag); try testing.expectEqual(@as(usize, 2), p.cli.pending()); - // Both requests to the server, both replies produced, then handed back in - // the wrong order. while (p.cli.output().len != 0) { const n = p.srv.push(p.cli.output()); p.cli.wrote(n); @@ -5245,7 +3521,6 @@ test "9p client: replies out of order are matched by tag and not by arrival" { while (p.srv.next()) |req| p.answer(req); try deliverReversed(&p); - // The SECOND request answers first, and it is recognised by its tag. const first = p.cli.take() orelse return error.NoReply; try testing.expectEqual(index_tag, first.tag); try testing.expectEqualStrings("index", first.result.stat.name); @@ -5260,22 +3535,15 @@ test "9p client: an Rerror answers one operation and the session carries on" { var p: Pair = .{}; try p.handshake(); - // A walk failing on its FIRST element is an `Rerror` rather than a short - // `Rwalk` — the one asymmetry in walk(5), and the reason `Done` reports - // the op beside the string. const bad = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"nope"} } }); try testing.expectEqual(Client.Op.walk, bad.op); try testing.expectEqualStrings(errString(2), bad.result.fail); - // The failed tag is free again and the connection is untouched: an error - // is an answer, not a fault. try testing.expectEqual(@as(usize, 0), p.cli.pending()); try testing.expect(!p.cli.dead); const st = try p.one(.{ .stat = .{ .fid = 0 } }); try testing.expectEqualStrings("/", st.result.stat.name); - // A refusal that comes from the server's own table rather than the core's, - // spelled the way Linux's error table holds it. const stale = try p.one(.{ .stat = .{ .fid = 9 } }); try testing.expectEqualStrings(e_unknown_fid, stale.result.fail); try testing.expect(!p.cli.dead); @@ -5286,8 +3554,6 @@ test "9p client: a reply arriving a byte at a time is taken when its last byte l try p.handshake(); const tag = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); - // The request out, the reply produced, and then held on this side of the - // wire so it can be dripped in. while (p.cli.output().len != 0) { const n = p.srv.push(p.cli.output()); p.cli.wrote(n); @@ -5300,8 +3566,6 @@ test "9p client: a reply arriving a byte at a time is taken when its last byte l const reply = scratch[0..out.len]; p.srv.wrote(reply.len); - // Every byte but the last leaves nothing to collect — including the first - // four, where `frameLen` becomes readable and still says "wait". for (reply[0 .. reply.len - 1]) |b| { try testing.expectEqual(@as(usize, 1), p.cli.push(&.{b})); try testing.expect(p.cli.take() == null); @@ -5317,21 +3581,17 @@ test "9p client: sixteen tags outstanding, and the seventeenth is refused" { var p: Pair = .{}; try p.handshake(); - // Nothing is wired, so nothing is answered and every tag stays out. var tags: [max_tags]u16 = undefined; for (&tags, 0..) |*t, i| { t.* = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); - // Lowest free tag first, which is what makes a trace readable. try testing.expectEqual(@as(u16, @intCast(i)), t.*); } try testing.expectEqual(max_tags, p.cli.pending()); try testing.expectError(error.NoTags, p.cli.submit(.{ .stat = .{ .fid = 0 } })); - // A refused submit costs nothing: no tag, and not a byte in the queue. const owed = p.cli.output().len; try testing.expectError(error.NoTags, p.cli.submit(.{ .clunk = .{ .fid = 0 } })); try testing.expectEqual(owed, p.cli.output().len); - // Drained, every tag comes back, and the seventeenth request now fits. p.wire(); var seen: [max_tags]bool = @splat(false); for (0..max_tags) |_| { @@ -5349,22 +3609,18 @@ test "9p client: what a caller may not ask for is refused before a tag is spent" var p: Pair = .{}; p.start(); - // Nothing before the handshake, and `Tversion` is the only exception. try testing.expectError(error.Handshake, p.cli.submit(.{ .stat = .{ .fid = 0 } })); try p.handshake(); - // `NOFID` is not a fid a client may name. try testing.expectError(error.BadRequest, p.cli.submit(.{ .stat = .{ .fid = nofid } })); try testing.expectError(error.BadRequest, p.cli.submit(.{ .clunk = .{ .fid = nofid } })); try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = nofid, .names = &.{} } })); - // A path that has not been split, and one longer than the protocol admits. try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"1/body"} } })); try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{""} } })); const seventeen: [max_welem + 1][]const u8 = @splat("x"); try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &seventeen } })); - // Both I/O bounds, each one byte past what the msize can carry. try testing.expectError(error.TooLarge, p.cli.submit(.{ .read = .{ .fid = 0, .offset = 0, @@ -5376,23 +3632,12 @@ test "9p client: what a caller may not ask for is refused before a tag is spent" .offset = 0, .data = big[0 .. p.cli.maxWrite() + 1], } })); - // And exactly at the bound, both fit — a cap that is off by one is a cap - // that costs a round trip on every large transfer. Each one on an EMPTY - // queue, which is what `Options.out` means by "room for one whole - // request": a maximum-size `Twrite` IS the msize, so it fits beside - // nothing at all. p.cli.wrote(p.cli.output().len); _ = try p.cli.submit(.{ .read = .{ .fid = 0, .offset = 0, .count = p.cli.maxRead() } }); p.cli.wrote(p.cli.output().len); _ = try p.cli.submit(.{ .write = .{ .fid = 0, .offset = 0, .data = big[0..p.cli.maxWrite()] } }); - // A second `Tversion` resets the connection, so it is refused while - // anything is outstanding rather than abandoning it. try testing.expectError(error.Handshake, p.cli.submit(.{ .version = .{} })); - // And with the queue full of that one write there is nowhere to put even - // an eleven-byte `Tstat`: the out queue is the only back-pressure a - // sans-io client has, and it lands at `submit` where the caller is - // standing right there. try testing.expectError(error.NoSpace, p.cli.submit(.{ .stat = .{ .fid = 0 } })); } @@ -5401,22 +3646,15 @@ test "9p client: an msize below the floor, and one the server tried to raise" { var out: [512]u8 = undefined; var buf: [64]u8 = undefined; - // A caller asking for less than an `Rwalk` is asking for a connection that - // cannot be served. var c = Client.init(.{ .in = &in, .out = &out }); try testing.expectError(error.BadRequest, c.submit(.{ .version = .{ .msize = msize_min - 1 } })); - // Zero means "whatever the buffers hold", which is the smaller of the two. _ = try c.submit(.{ .version = .{} }); try testing.expectEqual(@as(u32, 512), c.asked); - // A server answering with MORE than the client offered is a server whose - // next message will not fit the buffer that has to hold it. _ = c.push(try encode(.{ .rversion = .{ .msize = 1024, .version = "9P2000" } }, notag, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); - // "unknown" is a SUCCESSFUL reply with no dialect in common: the handshake - // completes, `msize` stays zero, and nothing more can be submitted. var c2 = Client.init(.{ .in = &in, .out = &out }); _ = try c2.submit(.{ .version = .{} }); _ = c2.push(try encode(.{ .rversion = .{ .msize = 512, .version = "unknown" } }, notag, &buf)); @@ -5426,8 +3664,6 @@ test "9p client: an msize below the floor, and one the server tried to raise" { try testing.expectEqual(@as(u32, 0), c2.msize); try testing.expectError(error.Handshake, c2.submit(.{ .stat = .{ .fid = 0 } })); - // A dialect we never offered is neither: agreeing to it would be agreeing - // to a layout this file does not implement. var c3 = Client.init(.{ .in = &in, .out = &out }); _ = try c3.submit(.{ .version = .{} }); _ = c3.push(try encode(.{ .rversion = .{ .msize = 512, .version = "9P2000.u" } }, notag, &buf)); @@ -5437,8 +3673,6 @@ test "9p client: an msize below the floor, and one the server tried to raise" { test "9p client: what is not an answer to one of our requests ends the connection" { var buf: [64]u8 = undefined; - // Each case gets a fresh connection past the handshake, because every one - // of them is fatal by design. const Case = struct { fn armed(in: []u8, out: []u8, scratch: []u8) !Client { var c = Client.init(.{ .in = in, .out = out }); @@ -5454,45 +3688,36 @@ test "9p client: what is not an answer to one of our requests ends the connectio var in: [512]u8 = undefined; var out: [512]u8 = undefined; - // A T-message. A client reads R-messages, and the parity says so with no - // table: this is `Server.startFrame`'s refusal read from the other end. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.{ .tstat = .{ .fid = 0 } }, 0, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // A reply on a tag nobody claimed. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.rclunk, 3, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // A tag outside the table entirely, which no reply to us can carry. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.rclunk, 900, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // The right tag and the WRONG SHAPE: an `Rclunk` where an `Rstat` was - // asked for. A tag is only as good as the table behind it. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.rclunk, 0, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // A reply to a request this client never sends. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.rwstat, 0, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // A `size` no encoder produced, and one past the negotiated msize. Both - // are streams that will never resynchronise. { var c = try Case.armed(&in, &out, &buf); _ = c.push(&.{ 3, 0, 0, 0 }); @@ -5505,7 +3730,6 @@ test "9p client: what is not an answer to one of our requests ends the connectio try testing.expect(c.take() == null); try testing.expect(c.dead); } - // And a body the codec refuses: the type byte is fine, the payload is not. { var c = try Case.armed(&in, &out, &buf); _ = c.push(&.{ 8, 0, 0, 0, @intFromEnum(Type.rstat), 0, 0, 0 }); @@ -5515,9 +3739,6 @@ test "9p client: what is not an answer to one of our requests ends the connectio } test "9p client: an Rread longer than the Tread asked for is refused" { - // The one bound a client cannot check from the frame alone, which is why - // `Slot` keeps the count: a server handing back more than was asked has - // given us bytes at offsets we never named. Linux calls it `-EIO`. var p: Pair = .{}; try p.handshake(); _ = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); @@ -5530,7 +3751,6 @@ test "9p client: an Rread longer than the Tread asked for is refused" { try testing.expect(p.cli.take() == null); try testing.expect(p.cli.dead); - // Exactly the count asked for is fine, and so is anything shorter. var q: Pair = .{}; try q.handshake(); _ = try q.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); @@ -5552,18 +3772,8 @@ test "9p client: hangup and a dead connection refuse everything after" { } test "9p client: one session is a hundred and change bytes plus its buffers" { - // The number the board is costed against, and the whole reason the client - // is shaped the way it is: sixteen eight-byte tag slots and nine scalars, - // with every payload borrowed out of the input buffer rather than copied - // into a per-tag one. A `Server` on the same connection is 9,488 B because - // it owns a fid table and a park table; a client owns neither, because the - // far end does. try testing.expect(@sizeOf(Client.Slot) <= 8); try testing.expect(@sizeOf(Client) <= 256); - // Two buffers at the 8,192-byte msize `src/fs9_service.zig` serves, plus - // the client itself: what one `9p` word costs while it is running. try testing.expect(2 * 8192 + @sizeOf(Client) <= 17 * 1024); - // And at the protocol floor, which is what a board would negotiate: two - // buffers of 217 bytes each is a 9P client in under 700 bytes of RAM. try testing.expect(2 * msize_min + @sizeOf(Client) <= 700); } diff --git a/src/9p_io.zig b/src/9p_io.zig new file mode 100644 index 00000000..89611c2b --- /dev/null +++ b/src/9p_io.zig @@ -0,0 +1,1454 @@ +const std = @import("std"); +const libc = std.c; +const builtin = @import("builtin"); +const ninep = @import("9p.zig"); +const pardes = @import("pardes.zig"); +const limits = @import("memory.zig").limits; +pub const quic_enabled = @import("9p_options").quic; +const quic = if (quic_enabled) @import("9p_quic.zig") else struct {}; + +const log = std.log.scoped(.ninep); + +pub const darwin = switch (builtin.os.tag) { + .macos, .ios, .tvos, .watchos, .visionos => true, + else => false, +}; +pub const supported = builtin.os.tag == .linux or darwin; +pub const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len; + +pub fn setCloexec(fd: c_int) void { + _ = libc.fcntl(fd, libc.F.SETFD, @as(c_int, 1)); +} + +pub fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 { + if (libc.getenv("XDG_RUNTIME_DIR")) |path| + return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(path)}, 0) catch null; + const home = libc.getenv("HOME") orelse return null; + return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{std.mem.span(home)}, 0) catch null; +} + +pub const FileFacts = struct { mode: u32, uid: libc.uid_t }; + +pub fn statNoFollow(path: [:0]const u8) ?FileFacts { + if (comptime darwin) { + var stat: libc.Stat = undefined; + if (libc.fstatat(libc.AT.FDCWD, path, &stat, libc.AT.SYMLINK_NOFOLLOW) != 0) return null; + return .{ .mode = stat.mode, .uid = stat.uid }; + } else { + const linux = std.os.linux; + var stat: linux.Statx = undefined; + const fields: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true }; + if (libc.statx(linux.AT.FDCWD, path, linux.AT.SYMLINK_NOFOLLOW, fields, &stat) != 0) return null; + return .{ .mode = stat.mode, .uid = stat.uid }; + } +} + +pub fn ensureSocketDir(dir: [:0]const u8) bool { + if (dir.len == 0) return false; + var partial: [sun_path_len:0]u8 = undefined; + @memcpy(partial[0 .. dir.len + 1], dir[0 .. dir.len + 1]); + for (1..dir.len) |i| { + if (dir[i] != '/') continue; + partial[i] = 0; + _ = libc.mkdir(partial[0..i :0], 0o700); + partial[i] = '/'; + } + _ = libc.mkdir(dir, 0o700); + const stat = statNoFollow(dir) orelse return false; + return stat.mode & 0o170000 == 0o040000 and stat.uid == libc.getuid() and stat.mode & 0o077 == 0; +} + +const prefix = "pardes-9p-"; + +pub const msize: u32 = 8192; + +pub const max_conns = 4; + +extern "c" fn inet_pton(family: c_int, src: [*:0]const u8, dst: *anyopaque) c_int; + +pub fn networkAddress(dial: []const u8, allow_zero_port: bool) error{BadDial}!std.Io.net.IpAddress { + if (comptime !supported) return error.BadDial; + const host_start: usize = if (std.mem.startsWith(u8, dial, "tcp!")) 4 else if (std.mem.startsWith(u8, dial, "quic!")) 5 else return error.BadDial; + const split = std.mem.lastIndexOfScalar(u8, dial, '!') orelse return error.BadDial; + if (split <= host_start or split + 1 == dial.len) return error.BadDial; + const port_text = dial[split + 1 ..]; + for (port_text) |c| if (c < '0' or c > '9') return error.BadDial; + const port = std.fmt.parseInt(u16, port_text, 10) catch return error.BadDial; + if (port == 0 and !allow_zero_port) return error.BadDial; + const host = dial[host_start..split]; + if (std.mem.indexOfScalar(u8, host, 0) != null) return error.BadDial; + var host_buf: [46]u8 = undefined; + const host_z = std.fmt.bufPrintSentinel(&host_buf, "{s}", .{host}, 0) catch return error.BadDial; + var ip4: std.Io.net.Ip4Address = .{ .port = port, .bytes = undefined }; + if (inet_pton(libc.AF.INET, host_z, &ip4.bytes) == 1) return .{ .ip4 = ip4 }; + var ip6: std.Io.net.Ip6Address = .{ .port = port, .bytes = undefined }; + if (inet_pton(libc.AF.INET6, host_z, &ip6.bytes) == 1) return canonicalIp(.{ .ip6 = ip6 }); + return error.BadDial; +} + +fn canonicalIp(address: std.Io.net.IpAddress) std.Io.net.IpAddress { + if (address == .ip6) { + if (std.Io.net.Ip4Address.fromIp6(address.ip6)) |ip4| return .{ .ip4 = ip4 }; + } + return address; +} + +fn ipSockaddr(address: std.Io.net.IpAddress, out: *libc.sockaddr.storage) libc.socklen_t { + return switch (address) { + .ip4 => |ip| blk: { + const addr: *libc.sockaddr.in = @ptrCast(out); + addr.* = .{ .port = std.mem.nativeToBig(u16, ip.port), .addr = @bitCast(ip.bytes) }; + break :blk @sizeOf(libc.sockaddr.in); + }, + .ip6 => |ip| blk: { + const addr: *libc.sockaddr.in6 = @ptrCast(out); + addr.* = .{ .port = std.mem.nativeToBig(u16, ip.port), .addr = ip.bytes, .flowinfo = 0, .scope_id = 0 }; + break :blk @sizeOf(libc.sockaddr.in6); + }, + }; +} + +fn sockaddrIp(address: *const libc.sockaddr) ?std.Io.net.IpAddress { + return switch (address.family) { + libc.AF.INET => blk: { + const addr: *const libc.sockaddr.in = @ptrCast(@alignCast(address)); + break :blk .{ .ip4 = .{ .port = std.mem.bigToNative(u16, addr.port), .bytes = @bitCast(addr.addr) } }; + }, + libc.AF.INET6 => blk: { + const addr: *const libc.sockaddr.in6 = @ptrCast(@alignCast(address)); + break :blk canonicalIp(.{ .ip6 = .{ .port = std.mem.bigToNative(u16, addr.port), .bytes = addr.addr } }); + }, + else => null, + }; +} + +const IfAddr = extern struct { + next: ?*IfAddr, + name: ?[*:0]u8, + flags: c_uint, + address: ?*libc.sockaddr, + netmask: ?*libc.sockaddr, + destination: ?*libc.sockaddr, + data: ?*anyopaque, +}; + +extern "c" fn getifaddrs(out: *?*IfAddr) c_int; +extern "c" fn freeifaddrs(first: *IfAddr) void; + +fn localIp(address: std.Io.net.IpAddress) bool { + switch (address) { + .ip4 => |ip| if (ip.bytes[0] == 127 or std.mem.allEqual(u8, &ip.bytes, 0)) return true, + .ip6 => |ip| if (ip.isLoopBack() or std.mem.allEqual(u8, &ip.bytes, 0)) return true, + } + var first: ?*IfAddr = null; + if (getifaddrs(&first) != 0) return false; + defer if (first) |head| freeifaddrs(head); + var next = first; + while (next) |entry| : (next = entry.next) { + var local = sockaddrIp(entry.address orelse continue) orelse continue; + local.setPort(address.getPort()); + if (address.eql(&local)) return true; + } + return false; +} + +const Srv = ninep.Server(pardes.filesystem, ninep.max_fids); + +const Conn = struct { + fd: c_int = -1, + quic: if (quic_enabled) ?quic.Connection else void = if (quic_enabled) null else {}, + draining: bool = false, + accepted_ms: i64 = 0, + srv: Srv = undefined, + in: [msize]u8 = undefined, + out: [2 * msize]u8 = undefined, + + fn step(c: *Conn, core: *pardes.Pardes, req: pardes.filesystem.Req) void { + core.update(.{ .fs_req = req }); + var answered = false; + while (core.nextEffect()) |effect| { + if (effect == .fs_reply) { + const reply = effect.fs_reply; + if (reply.tag == req.tag) answered = true; + c.srv.reply(&reply, core.fsPayload(reply)); + } else core.perform(effect); + } + if (!answered) { + const reply = pardes.filesystem.Reply.fail(req.tag, pardes.filesystem.E.IO); + c.srv.reply(&reply, ""); + } + } +}; + +const accept_pause_ms: i64 = 100; + +pub const Listener = struct { + fd: c_int = -1, + tcp_fd: c_int = -1, + tcp_address: ?std.Io.net.IpAddress = null, + quic: if (quic_enabled) ?quic.Listener else void = if (quic_enabled) null else {}, + quic_address: ?std.Io.net.IpAddress = null, + paused_ms: i64 = 0, + path_buf: [sun_path_len]u8 = undefined, + path_len: usize = 0, + conns: [max_conns]Conn = @splat(.{}), + control: [2]c_int = .{ -1, -1 }, + watcher: ?std.Thread = null, + stopping: std.atomic.Value(bool) = .init(false), + watch_lock: std.atomic.Mutex = .unlocked, + watch_fds: [max_conns + 3 + @as(usize, @intFromBool(quic_enabled))]libc.pollfd = undefined, + watch_len: usize = 0, + watch_timeout: c_int = -1, + wake_ctx: ?*anyopaque = null, + wake: ?*const fn (?*anyopaque) void = null, + + pub fn path(l: *const Listener) []const u8 { + return l.path_buf[0..l.path_len]; + } + + fn listenTcp(l: *Listener, address: std.Io.net.IpAddress) !void { + var addr: libc.sockaddr.storage = undefined; + const addr_len = ipSockaddr(address, &addr); + const fd = libc.socket(addr.family, libc.SOCK.STREAM, 0); + if (fd < 0) return error.SocketFailed; + errdefer _ = libc.close(fd); + setCloexec(fd); + setNonblock(fd); + const on: c_int = 1; + if (libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.REUSEADDR, &on, @sizeOf(c_int)) != 0) + return error.SocketOptionFailed; + if (address == .ip6) { + const v6only = if (darwin) 27 else std.os.linux.IPV6.V6ONLY; + if (libc.setsockopt(fd, libc.IPPROTO.IPV6, v6only, &on, @sizeOf(c_int)) != 0) + return error.SocketOptionFailed; + } + if (libc.bind(fd, @ptrCast(&addr), addr_len) != 0) return error.BindFailed; + if (libc.listen(fd, max_conns) != 0) return error.ListenFailed; + var actual_len: libc.socklen_t = @sizeOf(libc.sockaddr.storage); + if (libc.getsockname(fd, @ptrCast(&addr), &actual_len) != 0) return error.SocketAddressFailed; + l.tcp_address = sockaddrIp(@ptrCast(&addr)) orelse return error.SocketAddressFailed; + l.tcp_fd = fd; + log.info("serving 9P2000 over TCP on {f}", .{l.tcp_address.?}); + } + + pub fn accept(l: *Listener) void { + if (comptime !supported) return; + for ([_]c_int{ l.fd, l.tcp_fd }) |listener_fd| { + if (listener_fd < 0) continue; + for (0..max_conns + 1) |_| { + const fd = libc.accept(listener_fd, null, null); + if (fd < 0) switch (libc.errno(fd)) { + .AGAIN => break, + .INTR, .CONNABORTED => continue, + else => { + l.paused_ms = nowMs() +| accept_pause_ms; + log.warn("accept failed; pausing the listener for {d} ms", .{accept_pause_ms}); + return; + }, + }; + setCloexec(fd); + setNonblock(fd); + if (listener_fd == l.tcp_fd) { + const on: c_int = 1; + _ = libc.setsockopt(fd, libc.IPPROTO.TCP, libc.TCP.NODELAY, &on, @sizeOf(c_int)); + } + if (comptime darwin) { + const on: c_int = 1; + _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); + } + const c = for (&l.conns, 0..) |*cand, i| { + if (!l.live(@intCast(i)) and !cand.draining) break cand; + } else { + log.debug("refusing a connection, all {d} slots busy", .{max_conns}); + _ = libc.close(fd); + continue; + }; + c.fd = fd; + c.draining = false; + c.accepted_ms = nowMs(); + c.srv = .init(.{ + .in = &c.in, + .out = &c.out, + .root = pardes.filesystem.namespace_root, + }); + } + } + if (comptime quic_enabled) { + if (l.quic) |*listener| for (0..max_conns + 1) |_| { + var connection = (listener.accept() catch |err| { + log.warn("QUIC accept failed: {s}", .{@errorName(err)}); + return; + }) orelse break; + const c = for (&l.conns, 0..) |*cand, i| { + if (!l.live(@intCast(i)) and !cand.draining) break cand; + } else { + connection.deinit(); + continue; + }; + c.fd = -1; + c.quic = connection; + c.draining = false; + c.accepted_ms = nowMs(); + c.srv = .init(.{ .in = &c.in, .out = &c.out, .root = pardes.filesystem.namespace_root }); + }; + } + } + + pub const greet_deadline_ms: i64 = 5000; + + pub fn expire(l: *Listener) void { + if (comptime !supported) return; + const now = nowMs(); + if (now == 0) return; + for (&l.conns, 0..) |*c, i| { + if (!l.live(@intCast(i)) or c.srv.msize != 0) continue; + if (now - c.accepted_ms < greet_deadline_ms) continue; + log.debug("slot {d} never sent Tversion; taking it back", .{i}); + l.drop(@intCast(i)); + } + } + + pub fn accepting(l: *const Listener) bool { + if (comptime !supported) return false; + if (l.fd < 0 and l.tcp_fd < 0) return false; + if (l.paused_ms == 0) return true; + const now = nowMs(); + return now == 0 or now >= l.paused_ms; + } + + pub fn nextDue(l: *const Listener) ?i32 { + if (comptime !supported) return null; + const now = nowMs(); + if (now == 0) return null; + var due: ?i64 = null; + if (l.paused_ms > now) due = l.paused_ms; + if (comptime quic_enabled) { + if (l.quic) |*listener| if (listener.nextDue()) |ms| { + const at = now + ms; + due = if (due) |d| @min(d, at) else at; + }; + } + for (&l.conns, 0..) |*c, i| { + if (!l.live(@intCast(i))) continue; + if (comptime quic_enabled) { + if (c.quic) |*connection| if (connection.pending()) return 0; + } + if (c.srv.msize != 0) continue; + const at = c.accepted_ms + greet_deadline_ms; + due = if (due) |d| @min(d, at) else at; + } + const at = due orelse return null; + return @intCast(@max(0, at - now)); + } + + fn nowMs() i64 { + var ts: libc.timespec = undefined; + if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; + return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); + } + + pub fn fill(l: *Listener, i: u8) void { + if (comptime !supported) return; + const c = &l.conns[i]; + if (c.srv.dead) return l.drop(i); + const room = c.srv.in.len - c.srv.in_len; + if (room == 0) return; + var buf: [msize]u8 = undefined; + const got: isize = if (quic_enabled and c.quic != null) + @intCast((c.quic.?.read(buf[0..@min(room, buf.len)]) catch return l.drop(i)) orelse return) + else + libc.read(c.fd, &buf, @min(room, buf.len)); + if (got == 0) return l.drop(i); + if (got < 0) return switch (libc.errno(got)) { + .INTR, .AGAIN => {}, + else => l.drop(i), + }; + const n = c.srv.push(buf[0..@intCast(got)]); + if (c.srv.dead) return l.drop(i); + std.debug.assert(n == @as(usize, @intCast(got))); + } + + pub fn flush(l: *Listener, i: u8) void { + if (comptime !supported) return; + const c = &l.conns[i]; + if (!l.live(i)) return; + while (true) { + const bytes = c.srv.output(); + if (bytes.len == 0) return; + const n: isize = if (quic_enabled and c.quic != null) + @intCast(c.quic.?.write(bytes) catch return l.drop(i)) + else + libc.send(c.fd, bytes.ptr, bytes.len, nosignal); + if (n < 0) switch (libc.errno(n)) { + .INTR => continue, + .AGAIN => return, + else => return l.drop(i), + }; + if (n == 0) return; + c.srv.wrote(@intCast(n)); + } + } + + pub fn owes(l: *const Listener, i: u8) bool { + return l.conns[i].srv.output().len != 0; + } + + pub fn live(l: *const Listener, i: u8) bool { + return l.conns[i].fd >= 0 or (quic_enabled and l.conns[i].quic != null); + } + + pub fn drop(l: *Listener, i: u8) void { + const c = &l.conns[i]; + if (c.fd >= 0) { + _ = libc.close(c.fd); + c.fd = -1; + } + if (comptime quic_enabled) { + if (c.quic) |*connection| { + connection.deinit(); + c.quic = null; + } + } + if (c.draining or c.accepted_ms == 0) return; + c.srv.hangup(); + c.draining = true; + } + + pub const Drained = struct { count: usize = 0, pending: bool = false }; + + pub fn drain(l: *Listener, core: *pardes.Pardes) Drained { + core.fs.socket_path = l.path(); + core.fs.tcp_address = l.tcp_address; + core.fs.quic_address = l.quic_address; + l.expire(); + var result: Drained = .{}; + for (&l.conns, 0..) |*conn, i| { + if (!l.live(@intCast(i)) and !conn.draining) continue; + var count: usize = 0; + while (conn.srv.retry()) |req| { + conn.step(core, req); + l.collectOs(core); + count += 1; + } + while (count < 64) { + const req = conn.srv.next() orelse break; + conn.step(core, req); + l.collectOs(core); + count += 1; + } + result.count += count; + result.pending = result.pending or count >= 64; + if (conn.draining) { + if (count == 0) conn.draining = false else result.pending = true; + } else l.flush(@intCast(i)); + if (comptime quic_enabled) { + if (conn.quic) |*connection| result.pending = result.pending or connection.pending(); + } + } + return result; + } + + pub fn tick(l: *Listener, core: *pardes.Pardes) Drained { + if (comptime quic_enabled) { + if (l.quic) |*listener| listener.events() catch |err| { + log.warn("QUIC listener stopped: {s}", .{@errorName(err)}); + for (&l.conns, 0..) |*conn, i| if (conn.quic != null) l.drop(@intCast(i)); + listener.deinit(); + l.quic = null; + l.quic_address = null; + }; + } + if (l.accepting()) l.accept(); + for (0..max_conns) |i| if (l.live(@intCast(i))) l.fill(@intCast(i)); + const result = l.drain(core); + l.arm(); + return result; + } + + pub fn reset(l: *Listener, core: *pardes.Pardes) void { + for (0..max_conns) |i| l.drop(@intCast(i)); + while (l.drain(core).pending) {} + l.collectOs(core); + for (&l.conns) |*conn| conn.accepted_ms = 0; + l.arm(); + } + + fn collectOs(l: *Listener, core: *pardes.Pardes) void { + var i: usize = 0; + while (i < core.fs.os_paths.items.len) { + const entry = core.fs.os_paths.items[i]; + var held = false; + for (&l.conns, 0..) |*conn, j| { + if (!l.live(@intCast(j)) and !conn.draining) continue; + if (conn.srv.references(entry.node)) { + held = true; + break; + } + } + if (held) { + i += 1; + } else { + core.gpa.free(entry.path); + _ = core.fs.os_paths.swapRemove(i); + } + } + } + + pub fn wakeThread(l: *Listener, ctx: ?*anyopaque, wake: *const fn (?*anyopaque) void) !void { + if (l.watcher != null) return; + if (libc.pipe(&l.control) != 0) return error.PipeFailed; + errdefer { + _ = libc.close(l.control[0]); + _ = libc.close(l.control[1]); + l.control = .{ -1, -1 }; + } + for (l.control) |fd| { + setCloexec(fd); + setNonblock(fd); + } + l.wake_ctx = ctx; + l.wake = wake; + l.arm(); + l.watcher = try std.Thread.spawn(.{}, watch, .{l}); + } + + fn arm(l: *Listener) void { + if (l.control[1] < 0) return; + while (!l.watch_lock.tryLock()) std.atomic.spinLoopHint(); + l.watch_fds[0] = .{ .fd = l.control[0], .events = @intCast(libc.POLL.IN), .revents = 0 }; + l.watch_len = 1; + if (l.accepting()) { + for ([_]c_int{ l.fd, l.tcp_fd }) |fd| { + if (fd < 0) continue; + l.watch_fds[l.watch_len] = .{ .fd = fd, .events = @intCast(libc.POLL.IN), .revents = 0 }; + l.watch_len += 1; + } + } + if (comptime quic_enabled) { + if (l.quic) |*listener| { + l.watch_fds[l.watch_len] = listener.poll(); + l.watch_len += 1; + } + } + for (0..max_conns) |i| { + if (l.conns[i].fd < 0) continue; + l.watch_fds[l.watch_len] = .{ + .fd = l.conns[i].fd, + .events = @as(i16, @intCast(libc.POLL.IN)) | if (l.owes(@intCast(i))) @as(i16, @intCast(libc.POLL.OUT)) else 0, + .revents = 0, + }; + l.watch_len += 1; + } + l.watch_timeout = l.nextDue() orelse -1; + l.watch_lock.unlock(); + _ = libc.write(l.control[1], "w", 1); + } + + fn watch(l: *Listener) void { + var notified = false; + while (!l.stopping.load(.acquire)) { + var fds: [max_conns + 3 + @as(usize, @intFromBool(quic_enabled))]libc.pollfd = undefined; + while (!l.watch_lock.tryLock()) std.atomic.spinLoopHint(); + const len = if (notified) 1 else l.watch_len; + @memcpy(fds[0..len], l.watch_fds[0..len]); + const timeout = if (notified) -1 else l.watch_timeout; + l.watch_lock.unlock(); + const ready = libc.poll(&fds, @intCast(len), timeout); + if (ready < 0) continue; + if (fds[0].revents != 0) { + var buf: [64]u8 = undefined; + while (libc.read(l.control[0], &buf, buf.len) > 0) {} + notified = false; + continue; + } + if (!l.stopping.load(.acquire)) l.wake.?(l.wake_ctx); + notified = true; + } + } + + pub fn deinit(l: *Listener, gpa: std.mem.Allocator) void { + if (l.watcher) |thread| { + l.stopping.store(true, .release); + _ = libc.write(l.control[1], "q", 1); + thread.join(); + for (l.control) |fd| _ = libc.close(fd); + } + for (0..max_conns) |i| l.drop(@intCast(i)); + if (comptime quic_enabled) { + if (l.quic) |*listener| listener.deinit(); + } + if (l.tcp_fd >= 0) _ = libc.close(l.tcp_fd); + if (l.fd >= 0) { + _ = libc.close(l.fd); + l.fd = -1; + var z: [sun_path_len:0]u8 = undefined; + @memcpy(z[0..l.path_len], l.path_buf[0..l.path_len]); + z[l.path_len] = 0; + _ = libc.unlink(z[0..l.path_len :0]); + } + gpa.destroy(l); + } +}; + +pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 { + if (name.len == 0) return null; + if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null; + return std.fmt.bufPrintSentinel(buf, "{s}/" ++ prefix ++ "{s}.sock", .{ dir, name }, 0) catch null; +} + +pub fn listen(gpa: std.mem.Allocator, named: []const u8, fallback: []const u8, tcp_dial: ?[]const u8, quic_dial: ?[]const u8) ?*Listener { + if (comptime !supported) return null; + var dir_buf: [sun_path_len:0]u8 = undefined; + const dir = socketDir(&dir_buf) orelse { + log.warn("no runtime directory for the socket", .{}); + return null; + }; + if (!ensureSocketDir(dir)) return null; + const l = gpa.create(Listener) catch return null; + l.* = .{}; + const p = socketPath(&l.path_buf, dir, if (named.len != 0) named else fallback) orelse { + gpa.destroy(l); + return null; + }; + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. p.len + 1], p[0 .. p.len + 1]); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) { + gpa.destroy(l); + return null; + } + setCloexec(fd); + if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + const bind_error = libc.errno(-1); + const io = std.Io.Threaded.global_single_threaded.io(); + const existing = std.Io.Dir.cwd().statFile(io, p, .{ .follow_symlinks = false }) catch null; + if (bind_error != .ADDRINUSE or existing == null or existing.?.kind != .unix_domain_socket or alive(p)) { + log.warn("something is already listening on {s}", .{p}); + _ = libc.close(fd); + gpa.destroy(l); + return null; + } + if (libc.unlink(p) != 0 or libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + _ = libc.close(fd); + gpa.destroy(l); + return null; + } + } + if (libc.chmod(p, 0o600) != 0 or libc.listen(fd, max_conns) != 0) { + _ = libc.close(fd); + _ = libc.unlink(p); + gpa.destroy(l); + return null; + } + setNonblock(fd); + l.fd = fd; + l.path_len = p.len; + if (tcp_dial) |dial| { + if (!std.mem.startsWith(u8, dial, "tcp!")) { + l.deinit(gpa); + return null; + } + const address = networkAddress(dial, true) catch { + log.warn("invalid TCP address {s}", .{dial}); + l.deinit(gpa); + return null; + }; + l.listenTcp(address) catch |err| { + log.warn("cannot listen on {s}: {s}", .{ dial, @errorName(err) }); + l.deinit(gpa); + return null; + }; + } + if (quic_dial) |dial| { + if (comptime quic_enabled) { + if (!std.mem.startsWith(u8, dial, "quic!")) { + l.deinit(gpa); + return null; + } + const address = networkAddress(dial, true) catch { + log.warn("invalid QUIC address {s}", .{dial}); + l.deinit(gpa); + return null; + }; + l.quic = quic.Listener.init(address) catch |err| { + log.warn("cannot listen on {s}: {s}", .{ dial, @errorName(err) }); + l.deinit(gpa); + return null; + }; + l.quic_address = l.quic.?.address; + log.info("serving 9P2000 over QUIC on {f}", .{l.quic_address.?}); + } else { + log.warn("QUIC is unavailable in this build", .{}); + l.deinit(gpa); + return null; + } + } + log.info("serving 9P2000 on {s}", .{p}); + return l; +} + +fn alive(path: [:0]const u8) bool { + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + if (path.len + 1 > sun_path_len) return true; // cannot ask; assume occupied + @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) return true; + defer _ = libc.close(fd); + setCloexec(fd); + setNonblock(fd); + if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0) return true; + return libc.errno(-1) != .CONNREFUSED; +} + +fn setNonblock(fd: c_int) void { + const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); + if (flags < 0) return; + var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); + o.NONBLOCK = true; + _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); +} + +const nosignal: u32 = if (darwin) 0 else libc.MSG.NOSIGNAL; + +const testing = std.testing; + +test "the socket name is a third prefix in the shared directory" { + var buf: [sun_path_len]u8 = undefined; + const p = socketPath(&buf, "/run/user/1000", "t9srv").?; + try testing.expectEqualStrings("/run/user/1000/pardes-9p-t9srv.sock", p); + try testing.expect(!std.mem.startsWith(u8, std.fs.path.basename(p), "pardes-detached-")); +} + +test "a name that is not one path component is no address at all" { + var buf: [sun_path_len]u8 = undefined; + try testing.expect(socketPath(&buf, "/run", "") == null); + try testing.expect(socketPath(&buf, "/run", "a/b") == null); + try testing.expect(socketPath(&buf, "/run", "a\x00b") == null); +} + +test "one connection's buffers are sized from the one msize constant" { + try testing.expect(msize >= ninep.min_msize); + const c: Conn = .{}; + try testing.expectEqual(@as(usize, msize), c.in.len); + try testing.expectEqual(@as(usize, 2 * msize), c.out.len); +} + +test "TCP addresses are numeric and normalize mapped IPv4" { + const loopback = try networkAddress("tcp!127.0.0.1!5640", false); + try testing.expectEqualDeep(loopback, try networkAddress("tcp!::ffff:127.0.0.1!5640", false)); + try testing.expectEqualDeep(loopback, try networkAddress("tcp!::ffff:7f00:1!5640", false)); + try testing.expectEqualDeep(try networkAddress("tcp!::1!5640", false), try networkAddress("tcp!0:0:0:0:0:0:0:1!5640", false)); + try testing.expectEqual(@as(u16, 0), (try networkAddress("tcp!127.0.0.1!0", true)).getPort()); + for ([_][]const u8{ "tcp!localhost!5640", "tcp!127.0.0.1!0", "tcp!127.0.0.1!-1", "tcp!127.0.0.1!65536", "tcp!127.0.0.1!", "tcp!!5640" }) |dial| + try testing.expectError(error.BadDial, networkAddress(dial, false)); + try Client.validateDial("tcp!127.0.0.1!5640"); + try Client.validateDial("/tmp/pardes-owned.sock"); + try Client.validateDial("unix!/tmp/pardes-owned.sock"); + try testing.expectError(error.BadDial, Client.validateDial("unix!work")); + try testing.expectError(error.BadDial, Client.validateDial("unix!")); + try testing.expectError(error.BadDial, Client.validateDial("unix!/tmp/a\x00b")); + try testing.expectError(error.BadDial, Client.validateDial("tcp!localhost!5640")); + try testing.expectError(error.BadDial, Client.validateDial("/tmp/a\x00b")); + if (quic_enabled) { + try Client.validateDial("quic!127.0.0.1!5640"); + } else try testing.expectError(error.QuicUnavailable, Client.validateDial("quic!127.0.0.1!5640")); +} + +test "same-session TCP mounts compare canonical endpoints and local wildcard destinations" { + if (comptime !supported) return error.SkipZigTest; + const Case = struct { bound: []const u8, dial: []const u8, same: bool }; + for ([_]Case{ + .{ .bound = "tcp!127.0.0.1!5640", .dial = "tcp!::ffff:127.0.0.1!5640", .same = true }, + .{ .bound = "tcp!::ffff:127.0.0.1!5640", .dial = "tcp!127.0.0.1!5640", .same = true }, + .{ .bound = "tcp!::1!5640", .dial = "tcp!0:0:0:0:0:0:0:1!5640", .same = true }, + .{ .bound = "tcp!127.0.0.1!5640", .dial = "tcp!0.0.0.0!5640", .same = true }, + .{ .bound = "tcp!::1!5640", .dial = "tcp!::!5640", .same = true }, + .{ .bound = "tcp!0.0.0.0!5640", .dial = "tcp!127.0.0.2!5640", .same = true }, + .{ .bound = "tcp!::!5640", .dial = "tcp!::1!5640", .same = true }, + .{ .bound = "tcp!127.0.0.1!5640", .dial = "tcp!127.0.0.1!5641", .same = false }, + .{ .bound = "tcp!0.0.0.0!5640", .dial = "tcp!192.0.2.1!5640", .same = false }, + .{ .bound = "tcp!::!5640", .dial = "tcp!2001:db8::1!5640", .same = false }, + .{ .bound = "tcp!::!5640", .dial = "tcp!127.0.0.1!5640", .same = false }, + }) |c| try testing.expectEqual(c.same, Client.sameSession(c.dial, "", try networkAddress(c.bound, true), null)); + try testing.expect(Client.sameSession("/tmp/pardes-owned.sock", "/tmp/pardes-owned.sock", null, null)); + try testing.expect(Client.sameSession("unix!/tmp/pardes-owned.sock", "/tmp/pardes-owned.sock", null, null)); + try testing.expect(!Client.sameSession("tcp!127.0.0.1!5640", "/tmp/pardes-owned.sock", null, null)); + if (quic_enabled) { + const endpoint = try networkAddress("quic!127.0.0.1!5640", false); + try testing.expect(Client.sameSession("quic!::ffff:127.0.0.1!5640", "", null, endpoint)); + try testing.expect(!Client.sameSession("tcp!127.0.0.1!5640", "", null, endpoint)); + try testing.expect(!Client.sameSession("quic!127.0.0.1!5640", "", endpoint, null)); + } +} + +extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; +extern "c" fn rmdir(path: [*:0]const u8) c_int; + +test "Unix TCP and QUIC share one listener through reads writes reconnects and reset" { + if (comptime !supported) return error.SkipZigTest; + const gpa = testing.allocator; + var directory: [64:0]u8 = undefined; + _ = try std.fmt.bufPrintSentinel(&directory, "/tmp/pardes-tcp-XXXXXX", .{}, 0); + if (mkdtemp(&directory) == null) return error.TempDirectoryFailed; + defer _ = rmdir(&directory); + const old_runtime = if (libc.getenv("XDG_RUNTIME_DIR")) |v| try gpa.dupeZ(u8, std.mem.span(v)) else null; + defer { + if (old_runtime) |v| { + _ = setenv("XDG_RUNTIME_DIR", v, 1); + gpa.free(v); + } else _ = unsetenv("XDG_RUNTIME_DIR"); + } + try testing.expectEqual(@as(c_int, 0), setenv("XDG_RUNTIME_DIR", &directory, 1)); + const replacement = try gpa.alloc(u8, 3 * msize + 27); + defer gpa.free(replacement); + @memset(replacement, 'x'); + @memcpy(replacement[0.."changed café λ\n".len], "changed café λ\n"); + replacement[replacement.len - 1] = '\n'; + + const Worker = struct { + dial: []const u8, + body_path: []const u8, + expected: []const u8, + replacement: []const u8, + done: std.atomic.Value(bool) = .init(false), + failure: ?anyerror = null, + + fn run(w: *@This()) void { + defer w.done.store(true, .release); + w.check() catch |err| { + w.failure = err; + }; + } + + fn check(w: *@This()) !void { + const before = try Client.readLimit(testing.allocator, w.dial, w.body_path, w.body_path, w.expected.len); + defer testing.allocator.free(before); + try testing.expectEqualStrings(w.expected, before); + try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, w.body_path, w.body_path, w.expected.len - 1)); + const listing = try Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", 128); + defer testing.allocator.free(listing); + const exact_listing = try Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", listing.len); + defer testing.allocator.free(exact_listing); + try testing.expectEqualStrings(listing, exact_listing); + try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", listing.len - 1)); + try Client.write(testing.allocator, w.dial, w.body_path, w.replacement); + const after = try Client.read(testing.allocator, w.dial, w.body_path, w.body_path); + defer testing.allocator.free(after); + try testing.expectEqualStrings(w.replacement, after); + const screen = try Client.read(testing.allocator, w.dial, "/self/screen", "/self/screen"); + defer testing.allocator.free(screen); + const parsed = try std.json.parseFromSlice(struct { cols: u16, rows: u16 }, testing.allocator, screen, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + try testing.expectEqual(@as(u16, 40), parsed.value.cols); + try testing.expectEqual(@as(u16, 12), parsed.value.rows); + } + }; + + const protocols: []const []const u8 = if (quic_enabled) &.{ "tcp", "quic" } else &.{"tcp"}; + for (protocols) |protocol| for ([_][]const u8{ "127.0.0.1", "::1" }) |host| { + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("initial\n"); + while (p.nextEffect()) |_| {} + var bind_buf: [64]u8 = undefined; + const bind = try std.fmt.bufPrint(&bind_buf, "{s}!{s}!0", .{ protocol, host }); + const tcp = std.mem.eql(u8, protocol, "tcp"); + const l = listen(gpa, "roundtrip", "", if (tcp) bind else null, if (tcp) null else bind) orelse return error.ListenFailed; + defer { + l.reset(p); + l.deinit(gpa); + } + try testing.expect(l.fd >= 0); + try testing.expectEqual(tcp, l.tcp_fd >= 0); + try testing.expectEqual(@as(usize, 4), l.conns.len); + try testing.expect(l.watcher == null); + const port = (if (tcp) l.tcp_address else l.quic_address).?.getPort(); + try testing.expect(port != 0); + var dial_buf: [64]u8 = undefined; + const network_dial = try std.fmt.bufPrint(&dial_buf, "{s}!{s}!{d}", .{ protocol, host, port }); + var body_buf: [64]u8 = undefined; + const body = try std.fmt.bufPrint(&body_buf, "/self/pane/{d}/body", .{pane.serial}); + for ([_][]const u8{ network_dial, l.path(), network_dial }, 0..) |dial, attempt| { + var worker: Worker = .{ .dial = dial, .body_path = body, .expected = if (attempt == 0) "initial\n" else replacement, .replacement = replacement }; + const thread = try std.Thread.spawn(.{}, Worker.run, .{&worker}); + defer thread.join(); + const deadline = Client.nowMs() + 3 * Client.budget_ms; + while (!worker.done.load(.acquire) and Client.nowMs() < deadline) { + _ = l.tick(p); + Client.nap(1); + } + try testing.expect(worker.done.load(.acquire)); + if (worker.failure) |err| return err; + const unix_fd = l.fd; + const tcp_fd = l.tcp_fd; + l.reset(p); + try testing.expectEqual(unix_fd, l.fd); + try testing.expectEqual(tcp_fd, l.tcp_fd); + for (&l.conns, 0..) |conn, i| try testing.expect(!l.live(@intCast(i)) and !conn.draining); + for (p.fs.snapshots) |snapshot| try testing.expect(snapshot.node == 0); + } + }; +} + +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; +extern "c" fn unsetenv(name: [*:0]const u8) c_int; + +pub fn start(gpa: std.mem.Allocator, core: *pardes.Pardes) ?*Listener { + var name: [16]u8 = undefined; + const fallback = std.fmt.bufPrint(&name, "{d}", .{@as(u32, @intCast(libc.getpid()))}) catch unreachable; + const listener = listen(gpa, core.opts.ninep_name, fallback, core.opts.ninep_tcp, core.opts.ninep_quic) orelse { + core.reportError(0, "9p listener", error.ListenFailed); + return null; + }; + core.fs.socket_path = listener.path(); + core.fs.tcp_address = listener.tcp_address; + core.fs.quic_address = listener.quic_address; + return listener; +} + +pub fn exportPaneEnv(listener: ?*const Listener, serial: u32, forward_look: bool) void { + _ = unsetenv("PARDES_FORWARD_LOOK"); + if (listener) |l| exporting: { + var sock: [sun_path_len]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&sock, "{s}", .{l.path()}, 0) catch break :exporting; + var buf: [16]u8 = undefined; + const id = std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0) catch break :exporting; + if (setenv("PARDES_9P", path, 1) != 0) break :exporting; + if (setenv("PARDES_PANE", id, 1) != 0) break :exporting; + if (setenv("PARDES_FORWARD_LOOK", if (forward_look) "1" else "0", 1) == 0) return; + } + _ = unsetenv("PARDES_9P"); + _ = unsetenv("PARDES_PANE"); + _ = setenv("PARDES_FORWARD_LOOK", "0", 1); +} + +test "9P shell environment preserves identity when nested Look forwarding is disabled" { + const names = [_][*:0]const u8{ "XDG_RUNTIME_DIR", "HOME", "PARDES_9P", "PARDES_PANE", "PARDES_FORWARD_LOOK" }; + var saved: [names.len]?[:0]u8 = @splat(null); + for (names, &saved) |name, *value| { + if (libc.getenv(name)) |old| value.* = try testing.allocator.dupeZ(u8, std.mem.span(old)); + } + defer for (names, saved) |name, value| { + if (value) |old| { + _ = setenv(name, old, 1); + testing.allocator.free(old); + } else _ = unsetenv(name); + }; + + var dir: [sun_path_len:0]u8 = undefined; + _ = setenv("XDG_RUNTIME_DIR", "/run/user/1000", 1); + try testing.expectEqualStrings("/run/user/1000", socketDir(&dir).?); + _ = unsetenv("XDG_RUNTIME_DIR"); + _ = setenv("HOME", "/home/example", 1); + try testing.expectEqualStrings("/home/example/.local/state/pardes", socketDir(&dir).?); + _ = unsetenv("HOME"); + try testing.expect(socketDir(&dir) == null); + + const listener = try testing.allocator.create(Listener); + defer testing.allocator.destroy(listener); + listener.* = .{}; + const path = "/tmp/pardes-example.sock"; + @memcpy(listener.path_buf[0..path.len], path); + listener.path_len = path.len; + exportPaneEnv(listener, 7, false); + try testing.expectEqualStrings(path, std.mem.span(libc.getenv("PARDES_9P").?)); + try testing.expectEqualStrings("7", std.mem.span(libc.getenv("PARDES_PANE").?)); + try testing.expectEqualStrings("0", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + exportPaneEnv(listener, 8, true); + try testing.expectEqualStrings("8", std.mem.span(libc.getenv("PARDES_PANE").?)); + try testing.expectEqualStrings("1", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + exportPaneEnv(null, 0, false); + try testing.expect(libc.getenv("PARDES_9P") == null); + try testing.expect(libc.getenv("PARDES_PANE") == null); + try testing.expectEqualStrings("0", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); +} + +pub const Client = struct { + pub const budget_ms: i64 = 2000; + + pub const max_depth: usize = 2 * ninep.max_welem; + + const uname = "pardes"; + + const Dial = union(enum) { unix: [:0]const u8, tcp: std.Io.net.IpAddress, quic: std.Io.net.IpAddress }; + + pub const Error = error{ + PathTooDeep, + BadDial, + Dial, + Hangup, + Timeout, + Botch, + Remote, + IsDirectory, + NotFound, + FileTooLarge, + QuicUnavailable, + }; + + pub fn read(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, display_path: []const u8) ![]u8 { + return readLimit(gpa, dial, path, display_path, limits.max_file_bytes); + } + + pub fn readLimit(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, display_path: []const u8, max_bytes: usize) ![]u8 { + if (comptime !supported) return error.Unsupported; + var names: [max_depth][]const u8 = undefined; + const n = try elements(path, &names); + var sock_buf: [sun_path_len]u8 = undefined; + const sock = try resolve(&sock_buf, dial); + var remote: RemoteError = .{}; + return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes)); + } + + pub fn write(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, bytes: []const u8) !void { + if (comptime !supported) return error.Unsupported; + var names: [max_depth][]const u8 = undefined; + const n = try elements(path, &names); + var sock_buf: [sun_path_len]u8 = undefined; + const sock = try resolve(&sock_buf, dial); + var remote: RemoteError = .{}; + const result = try fetchBytes(gpa, sock, names[0..n], &remote, bytes, path, limits.max_file_bytes); + gpa.free(result); + } + + const RemoteError = struct { + buf: [ninep.errmax]u8 = undefined, + len: usize = 0, + + fn set(r: *RemoteError, msg: []const u8) error{Remote} { + r.len = @min(msg.len, r.buf.len); + @memcpy(r.buf[0..r.len], msg[0..r.len]); + return error.Remote; + } + }; + + fn elements(path: []const u8, out: *[max_depth][]const u8) Error!usize { + var n: usize = 0; + var it = std.mem.tokenizeScalar(u8, path, '/'); + while (it.next()) |name| { + if (n == out.len) return Error.PathTooDeep; + out[n] = name; + n += 1; + } + return n; + } + + fn resolve(buf: *[sun_path_len]u8, dial: []const u8) error{ BadDial, QuicUnavailable }!Dial { + if (dial.len == 0) return error.BadDial; + if (std.mem.startsWith(u8, dial, "tcp!")) return .{ .tcp = try networkAddress(dial, false) }; + if (std.mem.startsWith(u8, dial, "quic!")) { + if (comptime !quic_enabled) return error.QuicUnavailable; + return .{ .quic = try networkAddress(dial, false) }; + } + const explicit_unix = std.mem.startsWith(u8, dial, "unix!"); + const path = if (explicit_unix) dial[5..] else dial; + if (explicit_unix and !std.mem.startsWith(u8, path, "/")) return error.BadDial; + if (std.mem.indexOfScalar(u8, path, '/') != null) { + if (std.mem.indexOfScalar(u8, path, 0) != null) return error.BadDial; + return .{ .unix = std.fmt.bufPrintSentinel(buf, "{s}", .{path}, 0) catch return error.BadDial }; + } + var dir_buf: [sun_path_len:0]u8 = undefined; + const dir = socketDir(&dir_buf) orelse return error.BadDial; + return .{ .unix = socketPath(buf, dir, dial) orelse return error.BadDial }; + } + + pub fn validateDial(dial: []const u8) error{ BadDial, QuicUnavailable }!void { + var buf: [sun_path_len]u8 = undefined; + _ = try resolve(&buf, dial); + } + + pub fn sameSession(dial: []const u8, socket_path: []const u8, tcp_address: ?std.Io.net.IpAddress, quic_address: ?std.Io.net.IpAddress) bool { + if (comptime !supported) return false; + var buf: [sun_path_len]u8 = undefined; + const address = resolve(&buf, dial) catch return false; + switch (address) { + .unix => |path| return socket_path.len != 0 and std.mem.eql(u8, path, socket_path), + .tcp, .quic => |destination| { + var ip = destination; + switch (ip) { + .ip4 => |v4| if (std.mem.allEqual(u8, &v4.bytes, 0)) { + ip = .{ .ip4 = .loopback(v4.port) }; + }, + .ip6 => |v6| if (std.mem.allEqual(u8, &v6.bytes, 0)) { + ip = .{ .ip6 = .loopback(v6.port) }; + }, + } + const bound = canonicalIp((if (address == .tcp) tcp_address else quic_address) orelse return false); + if (ip.getPort() != bound.getPort() or @as(std.Io.net.IpAddress.Family, ip) != @as(std.Io.net.IpAddress.Family, bound)) return false; + if (ip.eql(&bound)) return true; + const wildcard = switch (bound) { + .ip4 => |v4| std.mem.allEqual(u8, &v4.bytes, 0), + .ip6 => |v6| std.mem.allEqual(u8, &v6.bytes, 0), + }; + if (wildcard) return localIp(ip); + return false; + }, + } + } + + const Session = struct { + fd: c_int, + quic: if (quic_enabled) ?quic.Connection else void = if (quic_enabled) null else {}, + deadline: i64, + display_path: []const u8, + cl: ninep.Client = undefined, + in: [msize]u8 = undefined, + out: [msize]u8 = undefined, + stage: [msize]u8 = undefined, + + fn wait(s: *Session, events: i16) Error!void { + while (true) { + const left = s.deadline - nowMs(); + if (left <= 0) return Error.Timeout; + if (comptime quic_enabled) { + if (s.quic) |*connection| { + var fds = [1]libc.pollfd{connection.poll().?}; + const timeout = @min(left, connection.nextDue() orelse budget_ms); + const ready = libc.poll(&fds, 1, @intCast(timeout)); + if (ready < 0) { + if (libc.errno(ready) == .INTR) continue; + return Error.Hangup; + } + if (nowMs() >= s.deadline) return Error.Timeout; + if (fds[0].revents & @as(i16, @intCast(libc.POLL.NVAL)) != 0) return Error.Hangup; + connection.events() catch return Error.Hangup; + return; + } + } + var fds = [1]libc.pollfd{.{ .fd = s.fd, .events = events, .revents = 0 }}; + const ready = libc.poll(&fds, 1, @intCast(@min(left, budget_ms))); + if (ready < 0) { + if (libc.errno(ready) == .INTR) continue; + return Error.Hangup; + } + if (ready == 0 or nowMs() >= s.deadline) return Error.Timeout; + if (fds[0].revents & events != 0) return; + return Error.Hangup; + } + } + + fn flush(s: *Session) Error!void { + while (s.cl.output().len != 0) { + if (nowMs() >= s.deadline) return Error.Timeout; + const bytes = s.cl.output(); + if (comptime quic_enabled) { + if (s.quic) |*connection| { + const sent = connection.write(bytes) catch return Error.Hangup; + if (sent == 0) { + try s.wait(poll_out); + continue; + } + s.cl.wrote(sent); + continue; + } + } + try s.wait(poll_out); + const sent = libc.send(s.fd, bytes.ptr, bytes.len, nosignal); + if (sent < 0) switch (libc.errno(sent)) { + .INTR, .AGAIN => continue, + else => return Error.Hangup, + }; + if (sent == 0) return Error.Hangup; + s.cl.wrote(@intCast(sent)); + } + } + + fn settle(s: *Session) Error!ninep.Client.Done { + while (true) { + if (nowMs() >= s.deadline) return Error.Timeout; + try s.flush(); + if (s.cl.take()) |done| return done; + if (s.cl.dead) return Error.Botch; + const room = s.cl.in.len - s.cl.in_len; + if (room == 0) return Error.Botch; + if (comptime quic_enabled) { + if (s.quic) |*connection| { + const got = (connection.read(s.stage[0..@min(room, s.stage.len)]) catch return Error.Hangup) orelse { + try s.wait(poll_in); + continue; + }; + if (got == 0) return Error.Hangup; + const n = s.cl.push(s.stage[0..got]); + std.debug.assert(n == got); + continue; + } + } + try s.wait(poll_in); + const got = libc.read(s.fd, &s.stage, @min(room, s.stage.len)); + if (got == 0) return Error.Hangup; + if (got < 0) switch (libc.errno(got)) { + .INTR, .AGAIN => continue, + else => return Error.Hangup, + }; + const n = s.cl.push(s.stage[0..@intCast(got)]); + std.debug.assert(n == @as(usize, @intCast(got))); + } + } + + fn ask(s: *Session, req: ninep.Client.Request, remote: *RemoteError) Error!ninep.Client.Result { + _ = s.cl.submit(req) catch return Error.Botch; + const done = try s.settle(); + if (done.result == .fail) return remote.set(done.result.fail); + if (std.mem.eql(u8, @tagName(done.result), @tagName(std.meta.activeTag(req)))) return done.result; + return Error.Botch; + } + + fn drop(s: *Session, fid: u32) void { + _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; + _ = s.settle() catch {}; + } + + fn dropNoWait(s: *Session, fid: u32) void { + _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; + s.flush() catch {}; + } + }; + + fn transact( + s: *Session, + names: []const []const u8, + out: *std.Io.Writer.Allocating, + remote: *RemoteError, + write_bytes: ?[]const u8, + read_limit: usize, + ) !void { + _ = try s.ask(.{ .version = .{} }, remote); + if (s.cl.msize == 0) return Error.Botch; + + const root: u32 = 0; + var here = (try s.ask(.{ .attach = .{ .fid = root, .uname = uname } }, remote)).attach; + var cur: u32 = root; + var next: u32 = 1; + + var i: usize = 0; + while (i < names.len) { + const n = @min(ninep.max_welem, names.len - i); + const w = (try s.ask(.{ .walk = .{ + .fid = cur, + .newfid = next, + .names = names[i..][0..n], + } }, remote)).walk; + if (w.nwqid != n) return Error.NotFound; + here = w.wqid[n - 1]; + if (cur != root) s.drop(cur); + cur = next; + next = if (next == 1) 2 else 1; + i += n; + } + defer s.dropNoWait(cur); + + const directory = here.type & ninep.qtdir != 0; + + const mode: u8 = if (write_bytes != null) ninep.owrite else ninep.oread; + const truncate = write_bytes != null and names.len > 0 and + (std.mem.eql(u8, names[0], "os") or std.mem.eql(u8, names[names.len - 1], "body")); + _ = try s.ask(.{ .open = .{ .fid = cur, .mode = mode | if (truncate) ninep.otrunc else 0 } }, remote); + if (write_bytes) |bytes| { + if (directory) return Error.IsDirectory; + var written: usize = 0; + while (written < bytes.len) { + const chunk = bytes[written..][0..@min(bytes.len - written, s.cl.maxWrite())]; + const count = (try s.ask(.{ .write = .{ .fid = cur, .offset = written, .data = chunk } }, remote)).write; + if (count == 0 or count > chunk.len) return Error.Botch; + written += count; + } + return; + } + + const max_bytes: u64 = @min(read_limit, @as(usize, if (directory) limits.max_stream_bytes else limits.max_file_bytes)); + const wire_limit: u64 = if (directory) limits.max_stream_bytes else max_bytes; + var off: u64 = 0; + while (true) { + const want: u32 = @intCast(@min(@as(u64, s.cl.maxRead()), wire_limit + 1 - off)); + const data = (try s.ask(.{ .read = .{ .fid = cur, .offset = off, .count = want } }, remote)).read; + if (data.len == 0) return; + if (off + data.len > wire_limit) return Error.FileTooLarge; + if (directory) { + var pos: usize = 0; + while (pos < data.len) { + if (data.len - pos < 2) return Error.Botch; + const len: usize = 2 + @as(usize, std.mem.readInt(u16, data[pos..][0..2], .little)); + if (len > data.len - pos) return Error.Botch; + const entry = ninep.Stat.decode(data[pos..][0..len]) catch return Error.Botch; + const display_dir = std.mem.trimEnd(u8, s.display_path, "/"); + const row_len = display_dir.len + entry.name.len + 2 + @as(usize, @intFromBool(entry.qid.type & ninep.qtdir != 0)); + if (row_len > max_bytes - out.written().len) return Error.FileTooLarge; + try out.writer.print("{s}/{s}", .{ display_dir, entry.name }); + if (entry.qid.type & ninep.qtdir != 0) try out.writer.writeByte('/'); + try out.writer.writeByte('\n'); + pos += len; + } + } else try out.writer.writeAll(data); + off += data.len; + } + } + + fn fetchBytes( + gpa: std.mem.Allocator, + sock: Dial, + names: []const []const u8, + remote: *RemoteError, + write_bytes: ?[]const u8, + display_path: []const u8, + read_limit: usize, + ) ![]u8 { + if (comptime !supported) return Error.Dial; + const deadline = nowMs() +| budget_ms; + const s = try gpa.create(Session); + s.* = .{ .fd = -1, .deadline = deadline, .display_path = display_path }; + defer { + if (quic_enabled and s.quic != null) { + s.quic.?.deinit(); + } else if (s.fd >= 0) _ = libc.close(s.fd); + gpa.destroy(s); + } + if (sock == .quic) { + if (comptime quic_enabled) { + s.quic = quic.Connection.dial(sock.quic) catch return Error.Dial; + s.fd = s.quic.?.fd; + } else return Error.QuicUnavailable; + } else s.fd = try connect(sock, deadline); + s.cl = .init(.{ .in = &s.in, .out = &s.out }); + + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + try transact(s, names, &out, remote, write_bytes, read_limit); + return out.toOwnedSlice(); + } + + fn connect(sock: Dial, deadline: i64) Error!c_int { + var addr: libc.sockaddr.storage = undefined; + const addr_len: libc.socklen_t = switch (sock) { + .unix => |path| blk: { + if (path.len + 1 > sun_path_len) return Error.BadDial; + const un: *libc.sockaddr.un = @ptrCast(&addr); + un.* = .{ .path = @splat(0) }; + @memcpy(un.path[0 .. path.len + 1], path[0 .. path.len + 1]); + break :blk @sizeOf(libc.sockaddr.un); + }, + .tcp => |ip| ipSockaddr(ip, &addr), + .quic => return Error.QuicUnavailable, + }; + const fd = libc.socket(addr.family, libc.SOCK.STREAM, 0); + if (fd < 0) return Error.Dial; + setCloexec(fd); + setNonblock(fd); + errdefer _ = libc.close(fd); + if (sock == .tcp) { + const on: c_int = 1; + _ = libc.setsockopt(fd, libc.IPPROTO.TCP, libc.TCP.NODELAY, &on, @sizeOf(c_int)); + } + while (true) { + if (libc.connect(fd, @ptrCast(&addr), addr_len) == 0) break; + switch (libc._errno().*) { + @intFromEnum(libc.E.AGAIN), @intFromEnum(libc.E.INTR) => { + if (nowMs() >= deadline) return Error.Dial; + nap(2); + }, + @intFromEnum(libc.E.INPROGRESS), @intFromEnum(libc.E.ALREADY) => { + const left = deadline - nowMs(); + if (left <= 0) return Error.Dial; + var pfd: [1]libc.pollfd = .{.{ .fd = fd, .events = poll_out, .revents = 0 }}; + if (libc.poll(&pfd, 1, @intCast(@min(left, 1000))) <= 0) continue; + var err: c_int = 0; + var len: libc.socklen_t = @sizeOf(c_int); + if (libc.getsockopt(fd, libc.SOL.SOCKET, libc.SO.ERROR, @ptrCast(&err), &len) != 0) + return Error.Dial; + if (err == 0) break; + return Error.Dial; + }, + @intFromEnum(libc.E.ISCONN) => break, + else => return Error.Dial, + } + } + if (comptime darwin) { + const on: c_int = 1; + _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); + } + return fd; + } + + fn nowMs() i64 { + var ts: libc.timespec = undefined; + if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return std.math.maxInt(i64); + return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); + } + + const poll_in: i16 = @intCast(libc.POLL.IN); + const poll_out: i16 = @intCast(libc.POLL.OUT); + + fn nap(ms: c_int) void { + _ = libc.poll(&[0]libc.pollfd{}, 0, ms); + } + + test "a path becomes walk elements, normalised the way a shell would" { + var out: [max_depth][]const u8 = undefined; + try testing.expectEqual(@as(usize, 4), try elements("/self/pane/1/body", &out)); + try testing.expectEqualStrings("self", out[0]); + try testing.expectEqualStrings("pane", out[1]); + try testing.expectEqualStrings("1", out[2]); + try testing.expectEqualStrings("body", out[3]); + + try testing.expectEqual(@as(usize, 4), try elements("self/pane/1/body", &out)); + try testing.expectEqual(@as(usize, 4), try elements("//self//pane//1//body//", &out)); + try testing.expectEqual(@as(usize, 2), try elements("/self/index", &out)); + + try testing.expectEqual(@as(usize, 0), try elements("/", &out)); + + var deep: [8 * max_depth]u8 = @splat('/'); + for (0..max_depth + 1) |i| deep[i * 2 + 1] = 'a'; + try testing.expectError(Error.PathTooDeep, elements(deep[0 .. (max_depth + 1) * 2], &out)); + } + + test "a bare dial resolves to the socket --9p binds, and a path is taken as given" { + if (comptime !supported) return error.SkipZigTest; + var buf: [sun_path_len]u8 = undefined; + + const named = (try resolve(&buf, "work")).unix; + try testing.expect(std.mem.endsWith(u8, named, "/pardes-9p-work.sock")); + var expect: [sun_path_len]u8 = undefined; + var dir_buf: [sun_path_len:0]u8 = undefined; + const dir = socketDir(&dir_buf).?; + try testing.expectEqualStrings(socketPath(&expect, dir, "work").?, named); + + const path = (try resolve(&buf, "/tmp/somewhere.sock")).unix; + try testing.expectEqualStrings("/tmp/somewhere.sock", path); + try testing.expectEqualStrings("/tmp/somewhere.sock", (try resolve(&buf, "unix!/tmp/somewhere.sock")).unix); + + try testing.expectError(error.BadDial, resolve(&buf, "")); + try testing.expectError(error.BadDial, resolve(&buf, "/tmp/a\x00b")); + } + + test "a dial with nothing listening is one error and not a wait" { + if (comptime !supported) return error.SkipZigTest; + var names: [max_depth][]const u8 = undefined; + const n = try elements("/self/pane/1/body", &names); + var remote: RemoteError = .{}; + const before = nowMs(); + try testing.expectError( + Error.Dial, + fetchBytes(testing.allocator, .{ .unix = "/tmp/pardes-9p-no-such-socket.sock" }, names[0..n], &remote, null, "/self/pane/1/body", limits.max_file_bytes), + ); + try testing.expect(nowMs() - before < budget_ms); + } + + test "one fetch has three msize buffers and bounded transport metadata" { + const transport_bytes = if (quic_enabled) @sizeOf(?quic.Connection) else 0; + try testing.expectEqual(@as(usize, msize), @as(usize, (Session{ .fd = -1, .deadline = 0, .display_path = "" }).in.len)); + try testing.expect(transport_bytes <= 64); + try testing.expect(@sizeOf(Session) <= 3 * msize + 256 + transport_bytes); + try testing.expect(@sizeOf(ninep.Client) <= 256); + } + + test "expired sessions do not send or consume buffered protocol work" { + var session: Session = .{ .fd = -1, .deadline = 0, .display_path = "" }; + session.cl = .init(.{ .in = &session.in, .out = &session.out }); + _ = try session.cl.submit(.{ .version = .{} }); + const queued = session.cl.output().len; + try testing.expect(queued > 0); + try testing.expectError(Error.Timeout, session.flush()); + try testing.expectEqual(queued, session.cl.output().len); + try testing.expectError(Error.Timeout, session.settle()); + try testing.expectError(Error.Timeout, session.wait(poll_in)); + } +}; diff --git a/src/9p_quic.zig b/src/9p_quic.zig new file mode 100644 index 00000000..7898adb0 --- /dev/null +++ b/src/9p_quic.zig @@ -0,0 +1,557 @@ +const std = @import("std"); +const libc = std.c; +const ssl = @import("openssl"); + +comptime { + if (ssl.OPENSSL_VERSION_NUMBER < 0x30600000) + @compileError("9P over QUIC requires OpenSSL 3.6 or newer"); +} + +pub const alpn = "pardes-9p"; +pub const Error = error{ Tls, Socket, SocketFlags, SocketOption, Bind, Address, Closed, InvalidWrite }; + +pub const Listener = struct { + fd: c_int, + handle: *ssl.SSL, + address: std.Io.net.IpAddress, + + pub fn init(address: std.Io.net.IpAddress) Error!Listener { + ssl.ERR_clear_error(); + const ctx = ssl.SSL_CTX_new(ssl.OSSL_QUIC_server_method()) orelse return error.Tls; + defer ssl.SSL_CTX_free(ctx); + const key = ssl.EVP_PKEY_Q_keygen(null, null, "EC", @as([*:0]const u8, "prime256v1")) orelse return error.Tls; + defer ssl.EVP_PKEY_free(key); + const cert = ssl.X509_new() orelse return error.Tls; + defer ssl.X509_free(cert); + if (ssl.X509_set_version(cert, 2) != 1 or + ssl.ASN1_INTEGER_set(ssl.X509_get_serialNumber(cert), 1) != 1 or + ssl.X509_gmtime_adj(ssl.X509_getm_notBefore(cert), -60) == null or + ssl.X509_gmtime_adj(ssl.X509_getm_notAfter(cert), 365 * 24 * 60 * 60) == null or + ssl.X509_set_pubkey(cert, key) != 1) return error.Tls; + const name = ssl.X509_get_subject_name(cert) orelse return error.Tls; + if (ssl.X509_NAME_add_entry_by_txt(name, "CN", ssl.MBSTRING_ASC, "pardes", -1, -1, 0) != 1 or + ssl.X509_set_issuer_name(cert, name) != 1 or + ssl.X509_sign(cert, key, ssl.EVP_sha256()) <= 0 or + ssl.SSL_CTX_use_certificate(ctx, cert) != 1 or + ssl.SSL_CTX_use_PrivateKey(ctx, key) != 1) return error.Tls; + ssl.SSL_CTX_set_verify(ctx, ssl.SSL_VERIFY_NONE, null); + ssl.SSL_CTX_set_alpn_select_cb(ctx, selectAlpn, null); + var addr: libc.sockaddr.storage = undefined; + const addr_len = sockaddr(address, &addr); + const fd = try udp(addr.family); + errdefer _ = libc.close(fd); + if (libc.bind(fd, @ptrCast(&addr), addr_len) != 0) return error.Bind; + var actual_len: libc.socklen_t = @sizeOf(@TypeOf(addr)); + if (libc.getsockname(fd, @ptrCast(&addr), &actual_len) != 0) return error.Address; + var actual = address; + actual.setPort(switch (address) { + .ip4 => std.mem.bigToNative(u16, @as(*const libc.sockaddr.in, @ptrCast(&addr)).port), + .ip6 => std.mem.bigToNative(u16, @as(*const libc.sockaddr.in6, @ptrCast(&addr)).port), + }); + const handle = ssl.SSL_new_listener(ctx, 0) orelse return error.Tls; + errdefer ssl.SSL_free(handle); + if (ssl.SSL_set_fd(handle, fd) != 1 or ssl.SSL_set_blocking_mode(handle, 0) != 1 or + ssl.SSL_listen(handle) != 1) return error.Tls; + return .{ .fd = fd, .handle = handle, .address = actual }; + } + + pub fn accept(l: *Listener) Error!?Connection { + ssl.ERR_clear_error(); + const handle = ssl.SSL_accept_connection(l.handle, ssl.SSL_ACCEPT_CONNECTION_NO_BLOCK) orelse { + if (ssl.ERR_peek_error() != 0) return error.Tls; + return null; + }; + errdefer ssl.SSL_free(handle); + if (ssl.SSL_set_default_stream_mode(handle, ssl.SSL_DEFAULT_STREAM_MODE_NONE) != 1 or + ssl.SSL_set_blocking_mode(handle, 0) != 1) return error.Tls; + return .{ .handle = handle }; + } + + pub fn events(l: *Listener) Error!void { + ssl.ERR_clear_error(); + if (ssl.SSL_handle_events(l.handle) != 1) return error.Tls; + } + + pub fn poll(l: *const Listener) libc.pollfd { + return pollFd(l.handle, l.fd); + } + + pub fn nextDue(l: *const Listener) ?i32 { + return due(l.handle); + } + + // Accepted connections must be released before the shared UDP socket. + pub fn deinit(l: *Listener) void { + ssl.SSL_free(l.handle); + _ = libc.close(l.fd); + l.* = undefined; + } +}; + +pub const Connection = struct { + handle: *ssl.SSL, + stream: ?*ssl.SSL = null, + fd: c_int = -1, + pending_write_len: usize = 0, + + pub fn dial(address: std.Io.net.IpAddress) Error!Connection { + ssl.ERR_clear_error(); + const ctx = ssl.SSL_CTX_new(ssl.OSSL_QUIC_client_method()) orelse return error.Tls; + defer ssl.SSL_CTX_free(ctx); + ssl.SSL_CTX_set_verify(ctx, ssl.SSL_VERIFY_NONE, null); + const fd = try udp(if (address == .ip4) libc.AF.INET else libc.AF.INET6); + errdefer _ = libc.close(fd); + const handle = ssl.SSL_new(ctx) orelse return error.Tls; + errdefer ssl.SSL_free(handle); + if (ssl.SSL_set_fd(handle, fd) != 1 or ssl.SSL_set_blocking_mode(handle, 0) != 1 or + ssl.SSL_set_default_stream_mode(handle, ssl.SSL_DEFAULT_STREAM_MODE_NONE) != 1) return error.Tls; + const protocols = [_]u8{alpn.len} ++ alpn.*; + if (ssl.SSL_set_alpn_protos(handle, &protocols, protocols.len) != 0) return error.Tls; + const peer = ssl.BIO_ADDR_new() orelse return error.Tls; + defer ssl.BIO_ADDR_free(peer); + const made = switch (address) { + .ip4 => |ip| ssl.BIO_ADDR_rawmake(peer, libc.AF.INET, &ip.bytes, ip.bytes.len, std.mem.nativeToBig(u16, ip.port)), + .ip6 => |ip| ssl.BIO_ADDR_rawmake(peer, libc.AF.INET6, &ip.bytes, ip.bytes.len, std.mem.nativeToBig(u16, ip.port)), + }; + if (made != 1 or ssl.SSL_set1_initial_peer_addr(handle, peer) != 1) return error.Tls; + return .{ .handle = handle, .fd = fd }; + } + + pub fn handshake(c: *Connection) Error!bool { + ssl.ERR_clear_error(); + var close_info: ssl.SSL_CONN_CLOSE_INFO = undefined; + if (ssl.SSL_get_conn_close_info(c.handle, &close_info, @sizeOf(@TypeOf(close_info))) == 1) + return error.Closed; + if (ssl.SSL_is_init_finished(c.handle) == 1) return true; + const rc = if (c.fd >= 0) ssl.SSL_connect(c.handle) else ssl.SSL_accept(c.handle); + if (rc == 1) return true; + try retry(c.handle, rc); + return false; + } + + fn ready(c: *Connection) Error!bool { + if (!try c.handshake()) return false; + if (c.stream != null) return true; + ssl.ERR_clear_error(); + const stream = if (c.fd >= 0) + ssl.SSL_new_stream(c.handle, ssl.SSL_STREAM_FLAG_NO_BLOCK) + else + ssl.SSL_accept_stream(c.handle, ssl.SSL_ACCEPT_STREAM_NO_BLOCK); + if (stream == null) { + if (ssl.ERR_peek_error() != 0) return error.Tls; + return false; + } + errdefer ssl.SSL_free(stream); + if (ssl.SSL_set_blocking_mode(stream, 0) != 1 or + ssl.SSL_get_stream_id(stream) != 0 or + ssl.SSL_set_incoming_stream_policy(c.handle, ssl.SSL_INCOMING_STREAM_POLICY_REJECT, 0) != 1) + return error.Tls; + _ = ssl.SSL_set_mode(stream, ssl.SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + c.stream = stream; + return true; + } + + pub fn read(c: *Connection, bytes: []u8) Error!?usize { + if (!try c.ready()) return null; + ssl.ERR_clear_error(); + var len: usize = 0; + const rc = ssl.SSL_read_ex(c.stream, bytes.ptr, bytes.len, &len); + if (rc == 1) return len; + if (ssl.SSL_get_error(c.stream, rc) == ssl.SSL_ERROR_ZERO_RETURN) return 0; + try retry(c.stream.?, rc); + return null; + } + + pub fn pending(c: *const Connection) bool { + var close_info: ssl.SSL_CONN_CLOSE_INFO = undefined; + if (ssl.SSL_get_conn_close_info(c.handle, &close_info, @sizeOf(@TypeOf(close_info))) == 1) return true; + if (c.stream) |stream| { + var item: ssl.SSL_POLL_ITEM = .{ .desc = ssl.SSL_as_poll_descriptor(stream), .events = ssl.SSL_POLL_EVENT_RE, .revents = 0 }; + const timeout: ssl.struct_timeval = .{ .tv_sec = 0, .tv_usec = 0 }; + if (ssl.SSL_poll(&item, 1, @sizeOf(@TypeOf(item)), &timeout, ssl.SSL_POLL_FLAG_NO_HANDLE_EVENTS, null) != 1) return true; + return item.revents != 0; + } + return ssl.SSL_get_accept_stream_queue_len(c.handle) != 0; + } + + pub fn write(c: *Connection, bytes: []const u8) Error!usize { + if (!try c.ready()) return 0; + if (bytes.len < c.pending_write_len) return error.InvalidWrite; + const requested = if (c.pending_write_len != 0) c.pending_write_len else bytes.len; + if (requested == 0) return 0; + ssl.ERR_clear_error(); + var len: usize = 0; + const rc = ssl.SSL_write_ex(c.stream, bytes.ptr, requested, &len); + if (rc == 1) { + c.pending_write_len = 0; + return len; + } + try retry(c.stream.?, rc); + c.pending_write_len = requested; + return 0; + } + + pub fn conclude(c: *Connection) Error!void { + if (c.pending_write_len != 0) return error.InvalidWrite; + if (!try c.ready()) return error.Closed; + ssl.ERR_clear_error(); + if (ssl.SSL_stream_conclude(c.stream, 0) != 1) return error.Tls; + } + + pub fn events(c: *Connection) Error!void { + if (c.fd < 0) return; + ssl.ERR_clear_error(); + if (ssl.SSL_handle_events(c.handle) != 1) return error.Tls; + } + + pub fn poll(c: *const Connection) ?libc.pollfd { + return if (c.fd >= 0) pollFd(c.handle, c.fd) else null; + } + + pub fn nextDue(c: *const Connection) ?i32 { + return if (c.fd >= 0) due(c.handle) else null; + } + + pub fn deinit(c: *Connection) void { + ssl.ERR_clear_error(); + _ = ssl.SSL_shutdown_ex(c.handle, ssl.SSL_SHUTDOWN_FLAG_RAPID | ssl.SSL_SHUTDOWN_FLAG_NO_STREAM_FLUSH | ssl.SSL_SHUTDOWN_FLAG_NO_BLOCK, null, 0); + ssl.SSL_free(c.stream); + ssl.SSL_free(c.handle); + if (c.fd >= 0) _ = libc.close(c.fd); + c.* = undefined; + } +}; + +fn udp(family: u16) Error!c_int { + const fd = libc.socket(family, libc.SOCK.DGRAM, 0); + if (fd < 0) return error.Socket; + errdefer _ = libc.close(fd); + const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); + if (flags < 0) return error.SocketFlags; + var options: libc.O = @bitCast(@as(u32, @bitCast(flags))); + options.NONBLOCK = true; + if (libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(options))))) != 0 or + libc.fcntl(fd, libc.F.SETFD, @as(c_int, libc.FD_CLOEXEC)) != 0) return error.SocketFlags; + if (family == libc.AF.INET6) { + const enabled: c_int = 1; + const v6only = if (@import("builtin").os.tag.isDarwin()) 27 else libc.IPV6.V6ONLY; + if (libc.setsockopt(fd, libc.IPPROTO.IPV6, v6only, &enabled, @sizeOf(c_int)) != 0) return error.SocketOption; + } + return fd; +} + +fn sockaddr(address: std.Io.net.IpAddress, out: *libc.sockaddr.storage) libc.socklen_t { + switch (address) { + .ip4 => |ip| { + const addr: *libc.sockaddr.in = @ptrCast(out); + addr.* = .{ .port = std.mem.nativeToBig(u16, ip.port), .addr = @bitCast(ip.bytes) }; + return @sizeOf(libc.sockaddr.in); + }, + .ip6 => |ip| { + const addr: *libc.sockaddr.in6 = @ptrCast(out); + addr.* = .{ .port = std.mem.nativeToBig(u16, ip.port), .addr = ip.bytes, .flowinfo = 0, .scope_id = 0 }; + return @sizeOf(libc.sockaddr.in6); + }, + } +} + +fn pollFd(handle: *ssl.SSL, fd: c_int) libc.pollfd { + var result: libc.pollfd = .{ .fd = fd, .events = 0, .revents = 0 }; + if (ssl.SSL_net_read_desired(handle) == 1) result.events |= libc.POLL.IN; + if (ssl.SSL_net_write_desired(handle) == 1) result.events |= libc.POLL.OUT; + return result; +} + +fn due(handle: *ssl.SSL) ?i32 { + var tv: ssl.struct_timeval = undefined; + var infinite: c_int = undefined; + if (ssl.SSL_get_event_timeout(handle, &tv, &infinite) != 1) return 0; + if (infinite != 0) return null; + const ms = @as(i128, tv.tv_sec) * 1000 + @divFloor(@as(i128, tv.tv_usec) + 999, 1000); + return @intCast(std.math.clamp(ms, 0, std.math.maxInt(i32))); +} + +fn retry(handle: *ssl.SSL, rc: c_int) Error!void { + switch (ssl.SSL_get_error(handle, rc)) { + ssl.SSL_ERROR_WANT_READ, ssl.SSL_ERROR_WANT_WRITE => {}, + ssl.SSL_ERROR_ZERO_RETURN => return error.Closed, + else => return error.Tls, + } +} + +fn selectAlpn(_: ?*ssl.SSL, out: [*c][*c]const u8, outlen: [*c]u8, input: [*c]const u8, len: c_uint, _: ?*anyopaque) callconv(.c) c_int { + var offset: usize = 0; + while (offset < len) { + const size = input[offset]; + offset += 1; + if (size > len - offset) return ssl.SSL_TLSEXT_ERR_ALERT_FATAL; + if (std.mem.eql(u8, input[offset..][0..size], alpn)) { + out.* = input + offset; + outlen.* = size; + return ssl.SSL_TLSEXT_ERR_OK; + } + offset += size; + } + return ssl.SSL_TLSEXT_ERR_ALERT_FATAL; +} + +const TestPair = struct { + listener: *Listener, + client: Connection, + server: ?Connection = null, + + fn init(listener: *Listener) !TestPair { + var p: TestPair = .{ .listener = listener, .client = try .dial(listener.address) }; + errdefer p.deinit(); + const deadline = testNow() + 3000; + while (true) { + try listener.events(); + try p.client.events(); + if (p.server == null) p.server = try listener.accept(); + const connected = try p.client.handshake(); + if (p.server) |*server| if (connected and try server.handshake()) return p; + try p.wait(deadline); + } + } + + fn wait(p: *TestPair, deadline: i64) !void { + const remaining = deadline - testNow(); + if (remaining <= 0) return error.Deadline; + var timeout: i32 = @intCast(@min(remaining, std.math.maxInt(i32))); + if (p.listener.nextDue()) |ms| timeout = @min(timeout, ms); + if (p.client.nextDue()) |ms| timeout = @min(timeout, ms); + var fds = [_]libc.pollfd{ p.listener.poll(), p.client.poll().? }; + const rc = libc.poll(&fds, fds.len, timeout); + if (rc < 0 and libc.errno(rc) != .INTR) return error.Poll; + if (testNow() >= deadline) return error.Deadline; + try p.listener.events(); + try p.client.events(); + } + + fn transfer(p: *TestPair, from_client: bool, bytes: []const u8, fragment: usize) !void { + const writer = if (from_client) &p.client else &p.server.?; + const reader = if (from_client) &p.server.? else &p.client; + var sent: usize = 0; + var received: usize = 0; + var buffer: [8192]u8 = undefined; + const deadline = testNow() + 3000; + while (received < bytes.len) { + const written = if (sent != bytes.len) try writer.write(bytes[sent..][0..@min(fragment, bytes.len - sent)]) else 0; + sent += written; + const count = try reader.read(buffer[0..@min(fragment, buffer.len)]); + if (count) |n| { + try std.testing.expect(n > 0 and n <= bytes.len - received); + try std.testing.expectEqualSlices(u8, bytes[received..][0..n], buffer[0..n]); + received += n; + } + if (testNow() >= deadline) return error.Deadline; + if (received != bytes.len and written == 0 and count == null) { + try p.wait(deadline); + } else { + try p.listener.events(); + try p.client.events(); + } + } + try std.testing.expectEqual(bytes.len, sent); + } + + fn finish(p: *TestPair) !void { + try p.client.conclude(); + try p.server.?.conclude(); + var buffer: [16]u8 = undefined; + var a = false; + var b = false; + const deadline = testNow() + 3000; + while (!a or !b) { + if (!a) if (try p.client.read(&buffer)) |n| { + try std.testing.expectEqual(@as(usize, 0), n); + a = true; + }; + if (!b) if (try p.server.?.read(&buffer)) |n| { + try std.testing.expectEqual(@as(usize, 0), n); + b = true; + }; + if (!a or !b) try p.wait(deadline); + } + } + + fn deinit(p: *TestPair) void { + if (p.server) |*server| server.deinit(); + p.client.deinit(); + } +}; + +fn testNow() i64 { + var ts: libc.timespec = undefined; + std.debug.assert(libc.clock_gettime(.MONOTONIC, &ts) == 0); + return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); +} + +test "QUIC fragmented 9P frames reconnect and stream EOF over IPv4 and IPv6" { + const request = "\x13\x00\x00\x00\x64\xff\xff\x00\x20\x00\x00\x06\x00" ++ "9P2000"; + const response = "\x13\x00\x00\x00\x65\xff\xff\x00\x20\x00\x00\x06\x00" ++ "9P2000"; + const read_request = "\x17\x00\x00\x00\x74\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0b\x00\x00\x00"; + const read_response = "\x16\x00\x00\x00\x75\x01\x00\x0b\x00\x00\x00" ++ "hello ninep"; + for ([_][]const u8{ "127.0.0.1", "::1" }) |host| { + var listener = try Listener.init(try .parse(host, 0)); + defer listener.deinit(); + try std.testing.expect(listener.address.getPort() != 0); + if (listener.address == .ip6) { + var enabled: c_int = 0; + var len: libc.socklen_t = @sizeOf(c_int); + const v6only = if (@import("builtin").os.tag.isDarwin()) 27 else libc.IPV6.V6ONLY; + try std.testing.expectEqual(@as(c_int, 0), libc.getsockopt(listener.fd, libc.IPPROTO.IPV6, v6only, @ptrCast(&enabled), &len)); + try std.testing.expectEqual(@as(c_int, 1), enabled); + } + for (0..3) |_| { + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try std.testing.expect(pair.server.?.poll() == null); + try std.testing.expect(pair.server.?.nextDue() == null); + for ([_]usize{ 1, 2, 7, 64 }) |fragment| { + try pair.transfer(true, request, fragment); + try pair.transfer(false, response, fragment); + try pair.transfer(true, read_request, fragment); + try pair.transfer(false, read_response, fragment); + } + try pair.finish(); + } + } +} + +test "QUIC backpressure retries a moved prefix while new replies are appended" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try pair.transfer(true, "hello", 1); + const bytes: [8192]u8 = @splat(0x5a); + var total: usize = 0; + const deadline = testNow() + 3000; + while (total < 64 * 1024 * 1024) { + const written = try pair.client.write(&bytes); + total += written; + if (written == 0) break; + try pair.listener.events(); + try pair.client.events(); + if (testNow() >= deadline) return error.Deadline; + } + try std.testing.expect(total > 0 and total < 64 * 1024 * 1024); + try std.testing.expectEqual(bytes.len, pair.client.pending_write_len); + try std.testing.expectError(error.InvalidWrite, pair.client.write(bytes[0..1])); + var buffer: [8192]u8 = undefined; + var received: usize = 0; + while (received < total) { + if (try pair.server.?.read(&buffer)) |n| { + try std.testing.expect(n > 0); + try std.testing.expect(std.mem.allEqual(u8, buffer[0..n], 0x5a)); + received += n; + } else try pair.wait(deadline); + } + try std.testing.expectEqual(total, received); + var moved: [8192 + 7]u8 = undefined; + @memcpy(moved[0..bytes.len], &bytes); + @memset(moved[bytes.len..], 0x6b); + while (true) { + const n = try pair.client.write(&moved); + if (n != 0) { + try std.testing.expectEqual(bytes.len, n); + break; + } + try pair.wait(deadline); + } + received = 0; + while (received < bytes.len) { + if (try pair.server.?.read(&buffer)) |n| { + try std.testing.expect(n > 0); + try std.testing.expect(std.mem.allEqual(u8, buffer[0..n], 0x5a)); + received += n; + } else try pair.wait(deadline); + } + try std.testing.expectEqual(bytes.len, received); + try pair.transfer(true, moved[bytes.len..], 7); + try pair.finish(); +} + +test "QUIC owner can enforce handshake and read deadlines without busy polling" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + var client = try Connection.dial(listener.address); + defer client.deinit(); + const handshake_deadline = testNow() + 100; + var turns: usize = 0; + while (testNow() < handshake_deadline) { + try std.testing.expect(!try client.handshake()); + try client.events(); + var timeout: i32 = @intCast(@max(0, handshake_deadline - testNow())); + if (client.nextDue()) |ms| timeout = @min(timeout, ms); + var fds = [_]libc.pollfd{client.poll().?}; + const rc = libc.poll(&fds, fds.len, timeout); + if (rc < 0 and libc.errno(rc) != .INTR) return error.Poll; + turns += 1; + } + try std.testing.expect(turns < 100); + var serving = try Listener.init(try .parse("127.0.0.1", 0)); + defer serving.deinit(); + var pair = try TestPair.init(&serving); + defer pair.deinit(); + try pair.transfer(true, "request", 2); + var buffer: [32]u8 = undefined; + const read_deadline = testNow() + 100; + turns = 0; + while (true) { + try std.testing.expect(try pair.client.read(&buffer) == null); + pair.wait(read_deadline) catch |err| { + try std.testing.expectEqual(error.Deadline, err); + break; + }; + turns += 1; + } + try std.testing.expect(turns < 100); +} + +test "QUIC bind failure leaves the existing listener usable" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + for (0..8) |_| try std.testing.expectError(error.Bind, Listener.init(listener.address)); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try pair.transfer(true, "still listening", 3); + try pair.transfer(false, "still serving", 2); + try pair.finish(); +} + +test "QUIC pending reports buffered bytes and EOF without UDP readiness" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try std.testing.expect(!pair.server.?.pending()); + try std.testing.expectEqual(@as(usize, 3), try pair.client.write("abc")); + const deadline = testNow() + 3000; + while (!pair.server.?.pending()) try pair.wait(deadline); + var buffer: [3]u8 = undefined; + try std.testing.expectEqual(@as(?usize, 1), try pair.server.?.read(buffer[0..1])); + try std.testing.expectEqual(@as(u8, 'a'), buffer[0]); + try listener.events(); + try pair.client.events(); + try std.testing.expect(pair.server.?.pending()); + try std.testing.expectEqual(@as(?usize, 2), try pair.server.?.read(buffer[1..])); + try std.testing.expectEqualStrings("abc", &buffer); + try std.testing.expect(!pair.server.?.pending()); + try pair.client.conclude(); + while (!pair.server.?.pending()) try pair.wait(deadline); + try std.testing.expectEqual(@as(?usize, 0), try pair.server.?.read(&buffer)); +} + +test "QUIC moved listener retains its in-memory identity" { + var original = try Listener.init(try .parse("127.0.0.1", 0)); + var listener = original; + original = undefined; + defer listener.deinit(); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try pair.transfer(true, "moved listener", 2); + try pair.transfer(false, "same identity", 3); + try pair.finish(); +} diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 2df1a82e..25067a17 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -95,7 +95,7 @@ environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`, and every pty shell, `|` filter and language server inherited it — so `yazi` in `/opt/homebrew/bin` was missing in the app and present in the same build run - from a shell, which reads as "the Dock build is broken". `shell_bin` + from a shell, which reads as "the Dock build is broken". `host_io.Shell` composes `/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them and adopts the result before the first fork in all four native hosts. It appends, so an inherited entry is never demoted and a configured diff --git a/src/acmefs.zig b/src/acmefs.zig deleted file mode 100644 index dc2780fe..00000000 --- a/src/acmefs.zig +++ /dev/null @@ -1,3782 +0,0 @@ -//! ACME'S CONTROL FILESYSTEM, as a pure transaction over the core. -//! -//! plan9's acme serves `/mnt/acme`: a directory per window holding `addr`, -//! `body`, `ctl`, `data`, `event`, `tag`..., and a program that opens those -//! files IS an editor extension — no plugin API, no embedded interpreter, no -//! rebuild. `pardes --fs` serves the same tree over Linux FUSE (src/fuse.zig), -//! and this file is the whole of what the files MEAN. Read it beside acme's -//! `fsys.c` (the tree) and `xfid.c` (the handlers). -//! -//! THE SHAPE, and why it is this shape. A filesystem is a request/response -//! protocol driven by other processes, i.e. exactly the kind of concurrency -//! the core does not have and must not grow. acme answers it with a thread per -//! in-flight request (`xfidallocthread`, a `Channel` per `Xfid`, a `QLock` per -//! window); pardes cannot and should not, so: -//! -//! * This module is a PURE MAIN-THREAD TRANSACTION: `handle(p, req) Reply`. -//! No thread, no waiting, no callback, no allocation on the hot path. It -//! is freestanding-safe (no libc, no OS) and unit-testable with no FUSE -//! anywhere near it — the tests below post requests and read replies. -//! * Requests arrive as an ordinary `Event.fs_req` and answers leave as an -//! ordinary `Effect.fs_reply`, so the transport is the queue every other -//! host<->core message already uses. A backend with no threads at all -//! (the browser, a test) is not a special case: it either never sends a -//! request, or sends one from its own frame loop. -//! * BLOCKING — acme's `event` file, whose read waits for the user to do -//! something (acme parks the `Xfid` in `w->eventx` and a later `winevent` -//! sends it a message) — is `Status.again` here: "nothing consumed, ask me -//! again". The waiting lives in the host, which is where the kernel's -//! request already is. The core keeps no waiter list and no wakeups. -//! -//! DIVERGENCE FROM ACME, deliberate: acme counts RUNES, pardes counts BYTES -//! (clamped to grapheme boundaries). Every offset in this filesystem — `addr`, -//! `data`, the event records' q0/q1, `index`'s lengths — is a byte offset, -//! because pardes is byte-addressed end to end (selections, look spots, LSP -//! offsets) and a second coordinate system would mean an O(n) conversion at -//! every boundary and a lossy `addr=dot`. acme pays that cost the other way -//! round: it keeps the document as `Rune*` and converts on every utf read -//! (`xfidutfread`, which carries a "BUG: stupid code: scan from beginning" -//! comment for its cache miss). Identical for ASCII, which is what scripts -//! compute with. -const std = @import("std"); -const mvzr = @import("mvzr"); -const pardes = @import("pardes.zig"); -const config = @import("config.zig"); -const modal = @import("modal.zig"); -const file_pane = @import("file_pane.zig"); -const output_pane = @import("output_pane.zig"); -/// only for the scrollback read below: a terminal's `body` is a grid, and -/// term_pane owns how a grid becomes bytes (and whether there is one at all). -const term_pane = @import("term_pane.zig"); -/// only for `look.readFile`, which is what a `get` verb IS — the same -/// synchronous path-backed read `file_pane.open` does, and the one place this -/// module touches a disk. -const look = @import("look.zig"); -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const MAX_PANES = pardes.MAX_PANES; - -// ============================================================================ -// THE ABI — what a transport hands in and gets back. -// ============================================================================ - -/// The filesystem operations the core answers. Protocol-neutral on purpose: -/// FUSE opcodes, 9P messages and a unit test all reduce to these. -pub const Op = enum(u8) { - /// resolve `data` (a name) inside the directory `node` - lookup, - getattr, - /// only `truncate` is honoured; a filesystem of live editor state has no - /// mode, owner or timestamps to set - setattr, - open, - read, - write, - release, - readdir, - statfs, -}; - -pub const Status = enum(u8) { - ok, - /// NO DATA YET, nothing consumed: the transport must hold this request and - /// re-submit it unchanged on a later frame. The one blocking primitive, - /// and the reason the core needs no waiters (see the header). - again, - err, -}; - -/// One operation. `data` is BORROWED for the length of the single -/// `update(.{ .fs_req = ... })` call that carries it — the same rule as -/// `.pty_read`'s bytes — so this never goes through `postEvent`. -pub const Req = struct { - /// opaque echo token; the transport's request id (FUSE `unique`) - tag: u64, - op: Op, - node: u64, - /// from `.open`, on read/write/release - handle: u32 = 0, - /// read/write: byte offset. readdir: how many entries to skip. - off: u64 = 0, - /// read/readdir: bytes wanted. Writes carry their length in `data`. - size: u32 = 0, - /// lookup: the name. write: the bytes. - data: []const u8 = &.{}, - /// setattr: a size was set (only 0 means anything here) - truncate: bool = false, -}; - -pub const Reply = struct { - tag: u64, - status: Status = .ok, - /// positive errno when `status == .err` - errno: u16 = 0, - /// lookup/getattr/setattr answer this; `open` leaves it zeroed - attr: Attr = .{}, - /// `open` answers this; every later read/write/release repeats it - handle: u32 = 0, - payload: Payload = .none, - /// write: how many of the offered bytes were taken. A short count is a - /// real answer (`data` refusing a partial grapheme), not an error. - written: u32 = 0, - - pub const Attr = struct { - node: u64 = 0, - dir: bool = false, - size: u64 = 0, - /// permission bits only; the transport adds the format bits - mode: u16 = 0o600, - }; - - /// WHERE THE ANSWER'S BYTES ARE. Resolved by `pardes.fsPayload` inside the - /// effect drain — a borrow window identical to `.save_text`'s — so reading - /// a megabyte of body copies nothing. - pub const Payload = union(enum) { - none, - /// `State.out[0..len]`: formatted answers (ctl, addr, index, dirents, - /// event records). Valid until the next `handle` call. - staged: u32, - /// a slice of a live pane's text. `serial` rejects a reused slot - /// exactly like `.save_text` does. - region: struct { pane: u8, serial: u32, off: u32, len: u32 }, - }; - - pub fn fail(tag: u64, e: u16) Reply { - return .{ .tag = tag, .status = .err, .errno = e }; - } -}; - -/// The errno values this filesystem returns, standing in for acme's error -/// strings (`fsys.c`/`xfid.c`: Eperm, Ebadctl, Ebadaddr, Ebadevent, Edel...). -/// A filesystem has one channel for "no": the number. -pub const E = struct { - pub const PERM: u16 = 1; - pub const NOENT: u16 = 2; - pub const IO: u16 = 5; - pub const NOMEM: u16 = 12; - pub const NOTDIR: u16 = 20; - pub const INVAL: u16 = 22; - pub const NFILE: u16 = 23; - /// the one FILE here with a fixed capacity: a pane's editable tag tail is - /// a bounded one-line buffer, so a write with no room left is full rather - /// than refused (`writeTag`) - pub const NOSPC: u16 = 28; - pub const NOSYS: u16 = 38; -}; - -// ============================================================================ -// THE TREE — nodes, names, and the packing that makes both cheap. -// ============================================================================ - -/// One file inside a pane's directory: acme's `dirtabw` minus the plan9 -/// compatibility stubs (`editout` needs acme's Edit language; `draw`, -/// `consctl` and `label` are rio artefacts acme keeps for other programs' -/// sake), plus the `pty/` directory and its three files — the one thing here -/// with no prior art anywhere, because acme has no terminals and `ad` has no -/// terminal surface at all. -/// -/// A pty is a file interface wearing the wrong clothes: everything one wants -/// to do to it is an `ioctl`, and no dialect of this protocol has one. So -/// `TIOCSWINSZ` becomes `winsize 80 24`, `kill` becomes `sig INT`, spawn -/// becomes `exec`, and `TIOCGWINSZ` becomes a read of `status`. Three files -/// and no more: being first is a reason to keep it small. -/// -/// THE FIELD IS FULL AFTER THIS. `Node.file` is a u4 — sixteen values — and -/// these four take it to fifteen used. ONE VALUE (15) IS LEFT. The next file -/// added to a pane's directory needs a wider field, which means `Node`'s -/// packing changes and every node id in flight through a transport changes -/// with it; that is a deliberate wall, not an oversight, and it is why `pty/` -/// is a DIRECTORY holding three names rather than three more names beside -/// `body` — a subdirectory costs one value for the directory itself and buys -/// a namespace of its own, so `ctl` and `data` did not have to be renamed. -pub const PaneFile = enum(u4) { - dir = 0, - addr, - body, - ctl, - data, - errors, - event, - tag, - xdata, - rdsel, - wrsel, - /// the `pty/` directory itself, present only on a terminal pane - pty, - /// `pty/ctl`: `winsize`, `sig`, `exec`. Spelled with the prefix because - /// the enum is flat — the tree is two levels and the tag namespace is one - /// — and `name()` below is what puts the short name back on the wire. - pty_ctl, - /// `pty/status`: the dimensions and who holds the tty - pty_status, - /// `pty/data`: the raw stream, both directions - pty_data, - - /// Every name IS the variant's name, except the directory itself (`.` is - /// not an identifier) and the three inside `pty/`, whose names are already - /// taken by files beside `body` and so carry a prefix in the enum only. - pub fn name(f: PaneFile) []const u8 { - return switch (f) { - .dir => ".", - .pty_ctl => "ctl", - .pty_status => "status", - .pty_data => "data", - else => @tagName(f), - }; - } - - /// acme's dirtabw modes: 0400 read, 0200 write, 0600 both. - pub fn mode(f: PaneFile) u16 { - return switch (f) { - .dir, .pty => 0o500, - .errors, .wrsel, .pty_ctl => 0o200, - .rdsel, .pty_status => 0o400, - else => 0o600, - }; - } - - /// The two directories a pane has. Asked by `stat` and by every handler - /// that must refuse to treat a directory as a file. - pub fn isDir(f: PaneFile) bool { - return f == .dir or f == .pty; - } - - /// Does this name exist ONLY on a terminal pane? A file pane has no pty, - /// so the whole subtree is absent there rather than present and refusing: - /// a script tests `-d $PARDES_FS/7/pty` to find out whether pane 7 is a - /// terminal, which is a question the tree could not answer before. - pub fn inPty(f: PaneFile) bool { - return switch (f) { - .pty, .pty_ctl, .pty_status, .pty_data => true, - else => false, - }; - } -}; - -/// The files at the root, and the root itself. `new` is a directory whose -/// every lookup CREATES a pane (acme(4): "Accessing any file in new creates a -/// new window"), which is how a script opens one without a keystroke. -pub const TopFile = enum(u4) { - root = 1, - index = 2, - cons = 3, - new = 4, - - pub fn name(f: TopFile) []const u8 { - return if (f == .root) "." else @tagName(f); - } - - pub fn mode(f: TopFile) u16 { - return switch (f) { - .root, .new => 0o500, - .index => 0o400, - .cons => 0o200, - }; - } - - pub fn dir(f: TopFile) bool { - return f == .root or f == .new; - } -}; - -/// A NODE ID, which is one integer to the kernel and two fields to us: the -/// file within a pane's directory, and the pane's SERIAL — never reused, so a -/// node id can never come to mean a different pane. acme does the same packing -/// with `QID(w->id, f)` / `WIN(q)` / `FILE(q)` macros over an int; a packed -/// struct is the same bits with the shifts and masks checked by the compiler, -/// and `serial == 0` (no pane) is what keeps the top-level ids 1..5 out of the -/// way with no separate range check. -pub const Node = packed struct(u64) { - file: u4 = 0, - serial: u60 = 0, - - pub fn of(serial: u32, file: PaneFile) u64 { - std.debug.assert(serial != 0); - return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial }); - } - - /// What this id points at, or null when it names neither a top-level file - /// nor a possible pane file. Validity is decided HERE so no handler has to. - pub fn target(node: u64) ?Target { - const n: Node = @bitCast(node); - if (n.serial == 0) { - return .{ .top = std.enums.fromInt(TopFile, n.file) orelse return null }; - } - return .{ .pane = .{ - .serial = std.math.cast(u32, n.serial) orelse return null, - .file = std.enums.fromInt(PaneFile, n.file) orelse return null, - } }; - } -}; - -/// What a node id points at. -pub const Target = union(enum) { - top: TopFile, - pane: struct { serial: u32, file: PaneFile }, -}; - -// ============================================================================ -// STATE — everything the filesystem remembers between requests. -// ============================================================================ - -/// What a formatted answer starts out able to hold before it grows: `index` -/// over every pane, a directory listing, one event record. The buffer is kept -/// between requests and cleared, not freed, so the steady state allocates -/// nothing and nothing is capped by a number picked here. -pub const out_reserve = 4 * 1024; - -/// Records a reader has not taken yet, per pane. Beyond this the oldest are -/// dropped: an editor must not stall or grow without bound because a script -/// stopped reading, and a reader that fell this far behind has already lost -/// the thread — it can re-read `body` and resynchronise. (acme grows -/// `w->events` with `realloc` and has no bound at all.) -pub const queue_cap = 64 * 1024; - -/// A byte queue of formatted event records, each framed by its length so a -/// record whose TEXT contains newlines still comes out whole. -pub const Queue = struct { - buf: std.ArrayList(u8) = .empty, - /// How much of `buf` has been consumed. Popping moves this instead of - /// sliding the remainder down: a full queue holds thousands of ~20-byte - /// records, and a memmove per pop made draining one quadratic. The space - /// is reclaimed when the head passes half the buffer, so the amortised - /// cost of a pop is a pointer bump. - head: usize = 0, - - pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void { - q.buf.deinit(gpa); - q.head = 0; - } - - pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void { - if (record.len > std.math.maxInt(u32)) return; - while (q.buf.items.len - q.head + record.len + 4 > queue_cap) { - if (q.peek() == null) return; - q.pop(); - } - q.compact(); - var head: [4]u8 = undefined; - std.mem.writeInt(u32, &head, @intCast(record.len), .little); - q.buf.appendSlice(gpa, &head) catch return; - q.buf.appendSlice(gpa, record) catch { - q.buf.shrinkRetainingCapacity(q.buf.items.len - 4); - return; - }; - } - - /// The oldest record, or null when empty. Does not consume. - pub fn peek(q: *const Queue) ?[]const u8 { - const rest = q.buf.items[@min(q.head, q.buf.items.len)..]; - if (rest.len < 4) return null; - const len = std.mem.readInt(u32, rest[0..4], .little); - if (rest.len < 4 + len) return null; - return rest[4 .. 4 + len]; - } - - pub fn pop(q: *Queue) void { - const record = q.peek() orelse return; - q.head += 4 + record.len; - if (q.head == q.buf.items.len) { - q.buf.clearRetainingCapacity(); - q.head = 0; - } - } - - /// Consume `n` bytes off the FRONT of the oldest record, leaving whatever - /// is left of it as the new oldest record. - /// - /// A record-framed queue can do this at all only because the frame is a - /// length written IMMEDIATELY BEFORE its bytes: shortening the record - /// means writing the new length into the four bytes that now sit just - /// before what remains, and those four bytes are inside the region the old - /// length and the consumed bytes already occupied. Nothing live is - /// overwritten and nothing moves. - /// - /// Only a STREAM wants this. `event`'s records are atomic — half a record - /// is unparseable and desynchronises the reader for the rest of the - /// session — so `event` uses `pop` and refuses a short read. `pty/data` - /// carries raw pty bytes, which have no framing of their own: the records - /// there are only "what arrived in one `.output` event" and a reader may - /// split them anywhere, exactly as `read(2)` on the pty itself would. - pub fn popFront(q: *Queue, n: usize) void { - const record = q.peek() orelse return; - if (n >= record.len) return q.pop(); - q.head += n; - std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little); - } - - fn compact(q: *Queue) void { - if (q.head == 0 or q.head * 2 < q.buf.items.len) return; - const rest = q.buf.items.len - q.head; - std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]); - q.buf.shrinkRetainingCapacity(rest); - q.head = 0; - } - - pub fn empty(q: *const Queue) bool { - return q.peek() == null; - } - - /// Drop everything AND give the memory back. A queue whose last reader - /// left must not hold `queue_cap` of a program's output until its pane - /// dies; `clearRetainingCapacity` inside `pop` is the right thing between - /// reads and the wrong thing between readers. - pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void { - q.buf.clearAndFree(gpa); - q.head = 0; - } -}; - -/// Per-pane filesystem state, indexed by pane SLOT (not serial): it dies with -/// the pane, and a reused slot must start clean. -pub const PaneFs = struct { - /// acme's `w->addr`: where `data`/`xdata` read and write. Byte offsets. - addr: Range = .{}, - /// `limit=addr`: the range regex searches are confined to, or none. - limit: ?Range = null, - /// how many opens of this pane's `event` file are live. Non-zero means the - /// pane is SCRIPT-DRIVEN: its Look and Exec are reported, not performed. - readers: u16 = 0, - events: Queue = .{}, - /// `nomark`: writes stop pushing an undo point each, so a script's batch - /// of edits is one Undo (acme: `w->nomark`). - nomark: bool = false, - /// `noscroll`: a body write does not drag the view to the new text. - noscroll: bool = false, - /// The tag as it was at the end of the last update, so tag edits can be - /// reported without a hook in every tag mutation. Only kept while somebody - /// is listening. - tag_snap: std.ArrayList(u8) = .empty, - /// How many opens of this pane's `pty/data` file are live. THE GATE on the - /// raw queue below, and deliberately NOT `readers` above: a script reading - /// a terminal's output stream is not claiming the pane's buttons, so a pty - /// reader must not make the pane script-driven. Nobody reading means - /// `notePtyOutput` is one load and one branch and copies nothing. - pty_readers: u16 = 0, - /// Raw pty bytes on their way to the emulator, kept only while somebody is - /// reading them. The core does not buffer these anywhere else — they go - /// into the grid, and a grid cannot be un-rendered back into a byte - /// stream — so this is where `pty/data`'s read comes from. Same - /// drop-oldest cap as `events`, for the same reason: a script that stops - /// reading must not grow the editor. - pty_out: Queue = .{}, - - pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; - - fn deinit(pf: *PaneFs, gpa: std.mem.Allocator) void { - pf.events.deinit(gpa); - pf.pty_out.deinit(gpa); - pf.tag_snap.deinit(gpa); - pf.* = .{}; - } -}; - -/// The core's filesystem state. Lives on `Pardes`; zero-initialised, so a core -/// that never serves a filesystem pays one branch per frame and no memory -/// beyond this struct. -pub const State = struct { - /// Formatted answers, valid until the next `handle` call (Payload.staged). - /// Kept and cleared rather than freed: after the first few requests the - /// capacity is there and staging an answer allocates nothing. - out: std.ArrayList(u8) = .empty, - panes: [MAX_PANES]PaneFs = @splat(.{}), - /// How many `event` files are open anywhere. The one gate every recording - /// hook in the core is behind: nobody listening, nothing recorded, no diff - /// computed, no bytes copied. - listeners: u16 = 0, - /// Which input the core is handling, as acme's origin character: `K` - /// keyboard, `M` mouse, `E` a write to body/tag through this filesystem, - /// `F` an action through one of its other files. Set once per update. - origin: u8 = 'K', - - pub fn deinit(st: *State, gpa: std.mem.Allocator) void { - for (&st.panes) |*pf| pf.deinit(gpa); - st.out.deinit(gpa); - } - - /// Start a fresh answer. The previous one's bytes are dead the moment the - /// next request arrives, which is exactly the borrow window `fsPayload` - /// documents. - pub fn stage(st: *State, gpa: std.mem.Allocator) *std.ArrayList(u8) { - st.out.clearRetainingCapacity(); - st.out.ensureTotalCapacity(gpa, out_reserve) catch {}; - return &st.out; - } - - /// A pane died: drop its filesystem state, and with it any listener count - /// it held, so a script killed with its pane cannot leave the editor - /// suppressing button actions forever. - pub fn forget(st: *State, gpa: std.mem.Allocator, id: usize) void { - if (id >= MAX_PANES) return; - st.listeners -= @min(st.listeners, st.panes[id].readers); - st.panes[id].deinit(gpa); - } - - /// Is anybody reading this pane's events? The suppression rule and every - /// recording hook ask this. - pub fn scripted(st: *const State, id: usize) bool { - return id < MAX_PANES and st.panes[id].readers != 0; - } -}; - -// ============================================================================ -// EVENT RECORDS — what the core reports, in acme's wire format. -// ============================================================================ - -/// acme's record, byte for byte: origin char, type char, then four -/// blank-separated decimals (q0, q1, flag, text length), a blank, the text, -/// and a newline — `winevent`'s `"%c%d %d %d %d %.*S\n"` with the owner char -/// pushed in front (`wind.c`). Text of 256 bytes or more is elided (the -/// reader fetches it from `data`), which is also what bounds this buffer. -pub const max_record_text = 256; - -/// The action characters. Lower case is the tag, upper case the body, which is -/// how a reader tells them apart with no extra field. -pub const Action = enum(u8) { - body_delete = 'D', - tag_delete = 'd', - body_insert = 'I', - tag_insert = 'i', - body_look = 'L', - tag_look = 'l', - body_exec = 'X', - tag_exec = 'x', - - /// The enum IS the character, the way acme's `winevent` takes a `char` and - /// prints `%c` (`wind.c`) — so these three are expressions rather than the - /// three parallel switches that spelled the same alphabet out again. - pub fn char(a: Action) u8 { - return @intFromEnum(a); - } - - pub fn fromChar(c: u8) ?Action { - return std.enums.fromInt(Action, c); - } - - /// Lower case is the tag, upper case the body: acme's whole encoding of - /// WHICH TEXT a record is about, with no extra field. - pub fn onTag(a: Action) bool { - return @intFromEnum(a) >= 'a'; - } -}; - -/// Flag bits, acme(4). Look and exec are different vocabularies at the same -/// bit positions, so they get separate names rather than one enum. -pub const flag_builtin: u32 = 1; -pub const flag_expansion: u32 = 2; -pub const flag_filename: u32 = 4; -pub const flag_chorded: u32 = 8; - -/// Format one record into `buf` and return the bytes. -pub fn formatRecord( - buf: []u8, - origin: u8, - action: Action, - q0: u32, - q1: u32, - flag: u32, - text: []const u8, -) []const u8 { - const sent = if (text.len >= max_record_text) text[0..0] else text; - return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{ - origin, - action.char(), - q0, - q1, - flag, - sent.len, - sent, - }) catch buf[0..0]; -} - -/// THE SPAN TWO VERSIONS OF A TEXT DIFFER IN: everything outside their common -/// prefix and common suffix. -/// -/// Chunked through `std.mem.eql`, which lowers to vectorised compares. That is -/// not premature: this runs on EVERY edit of a scripted pane, over the whole -/// buffer, and the byte-at-a-time loop it replaces cost 2.4x per keystroke on -/// a 40 KB body (`zig build fs-bench`, the two `keystroke` rows). -pub const Span = struct { at: u32, removed: u32, inserted: u32 }; - -pub fn diffSpan(old: []const u8, new: []const u8) Span { - const both = @min(old.len, new.len); - const stride = 64; - var head: usize = 0; - while (head + stride <= both and - std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride; - while (head < both and old[head] == new[head]) head += 1; - var tail: usize = 0; - const rest = both - head; - while (tail + stride <= rest and std.mem.eql( - u8, - old[old.len - tail - stride ..][0..stride], - new[new.len - tail - stride ..][0..stride], - )) tail += stride; - while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1; - return .{ - .at = @intCast(head), - .removed = @intCast(old.len - tail - head), - .inserted = @intCast(new.len - tail - head), - }; -} - -/// Report a whole-text replacement the way acme reports an edit: the deletion -/// first and then the insertion, because that is the order `textdelete` and -/// `textinsert` would have run in. pardes replaces whole buffers, so the pair -/// is recovered here — one implementation, one place that knows the order, and -/// the only cost paid by an unscripted editor is the `scripted` check. -pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void { - if (!p.fs.scripted(id)) return; - const span = diffSpan(old, new); - if (span.removed == 0 and span.inserted == 0) return; - if (span.removed > 0) _ = noteAction( - p, - id, - if (on_tag) .tag_delete else .body_delete, - span.at, - span.at + span.removed, - 0, - "", - ); - if (span.inserted > 0) _ = noteAction( - p, - id, - if (on_tag) .tag_insert else .body_insert, - span.at, - span.at + span.inserted, - 0, - new[span.at..][0..span.inserted], - ); -} - -/// Record a Look or an Exec, and say whether THE CORE MUST NOT PERFORM IT. -/// -/// That inversion is acme's whole extension model: while a script holds a -/// pane's `event` file open, buttons 2 and 3 in that pane belong to the script -/// — the words in its tag are its commands, not pardes's. A script that dies -/// closes the file and the pane goes back to being an editor. -pub fn noteAction( - p: *Pardes, - id: usize, - action: Action, - q0: u32, - q1: u32, - flag: u32, - text: []const u8, -) bool { - if (!p.fs.scripted(id)) return false; - var buf: [max_record_text + 64]u8 = undefined; - const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text); - p.fs.panes[id].events.push(p.gpa, record); - return true; -} - -/// A PANE'S SHELL PRODUCED OUTPUT, raw, before the emulator ate it. -/// -/// The one hook `pty/data`'s read needs, and the reason it has to be a hook at -/// all: the core's only memory of a program's output is the emulator GRID, -/// which is a rendering — the escape sequences are gone, the scrollback is -/// reflowed, and no amount of reading it back gives a script the byte stream a -/// pipe would have given it. So the bytes are copied here, where they arrive, -/// or not at all. -/// -/// GATED ON A READER COUNT, exactly as every recording hook in this file is -/// gated on `scripted`: a pane nobody is reading pays one load and one branch -/// and allocates nothing, which is what makes an editor that serves this -/// filesystem cost the same as one that does not. `Queue` caps itself and -/// drops the oldest, so a script that opens the file and then stops reading -/// bounds the damage at `queue_cap` per pane. -pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { - if (id >= MAX_PANES or bytes.len == 0) return; - const pf = &p.fs.panes[id]; - if (pf.pty_readers == 0) return; - // SPLIT, because a record larger than `queue_cap - 4` can never be - // admitted: `Queue.push`'s eviction loop pops until `peek()` is null — - // destroying every unread byte the script was still owed — and then drops - // the new record too, silently. - // - // That is not a theoretical size. Every host reads a pty master with a - // 64 KiB buffer (`pty_chunk` in detached/server.zig, `[0x10000]u8` in - // tty.zig, gui.zig and macos.zig) and a single read really does return - // 65536 on Linux — measured. So a pane running a build or a `cat` of - // anything large produces exactly the record that empties the queue, - // repeatedly, for as long as a script holds `pty/data` open. - // - // The `event` queue never met this because its records are a few dozen - // bytes; `pty/data` inherited the cap without inheriting that property. - // Half the cap per record, so a full queue is at least two records and the - // eviction loop always has something to evict. - var off: usize = 0; - while (off < bytes.len) { - const n = @min(bytes.len - off, queue_cap / 2); - pf.pty_out.push(p.gpa, bytes[off..][0..n]); - off += n; - } -} - -// ============================================================================ -// THE TRANSACTION. -// ============================================================================ - -/// Answer one filesystem request against the live editor. The only entry -/// point: `Event.fs_req` lands here and the `Reply` leaves as -/// `Effect.fs_reply`. -pub fn handle(p: *Pardes, req: Req) Reply { - const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); - // THE ORIGIN CHARACTER for everything this request goes on to cause — - // including the TAG DIFF the core takes at the end of the update, after - // this function has returned. acme sets `w->owner` in `winlock` and calls - // `winsettag` before `winunlock`, so the tag change a body write provokes - // (the dirty marker appearing) is attributed to that write and not to - // whatever touched the editor last. Set once, here, for the same reason. - // - // Nothing restores it: `Pardes.update` sets the origin afresh on every - // keystroke and every mouse event, which is what owns it the rest of the - // time. A read cannot cause a record, so only the mutating ops set it. - if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { - // acme's `xfidwrite` opens with exactly this: `c = 'F'; if(qid==QWtag - // || qid==QWbody) c = 'E';` — `E` is "writes to the body or tag file", - // `F` is "actions through the window's other files" (acme(4)). - .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), - .top => 'F', - }; - return switch (req.op) { - .lookup => lookup(p, req, target), - .getattr => switch (attrOf(p, target)) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }, - .setattr => setattr(p, req, target), - .open => open(p, req, target), - .release => release(p, req), - .readdir => readdir(p, req, target), - .read => read(p, req, target), - .write => write(p, req, target), - // A synthetic filesystem has no blocks. Answering successfully with - // zeros keeps `df` and anything that stats the mount working. - .statfs => .{ .tag = req.tag }, - }; -} - -const AttrResult = union(enum) { ok: Reply.Attr, missing }; - -fn attrOf(p: *Pardes, target: Target) AttrResult { - switch (target) { - .top => |f| return .{ .ok = .{ - .node = @intFromEnum(f), - .dir = f.dir(), - .mode = f.mode(), - .size = topSize(p, f), - } }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return .missing; - // THE WHOLE OF "a non-terminal pane has no pty/". Decided here so - // no handler has to: `lookup` answers with the target's attributes - // and `getattr` asks the same question, so one check makes the - // subtree ENOENT on a file pane for every operation at once. - if (t.file.inPty() and !p.panes[id].?.isTerminal()) return .missing; - return .{ .ok = .{ - .node = Node.of(t.serial, t.file), - .dir = t.file.isDir(), - .mode = t.file.mode(), - .size = paneFileSize(p, id, t.file), - } }; - }, - } -} - -/// A size for `stat`. Exact where it is cheap and honest (`body`, `tag`), zero -/// where the file is a stream whose length is not a property (`event`, `log`); -/// FUSE serves these with direct IO, so a zero-length file still reads. -fn topSize(p: *Pardes, f: TopFile) u64 { - return switch (f) { - .root, .new, .cons => 0, - .index => indexLen(p), - }; -} - -fn paneFileSize(p: *Pardes, id: usize, f: PaneFile) u64 { - const pane = p.panes[id] orelse return 0; - return switch (f) { - .body, .data, .xdata => bodyLen(p, pane), - .tag => tagLen(p, pane), - // `pty/status` is formatted per read like `ctl` is, and `pty/data` is - // a stream whose length is not a property of anything. - .dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 0, - .pty, .pty_ctl, .pty_status, .pty_data => 0, - }; -} - -// --------------------------------------------------------------------------- -// PER-FILE SEMANTICS. Everything above is the frame: the ABI, the tree, the -// state, the records. Everything below is what acme's xfid.c does. -// --------------------------------------------------------------------------- - -// =========================================================================== -// THE TWO TEXTS A PANE HAS. Every handler below asks these, so "what is this -// pane's body" has one answer here and not eleven answers scattered about. -// =========================================================================== - -/// A pane's BODY, BORROWED. A file pane — which includes every output buffer -/// — lends its content, and that is the whole reason a `body` read costs -/// nothing (`Payload.region`). A terminal has no such buffer: its body is the -/// emulator's scrollback, which has to be RENDERED before it is bytes, so it -/// is not lendable and `readBody` produces one instead. Empty here therefore -/// means "nothing to lend", which for a terminal is not "empty document". -fn bodyOf(pane: *const Pane) []const u8 { - if (pane.file) |*f| return f.content; - return ""; -} - -/// ...and the writable side of the same question. Null is "this pane has no -/// document", which is every terminal and the answer to every write that -/// would need one. -fn fileOf(pane: *Pane) ?*file_pane.State { - return if (pane.file) |*f| f else null; -} - -/// The pane's TAG exactly as it is drawn: the live read-only prefix (the path, -/// the dirty marker, the pane's builtin words, the alignment gap) then the -/// editable tail. -/// -/// Scratch-owned — and `Pardes.update` resets that arena before the transport -/// ever reads a payload, so every tag answer is COPIED into `State.out`. -/// `body` is the only text lent out, because it is the only one that is a -/// buffer rather than a rendering. -fn tagOf(p: *Pardes, pane: *Pane) []const u8 { - return p.tagText(p.scratch.allocator(), pane) catch ""; -} - -/// The directory a pane belongs to: acme's "the directory currently named in -/// the tag", which is where this pane's `+Errors` goes. -fn dirOf(pane: *Pane) []const u8 { - if (pane.file) |*f| return std.fs.path.dirname(f.path) orelse "/"; - const cwd = pane.cwdSlice(); - return if (cwd.len > 0) cwd else "/"; -} - -/// acme's `w->dirty`: the body differs from what is on disk. A terminal and an -/// output buffer have nothing on disk, so they are never dirty — the same -/// `saves` trait the tag's `*` marker already asks. -fn dirtyOf(pane: *const Pane) bool { - const f = if (pane.file) |*x| x else return false; - if (!output_pane.fileTraits(f.output).saves) return false; - return f.revision != f.saved_revision; -} - -/// A byte offset as a `Range` field. A pane holding four gigabytes of text is -/// not something this editor does; saturating is honest where a silent wrap -/// would hand a script an address pointing at the wrong end of the file. -fn clip(n: usize) u32 { - return std.math.cast(u32, n) orelse std.math.maxInt(u32); -} - -fn cellOf(row: i32, col: i32) modal.Cursor { - return .{ .row = @intCast(@max(0, row)), .col = @intCast(@max(0, col)) }; -} - -fn firstLine(s: []const u8) []const u8 { - return s[0 .. std.mem.indexOfScalar(u8, s, '\n') orelse s.len]; -} - -/// acme's DOT — the user's selection — as a byte range over the body. -/// -/// pardes keeps the selection as two (row, col) cells with a HELIX block -/// cursor, i.e. the head cell is INSIDE the range; acme's dot is gap to gap. -/// This is the one place that conversion lives and `setDot` is its inverse, so -/// `addr=dot` followed by `dot=addr` is the identity rather than a range that -/// creeps by one grapheme each round trip. -fn dotOf(pane: *Pane) PaneFs.Range { - const text = bodyOf(pane); - const head = modal.hxOff(text, cellOf(pane.cur_row, pane.cur_col)); - if (!pane.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; - const anchor = modal.hxOff(text, cellOf(pane.vsel.row, pane.vsel.col)); - var hi = @max(head, anchor); - if (hi < text.len) hi = modal.nextGrapheme(text, hi); - return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; -} - -/// acme's `textsetselect`. The head lands ON the last grapheme of the range, -/// never one past it, because that is where every pardes motion leaves it and -/// a cursor sitting one cell right of its own selection is a selection the -/// acme chords will not act on. -fn setDot(pane: *Pane, r: PaneFs.Range) void { - const text = bodyOf(pane); - const q0 = @min(@as(usize, r.q0), text.len); - const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); - const a = modal.hxPos(text, q0); - pane.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; - const h = modal.hxPos(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0); - pane.cur_row = @intCast(h.row); - pane.cur_col = @intCast(h.col); - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.msel.active = false; - pane.ensureCursorVisible(); -} - -/// acme's `textshow`: put a spot on screen. Suppressed by `noscroll`. -fn showOffset(pane: *Pane, off: usize) void { - const text = bodyOf(pane); - const c = modal.hxPos(text, @min(off, text.len)); - pane.cur_row = @intCast(c.row); - pane.cur_col = @intCast(c.col); - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.ensureCursorVisible(); -} - -/// acme's `clampaddr`. Its `Range` is signed and it clamps both ends; ours is -/// unsigned, so only the top can be wrong — and it can, the moment a pane's -/// body shrinks under a stored address. -fn clampAddr(pf: *PaneFs, len: usize) void { - const n = clip(len); - pf.addr.q0 = @min(pf.addr.q0, n); - pf.addr.q1 = @min(pf.addr.q1, n); - if (pf.limit) |*l| { - l.q0 = @min(l.q0, n); - l.q1 = @min(l.q1, n); - } -} - -/// Move a range across an edit at `at` that replaced `removed` bytes with -/// `inserted` — acme's `if(tq0 >= q0) tq0 += nr;`, applied to both ends, which -/// is what keeps a script rewriting text under your cursor from dragging the -/// cursor onto a different word. -fn shiftBy(r: PaneFs.Range, at: u32, removed: u32, inserted: u32) PaneFs.Range { - return .{ .q0 = shiftOne(r.q0, at, removed, inserted), .q1 = shiftOne(r.q1, at, removed, inserted) }; -} - -fn shiftOne(v: u32, at: u32, removed: u32, inserted: u32) u32 { - if (v <= at) return v; - if (v <= at +| removed) return at +| inserted; - return v - removed +| inserted; -} - -/// How many of these bytes end on a character boundary. -/// -/// acme buffers a partial rune on the Fid (`fullrunewrite` plus `f->rpart`) -/// and stitches it onto the next write. A SHORT COUNT is the POSIX spelling of -/// the same promise — the writer's libc retries with the tail — and it needs -/// no per-handle state at all. Never zero for a -/// non-empty write: a writer handed 0 retries the same bytes forever. -fn wholeUtf8(data: []const u8) usize { - var i = data.len; - var back: usize = 0; - while (i > 0 and back < 4) : (back += 1) { - i -= 1; - const c = data[i]; - if (c < 0x80) return data.len; // an ASCII tail is always complete - if (c & 0xC0 == 0xC0) { // a lead byte: is its sequence all here? - const need = std.unicode.utf8ByteSequenceLength(c) catch return data.len; - if (i + need <= data.len or i == 0) return data.len; - return i; - } - } - // four trailing continuation bytes and no lead: not UTF-8 at all. acme's - // `cvttorunes` substitutes for bad bytes rather than refusing them, and so - // does storing them verbatim. - return data.len; -} - -// =========================================================================== -// LOOKUP — acme's `fsyswalk`, minus 9P's fid bookkeeping. -// =========================================================================== - -/// A name inside a pane's directory. `.` and `..` are the kernel's business, -/// never ours, the directory variant is not nameable, and the three inside -/// `pty/` are not nameable HERE — their enum names carry a prefix precisely so -/// that `stringToEnum` cannot hand `7/pty_ctl` back as a file beside `body`. -/// `pty` itself resolves; whether it EXISTS is `attrOf`'s question. -fn paneFileNamed(name: []const u8) ?PaneFile { - const f = std.meta.stringToEnum(PaneFile, name) orelse return null; - if (f == .dir) return null; - return if (f.inPty() and f != .pty) null else f; -} - -/// ...and a name inside `pty/`, which is a separate namespace: `ctl` and -/// `data` mean different files on the two sides of the slash, which is the -/// whole reason `pty/` is a directory (see `PaneFile`). -fn ptyFileNamed(name: []const u8) ?PaneFile { - if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; - if (std.mem.eql(u8, name, "status")) return .pty_status; - if (std.mem.eql(u8, name, "data")) return .pty_data; - return null; -} - -fn topFileNamed(name: []const u8) ?TopFile { - const f = std.meta.stringToEnum(TopFile, name) orelse return null; - return if (f == .root) null else f; -} - -/// acme: "is it a numeric name? yes: it's a directory". A pane's directory is -/// named by its SERIAL, which is never reused, so a stale path can go stale -/// but can never come to mean a different pane. -fn serialNamed(name: []const u8) ?u32 { - if (name.len == 0 or name.len > 10) return null; - for (name) |c| if (c < '0' or c > '9') return null; - return std.fmt.parseInt(u32, name, 10) catch null; -} - -/// The smallest live serial greater than `after`, so a caller can walk every -/// pane in ascending serial without sorting anything. O(panes) per step over -/// at most sixteen slots, and no allocation — the alternative was a scratch -/// array in a function that must not allocate. -fn nextSerialAfter(p: *Pardes, after: u32) ?u32 { - var best: ?u32 = null; - for (p.panes) |slot| { - const pane = slot orelse continue; - if (pane.serial <= after) continue; - if (best == null or pane.serial < best.?) best = pane.serial; - } - return best; -} - -/// Create a pane the way the `New` builtin does — an empty scratch below the -/// active one, in its column — and answer its serial. -/// -/// acme has `newwindowthread` sitting on a channel for exactly this, and its -/// windows go wherever `rowadd` puts them. Going through `newScratchBelow` -/// means a pane a script opened is in every respect a pane you opened: same -/// tag, same builtins, same undo, same Del. -fn newPane(p: *Pardes) ?u32 { - const slot = p.freeSlot() orelse return null; - p.newScratchBelow(p.active); - const pane = p.panes[slot] orelse return null; - return pane.serial; -} - -fn lookup(p: *Pardes, req: Req, target: Target) Reply { - const name = req.data; - if (name.len == 0 or std.mem.indexOfScalar(u8, name, '/') != null) return Reply.fail(req.tag, E.NOENT); - const node: u64 = switch (target) { - .top => |f| switch (f) { - .root => root: { - if (topFileNamed(name)) |t| break :root @intFromEnum(t); - const serial = serialNamed(name) orelse return Reply.fail(req.tag, E.NOENT); - _ = p.paneBySerial(serial) orelse return Reply.fail(req.tag, E.NOENT); - break :root Node.of(serial, .dir); - }, - .new => new: { - // acme(4): "Accessing any file in new creates a new window." - // - // acme creates it one component EARLIER — `fsyswalk` sends on - // `cnewwindow` the moment it walks the name `new` itself. That - // cannot work over FUSE: the kernel CACHES the dentry for - // `new`, so a lookup there would fire once per mount and never - // again. Creating at the CHILD keeps the promise the man page - // makes (`echo hi > $PARDES_FS/new/body` opens a pane holding - // `hi`) under a protocol that caches. - // - // The name is checked BEFORE the pane is made, so a stat of - // `new/nosuchfile` leaves no litter. acme's walk creates the - // window first and then fails the second component, which - // leaves an empty window behind for every typo. - const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT); - // `new/` makes a SCRATCH pane, which is a document and never a - // terminal, so `new/pty` names something that cannot exist. - // Refused before the pane is made, for the same reason every - // other bad name here is: a typo must leave no litter. - if (want.inPty()) return Reply.fail(req.tag, E.NOENT); - const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); - break :new Node.of(serial, want); - }, - else => return Reply.fail(req.tag, E.NOTDIR), - }, - .pane => |t| pane: { - _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - // Two directories, two namespaces. `attrOf` below is what decides - // whether the `pty` half exists on this pane at all. - const f = switch (t.file) { - .dir => paneFileNamed(name), - .pty => ptyFileNamed(name), - else => return Reply.fail(req.tag, E.NOTDIR), - } orelse return Reply.fail(req.tag, E.NOENT); - break :pane Node.of(t.serial, f); - }, - }; - // A lookup answers with the TARGET's attributes, which is exactly what a - // getattr of that node would say — one spelling, so the two can never - // disagree about a size or a mode. - return switch (attrOf(p, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT))) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }; -} - -// =========================================================================== -// READDIR -// =========================================================================== - -/// One directory entry in the transport-neutral staging format `src/fuse.zig` -/// decodes: node id, kind, name length, name — packed, little-endian, no -/// padding. A readdir answer is that record repeated. -/// -/// `node` travels because it becomes the `d_ino` a `getdents64` reports, and a -/// `d_ino` that disagrees with the later `st_ino` is a filesystem that lies to -/// `find -inum`. -fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void { - if (name.len == 0 or name.len > 255) return; - var head: [10]u8 = undefined; - std.mem.writeInt(u64, head[0..8], node, .little); - head[8] = @intFromBool(dir); - head[9] = @intCast(name.len); - out.appendSlice(gpa, &head) catch return; - out.appendSlice(gpa, name) catch return; -} - -/// The pane files, for a pane directory. `pty/` is listed only on a terminal: -/// a file pane's listing is byte for byte what it was before that directory -/// existed, which is what keeps every existing script's `ls` unsurprised. -fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void { - inline for (comptime std.enums.values(PaneFile)) |f| { - // The directory itself is never an entry, and the three names inside - // `pty/` belong to THAT directory's listing rather than to this one — - // the enum is flat, the tree is not. - if (comptime f == .dir or (f.inPty() and f != .pty)) continue; - // `pty/` itself is present only on a terminal. A runtime `continue` - // cannot leave an `inline for` body, so the entry is conditional - // rather than the iteration. - const present = f != .pty or terminal; - if (present) { - if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.name()); - } - } -} - -/// ...and the three inside `pty/`, in declaration order like every other -/// listing here, so a script that walks the tree twice can diff the walks. -fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void { - inline for (comptime std.enums.values(PaneFile)) |f| { - if (comptime !f.inPty() or f == .pty) continue; - if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.name()); - } -} - -fn readdir(p: *Pardes, req: Req, target: Target) Reply { - const out = p.fs.stage(p.gpa); - var skip = req.off; - switch (target) { - .top => |f| switch (f) { - .root => { - inline for (.{ TopFile.index, TopFile.cons, TopFile.new }) |t| { - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.name()); - } - // Ascending serial: serials are never reused, so this order is - // stable across a create and a delete — which is what a script - // that walks the tree twice and diffs the two walks needs. - // acme lists windows in SCREEN order (column by column), which - // changes when you drag a window and says nothing a script can - // rely on. - var last: u32 = 0; - while (nextSerialAfter(p, last)) |s| { - last = s; - if (skip > 0) { - skip -= 1; - continue; - } - var buf: [16]u8 = undefined; - const name = std.fmt.bufPrint(&buf, "{d}", .{s}) catch continue; - stageDirent(out, p.gpa, Node.of(s, .dir), true, name); - } - }, - // `new/` ENUMERATES NOTHING, and that is a guarantee rather than a - // shrug: the names it could list are exactly the names whose LOOKUP - // creates a pane, and every tool that lists a directory then stats - // what it found — `ls -l`, `ls --color`, `find`, a shell completing - // `$PARDES_FS/new/` — would make one pane per name. acme never - // lists it either. Naming a file here is what creates one; see - // `lookup`. - .new => {}, - else => return Reply.fail(req.tag, E.NOTDIR), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const terminal = p.panes[id].?.isTerminal(); - switch (t.file) { - .dir => stagePaneFiles(p, out, t.serial, terminal, &skip), - // A node id naming `pty/` can only have come from a pane that - // was a terminal when it was resolved. It may not be one now - // (a pane can acquire a document), so answer what a lookup - // would answer today rather than trusting the id. - .pty => { - if (!terminal) return Reply.fail(req.tag, E.NOENT); - stagePtyFiles(p, out, t.serial, &skip); - }, - else => return Reply.fail(req.tag, E.NOTDIR), - } - }, - } - // Zero bytes is END OF DIRECTORY, never an error: the transport stops - // asking, and re-staging from scratch on every call is what makes a - // partially consumed answer safe to ask for again at a higher cookie. - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -// =========================================================================== -// OPEN / RELEASE / SETATTR -// =========================================================================== - -/// Open carries no per-open state, because there is none to carry: `addr` and -/// `limit` belong to the pane (as they do in acme, where they are Window -/// fields), and every read brings its own offset. What an open DOES do is -/// arm the two things acme arms on open, and count the two kinds of reader. -/// -/// So there is no fid table. acme needs one because 9P walks to a fid and -/// every later message names only that fid; FUSE puts the nodeid on every -/// request, RELEASE included, so the handle is decoration. It is answered -/// non-zero only because the transport spells "no handle" as zero. -fn open(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => {}, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pf = &p.fs.panes[id]; - // A file that is not there cannot be opened, so the reader count - // below cannot be armed on a pane with no pty. Same answer - // `lookup`, `read` and `write` give (`attrOf`). - if (t.file.inPty() and !p.panes[id].?.isTerminal()) return Reply.fail(req.tag, E.NOENT); - switch (t.file) { - // acme(4): "When the ctl file is first opened, regular - // expression context searches in addr addresses examine the - // whole file"; `limit=addr` narrows them again. - .ctl => pf.limit = null, - // acme resets both on the FIRST open (`w->nopen[QWaddr]++ == - // 0`) and keeps a per-file open count to know. There is none - // here: `addr` is one piece of per-pane state that a second - // opener would be sharing anyway, so the honest reading of - // "first" is "whenever somebody opens it" — and a script's - // first act on `addr` is always to write one. - .addr => { - pf.addr = .{}; - pf.limit = null; - }, - // THE SUPPRESSION GATE. While this is non-zero the pane is - // script-driven: its Look and Exec are reported, not - // performed (`noteAction`). Counted per OPEN, not per pane, so - // two readers means the second one closing leaves the first - // still in charge. - .event => { - pf.readers +|= 1; - p.fs.listeners +|= 1; - }, - // THE OTHER GATE, and deliberately a separate count: while - // this is non-zero the raw pty bytes are copied into - // `pty_out` as they arrive (`notePtyOutput`). It does NOT - // touch `listeners` — reading a terminal's output stream is - // not claiming the pane's buttons, and a script that did both - // would have opened `event` too. - .pty_data => pf.pty_readers +|= 1, - else => {}, - } - }, - } - return .{ .tag = req.tag, .handle = 1 }; -} - -fn release(p: *Pardes, req: Req) Reply { - const target = Node.target(req.node) orelse return .{ .tag = req.tag }; - switch (target) { - .top => {}, - .pane => |t| { - if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; - // The pane may have DIED while this was open. `State.forget` has - // then already taken its whole reader count out of `listeners` - // (the core calls it from `deinitPane`), so a serial that no - // longer resolves must not be decremented a second time — that - // underflow is exactly what would leave the editor suppressing - // button actions forever with no script left to interpret them. - const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; - const pf = &p.fs.panes[id]; - if (t.file == .pty_data) { - if (pf.pty_readers == 0) return .{ .tag = req.tag }; - pf.pty_readers -= 1; - // The LAST pty reader leaving takes the queue's MEMORY with - // it, not merely its contents: `queue_cap` per pane held - // until the pane dies would be an editor that grew by being - // scripted once. And what is in it is stale anyway — the next - // reader wants the program's output from when IT opened the - // file, not a replay of somebody else's session. - if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa); - return .{ .tag = req.tag }; - } - if (pf.readers == 0) return .{ .tag = req.tag }; - pf.readers -= 1; - p.fs.listeners -|= 1; - // The LAST reader leaving takes the tag snapshot with it. It is - // only ever compared against while somebody is listening, so - // keeping it would let the tag drift unobserved and then hand the - // NEXT reader a `d`/`i` pair for a change it never saw. - if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa); - }, - } - return .{ .tag = req.tag }; -} - -fn setattr(p: *Pardes, req: Req, target: Target) Reply { - // acme has NO equivalent: 9P has no truncate-on-open, so nothing in - // `xfid.c` answers a Twstat carrying a length. Linux does — `> body` is - // O_TRUNC — and refusing it would make the shell's most natural way to - // REPLACE a pane's text (rather than append to it) fail with EPERM on the - // redirect, before a single byte was written. So exactly one field is - // honoured, only the value zero means anything, and everything else a - // `stat` structure can carry (mode, owner, times) is silently accepted and - // ignored the way a filesystem of live editor state has to. - if (req.truncate) switch (target) { - .pane => |t| switch (t.file) { - .body, .data, .xdata => { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - if (fileOf(pane) != null) { - _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse - return Reply.fail(req.tag, E.NOMEM); - p.fs.panes[id].addr = .{}; - setDot(pane, .{}); - } - }, - else => {}, - }, - else => {}, - }; - return switch (attrOf(p, target)) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }; -} - -// =========================================================================== -// READ -// =========================================================================== - -/// Answer with a WINDOW onto what was just staged. `Payload.staged` is a -/// LENGTH from the start of the buffer, so a read at an offset slides the -/// bytes down rather than growing the payload union with a second field -/// nothing else would ever use. -fn staged(p: *Pardes, req: Req) Reply { - const out = &p.fs.out; - const off = @min(req.off, out.items.len); - const n = @min(out.items.len - off, req.size); - if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]); - out.shrinkRetainingCapacity(n); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; -} - -fn read(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => |f| return switch (f) { - .index => readIndex(p, req), - // acme's dirtab: `cons` is 0200 and a directory is not read(2)able. - .cons, .root, .new => Reply.fail(req.tag, E.PERM), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - const pf = &p.fs.panes[id]; - // A `pty/` node whose pane is no longer a terminal reads as - // absent, not as empty: the same answer `lookup` gives today. - if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); - return switch (t.file) { - .addr => readAddr(p, req, pf, pane), - .body => readBody(p, req, id, pane), - .ctl => readCtl(p, req, pane), - .data => readData(req, id, pane, pf, false), - .xdata => readData(req, id, pane, pf, true), - .tag => readTag(p, req, pane), - .event => readQueue(p, req, &pf.events), - .rdsel => readRdsel(req, id, pane), - .pty_status => readPtyStatus(p, req, id, pane), - .pty_data => readPtyData(p, req, pf), - .dir, .errors, .wrsel, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM), - }; - }, - } -} - -/// acme's `Ctlsize`: five `%11d ` fields = 60 bytes, before the tag. -const ctl_fields = 5 * 12; - -/// acme's `winctlprint(w, buf, 0)` — the five numbers `index` and `ctl` share. -/// -/// COST: acme reads the tag's length off `w->tag.file->nc` for free, because -/// acme's tag IS a buffer. pardes's is COMPUTED every time it is asked for -/// (path, dirty marker, builtins, and the alignment gap, which is measured -/// against every other pane in the same layout column), so these five numbers -/// cost one tag render — a couple of microseconds and a few bumps of the -/// per-update scratch arena, which `Pardes.update` resets. That is the price -/// of the second field being the number a `tag` read will actually hand back; -/// a cheaper approximation that disagreed with `read tag` would be worse than -/// slow, it would be wrong. -fn stageCtlNumbers(p: *Pardes, out: *std.ArrayList(u8), pane: *Pane) void { - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ - pane.serial, - tagOf(p, pane).len, - bodyOf(pane).len, - // acme's `isdir` marks a window holding a DIRECTORY LISTING. pardes - // never opens one — a Look at a directory spawns a shell there - // (look.zig) — so this is structurally zero, not unimplemented. - @as(u32, 0), - @intFromBool(dirtyOf(pane)), - }) catch {}; -} - -/// acme's `xfidindexread`: one line per pane, the five numbers then the tag up -/// to its first newline. Seekable, so a script can pread the middle of it — -/// "at character position 5×12 starts the name of the window" (acme(4)). -fn readIndex(p: *Pardes, req: Req) Reply { - const out = p.fs.stage(p.gpa); - var last: u32 = 0; - while (nextSerialAfter(p, last)) |s| { - last = s; - const pane = p.panes[p.paneBySerial(s).?].?; - stageCtlNumbers(p, out, pane); - out.appendSlice(p.gpa, firstLine(tagOf(p, pane))) catch {}; - out.append(p.gpa, '\n') catch {}; - } - return staged(p, req); -} - -/// acme: `sprint(buf, "%11d %11d ", w->addr.q0, w->addr.q1)`. acme's numbers -/// are RUNE offsets; these are bytes (see the header). "Thus a regular -/// expression may be evaluated by writing it to addr and reading it back." -fn readAddr(p: *Pardes, req: Req, pf: *PaneFs, pane: *Pane) Reply { - clampAddr(pf, bodyOf(pane).len); - const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {}; - return staged(p, req); -} - -fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (pane.file != null) { - // ZERO COPY: `.region` is resolved by `fsPayload` during the effect - // drain, so reading a megabyte of body moves no bytes in here at all. - // This is the whole reason `Payload` is a union and not a slice. - const text = bodyOf(pane); - const off = @min(req.off, text.len); - const n = @min(text.len - off, req.size); - return .{ .tag = req.tag, .payload = .{ .region = .{ - .pane = @intCast(id), - .serial = pane.serial, - .off = clip(off), - .len = clip(n), - } } }; - } - // A TERMINAL has no such buffer. acme's body is always a `Text`; pardes's - // is a terminal emulator, and its "body" is the scrollback — which only - // becomes bytes when somebody renders the pages into lines. So it is - // produced, staged, and paid for per read. `win`'s transcript, read side. - const text = term_pane.screenTextAlloc(pane, p.gpa) catch - return Reply.fail(req.tag, E.NOMEM); - defer p.gpa.free(text); - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); - return staged(p, req); -} - -/// The face the shell was last asked to wear. acme owns its fonts and prints -/// the real one; the core only knows what it REQUESTED — on a tty the font -/// belongs to the terminal emulator and in the browser to the page — so it -/// prints that, or `default`, which is the same word the Debug overlay shows -/// for the same reason. -fn fontName(p: *Pardes) []const u8 { - const name = p.settings.font.effective_name.get(); - return if (name.len == 0) "default" else name; -} - -/// plan9's `%q` (`quotestrfmt`): a string with nothing special in it prints -/// bare, anything else is wrapped in single quotes with internal quotes -/// doubled. Load-bearing rather than decoration — a script splits the ctl line -/// into shell words, and a font name with a space in it is one word. -fn stageQuoted(out: *std.ArrayList(u8), gpa: std.mem.Allocator, s: []const u8) void { - const plain = s.len > 0 and for (s) |c| { - if (c <= ' ' or c == '\'') break false; - } else true; - if (plain) { - out.appendSlice(gpa, s) catch {}; - return; - } - out.append(gpa, '\'') catch {}; - for (s) |c| { - if (c == '\'') out.append(gpa, '\'') catch {}; - out.append(gpa, c) catch {}; - } - out.append(gpa, '\'') catch {}; -} - -/// acme's `winctlprint(w, buf, 1)`: index's five numbers plus three more. -fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - stageCtlNumbers(p, out, pane); - // acme prints `Dx(w->body.r)` — the body's width in PIXELS — and - // `w->body.maxtab`, a tab's width in pixels too. pardes is a CELL GRID: - // on a tty there is no pixel width to report at all, and on the two pixel - // shells the number a script actually wants is still how many characters - // fit. So both are CELLS. A script that would have divided by the font - // width to get columns gets columns without dividing. - out.print(p.gpa, "{d:>11} ", .{pane.cols}) catch {}; - stageQuoted(out, p.gpa, fontName(p)); - out.print(p.gpa, " {d:>11} ", .{config.tab_width}) catch {}; - return staged(p, req); -} - -fn readTag(p: *Pardes, req: Req, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, tagOf(p, pane)) catch {}; - return staged(p, req); -} - -/// acme's `xfidruneread`: hand back whole characters from the START of `addr` -/// and move `addr` to the null string just after them; `xdata` additionally -/// stops at the END of `addr` (acme passes `w->addr.q1` where `data` passes -/// `nc`). The file offset is ignored — `addr` is the position. -/// -/// "Whole characters" is acme's partial-rune rule; here it is a GRAPHEME -/// boundary, which is strictly stronger and is what every other offset in -/// pardes already respects. A read too small for the next grapheme returns -/// zero bytes rather than half of one — acme's `if(m == 0) break`. -fn readData(req: Req, id: usize, pane: *Pane, pf: *PaneFs, stop_at_end: bool) Reply { - const text = bodyOf(pane); - clampAddr(pf, text.len); - const q0: usize = pf.addr.q0; - // acme carries a "BUG: what should happen if q1 > q0?" here and answers by - // reading nothing. An inverted address is a legal thing to have written - // (`address()` never normalises), so the empty read is the answer. - const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len; - var end = @min(hi, q0 +| req.size); - end = @max(q0, modal.graphemeStart(text, end)); - // `data` collapses the address onto the point it read up to; `xdata` moves - // only q0 and KEEPS q1, because q1 is the stop address the man page - // promises ("reads stop at the end address") and the next chunked read has - // to be able to continue from where this one stopped. acme spells the same - // difference at xfid.c:331-341: QWdata assigns both, QWxdata only q0. - pf.addr.q0 = clip(end); - if (!stop_at_end) pf.addr.q1 = clip(end); - if (pane.file == null) return .{ .tag = req.tag }; - return .{ .tag = req.tag, .payload = .{ .region = .{ - .pane = @intCast(id), - .serial = pane.serial, - .off = clip(q0), - .len = clip(end - q0), - } } }; -} - -/// acme copies the selection into a TEMP FILE at open, with a comment -/// apologising for it, so a `|sort` cannot see the text change underneath. -/// There is no such window here: the whole request is one main-thread -/// transaction, nothing can run between the open and the read, and the bytes -/// go out of the pane unmoved. -fn readRdsel(req: Req, id: usize, pane: *Pane) Reply { - if (pane.file == null) return .{ .tag = req.tag }; - const text = bodyOf(pane); - const d = dotOf(pane); - const lo = @min(@as(usize, d.q0), text.len); - const hi = @max(lo, @min(@as(usize, d.q1), text.len)); - const off = @min(req.off, hi - lo); - const n = @min(hi - lo - off, req.size); - return .{ .tag = req.tag, .payload = .{ .region = .{ - .pane = @intCast(id), - .serial = pane.serial, - .off = clip(lo + off), - .len = clip(n), - } } }; -} - -/// ONE RECORD PER READ, and `Status.again` when there is none. -/// -/// This is the whole of what acme's blocking `event` read becomes. acme parks -/// the `Xfid` in `w->eventx` and `winevent` sends it a message to wake it up; -/// the waiting lives in a thread per in-flight request, and `xfidflush` exists -/// to cancel one. Here nothing is consumed and nothing is remembered: the -/// transport still holds the kernel's request and asks again. No waiter list, -/// no wakeup, no flush bookkeeping, and no loop anywhere in the core. -fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { - const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; - // acme hands back as much of its event buffer as the count allows and - // keeps the rest, which can split a record down the middle; a reader is - // simply expected never to ask for less than one. Refusing is the honest - // version of that contract — half a record is unparseable and silently - // desynchronises the reader for the rest of the session. - if (req.size < record.len) return Reply.fail(req.tag, E.INVAL); - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM); - q.pop(); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -// =========================================================================== -// `pty/` — the terminal a pane is, as three files. No prior art: acme has no -// terminals and `ad` has no terminal surface at all, so nobody has made these -// mistakes for us and nobody's scripts expect a particular spelling. Which is -// the argument for three files and no fourth. -// =========================================================================== - -/// `pty/status`: `%11d `-formatted, exactly like `ctl` and `index`, so a -/// script splits it the same way and `read`s it at an offset. -/// -/// THREE NUMBERS, and the choice of which three is the whole content of this -/// function. The core knows the grid it asked for and it can ask the host who -/// holds the tty; that is all it knows, and inventing a fourth field would be -/// inventing the number behind it. -/// -/// cols, rows the grid, in cells. What `TIOCGWINSZ` would answer, and the -/// same pair `winsize` sets — so a script can set a size and -/// read back that it took. -/// taken 1 while a PROGRAM holds the tty (vim, a pager, a build), 0 -/// at the shell's own prompt. `pull_tty_taken`, the probe the -/// core already asks before it types a command line; a host -/// that cannot tell says 0, which is how pardes behaved before -/// the probe existed. -/// -/// WHAT IS NOT HERE, and why not, because a missing field is a fact about the -/// core rather than an omission: -/// -/// exit status NOT TRACKED ANYWHERE. A shell's death arrives as -/// `Event.eof`, whose whole handler is `removePane` — the pane -/// and its serial are gone, so by the time anybody could read -/// a status file there is no directory to read it in. Reporting -/// a zero here would be reporting a number the core does not -/// have. Giving the pane an exit status means keeping the pane -/// alive past its child, which is a change to what a terminal -/// pane IS and does not belong in a status file's formatter. -/// raw/cooked the draft's `TCSETS` line. The core never sets a termios: -/// the mode belongs to the program on the far side of the pty, -/// which sets it for itself and never tells us. There is -/// nothing to report and nothing to set. -fn readPtyStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ - pane.cols, - pane.rows, - @intFromBool(p.hostTtyTaken(id)), - }) catch {}; - return staged(p, req); -} - -/// `pty/data`, read side: THE RAW OUTPUT STREAM, as a stream. -/// -/// FRAMING, which is the one decision here. `event` refuses a read smaller -/// than one record because half a record is unparseable. Raw pty bytes have no -/// records: what is in the queue is only "what arrived in one `.output` -/// event", which is wherever the host's `read(2)` happened to land, so -/// refusing a short read would be enforcing a boundary that means nothing — -/// and a reader with a 1 KB buffer would deadlock against a 4 KB arrival -/// forever. So this hands back as much as the count allows, spanning arrivals, -/// and keeps the remainder (`Queue.popFront`). That is what `read(2)` on the -/// pty itself would do. -/// -/// The OFFSET is ignored, for the same reason `event`'s is: the queue is the -/// position. And an empty queue is `Status.again` — nothing consumed, ask me -/// again — which is the whole of how a blocking read works here. -/// -/// A pane nobody has OPENED this file on has an empty queue by construction -/// (`notePtyOutput` is gated on the count `open` keeps), so a read that beats -/// the first byte of output and a read on a pane that never recorded any are -/// the same cheap answer. -fn readPtyData(p: *Pardes, req: Req, pf: *PaneFs) Reply { - if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; - const out = p.fs.stage(p.gpa); - while (out.items.len < req.size) { - const chunk = pf.pty_out.peek() orelse break; - const n = @min(chunk.len, req.size - out.items.len); - out.appendSlice(p.gpa, chunk[0..n]) catch break; - pf.pty_out.popFront(n); - } - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -// =========================================================================== -// WRITE -// =========================================================================== - -fn write(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => |f| return switch (f) { - // acme(4): text written to `cons` appears in `dir/+Errors`, where - // `dir` is the directory the command ran in — acme knows which - // from the mount the writer inherited (`x->f->mntdir`, one per - // `win`). A FUSE mount is ONE directory for the whole editor, so - // the writing process is anonymous and the only defensible owner - // is the pane the user is in. A script that wants a specific - // pane's errors writes `/errors`, which is unambiguous. - .cons => if (appendErrors(p, p.active, req.data)) |took| - .{ .tag = req.tag, .written = @intCast(took) } - else - Reply.fail(req.tag, E.IO), - else => Reply.fail(req.tag, E.PERM), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); - return switch (t.file) { - .addr => writeAddr(p, req, id, pane), - .body => writeBody(p, req, id, pane), - .ctl => writeCtl(p, req, t.serial), - // acme's `data` and `xdata` differ only in what a READ stops - // at; the writes are the same code path there and here. - .data, .xdata => writeData(p, req, id, pane), - .tag => writeTag(p, req, pane), - .event => writeEvent(p, req, id), - .wrsel => writeWrsel(p, req, id, pane), - .errors => if (appendErrors(p, id, req.data)) |took| - .{ .tag = req.tag, .written = @intCast(took) } - else - Reply.fail(req.tag, E.IO), - .pty_ctl => writePtyCtl(p, req, id), - .pty_data => writePtyData(p, req, id), - .dir, .rdsel, .pty, .pty_status => Reply.fail(req.tag, E.PERM), - }; - }, - } -} - -/// THE ONE BODY SPLICE every writing file goes through: replace `[q0, q1)` -/// with `bytes`, via `file_pane.setContent` — which is where the core diffs -/// out the insert/delete event records, so a script's edit is reported exactly -/// once and in exactly the same shape as a keystroke's. One swap per write for -/// the same reason: two swaps would be two `D`/`I` pairs for one write. -/// -/// The origin character the records carry is `handle`'s, set once per request -/// (acme's winlock owner), so nothing here has to know which file it is -/// serving. -fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, bytes: []const u8) ?usize { - const f = fileOf(pane) orelse return null; - const take = if (bytes.len == 0) 0 else wholeUtf8(bytes); - const lo = @min(q0, f.content.len); - const hi = @max(lo, @min(q1, f.content.len)); - const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null; - @memcpy(new[0..lo], f.content[0..lo]); - @memcpy(new[lo..][0..take], bytes[0..take]); - @memcpy(new[lo + take ..], f.content[hi..]); - // acme: `if(w->nomark == FALSE){ seq++; filemark(t->file); }` — `nomark` - // is how a script makes a batch of edits one Undo. - // - // COST, and the reason `nomark` matters more here than it does in acme: - // acme's `filemark` is a sequence number on a log-structured, disk-backed - // Buffer, so it is O(1). pardes's undo is a SNAPSHOT of the whole body - // (`file_pane.pushUndo` compares and then duplicates it), so a script that - // appends a line at a time to a megabyte body pays a megabyte per line and - // keeps 256 of them. That is exactly the same cost one KEYSTROKE pays on - // the same body — this is not a filesystem tax, it is the core's edit - // model — but a script can do it ten thousand times a second where a - // typist cannot. `nomark` is the documented remedy and the reason acme - // gave scripts the verb. - if (!p.fs.panes[id].nomark) file_pane.pushUndo(p, pane); - file_pane.setContent(p, f, new); - return take; -} - -/// acme(4): "Text written to body is always appended; the file offset is -/// ignored." So `req.off` is deliberately never read here. -fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - // A TERMINAL's body is not a document, it is a program's transcript — and - // the only way to put text into a transcript is to TYPE it. So a body - // write to a terminal pane is a pty write: `echo ls > $PARDES_FS/3/body` - // runs ls in pane 3's shell. That is `win`'s semantics in acme (the shell - // reads what you write to its window's body), reached through the effect - // the core already has instead of through a pipe. - // - // Nothing is RECORDED for it: the insert/delete diff lives in - // `file_pane.setContent`, and a terminal has no `file` to swap. The - // program's output comes back as ordinary `.output` bytes. - if (pane.file == null) { - const take = wholeUtf8(req.data); - p.emitWrite(id, req.data[0..take]); - return .{ .tag = req.tag, .written = @intCast(take) }; - } - const at = bodyOf(pane).len; - const take = spliceBody(p, id, pane, at, at, req.data) orelse - return Reply.fail(req.tag, E.NOMEM); - if (!p.fs.panes[id].noscroll) showOffset(pane, at + take); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme's tag is one `Text` and a write appends to all of it. pardes's tag is -/// PREFIX ++ TAIL: the prefix is chrome the core recomputes every frame (the -/// path, the dirty marker, the builtin words, the alignment gap), so bytes -/// appended to it would be gone by the next render. A tag write therefore -/// appends to the TAIL — which is the part that is a buffer, and the part a -/// script means when it writes ` Undo` into a tag. -/// -/// The tail is a fixed one-line buffer (`Pane.tag_tail`), so a write that does -/// not fit is short, and one with no room at all is ENOSPC rather than a zero -/// count the writer would retry forever. -fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - // the laid-out default tail becomes real bytes on first touch, exactly as - // it does when you click into the tag - p.seedTail(pane); - const room = pane.tag_tail.len - pane.tag_tail_len; - if (room == 0) return Reply.fail(req.tag, E.NOSPC); - const take = wholeUtf8(req.data[0..@min(req.data.len, room)]); - @memcpy(pane.tag_tail[pane.tag_tail_len..][0..take], req.data[0..take]); - pane.tag_tail_len += take; - pane.tag_init = true; - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme(4): text written to `data` "replaces the characters addressed by the -/// addr file and sets the address to the null string at the end of the written -/// text". The file offset is ignored. -fn writeData(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); - const pf = &p.fs.panes[id]; - clampAddr(pf, bodyOf(pane).len); - const q0: usize = pf.addr.q0; - const q1: usize = @max(q0, @as(usize, pf.addr.q1)); - const before = dotOf(pane); - // acme's winlock(w, 'F'): everything but body and tag is "an action - // through the window's other files". - const take = spliceBody(p, id, pane, q0, q1, req.data) orelse - return Reply.fail(req.tag, E.NOMEM); - setDot(pane, shiftBy(before, clip(q0), clip(q1 - q0), clip(take))); - pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }; - if (!pf.noscroll) showOffset(pane, q0 + take); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme's `wrsel` cuts the selection when the file is OPENED and inserts each -/// write at a running point after it (`w->wrselrange`). Same result, no -/// open-time mutation: each write REPLACES the selection, and because the -/// selection is left collapsed just after the inserted text, a second write -/// appends to the first exactly as `wrselrange` does. The only difference is -/// what an open and close with NO write does — acme has already emptied the -/// selection by then, this leaves the pane untouched. A filesystem that edits -/// your document when you `stat` it is a filesystem you cannot explore. -/// -/// acme also forces `nomark` for the file's lifetime so the whole stream is -/// one Undo. That needs open-time state we do not keep; a script that wants it -/// writes `nomark` to `ctl`, which is the same button with a name on it. -fn writeWrsel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); - const d = dotOf(pane); - const q0: usize = d.q0; - const q1: usize = @max(q0, @as(usize, d.q1)); - const take = spliceBody(p, id, pane, q0, q1, req.data) orelse - return Reply.fail(req.tag, E.NOMEM); - setDot(pane, .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme's `xfidwrite` QWaddr. Two failures, and acme has two error strings for -/// them: `Ebadaddr` (the parser stopped before the end of the expression) and -/// `Eaddr` (it parsed but did not evaluate — out of range, or no match). A -/// filesystem has one channel for "no", so both are EINVAL. -fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - const pf = &p.fs.panes[id]; - const text = bodyOf(pane); - clampAddr(pf, text.len); - // acme's parser stops at a newline of its own accord (`\n` reaches the - // `default:` arm), which is what lets `echo '/foo/' > addr` work from a - // shell. Trimming says the same thing without threading it through every - // arm of the state machine. - const expr = std.mem.trimEnd(u8, req.data, "\n"); - var a: Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; - const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL); - if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL); - pf.addr = r; - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -// =========================================================================== -// THE ADDRESS LANGUAGE — acme's addr.c, byte-addressed. -// =========================================================================== - -/// mvzr PANICS on a pattern that ends inside an escape: `parseCharSet` slices -/// `in[i+1..]` and `valueFor` indexes `[0]` of it, so a trailing backslash is -/// an out-of-bounds read rather than a compile failure (pardes.zig's -/// `applySelRegex` carries the same warning about the prefix `[^\`). A live -/// typist can only reach that by accident; a SCRIPT's regex is untrusted -/// input, so it is screened here before the engine ever sees it. -fn safePattern(pat: []const u8) bool { - var i: usize = 0; - while (i < pat.len) : (i += 1) { - if (pat[i] != '\\') continue; - if (i + 1 >= pat.len) return false; - i += 1; - } - return true; -} - -/// THE ADDRESS PARSER, in acme's shape: one left-to-right pass with three -/// pieces of state — a running range, a DIRECTION (`+`/`-`/none) and a SIZE -/// (line or character) — recursing once per `,` or `;`. -/// -/// What is gone is the C. acme reads the expression through a `getc` callback -/// over a `Rune*` so one parser can serve both the filesystem and the Edit -/// language; it reports failure through two out-parameters (`evalp` for "did -/// not evaluate", `qp` for "stopped here") because it cannot return three -/// things; and it grows the regex pattern with `runerealloc` one rune at a -/// time. Here the expression is a slice, the cursor is a field, a pattern is a -/// subslice of the expression, and "did not evaluate" is `null`. -const Addr = struct { - text: []const u8, - /// `limit=addr`: regex context searches are confined to this. acme applies - /// it FORWARDS only, and so does this. - lim: ?PaneFs.Range, - expr: []const u8, - i: usize = 0, - /// One frame per `,` or `;`. acme recurses without a bound, which is fine - /// when the expression came from a person typing into a tag and is a - /// STACK OVERFLOW when it came from a script: `,,,,,...` a hundred - /// thousand deep is one write(2). A compound address deeper than this is - /// not an address anybody meant. - depth: u8 = 0, - - const max_depth = 32; - const Size = enum { char, line }; - - /// acme's `address()`. `ar` is what `.` means — and `xfidwrite` passes - /// `w->addr`, NOT the user's selection, so `.` is the CURRENT ADDRESS and - /// `addr=dot` is the only door the selection comes in by. (acme(4) - /// describes the language as "the format understood by button 3", where - /// `.` is dot; the code is the authority and this follows the code.) - fn address(a: *Addr, ar_in: PaneFs.Range) ?PaneFs.Range { - const start = a.i; - var ar = ar_in; - var r = ar_in; - var dir: u8 = 0; - var size: Size = .line; - var c: u8 = 0; - while (a.i < a.expr.len) { - const prevc = c; - c = a.expr[a.i]; - a.i += 1; - switch (c) { - ',', ';' => { - // `;` differs from `,` in one way: it makes the RIGHT side - // relative to the left one. - if (c == ';') ar = r; - if (prevc == 0) r.q0 = 0; // lhs defaults to 0 - if (a.i >= a.expr.len) { - r.q1 = clip(a.text.len); // rhs defaults to $ - } else { - if (a.depth >= max_depth) return null; - a.depth += 1; - const nr = a.address(ar) orelse return null; - a.depth -= 1; - r.q1 = nr.q1; - } - return r; - }, - '+', '-' => { - // a pending `+`/`-` with no count of its own means one - // line, unless what follows is itself an operand - if (prevc == '+' or prevc == '-') { - const nc = if (a.i < a.expr.len) a.expr[a.i] else 0; - if (nc != '#' and nc != '/' and nc != '?') - r = a.number(r, 1, prevc, .line) orelse return null; - } - dir = c; - }, - '.', '$' => { - // both are only meaningful as the FIRST character of a - // (sub)expression; anywhere else they end the parse - if (a.i != start + 1) { - a.i -= 1; - return r; - } - r = if (c == '.') ar else .{ .q0 = clip(a.text.len), .q1 = clip(a.text.len) }; - dir = if (a.i < a.expr.len) '+' else 0; - }, - '#', '0'...'9' => { - var digit = c; - if (c == '#') { - if (a.i >= a.expr.len or a.expr[a.i] < '0' or a.expr[a.i] > '9') { - a.i -= 1; - return r; - } - digit = a.expr[a.i]; - a.i += 1; - size = .char; - } - var n: u64 = digit - '0'; - while (a.i < a.expr.len) : (a.i += 1) { - const d = a.expr[a.i]; - if (d < '0' or d > '9') break; - n = @min(n * 10 + (d - '0'), std.math.maxInt(u32)); - } - r = a.number(r, @intCast(n), dir, size) orelse return null; - dir = 0; - size = .line; - }, - '/', '?' => { - const back = c == '?'; - r = a.regexp(r, a.pattern(c), back) orelse return null; - dir = 0; - size = .line; - }, - else => { - a.i -= 1; - return r; - }, - } - } - // a trailing `+` or `-` with nothing after it: one line that way - if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null; - return r; - } - - /// The pattern between the delimiters, with the backslash of an escape - /// KEPT (it belongs to the regex engine, not to this parser). - /// - /// DIVERGENCE: acme closes both `/re/` and `?re?` on a `/` — its scanner - /// has no `case '?'` at all, so `?foo?` yields the pattern `foo?`, which - /// as a regex means `fo` plus an optional `o`. That is a bug you can only - /// find by reading addr.c. Here the OPENING delimiter closes. - fn pattern(a: *Addr, delim: u8) []const u8 { - const s = a.i; - while (a.i < a.expr.len) { - const c = a.expr[a.i]; - if (c == '\n') break; - a.i += 1; - if (c == '\\') { - if (a.i < a.expr.len) a.i += 1; - continue; - } - if (c == delim) return a.expr[s .. a.i - 1]; - } - return a.expr[s..a.i]; - } - - /// acme's `number()`, byte for byte — including its two oddities: a `-` - /// count from offset 0 wraps to the END of the file, and `:1-1` is legal - /// (it means `#0`) while `:1-2` is an error. - fn number(a: *Addr, r_in: PaneFs.Range, n: u32, dir: u8, size: Size) ?PaneFs.Range { - var r = r_in; - if (size == .char) { - var off: i64 = n; - if (dir == '+') { - off = @as(i64, r.q1) + n; - } else if (dir == '-') { - if (r.q0 == 0 and n > 0) r.q0 = clip(a.text.len); - off = @as(i64, r.q0) - n; - } - if (off < 0 or off > @as(i64, @intCast(a.text.len))) return null; - // BYTES, and a byte offset can land inside a grapheme where acme's - // rune offset never could. Clamped to the boundary at or before - // it, which is the rule every other offset in pardes follows. - const g = clip(modal.graphemeStart(a.text, @intCast(off))); - return .{ .q0 = g, .q1 = g }; - } - var line: i64 = n; - var q0: usize = r.q0; - var q1: usize = r.q1; - switch (dir) { - '-' => { - if (q0 < a.text.len) while (q0 > 0 and a.text[q0 - 1] != '\n') { - q0 -= 1; - }; - q1 = q0; - while (line > 0 and q0 > 0) { - if (a.text[q0 - 1] == '\n') { - line -= 1; - q1 = q0; - } - q0 -= 1; - } - if (line > 1) return null; - while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1; - return .{ .q0 = clip(q0), .q1 = clip(q1) }; - }, - '+' => { - if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') { - q1 += 1; - }; - q0 = q1; - }, - else => { - q0 = 0; - q1 = 0; - }, - } - while (line > 0 and q1 < a.text.len) { - const ch = a.text[q1]; - q1 += 1; - if (ch == '\n' or q1 == a.text.len) { - line -= 1; - if (line > 0) q0 = q1; - } - } - if (line > 0) return null; - return .{ .q0 = clip(q0), .q1 = clip(q1) }; - } - - /// acme's `regexp()`. Forward runs from the END of the running range to - /// the limit (`limit=addr`, else the end of the file); backward runs from - /// its START back to the beginning. - /// - /// The engine is mvzr, the one `%s` and the selection previews already - /// use. Two of its properties come along and cannot be fixed here: `^` and - /// `$` assert against the SLICE being searched rather than against a line, - /// and `.` matches a newline like any other byte. Both are already waived - /// in pardes.zig; an address that needs a line anchor matches `\n`. - fn regexp(a: *Addr, r: PaneFs.Range, pat: []const u8, back: bool) ?PaneFs.Range { - // acme reuses the LAST compiled expression for an empty pattern - // (`rxnull`). There is no such global here — one more piece of hidden - // state for a script to guess wrong about — so `//` is not an address. - if (pat.len == 0 or !safePattern(pat)) return null; - const re = mvzr.compile(pat) orelse return null; - if (back) { - const hi = @min(@as(usize, r.q0), a.text.len); - var best: ?mvzr.Match = null; - var at: usize = 0; - while (at < hi) { - const m = re.matchPos(at, a.text[0..hi]) orelse break; - best = m; - at = if (m.end > m.start) m.end else m.end + 1; - } - const m = best orelse return null; - return .{ .q0 = clip(m.start), .q1 = clip(m.end) }; - } - const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len; - const from = @min(@as(usize, r.q1), hi); - const m = re.match(a.text[from..hi]) orelse return null; - return .{ .q0 = clip(from + m.start), .q1 = clip(from + m.end) }; - } -}; - -// =========================================================================== -// CTL VERBS — acme's xfidctlwrite. -// =========================================================================== - -/// The verbs that mean something here. acme matches PREFIXES with `strncmp` -/// and advances by the matched length, which is why its arms have to be -/// ordered `delete` before `del`, `nomark` before `mark`, `noscroll` before -/// `scroll` — get that ordering wrong and a verb is silently truncated into a -/// different one. Splitting on the newline the man page already requires and -/// matching WHOLE tokens makes that class of bug unrepresentable. -const Verb = enum { - @"addr=dot", - clean, - cleartag, - del, - delete, - dirty, - @"dot=addr", - get, - @"limit=addr", - mark, - nomark, - noscroll, - put, - scroll, - show, -}; - -/// ...and the ones acme has that pardes REFUSES. Loudly, because a silently -/// accepted no-op is the worse failure: the script believes it holds the lock. -/// -/// menu / nomenu — acme maintains `Undo Redo Put` in the LEFT HALF of the -/// tag and these switch that off. pardes's tag prefix is computed chrome -/// (the path, the dirty marker, the pane's own builtins) with no halves -/// and no writable menu region, so there is nothing to switch. -/// dump / dumpdir — acme's dump file stores a COMMAND that recreates a -/// window. pardes's dump (src/dump.zig) stores the window's TEXT, so a -/// recreation command has nowhere to be kept and nothing to run it. -/// font — the face belongs to the SHELL, not the core: on a tty it is the -/// terminal emulator's and in the browser it is the page's. The `Font` -/// builtin only ASKS; a ctl verb that looked like it set one would be a -/// lie on three of the four platforms. -/// lock / unlock — acme's exclusive-use lock is a `QLock` held against a 9P -/// fid. There is no fid here and the core is single-threaded, so a lock -/// would promise a mutual exclusion nothing can violate and nothing -/// provides. -const refused_verbs = [_][]const u8{ "dump", "dumpdir", "font", "lock", "menu", "nomenu", "unlock" }; - -fn verbIs(line: []const u8, word: []const u8) bool { - if (!std.mem.startsWith(u8, line, word)) return false; - return line.len == word.len or line[word.len] == ' '; -} - -/// acme's ctl write is NOT atomic: it applies verbs until one fails, then -/// answers `Ebadctl` with a count of the bytes it got through, so -/// `dirty\nbogus\n` leaves the window dirty and the write "fails". A short -/// count on a Linux write is not read as "the rest failed" by anybody, so the -/// only honest translation is all-or-nothing: validate every verb first, then -/// apply. `ctlVerb` answers the same yes/no in both passes. -fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { - for ([2]bool{ false, true }) |apply| { - // `del`'s guard is the one predicate that reads state EARLIER VERBS IN - // THE SAME WRITE change, so the validation pass has to model it or the - // two passes disagree: `clean\ndel` (acme's own idiom, and what - // examples/acmefs/life.py sends on the way out) would fail validation - // while `dirty\ndel` would pass it and then fail half-applied. - var dirty = if (p.paneBySerial(serial)) |id| dirtyOf(p.panes[id].?) else false; - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - // `del` and `delete` remove the pane, and the verbs after them in - // the same write have nothing left to act on. - const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); - if (!ctlVerb(p, live, line, apply, &dirty)) return Reply.fail(req.tag, E.INVAL); - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -/// One verb. `apply` false is the validation pass and must change nothing but -/// `dirty`, which both passes advance identically so that `del`'s guard sees -/// the same answer in each. -fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) bool { - const pane = p.panes[id] orelse return false; - const pf = &p.fs.panes[id]; - - // The one verb with an argument. acme rejects a name containing any - // character `<= ' '` and an empty one; so does this. - if (verbIs(line, "name")) { - if (line.len <= 5) return false; - const name = std.mem.trim(u8, line[5..], " \t"); - if (name.len == 0) return false; - for (name) |c| if (c <= ' ') return false; - if (!apply) return true; - const f = fileOf(pane) orelse return true; // a terminal has no name to set - const copy = p.gpa.dupe(u8, name) catch return true; - p.gpa.free(f.path); - f.path = copy; - return true; - } - for (refused_verbs) |w| if (verbIs(line, w)) return false; - - const v = std.meta.stringToEnum(Verb, line) orelse return false; - // acme: `del` is "delete, but check dirty", `delete` is "delete for sure". - // pardes's `Del` builtin is unconditional (the guard there is the `*` you - // can see in the tag), so `del` gets acme's guard here and `delete` does - // not — which is the whole difference between the two words. - if (v == .del and dirty.*) return false; - switch (v) { - .dirty => dirty.* = true, - .clean, .get, .put => dirty.* = false, - else => {}, - } - if (!apply) return true; - - switch (v) { - .@"addr=dot" => pf.addr = dotOf(pane), - .@"dot=addr" => { - clampAddr(pf, bodyOf(pane).len); - setDot(pane, pf.addr); - }, - .@"limit=addr" => { - clampAddr(pf, bodyOf(pane).len); - pf.limit = pf.addr; - }, - // acme marks the window clean by resetting the file's sequence number; - // pardes's equivalent is "the revision on screen IS the saved one". - .clean => if (fileOf(pane)) |f| { - f.saved_revision = f.revision; - }, - .dirty => if (fileOf(pane)) |f| { - f.saved_revision = f.revision -% 1; - }, - // acme: "wipe tag right of bar". pardes's bar is the boundary between - // the computed prefix and the editable tail, so this empties the tail - // — and leaves it SEEDED, or the next render would put the default - // builtins straight back. - .cleartag => { - pane.tag_tail_len = 0; - pane.tag_init = true; - }, - .del, .delete => _ = p.executeBuiltinLine(id, "Del"), - .put => _ = p.executeBuiltinLine(id, "Save"), - // acme's `get`: "Equivalent to the Get interactive command with no - // arguments". pardes has no such builtin, so this is what Get would - // be — the same synchronous read `file_pane.open` does, through the - // same content swap, with an undo point in front of it so a script - // cannot discard your edits irrecoverably. - .get => if (fileOf(pane)) |f| { - if (output_pane.fileTraits(f.output).saves) { - if (look.readFile(p.gpa, f.path)) |bytes| { - file_pane.pushUndo(p, pane); - file_pane.setContent(p, f, bytes); - f.saved_revision = f.revision; - } else |_| {} - } - }, - // acme's `mark` both cancels `nomark` AND pushes a mark, so the edits - // made while nomark was on stay one Undo and the next one starts fresh. - .mark => { - pf.nomark = false; - file_pane.pushUndo(p, pane); - }, - .nomark => pf.nomark = true, - .noscroll => pf.noscroll = true, - .scroll => pf.noscroll = false, - .show => showOffset(pane, dotOf(pane).q0), - } - return true; -} - -// =========================================================================== -// `pty/ctl` VERBS — the ioctls, as words. -// =========================================================================== - -/// THE WHOLE GRAMMAR, one verb per line, blank lines ignored, each line -/// trimmed and split on blanks: -/// -/// winsize two decimals, each 1..65535 -/// sig one of INT, TERM, HUP, QUIT, KILL -/// exec no argument -/// -/// An enum and an exhaustive switch for the same reason `Verb` above is one: -/// adding a word is a compile error until it is handled, and matching WHOLE -/// tokens makes acme's ordering bug (`del` shadowing `delete`) unrepresentable. -const PtyVerb = enum { winsize, sig, exec }; - -/// A `winsize` field. -/// -/// ZERO IS REFUSED. `TIOCSWINSZ` reads a zero as "unknown", so `winsize 0 24` -/// would not be a narrow terminal, it would be a terminal of no known width — -/// which is what a program sees when nobody has set a size at all, and never -/// something a script asked for on purpose. -fn ptyDimension(word: []const u8) ?u16 { - if (word.len == 0 or word.len > 5) return null; - for (word) |c| if (c < '0' or c > '9') return null; - const n = std.fmt.parseInt(u16, word, 10) catch return null; - return if (n == 0) null else n; -} - -/// `sig`'s argument: the five names, upper case, spelled the way `kill -INT` -/// and `trap` spell them. -/// -/// NOT A NUMBER, and not `SIGINT` either. A number would be one platform's -/// number in a tree meant to be read from another machine, and the core has no -/// signal numbers of its own (see `pardes.PtySignal`); the `SIG` prefix has -/// been optional to `kill` since 1988 and carrying it here would mean -/// accepting both spellings or refusing the shorter one people type. -fn ptySignalNamed(word: []const u8) ?pardes.PtySignal { - if (std.mem.eql(u8, word, "INT")) return .int; - if (std.mem.eql(u8, word, "TERM")) return .term; - if (std.mem.eql(u8, word, "HUP")) return .hup; - if (std.mem.eql(u8, word, "QUIT")) return .quit; - if (std.mem.eql(u8, word, "KILL")) return .kill; - return null; -} - -/// VALIDATE EVERY VERB, THEN APPLY — `writeCtl`'s shape, for `writeCtl`'s -/// reason: acme applies verbs until one fails and answers with a byte count of -/// how far it got, and nothing on Linux reads a short count on a `write(2)` as -/// "the rest failed", so all-or-nothing is the only honest translation. -/// -/// Simpler than `writeCtl` in exactly one way, and it is worth saying why the -/// two passes need no shared bookkeeping here: no verb in this file can remove -/// the pane or change what a later verb in the same write would decide. `ctl` -/// has `del`, whose guard reads state `clean` sets, so its passes have to -/// model each other; these three are independent, so the validation pass is a -/// pure predicate. -fn writePtyCtl(p: *Pardes, req: Req, id: usize) Reply { - for ([2]bool{ false, true }) |apply| { - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - if (!ptyVerb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL); - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -/// One `pty/ctl` verb. `apply` false is the validation pass and must change -/// nothing whatsoever — not even a queued effect, which is the only state -/// these three touch. -fn ptyVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { - const pane = p.panes[id] orelse return false; - var words = std.mem.tokenizeAny(u8, line, " \t"); - // the line is non-empty and trimmed, so there is always a first token - const v = std.meta.stringToEnum(PtyVerb, words.next() orelse return false) orelse return false; - switch (v) { - // `TIOCSWINSZ`, and DELIBERATELY NOTHING ELSE — in particular not the - // core's own grid. - // - // A pane's grid size is not a free variable here: `Pardes.sync` derives - // `pane.cols`/`pane.rows` from the pane's RECTANGLE at the end of every - // update, so a script that wrote them would have them overwritten - // before its write returned — and `sync` would then emit a second - // `resize_pty` putting the pty back to the layout's size, so the verb - // would visibly undo itself. Telling only the pty leaves the script's - // size in force until the pane's rectangle actually changes, which for - // a layout nobody is dragging is for good. - // - // Which is also why a `winsize` write is not read back from `status`: - // `status` reports the grid the editor computed, the only size the core - // has. What a program was last TOLD is remembered by the pty, and the - // pty will not say. - .winsize => { - const cols = ptyDimension(words.next() orelse return false) orelse return false; - const rows = ptyDimension(words.next() orelse return false) orelse return false; - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); - }, - // The one genuinely new capability in the whole `pty/` directory: - // there is no `kill` anywhere in the host seam until this effect. - .sig => { - const which = ptySignalNamed(words.next() orelse return false) orelse return false; - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); - }, - // RESPAWN THIS PANE'S SHELL, and NO ARGUMENT — which is a limit of the - // effect and not a choice made here. `Effect.spawn` carries a pane and - // a cwd (pardes.zig) and has nowhere to put an argv; the host answers - // it by forking `core.shellBin()`, and the argv it builds is the - // prompt-integration rc files, not something a caller supplies. So - // `exec` respawns the configured shell in the pane's own directory, - // and `exec /bin/sh` is EINVAL — refused loudly rather than accepted - // and silently ignored, which is the failure a script cannot see. - // - // Giving it an argv means widening the effect and teaching four hosts - // to exec something the user did not configure, which is a change to - // what a terminal pane IS and wants its own argument. - // - // The host reaps the old child and forks a new one (every `push_spawn` - // opens by doing exactly that, because the core has no close effect). - // The GRID is not cleared: a terminal's body is a transcript, and the - // transcript of the shell that just died is the thing a script would - // want to read afterwards. - .exec => { - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); - }, - } - return true; -} - -/// `pty/data`, write side: TYPE AT THE PROGRAM. -/// -/// Identical to what a `body` write to a terminal already does (`writeBody`), -/// and that is the point of the name rather than a duplication: `body` is a -/// pty write because a transcript can only be written by typing, `pty/data` is -/// a pty write because it IS the pty. A script that knows it is talking to a -/// terminal says so; one that is generic over panes writes `body`. -/// -/// The offset is ignored — a stream has no offsets — and the count is short at -/// a character boundary exactly as every other write here is, so a caller -/// whose buffer was split mid-sequence by the kernel's `max_write` retries the -/// tail instead of having it dropped. -fn writePtyData(p: *Pardes, req: Req, id: usize) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - const take = wholeUtf8(req.data); - p.emitWrite(id, req.data[0..take]); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -// =========================================================================== -// EVENT WRITE-BACK — acme's xfideventwrite. -// =========================================================================== - -const EventRecord = struct { action: Action, q0: u32, q1: u32 }; - -/// `{origin}{type}{q0} {q1}\n`, acme's `xfideventwrite` parse: two characters, -/// two blank-separated decimals, a newline. Everything a full record carries -/// after that — the flag, the count, the text — is omitted on the way back in, -/// which is what acme(4) means by "with the flag, count, and text omitted". -/// -/// acme walks this with `strtoul`, pointer arithmetic and `goto Rescue`; here -/// the failure is `null` and the position stays in the struct, so the caller -/// can tell "ran out cleanly" from "stopped on garbage" by looking at `i`. -const EventReader = struct { - data: []const u8, - i: usize = 0, - - fn next(er: *EventReader) ?EventRecord { - if (er.i >= er.data.len) return null; - var i = er.i; - if (i + 2 > er.data.len) return null; - // acme stores the first character as `w->owner` (with a - // `/* disgusting */` beside it) so later records inherit whatever the - // writer claimed. Read and dropped here — see `writeEvent`. - i += 1; - const action = Action.fromChar(er.data[i]) orelse return null; - i += 1; - const q0 = scanNumber(er.data, &i) orelse return null; - const q1 = scanNumber(er.data, &i) orelse return null; - while (i < er.data.len and er.data[i] == ' ') i += 1; - if (i >= er.data.len or er.data[i] != '\n') return null; - er.i = i + 1; - return .{ .action = action, .q0 = q0, .q1 = q1 }; - } -}; - -fn scanNumber(data: []const u8, i: *usize) ?u32 { - while (i.* < data.len and data[i.*] == ' ') i.* += 1; - const s = i.*; - var n: u64 = 0; - while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1) - n = @min(n * 10 + (data[i.*] - '0'), std.math.maxInt(u32)); - if (i.* == s) return null; - return @intCast(n); -} - -/// Writing a record back PERFORMS the action it names, "exactly as it would -/// have been if the event file had not been open" (acme(4)). This is the -/// documented remote-control door and the point of the whole suppression rule: -/// a script reads an `X` record, decides the text is not one of its own tag -/// commands, and hands it back for pardes to run. -/// -/// It is also, deliberately, arbitrary code execution — an `X` record is an -/// Exec — which is why the mount is 0700 under the user's runtime directory. -/// -/// NOTHING in the write applies unless all of it parses: acme validates each -/// record just before executing it and leaves the earlier ones done, which -/// makes a malformed batch half-applied and unrepeatable. -fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { - const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT); - const serial = pane0.serial; - { - const body = bodyOf(pane0); - const tag = tagOf(p, pane0); - var check: EventReader = .{ .data = req.data }; - while (check.next()) |r| { - switch (r.action) { - // acme accepts only `xXlL` on the way back in. A `D` or an `I` - // is a REPORT, not a request; writing one back would mean - // "pretend the user typed this", which nothing implements and - // acme's switch rejects with `Ebadevent`. - .body_look, .tag_look, .body_exec, .tag_exec => {}, - else => return Reply.fail(req.tag, E.INVAL), - } - // lower case is the tag, upper case the body — how a reader tells - // the two texts apart with no extra field - const n = if (r.action.onTag()) tag.len else body.len; - if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL); - } - if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL); - } - // acme(4): `F` is "actions through the window's other files", which is - // exactly what this is, and `handle` has already set it. acme takes the - // origin from the RECORD instead (`w->owner = *p++`, with a - // `/* disgusting */` beside it), so a writer can attribute its own action - // to the keyboard; the character is parsed here and dropped, because a - // record saying where it came from is worth nothing if the sender picks. - var run: EventReader = .{ .data = req.data }; - while (run.next()) |r| { - // an earlier action in this same write may have deleted the pane - const now = p.paneBySerial(serial) orelse break; - const pane = p.panes[now].?; - const whole = if (r.action.onTag()) tagOf(p, pane) else bodyOf(pane); - const lo = @min(@as(usize, r.q0), whole.len); - const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); - // the action can replace the very text it is reading from - const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; - switch (r.action) { - .body_exec, .tag_exec => _ = p.execute(now, text), - .body_look, .tag_look => p.lookAt(now, text), - else => unreachable, - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -// =========================================================================== -// +Errors — acme's `errorwin`. -// =========================================================================== - -/// acme(4): writing to `errors` "appends to the body of the dir/+Errors -/// window, where dir is the directory currently named in the tag. The window -/// is created if necessary, but not until text is actually written." -/// -/// One buffer per DIRECTORY, not per pane — which is why a search for an -/// existing one matches on the dirname and not on the writer. Answers HOW -/// MANY BYTES WERE TAKEN, or null for failure. -/// -/// The count matters because the append goes through `spliceBody`, which stops -/// at a whole-character boundary: the kernel splits a large `write(2)` at -/// `max_write` wherever it lands, so a multi-byte character straddling that -/// boundary must be reported short and retried by the writer's libc, exactly -/// as `body` and `data` do. Acknowledging the whole buffer would drop it. -fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { - if (text.len == 0) return 0; - const pane = p.panes[id] orelse return null; - const dir = dirOf(pane); - for (p.panes, 0..) |slot, i| { - const q = slot orelse continue; - const qf = fileOf(q) orelse continue; - const o = qf.output orelse continue; - if (std.meta.activeTag(o.from) != .errors) continue; - if (!std.mem.eql(u8, std.fs.path.dirname(qf.path) orelse "", dir)) continue; - return spliceBody(p, i, q, qf.content.len, qf.content.len, text); - } - const free = p.freeSlot() orelse return null; - const content = p.gpa.dupe(u8, text) catch return null; - const np = output_pane.open(p, free, dir, .errors, "", content) catch { - p.gpa.free(content); - return null; - }; - p.placeDoc(id, free, np); - return text.len; -} - -// =========================================================================== -// SIZES — what `stat` reports. -// =========================================================================== - -/// The whole `index`, measured. acme's `xfidindexread` walks every window to -/// size its buffer too; the tag of each is FORMATTED to be measured, into the -/// per-update scratch arena that is reset anyway, so this is bump allocation -/// rather than sixteen allocations a frame. -fn indexLen(p: *Pardes) u64 { - var n: u64 = 0; - for (p.panes) |slot| { - const pane = slot orelse continue; - n += ctl_fields + firstLine(tagOf(p, pane)).len + 1; - } - return n; -} - -/// A terminal's body has no length that is cheap AND honest — measuring it -/// means rendering the whole scrollback — so it reports zero and is served -/// with direct IO, exactly like `event` and `log`. -fn bodyLen(p: *Pardes, pane: *Pane) u64 { - _ = p; - return bodyOf(pane).len; -} - -fn tagLen(p: *Pardes, pane: *Pane) u64 { - return tagOf(p, pane).len; -} - -// =========================================================================== -// TESTS. -// -// The whole point of the split: every one of these drives `handle()` through -// the ordinary event queue with NO FUSE, NO mount, NO thread and no /dev/fuse -// anywhere. A filesystem whose semantics are a pure function of the core is a -// filesystem you can unit-test at the speed of a function call, and one whose -// blocking is a return value is one you can test without a scheduler. -// =========================================================================== - -const testing = std.testing; - -/// What a transport sees: the reply, and the bytes `fsPayload` resolved for it -/// inside the drain's borrow window. The two effects a filesystem operation -/// can additionally cause are captured too, because for `put` and for a write -/// to a terminal's body THE EFFECT IS THE ANSWER. -const Answer = struct { - reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO }, - bytes: []const u8 = "", - saved: bool = false, - pty_buf: [256]u8 = undefined, - pty_len: usize = 0, - /// `pty/ctl`'s three verbs are each ONE EFFECT and nothing else, so the - /// effect is the only thing a test can look at. - winsize: ?struct { cols: u16, rows: u16 } = null, - signal: ?pardes.PtySignal = null, - spawned: bool = false, - - fn pty(a: *const Answer) []const u8 { - return a.pty_buf[0..a.pty_len]; - } - - fn errno(a: Answer) u16 { - return if (a.reply.status == .err) a.reply.errno else 0; - } -}; - -/// One request in, one answer out. Effects are DRAINED but not performed: a -/// `put` must be observable as a `.save_file` without a test writing to the -/// real filesystem. -fn call(p: *Pardes, req: Req) Answer { - p.update(.{ .fs_req = req }); - var ans: Answer = .{}; - while (p.nextEffect()) |e| switch (e) { - .fs_reply => |r| { - ans.reply = r; - ans.bytes = p.fsPayload(r); - }, - .save_file, .save_text => ans.saved = true, - .write => |w| { - const b = w.bytes.slice(); - const n = @min(b.len, ans.pty_buf.len - ans.pty_len); - @memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]); - ans.pty_len += n; - }, - .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows }, - .signal_pty => |s| ans.signal = s.sig, - .spawn => ans.spawned = true, - else => {}, - }; - return ans; -} - -fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { - return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size }); -} - -fn wr(p: *Pardes, node: u64, data: []const u8) Answer { - return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data }); -} - -fn rdir(p: *Pardes, node: u64, skip: u64) Answer { - return call(p, .{ .tag = 4, .op = .readdir, .node = node, .off = skip, .size = 4096 }); -} - -fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { - return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); -} - -/// A core with one FILE pane holding `text`, which is what most of acme's -/// window files are about. Slot 0, and its serial is the directory name. -fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); - errdefer p.deinit(); - while (p.nextEffect()) |_| {} - _ = try p.hxOpenFileContent(text); - while (p.nextEffect()) |_| {} - return p; -} - -/// ...and a core whose slot 0 is a TERMINAL, which is what `pty/` is about. -/// `tty_only` opens exactly one shell pane and nothing else, so there is no -/// document anywhere and the geometry has already settled by the time the -/// startup effects are drained — a later `.resize_pty` in a test is therefore -/// one a verb caused. -fn withTerm(gpa: std.mem.Allocator) !*Pardes { - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); - errdefer p.deinit(); - while (p.nextEffect()) |_| {} - std.debug.assert(p.panes[0].?.isTerminal()); - return p; -} - -fn serialOf(p: *Pardes) u32 { - return p.panes[0].?.serial; -} - -const Dirent = struct { node: u64, dir: bool, name: []const u8 }; - -/// Decode the readdir staging format `src/fuse.zig` agreed to. -fn dirents(bytes: []const u8, out: []Dirent) []Dirent { - var n: usize = 0; - var i: usize = 0; - while (i + 10 <= bytes.len and n < out.len) { - const node = std.mem.readInt(u64, bytes[i..][0..8], .little); - const kind = bytes[i + 8]; - const len = bytes[i + 9]; - i += 10; - if (i + len > bytes.len) break; - out[n] = .{ .node = node, .dir = kind == 1, .name = bytes[i .. i + len] }; - i += len; - n += 1; - } - return out[0..n]; -} - -fn nameAt(list: []const Dirent, want: []const u8) ?Dirent { - for (list) |d| if (std.mem.eql(u8, d.name, want)) return d; - return null; -} - -test "readdir lists the root, a pane directory, and new/ without creating anything" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - var buf: [32]Dirent = undefined; - - const root = rdir(p, @intFromEnum(TopFile.root), 0); - try testing.expectEqual(Status.ok, root.reply.status); - const top = dirents(root.bytes, &buf); - try testing.expectEqual(@as(usize, 4), top.len); - try testing.expectEqualStrings("index", top[0].name); - try testing.expectEqualStrings("cons", top[1].name); - try testing.expectEqualStrings("new", top[2].name); - try testing.expect(top[2].dir and !top[0].dir); - var idbuf: [16]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&idbuf, "{d}", .{serial}), top[3].name); - try testing.expect(top[3].dir); - // the id a readdir reports is the id a getattr will report - try testing.expectEqual(Node.of(serial, .dir), top[3].node); - - // `off` skips entries, and past the end is EOF, not an error - const rest = rdir(p, @intFromEnum(TopFile.root), 3); - try testing.expectEqual(@as(usize, 1), dirents(rest.bytes, &buf).len); - const eof = rdir(p, @intFromEnum(TopFile.root), 99); - try testing.expectEqual(Status.ok, eof.reply.status); - try testing.expectEqual(@as(usize, 0), eof.bytes.len); - - const dir = rdir(p, Node.of(serial, .dir), 0); - const files = dirents(dir.bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); // dirtabw minus "." - try testing.expect(nameAt(files, "addr") != null); - try testing.expect(nameAt(files, "xdata") != null); - try testing.expect(nameAt(files, ".") == null); - try testing.expectEqual(Node.of(serial, .body), nameAt(files, "body").?.node); - - // acme(4) says accessing a file in `new` creates a window, so LISTING it - // must enumerate nothing at all: every name it could report is a name - // whose lookup creates a pane, and `ls -l` stats what a listing reported. - const before = p.next_serial; - const new = rdir(p, @intFromEnum(TopFile.new), 0); - try testing.expectEqual(Status.ok, new.reply.status); - try testing.expectEqual(@as(usize, 0), new.bytes.len); - try testing.expectEqual(before, p.next_serial); - - // a file is not a directory - try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); -} - -test "lookup resolves top files, pane serials and pane files" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - const root = @intFromEnum(TopFile.root); - - try testing.expectEqual(@as(u64, @intFromEnum(TopFile.index)), look_up(p, root, "index").reply.attr.node); - try testing.expect(look_up(p, root, "new").reply.attr.dir); - try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); - - var idbuf: [16]u8 = undefined; - const dir = look_up(p, root, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})); - try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); - try testing.expect(dir.reply.attr.dir); - // a serial that is not a live pane, and a serial that never existed - try testing.expectEqual(E.NOENT, look_up(p, root, "99999").errno()); - - const body = look_up(p, Node.of(serial, .dir), "body"); - try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node); - // a lookup answers exactly what a getattr of the same node would - const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) }); - try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size); - try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno()); - try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); -} - -test "a lookup inside new/ creates a pane and resolves that pane's file" { - const gpa = testing.allocator; - const p = try withFile(gpa, "first\n"); - defer p.deinit(); - const before = serialOf(p); - - // a name that is not a pane file creates nothing - try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.new), "bogus").errno()); - try testing.expectEqual(before, p.next_serial); - - const a = look_up(p, @intFromEnum(TopFile.new), "body"); - try testing.expectEqual(Status.ok, a.reply.status); - const made: Node = @bitCast(a.reply.attr.node); - try testing.expect(made.serial != before); - try testing.expectEqual(@intFromEnum(PaneFile.body), made.file); - - // ...and it is a real pane: `echo hi > new/body` leaves a pane holding hi - _ = wr(p, a.reply.attr.node, "hi"); - const id = p.paneBySerial(@intCast(made.serial)).?; - try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); -} - -test "index prints winctlprint's five fields then the tag" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\nthere\n"); - defer p.deinit(); - const pane = p.panes[0].?; - - const a = rd(p, @intFromEnum(TopFile.index), 0, 4096); - try testing.expectEqual(Status.ok, a.reply.status); - var got: [512]u8 = undefined; - @memcpy(got[0..a.bytes.len], a.bytes); - const line = got[0..a.bytes.len]; - - const tag = tagOf(p, pane); - var want: std.ArrayList(u8) = .empty; - defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s}\n", .{ - pane.serial, tag.len, @as(usize, "hello\nthere\n".len), 0, 0, firstLine(tag), - }); - try testing.expectEqualStrings(want.items, line); - // acme(4): "at character position 5x12 starts the name of the window" - try testing.expectEqual(@as(usize, 60), std.mem.indexOf(u8, line, firstLine(tag)).?); - - // seekable: a script may pread the middle of it - const mid = rd(p, @intFromEnum(TopFile.index), 60, 5); - try testing.expectEqualStrings(firstLine(tag)[0..5], mid.bytes); - - // ...and a dirty pane says so in the fifth field - pane.file.?.saved_revision = pane.file.?.revision -% 1; - const dirty = rd(p, @intFromEnum(TopFile.index), 48, 12); - try testing.expectEqualStrings(" 1 ", dirty.bytes); -} - -test "ctl read is index's five fields plus width in cells, font and tab width" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const pane = p.panes[0].?; - - const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096); - try testing.expectEqual(Status.ok, a.reply.status); - var want: std.ArrayList(u8) = .empty; - defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{ - pane.serial, tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, - }); - try testing.expectEqualStrings(want.items, a.bytes); - - // plan9 %q: a name with a space in it becomes one shell word - var quoted: std.ArrayList(u8) = .empty; - defer quoted.deinit(gpa); - stageQuoted("ed, gpa, "DejaVu Sans Mono"); - try testing.expectEqualStrings("'DejaVu Sans Mono'", quoted.items); - quoted.clearRetainingCapacity(); - stageQuoted("ed, gpa, "it's"); - try testing.expectEqualStrings("'it''s'", quoted.items); -} - -test "body reads at any offset and writes append" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const body = Node.of(serial, .body); - - try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 100).bytes); - try testing.expectEqualStrings("two\n", rd(p, body, 4, 100).bytes); - try testing.expectEqualStrings("wo", rd(p, body, 5, 2).bytes); - try testing.expectEqualStrings("", rd(p, body, 999, 2).bytes); - // zero copy: the answer points INTO the pane, it is not a staged copy - try testing.expect(rd(p, body, 0, 100).bytes.ptr == p.panes[0].?.file.?.content.ptr); - - // acme(4): "Text written to body is always appended; the file offset is - // ignored" — so a write at offset 0 still lands at the end. - const w = call(p, .{ .tag = 5, .op = .write, .node = body, .off = 0, .data = "three\n" }); - try testing.expectEqual(@as(u32, 6), w.reply.written); - try testing.expectEqualStrings("one\ntwo\nthree\n", p.panes[0].?.file.?.content); - - // a write cut mid-character is SHORT, never split - const short = wr(p, body, "a\xC3"); - try testing.expectEqual(@as(u32, 1), short.reply.written); - try testing.expectEqualStrings("one\ntwo\nthree\na", p.panes[0].?.file.?.content); -} - -test "a body write to a terminal pane types at its shell" { - const gpa = testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.isTerminal()); - - // `win`'s transcript semantics: the only way into a program's transcript - // is to type at it, so a body write becomes a pty write. - const a = wr(p, Node.of(pane.serial, .body), "ls -l\r"); - try testing.expectEqual(@as(u32, 6), a.reply.written); - try testing.expectEqualStrings("ls -l\r", a.pty()); - - // and a body READ renders the scrollback rather than lending a buffer - const r = rd(p, Node.of(pane.serial, .body), 0, 64); - try testing.expectEqual(Status.ok, r.reply.status); -} - -test "tag reads the whole tag and writes append to the editable tail" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const pane = p.panes[0].?; - const node = Node.of(pane.serial, .tag); - - const whole = rd(p, node, 0, 4096); - try testing.expect(std.mem.startsWith(u8, whole.bytes, "/hxcase.txt")); - try testing.expect(std.mem.indexOf(u8, whole.bytes, "Del") != null); - - const before = rd(p, node, 0, 4096).bytes.len; - const w = wr(p, node, " Mine"); - try testing.expectEqual(@as(u32, 5), w.reply.written); - try testing.expect(std.mem.endsWith(u8, pane.tag_tail[0..pane.tag_tail_len], " Mine")); - const after = rd(p, node, 0, 4096); - try testing.expectEqual(before + 5, after.bytes.len); - try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine")); - - // the tail is one bounded line; with no room left the file is FULL - pane.tag_tail_len = pane.tag_tail.len; - try testing.expectEqual(E.NOSPC, wr(p, node, "x").errno()); -} - -test "the address language, form by form" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\nthree\n"); // 14 bytes, three lines - defer p.deinit(); - const serial = serialOf(p); - const addr = Node.of(serial, .addr); - - const Case = struct { expr: []const u8, q0: u32, q1: u32 }; - for ([_]Case{ - .{ .expr = "#0", .q0 = 0, .q1 = 0 }, - .{ .expr = "#5", .q0 = 5, .q1 = 5 }, - .{ .expr = "0", .q0 = 0, .q1 = 0 }, - .{ .expr = "1", .q0 = 0, .q1 = 4 }, - .{ .expr = "2", .q0 = 4, .q1 = 8 }, - .{ .expr = "$", .q0 = 14, .q1 = 14 }, - .{ .expr = ",", .q0 = 0, .q1 = 14 }, - .{ .expr = "1,2", .q0 = 0, .q1 = 8 }, - .{ .expr = "#1,#4", .q0 = 1, .q1 = 4 }, - .{ .expr = "2+1", .q0 = 8, .q1 = 14 }, - .{ .expr = "$-1", .q0 = 8, .q1 = 14 }, - .{ .expr = "/two/", .q0 = 4, .q1 = 7 }, - .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 }, - // a trailing newline is what a shell redirect leaves behind - .{ .expr = "1\n", .q0 = 0, .q1 = 4 }, - }) |c| { - // every case starts from a known address, so `.` and `+`/`-` are - // measured against the same place each time - _ = wr(p, addr, "#0"); - const w = wr(p, addr, c.expr); - try testing.expectEqual(Status.ok, w.reply.status); - const got = rd(p, addr, 0, 64); - var want: [32]u8 = undefined; - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} ", .{ c.q0, c.q1 }), - got.bytes, - ); - } - - // `.` is the CURRENT ADDRESS (acme passes w->addr as `ar`), not the - // selection: set it, then ask for it back. - _ = wr(p, addr, "1"); - _ = wr(p, addr, "."); - try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q1); - - // `?re?` searches BACKWARD from the start of the running range and takes - // the LAST match before it — acme's `rxbexecute`. - _ = wr(p, addr, "$"); - _ = wr(p, addr, "?o?"); - try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two" - try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1); - - // limit=addr confines a forward search - _ = wr(p, addr, "1"); - _ = wr(p, Node.of(serial, .ctl), "limit=addr\n"); - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, "/three/").errno()); - _ = wr(p, Node.of(serial, .ctl), "clean\n"); // any ctl write; limit stays - // ...and opening ctl clears it again (acme(4)) - _ = call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) }); - try testing.expect(p.fs.panes[0].limit == null); - _ = wr(p, addr, "#0"); - try testing.expectEqual(Status.ok, wr(p, addr, "/three/").reply.status); - - // refusals - for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, bad).errno()); - } - - // acme recurses once per `,` with no bound at all, which a script turns - // into a stack overflow with one write(2). Refused, not crashed. - const nested = "," ** 4096; - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, nested).errno()); -} - -test "data and xdata read from addr, move it, and write through it" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const addr = Node.of(serial, .addr); - const data = Node.of(serial, .data); - const xdata = Node.of(serial, .xdata); - - _ = wr(p, addr, "#0"); - try testing.expectEqualStrings("one", rd(p, data, 0, 3).bytes); - // ...and the address is now the null string after what was returned - try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); - - // xdata stops at the END of the address where data would run on - _ = wr(p, addr, "1"); - try testing.expectEqualStrings("one\n", rd(p, xdata, 0, 100).bytes); - _ = wr(p, addr, "1"); - try testing.expectEqualStrings("one\ntwo\n", rd(p, data, 0, 100).bytes); - - // a write REPLACES the addressed text and leaves the address after it - _ = wr(p, addr, "1"); - const w = wr(p, data, "ONE\n"); - try testing.expectEqual(@as(u32, 4), w.reply.written); - try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); - try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q0); -} - -test "data never splits a grapheme, in either direction" { - const gpa = testing.allocator; - const p = try withFile(gpa, "\u{00e9}x\n"); // é is two bytes - defer p.deinit(); - const serial = serialOf(p); - _ = wr(p, Node.of(serial, .addr), "#0"); - // one byte is not enough for the first character: acme's `if(m == 0) break` - try testing.expectEqualStrings("", rd(p, Node.of(serial, .data), 0, 1).bytes); - _ = wr(p, Node.of(serial, .addr), "#0"); - try testing.expectEqualStrings("\u{00e9}", rd(p, Node.of(serial, .data), 0, 2).bytes); - - // and a write ending mid-character is short rather than corrupting - _ = wr(p, Node.of(serial, .addr), "#0"); - try testing.expectEqual(@as(u32, 1), wr(p, Node.of(serial, .data), "a\xC3").reply.written); -} - -test "rdsel reads the selection and wrsel replaces it" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - - _ = wr(p, Node.of(serial, .addr), "#0,#3"); - try testing.expectEqual(Status.ok, wr(p, ctl, "dot=addr\n").reply.status); - try testing.expectEqualStrings("one", rd(p, Node.of(serial, .rdsel), 0, 100).bytes); - - // ...and the round trip back out is the identity, not a range that creeps - _ = wr(p, ctl, "addr=dot\n"); - try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); - - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .wrsel), "ONE").reply.status); - try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); - // a second write appends after the first, acme's `wrselrange` - _ = wr(p, Node.of(serial, .wrsel), "!"); - try testing.expectEqualStrings("ONE!\ntwo\n", p.panes[0].?.file.?.content); -} - -test "every ctl verb, and every refusal" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - const pane = p.panes[0].?; - const pf = &p.fs.panes[0]; - - // several verbs in one write, which is what the man page promises - try testing.expectEqual(Status.ok, wr(p, ctl, "nomark\nnoscroll\ndirty\n").reply.status); - try testing.expect(pf.nomark and pf.noscroll and dirtyOf(pane)); - try testing.expectEqual(Status.ok, wr(p, ctl, "mark\nscroll\nclean\n").reply.status); - try testing.expect(!pf.nomark and !pf.noscroll and !dirtyOf(pane)); - - _ = wr(p, ctl, "cleartag\n"); - try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); - - _ = wr(p, Node.of(serial, .addr), "2"); - _ = wr(p, ctl, "limit=addr\n"); - try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); - _ = wr(p, ctl, "dot=addr\nshow\n"); - try testing.expectEqual(@as(i32, 1), pane.cur_row); - - try testing.expectEqual(Status.ok, wr(p, ctl, "name /tmp/renamed.txt\n").reply.status); - try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); - // acme rejects a name with any character <= ' ' in it - try testing.expectEqual(E.INVAL, wr(p, ctl, "name two words\n").errno()); - try testing.expectEqual(E.INVAL, wr(p, ctl, "name\n").errno()); - try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); - - // `put` is acme's Put, which is pardes's Save - try testing.expect(wr(p, ctl, "put\n").saved); - - // REFUSED, each for a reason that is not "unimplemented" — see - // `refused_verbs`. Silently accepting these is the worse failure. - for ([_][]const u8{ - "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", - }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl, bad).errno()); - - // ATOMIC, which acme is not: an unknown verb aborts the WHOLE write. - try testing.expect(!dirtyOf(pane)); - try testing.expectEqual(E.INVAL, wr(p, ctl, "dirty\nbogus\n").errno()); - try testing.expect(!dirtyOf(pane)); -} - -test "ctl get reloads the pane from disk and del honours a dirty body" { - const gpa = testing.allocator; - var tmp = testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/note.txt", .{tmp.sub_path}); - - const p = try withFile(gpa, "in memory\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - const pane = p.panes[0].?; - - var name: [std.fs.max_path_bytes + 8]u8 = undefined; - _ = wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}\n", .{path})); - try testing.expectEqual(Status.ok, wr(p, ctl, "get\n").reply.status); - try testing.expectEqualStrings("from disk\n", pane.file.?.content); - // Get leaves the pane clean and the previous text one Undo away - try testing.expect(!dirtyOf(pane)); - try testing.expect(pane.file.?.undo_len > 0); - - // acme: `del` is "delete, but check dirty"; `delete` is "delete for sure" - _ = wr(p, ctl, "dirty\n"); - try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno()); - try testing.expect(p.paneBySerial(serial) != null); - // ...and a second pane so the last one closing does not quit the editor - _ = look_up(p, @intFromEnum(TopFile.new), "body"); - try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status); - try testing.expect(p.paneBySerial(serial) == null); -} - -test "errors and cons append to one +Errors buffer per directory" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const serial = serialOf(p); - - const live = for (p.panes) |slot| { - if (slot) |q| if (q.file) |f| if (f.output) |o| if (std.meta.activeTag(o.from) == .errors) break q; - } else null; - try testing.expect(live == null); // "not until text is actually written" - - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "boom\n").reply.status); - _ = wr(p, @intFromEnum(TopFile.cons), "again\n"); - - var found: usize = 0; - for (p.panes) |slot| { - const q = slot orelse continue; - const f = q.file orelse continue; - const o = f.output orelse continue; - if (std.meta.activeTag(o.from) != .errors) continue; - found += 1; - try testing.expectEqualStrings("boom\nagain\n", f.content); - try testing.expectEqualStrings("/+Errors", f.path); - } - try testing.expectEqual(@as(usize, 1), found); -} - -test "setattr truncation empties the body and answers fresh attributes" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - - const a = call(p, .{ .tag = 7, .op = .setattr, .node = Node.of(serial, .body), .truncate = true }); - try testing.expectEqual(Status.ok, a.reply.status); - try testing.expectEqual(@as(u64, 0), a.reply.attr.size); - try testing.expectEqualStrings("", p.panes[0].?.file.?.content); - - // `> body` then a write is the shell's way of REPLACING a pane's text - _ = wr(p, Node.of(serial, .body), "new text\n"); - try testing.expectEqualStrings("new text\n", p.panes[0].?.file.?.content); - - // a setattr that sets no size changes nothing - const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) }); - try testing.expectEqual(@as(u64, 9), noop.reply.attr.size); -} - -test "event records are acme's bytes, one per read, and .again when empty" { - const gpa = testing.allocator; - const p = try withFile(gpa, "Msg fs-ran\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - - // nothing is recorded while nobody is listening - _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg "); - try testing.expect(p.fs.panes[0].events.empty()); - - const h = call(p, .{ .tag = 10, .op = .open, .node = event }); - try testing.expect(h.reply.handle != 0); - try testing.expectEqual(@as(u16, 1), p.fs.listeners); - - // an empty queue is `.again`: nothing consumed, ask me later. NEVER an - // error, and never a loop. - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); - - p.fs.origin = 'M'; - _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell"); - _ = noteAction(p, 0, .body_delete, 0, 3, 0, ""); - // `%c%c%d %d %d %d %s\n`, wind.c's winevent with the owner char in front - try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes); - try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes); - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); - - // one record per read: a read too small to hold one is refused rather - // than answered with half a record the reader cannot resynchronise from - _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one"); - try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno()); - try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes); - - // text of 256 bytes or more is elided; the reader fetches it from `data` - const big = "z" ** max_record_text; - _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big); - try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes); - - _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); -} - -/// Drain a queue into `store` and return the records. Reading is destructive -/// and a `.staged` answer is only valid until the next request, so each record -/// is copied out as it arrives. -fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 { - var used: usize = 0; - var n: usize = 0; - while (n < out.len) { - const a = rd(p, node, 0, 4096); - if (a.reply.status != .ok) break; - @memcpy(store[used..][0..a.bytes.len], a.bytes); - out[n] = store[used..][0..a.bytes.len]; - used += a.bytes.len; - n += 1; - } - return out[0..n]; -} - -test "a write through the filesystem is reported once, attributed to the file it came through" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - _ = call(p, .{ .tag = 40, .op = .open, .node = event }); - var store: [4096]u8 = undefined; - var slots: [16][]const u8 = undefined; - _ = drainEvents(p, event, &store, &slots); - - // A body write is acme's `E`: "writes to the body or tag file". ONE pair - // per write, because the diff lives in `file_pane.setContent` and a write - // is one content swap — that is the contract with the core's hook, and - // emitting records from the handler as well is what it forbids. - _ = wr(p, Node.of(serial, .body), "three\n"); - const body_recs = drainEvents(p, event, &store, &slots); - try testing.expect(body_recs.len >= 1); - // ...and the record's TEXT here contains a newline of its own, which is - // exactly why `Queue` frames records by length instead of by line - try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]); - // the write also made the pane dirty, so its TAG changed — and acme - // attributes that to the write too (`winsettag` runs inside the same - // `winlock(w, 'E')`), which is why the origin is set for the whole - // request and not just for the mutation. - for (body_recs[1..]) |r| { - try testing.expectEqual(@as(u8, 'E'), r[0]); - try testing.expect(Action.fromChar(r[1]).?.onTag()); - } - - // A `data` write is acme's `F`: "actions through the window's other - // files" — and a replacement is a delete then an insert, acme's order, - // with no text on the delete. - _ = wr(p, Node.of(serial, .addr), "1"); - _ = wr(p, Node.of(serial, .data), "ONE\n"); - const data_recs = drainEvents(p, event, &store, &slots); - try testing.expectEqual(@as(usize, 2), data_recs.len); - try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]); - try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]); - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); -} - -test "two event readers each count once, and the second closing leaves the first" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const event = Node.of(serialOf(p), .event); - - _ = call(p, .{ .tag = 12, .op = .open, .node = event }); - _ = call(p, .{ .tag = 13, .op = .open, .node = event }); - try testing.expectEqual(@as(u16, 2), p.fs.panes[0].readers); - try testing.expectEqual(@as(u16, 2), p.fs.listeners); - - // A release names the NODE, not a handle: FUSE carries the nodeid on every - // request, so there is no fid table to look one up in, and one release - // answers for one open. - _ = call(p, .{ .tag = 14, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].readers); - try testing.expect(p.fs.scripted(0)); // the pane is STILL script-driven - - // a release with nothing left to release changes nothing and is not an - // error, and neither is one naming a node that never counted - _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) }); - try testing.expectEqual(@as(u16, 1), p.fs.listeners); - - _ = call(p, .{ .tag = 16, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - try testing.expect(!p.fs.scripted(0)); - _ = call(p, .{ .tag = 17, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); -} - -test "a pane deleted while its event file is open leaves no suppression behind" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - // a second pane, so deleting the first does not quit the editor - _ = look_up(p, @intFromEnum(TopFile.new), "body"); - - const a = call(p, .{ .tag = 18, .op = .open, .node = event }); - const b = call(p, .{ .tag = 19, .op = .open, .node = event }); - try testing.expectEqual(@as(u16, 2), p.fs.listeners); - - _ = wr(p, Node.of(serial, .ctl), "delete\n"); - try testing.expect(p.paneBySerial(serial) == null); - // the core's `State.forget` took BOTH readers out with the pane - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - - // ...and the two late releases must not underflow it back to 65535, which - // would suppress every button action in the editor forever - _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); - _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - - // every operation on the dead pane is ENOENT — acme's Edel - try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); - try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno()); - try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); -} - -test "writing an event record back performs the action it names" { - const gpa = testing.allocator; - const p = try withFile(gpa, "Msg fs-ran\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - const pane = p.panes[0].?; - - // an `X` record over the body text `Msg fs-ran` is an Exec of it - const w = wr(p, event, "FX0 10\n"); - try testing.expectEqual(Status.ok, w.reply.status); - try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); - - // several records in one write - pane.msg_len = 0; - try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status); - try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); - - // ...and nothing applies when any of it is malformed: acme's Ebadevent - pane.msg_len = 0; - for ([_][]const u8{ - "FX0 10\nFQ0 1\n", // unknown type character - "FX0 999\n", // out of range - "FX0 10", // no newline - "FX5 1\n", // q0 > q1 - "FD0 3\n", // a report, not a request - "F\n", - }) |bad| { - try testing.expectEqual(E.INVAL, wr(p, event, bad).errno()); - try testing.expectEqual(@as(usize, 0), pane.msg_len); - } - - // The action is attributed to the FILESYSTEM (`F`), never to whatever the - // writer put in the record's origin character — acme copies that byte - // into `w->owner` and lets a script claim its Exec came from the - // keyboard. - _ = call(p, .{ .tag = 23, .op = .open, .node = event }); - p.fs.origin = 'K'; - _ = wr(p, event, "KX0 10\n"); - try testing.expectEqual(@as(u8, 'F'), p.fs.origin); -} - -test "a pane that is not a terminal has no pty/ at all" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - const dir = Node.of(serial, .dir); - - // ABSENT, not present-and-refusing: `-d $PARDES_FS//pty` is how a - // script asks whether a pane is a terminal. - try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno()); - try testing.expectEqual(E.NOENT, call(p, .{ - .tag = 1, - .op = .getattr, - .node = Node.of(serial, .pty), - }).errno()); - try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno()); - try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno()); - // ...and an OPEN too, so the reader count that gates the raw queue can - // never be armed on a pane that has no pty to produce bytes - try testing.expectEqual(E.NOENT, call(p, .{ - .tag = 2, - .op = .open, - .node = Node.of(serial, .pty_data), - }).errno()); - try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); - - // ...and the listing is byte for byte the ten entries it always was - var buf: [32]Dirent = undefined; - const files = dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); - try testing.expect(nameAt(files, "pty") == null); - - // the enum's spelling is not a name in the tree: `pty_ctl` is how the flat - // enum spells `pty/ctl`, and neither directory answers to it - try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); - try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno()); - - // `new/` makes a scratch, which can never be a terminal, so naming a pty - // file there creates nothing at all - const before = p.next_serial; - try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.new), "pty").errno()); - try testing.expectEqual(before, p.next_serial); -} - -test "a terminal pane's pty/ holds exactly ctl, status and data" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const serial = serialOf(p); - const dir = Node.of(serial, .dir); - - const pty = look_up(p, dir, "pty"); - try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node); - try testing.expect(pty.reply.attr.dir); - try testing.expectEqual(@as(u16, 0o500), pty.reply.attr.mode); - - var buf: [32]Dirent = undefined; - const files = dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 11), files.len); // the ten, plus pty - try testing.expect(nameAt(files, "pty").?.dir); - - const inside = dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); - try testing.expectEqual(@as(usize, 3), inside.len); - try testing.expectEqualStrings("ctl", inside[0].name); - try testing.expectEqualStrings("status", inside[1].name); - try testing.expectEqualStrings("data", inside[2].name); - for (inside) |d| try testing.expect(!d.dir); - // the ids a listing reports are the ids a lookup resolves - try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); - - // ...and the two namespaces do not leak into each other - const ctl = look_up(p, Node.of(serial, .pty), "ctl"); - try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node); - try testing.expectEqual(@as(u16, 0o200), ctl.reply.attr.mode); - try testing.expectEqual(@as(u16, 0o400), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno()); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno()); - - // a file is not a directory, on either side of the slash - try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno()); - try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno()); - // and the directory itself is not read(2)able, nor is a write-only file - try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno()); - try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno()); - try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno()); -} - -test "every pty/ctl verb, and every refusal" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const ctl = Node.of(serialOf(p), .pty_ctl); - - // winsize reaches the effect queue, and ONLY the pty: the grid belongs to - // the layout, so the pane's own cols/rows are untouched. - const pane = p.panes[0].?; - const cols = pane.cols; - const rows = pane.rows; - const ws = wr(p, ctl, "winsize 132 44\n"); - try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written); - try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols); - try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); - try testing.expectEqual(cols, pane.cols); - try testing.expectEqual(rows, pane.rows); - - // all five signal names, and no others - for ([_]struct { line: []const u8, want: pardes.PtySignal }{ - .{ .line = "sig INT", .want = .int }, - .{ .line = "sig TERM", .want = .term }, - .{ .line = "sig HUP", .want = .hup }, - .{ .line = "sig QUIT", .want = .quit }, - .{ .line = "sig KILL", .want = .kill }, - }) |c| { - const a = wr(p, ctl, c.line); - try testing.expectEqual(Status.ok, a.reply.status); - try testing.expectEqual(c.want, a.signal.?); - } - - // exec respawns the shell: the same effect `newShell` emits - const ex = wr(p, ctl, "exec\n"); - try testing.expectEqual(Status.ok, ex.reply.status); - try testing.expect(ex.spawned); - - // several verbs in one write, no trailing newline needed - const both = wr(p, ctl, "winsize 100 30\nsig TERM"); - try testing.expectEqual(@as(u16, 100), both.winsize.?.cols); - try testing.expectEqual(pardes.PtySignal.term, both.signal.?); - - // ...and EVERY malformed line refuses the WHOLE batch, so the good verb - // beside it never reached the queue. Two passes, one applied. - for ([_][]const u8{ - "winsize", // no arguments - "winsize 80", // one argument - "winsize 80 24 extra", // three - "winsize 0 24", // zero is "unknown", never a width - "winsize 80 0", - "winsize -1 24", // not a decimal - "winsize 999999 24", // wider than a u16 - "sig", // no name - "sig INT TERM", // two - "sig SIGINT", // the prefix `kill` dropped in 1988 - "sig int", // lower case - "sig 9", // a number is one platform's number - "sig USR1", // a real signal, deliberately not offered - "exec /bin/sh", // the effect carries no argv; refused, never ignored - "raw", // the draft's TCSETS line, which the core cannot answer - "cooked", - "winsize 80 24\nbogus", // a good verb beside a bad one - "bogus\nwinsize 80 24", - "name x", // a `ctl` verb; the two files share no vocabulary - "del", - }) |bad| { - const a = wr(p, ctl, bad); - try testing.expectEqual(E.INVAL, a.errno()); - try testing.expect(a.winsize == null); - try testing.expect(a.signal == null); - try testing.expect(!a.spawned); - } - - // blank lines and surrounding space are not verbs and not errors - const spaced = wr(p, ctl, "\n winsize 90 20 \n\n"); - try testing.expectEqual(Status.ok, spaced.reply.status); - try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols); - // an empty write is a write of nothing - try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status); -} - -/// A host that answers `pull_tty_taken` and nothing else, so `pty/status`'s -/// third field can be tested with no pty anywhere. The same shape -/// `pardes.zig`'s own `FakeTtyQuery` has, spelled again here because that one -/// is private to its own tests. -const FakeTty = struct { - taken: bool, - - const vtable: pardes.Host.VTable = .{ .pull_tty_taken = answer }; - - fn answer(ctx: ?*anyopaque, pane: u8) bool { - _ = pane; - const f: *FakeTty = @ptrCast(@alignCast(ctx.?)); - return f.taken; - } -}; - -test "pty/status reports the grid and who holds the tty" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const pane = p.panes[0].?; - const status = Node.of(pane.serial, .pty_status); - - const a = rd(p, status, 0, 256); - try testing.expectEqual(Status.ok, a.reply.status); - var want: [64]u8 = undefined; - const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); - try testing.expectEqualStrings(whole, a.bytes); - // three `%11d ` fields, like `ctl` and `index`, and seekable like both. - // The expectation is compared against `want` and not against `a.bytes`, - // which the NEXT request's staging invalidates — the borrow window this - // whole module is built on. - try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); - try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); - - // the third field is `pull_tty_taken`, the probe the core already has - var probe: FakeTty = .{ .taken = true }; - p.host = .{ .ctx = &probe, .vtable = &FakeTty.vtable }; - const held = rd(p, status, 0, 256); - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), - held.bytes, - ); -} - -test "pty/data writes at the shell and reads the raw stream" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const serial = serialOf(p); - const data = Node.of(serial, .pty_data); - - // WRITE is a pty write, exactly as a body write to a terminal is, and the - // offset is ignored because a stream has none - const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" }); - try testing.expectEqual(@as(u32, 6), w.reply.written); - try testing.expectEqualStrings("ls -l\r", w.pty()); - // short at a character boundary, never split, never zero for real bytes - try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written); - try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written); - - // READ blocks — `.again`, nothing consumed — while there is nothing there - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - // THE READER COUNT IS THE GATE: output arriving at a pane nobody is - // reading is not recorded, so the queue stays empty and the pane pays - // nothing for a filesystem it is not using. - p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - _ = call(p, .{ .tag = 5, .op = .open, .node = data }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); - // ...and it is NOT the event-suppression gate: reading a terminal's output - // is not claiming the pane's buttons. - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - try testing.expect(!p.fs.scripted(0)); - - p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - // UNFRAMED: a read smaller than one arrival is served and the remainder - // kept, because raw pty bytes have no records to split down the middle. - // `event` refuses exactly this read; that is the difference, on purpose. - p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes); - try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes); - // ...and a read SPANS arrivals, which one read(2) on the pty would too - p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes); - - // the LAST reader leaving gives the memory back and drops what is stale - p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } }); - while (p.nextEffect()) |_| {} - _ = call(p, .{ .tag = 6, .op = .release, .node = data }); - try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); - try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - // two readers: the second closing leaves the first still recording - _ = call(p, .{ .tag = 7, .op = .open, .node = data }); - _ = call(p, .{ .tag = 8, .op = .open, .node = data }); - _ = call(p, .{ .tag = 9, .op = .release, .node = data }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); - p.update(.{ .output = .{ .pane = 0, .bytes = "still" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); -} - -test "the pty queue drops the oldest at its cap" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const data = Node.of(serialOf(p), .pty_data); - _ = call(p, .{ .tag = 5, .op = .open, .node = data }); - - // A script that opens the file and stops reading must BOUND the editor, - // not grow it. The oldest arrivals go; a reader that fell this far behind - // has lost the thread anyway and can re-read `body` to resynchronise. - const oldest: [4096]u8 = @splat('A'); - const rest: [4096]u8 = @splat('B'); - notePtyOutput(p, 0, &oldest); - for (0..queue_cap / rest.len + 4) |_| notePtyOutput(p, 0, &rest); - // LIVE bytes, not the buffer: `Queue` pops by moving `head` and reclaims - // the space lazily (`compact`), so the allocation trails the contents by - // design and the cap is a bound on what is still owed to a reader. - const q = &p.fs.panes[0].pty_out; - try testing.expect(q.buf.items.len - q.head <= queue_cap); - - var seen: usize = 0; - while (true) { - const a = rd(p, data, 0, 1 << 16); - if (a.reply.status == .again) break; - try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null); - if (a.bytes.len == 0) break; - seen += a.bytes.len; - } - try testing.expect(seen > 0 and seen <= queue_cap); -} diff --git a/src/allocators.zig b/src/allocators.zig deleted file mode 100644 index 61c55f39..00000000 --- a/src/allocators.zig +++ /dev/null @@ -1,104 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const limits = @import("limits.zig"); - -const Allocator = std.mem.Allocator; -const debug_enabled = builtin.mode == .Debug; - -pub const Allocators = struct { - pardes: Allocator, - frame: Allocator, - lsp: Allocator, - tree_sitter: Allocator, - image: Allocator, - pdf: Allocator, -}; - -/// The static reservations, one per profile tier. See `limits.arena` for why -/// each number is what it is, and why the board's are 4 KiB and zero. -var pardes_fallback: std.heap.StackFallbackAllocator(limits.arena.pardes) = undefined; -var frame_fallback: std.heap.StackFallbackAllocator(limits.arena.frame) = undefined; -var tree_sitter_fallback: std.heap.StackFallbackAllocator(limits.arena.tree_sitter) = undefined; -var image_fallback: std.heap.StackFallbackAllocator(limits.arena.image) = undefined; -var pdf_fallback: std.heap.StackFallbackAllocator(limits.arena.pdf) = undefined; - -const Debug = std.heap.DebugAllocator(.{}); -var pardes_debug: Debug = .init; -var frame_debug: Debug = .init; -var lsp_debug: Debug = .init; -var tree_sitter_debug: Debug = .init; -var image_debug: Debug = .init; -var pdf_debug: Debug = .init; - -/// Returns ordinary allocators backed by stdlib fixed-buffer fallbacks. LSP -/// keeps the caller's allocator because its detached workers are concurrent. -pub fn init(fallback: Allocator) Allocators { - pardes_fallback.fallback_allocator = fallback; - pardes_fallback.get_called = if (std.debug.runtime_safety) false else {}; - frame_fallback.fallback_allocator = fallback; - frame_fallback.get_called = if (std.debug.runtime_safety) false else {}; - tree_sitter_fallback.fallback_allocator = fallback; - tree_sitter_fallback.get_called = if (std.debug.runtime_safety) false else {}; - image_fallback.fallback_allocator = fallback; - image_fallback.get_called = if (std.debug.runtime_safety) false else {}; - pdf_fallback.fallback_allocator = fallback; - pdf_fallback.get_called = if (std.debug.runtime_safety) false else {}; - - const raw: Allocators = .{ - .pardes = pardes_fallback.get(), - .frame = frame_fallback.get(), - .lsp = fallback, - .tree_sitter = tree_sitter_fallback.get(), - .image = image_fallback.get(), - .pdf = pdf_fallback.get(), - }; - if (!debug_enabled) return raw; - - pardes_debug = .{ .backing_allocator = raw.pardes }; - frame_debug = .{ .backing_allocator = raw.frame }; - lsp_debug = .{ .backing_allocator = raw.lsp }; - tree_sitter_debug = .{ .backing_allocator = raw.tree_sitter }; - image_debug = .{ .backing_allocator = raw.image }; - pdf_debug = .{ .backing_allocator = raw.pdf }; - return .{ - .pardes = pardes_debug.allocator(), - .frame = frame_debug.allocator(), - .lsp = lsp_debug.allocator(), - .tree_sitter = tree_sitter_debug.allocator(), - .image = image_debug.allocator(), - .pdf = pdf_debug.allocator(), - }; -} - -pub fn deinit() void { - if (!debug_enabled) return; - var leaked = pardes_debug.deinit() == .leak; - leaked = (frame_debug.deinit() == .leak) or leaked; - leaked = (lsp_debug.deinit() == .leak) or leaked; - leaked = (tree_sitter_debug.deinit() == .leak) or leaked; - leaked = (image_debug.deinit() == .leak) or leaked; - leaked = (pdf_debug.deinit() == .leak) or leaked; - if (leaked) @panic("allocator leaks detected"); -} - -test "fixed allocators are separate, spill, and restart" { - var allocs = init(std.testing.allocator); - const core = try allocs.pardes.alloc(u8, 32); - const frame = try allocs.frame.alloc(u8, 32); - try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(core.ptr)); - try std.testing.expect(frame_fallback.fixed_buffer_allocator.ownsPtr(frame.ptr)); - try std.testing.expect(core.ptr != frame.ptr); - - const spill = try allocs.pardes.alloc(u8, limits.arena.pardes + 1); - try std.testing.expect(!pardes_fallback.fixed_buffer_allocator.ownsPtr(spill.ptr)); - allocs.pardes.free(spill); - allocs.frame.free(frame); - allocs.pardes.free(core); - deinit(); - - allocs = init(std.testing.allocator); - defer deinit(); - const restarted = try allocs.pardes.alloc(u8, 32); - defer allocs.pardes.free(restarted); - try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(restarted.ptr)); -} diff --git a/src/animation.zig b/src/animation.zig deleted file mode 100644 index c22396b8..00000000 --- a/src/animation.zig +++ /dev/null @@ -1,189 +0,0 @@ -//! Small, backend-neutral fixed-step animations. -//! -//! A transition always interpolates from its saved endpoints. It never folds -//! the rounded value from one frame into the next, so channels are monotonic, -//! completion is exact, and a different backend cadence cannot accumulate a -//! different rounding error. Values opt in by providing -//! `interpolate(from, to, step, steps)`. -const std = @import("std"); - -/// Frontends aim for one animation step per display frame. Ten 16 ms steps is -/// deliberately short: enough to make a palette change legible without -/// turning theme browsing into something the user has to wait through. -pub const frame_ms: u32 = 16; -pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; -pub const transition_steps: u16 = 10; - -pub fn Transition(comptime Value: type) type { - return struct { - const Self = @This(); - - from: Value, - to: Value, - displayed: Value, - step: u16 = transition_steps, - - pub fn init(value: Value) Self { - return .{ .from = value, .to = value, .displayed = value }; - } - - pub fn isActive(a: *const Self) bool { - return a.step < transition_steps; - } - - /// Begin again from the value on screen, not the old target. This is - /// what makes a mid-flight retarget continuous. - pub fn retarget(a: *Self, target: Value) void { - a.from = a.displayed; - a.to = target; - a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; - if (a.step == transition_steps) a.displayed = target; - } - - pub fn advance(a: *Self) void { - if (!a.isActive()) return; - a.step += 1; - // Assign the endpoint directly. Besides documenting the contract, - // this keeps exact completion independent of an interpolator's - // internal rounding choices. - a.displayed = if (a.step == transition_steps) - a.to - else - Value.interpolate(a.from, a.to, a.step, transition_steps); - } - - /// Initialization and dump restore use snap: their first frame is the - /// selected theme, never an animation from a compiled-in default. - pub fn snap(a: *Self, value: Value) void { - a.* = init(value); - } - }; -} - -/// `Transition`'s interface with the animation taken OUT: a value that is only ever the one it was -/// last set to. -/// -/// This exists so that a build which never fades does not carry the machinery for fading. A runtime -/// flag around the same `Transition` cannot achieve that - the endpoints stay in the struct and -/// `Value.interpolate` stays in the binary, reachable and therefore emitted. Selecting a different -/// type at comptime is what makes the interpolator genuinely unreachable, and on a target whose whole -/// display is a 115200-baud serial line, absent code and unspent frames are the same saving twice. -/// -/// Every method here is the trivial one, and `retarget` is deliberately `snap` rather than an error: -/// callers ask for a new palette and get it, on the next frame, in one step. Nothing about the -/// interface says how many frames the arrival takes. -pub fn Immediate(comptime Value: type) type { - return struct { - const Self = @This(); - - displayed: Value, - - pub fn init(value: Value) Self { - return .{ .displayed = value }; - } - - pub fn isActive(_: *const Self) bool { - return false; - } - - pub fn retarget(a: *Self, target: Value) void { - a.displayed = target; - } - - pub fn advance(_: *Self) void {} - - pub fn snap(a: *Self, value: Value) void { - a.displayed = value; - } - }; -} - -/// Linear RGB interpolation with nearest-integer rounding. The weighted-sum -/// form stays unsigned for both rising and falling channels. -pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { - if (step == 0) return from; - if (step >= steps) return to; - var out: [3]u8 = undefined; - for (&out, from, to) |*dst, a, b| { - const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; - dst.* = @intCast((numerator + steps / 2) / steps); - } - return out; -} - -const TestColor = struct { - rgb: [3]u8, - - pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { - return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; - } -}; - -// The substitute has to be interchangeable, and the property that matters is the one a caller could -// otherwise get wrong: it must arrive at the SAME palette a completed fade arrives at. A fade whose -// endpoint differed by a rounding step would make the build option a visible change of colors rather -// than a change of how long they take. -test "Immediate lands where a completed Transition lands" { - const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; - const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; - - var faded = Transition(TestColor).init(from); - faded.retarget(to); - for (0..transition_steps) |_| faded.advance(); - - var instant = Immediate(TestColor).init(from); - try std.testing.expect(!instant.isActive()); - instant.retarget(to); - try std.testing.expectEqual(faded.displayed, instant.displayed); - - // Never active, so a frontend that renders only while something is animating stops immediately - // rather than spending ten frames discovering there is nothing to draw. - try std.testing.expect(!instant.isActive()); - instant.advance(); - try std.testing.expectEqual(to, instant.displayed); - - instant.snap(from); - try std.testing.expectEqual(from, instant.displayed); -} - -test "fixed-step interpolation has exact monotonic endpoints" { - const Tween = Transition(TestColor); - const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; - const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; - var tween = Tween.init(from); - tween.retarget(to); - try std.testing.expectEqual(from, tween.displayed); - - var previous = tween.displayed; - for (0..transition_steps) |_| { - tween.advance(); - try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); - try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); - try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); - previous = tween.displayed; - } - try std.testing.expect(!tween.isActive()); - try std.testing.expectEqual(to, tween.displayed); - tween.advance(); - try std.testing.expectEqual(to, tween.displayed); -} - -test "retarget starts at the currently displayed value" { - const Tween = Transition(TestColor); - const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; - const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; - const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; - var tween = Tween.init(first); - tween.retarget(second); - tween.advance(); - tween.advance(); - tween.advance(); - const on_screen = tween.displayed; - - tween.retarget(third); - try std.testing.expectEqual(on_screen, tween.from); - try std.testing.expectEqual(on_screen, tween.displayed); - try std.testing.expect(tween.isActive()); - for (0..transition_steps) |_| tween.advance(); - try std.testing.expectEqual(third, tween.displayed); -} diff --git a/src/board9p.zig b/src/board9p.zig deleted file mode 100644 index b5b18bf2..00000000 --- a/src/board9p.zig +++ /dev/null @@ -1,874 +0,0 @@ -//! THE BOARD AS A FILESYSTEM, generated from a comptime table of what the board can do. -//! -//! The ESP32-P4 already exposes its pads and its address space — by TYPING A WORD into a tag. -//! `Gpio 20` flips a pin and prints `GPIO 20: 0->1`, `Gpio` alone draws JP1, `Peek`, `Poke` and -//! `Hexdump` reach all 2³² addresses (`src/board_memory.zig`), and every one of the four caps at -//! 4,096 bytes because the answer has to fit down a 115200-baud console. Nothing about that is -//! machine-readable and nothing about it is remote: the answer lands in an output pane, for a person -//! to read (`docs/registry.typ` `9P-11`, review note). -//! -//! This file is that same capability WITH NAMES INSTEAD OF VERBS. `cat gpio/pinout` is `Gpio`; -//! `echo 1 > gpio/20/value` is `Gpio 20`, except that it says which level it wants instead of asking -//! for whichever one it is not. A shell pipeline can do it, a script on a laptop can do it over the -//! UART, and neither needs a terminal emulator or a pane. -//! -//! WHY A TABLE, which is the whole design and not a flourish. A hand-written tree is a `Node` -//! packing, a `lookup`, a `getattr`, a `readdir`, a `read` and a `write` — six places that have to -//! agree about what exists — and the cost of adding `uptime` to it is an edit to all six plus a new -//! node id nobody else is using. The board's capabilities are a LIST, they will grow, and the entry -//! that describes one should be the only place it is described. So `caps` below is the tree: the -//! directories, the files, the per-pin fan-out, the permissions, the handlers and even the size of -//! the answer buffer are all derived from it at comptime, and the six functions at the bottom read -//! the derived table and know nothing about GPIO at all. -//! -//! WHAT A SECOND CAPABILITY COSTS, entry by entry, because "extensible" is a claim and this is the -//! evidence for it. Not implemented here — none of them is needed to serve a pin — but each is one -//! `Cap` and its handlers, and NO tree code: -//! -//! * `mem/` — `peek` and `poke` over `board_memory.readWord`/`writeWord` -//! (`src/board_memory.zig:136-144`), which are four lines of `*allowzero volatile` and already -//! compile for this target. `poke` is a WRITE handler that parses ` `, so -//! it needs `Fault.Malformed` and nothing else; `peek` needs an address to read, which a -//! stateless file cannot carry, so it is either a write-then-read pair (`echo 4ff40000 > addr; -//! cat word`, one more file and one `u32` of state) or a fan over a comptime list of interesting -//! registers. The second is free: `fan` below already generates a directory per key. -//! * `hexdump` — the same, with `scratch = 4096`: the one field that makes the shared answer -//! buffer grow, and the reason that field is in the table rather than a constant at the top. -//! * `prof` — three cycle counts from `pardes_esp32p4_frame_prof`, which the editor object already -//! exports (`src/esp32p4.zig:985`). It is the one capability that is NOT available in this -//! image: that symbol lives in the pardes object and the 9P image links none, so serving it -//! would mean either linking the editor or moving the counters. Worth saying out loud rather -//! than listing it as cheap. -//! * `uptime` and `heap` — `hal.systimer` and the heap's own free count, both of which the -//! runtime (`src/esp32p4_9p.zig`) can reach today. Two read handlers, `scratch = 24`, one -//! `Cap` each. These are the cheapest of the four and the reason the table's `board` parameter -//! is a TYPE rather than a pair of function pointers: adding `board.uptimeMs()` to the seam -//! adds a capability without changing anything here but the table. -//! -//! THE ABI IS `acmefs`'s, VERBATIM — `Op`, `Status`, `Req`, `Reply`, `Reply.Attr` with the same -//! fields and the same meanings — so `src/9p.zig`'s `Server` serves this tree with no translation -//! layer, exactly as it serves the editor's. That is the point of `Server` being a generic over the -//! filesystem rather than an importer of one (`src/9p.zig:1994-2010`), and it is what makes a board -//! image possible at all: `acmefs.zig` reaches `pardes.zig` and the whole core, and this file -//! reaches `std` and one leaf table. -//! -//! NO ALLOCATOR, NO OS, ONE REQUEST AT A TIME. Same rules as `acmefs`: `handle(req) -> Answer` is a -//! pure transaction, the answer's bytes are either `.rodata` or the one shared buffer, and they are -//! borrowed until the next call. Nothing here blocks, so `Status.again` never appears — the board -//! has no `event` file and no reader to park. -const std = @import("std"); -const board_pins = @import("board_pins.zig"); - -/// The errno values this tree returns. `acmefs.E`'s subset — the four a tree with no panes, no -/// blocking and no allocation can produce — with the same numbers, because they are Linux's and a -/// second spelling would be a second thing to check against `9p.errString`. -pub const E = struct { - pub const NOENT: u16 = 2; - pub const IO: u16 = 5; - pub const NOTDIR: u16 = 20; - pub const INVAL: u16 = 22; -}; - -/// How a HANDLER refuses, as against how the tree refuses. The tree answers ENOENT and ENOTDIR -/// itself, out of the table, before any handler runs; this is the set of things only the handler can -/// know. -/// -/// One variant today, and it is the honest count: a pad takes `0` or `1` and nothing else. A -/// capability that can refuse for a second reason adds a variant here and a prong to `errnoOf`, -/// which is the whole of what "another kind of no" costs. -pub const Fault = error{ - /// the bytes offered are not a value this file takes - Malformed, -}; - -/// The one place a `Fault` becomes a number. -fn errnoOf(f: Fault) u16 { - return switch (f) { - error.Malformed => E.INVAL, - }; -} - -/// A file's two halves, as POINTERS rather than function types: the derived table below is an -/// ordinary runtime array, and a struct holding a bare `fn` is comptime-only. -/// -/// `key` says which pad, address or counter the call is about, and `out` is the slice of the shared -/// answer buffer this file's table entry declared — exactly `scratch` bytes, so a handler cannot -/// write past its own budget. A read may also ignore `out` entirely and answer out of `.rodata`, -/// which is what the JP1 drawing does. -const ReadFn = *const fn (key: u16, out: []u8) Fault![]const u8; -const WriteFn = *const fn (key: u16, bytes: []const u8) Fault!u32; - -/// One FILE in the table. `key` is not here: it comes from the directory the file is generated -/// into, which is what makes one entry serve eleven pins. -/// -/// The MODE is derived, never declared: a file with both handlers is 0o600, a read handler alone is -/// 0o400, a write handler alone is 0o200, and neither is a compile error. A declared mode is a -/// fourth thing that can disagree with the three that decide it. -pub const FileSpec = struct { - name: []const u8, - read: ?ReadFn = null, - write: ?WriteFn = null, - /// Bytes of the shared answer buffer this file's read needs. ZERO when the read answers out of - /// `.rodata` and copies nothing, which is what `gpio/pinout` does — the JP1 drawing is 468 - /// bytes of static text and there is no reason to stage it. The largest `scratch` in the table - /// is one of the two numbers that size `Tree.out`. - scratch: u32 = 0, -}; - -/// One generated subdirectory of a capability, and its KEY: the pad, address or counter every file -/// inside it is about. `gpio/20/value` is `key = 20`. -pub const FanDir = struct { key: u16, name: []const u8 }; - -/// A capability's fan-out: one directory per key, each holding the same files. THE REASON the tree -/// has exactly the pins this board has — the dirs are collected from `board_pins.gpio_pins`, which -/// is collected from the JP1 rows, which are the schematic. -pub const Fan = struct { dirs: []const FanDir, files: []const FileSpec }; - -/// ONE CAPABILITY = ONE DIRECTORY under the root. Always a directory, even for a capability with a -/// single file: a flat root would put every capability's names in one u4 (see `block` below) and -/// would make `ls /` a list of files whose grouping a reader has to infer. `ls /` here is the list -/// of things this board can do. -pub const Cap = struct { - name: []const u8, - files: []const FileSpec = &.{}, - fan: ?Fan = null, -}; - -/// One node of the derived tree. Flat, because a table of fifteen entries scanned linearly is -/// faster than any structure with pointers in it and is the same shape `src/9p.zig`'s own test stub -/// uses — and because a scan cannot disagree with itself about what the tree contains. -const Entry = struct { - node: u64, - /// Where `..` goes. See `block`: this is also the value `src/9p.zig`'s `parentOf` derives from - /// the node id, for every entry but a fan leaf, and the test at the bottom asserts it. - parent: u64, - name: []const u8, - dir: bool, - mode: u16, - /// the pad this file is about, or zero - key: u16 = 0, - read: ?ReadFn = null, - write: ?WriteFn = null, - scratch: u32 = 0, -}; - -/// THE NODE ID PACKING, and it is not ours: it is `acmefs.Node`'s, `{ file: u4, serial: u60 }`, -/// because `src/9p.zig:1955` `parentOf` READS node ids to answer `..` and has that packing built in. -/// A tree that numbered its nodes freely would get a wrong answer to `cd ..` and no diagnostic. -/// -/// The rule, restated as arithmetic: a node's parent is the node rounded down to a multiple of 16, -/// except that a node already at a multiple of 16 — or below 16 — is a child of the root. -/// -/// * the root is 1: serial 0, so `..` is itself, which is POSIX's rule and `intro(5)`'s. -/// * a capability directory is its own BLOCK BASE, `(index + 1) * 16`, so its `..` is the root. -/// * everything inside a capability — its files AND its fan directories — is a member of that -/// block, `base + 1 .. base + 15`, so their `..` is the capability directory. Correct, which is -/// what matters for the one `..` a client actually performs: `cd /gpio/20; cd ..`. -/// * a fan LEAF (`gpio/20/value`) cannot be expressed. Its parent is a block member, and -/// `parentOf` can only produce block bases. So leaves get blocks of their own, above every -/// capability's, and `..` from one lands on an unallocated block base, which this tree answers -/// ENOENT. That is the honest failure: a walk that cannot be expressed is refused rather than -/// silently landing on a different file. No client does it — `..` from a file requires having -/// walked INTO a file, and a file is not a directory — and the fix, if one is ever wanted, is a -/// `parent` hook on `Server` so a filesystem deeper than two levels answers `..` itself. That -/// is exactly the wall `parentOf`'s own doc comment says it is (`src/9p.zig:1950-1954`), and -/// `acmefs`'s `pty/` subtree stands on the same side of it today. -const block: u64 = 16; - -/// The root, and the value the runtime hands `Server.init` as `Options.root`. One, for the same -/// reason `acmefs.TopFile.root` is one: node 0 is `{ file: 0, serial: 0 }` and cannot be a root -/// (`src/9p.zig:2190-2192`). -pub const root: u64 = 1; - -/// Every key the GPIO fan generates, re-exported for the RUNTIME's benefit: `src/esp32p4_9p.zig` -/// checks at comptime that each one is a pad `hal.gpio` will accept, which is the one thing this -/// file cannot check for itself — `max_pin` is a property of the chip package and lives in the -/// toolchain repository, and importing it here would make the tree unbuildable on a host. -pub const pins = board_pins.gpio_pins; - -/// The board's own tree, over a `board` seam the runtime supplies. -/// -/// GENERIC over the board for exactly the reason `Server` is generic over the filesystem: the pads -/// are four register files behind `hal.gpio` in the toolchain package, which exists only for -/// riscv32, and a tree that imported it could not be tested on a host at all. The seam is two -/// functions, both about the level the board is DRIVING: -/// -/// * `board.level(pin: u8) u1` -/// * `board.drive(pin: u8, level: u1) void` -/// -/// `src/esp32p4_9p.zig` implements them over `hal.gpio`, in the same four calls -/// `src/esp32p4/app.zig:200-209` uses for the `Gpio` word — the same seam, a second caller, not a -/// second copy of the register sequence. The tests below implement them over a recording stub, the -/// way `src/9p.zig`'s server tests implement a filesystem. -pub fn Tree(comptime board: type) type { - return struct { - const Self = @This(); - - // -- the ABI, which is `acmefs`'s ------------------------------------ - // - // A MIRROR, not a redefinition: `Server(acmefs)` is the instantiation that proves the - // shape, and a field that drifts from it is a compile error the moment `Server(Tree(...))` - // is built — which the tests at the bottom do. - - pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir, statfs }; - - /// `again` is here because the ABI has it, and it never occurs: nothing on this board - /// blocks. The board's answer to "what is this pin at" is a register read. - pub const Status = enum(u8) { ok, again, err }; - - pub const Req = struct { - tag: u64, - op: Op, - node: u64, - handle: u32 = 0, - off: u64 = 0, - size: u32 = 0, - data: []const u8 = &.{}, - truncate: bool = false, - }; - - pub const Reply = struct { - tag: u64, - status: Status = .ok, - errno: u16 = 0, - attr: Attr = .{}, - handle: u32 = 0, - written: u32 = 0, - - pub const Attr = struct { - node: u64 = 0, - dir: bool = false, - size: u64 = 0, - mode: u16 = 0o600, - }; - - /// `acmefs.Reply.fail`'s twin, so a refusal is one expression here as it is there. - pub fn fail(tag: u64, e: u16) Reply { - return .{ .tag = tag, .status = .err, .errno = e }; - } - }; - - /// A reply and the bytes it points at, borrowed until the next `handle`. `Server.reply` - /// takes exactly this pair. - pub const Answer = struct { reply: Reply, bytes: []const u8 = "" }; - - // -- the handlers ---------------------------------------------------- - - /// `gpio/pinout` — JP1, as the `Gpio` word draws it, TO THE BYTE. The same - /// `board_pins.jp1_text` the word prints (`src/board_memory.zig:364`), returned out of - /// `.rodata` rather than staged, so this read costs no buffer and no copy. - fn readPinout(_: u16, _: []u8) Fault![]const u8 { - return board_pins.jp1_text; - } - - /// `gpio//value` — the level this board is DRIVING on pad `n`, as `0` or `1` and a - /// newline. - /// - /// THE DRIVEN LEVEL and not the pad's, for the reason `src/esp32p4/app.zig:196-199` gives: - /// the pad's own level is what the outside world says, and on an unconnected header pin that - /// is noise. The driven level is defined for every pin, which is what a file that a script - /// reads in a loop needs. - /// - /// The trailing newline is not decoration: `cat gpio/20/value` in a terminal and `$(cat - /// ...)` in a script both want it, and the write side accepts it back, so `cp` of one pin's - /// value onto another's is a legal round trip. - fn readValue(key: u16, out: []u8) Fault![]const u8 { - out[0] = '0' + @as(u8, board.level(@intCast(key))); - out[1] = '\n'; - return out[0..2]; - } - - /// `gpio//value` — drive pad `n` to `0` or `1`. - /// - /// WRITING THE OPPOSITE OF THE CURRENT LEVEL IS THE `Gpio` WORD'S TOGGLE, through the same - /// seam; writing the level it is already at is not a no-op, because the FIRST write to a pad - /// is what makes it an output at all (`hal.gpio.configureOutput`, four register files). So - /// this always drives, and `echo 0 > value` on a fresh boot is a meaningful command: it - /// takes the pad off whatever the IO MUX had it pointed at and holds it low. - /// - /// `0`, `1`, `0\n` and `1\n` are the whole language. Anything else is EINVAL, including - /// `true`, `high`, `01` and the empty write — a file whose only two values are one character - /// each has no room for a spelling debate, and guessing at `on` would be the beginning of - /// one. - fn writeValue(key: u16, bytes: []const u8) Fault!u32 { - const want = try oneBit(bytes); - board.drive(@intCast(key), want); - // The whole write is consumed, trailing newline included: a short count would make - // `echo` retry the tail and drive the pin a second time. - return @intCast(bytes.len); - } - - /// `0` or `1`, with at most one trailing newline (and the `\r` a Windows-ish client may put - /// in front of it). Nothing else. - fn oneBit(bytes: []const u8) Fault!u1 { - var end = bytes.len; - while (end > 0 and (bytes[end - 1] == '\n' or bytes[end - 1] == '\r')) end -= 1; - if (end != 1) return error.Malformed; - return switch (bytes[0]) { - '0' => 0, - '1' => 1, - else => error.Malformed, - }; - } - - // -- THE TABLE ------------------------------------------------------- - - /// The pin directories, one per P4 GPIO the header brings out, named by the pin number in - /// DECIMAL — the number the schematic, the silkscreen and the datasheet all use, and the one - /// literal in `board_memory.zig` that is not hex (`:394-399`). Generated from - /// `board_pins.gpio_pins`, so this list cannot contain a pin JP1 does not have. - const gpio_dirs = dirs: { - var out: [board_pins.gpio_pins.len]FanDir = undefined; - for (board_pins.gpio_pins, 0..) |pin, i| out[i] = .{ - .key = pin, - .name = std.fmt.comptimePrint("{d}", .{pin}), - }; - break :dirs out; - }; - - /// EVERYTHING THIS BOARD OFFERS, and the only place any of it is described. The tree, the - /// permissions, the handlers, the node ids and the answer buffer all come out of here. - const caps = [_]Cap{ - .{ - .name = "gpio", - .files = &.{ - .{ .name = "pinout", .read = readPinout }, - }, - .fan = .{ - .dirs = &gpio_dirs, - .files = &.{ - .{ .name = "value", .read = readValue, .write = writeValue, .scratch = 2 }, - }, - }, - }, - }; - - /// How many nodes the table generates, counted separately because it is an array length. - const node_count = count: { - var n: usize = 1; // the root - for (caps) |c| { - n += 1 + c.files.len; - if (c.fan) |f| n += f.dirs.len * (1 + f.files.len); - } - break :count n; - }; - - /// THE DERIVED TREE. Built once at comptime and `const`, so it lands in `.rodata` and costs - /// the image its bytes and the board's RAM nothing. - const table: [node_count]Entry = build: { - var out: [node_count]Entry = undefined; - out[0] = .{ .node = root, .parent = root, .name = "/", .dir = true, .mode = 0o500 }; - var at: usize = 1; - // Blocks 1..caps.len are the capability directories; fan leaves take the ones above, - // which is what keeps a leaf's unexpressible parent from landing on a real node. - var next_block: u64 = caps.len + 1; - for (caps, 0..) |c, ci| { - const dir_node = (ci + 1) * block; - out[at] = .{ .node = dir_node, .parent = root, .name = c.name, .dir = true, .mode = 0o500 }; - at += 1; - // The u4 in the node id, spent one per name inside this capability. Directories and - // files come out of the same fifteen, which is the wall `acmefs.PaneFile`'s doc - // comment describes from the other side. - var slot: u64 = 1; - for (c.files) |f| { - out[at] = fileEntry(dir_node + slot, dir_node, f, 0); - at += 1; - slot += 1; - } - if (c.fan) |fan| for (fan.dirs) |d| { - const fan_node = dir_node + slot; - slot += 1; - out[at] = .{ .node = fan_node, .parent = dir_node, .name = d.name, .dir = true, .mode = 0o500 }; - at += 1; - const leaf_base = next_block * block; - next_block += 1; - for (fan.files, 0..) |f, l| { - out[at] = fileEntry(leaf_base + 1 + l, fan_node, f, d.key); - at += 1; - } - }; - if (slot >= block) @compileError( - "capability '" ++ c.name ++ - "' has more than 15 names in it, and a node id has four bits for them:" ++ - " `acmefs.Node.file` is a u4 and `9p.parentOf` reads it. Split it into two" ++ - " capabilities, or widen the packing in acmefs.zig, 9p.zig and here at once.", - ); - } - break :build out; - }; - - /// One file's entry, with the mode derived from which handlers it has. - fn fileEntry(node: u64, parent: u64, f: FileSpec, key: u16) Entry { - const mode: u16 = if (f.read != null and f.write != null) - 0o600 - else if (f.read != null) - 0o400 - else if (f.write != null) - 0o200 - else - @compileError("file '" ++ f.name ++ "' has no read and no write, so it is a name and not a file"); - return .{ - .node = node, - .parent = parent, - .name = f.name, - .dir = false, - .mode = mode, - .key = key, - .read = f.read, - .write = f.write, - .scratch = f.scratch, - }; - } - - /// THE ONE BUFFER, and both numbers that size it come out of the table: the largest - /// `scratch` any read declares, and the widest directory's worth of staged entries. Never - /// both at once — one request is in flight at a time — so one buffer serves both, and the - /// board pays for the larger. - const out_max = size: { - var most: usize = 0; - for (table) |e| most = @max(most, e.scratch); - for (table) |d| { - if (!d.dir) continue; - var n: usize = 0; - for (table) |e| if (e.parent == d.node and e.node != d.node) { - n += dirent_fixed + e.name.len; - }; - most = @max(most, n); - } - break :size most; - }; - - /// `node[8] dir[1] namelen[1]` — `acmefs`'s staging format for a readdir - /// (`acmefs.zig:942-957`), which is what `Server` decodes. Ten bytes and then the name. - const dirent_fixed = 8 + 1 + 1; - - /// Formatted answers and staged directory entries. Valid until the next `handle`, which is - /// the borrow window `Server.reply` documents. - out: [out_max]u8 = undefined, - - /// Every request the board has been asked, for the runtime's own diagnostics. Not a - /// protocol counter — `Server` keeps those — and not a statistic anybody has to read: it is - /// the one number that distinguishes "nothing is arriving" from "everything is being - /// refused" on a board with no second console to ask. - calls: u32 = 0, - - fn find(node: u64) ?*const Entry { - for (&table) |*e| if (e.node == node) return e; - return null; - } - - fn attrOf(t: *Self, e: *const Entry) Reply.Attr { - return .{ .node = e.node, .dir = e.dir, .mode = e.mode, .size = t.sizeOf(e) }; - } - - /// A file's size is WHAT ITS READ ANSWERS, asked rather than declared. That means a - /// `getattr` of `gpio/20/value` reads the pad's output register, which is a load from a - /// peripheral and nothing more; the alternative is a second declaration in the table that - /// can disagree with the handler, on a tree whose whole claim is that there is one place per - /// fact. A write-only file has no size and reports zero, which is what `acmefs` reports for - /// every file it cannot cheaply measure. - fn sizeOf(t: *Self, e: *const Entry) u64 { - const read = e.read orelse return 0; - const bytes = read(e.key, t.out[0..e.scratch]) catch return 0; - return bytes.len; - } - - /// ONE OPERATION, and the whole of what this filesystem is. Pure: no allocation, no - /// blocking, no state but `out` and the counter. - pub fn handle(t: *Self, req: Req) Answer { - t.calls += 1; - const e = find(req.node) orelse return .{ .reply = .fail(req.tag, E.NOENT) }; - switch (req.op) { - .lookup => { - if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) }; - for (&table) |*c| { - if (c.parent != req.node or c.node == req.node) continue; - if (!std.mem.eql(u8, c.name, req.data)) continue; - return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(c) } }; - } - return .{ .reply = .fail(req.tag, E.NOENT) }; - }, - .getattr => return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } }, - // The only `setattr` that reaches here is a truncate, from `Topen` with `OTRUNC` - // (`src/9p.zig:2816-2822`) — which is what `echo 1 > gpio/20/value` opens with. - // Every file here is a fixed-length register view, so there is nothing to truncate - // and nothing to refuse either: answering EINVAL would make the shell's own - // redirection fail on a pin that is perfectly writable. - .setattr => { - if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; - return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } }; - }, - // No per-open state, so one handle for every open. `Server` checks the mode against - // the fid's cached permissions before it gets here (`src/9p.zig:2075-2079`). - .open => return .{ .reply = .{ .tag = req.tag, .handle = 1 } }, - .release => return .{ .reply = .{ .tag = req.tag } }, - .read => { - if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; - const read = e.read orelse return .{ .reply = .fail(req.tag, E.INVAL) }; - const all = read(e.key, t.out[0..e.scratch]) catch |f| { - return .{ .reply = .fail(req.tag, errnoOf(f)) }; - }; - // Past the end is the empty read every client uses to stop, not an error. - if (req.off >= all.len) return .{ .reply = .{ .tag = req.tag } }; - const from = all[@intCast(req.off)..]; - return .{ .reply = .{ .tag = req.tag }, .bytes = from[0..@min(from.len, req.size)] }; - }, - .write => { - if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; - const write = e.write orelse return .{ .reply = .fail(req.tag, E.INVAL) }; - // A REGISTER IS NOT A STREAM. Every file here is one value, so the only offset - // that means anything is zero; a client that seeks and writes is describing an - // edit to a byte range this file does not have. `echo`, `9p write` and - // `cat > file` all write at zero. - if (req.off != 0) return .{ .reply = .fail(req.tag, E.INVAL) }; - const n = write(e.key, req.data) catch |f| { - return .{ .reply = .fail(req.tag, errnoOf(f)) }; - }; - return .{ .reply = .{ .tag = req.tag, .written = n } }; - }, - .readdir => { - if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) }; - return .{ .reply = .{ .tag = req.tag }, .bytes = t.stage(req.node, req.off) }; - }, - // 9P2000 has no `Tstatfs` — that is a `.L` message (`src/9p.zig:24-28`) — so - // nothing reaches this. It is answered rather than `unreachable` because the ABI - // names it and a panic in a server is worse than an empty answer. - .statfs => return .{ .reply = .{ .tag = req.tag } }, - } - } - - /// A directory's children in `acmefs`'s staging format, from an ENTRY INDEX rather than a - /// byte offset — `Server` does that coordinate change and advances both cursors - /// (`src/9p.zig:2836-2849`). The whole of the widest directory fits `out` by construction, - /// so this never stages a short list for want of room; `Server` still takes only what one - /// reply holds and asks again. - fn stage(t: *Self, node: u64, skip: u64) []const u8 { - var n: usize = 0; - var seen: u64 = 0; - for (&table) |*e| { - if (e.parent != node or e.node == node) continue; - if (seen < skip) { - seen += 1; - continue; - } - std.mem.writeInt(u64, t.out[n..][0..8], e.node, .little); - t.out[n + 8] = @intFromBool(e.dir); - t.out[n + 9] = @intCast(e.name.len); - @memcpy(t.out[n + dirent_fixed ..][0..e.name.len], e.name); - n += dirent_fixed + e.name.len; - } - return t.out[0..n]; - } - }; -} - -// --------------------------------------------------------------------------- -// tests -// --------------------------------------------------------------------------- -// -// A RECORDING STUB FOR THE PADS, exactly as `src/9p.zig`'s server tests use a stub filesystem: the -// seam is two functions, so the test can hold the pads still and check what was asked of them. Every -// claim below is one a host can answer — the tree's shape, the bytes of an answer, which pin the -// seam was called with — and the one claim it cannot is stated as such: whether `hal.gpio` drives -// the pad, which only the die knows. - -const testing = std.testing; - -/// The pads, faked. `driven` is the board's output register. -const StubPads = struct { - var driven: [64]u1 = @splat(0); - var log: [16]Call = undefined; - var log_len: usize = 0; - - const Call = struct { pin: u8, level: u1 }; - - fn reset() void { - driven = @splat(0); - log_len = 0; - } - - fn level(pin: u8) u1 { - return driven[pin]; - } - - fn drive(pin: u8, want: u1) void { - driven[pin] = want; - log[log_len] = .{ .pin = pin, .level = want }; - log_len += 1; - } -}; - -const Board = Tree(StubPads); - -/// The tree, walked by name the way a client walks it: `lookup` after `lookup` from the root, which -/// is the only way to find out what the generated table actually offers. -fn walk(t: *Board, path: []const []const u8) !Board.Reply.Attr { - var at: u64 = root; - var attr: Board.Reply.Attr = .{ .node = root, .dir = true, .mode = 0o500 }; - for (path) |name| { - const a = t.handle(.{ .tag = 1, .op = .lookup, .node = at, .data = name }); - if (a.reply.status == .err) return switch (a.reply.errno) { - E.NOENT => error.NoEntry, - E.NOTDIR => error.NotDirectory, - else => error.Refused, - }; - attr = a.reply.attr; - at = attr.node; - } - return attr; -} - -fn readAll(t: *Board, node: u64) !Board.Answer { - const open = t.handle(.{ .tag = 1, .op = .open, .node = node }); - try testing.expectEqual(Board.Status.ok, open.reply.status); - return t.handle(.{ .tag = 2, .op = .read, .node = node, .handle = open.reply.handle, .size = 65535 }); -} - -test "board9p: the generated tree has exactly the header's pins, and nothing else" { - var t: Board = .{}; - - // The capability directory, and its one hand-written file. - try testing.expect((try walk(&t, &.{"gpio"})).dir); - try testing.expect(!(try walk(&t, &.{ "gpio", "pinout" })).dir); - - // Every pin JP1 brings out is a directory with a `value` in it. Eleven of them, generated. - for (board_pins.gpio_pins) |pin| { - var name: [4]u8 = undefined; - const dir = try std.fmt.bufPrint(&name, "{d}", .{pin}); - try testing.expect((try walk(&t, &.{ "gpio", dir })).dir); - const value = try walk(&t, &.{ "gpio", dir, "value" }); - try testing.expect(!value.dir); - try testing.expectEqual(@as(u16, 0o600), value.mode); - } - - // And a pin the board does not bring out is not there. 6 and 21 are real ESP32-P4 GPIOs that - // JP1 simply does not route, which is the distinction the table exists to keep: the tree has - // the pins the BOARD has, not the pins the CHIP has. - try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "6" })); - try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "21" })); - try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "20", "level" })); - try testing.expectError(error.NoEntry, walk(&t, &.{"mem"})); -} - -test "board9p: a read of gpio/pinout is the bytes the Gpio word draws" { - var t: Board = .{}; - const at = try walk(&t, &.{ "gpio", "pinout" }); - // `board_memory.zig:364`'s `pinout` IS this declaration, so this is the word's own output and - // not a copy of it. The bytes themselves are pinned by `board_pins.zig`'s golden test. - const a = try readAll(&t, at.node); - try testing.expectEqualStrings(board_pins.jp1_text, a.bytes); - // The size a client is told matches what it gets, which is what makes `cat` stop in one read. - try testing.expectEqual(board_pins.jp1_text.len, at.size); - // Read-only: the drawing is the header's, not the client's. - try testing.expectEqual(@as(u16, 0o400), at.mode); - const w = t.handle(.{ .tag = 3, .op = .write, .node = at.node, .data = "x" }); - try testing.expectEqual(E.INVAL, w.reply.errno); -} - -test "board9p: writing 1 then 0 drives the pad twice, through the seam" { - StubPads.reset(); - var t: Board = .{}; - const at = try walk(&t, &.{ "gpio", "20", "value" }); - - // A fresh pad reads 0 — the level the board is DRIVING, which is defined before anybody has - // written anything. - const before = try readAll(&t, at.node); - try testing.expectEqualStrings("0\n", before.bytes); - - const one = t.handle(.{ .tag = 4, .op = .write, .node = at.node, .data = "1" }); - try testing.expectEqual(Board.Status.ok, one.reply.status); - try testing.expectEqual(@as(u32, 1), one.reply.written); - try testing.expectEqualStrings("1\n", (try readAll(&t, at.node)).bytes); - - // `echo 0 > value`, newline and all: the whole write is consumed, so the shell does not retry - // the tail and drive the pin a second time. - const zero = t.handle(.{ .tag = 5, .op = .write, .node = at.node, .data = "0\n" }); - try testing.expectEqual(@as(u32, 2), zero.reply.written); - try testing.expectEqualStrings("0\n", (try readAll(&t, at.node)).bytes); - - // TWO CALLS, the right pin, the right levels, in order. This is the whole of what the host can - // check about the seam; that `hal.gpio` then moves the pad is the die's to answer. - try testing.expectEqual(@as(usize, 2), StubPads.log_len); - try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 1 }, StubPads.log[0]); - try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 0 }, StubPads.log[1]); -} - -test "board9p: a pad takes 0 and 1 and refuses everything else, without touching the pads" { - StubPads.reset(); - var t: Board = .{}; - const at = try walk(&t, &.{ "gpio", "45", "value" }); - - for ([_][]const u8{ "2", "", "01", "x", "true", "high", "\n", "1 ", " 1", "10" }) |bad| { - const a = t.handle(.{ .tag = 6, .op = .write, .node = at.node, .data = bad }); - try testing.expectEqual(Board.Status.err, a.reply.status); - try testing.expectEqual(E.INVAL, a.reply.errno); - } - // A refused write is a pad that was never driven, which is the part that matters: a half-parsed - // command must not leave the board in a state nobody asked for. - try testing.expectEqual(@as(usize, 0), StubPads.log_len); - - // A register is one value, so a write at an offset is refused too, and refused before the pads. - const off = t.handle(.{ .tag = 7, .op = .write, .node = at.node, .off = 1, .data = "1" }); - try testing.expectEqual(E.INVAL, off.reply.errno); - try testing.expectEqual(@as(usize, 0), StubPads.log_len); -} - -test "board9p: every node's parent is the one 9p.parentOf derives, or an unallocated block" { - // THE ENCODING'S OWN TEST, and it defends the one thing this file cannot see: `src/9p.zig` - // answers `..` from the node id alone, by the rule restated at `block` above. A node numbered - // outside that rule would make `cd ..` land somewhere else with no diagnostic, so the rule is - // applied here to every generated node and compared against the table's own `parent`. - for (&Board.table) |*e| { - const serial = e.node >> 4; - const file = e.node & 0xF; - const derived: u64 = if (e.node == root or serial == 0 or file == 0) root else serial << 4; - if (derived == e.parent) continue; - // The one exception, and it must be exactly the one documented: a fan leaf, whose parent is - // a block MEMBER and therefore unexpressible. Its derived parent has to be a node that does - // not exist, so the walk is refused rather than landing on the wrong file. - try testing.expectEqualStrings("value", e.name); - var t: Board = .{}; - const a = t.handle(.{ .tag = 8, .op = .getattr, .node = derived }); - try testing.expectEqual(E.NOENT, a.reply.errno); - } -} - -test "board9p: a directory read lists what the table generated, in table order" { - var t: Board = .{}; - - // The root is the capability list, and today that is one name. - try testing.expectEqualStrings("gpio", (try names(&t, root, 0))[0]); - try testing.expectEqual(@as(usize, 1), (try names(&t, root, 0)).len); - - const gpio = (try walk(&t, &.{"gpio"})).node; - const listing = try names(&t, gpio, 0); - try testing.expectEqual(board_pins.gpio_pins.len + 1, listing.len); - try testing.expectEqualStrings("pinout", listing[0]); - for (board_pins.gpio_pins, 0..) |pin, i| { - var buf: [4]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "{d}", .{pin}), listing[i + 1]); - } - - // The cursor is an ENTRY INDEX, which is what `Server` advances between reads of a directory - // bigger than one reply. - const rest = try names(&t, gpio, 5); - try testing.expectEqual(board_pins.gpio_pins.len + 1 - 5, rest.len); - try testing.expectEqualStrings("5", rest[0]); -} - -/// The names in one staged directory read, decoded out of `acmefs`'s `node[8] dir[1] namelen[1] -/// name[]` records — the same decode `Server` does. -var name_slots: [32][]const u8 = undefined; -fn names(t: *Board, node: u64, skip: u64) ![][]const u8 { - const a = t.handle(.{ .tag = 9, .op = .readdir, .node = node, .off = skip, .size = 65535 }); - try testing.expectEqual(Board.Status.ok, a.reply.status); - var n: usize = 0; - var i: usize = 0; - while (i < a.bytes.len) { - const len = a.bytes[i + 9]; - name_slots[n] = a.bytes[i + 10 ..][0..len]; - n += 1; - i += 10 + len; - } - return name_slots[0..n]; -} - -test "board9p: the whole tree costs one buffer, and the table says how big" { - // The two numbers the board's RAM budget is quoted from. `out` is the ONLY buffer this - // filesystem has, and both of its bounds come out of the table: the widest directory's staged - // entries (gpio's twelve) and the largest read scratch (a pin's two bytes). - try testing.expectEqual(@as(usize, 143), Board.out_max); - try testing.expect(@sizeOf(Board) <= 160); - // The JP1 drawing is not in it, and that is the point of `scratch = 0`: 468 bytes of static - // text are served straight out of `.rodata`. - try testing.expect(board_pins.jp1_text.len > Board.out_max); -} - -// The proof that the ABI claim in this file's header is true, and the only place the two halves meet -// on the host: `Server` is a generic over exactly `Op`, `Status`, `Req`, `Reply` and `Reply.Attr`, -// so a field that drifts from `acmefs`'s is a compile error HERE, and a real client's bytes are what -// comes out. -// -// A PATH IMPORT, and it took two goes to get here. The first was -// `@import("9p.zig")`, which did not compile while `src/9p.zig` was also the -// ROOT of a named `ninep` module in the same link — a file belongs to exactly -// one module, and it was both. The second was a named module, declared twice in -// `build.zig`; that compiled and made this file unbuildable by anyone but -// `build.zig`, which is what broke the board image the moment the firmware -// link moved to the toolchain repository and stopped injecting modules. -// -// The path form works now because nothing declares `src/9p.zig` as a module -// root any more: `fs9_service.zig` and `fs9_client.zig` reach it by path too, -// so every link that contains it contains it once. The gain is that this file -// and `src/esp32p4_9p.zig` are self-contained — `zig test src/board9p.zig` -// works with no flags, and any builder can root an image at `nine.zig` without -// being told what modules to inject. -const ninep = @import("9p.zig"); - -test "board9p: a real 9P client reads a pin's value off this tree" { - StubPads.reset(); - const Server = ninep.Server(Board); - // The board's own buffers, at the board's own msize. See `src/esp32p4_9p.zig` for why 1024. - var in: [1024]u8 = undefined; - var out: [2048]u8 = undefined; - var fsys: Board = .{}; - var srv = Server.init(.{ .in = &in, .out = &out, .root = root }); - - var scratch: [256]u8 = undefined; - const send = struct { - fn call(s: *Server, f: *Board, buf: []u8, tag: u16, msg: ninep.Msg) !void { - const bytes = try ninep.encode(msg, tag, buf); - try testing.expectEqual(bytes.len, s.push(bytes)); - while (s.retry()) |req| { - const a = f.handle(req); - s.reply(&a.reply, a.bytes); - } - while (s.next()) |req| { - const a = f.handle(req); - s.reply(&a.reply, a.bytes); - } - } - }.call; - const reap = struct { - fn call(s: *Server) !ninep.Decoded { - const queued = s.output(); - const len = ninep.frameLen(queued) orelse return error.NoReply; - const got = try ninep.decode(queued[0..len]); - s.wrote(len); - return got; - } - }.call; - - try send(&srv, &fsys, &scratch, ninep.notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }); - const v = try reap(&srv); - // Clamped to what the board's buffers hold, which is the number the RAM budget was chosen for. - try testing.expectEqual(@as(u32, 1024), v.msg.rversion.msize); - - try send(&srv, &fsys, &scratch, 1, .{ .tattach = .{ .fid = 0, .afid = ninep.nofid, .uname = "goblin", .aname = "" } }); - try testing.expectEqual(root, (try reap(&srv)).msg.rattach.qid.path); - - var wname: [ninep.max_welem][]const u8 = @splat(""); - wname[0] = "gpio"; - wname[1] = "20"; - wname[2] = "value"; - try send(&srv, &fsys, &scratch, 2, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 3, .wname = wname } }); - try testing.expectEqual(@as(u16, 3), (try reap(&srv)).msg.rwalk.nwqid); - - try send(&srv, &fsys, &scratch, 3, .{ .topen = .{ .fid = 1, .mode = ninep.ordwr } }); - _ = try reap(&srv); - - // `echo 1 > /mnt/board/gpio/20/value`, as bytes on a wire. - try send(&srv, &fsys, &scratch, 4, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "1\n" } }); - try testing.expectEqual(@as(u32, 2), (try reap(&srv)).msg.rwrite.count); - try testing.expectEqual(@as(u1, 1), StubPads.driven[20]); - - // ...and `cat` of the same file. - try send(&srv, &fsys, &scratch, 5, .{ .tread = .{ .fid = 1, .offset = 0, .count = 512 } }); - try testing.expectEqualStrings("1\n", (try reap(&srv)).msg.rread.data); - - // The refusal reaches the client as an error STRING, which is 9P's only channel for "no": EINVAL - // becomes the wording `9p.errString` gives it, and the pad is not touched. - try send(&srv, &fsys, &scratch, 6, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "on" } }); - try testing.expectEqualStrings(ninep.errString(E.INVAL), (try reap(&srv)).msg.rerror.ename); - try testing.expectEqual(@as(usize, 1), StubPads.log_len); -} diff --git a/src/board_memory.zig b/src/board_memory.zig deleted file mode 100644 index 4bee9e6e..00000000 --- a/src/board_memory.zig +++ /dev/null @@ -1,465 +0,0 @@ -//! The board's own address space and its pins, as text: the Peek, Poke, Hexdump and Gpio builtins' -//! whole implementation. -//! -//! THE P4 BUILD ONLY (`enabled` below), and the reason is not caution but honesty: with no OS there -//! is no MMU, no supervisor and no process - the editor IS the system software - so every one of the -//! 2^32 addresses is legitimately this program's to read and write, and a word that could name only -//! some of them would be lying about where it is running. Under an OS the same words would be either -//! a segfault or a syscall stub, so they are absent from those builds entirely rather than present -//! and refusing. Absent means not compiled, not hidden: nothing below is analysed for a build whose -//! platform is not `esp32p4`. -//! -//! Everything here goes through `*allowzero volatile` pointers. A peripheral -//! register is not memory: reading UART_STATUS twice is two reads and must not -//! be folded into one, a write to a write-only command register has no -//! observable value for the optimizer to keep, and address 0 is an ordinary -//! (unmapped) address on this bus rather than the null Zig assumes it is. -//! -//! The formatting side is a plain renderer over `Pardes.gpa`, so it lands in -//! an output buffer the same way Jumplist and Config do: an output buffer is a -//! file pane, so every motion, chord and Look works on a dump for free — you -//! can right-click an address in a hexdump row and Peek it. -const std = @import("std"); -const builtin = @import("builtin"); -const pardes = @import("pardes.zig"); -const Pardes = pardes.Pardes; -const output_pane = @import("output_pane.zig"); -const limits = @import("limits.zig"); - -/// THE ONE GATE, and it names the esp32p4 build, so `Peek`, `Poke`, `Hexdump` and `Gpio` are analysed -/// and emitted for that build and for no other. Nothing in this file reaches any other target's -/// binary: not the volatile accessors, not the JP1 pinout, not the parsers. -/// -/// This used to be derived from the target - `os.tag == .freestanding and !isWasm()` - on the -/// argument that these words are a property of having no operating system rather than a product -/// configuration, and that a predicate spelled out of `builtin` cannot drift the way a -/// hand-maintained enum can. The argument was tidy and it answered the wrong question. A word -/// only exists if some SHELL offers it, and the shells are the platforms; `Gpio` settles it beyond -/// argument, because its whole content is one board's header, and a second freestanding port would -/// need its own pinout rather than inheriting this one. "Bare metal" was never the requirement, -/// "this board" was, and the two only looked identical because there is currently one of them. -/// -/// The old predicate's real work was excluding wasm, which is `freestanding` too - inside the -/// browser's sandbox an address is an offset into a linear memory the engine owns, so a `Peek` -/// would read a number that means nothing about any machine and a `Poke` would corrupt the heap -/// this same editor runs out of. Naming `esp32p4` excludes it by construction rather than by a term -/// somebody has to keep remembering. -pub const enabled = pardes.platform == .esp32p4; - -// The target is now the WITNESS rather than the gate: whatever else `esp32p4` means, it has to still be -// a machine whose addresses are the bus's, and a hosted or wasm build reaching this line means the -// platform and the target disagree about what the firmware is. -comptime { - if (enabled and pardes.hosted) @compileError("an OS is not bare metal"); - if (enabled and builtin.os.tag != .freestanding) @compileError("the P4 firmware is freestanding"); - if (enabled and builtin.target.cpu.arch.isWasm()) @compileError("wasm addresses are not a bus"); -} - -/// How much of the address space ONE command may render. -/// -/// The number is set by the console, not by the memory: UART0 runs at 115200 -/// baud and measures ~11.9 KB/s on the wire, and a hexdump row is 76 bytes of -/// text per 16 bytes of memory. 4 KiB is therefore 256 rows and ~19.5 KiB of -/// text — under two seconds to paint the whole buffer, and ~4% of the 512 KiB -/// heap the firmware hands over. `Hexdump 0x0 0xffffffff` would otherwise wedge -/// the only console the board has for eleven hours, with no way to interrupt -/// it, which makes an unbounded dump not a slow command but a lost session. -/// -/// Peek's cap is the same 4 KiB window expressed in words, so `Peek a 1024` -/// and `Hexdump a 4096` cover exactly the same bytes. -pub const max_bytes: u32 = 4096; -pub const max_words: u32 = max_bytes / 4; - -/// One address past the last: the reads below are bounded by this rather than -/// wrapping, because `Hexdump 0xfffffff0 256` wrapping to 0 would silently -/// show you the bottom of the space labelled with top-of-space addresses. -const space: u64 = 1 << 32; - -pub const Error = error{ - MissingAddress, - BadAddress, - BadCount, - MissingValue, - BadValue, - /// the ONE fault this file exists to prevent by hand: the RISC-V core - /// traps an unaligned 32-bit access, and a trap in firmware with no - /// handler is a watchdog reset that takes the session with it. Reported on - /// the message row instead. - MisalignedAddress, - ExtraArgument, - /// not a number, or a number the part does not have a pad for - BadPin, - /// the host brought no pads: every build but the firmware, where the word - /// is not registered at all, and a firmware too old to pass the hook - NoPads, -}; - -/// EVERY literal these three words take is HEX, with or without an `0x`, and there is no way to -/// write a decimal one. -/// -/// This replaces base-0 parsing, which accepted `0x4ff40000` and `1341390848` and refused a bare -/// `4ff40000` on the grounds that guessing between hex and decimal would make one typo address -/// somewhere else entirely. That reasoning was sound and the conclusion was still wrong: the -/// ambiguity it protected against is not a real one. Every address anybody has ever typed at these -/// three words is hex - it came off a datasheet, a linker map, or a previous dump's own output, all -/// of which print hex - so the base was never in doubt, and demanding `0x` on every one of them was -/// a toll on the common case to guard a case that does not arise. -/// -/// The COUNTS go with them, and that is the part worth stating out loud rather than leaving as a -/// surprise: `Hexdump 4ff40000 100` shows 0x100 bytes, which is 256, not one hundred. One rule for -/// every literal in the word is worth more than two rules that each fit their argument better, -/// because the second kind is the sort of thing you have to remember at the moment you are already -/// concentrating on something else. Everything these words PRINT is hex too, including the clamp -/// notes, so a number can go back in where it came out. -fn parseHex(comptime T: type, tok: []const u8, bad: Error) Error!T { - // `parseInt` only honours an `0x` when its base is 0, so with base 16 the prefix has to come off - // here. A bare `0x` leaves nothing behind and `parseInt` rejects the empty string, which is the - // answer that wants giving. - const body = if (tok.len > 2 and tok[0] == '0' and (tok[1] | 0x20) == 'x') tok[2..] else tok; - return std.fmt.parseInt(T, body, 16) catch bad; -} - -fn parseAddr(tok: []const u8) Error!u32 { - return parseHex(u32, tok, Error.BadAddress); -} - -fn parseCount(tok: []const u8) Error!u64 { - return parseHex(u64, tok, Error.BadCount); -} - -fn parseValue(tok: []const u8) Error!u32 { - return parseHex(u32, tok, Error.BadValue); -} - -/// A 32-bit peripheral or RAM read that the compiler may neither elide, -/// duplicate, reorder past another access, nor narrow. -fn readWord(addr: u32) u32 { - const cell: *allowzero const volatile u32 = @ptrFromInt(@as(usize, addr)); - return cell.*; -} - -fn writeWord(addr: u32, value: u32) void { - const cell: *allowzero volatile u32 = @ptrFromInt(@as(usize, addr)); - cell.* = value; -} - -fn readByte(addr: u32) u8 { - const cell: *allowzero const volatile u8 = @ptrFromInt(@as(usize, addr)); - return cell.*; -} - -const Limit = enum { - /// the 4 KiB console cap above - console, - /// the end of the 32-bit address space - space, -}; - -/// How many units this command will actually show, and WHY that is fewer than -/// you asked for when it is. Never silent: the note below becomes the buffer's -/// FIRST line, which is the one place a clamp cannot be missed — a trailing -/// note on a 256-row dump is a note you scroll past. -const Extent = struct { - count: u32, - /// the tighter of the two bounds, or null when neither applied - limit: ?Limit, -}; - -fn extent(addr: u32, requested: u64, unit: u32, cap: u32) Extent { - var count = requested; - var limit: ?Limit = null; - if (count > cap) { - count = cap; - limit = .console; - } - const fits = (space - addr) / unit; - if (count > fits) { - count = fits; - limit = .space; - } - return .{ .count = @intCast(count), .limit = limit }; -} - -fn writeNote(w: *std.Io.Writer, e: Extent, requested: u64, unit_name: []const u8) !void { - switch (e.limit orelse return) { - // Hex, like everything else these words read and print, so the number in a clamp note can go - // straight back into the command that produced it. - .console => try w.print( - "clamped: 0x{x} {s} requested, 0x{x} shown (0x{x}-byte cap, one 115200-baud console)\n", - .{ requested, unit_name, e.count, max_bytes }, - ), - .space => try w.print( - "clamped: 0x{x} {s} requested, 0x{x} shown (the 32-bit address space ends at 0x100000000)\n", - .{ requested, unit_name, e.count }, - ), - } -} - -// The two bounds and their reporting, on the one part of this file that is -// pure arithmetic and therefore testable on any target — the accesses -// themselves are only meaningful on the board. -test "the clamp reports the tighter bound and never wraps the address space" { - const eq = std.testing.expectEqual; - // neither bound applied: what you asked for, and nothing to report - try eq(Extent{ .count = 3, .limit = null }, extent(0x4ff40000, 3, 4, max_words)); - // the console cap, in words and in bytes - try eq(Extent{ .count = max_words, .limit = .console }, extent(0x4ff40000, 99_999, 4, max_words)); - try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0, 100_000, 1, max_bytes)); - // sixteen bytes left above 0xfffffff0 — the whole point, because wrapping - // would show the BOTTOM of the space under top-of-space addresses - try eq(Extent{ .count = 16, .limit = .space }, extent(0xfffffff0, 64, 1, max_bytes)); - try eq(Extent{ .count = 4, .limit = .space }, extent(0xfffffff0, 64, 4, max_words)); - // ...including the row that has no whole word left in it - try eq(Extent{ .count = 0, .limit = .space }, extent(0xffffffff, 1, 4, max_words)); - // both bounds at once: the tighter one is the one reported - try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0xffff0000, 1 << 20, 1, max_bytes)); -} - -test "a clamp note is written exactly when something was clamped" { - var buf: [256]u8 = undefined; - var w: std.Io.Writer = .fixed(&buf); - - try writeNote(&w, extent(0x4ff40000, 3, 4, max_words), 3, "words"); - try std.testing.expectEqualStrings("", w.buffered()); - - try writeNote(&w, extent(0x4ff40000, 99_999, 4, max_words), 99_999, "words"); - try std.testing.expectEqualStrings( - "clamped: 0x1869f words requested, 0x400 shown (0x1000-byte cap, one 115200-baud console)\n", - w.buffered(), - ); - - w = .fixed(&buf); - try writeNote(&w, extent(0xfffffff0, 64, 1, max_bytes), 64, "bytes"); - try std.testing.expectEqualStrings( - "clamped: 0x40 bytes requested, 0x10 shown (the 32-bit address space ends at 0x100000000)\n", - w.buffered(), - ); -} - -test "every literal is hex, with or without the prefix" { - const eq = std.testing.expectEqual; - // the prefix is optional, never required, and never changes the answer - try eq(0x4ff40000, parseAddr("0x4ff40000")); - try eq(0x4ff40000, parseAddr("4ff40000")); - try eq(0x4ff40000, parseAddr("0X4FF40000")); - try eq(0x4ff40000, parseAddr("4FF40000")); - // a token that looks decimal is hex too - the whole point, and the thing to remember - try eq(0x100, parseCount("100")); - try eq(0x256, parseCount("256")); - try eq(0xdeadbeef, parseValue("deadbeef")); - // and the refusals still refuse - try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000")); - try std.testing.expectError(Error.BadAddress, parseAddr("0x")); - try std.testing.expectError(Error.BadAddress, parseAddr("nope")); - try std.testing.expectError(Error.BadAddress, parseAddr("12g4")); - try std.testing.expectError(Error.BadCount, parseCount("-1")); - try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000")); -} - -// The pinout is the one thing here whose CORRECTNESS IS ITS SHAPE: a header drawn in two columns -// stops being a header the moment a row wraps, and it wraps on the board rather than on a -// developer's terminal, which is the worst place to find out. So the width is asserted against the -// grid the board is actually built with, and the alignment is asserted against the column the pin -// numbers are supposed to share. -test "the pinout fits the board's own grid, in two aligned columns" { - const cols: usize = @import("pardes_config").esp32p4_cols; - // Seven columns of the shell's grid go to the line-number gutter before a pane's text starts. - const usable = cols - 7; - - var rows: usize = 0; - var pins: usize = 0; - var first_bar: ?usize = null; - var it = std.mem.splitScalar(u8, pinout, '\n'); - while (it.next()) |line| { - try std.testing.expect(line.len <= usable); - rows += 1; - // A pin row is one with two numbers in it; every one must put its bars in the same place, - // which is what "aligned in two columns" means when the check is mechanical. - const bar = std.mem.indexOfScalar(u8, line, '|') orelse continue; - if (line[line.len - 1] == '+') continue; - pins += 1; - if (first_bar) |b| try std.testing.expectEqual(b, bar) else first_bar = bar; - } - try std.testing.expectEqual(@as(usize, 13), pins); - try std.testing.expect(rows > 15); - - // Two independent facts about the board, each with a witness outside this file: GPIO20 is - // `05-zig-p4/build.zig`'s documented `-Dled` default ("JP1 pin 17"), and pin 8 is the one - // header pin the vendor schematic leaves unconnected. - try std.testing.expect(std.mem.indexOf(u8, pinout, "GPIO 20 | 17 |") != null); - try std.testing.expect(std.mem.indexOf(u8, pinout, "| 8 | --") != null); -} - -// The exception to the file's own rule, so it is written down as a test rather than only as a -// comment: a pin number is part of a name and is read as decimal, while every address beside it is -// hex. `Gpio 20` must mean the pin the schematic calls GPIO20, not 0x20. -test "a pin number is decimal, unlike every address in this file" { - try std.testing.expectEqual(@as(u16, 20), try std.fmt.parseInt(u16, "20", 10)); - try std.testing.expectEqual(@as(u32, 0x20), try parseAddr("20")); - try std.testing.expect(20 != 0x20); -} - -/// `Peek [count]` — count 32-bit words at addr, one `addr: value` row -/// each. One word per row rather than four so that every row carries its own -/// address: the rows are then ordinary Look targets, and `Peek` or `Poke` -/// chorded onto one re-reads or writes exactly that word. -pub fn peek(p: *Pardes, id: usize, argument: []const u8) !void { - var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); - const addr = try parseAddr(it.next() orelse return Error.MissingAddress); - const requested = if (it.next()) |tok| try parseCount(tok) else 0x1; - if (it.next() != null) return Error.ExtraArgument; - if (addr % 4 != 0) return Error.MisalignedAddress; - - const e = extent(addr, requested, 4, max_words); - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try writeNote(&out.writer, e, requested, "words"); - for (0..e.count) |i| { - const at = addr + @as(u32, @intCast(i * 4)); - try out.writer.print("{x:0>8}: {x:0>8}\n", .{ at, readWord(at) }); - } - const content = try out.toOwnedSlice(); - try fill(p, id, .{ .cmd = .Peek }, content); -} - -/// `Poke ` — one 32-bit store, then one load back, both reported -/// on the message row. -/// -/// The READ-BACK is the whole point of the word and not a confirmation: on RAM -/// it always equals what you wrote and tells you nothing, and on MMIO it -/// almost never does — a write-only command register reads as 0, a W1C status -/// bit reads back cleared, a reserved field reads back masked, and a register -/// behind a gated clock reads back whatever the bus returns for nothing at -/// all. Printing only the value written would show you your own argument. -pub fn poke(p: *Pardes, id: usize, argument: []const u8) !void { - var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); - const addr = try parseAddr(it.next() orelse return Error.MissingAddress); - const value = try parseValue(it.next() orelse return Error.MissingValue); - if (it.next() != null) return Error.ExtraArgument; - if (addr % 4 != 0) return Error.MisalignedAddress; - - writeWord(addr, value); - const back = readWord(addr); - var buf: [96]u8 = undefined; - p.setMessage(id, std.fmt.bufPrint( - &buf, - "{x:0>8}: wrote {x:0>8}, reads {x:0>8}", - .{ addr, value, back }, - ) catch unreachable); -} - -/// JP1, the 26-pin header down the left edge of the JC-ESP32P4-M3-DEV, as the board wears it: two -/// columns, odd pins on the left, even on the right, pin 1 at the top. -/// -/// MOVED TO `src/board_pins.zig`, where the thirteen rows are DATA and this drawing is rendered -/// from them at comptime. Not for tidiness: the board's 9P image (`src/esp32p4_9p.zig`) links no -/// core, so it cannot import this file — this one imports `pardes.zig` — and that image serves this -/// exact drawing as `gpio/pinout` while generating its per-pin directories from the same rows. The -/// alternative was transcribing a schematic twice, which is two things to maintain and no test that -/// could say which one was wrong. The provenance moved with the rows: which sheet of which -/// schematic, how pin 8 was identified as unconnected, and what `--`, `C6_*` and `ES_I2C_*` mean. -/// -/// The test below is unchanged, and it is still the check that matters HERE: whoever renders this -/// drawing, the `Gpio` word's output has to fit the board's own grid in two aligned columns. -const pinout = @import("board_pins.zig").jp1_text; - -/// `Gpio ` flips one pad and says what it did; `Gpio` alone draws JP1. -/// -/// THE PIN NUMBER IS DECIMAL, and it is the one literal in this file that is. Every other one is -/// hex because every other one is an address, and addresses come off datasheets and linker maps -/// that print hex. A GPIO number is not an address - it is part of a NAME. The schematic says -/// `GPIO47`, the silkscreen says 47, the datasheet's pin table says 47, and `Gpio 20` meaning pin -/// 32 would be a trap laid for the one argument a person types from memory. One rule per KIND of -/// literal beats one rule per file when the kinds are this different. -/// -/// The toggle is the host's to perform (`Host.VTable.pull_gpio_toggle`) even though `Poke` two -/// functions up would happily write GPIO_OUT_REG directly. Writing that register is not the job: -/// a pad has to be pointed at the GPIO peripheral in the IO MUX, routed in the GPIO matrix, have -/// its driver and input buffer enabled, and only then be driven - and getting that wrong on a pin -/// that boots as something else is how you lose the console you are typing on. -/// -/// Reported levels are the OUTPUT bits, before and after, because that is what a toggle means: the -/// level this board is DRIVING. A pad's input buffer on an unconnected header pin reads whatever -/// the air says. -pub fn gpio(p: *Pardes, id: usize, argument: []const u8) !void { - var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); - const tok = it.next() orelse { - // No argument is not an error and not inert: it is the question "which pins are there", - // and the answer is a picture of the header. - const content = try p.gpa.dupe(u8, pinout); - errdefer p.gpa.free(content); - return fill(p, id, .{ .cmd = .Gpio }, content); - }; - if (it.next() != null) return Error.ExtraArgument; - const pin = std.fmt.parseInt(u16, tok, 10) catch return Error.BadPin; - - const toggle = p.host.vtable.pull_gpio_toggle orelse return Error.NoPads; - var was: u8 = 0; - var now: u8 = 0; - if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin; - - var buf: [48]u8 = undefined; - p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable); -} - -/// Bytes per dumped row, and it is a different number on the board — see -/// `limits.hexdump_row_bytes`, which is where that number and its reasoning -/// live now. -const row_bytes: u32 = limits.hexdump_row_bytes; - -/// `Hexdump [len]` — len bytes, `row_bytes` to a row, hex columns and an ASCII gutter, in -/// `hexdump -C`'s layout because that is the one everyone can already read. BYTE reads, so a partial -/// row at the end of the space is a short row rather than a refusal, and no alignment is required: -/// this is the word you reach for when you do not yet know what is there. -pub fn hexdump(p: *Pardes, id: usize, argument: []const u8) !void { - var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); - const addr = try parseAddr(it.next() orelse return Error.MissingAddress); - const requested = if (it.next()) |tok| try parseCount(tok) else 0x100; - if (it.next() != null) return Error.ExtraArgument; - - const e = extent(addr, requested, 1, max_bytes); - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try writeNote(&out.writer, e, requested, "bytes"); - var row: u32 = 0; - while (row < e.count) : (row += row_bytes) { - const n = @min(row_bytes, e.count - row); - var bytes: [row_bytes]u8 = undefined; - for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i))); - try out.writer.print("{x:0>8} ", .{addr + row}); - for (0..row_bytes) |i| { - // The gap at the halfway mark: the eye counts to four or eight, not to sixteen. - if (i == row_bytes / 2) try out.writer.writeByte(' '); - if (i < n) - try out.writer.print(" {x:0>2}", .{bytes[i]}) - else - try out.writer.writeAll(" "); - } - try out.writer.writeAll(" |"); - for (0..n) |i| try out.writer.writeByte( - if (bytes[i] >= 0x20 and bytes[i] < 0x7f) bytes[i] else '.', - ); - try out.writer.writeAll("|\n"); - } - const content = try out.toOwnedSlice(); - try fill(p, id, .{ .cmd = .Hexdump }, content); -} - -/// The shared tail. `fillResults` is the one public entry that REFILLS the -/// buffer a command already opened instead of stacking a twin beside it, which -/// is what a dump wants: peeking twenty addresses in a row is twenty renders -/// of one window on memory, not twenty panes. The empty argument is what makes -/// it one window — a dump is identified by the command, never by the address, -/// so a second Peek replaces the first rather than opening a buffer per -/// address and exhausting the pane slots. -/// -/// Neither buffer `steps`, so nothing is armed on n/N and focus stays in the -/// pane you typed the command in. `content` is gpa-owned and adopted there. -fn fill(p: *Pardes, id: usize, from: output_pane.Origin, content: []u8) !void { - const pane = p.panes[id] orelse { - p.gpa.free(content); - return error.MissingPane; - }; - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - try output_pane.fillResults(p, id, dir, from, "", content, null); -} diff --git a/src/board_pins.zig b/src/board_pins.zig deleted file mode 100644 index 134cd92c..00000000 --- a/src/board_pins.zig +++ /dev/null @@ -1,186 +0,0 @@ -//! JP1, the JC-ESP32P4-M3-DEV's 26-pin header, as ONE TABLE that everything else is derived from: -//! the ASCII drawing the `Gpio` word prints, and the pin directories the board's 9P tree generates. -//! -//! WHY THIS IS ITS OWN FILE, and it is the whole reason it exists. The drawing lived in -//! `src/board_memory.zig`, which imports `pardes.zig` and therefore the entire core; the board's 9P -//! image (`src/esp32p4_9p.zig`) links no core at all, so it could not have reached it. The two -//! ways out of that were a second copy of the header in the 9P tree — a table of thirteen rows -//! transcribed off a schematic, maintained twice, with no test that could tell you the day they -//! disagreed — or this: a LEAF that imports `std` and nothing else, so both sides import the same -//! thirteen rows. `board_memory.zig` keeps its `pinout` name as an alias of `jp1_text` and its own -//! shape test, so the console word's output is unchanged to the byte. -//! -//! WHY A TABLE AND NOT THE STRING. The string was the source before, and a string is fine for one -//! consumer that prints it. It is no use at all to the second, which needs to know WHICH of these -//! twenty-six pins are the P4's own GPIOs, because that is the set of directories its tree has. A -//! consumer would have to parse the drawing back out — scan for `GPIO `, take the digits, hope -//! nobody aligned a column differently — which is exactly the sort of code that works until the -//! day the drawing is edited. So the rows are data, the drawing is RENDERED from them at comptime, -//! and `gpio_pins` is COLLECTED from them at comptime. Adding a pin to the header is one row, and -//! the drawing, the pin list and the 9P tree all move together because there is only one of them. -//! -//! READ OFF THE VENDOR SCHEMATIC, sheet 2 "Expand IO" -//! (`01-esp32p4-m3/docs/schematics/2_EXPAND_IO&BAT.png`), which is the only document that carries -//! this mapping — the specification PDF's "Interface Description" page is a marketing render, and -//! there is no board user guide. The sheet is a 872x1168 raster, so the assignment was taken from -//! the drawing's own geometry rather than by eye: thirteen wires leave each side of the symbol, a -//! net wire runs ~100 px to its label and a power stub ~21 px, which is what identifies pin 8 as -//! unconnected rather than as the first of the GPIO4x labels. Cross-checked against a second, -//! independent source: `05-zig-p4/build.zig` has always documented `-Dled=20` as "JP1 pin 17", and -//! GPIO20 lands on pin 17 here. -const std = @import("std"); - -/// What is behind one header pin, and the ONE distinction that matters to both consumers: whether -/// this pad is a GPIO of the ESP32-P4 this program is running on. -/// -/// `.none` is a pin the header brings out with nothing behind it (pin 8). `.net` is a pad that is -/// not the P4's to drive as a GPIO: `3V3`, `5V` and `GND` are power, `C6_*` are the ESP32-C6 -/// companion's pins — toggling a P4 GPIO cannot reach them — and `ES_I2C_*` is the audio codec's -/// bus. The codec's two ARE P4 pads, and they are `.net` anyway, deliberately: the schematic does -/// not name their GPIO numbers, and a tree that invented one would offer a file that drives an -/// unknown pin. They stay in the drawing because a shared bus is a reason to know the pin is there. -pub const Pad = union(enum) { - none, - /// a P4 GPIO, by the number the schematic, the silkscreen and the datasheet all use - gpio: u8, - /// a named net that is not a P4 GPIO - net: []const u8, - - /// The text this pad wears in the drawing. `GPIO 47` and not `GPIO47`: the space is what the - /// header has always printed, and the shape test in `board_memory.zig` matches on it. - pub fn label(p: Pad) []const u8 { - return switch (p) { - .none => "--", - .gpio => |n| std.fmt.comptimePrint("GPIO {d}", .{n}), - .net => |s| s, - }; - } -}; - -/// One row of the header: the odd pin on the left, the even pin on its right, exactly as the board -/// wears it. The pin NUMBERS are not stored — row `i` is pins `2i+1` and `2i+2` — because a -/// hand-written number beside a row is a number that can disagree with its position. -pub const Row = struct { left: Pad, right: Pad }; - -/// JP1 itself: thirteen rows, pin 1 at the top left. THE SINGLE SOURCE for the drawing below, for -/// `gpio_pins`, and for the per-pin directories in `src/board9p.zig`. -pub const jp1 = [13]Row{ - .{ .left = .{ .net = "3V3" }, .right = .{ .net = "5V" } }, - .{ .left = .{ .net = "3V3" }, .right = .{ .net = "5V" } }, - .{ .left = .{ .net = "GND" }, .right = .{ .net = "GND" } }, - .{ .left = .{ .gpio = 1 }, .right = .none }, - .{ .left = .{ .gpio = 2 }, .right = .{ .gpio = 47 } }, - .{ .left = .{ .gpio = 3 }, .right = .{ .gpio = 46 } }, - .{ .left = .{ .gpio = 4 }, .right = .{ .gpio = 45 } }, - .{ .left = .{ .gpio = 5 }, .right = .{ .net = "GND" } }, - .{ .left = .{ .gpio = 20 }, .right = .{ .net = "3V3" } }, - .{ .left = .{ .gpio = 32 }, .right = .{ .net = "C6_U0RXD" } }, - .{ .left = .{ .gpio = 33 }, .right = .{ .net = "C6_U0TXD" } }, - .{ .left = .{ .net = "ES_I2C_SDA" }, .right = .{ .net = "C6_IO9" } }, - .{ .left = .{ .net = "ES_I2C_SCL" }, .right = .{ .net = "C6_CHIP_PU" } }, -}; - -/// The row format, and it is load-bearing rather than cosmetic: a header drawn in two columns stops -/// being a header the moment a row wraps or a column slips, and the widest row here is 34 columns -/// against the board's own 80-column grid. Ten for the left label right-aligned, two for each pin -/// number, and the three bars land under the box's own corners because the left label's field plus -/// one space is eleven characters and `+---------+` is eleven wide. -/// -/// `board_memory.zig`'s "the pinout fits the board's own grid" test is the check that this stays -/// true, and it checks the RENDERED text mechanically — every pin row's first bar in the same -/// column — rather than trusting this string. -const row_format = "{s:>10} | {d:>2} | {d:>2} | {s}\n"; - -/// The box the pin numbers sit inside. Eleven characters, indented by the left label's field width -/// plus the space before the first bar, so its corners are the bars. -const border = " +---------+\n"; - -/// JP1 as the text the `Gpio` word prints and a read of the 9P tree's `gpio/pinout` returns — the -/// SAME BYTES, which is a test in `src/board9p.zig` and not a hope. -/// -/// The trailer names the `Gpio` word, which the 9P image does not have. It is here anyway, because -/// "the same bytes" is worth more than a sentence that is true of both faces and useful to neither: -/// a person reading this table through 9P is a person who has the editor's own console in the other -/// window, and telling them the word that flips a pin is telling them something they can use. The -/// 9P equivalent — writing `0` or `1` to `gpio//value` — is documented where a 9P client will -/// look for it, which is the tree's own doc comment. -pub const jp1_text = text: { - var out: []const u8 = - \\JP1 header - 26 pins, pin 1 top left. - \\Every number here is DECIMAL. - \\ - \\ - ; - out = out ++ border; - for (jp1, 0..) |row, i| out = out ++ std.fmt.comptimePrint( - row_format, - .{ row.left.label(), 2 * i + 1, 2 * i + 2, row.right.label() }, - ); - break :text out ++ border ++ - \\ - \\Gpio flips one: 0->1 or 1->0. - \\ - ; -}; - -/// Every P4 GPIO JP1 brings out, ascending. THE SET OF PIN DIRECTORIES the board's 9P tree has, so -/// that tree has exactly the pins this board has and not a range somebody typed. -/// -/// Ascending rather than in header order, because the consumer is `ls`: the header's order puts 47 -/// between 2 and 3, and a directory listing that counts 1 2 3 4 5 20 32 33 45 46 47 is one a person -/// can scan. Nothing depends on the order — the names are the pin numbers — so it may as well be -/// the readable one. -pub const gpio_pins = pins: { - var found: [2 * jp1.len]u8 = undefined; - var n: usize = 0; - for (jp1) |row| for ([2]Pad{ row.left, row.right }) |p| switch (p) { - .gpio => |g| { - found[n] = g; - n += 1; - }, - else => {}, - }; - std.mem.sort(u8, found[0..n], {}, std.sort.asc(u8)); - break :pins found[0..n].*; -}; - -// The drawing, byte for byte, because it is the one thing here whose CORRECTNESS IS ITS SHAPE and -// because it used to be a string literal: this is the check that the renderer above reproduces what -// the console has always printed. A golden test is the right kind of duplication — the expectation -// is the thing being asserted, and if the two ever differ the diff says which byte. -test "the rendered header is the drawing the console has always printed" { - try std.testing.expectEqualStrings( - \\JP1 header - 26 pins, pin 1 top left. - \\Every number here is DECIMAL. - \\ - \\ +---------+ - \\ 3V3 | 1 | 2 | 5V - \\ 3V3 | 3 | 4 | 5V - \\ GND | 5 | 6 | GND - \\ GPIO 1 | 7 | 8 | -- - \\ GPIO 2 | 9 | 10 | GPIO 47 - \\ GPIO 3 | 11 | 12 | GPIO 46 - \\ GPIO 4 | 13 | 14 | GPIO 45 - \\ GPIO 5 | 15 | 16 | GND - \\ GPIO 20 | 17 | 18 | 3V3 - \\ GPIO 32 | 19 | 20 | C6_U0RXD - \\ GPIO 33 | 21 | 22 | C6_U0TXD - \\ES_I2C_SDA | 23 | 24 | C6_IO9 - \\ES_I2C_SCL | 25 | 26 | C6_CHIP_PU - \\ +---------+ - \\ - \\Gpio flips one: 0->1 or 1->0. - \\ - , jp1_text); -} - -// The pin list is the tree's shape, so it is asserted as a list rather than as a count: a row edited -// wrongly changes WHICH pins the board offers, and a count would not notice a 45 that became a 44. -test "the header's own GPIOs, and only those" { - try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4, 5, 20, 32, 33, 45, 46, 47 }, &gpio_pins); - // Pin 8 is unconnected and pin 24 is the C6's, so neither contributes a pad. Both are counted - // here rather than only drawn, because "the tree has exactly the pins the board has" is a claim - // about what is ABSENT as much as what is present. - try std.testing.expectEqual(Pad.none, jp1[3].right); - try std.testing.expectEqualStrings("C6_IO9", jp1[11].right.net); -} diff --git a/src/builtins.zig b/src/builtins.zig index f7822caf..e55a418f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1,48 +1,17 @@ -//! The builtins: one struct each, plus setting commands generated from one -//! runtime_config table. -//! -//! Executing a builtin's NAME (middle-click / Tab) runs it through the one -//! dispatcher (Pardes.runBuiltin), no matter where the name appears. The -//! struct's DECL NAME is the user-visible word — the one in the topbar, the -//! one sitting in a tag, the one Help prints, the one you execute — so -//! `std.meta.stringToEnum` is the lookup and there is no name table to sync. -//! -//! A zig file IS a struct, so THIS FILE'S declarations are the manual list: -//! the registry walks them at comptime and appends the enabled settings from -//! runtime_config.settings. There is no hand-maintained enum or dispatcher -//! switch to keep in sync; declarations and setting descriptors are the data. -//! -//! A manual builtin is a struct declaring `pub fn run(Ctx) void`; an optional -//! explicit `enabled` declaration gates it. Helpers have no `run`, and a -//! claimed builtin with the wrong signature is a compile error. -//! -//! What is NOT here: the key bindings. `leader_path` is ONE table in -//! config.zig next to `topbar_str` and every other syntactic choice — the -//! whole remapping surface belongs in one file a user can read top to bottom, -//! not scattered a line at a time across thirty-three structs. +//! Command structs and runtime settings form the builtin registry; bindings live in config.zig. const std = @import("std"); const pardes = @import("pardes.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; -const output_pane = @import("output_pane.zig"); -const image_pane = @import("image_pane.zig"); +const panes = @import("panes.zig"); +const layout = @import("layout.zig"); const config = @import("config.zig"); -const runtime_config = @import("runtime_config.zig"); -const board_memory = @import("board_memory.zig"); -/// The host half of the 9P client, for the `9p` word at the bottom. Imported -/// unconditionally and gated on `fs9_client.supported`, exactly like -/// board_memory above: nothing in it is analysed for a build whose platform -/// has no unix sockets, because the word is not registered there at all. -const fs9_client = @import("fs9_client.zig"); - -/// The platform's runtime-setting facilities, stated once as plain data. -/// Registry generation, leader paths, Config, and EffectCode all consume this -/// exact value rather than rebuilding equivalent-looking boolean expressions. -pub const capabilities: runtime_config.Capabilities = .{ +const builtin = @import("builtin"); +const Header = @import("esp32p4_gpio.zig").Header; +const limits = @import("memory.zig").limits; + +pub const capabilities: config.Runtime.Capabilities = .{ .font_picker = pardes.font_picker, - // A transition is composited by the shell, and EffectCode has to be able - // to show WHICH compositor: only the three hosted shells are in this - // package, so the hostless platforms have no honest source to print. .panel_transitions = pardes.hosted, .scene_shaders = pardes.platform == .gui or pardes.platform == .macos, // The tty's font belongs to its emulator, and the P4 firmware's belongs to @@ -50,26 +19,14 @@ pub const capabilities: runtime_config.Capabilities = .{ .tagline_font_size = pardes.platform != .tty and pardes.platform != .esp32p4, }; -/// What a builtin gets to act on. One bundle rather than five parameters -/// because most builtins want two of them and zig rejects the unused rest. -/// `txt` is the executed text (Restore reads its path back out of it) and -/// `arg` the execute's ARGUMENT — text typed after the name, or the selection -/// a mouse chord kept, which is why Grep and Find run straight away when there -/// is one instead of asking. The leader passes "" and null: a key path names a -/// builtin, never an argument. pub const Ctx = struct { p: *Pardes, - /// pane `id`, already resolved — the dispatcher's null check is the one - /// guard every builtin used to share. pane: *Pane, id: usize, txt: []const u8, arg: ?[]const u8, }; -/// Every enabled builtin, in source order. Feature gates are explicit data; -/// a declaration that claims to be enabled but has the wrong run signature is -/// a compile error instead of silently disappearing from the command enum. fn isEnabled(comptime T: type) bool { return !@hasDecl(T, "enabled") or T.enabled; } @@ -108,18 +65,18 @@ fn manualBuiltinList() [manualBuiltinCount()]type { fn settingCount() comptime_int { comptime { var count = 0; - for (runtime_config.settings) |setting| if (setting.enabled(capabilities)) { + for (config.Runtime.settings) |setting| if (setting.enabled(capabilities)) { count += 1; }; return count; } } -fn settingList() [settingCount()]runtime_config.Setting { +fn settingList() [settingCount()]config.Runtime.Setting { comptime { - var list: [settingCount()]runtime_config.Setting = undefined; + var list: [settingCount()]config.Runtime.Setting = undefined; var count = 0; - for (runtime_config.settings) |setting| if (setting.enabled(capabilities)) { + for (config.Runtime.settings) |setting| if (setting.enabled(capabilities)) { list[count] = setting; count += 1; }; @@ -127,11 +84,6 @@ fn settingList() [settingCount()]runtime_config.Setting { } } -/// A builtin's user-visible word: the LAST dotted segment of `@typeName`, -/// because @typeName spells a file-scope struct fully qualified -/// ("builtins.Kill"). Deriving it beats a `pub const name` field per struct, -/// which would be the same word written twice with nothing keeping the two -/// honest. A name that is not a legal identifier would be spelled `@"..."`. pub fn word(comptime T: type) []const u8 { const n = @typeName(T); const dot = std.mem.lastIndexOfScalar(u8, n, '.') orelse return n; @@ -144,28 +96,14 @@ pub const OutputTraits = struct { jumps: bool = false, commands: bool = false, doc: bool = false, - /// the buffer BECOMES an ordinary file once written (the New scratch, and a - /// real file, which is one already). Every other output buffer is a - /// RENDERING: Save writes its text out and the buffer stays what it is, - /// refillable and steppable, because a saved copy of a search is a copy of - /// a search and not the search. + // Saving promotes this scratch buffer into an ordinary file. saves: bool = false, }; -/// The enum: field name = struct name, value = index into `all()`. Everything -/// downstream (leader_path's EnumArray, leader_rows, the topbar's comptime -/// check, stringToEnum) speaks it exactly as it did when it was hand-written. -/// -/// Registry-dependent APIs live in one namespace so the outer declaration -/// walk only sees this namespace's type, not functions whose signatures depend -/// on the builtin enum being constructed. +// Keep enum-dependent signatures out of the outer declaration walk. pub const registry = struct { pub fn Builtin() type { - // The duplicate-name check below is O(n^2) string comparisons over every manual builtin AND - // every generated setting, so this quota grows quadratically with the builtin count. 20,000 - // was enough until three more (Peek/Poke/Hexdump) tipped `-Dplatform=gui` over with - // "evaluation exceeded 20000 backwards branches". Raised with room rather than to the next - // value that happens to pass, so the next builtin does not have to rediscover this. + // Duplicate-name validation compares every pair. @setEvalBranchQuota(200_000); const manual = manualBuiltinList(); const generated = settingList(); @@ -206,7 +144,7 @@ pub const registry = struct { test "capabilities exactly gate setting and effect-source builtins" { const Builtin = registry.Builtin(); - for (runtime_config.settings) |setting| { + for (config.Runtime.settings) |setting| { const registered = std.meta.stringToEnum(Builtin, setting.word) != null; try std.testing.expectEqual(setting.enabled(capabilities), registered); } @@ -214,34 +152,15 @@ test "capabilities exactly gate setting and effect-source builtins" { try std.testing.expectEqual(EffectCode.enabled, effect_code_registered); } -// The three memory words, checked the same way but at COMPTIME rather than in -// a test, because the property is about builds this test binary is not: the -// tty suite can only ever observe its own platform, and what matters is that -// `-Dplatform=web -Dtarget=wasm32-freestanding` does not quietly hand a -// browser tab a Poke. Every build of every platform now proves its own half. comptime { for ([_][]const u8{ "Peek", "Poke", "Hexdump" }) |name| - if (@hasField(registry.Builtin(), name) != board_memory.enabled) @compileError( + if (@hasField(registry.Builtin(), name) != Board.enabled) @compileError( "bare-metal memory word gating leaked: " ++ name, ); } // ---- the two acme verbs ---- -// Look and Execute are the verbs the whole environment is built on, and they -// are BUILTINS: `Look main.zig` typed in a tag and executed is the same look a -// right click on `main.zig` is, `Exec ls` the same as a middle click on `ls`. -// The mouse buttons and Enter/Tab are not a second path into them any more — -// they are two bindings pointing here (config.look_cmd / exec_cmd), the status -// `SPC f s` has relative to Save. That is the whole feature: what used to be a -// `button` parameter threaded through every keyboard call site, with the -// builtin dispatch nested INSIDE it, is now one word each. -// -// The operand is `arg` in both — a name's tail (`Look main.zig`), else the -// selection a chord kept, else the word the gesture pointed at, which the -// gesture resolves and passes. Nothing to act on means nothing happens, the -// way `Save` on a terminal is inert. - pub const Look = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { @@ -276,38 +195,19 @@ pub const Dump = struct { pub const Restore = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { - var it = std.mem.tokenizeAny(u8, c.txt, " \t"); - _ = it.next(); // the word "Restore" - const path = it.next() orelse (c.p.last_dump orelse return); + const path = c.arg orelse (c.p.last_dump orelse return); if (path.len > c.p.restore_buf.len) return; @memcpy(c.p.restore_buf[0..path.len], path); c.p.restore_req = c.p.restore_buf[0..path.len]; } }; -/// `Attach [name]` — hand this frontend's screen to a detached core, the one -/// `pardes --detach [name]` left running. Bare, it means "the session that is -/// there", which is the case worth typing: one detached session, and one word -/// to walk back into it. -/// -/// Nothing is torn down HERE, and that is the feature rather than an omission. -/// The effect only ASKS; the shell connects first and swaps second, so an -/// Attach that reaches nothing leaves this instance with every pane and every -/// undo exactly where they were and a line on the message row. Absent where -/// there is no unix socket to attach to — the browser and the board — and also -/// absent where the frontend would never NOTICE the request: see -/// `pardes.can_attach`, which is narrower than `hosted` because macOS never -/// polls `takeAttach`, so the word would have queued an effect and then done -/// nothing at all. pub const Attach = struct { pub const takes_arg = true; pub const enabled = pardes.can_attach; pub fn run(c: Ctx) void { if (comptime enabled) ask(c) else unreachable; } - /// A name too long for `Effect.attach` is too long for `sun_path` several - /// times over, so it can never name a session: reporting it here is the - /// same answer a failed connect gets, one round trip earlier. fn ask(c: Ctx) void { const name = c.arg orelse ""; if (name.len > pardes.attach_name_max) @@ -316,17 +216,6 @@ pub const Attach = struct { } }; -/// `Detach` — leave the session and let it carry on without you, which is -/// tmux's detach-client. Executed inside an ATTACHED frontend, where it -/// travels to the daemon as an ordinary command line, is run by the core that -/// owns the panes, and comes back as the effect that dismisses the screen -/// which asked for it. Hence no argument: the daemon knows who typed. -/// -/// It is NOT `Attach` backwards, and no word here is. Making a live local -/// session outlive its terminal means setsid and a fork; a word that pretended -/// to would hand you a session that dies with the window it was typed in. Run -/// locally this therefore REPORTS rather than acts — see the `.detach` arm of -/// Pardes.perform, which finds no host method to call. pub const Detach = struct { pub const enabled = pardes.can_attach; pub fn run(c: Ctx) void { @@ -337,26 +226,33 @@ pub const Detach = struct { } }; +pub const Mount = struct { + pub const takes_arg = true; + pub const enabled = pardes.hosted; + pub fn run(c: Ctx) void { + if (comptime !enabled) unreachable; + var args = std.mem.tokenizeAny(u8, c.arg orelse "", " \t"); + const name = args.next() orelse return c.p.reportError(c.id, "Mount name dial", error.MissingArgument); + const dial = std.mem.trim(u8, args.rest(), " \t"); + if (dial.len == 0) return c.p.reportError(c.id, "Mount name dial", error.MissingArgument); + c.p.fs.mount(c.p.gpa, name, dial) catch |err| return c.p.reportError(c.id, "Mount", err); + } +}; + +pub const Unmount = struct { + pub const takes_arg = true; + pub const enabled = pardes.hosted; + pub fn run(c: Ctx) void { + if (comptime !enabled) unreachable; + var args = std.mem.tokenizeAny(u8, c.arg orelse "", " \t"); + const name = args.next() orelse return c.p.reportError(c.id, "Unmount name", error.MissingArgument); + if (args.next() != null) return c.p.reportError(c.id, "Unmount name", error.TooManyArguments); + pardes.filesystem.unmount(c.p, name) catch |err| return c.p.reportError(c.id, "Unmount", err); + } +}; + // ---- the message row ---- -/// TEXT onto this pane's transient message row — the row a failed save, a -/// refused Look and a language server that would not start all report through -/// (Pardes.setMessage, and Pardes.reportError one line above it). Every writer -/// of that row is something going wrong, so until this word there was no way -/// to look at it without breaking something on purpose: no wording could be -/// checked against a narrow pane, and no test could pin the row without -/// arranging a real failure first. -/// -/// Bare, it reports ITSELF through the error path, because that is the other -/// half of the same machinery — `reportError` is `setMessage` plus an -/// `: ` — and because a word that needs no argument to -/// demonstrate one is a word you can also just click. -/// -/// Whether the row is FREE is not asked here and is not this word's business: -/// an armed prompt outranks a message at render time, so posting under one is -/// stored and invisible, exactly as a save finishing under one is. Nor does -/// anything here decide when it goes away — your next key or click does, on -/// every pane at once, because a message is exactly as old as your last input. pub const Msg = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { @@ -367,53 +263,19 @@ pub const Msg = struct { } }; -// ---- display choices ---- -// -// The plain toggles, named theme/shell/font setters and animation effects are -// generated from runtime_config.settings. Keeping their command metadata and -// their query order in the same value table is what prevents a settable choice -// from disappearing from Config. Hand-written commands continue below. - -/// One step along the ring. With 228 themes in it this is no longer a way to -/// REACH a theme — ThemeSel is — but it is still the way to browse one, and the -/// browse got better rather than worse: the generated half is sorted by name, so -/// the neighbours of wherever you are are that theme's own variants (light, -/// hard, soft, the whole gruvbox family in a row). Kept as the topbar word and -/// SPC t n it has always been; a ring you can walk off the end of in three -/// clicks was never what made it useful. pub const NextColor = struct { pub fn run(c: Ctx) void { c.p.setThemeIndex((@as(usize, c.p.settings.theme) + 1) % pardes.themes.len); } }; -/// The theme BY NAME — `Theme acme`. The ring grew past the point where -/// cycling to the one you want is reasonable, so this is the way to ask for -/// one, and NextColor stays as the way to browse. Inert without an argument -/// (there is no theme called nothing), which is also why it has no leader path: -/// a key path names a builtin and can never carry the name of a theme. -/// -/// A LINEAR SCAN over 228 names, on a keystroke: the alternative is a comptime -/// name->index map, which is a second copy of the ring to build for a lookup -/// nobody will ever measure. 228 short string compares is microseconds, and it -/// happens once per theme change, not once per frame. -/// ...and the list of what Theme takes, as a buffer you walk. Its rows are -/// `Theme ` COMMANDS rather than locations, which is one flag on the -/// buffer (output_pane.Traits.commands) and changes what a step SELECTS: the -/// whole line, since there is no path inside it to pick out. Tab on what n -/// selected wears that theme — the same middle click on the row is — so -/// walking the list with n/Tab is trying them on, and stopping is choosing. pub const ThemeSel = struct { pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .commands = true }; pub fn run(c: Ctx) void { - output_pane.openThemes(c.p, c.id) catch |err| c.p.reportError(c.id, "themes", err); + panes.Output.openThemes(c.p, c.id) catch |err| c.p.reportError(c.id, "themes", err); } }; -/// Load one complete Theme value from a .zon file. Relative paths are rooted -/// at the per-user pardes directory, so an init line can simply say -/// `ThemeFile themes/mine.zon`. The native host owns the read and watch; the -/// core owns parsing and keeps the last valid value across a bad live edit. pub const ThemeFile = struct { pub const takes_arg = true; pub const enabled = pardes.hosted; @@ -425,9 +287,6 @@ pub const ThemeFile = struct { } }; -/// Materialize every compiled theme as editable ZON under -/// `/themes/builtin`. Filesystem work remains a host effect, just like -/// Dump and Save; the build-time ring itself is the sole source of the data. pub const DumpThemes = struct { pub const enabled = pardes.hosted; pub fn run(c: Ctx) void { @@ -441,33 +300,6 @@ pub const DumpThemes = struct { } }; -// ---- the GUI's font list, and NOTHING on any other platform ---- -// -// Runtime settings such as Font are generated from runtime_config.settings. -// FontSel remains hand-written because it opens a result pane. Its explicit -// `enabled` bit is the same feature gate the registry uses for PDF commands: -// disabled commands have no enum field, help row, or dispatcher case. - -/// The GUI font BY NAME — `Font DejaVuSansMono-Regular`, the way `Theme ` -/// takes a theme, and inert without an argument for the same reason (there is -/// no font called nothing). The name is a font FILE's stem, which is what the -/// picker lists; resolving it is a walk of the font directories that stops at -/// the first match, so nothing is cached and an install five seconds ago is -/// findable. -/// -/// The core cannot load a font — it has no rasterizer, no atlas and no window -/// — so this asks: the resolved PATH goes in runtime config, the shell takes it on -/// its next pass and re-rasters. Exactly the shape Restore already has. -/// ...and the list of what Font takes: every MONOSPACE font on the machine, -/// one `Font ` row each, in the picker ThemeSel already is (rows that -/// are commands, so n/N select each one WHOLE and Tab runs it — walking with -/// n and pressing Tab wears each font in turn, and picking one is stopping -/// there). -/// -/// Monospace only, which is the one judgement in the feature: the grid is a -/// fixed cell, so a proportional face is not a worse-looking option but an -/// unreadable one — and this picker EXECUTES what it steps onto, so listing -/// them would mean the list wearing one on the way past. See fonts.monospaced. pub const FontSel = struct { pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .commands = true }; pub const enabled = capabilities.font_picker; @@ -475,39 +307,30 @@ pub const FontSel = struct { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { - output_pane.openFonts(c.p, c.id) catch |err| c.p.reportError(c.id, "fonts", err); + panes.Output.openFonts(c.p, c.id) catch |err| c.p.reportError(c.id, "fonts", err); } }; -/// Toggle a native PDF between the reading-oriented fit-width view and the -/// whole-page-height view. The explicit feature gate omits the command, help -/// row, and dispatcher case when MuPDF is disabled. pub const PdfFit = struct { pub const enabled = pardes.pdf_enabled; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { - pardes.pdf_pane.toggleFit(c.pane); + panes.Pdf.toggleFit(c.pane); } }; -/// Cycle a native PDF through original pixels, a chroma-preserving themed -/// filter, and a full theme duotone. It has the same explicit feature gate as -/// PdfFit: absent without MuPDF and inert off a PDF pane. pub const PdfTint = struct { pub const enabled = pardes.pdf_enabled; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { - pardes.pdf_pane.toggleTint(c.pane); + panes.Pdf.toggleTint(c.pane); } }; -/// Show this PDF's document outline as a live, steppable output pane. The -/// command is absent from non-MuPDF builds and deliberately inert on every -/// other pane kind, like the two PDF display toggles above. pub const PdfSections = struct { pub const output: OutputTraits = .{ .name = config.pdf_sections_buffer, .steps = true }; pub const enabled = pardes.pdf_enabled; @@ -515,60 +338,33 @@ pub const PdfSections = struct { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { - pardes.pdf_pane.openSections(c.p, c.id); + panes.Pdf.openSections(c.p, c.id); } }; -// The image pane's three renderer toggles. They used to be executable words -// interpreted by a special tag dispatcher; as ordinary builtins they are -// pressable under SPC and listed by `SPC ?`. The tag now reports their plain -// live values without becoming a second mutation path. Each acts on the pane -// it runs in and is inert elsewhere, the way Save is on a terminal. - /// glyph art over the host's pixels pub const Petscii = struct { pub fn run(c: Ctx) void { - if (c.pane.image) |*state| image_pane.togglePetscii(state); + if (c.pane.image) |*state| panes.Image.toggleGlyphArt(state); } }; /// the C64 palette or the terminal's own 16 pub const Palette = struct { pub fn run(c: Ctx) void { - if (c.pane.image) |*state| image_pane.togglePalette(state); + if (c.pane.image) |*state| panes.Image.togglePalette(state); } }; /// add the printable ASCII bitmaps to the matcher's glyph set pub const Ascii = struct { pub fn run(c: Ctx) void { - if (c.pane.image) |*state| image_pane.toggleAscii(state); + if (c.pane.image) |*state| panes.Image.toggleAscii(state); } }; // ---- the system clipboard ---- -// helix's `` clipboard menu, and the ONLY five words in pardes that -// touch the desktop's clipboard. Everything else — `y`, `d`, `c`, `p`, `P`, -// `R`, the acme cut/paste chords — lives entirely in the internal register, -// which is helix's arrangement and, less abstractly, the reason deleting a -// character no longer throws away whatever you had copied from a browser. -// -// They are builtins rather than bare chords because the leader table is the -// remapping surface and a leader path names a builtin: spelling them here -// puts them in Help's index, makes them executable words like every other -// verb, and costs no second mechanism. Their paths ARE helix's letters, on -// the same leader helix uses — see config.leader_path. -// -// The two directions are not symmetric, and cannot be. Writing is a fire-off: -// the core owns the bytes and the shell copies them out. READING has to leave -// the core and come back — SDL and NSPasteboard answer inside the same drain, -// a browser answers a promise later, and a terminal answers over OSC 52 or, -// far more often, refuses outright. So a paste is a REQUEST (the -// read_clipboard effect) that may simply never be answered, and a `SPC p` -// that does nothing in a locked-down terminal is the honest outcome rather -// than a bug to paper over with the internal register. - pub const ClipYank = struct { pub fn run(c: Ctx) void { c.p.clipYank(c.pane, false); @@ -603,33 +399,16 @@ pub const ClipReplace = struct { // ---- panes and columns ---- -/// Write this pane's text out. A pane with a real file behind it writes THAT -/// file with no argument — acme's Put, what `:w` has always meant — and -/// that is the only pane Save can serve without being told where. -/// -/// Everywhere else the path is REQUIRED, so a bare `Save` asks for one exactly -/// the way Find and Grep ask for a pattern: the tag input arms prefilled with -/// the pane's directory and Enter commits it. A terminal writes its plaintext -/// scrollback and stays a terminal; an output buffer writes its rows and stays -/// an output buffer, still refillable and still walked by n/N — with the one -/// exception the New scratch has always been, an empty buffer whose whole -/// purpose is to become the file you name (output traits: `saves`). -/// -/// Images and PDFs hold nothing of their own that is unwritten, so the word is -/// inert there and absent from their tag. pub const Save = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { const path = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (path.len > 0) return c.p.saveTo(c.id, path); if (c.pane.file) |file| if (file.output == null) return c.p.saveFile(c.id); - if (c.pane.file != null or c.pane.isTerminal()) c.p.startSavePrompt(c.pane); + if (c.pane.file != null or c.pane.isTerminal()) c.p.startPrompt(c.pane, .save); } }; -/// An empty scratch buffer below the calling pane, inheriting its directory. -/// No file exists yet, so Save asks for a path prefilled with that directory -/// and, once written, the buffer becomes an ordinary file pane. pub const New = struct { pub const output: OutputTraits = .{ .name = config.scratch_buffer, .doc = true, .saves = true }; pub fn run(c: Ctx) void { @@ -646,22 +425,10 @@ pub const Newcol = struct { pub const Del = struct { pub fn run(c: Ctx) void { - c.p.absorbVWeight(c.id); - c.p.layoutRemove(c.id); - c.p.deinitPane(c.pane); - c.p.panes[c.id] = null; - if (c.p.active == c.id) c.p.active = c.p.prevFocus(c.id) orelse { - c.p.quit = true; - c.p.emit(.quit); - return; - }; + c.p.removePane(c.id) catch |err| c.p.reportError(c.id, "close", err); } }; -/// Project this terminal's displayed cell foregrounds and backgrounds through -/// the current Pardes theme. The emulator keeps its original colour state; -/// only this pane's rendered cells change, so OSC queries and later resets -/// remain truthful. pub const Filter = struct { pub fn run(c: Ctx) void { if (!c.pane.isTerminal()) return; @@ -671,22 +438,7 @@ pub const Filter = struct { pub const Delcol = struct { pub fn run(c: Ctx) void { - const f = c.p.layoutFindTerm(c.id) orelse return; - var ids: [pardes.MAX_PANES]usize = undefined; - const nids = c.p.col_n[f.col]; - for (0..nids) |k| ids[k] = c.p.col_terms[f.col][k]; - for (ids[0..nids]) |tid| { - if (c.p.panes[tid]) |tt| { - c.p.layoutRemove(tid); - c.p.deinitPane(tt); - c.p.panes[tid] = null; - } - } - if (c.p.panes[c.p.active] == null) c.p.active = c.p.prevFocus(c.p.active) orelse { - c.p.quit = true; - c.p.emit(.quit); - return; - }; + c.p.removeColumn(c.id) catch |err| c.p.reportError(c.id, "close column", err); } }; @@ -702,7 +454,7 @@ pub const Newtty = struct { /// The horizontal mirror of the vertical stacking `New` does. pub const Joincol = struct { pub fn run(c: Ctx) void { - c.p.joinCol(); + layout.joinCol(c.p); } }; @@ -717,35 +469,21 @@ pub const Tutor = struct { pub const Help = struct { pub const output: OutputTraits = .{ .name = config.help_buffer }; pub fn run(c: Ctx) void { - output_pane.openHelp(c.p, c.id, "") catch |err| c.p.reportError(c.id, "help", err); + panes.Output.openHelp(c.p, c.id, "") catch |err| c.p.reportError(c.id, "help", err); } }; -/// Where pardes read its startup commands from — the path, printed into an -/// output buffer, `SPC f c` or the word executed anywhere. -/// -/// The one question docs/config.md cannot answer, because the answer depends -/// on the machine: XDG_CONFIG_HOME if it is set and absolute, else -/// ~/Library/Application Support/pardes/init on macOS and -/// ~/.config/pardes/init everywhere else. Printing it beats documenting it — -/// the row is ordinary text, so a right click on it opens the file, and when -/// there is no file there yet the path is still exactly what you needed to -/// know. pub const Config = struct { pub const output: OutputTraits = .{ .name = config.config_buffer }; pub fn run(c: Ctx) void { - output_pane.openConfig(c.p, c.id) catch |err| c.p.reportError(c.id, "config", err); + panes.Output.openConfig(c.p, c.id) catch |err| c.p.reportError(c.id, "config", err); } }; -/// Read back what the message rows said. A message row is cleared by the next -/// keystroke, so anything reported while you were looking at another pane was -/// gone before you could read it — a failed save, a watcher's reload, a -/// builtin's complaint. pub const Messages = struct { pub const output: OutputTraits = .{ .name = config.messages_buffer }; pub fn run(c: Ctx) void { - output_pane.openMessages(c.p, c.id) catch |err| c.p.reportError(c.id, "messages", err); + panes.Output.openMessages(c.p, c.id) catch |err| c.p.reportError(c.id, "messages", err); } }; @@ -754,33 +492,34 @@ pub const Messages = struct { pub const Changelog = struct { pub const output: OutputTraits = .{ .name = config.changelog_buffer }; pub fn run(c: Ctx) void { - output_pane.openChangelog(c.p, c.id) catch |err| c.p.reportError(c.id, "changelog", err); + panes.Output.openChangelog(c.p, c.id) catch |err| c.p.reportError(c.id, "changelog", err); } }; -/// Source code for the concrete backend implementation of a Panel*/scene -/// effect. The bytes are embedded at build time, so this works from an -/// installed executable rather than depending on a source checkout. pub const EffectCode = struct { pub const takes_arg = true; pub const enabled = capabilities.panel_transitions or capabilities.scene_shaders; pub const output: OutputTraits = .{ .name = config.effect_code_buffer }; pub fn run(c: Ctx) void { if (comptime enabled) - output_pane.openEffectCode(c.p, c.id, c.arg orelse return) catch |err| + panes.Output.openEffectCode(c.p, c.id, c.arg orelse return) catch |err| c.p.reportError(c.id, "effect code", err) else unreachable; } }; -// ---- search ---- +pub const Mini = struct { + pub const takes_arg = true; + pub const output: OutputTraits = .{ .name = "Mini", .doc = true }; + + pub fn run(c: Ctx) void { + panes.Mini.open(c.p, c.id, c.arg orelse "") catch |err| + c.p.reportError(c.id, "mini", err); + } +}; -// The two builtins that ASK for something — Find walks file NAMES under this -// pane's directory, Grep file CONTENTS under every pane's. With an argument -// there is nothing to ask: it IS the pattern, so the walk runs now (this is -// what a `Grep` executed with a selection chorded to it means). Without one -// they arm the same tag input `/` does, and Enter runs it (submitSearch). +// ---- search ---- pub const Find = struct { pub const takes_arg = true; @@ -789,7 +528,7 @@ pub const Find = struct { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) return c.p.runSearch(c.id, pat, .find, .top) catch |err| c.p.reportError(c.id, "find", err); - c.p.startSearch(c.pane, config.find_marker); + c.p.startPrompt(c.pane, .{ .search = config.find_marker }); } }; @@ -802,51 +541,38 @@ pub const Grep = struct { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep, .top) catch |err| c.p.reportError(c.id, "grep", err); - c.p.startSearch(c.pane, config.grep_marker); + c.p.startPrompt(c.pane, .{ .search = config.grep_marker }); } }; // ---- the window group ---- -// The DESTINATION is the name — a word, the way a tag holds Del or Save — -// because these names live in the same vocabulary as everything else here: -// `Wh` would be a leader key path leaking into the text you can middle-click. -// Plain English words are safe for exactly these five: focus is the cheapest -// thing to change by accident (nothing is edited, closed or written) and the -// way back is the opposite word. - pub const Left = struct { pub fn run(c: Ctx) void { - c.p.focusDir(c.id, .left); + layout.focusDir(c.p, c.id, .left); } }; pub const Down = struct { pub fn run(c: Ctx) void { - c.p.focusDir(c.id, .down); + layout.focusDir(c.p, c.id, .down); } }; pub const Up = struct { pub fn run(c: Ctx) void { - c.p.focusDir(c.id, .up); + layout.focusDir(c.p, c.id, .up); } }; pub const Right = struct { pub fn run(c: Ctx) void { - c.p.focusDir(c.id, .right); + layout.focusDir(c.p, c.id, .right); } }; // ---- the jump group ---- -// Where focus HAS BEEN, as three verbs and a list over the one stack pardes -// keeps (Pardes.jumps — see trackJump for what gets onto it). Builtins rather -// than bare key handlers for the same reason the four directions are: one -// implementation, reachable by chord, by `SPC j ...`, and by executing the -// word wherever it is written. - /// Ctrl-o: one step back into the history. pub const Back = struct { pub fn run(c: Ctx) void { @@ -861,65 +587,27 @@ pub const Forward = struct { } }; -/// vim's Ctrl-^: the pane you were in before this one, whichever it was — the -/// hop you press twice a minute and never want to count steps for. Body-normal -/// Esc is this, which is what makes alternating between two panes one key you -/// hold down: two files, or a file and its shell, or a file and a +Search. -/// -/// It does NOT move the stack cursor: it goes somewhere, so trackJump records -/// it like any other move, and that is exactly what makes it an involution — -/// after the hop, the pane you came from is the newest OTHER pane, so pressing -/// it again comes straight back. Back/Forward walk history; this one makes it. -/// -/// It replaced a `Toggleterm` that hopped specifically between the newest DOC -/// and the newest TERMINAL. That distinction never earned its keep: it made Esc -/// unpredictable (which of three panes you landed on depended on their kinds), -/// and it could not alternate between two files at all — the case you hit most. -/// "The pane before this one" needs no kinds and is the same key twice. pub const Last = struct { pub fn run(c: Ctx) void { var i = c.p.njumps; while (i > 0) { i -= 1; const j = c.p.jumps[i]; - // `.keep`: Esc is a RETURN, and the pane still holds the view it - // was left with. Recentring it moved the whole screen to show a line - // that was, nearly always, already on it. - // - // Not `line = 0`, which focusPaneLine already understands as "focus - // and touch nothing": a background pane's view CAN move while you - // are away — the wheel scrolls the pane under the pointer, not the - // active one, and a resize recomputes geometry without revealing any - // cursor — so `.keep` restores the recorded cursor and lets - // ensureCursorVisible pull it back on screen by the least it can. + // Restore the cursor without recentering the pane's retained view. if (j.pane != c.id) return c.p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .keep); } } }; -/// The same stack, as text you can read and click. Not a copy of it and not a -/// second list kept in step — the buffer is RENDERED from the stack when you -/// ask, the way +Search is rendered from a walk. pub const Jumplist = struct { pub const output: OutputTraits = .{ .name = config.jumps_buffer, .steps = true }; pub fn run(c: Ctx) void { - output_pane.openJumps(c.p, c.id) catch |err| c.p.reportError(c.id, "jumplist", err); + panes.Output.openJumps(c.p, c.id) catch |err| c.p.reportError(c.id, "jumplist", err); } }; // ---- the language group ---- -// Reached as `SPC l ` — see leader_path for why the prefix -// exists. They are builtins rather than bare keys for the same reason Save is -// one: the word is executable wherever it appears, so a middle-click on -// `Hover` in a tag does what `SPC l k` does. The five GOTOS are not here — -// helix binds them under `g` as motions, and a motion has no business being a -// word you can click. -// -// Most of them are one call: ask, and let the answer land in lspResponse. -// Nothing here blocks or knows what a backend is — swapping backends changes -// lsp.query and not one line below. - pub const Hover = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .hover, ""); @@ -956,10 +644,6 @@ pub const WsDiagnostics = struct { } }; -// The four hierarchy words, protocol-only (LSP 3.16/3.17): the in-process -// Zig backend has no analyser for them, so in a `.zig` pane they answer -// nothing. helix has no binding for any of the four. - pub const Callers = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .incoming_calls, ""); @@ -984,16 +668,12 @@ pub const Subtypes = struct { } }; -// The two that need a word from the user, handled exactly the way Find and -// Grep are: an argument means run it now (a selection chorded onto the name), -// no argument arms the tag input and Enter submits (submitSearch). - pub const Rename = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (a.len > 0) return c.p.lspRequest(c.id, .rename, a); - c.p.startSearch(c.pane, config.rename_marker); + c.p.startPrompt(c.pane, .{ .search = config.rename_marker }); } }; @@ -1002,16 +682,10 @@ pub const WsSymbols = struct { pub fn run(c: Ctx) void { const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a); - c.p.startSearch(c.pane, config.symbol_marker); + c.p.startPrompt(c.pane, .{ .search = config.symbol_marker }); } }; -// Introspection. A language backend that answers nothing looks exactly like -// one that is broken — from the outside, `gd` doing nothing is both "there is -// no definition" and "the analyser threw and we swallowed it". These two are -// how you tell: Lspinfo says what the backend IS, Lspwhy says what it just DID -// and where it stopped. - pub const Lspinfo = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .status, ""); @@ -1024,123 +698,324 @@ pub const Lspwhy = struct { } }; -// ---- the machine's address space (bare metal only) ---- -// -// Three words gated by `board_memory.enabled`, which is a fact about the -// TARGET (freestanding, and not wasm) rather than about `pardes.platform` — -// see the reasoning there. Today that is exactly `-Dplatform=esp32p4`; what makes -// it the right predicate is that a second bare-metal port gets them without -// anyone remembering to add an enum arm, and the browser never does. -// Elsewhere they are absent from the command enum, the help index, the leader -// table and the dispatcher, which is the gate ThemeFile and DumpThemes -// already use. -// -// They are not a debugger and not a privilege: with no OS there is no MMU, no -// supervisor and no process, so pardes IS the system software and all 2^32 -// addresses are already its own. RAM, the peripheral registers behind the -// console it is talking to you over, and its own .text are one flat space, and -// a word that could reach only part of it would be pretending to be an -// application. What you actually reach for these for is the case a hosted -// editor never has: the display did not come up, and the question is whether -// the register you thought you wrote holds what you thought you wrote. -// -// Implementation, parsing, the volatile accesses and the clamp are all in -// board_memory.zig, the way the PDF words live in pdf_pane.zig — these three -// structs are the words, their argument contract, and where the answer goes. - -/// `Peek [count]` — count 32-bit words (default 1) as `addr: value` -/// rows, hex or decimal address, refused rather than trapped when unaligned. pub const Peek = struct { pub const takes_arg = true; - pub const enabled = board_memory.enabled; + pub const enabled = Board.enabled; pub const output: OutputTraits = .{ .name = config.peek_buffer }; pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; - } - fn apply(c: Ctx) void { - board_memory.peek(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "peek", err); + if (comptime enabled) { + Board.peek(c.p, c.id, c.arg orelse "") catch |err| + c.p.reportError(c.id, "peek", err); + } else unreachable; } }; -/// `Poke ` — one 32-bit store, answered on the message row with -/// the value written AND the value that reads back, which on MMIO is the -/// interesting half (see board_memory.poke). pub const Poke = struct { pub const takes_arg = true; - pub const enabled = board_memory.enabled; + pub const enabled = Board.enabled; pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; - } - fn apply(c: Ctx) void { - board_memory.poke(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "poke", err); + if (comptime enabled) { + Board.poke(c.p, c.id, c.arg orelse "") catch |err| + c.p.reportError(c.id, "poke", err); + } else unreachable; } }; -/// `Hexdump [len]` — len bytes (default 256) in `hexdump -C`'s layout. pub const Hexdump = struct { pub const takes_arg = true; - pub const enabled = board_memory.enabled; + pub const enabled = Board.enabled; pub const output: OutputTraits = .{ .name = config.hexdump_buffer }; pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; - } - fn apply(c: Ctx) void { - board_memory.hexdump(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "hexdump", err); + if (comptime enabled) { + Board.hexdump(c.p, c.id, c.arg orelse "") catch |err| + c.p.reportError(c.id, "hexdump", err); + } else unreachable; } }; -/// `Gpio ` — flip one pad, answered on the message row as `0->1`. Bare `Gpio` draws JP1's -/// pinout into a pane instead, because the first question about a header is which pins it has. -/// -/// The only word here whose argument is DECIMAL, and `board_memory.gpio` says why at length: a -/// GPIO number is part of a name, not an address. pub const Gpio = struct { pub const takes_arg = true; - pub const enabled = board_memory.enabled; + pub const enabled = Board.enabled; pub const output: OutputTraits = .{ .name = config.gpio_buffer }; pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; + if (comptime enabled) { + Board.gpio(c.p, c.id, c.arg orelse "") catch |err| + c.p.reportError(c.id, "gpio", err); + } else unreachable; } - fn apply(c: Ctx) void { - board_memory.gpio(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "gpio", err); - } -}; - -// ---- somebody else's tree ---- - -/// `9p ` — walk to a file in ANOTHER pardes's tree, read it, and -/// open the bytes in a pane. -/// -/// THE OTHER END OF `--fs9`, and the reason the client in `src/9p.zig` is not a -/// library with no caller: one pardes serves acme's control filesystem over -/// 9P2000 on a unix socket, and this word is the second one reading it. `9p -/// work /1/body` shows you what pane 1 of the session called `work` is holding, -/// from a pane in this session, with no mount and no `plan9port` in the way. -/// -/// A DIAL IS A NAME OR A PATH: `work` resolves through the same -/// `fs9_service.socketPath` that bound it, and anything with a `/` in it is a -/// socket path taken as given. Unix sockets only for now — a 9P server across a -/// network is tunnelled (docs/9p.typ §10), and this word is not the place to -/// decide otherwise. -/// -/// It BLOCKS while it fetches, bounded by `fs9_client.budget_ms`, exactly the -/// way Look blocks on a disk read; `src/fs9_client.zig` argues that at length -/// and enforces it with a deadline rather than a promise. -pub const @"9p" = struct { - pub const takes_arg = true; - pub const enabled = fs9_client.supported; - /// Prose and not a list: the bytes are a file's, so n/N walks its words the - /// way it walks any document's, and there is nothing here to step to. - pub const output: OutputTraits = .{ .name = config.ninep_buffer, .doc = true }; - pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; +}; + +const Board = struct { + const enabled = pardes.platform == .esp32p4; + + comptime { + if (enabled and pardes.hosted) @compileError("an OS is not bare metal"); + if (enabled and builtin.os.tag != .freestanding) @compileError("the P4 firmware is freestanding"); + if (enabled and builtin.target.cpu.arch.isWasm()) @compileError("wasm addresses are not a bus"); } - fn apply(c: Ctx) void { - fs9_client.fetch(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "9p", err); + + // Bound output sent over the board's 115200-baud console. + const max_bytes: u32 = 4096; + const max_words: u32 = max_bytes / 4; + + const address_space_end: u64 = 1 << 32; + + const Error = error{ + MissingAddress, + BadAddress, + BadCount, + MissingValue, + BadValue, + MisalignedAddress, + ExtraArgument, + BadPin, + NoPads, + }; + + fn parseHex(comptime T: type, tok: []const u8, bad: Error) Error!T { + const body = if (tok.len > 2 and tok[0] == '0' and (tok[1] | 0x20) == 'x') tok[2..] else tok; + return std.fmt.parseInt(T, body, 16) catch bad; + } + + fn parseAddr(tok: []const u8) Error!u32 { + return parseHex(u32, tok, Error.BadAddress); + } + + fn parseCount(tok: []const u8) Error!u64 { + return parseHex(u64, tok, Error.BadCount); + } + + fn parseValue(tok: []const u8) Error!u32 { + return parseHex(u32, tok, Error.BadValue); + } + + // Callers reject unaligned words; volatile accesses must reach the device. + fn readWord(addr: u32) u32 { + const cell: *allowzero const volatile u32 = @ptrFromInt(@as(usize, addr)); + return cell.*; + } + + fn writeWord(addr: u32, value: u32) void { + const cell: *allowzero volatile u32 = @ptrFromInt(@as(usize, addr)); + cell.* = value; + } + + fn readByte(addr: u32) u8 { + const cell: *allowzero const volatile u8 = @ptrFromInt(@as(usize, addr)); + return cell.*; + } + + const Limit = enum { + console, + space, + }; + + const Extent = struct { + count: u32, + limit: ?Limit, + }; + + fn extent(addr: u32, requested: u64, unit: u32, cap: u32) Extent { + var count = requested; + var limit: ?Limit = null; + if (count > cap) { + count = cap; + limit = .console; + } + const fits = (address_space_end - addr) / unit; + if (count > fits) { + count = fits; + limit = .space; + } + return .{ .count = @intCast(count), .limit = limit }; + } + + fn writeNote(w: *std.Io.Writer, e: Extent, requested: u64, unit_name: []const u8) !void { + switch (e.limit orelse return) { + .console => try w.print( + "clamped: 0x{x} {s} requested, 0x{x} shown (0x{x}-byte cap, one 115200-baud console)\n", + .{ requested, unit_name, e.count, max_bytes }, + ), + .space => try w.print( + "clamped: 0x{x} {s} requested, 0x{x} shown (the 32-bit address space ends at 0x100000000)\n", + .{ requested, unit_name, e.count }, + ), + } + } + + fn peek(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const requested = if (it.next()) |tok| try parseCount(tok) else 0x1; + if (it.next() != null) return Error.ExtraArgument; + if (addr % 4 != 0) return Error.MisalignedAddress; + + const e = extent(addr, requested, 4, max_words); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try writeNote(&out.writer, e, requested, "words"); + for (0..e.count) |i| { + const at = addr + @as(u32, @intCast(i * 4)); + try out.writer.print("{x:0>8}: {x:0>8}\n", .{ at, readWord(at) }); + } + const content = try out.toOwnedSlice(); + try fill(p, id, .{ .cmd = .Peek }, content); + } + + fn poke(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const value = try parseValue(it.next() orelse return Error.MissingValue); + if (it.next() != null) return Error.ExtraArgument; + if (addr % 4 != 0) return Error.MisalignedAddress; + + writeWord(addr, value); + const back = readWord(addr); + var buf: [96]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint( + &buf, + "{x:0>8}: wrote {x:0>8}, reads {x:0>8}", + .{ addr, value, back }, + ) catch unreachable); + } + + fn gpio(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const tok = it.next() orelse { + const content = try p.gpa.dupe(u8, Header.text); + return fill(p, id, .{ .cmd = .Gpio }, content); + }; + if (it.next() != null) return Error.ExtraArgument; + const pin = std.fmt.parseInt(u16, tok, 10) catch return Error.BadPin; + + const toggle = p.host.vtable.gpio_toggle orelse return Error.NoPads; + var was: u8 = 0; + var now: u8 = 0; + if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin; + + var buf: [48]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable); + } + + const row_bytes: u32 = limits.hexdump_row_bytes; + + fn hexdump(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const requested = if (it.next()) |tok| try parseCount(tok) else 0x100; + if (it.next() != null) return Error.ExtraArgument; + + const e = extent(addr, requested, 1, max_bytes); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try writeNote(&out.writer, e, requested, "bytes"); + var row: u32 = 0; + while (row < e.count) : (row += row_bytes) { + const n = @min(row_bytes, e.count - row); + var bytes: [row_bytes]u8 = undefined; + for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i))); + try out.writer.print("{x:0>8} ", .{addr + row}); + for (0..row_bytes) |i| { + if (i == row_bytes / 2) try out.writer.writeByte(' '); + if (i < n) + try out.writer.print(" {x:0>2}", .{bytes[i]}) + else + try out.writer.writeAll(" "); + } + try out.writer.writeAll(" |"); + for (0..n) |i| try out.writer.writeByte( + if (bytes[i] >= 0x20 and bytes[i] < 0x7f) bytes[i] else '.', + ); + try out.writer.writeAll("|\n"); + } + const content = try out.toOwnedSlice(); + try fill(p, id, .{ .cmd = .Hexdump }, content); + } + + fn fill(p: *Pardes, id: usize, from: panes.Output.Origin, content: []u8) !void { + const pane = p.panes[id] orelse { + p.gpa.free(content); + return error.MissingPane; + }; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + try panes.Output.fillResults(p, id, dir, from, "", content, null); + } + + test "the clamp reports the tighter bound and never wraps the address space" { + const eq = std.testing.expectEqual; + try eq(Extent{ .count = 3, .limit = null }, extent(0x4ff40000, 3, 4, max_words)); + try eq(Extent{ .count = max_words, .limit = .console }, extent(0x4ff40000, 99_999, 4, max_words)); + try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0, 100_000, 1, max_bytes)); + try eq(Extent{ .count = 16, .limit = .space }, extent(0xfffffff0, 64, 1, max_bytes)); + try eq(Extent{ .count = 4, .limit = .space }, extent(0xfffffff0, 64, 4, max_words)); + try eq(Extent{ .count = 0, .limit = .space }, extent(0xffffffff, 1, 4, max_words)); + try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0xffff0000, 1 << 20, 1, max_bytes)); + } + + test "a clamp note is written exactly when something was clamped" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + + try writeNote(&w, extent(0x4ff40000, 3, 4, max_words), 3, "words"); + try std.testing.expectEqualStrings("", w.buffered()); + + try writeNote(&w, extent(0x4ff40000, 99_999, 4, max_words), 99_999, "words"); + try std.testing.expectEqualStrings( + "clamped: 0x1869f words requested, 0x400 shown (0x1000-byte cap, one 115200-baud console)\n", + w.buffered(), + ); + + w = .fixed(&buf); + try writeNote(&w, extent(0xfffffff0, 64, 1, max_bytes), 64, "bytes"); + try std.testing.expectEqualStrings( + "clamped: 0x40 bytes requested, 0x10 shown (the 32-bit address space ends at 0x100000000)\n", + w.buffered(), + ); + } + + test "every literal is hex, with or without the prefix" { + const eq = std.testing.expectEqual; + try eq(0x4ff40000, parseAddr("0x4ff40000")); + try eq(0x4ff40000, parseAddr("4ff40000")); + try eq(0x4ff40000, parseAddr("0X4FF40000")); + try eq(0x4ff40000, parseAddr("4FF40000")); + try eq(0x100, parseCount("100")); + try eq(0x256, parseCount("256")); + try eq(0xdeadbeef, parseValue("deadbeef")); + try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000")); + try std.testing.expectError(Error.BadAddress, parseAddr("0x")); + try std.testing.expectError(Error.BadAddress, parseAddr("nope")); + try std.testing.expectError(Error.BadAddress, parseAddr("12g4")); + try std.testing.expectError(Error.BadCount, parseCount("-1")); + try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000")); + } + + test "the pinout fits the board's own grid, in two aligned columns" { + const cols: usize = @import("pardes_config").esp32p4_cols; + const usable = cols - 7; + + var rows: usize = 0; + var pins: usize = 0; + var first_bar: ?usize = null; + var it = std.mem.splitScalar(u8, Header.text, '\n'); + while (it.next()) |line| { + try std.testing.expect(line.len <= usable); + rows += 1; + const bar = std.mem.indexOfScalar(u8, line, '|') orelse continue; + if (line[line.len - 1] == '+') continue; + pins += 1; + if (first_bar) |b| try std.testing.expectEqual(b, bar) else first_bar = bar; + } + try std.testing.expectEqual(@as(usize, 13), pins); + try std.testing.expect(rows > 15); + + try std.testing.expect(std.mem.indexOf(u8, Header.text, "GPIO 20 | 17 |") != null); + try std.testing.expect(std.mem.indexOf(u8, Header.text, "| 8 | --") != null); + } + + test "board addresses are hexadecimal and GPIO numbers are decimal" { + try std.testing.expectEqual(@as(u16, 20), try std.fmt.parseInt(u16, "20", 10)); + try std.testing.expectEqual(@as(u32, 0x20), try parseAddr("20")); + try std.testing.expect(20 != 0x20); } }; diff --git a/src/config.zig b/src/config.zig index 331cb4ab..5dcfef01 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,39 +1,14 @@ -//! Every syntactic choice pardes makes, in one file: which key runs what, -//! which mouse button means what, how a looked-at word is SPELLED, and the -//! handful of layout numbers that are taste rather than structure. -//! -//! The point is the editing session, not the architecture: retargeting a key, -//! a chord or a piece of Look syntax is an edit HERE and nowhere else. Nothing -//! below is read at runtime from a file — this IS the config format, recompiled -//! — so a binding may be any comptime expression and a wrong one is a compile -//! error rather than a silent no-op. -//! -//! Order is deliberate. PART 1 is pardes's OWN vocabulary, the part a user -//! actually fiddles with, so it is where a reader lands. PART 2 is Look/Exec -//! syntax. PART 3 is the helix keymap, which is under a differential-testing -//! contract — see the banner there before touching it. -//! -//! These COMPILED bindings are distinct from the small startup command file -//! described in docs/config.md. That file can run builtins such as `Theme` -//! and `Font`; it does not replace or mutate this keymap at runtime. -//! -//! What is NOT a binding: a named key's own identity. Insert mode's Backspace, -//! Delete, Enter and Tab are dispatched on `Key.` in handleInsert and -//! stay there — Backspace deleting backwards is what the key IS, not a choice -//! anyone remaps. `Ctrl-w` deleting a word backwards is a choice, and it is -//! here. const std = @import("std"); +const builtin = @import("builtin"); +const layout = @import("layout.zig"); +const limits = @import("memory.zig").limits; const pardes = @import("pardes.zig"); const builtins = @import("builtins.zig"); const Key = pardes.Key; const Mouse = pardes.Mouse; const Builtin = builtins.registry.Builtin(); -/// One key press a binding matches. `shift` is only consulted when a binding -/// ASKS for it: shift is already carried in the codepoint for anything -/// printable (`A` is `A`, not shift-`a`), so the one chord that needs the flag -/// is Shift-Esc, on a key that has no shifted codepoint. pardes.zig's `hit` -/// is the matcher. +// Printable shift is encoded in cp; shift only constrains chords that request it. pub const Chord = struct { cp: u21, ctrl: bool = false, @@ -41,62 +16,17 @@ pub const Chord = struct { shift: bool = false, }; -// A binding is a LIST because most have two spellings that must reach the same -// arm — a letter and an arrow, `Ctrl-f` and PageDown. One list, one dispatch -// site; the `or` chains this replaces had the modifier logic written out three -// ways (the old is/isC/isA). - -// ============================================================================ -// PART 1 — pardes's own bindings. Nothing here is inherited from anywhere; -// these are the ones to fiddle with. -// ============================================================================ - -// ---- the SPC leader ---- - -/// opens the leader: a key path from here runs a BUILTIN with no arguments. -/// Body normal mode only — a tag is always insert, and a tty pane's keys -/// belong to the program. +// Leader paths apply in body normal mode, not tags or raw terminals. pub const leader: []const Chord = &.{.{ .cp = ' ' }}; -/// Help's key, honored at ANY depth: it lists what the prefix typed so far can -/// still reach. Also Help's own path below, so the character is spelled once. pub const leader_help: u8 = '?'; -/// what an unlisted builtin's path is until someone says otherwise: a value no -/// key path can be, so the loop at the bottom of the table can refuse it const undecided: []const u8 = ""; -/// SPC leader: ONE key path per builtin, the whole remapping surface. A new -/// enum field is a compile error until someone has DECIDED its path — that is -/// what `undecided` and the loop under the table are for, and it used to be -/// EnumArray.init's own doing (it demands every field). It cannot be any more: -/// the gui-only builtins are not fields of this literal's type on tty or web, -/// so the literal cannot name them and a default is the only way to have both. -/// Groups are just shared first letters (f files, h docs, c columns, t -/// toggles, s session, l language, w windows). -/// -/// `null` = this builtin's shortcut is not a leader path. Look and Exec are -/// the two: their shortcuts are Enter/Tab and the two mouse buttons below, and -/// a third spelling under SPC would be a key that does nothing you cannot -/// already do with the key your hand is on. The option is the honest type — -/// "every builtin has a leader path" was only ever true by accident. +// null means word/chord-only. Every enabled builtin must explicitly choose a path. pub const leader_path = paths: { var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{ .Help = &[_]u8{leader_help}, - // The whole LANGUAGE group lives under `l`, and pardes's own builtins keep - // the letters they always had — `SPC d` is Del, `SPC k` is Kill. - // - // Helix puts these on bare `` letters, and an earlier pass followed - // it there, which cost `d`, `k`, `s`, `h` and the session group. That is - // the wrong trade: those five are pardes's most-pressed keys and predate - // the language work, whereas an LSP command is something you reach for - // deliberately and can afford one more keystroke. Each one still keeps - // HELIX'S OWN LETTER inside the group, so the mapping is `X` -> - // `SPC l X` with nothing to re-learn but the prefix. - // - // The five GOTOS are untouched and remain exactly helix's — `gd` `gD` `gy` - // `gi` `gr`, plus `]d`/`[d` and `=`. Those never collided with anything, so - // there was never a reason to move them. They are in PART 3. .Hover = "lk", .Rename = "lr", .CodeAction = "la", @@ -107,110 +37,61 @@ pub const leader_path = paths: { .WsDiagnostics = "lD", .Lspinfo = "li", .Lspwhy = "lw", - // The hierarchy group, protocol servers only. `c`/`t` were free under - // `l`; helix has no spelling for these at all (they postdate its - // keymap), so the letters are pardes's own: who Calls me / whom I - // Call, and the Type lattice up / down. .Callers = "lc", .Callees = "lC", .Supertypes = "lt", .Subtypes = "lT", .Del = "d", - // A terminal-pane tag owns this presentation switch. It deliberately - // has no global leader path: executing the word beside that terminal - // makes the pane-local scope visible at the point of use. .Filter = null, - .Kill = "k", - // THE CLIPBOARD MENU, on helix's own five letters and nowhere else. - // These are the only paths in the table that keep their helix spelling - // unprefixed, and they can: `y` `Y` `p` `P` `R` were free at the top - // level, and moving them into a group would have made the one thing - // here that IS helix's leader stop looking like it. - // - // Bare `y`/`p`/`P`/`R` remain the DEFAULT register — that split is the - // whole design (see builtins.zig's clipboard section), and it is why - // an ordinary delete no longer reaches past the editor. + .Kill = null, .ClipYank = "y", .ClipYankMain = "Y", .ClipPaste = "p", .ClipPasteBefore = "P", .ClipReplace = "R", - // the `f` file group (spacemacs): Save left vim's `w` to join Find and - // New here, which frees `w` for the window group (SPC w h/j/k/l). .Save = "fs", .New = "fn", .Newtty = "nt", .Find = "ff", .Grep = "fg", - // the config FILE joins the file group: `SPC f c` says where pardes - // read (or would read) its startup commands from. .Config = "fc", .Tutor = "ht", .Changelog = "hc", - // `Messages` joins the help group because it answers the same kind of - // question they do — "what did that say?" — about lines that have - // already left the screen. .Messages = "hm", .Newcol = "cn", .Delcol = "cd", .Joincol = "cj", .Debug = "td", - // `Msg` takes the text to post, so it has no path, for the reason - // `Theme` and the two acme verbs below have none: a key path names a - // builtin and can never carry an argument. Bare `Msg` still runs — it - // reports itself through the error path. .Msg = null, .Colors = "tc", .Wrap = "tw", .Tagbottom = "tb", .NextColor = "tn", - // the theme picker joins the toggles it belongs with; `Theme` itself takes - // a NAME, and a key path can never carry one, so it has none (the same - // reason Look and Exec have none) .ThemeSel = "tt", .Theme = null, - // ...and `Shell` takes the name of a binary, so it has none either .Shell = null, - // the image toggles join the same `t` group; Palette takes `l` because - // `p` is Petscii's and `c` is Colors'. .Petscii = "tp", .Palette = "tl", .Ascii = "ta", .Dump = "sd", .Restore = "sr", - // the `w` window group `Save` vacated: the four directional focus moves - // the Ctrl-w prefix does, spelled h/j/k/l because focus IS a motion, plus - // `t` for the file<->terminal hop. .Left = "wh", .Down = "wj", .Up = "wk", .Right = "wl", - // the `j` JUMP group, its own letter rather than more of `w`: the window - // group moves focus by GEOMETRY (the pane left of this one), these move it - // by TIME (the pane I was in before). `o` and `i` are the letters of the - // chords that do the same thing, `jj` is the group's obvious verb, and - // `jl` is the list itself. .Back = "jo", .Forward = "ji", .Last = "jj", .Jumplist = "jl", - // the two acme verbs: keys and buttons, no leader path — see above .Look = null, + .Mini = null, .Exec = null, }); - // The GUI's font setting and picker, in the same `t` group as the theme - // picker they mirror. Their explicit availability metadata means tty and - // web Builtin enums have no fields for them. `Font` takes a NAME and - // `TaglineSize` takes a percentage, so neither has a path: a leader chord - // cannot carry either argument. if (builtins.capabilities.font_picker) { table.set(.FontSel, "tf"); table.set(.Font, null); table.set(.TaglineSize, null); } - // Panel transitions are implemented by the fixed cell grid in TTY and by - // shader-capable native GUI shells. The DOM web shell does not advertise - // them until it has an equivalent renderer. if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -229,105 +110,73 @@ pub const leader_path = paths: { table.set(.Ripple, "tR"); table.set(.Glitch, "tg"); } - // Takes the name of an effect builtin; a leader path cannot carry it. - // The web build has no runnable effect argument, so it has no command, - // help row, dispatcher case, or leader entry for EffectCode either. if (builtins.EffectCode.enabled) table.set(.EffectCode, null); - // Native-only filesystem theme commands. ThemeFile needs an operand and - // DumpThemes is intentionally occasional, so both stay word-executed - // rather than spending leader chords. if (pardes.hosted) { table.set(.ThemeFile, null); table.set(.DumpThemes, null); + table.set(.Mount, null); + table.set(.Unmount, null); } - // ...and the one native word that DOES earn a chord. Gated on `can_attach` - // and NOT on `hosted`, because this table may only name a builtin that - // exists: macOS is hosted but never polls `takeAttach`, so the two words - // below are compiled out there and naming them would be a compile error — - // which is the good outcome, and the reason the predicate exists. if (pardes.can_attach) { - // In the `s` session group beside Dump and Restore. Bare Attach means - // "whichever detached session is there", which is the whole case worth - // a key; the named form is typed, like every other builtin that takes - // an operand. Safe to press by accident, uniquely among the three: it - // connects before it swaps, so nothing to attach to costs you a - // message row. table.set(.Attach, "sa"); - // ...and the way back out, which is where the group runs out of - // letters: `sd` has been Dump's since before there was anything to - // detach from, and Detach is not worth breaking that muscle memory - // for. A capital where the lowercase is taken is what this table - // already does one group over (`lS` beside `ls`, `lD` beside `ld`). table.set(.Detach, "sD"); } - // The bare-metal memory words. Peek, Poke and Hexdump all take an ADDRESS, - // so none of them can have a leader path for the reason Theme and Msg have - // none: a key path names a builtin and can never carry an operand. if (builtins.Peek.enabled) { table.set(.Peek, null); table.set(.Poke, null); table.set(.Hexdump, null); table.set(.Gpio, null); } - // The 9P client word. Takes a dial AND a path, so it has no leader path - // for the reason the three above have none, twice over. Its gate is the - // presence of unix sockets, which is narrower than `hosted`. - if (builtins.@"9p".enabled) table.set(.@"9p", null); - // The pane-local PDF commands exist only in MuPDF builds through their - // explicit registry availability, so name their paths inside the same - // comptime branch. PdfTint/PdfFit retain their display slots and - // PdfSections takes the mnemonic `s` between them. if (pardes.pdf_enabled) { table.set(.PdfTint, "ti"); table.set(.PdfSections, "ts"); table.set(.PdfFit, "tz"); } - // ...and the property EnumArray.init used to give for free: every builtin - // this build HAS is a builtin someone decided a path (or a null) for. for (std.enums.values(Builtin)) |b| if (table.get(b)) |p| { if (std.mem.eql(u8, p, undecided)) @compileError("builtin has no leader path decided: " ++ @tagName(b)); }; break :paths table; }; -// ---- the acme chords ---- +test "Space-k is unbound while Kill and other k chords remain available" { + try std.testing.expect(leader_path.get(.Kill) == null); + try std.testing.expectEqualStrings("wk", leader_path.get(.Up).?); + try std.testing.expectEqualStrings("lk", leader_path.get(.Hover).?); + try std.testing.expectEqualStrings("d", leader_path.get(.Del).?); + for (pardes.builtin_rows) |row| if (row.cmd == .Kill) { + try std.testing.expect(row.path == null); + try std.testing.expect(std.mem.indexOf(u8, row.line, "SPC") == null); + try std.testing.expect(std.mem.indexOf(u8, row.line, "Kill") != null); + }; + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("first\nsecond\n"); + pane.cur_row = 1; + while (p.nextEffect()) |_| {} + p.update(.{ .key = .{ .cp = ' ' } }); + try std.testing.expect(p.leader_on); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expect(!p.leader_on and !p.quit); + try std.testing.expectEqual(@as(i32, 1), pane.cur_row); + while (p.nextEffect()) |effect| try std.testing.expect(effect != .quit); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(i32, 0), pane.cur_row); + try std.testing.expect(p.executeBuiltinLine(p.active, "Kill")); + try std.testing.expect(p.quit); +} -// Look and Execute, the two verbs the whole environment is built on. Each has -// a KEY and a mouse BUTTON, and they are the same two verbs: Enter on a word -// does what a right click on it does. Swap the two `_key` lines for the vim -// reading, where Enter runs the command line. (This pair replaced a -// `swap_enter_tab` bool, which could only ever exchange them — two bindings -// can also be moved somewhere else entirely.) pub const look_key: []const Chord = &.{.{ .cp = Key.enter }}; pub const exec_key: []const Chord = &.{.{ .cp = Key.tab }}; pub const look_button: Mouse.Button = .right; pub const exec_button: Mouse.Button = .middle; -/// sweep, focus, place the cursor, and the left half of every acme chord pub const select_button: Mouse.Button = .left; -/// ...and WHAT those four run. Look and Exec are ORDINARY builtins — the same -/// kind of thing Save and Grep are, executable by name wherever text lives -/// (`Look main.zig` in a tag does what a right click on `main.zig` does) — so -/// the four bindings above are bindings like any other, and these two lines -/// are the whole of what makes them special. Point `look_cmd` at `.Grep` and -/// Enter greps. pub const look_cmd: Builtin = .Look; pub const exec_cmd: Builtin = .Exec; -// ---- windows ---- - -/// helix's window prefix. It stays despite `SPC w` covering the same four -/// builtins because it reaches one place the leader cannot: a pane in raw tty -/// mode never sees SPC (the shell owns every printable key), so this is the -/// only keyboard way out of one. +// Ctrl-w remains available when a terminal owns printable keys. pub const window_prefix: []const Chord = &.{.{ .cp = 'w', .ctrl = true }}; -/// The four directional focus moves, as data: `Ctrl-w ` in a -/// body, and the bare LETTER on a focused tagline, where focus IS the motion -/// (the arrows stay grapheme motion inside the tag, which is why the two -/// spellings are separate fields rather than one list). One table, two -/// readers — and the `cmd` column is what makes the chord discoverable from -/// the builtin as well as the other way round. pub const window_keys = [_]struct { letter: Chord, arrow: Chord, cmd: Builtin }{ .{ .letter = .{ .cp = 'h' }, .arrow = .{ .cp = Key.left }, .cmd = .Left }, .{ .letter = .{ .cp = 'j' }, .arrow = .{ .cp = Key.down }, .cmd = .Down }, @@ -335,346 +184,92 @@ pub const window_keys = [_]struct { letter: Chord, arrow: Chord, cmd: Builtin }{ .{ .letter = .{ .cp = 'l' }, .arrow = .{ .cp = Key.right }, .cmd = .Right }, }; -/// global window ops, live in ANY mode (which is why they are Alt-, not a -/// leader path): a new terminal below, and moving this terminal into a fresh -/// column. Alt-c is a deliberate divergence from helix's change-noyank — -/// hxdiff waives it by name. pub const new_shell_below: []const Chord = &.{.{ .cp = 'n', .alt = true }}; pub const pane_to_new_column: []const Chord = &.{.{ .cp = 'c', .alt = true }}; -// ---- jumps ---- - -/// vim's Ctrl-o / Ctrl-i, walking the focus history back and forward. Global -/// in any mode, like the two Alt- ops above and for the same reason: getting -/// BACK has to work from inside a pane that owns its keys. -/// -/// CAREFUL, and this is why the table has a comment: **Ctrl-i is Tab**. On the -/// wire they are the same byte (0x09), so on a host that speaks only the -/// legacy encoding this binding never fires and 0x09 keeps meaning `exec_key` -/// below — which is the right way round, since Tab-executes is the older and -/// more used of the two. Where the host speaks the kitty keyboard protocol -/// (`CSI 105;5u`) the two are distinct keys and both work. Shift-Esc -/// (tty_toggle_alt) is already spelled on that same bet. -/// -/// Shaped like window_keys: the `cmd` column is what makes the chord -/// discoverable from the builtin as well as the other way round, and it is -/// what keeps ONE implementation — pressing the chord and executing the word -/// `Back` are the same call. +// Legacy Ctrl-i is Tab; distinguishing them requires the kitty keyboard protocol. pub const jump_keys = [_]struct { chord: Chord, cmd: Builtin }{ .{ .chord = .{ .cp = 'o', .ctrl = true }, .cmd = .Back }, .{ .chord = .{ .cp = 'i', .ctrl = true }, .cmd = .Forward }, }; -/// `j` off the topbar drops back onto a tagline — the mirror of the `k` that -/// got you there (window_keys' letter, answered by tagNormalKey). pub const topbar_down: []const Chord = &.{.{ .cp = 'j' }}; -/// the topbar has no neighbouring window to walk to, so up here h/l are plain -/// grapheme motion instead pub const topbar_left: []const Chord = &.{.{ .cp = 'h' }}; pub const topbar_right: []const Chord = &.{.{ .cp = 'l' }}; -/// LEAVE the pane you are in — the `Last` builtin — from a pane whose own -/// plain Escape already means something else. A terminal in raw tty mode has -/// had it since it existed (tty_toggle_alt below, same chord, same job); a PDF -/// needs it because Escape there cancels the selection and the search overlay -/// without moving focus out of the document you are reading. -/// -/// On a host that reports no modifier on Escape it arrives as a plain Escape -/// and still means what Escape always means in that pane. pub const leave_pane: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; -// ---- raw tty mode ---- - -/// Ctrl- toggles raw tty mode in and out (terminals only); tty is -/// deliberately off the normal editing path. Not a Chord because the shell can -/// override it at runtime (`--tty-toggle`), so this is only Options' default. pub const tty_toggle_default: u21 = 'b'; -/// the second spelling, for hosts that report modifiers on Escape (the kitty -/// keyboard protocol). Where they don't it arrives as a plain Escape and still -/// means what Escape always means. pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; -/// Paste INTO the program a tty pane is running. `SPC p` and the acme 1-3 -/// chord cannot be reached there — the pty owns every keystroke and every -/// button — so raw tty mode needs its own pair, and these are the two a -/// terminal user already has in their hands. -/// -/// The split is the one the whole clipboard design rests on: Ctrl-V types the -/// DEFAULT register (what `y` put there, no round trip, no desktop involved), -/// Ctrl-Shift-V asks for the SYSTEM clipboard. Two spellings for the second -/// because a host may or may not fold the shift into the codepoint, and it -/// must be tested BEFORE the first: `hit` ignores an unasked shift, so plain -/// Ctrl-V matches a shifted key too. -/// -/// What this TAKES: forwardKey encoded both as the same byte, 0x16, so -/// Ctrl-Shift-V was a duplicate ^V and costs nothing to claim. Ctrl-V was -/// readline's quoted-insert, and that one is now unreachable in a tty pane — -/// the trade a terminal user expects, and one line to give back. +// Test the shifted/system-clipboard chord first; unrequested shift is ignored. pub const tty_paste: []const Chord = &.{.{ .cp = 'v', .ctrl = true }}; pub const tty_paste_clipboard: []const Chord = &.{ .{ .cp = 'v', .ctrl = true, .shift = true }, .{ .cp = 'V', .ctrl = true }, }; -/// What LEAVING raw tty mode hides on the shell's prompt rows. -/// -/// A prompt is CHROME. `user@host ~/src $` is redrawn on every keystroke, says -/// nothing a second time, and is never what you want to select, look at or -/// edit — so blanking it is most of what turns a scrollback into a readable -/// document, and pardes has always done it (OSC 133 is how it knows). -/// -/// What it USED to take with it was the command you had typed at that prompt, -/// because the two share a grid row and the row was the unit. That command is -/// content: the one thing on the row worth keeping, and the thing you reach -/// for `b` to get at in the first place. OSC 133 marks the two separately — -/// per CELL, not just per row — so `.prompt` blanks the prompt's own cells and -/// leaves the input sitting in the COLUMNS it really occupies. Those columns -/// are not cosmetic: clicking the command in normal mode and pressing the -/// toggle carries the click into the shell's own cursor (promptClickMove), -/// which counts them. -/// -/// `.prompt_and_input` is the older behaviour, kept for anyone who wants a -/// terminal to read as output and nothing else. +// OSC 133 prompt cells are hidden in normal mode; input columns stay intact. pub const tty_blank: enum { prompt, prompt_and_input } = .prompt; -/// The WCAG contrast ratio a filtered terminal foreground has to keep against -/// the default background before `Filter` will paint it in the theme colour -/// the projection chose. 1.0 is "the same colour"; 21.0 is black on white. -/// -/// `Filter` maps the default foreground and background roles FIRST — they are -/// the anchors Ghostty generates the 256-colour projection from — and every -/// other colour after them, by reducing it to its nearest canonical xterm key -/// and reading that key out of the projection. That reduction is a distance -/// between two RGB triples: it knows about hue and nothing about the page. The -/// cube's own corners ARE the two anchors, so the nearest key to a truecolour -/// extreme is the background itself — `\x1b[38;2;255;255;255m` on acme's -/// #ffffea paper resolved to #ffffea, ratio 1.000, text painted the colour of -/// the page under it. Every curated theme owns such a key: 231 on the light -/// one, 0 (ANSI black, which a shell writes with `\x1b[30m`) on both dark ones. -/// -/// A foreground that misses this floor is not mapped. It takes whichever of -/// the theme's own two anchors contrasts BETTER with the background actually -/// behind it, which is the choice the vendored renderer's `contrasted_color` -/// makes between white and black for the same reason. -/// -/// 1.5 is deliberately low: the point is legibility, not WCAG body text, and a -/// theme's comment and dim colours are MEANT to sit close to the page. Measured -/// across the curated three it rejects 12, 16 and 7 of 256 keys, where 3.0 -/// would reject 34, 92 and 41 and flatten a third of the dark palette. It also -/// has to stay below the contrast a theme's own pair achieves — 4.71 on `dark` -/// — or the fallback would fail the very test it answers. 1.0 accepts every -/// projected colour, collapses included. +// Projected colors below this contrast use the theme's more legible anchor. pub const tty_filter_min_contrast: f64 = 1.5; -// ---- the tag line and the topbar ---- - -// The topbar is a HAND-PICKED subset in a fixed order, not a derivation: row 0 -// is where topbar clicks land, so its exact bytes are load-bearing (every -// snapshot golden records the column each word starts at). Comptime-checked -// against the enum in pardes.zig so a rename cannot silently rot it. -// Colors and the scene/panel effects are NOT here: they are display switches -// you flip and forget, and a bar read every frame should not spend width on -// them now that their leader paths are discoverable through Help. NextColor -// stays — it is the one you cycle repeatedly, so a click beats a three-key -// path. -// -// Ordered by day-to-day usefulness, in stable functional groups: creation -// (New/Newcol), search/navigation (Find/Grep), learning (Help/Tutor), session -// persistence (Dump), appearance/diagnostics (NextColor/Debug), then the one -// destructive global action (Kill) exactly last. Find and Grep stay adjacent: -// the former matches file NAMES, the latter their CONTENTS. -// -// Help has to be here even though it is secondary. A bare `pardes` boots -// straight into tty mode (main.zig: `args.len == 1`), where every printable -// key belongs to the shell — so SPC never reaches the leader and `SPC ?`, the -// thing that would tell you the leader exists, is exactly what you cannot -// press. Row 0 is not a pane, so a middle-click on it is dispatched before any -// pane's mode is consulted: Help works in tty mode, which earns its width. pub const topbar_str = "New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill"; -/// The default editable tail of a pane's tag, per kind. Save LEADS wherever the -/// pane holds text of its own to write — a file, an output buffer, a terminal's -/// scrollback — because `:` parks at the tail boundary and the established -/// `:w` spelling walks to the first word from there. Images and PDFs get -/// the plain tail: their bytes on disk already are exactly what they are, so -/// there is nothing of the pane's own left to save. Terminals alone expose -/// Filter, the pane-local theme-keyed colour projection. pub const pane_builtins_str = "New Newtty Del"; pub const file_pane_builtins_str = "Save New Newtty Del"; pub const terminal_pane_builtins_str = "Save New Newtty Del Filter"; -/// Columns kept clear to the RIGHT of a tagline's builtins. The path stays at -/// the left edge and the builtins are pushed over to end this far short of the -/// pane's, which leaves somewhere to type: a word executed from the tagline is -/// how you run anything here, and with the builtins hard against the edge -/// there was nowhere to put one without first making room. -/// -/// The gap that does the pushing is made of ordinary spaces inside the tag, so -/// both it and this run are editable text — see Pardes.tagGap. Widen it and -/// every untouched tagline reflows on the next frame; taglines you have -/// already edited keep the spacing you left them with. pub const tag_right_pad: u16 = 20; -/// the pane's mode, as ONE character in the layout box at its top-left — live -/// chrome, not text you own. It used to be a three-letter word leading every -/// tagline; the box was already there carrying no information at all, so the -/// mode moved into it and the taglines got their four columns back. -/// -/// These are NOT the initials. A badge you read at a glance every time your -/// eye crosses a pane should LOOK like what it means, and each of these is a -/// mark that already means its mode somewhere else: `^` is the proofreader's -/// caret, the mark that says text goes in HERE; `$` is the shell prompt, and a -/// pane wearing it has the keyboard wired straight to the program on the other -/// end. NORMAL is a SPACE, and the empty box is the point: a pane at rest has -/// nothing waiting to eat what you type, and two thirds of the screen wearing -/// a bullet would be two thirds of the screen saying nothing loudly. (The -/// caret's true form is `‸` U+2038 and the ASCII `^` is only its -/// stand-in — but `^` is in every font ever made and `‸` is in about four, and -/// a mode badge that renders blank on someone's terminal is worse than one -/// spelled with the near-miss.) -/// -/// ONE CODEPOINT each. The box prints a single cell, so a two-character string -/// here would be pushed into one cell as a single grapheme and come out wrong. -/// A font missing the glyph draws a blank box, which is exactly what the box -/// drew before there was anything in it. +// Each mode badge is one codepoint. pub const tag_normal = " "; pub const tag_insert = "^"; pub const tag_tty = "$"; -/// ...and `img` stays a WORD at the head of an image pane's tagline, because -/// it is not a mode: it says what the pane IS, which no amount of watching the -/// box will tell you. The box on an image pane still shows its mode. pub const tag_image = "img"; -/// on a focused tag: yank what the chord would run (the selection, else the -/// word under the cursor). The path is selectable, so this is how you copy it. pub const tag_yank: []const Chord = &.{.{ .cp = 'y' }}; -// ---- the command line and search ---- - -/// vim's command line with acme's vocabulary: focus the pane's own tag in -/// normal mode, parked at the tail's start, and the execute chord runs the -/// word under the cursor (`:w` = Save). pub const command_line: []const Chord = &.{.{ .cp = ':' }}; -/// `/` types a pattern into the tag; n/N walk the results. Same keys on every -/// kind of pane — a terminal with no search armed falls back to n/N as a -/// motion over the lookable tokens in its output. -/// -/// Helix's letters, but NOT helix's commands (it searches by regex and pardes -/// has no regex engine), so these three sit out here rather than under the -/// contract in PART 3 — the differential suites never press them. pub const search: []const Chord = &.{.{ .cp = '/' }}; pub const search_next: []const Chord = &.{.{ .cp = 'n' }}; pub const search_prev: []const Chord = &.{.{ .cp = 'N' }}; -/// Helix `|`: in body normal mode, pipe every file selection through one -/// command typed in the pane's visible tag-tail input, and REPLACE each -/// selection with what the command wrote. pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }}; -/// Helix `A-|`: the same, and throw the output away. For a command run FOR its -/// effect — `| git add -` — where replacing the text with its chatter is the -/// last thing you want. pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; -/// Helix `!`: run a command with NO stdin and insert what it wrote BEFORE each -/// selection. `date`, a license header, the output of a generator. pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; -/// Helix `A-!`: the same, appended AFTER each selection. pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; -/// What the pane's tag-tail input shows while each of the four is armed, so -/// the prompt says which one you are in — they take the same command line and -/// do very different things to the buffer. `submitPipe` reads the command back -/// from after the marker, so these must stay distinct and non-empty. +// Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; pub const pipe_marker_append = " !+"; -/// Enter on an armed search input runs it; `escape` (PART 3) abandons it. pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }}; -// ---- mouse ---- - -/// wheel step, in rows / in columns. The horizontal step is bigger because a -/// column is narrower than a row is tall and a wheel tick should move a -/// comparable distance either way. pub const wheel_rows: i32 = 1; pub const wheel_cols: i32 = 4; -/// Enabled by default: rest the pointer over text for this many animation -/// frames before showing the exact span a right-click Look would expand. -/// Repeated motion inside the same cell does not restart the count; moving to -/// another cell does. Set to `null` to compile the preview out while retaining -/// ordinary mouse hover and resize-handle hints. pub const look_preview_delay_frames: ?u16 = 2; -/// GUI shells rasterize pane-tag text at this percentage of the body face -/// while retaining the same cell geometry. TTY ignores the visual role. +// Tag fonts keep body-cell geometry; valid sizes are 1...100 percent. pub const gui_tagline_font_percent: u8 = 82; comptime { if (gui_tagline_font_percent == 0 or gui_tagline_font_percent > 100) @compileError("config.gui_tagline_font_percent must be in 1...100"); } -/// Physical-pixel rule between the global topbar and pane taglines, in both -/// pixel GUIs (SDL and native macOS, which reach the shared rule in -/// `pardes.taglineBandOffset`). Their smaller font bands retain body-sized grid -/// rows; without an explicit join, centering both bands leaves the two unused -/// half-bands touching and makes a wide strip of the window background show -/// through. Zero disables the rule and joins the two bands directly. +// Physical pixels between topbar and pane tag bands; zero disables the rule. pub const gui_topbar_pane_border_px: u8 = 1; -/// Fixed RGB for that rule, or null to follow the active theme's scrollbar -/// track. The themed default stays quiet across light and dark themes while a -/// build that wants a deliberate accent can pin one here. +// null follows the theme's scrollbar track. pub const gui_topbar_pane_border_rgb: ?[3]u8 = null; -/// Open the SDL window with a transparent buffer, so that a theme declaring NO -/// background of its own (the curated `dark`, every vendored `*_transparent`) -/// shows the desktop through the grid instead of a colour this shell had to -/// invent. That is what the AppKit shell does over its NSVisualEffectView, and -/// on linux the compositor supplies the backdrop — a niri `background-effect -/// { blur true }` window rule, picom, whatever is running. -/// -/// OFF by default because it is not free, and the cost is structural rather -/// than ours: `SDL_ClaimWindowForGPUDevice` refuses a transparent window -/// outright ("The GPU API doesn't support transparent windows", SDL_gpu.c, -/// still upstream), because D3D12 has no transparent swapchain and the API -/// says no everywhere rather than only where it must. A transparent window -/// therefore has no swapchain to render into, and the frame reaches the screen -/// down the same readback-and-blit path a compositor that cannot back a Vulkan -/// swapchain already uses (`soft_present` in `src/gui/gui.zig`): one -/// GPU->CPU download plus one upload per PAINTED frame, measured at 1.2 ms for -/// 2240x1440 and 3.0 ms for 3840x2160 on an RTX 3050. Idle frames cost -/// nothing — this shell only paints when something changed — but an animation -/// at 60 Hz spends that every frame. -/// -/// With a theme that DOES bring a background this changes nothing visible: the -/// ground is painted at full alpha, exactly as an opaque window would. It -/// still pays the readback, because window transparency is fixed at creation -/// and a `Theme` command may reach a transparent theme later. +// Transparency requires the SDL readback/blit path, even with an opaque theme. pub const gui_transparent: bool = false; -/// Touchpad drift guard, in ticks. A two-finger swipe that is MEANT to be -/// vertical carries a little sideways drift, and the pad faithfully turns that -/// drift into wheel_left/wheel_right — so a plain scroll slides the view -/// sideways under you. Every vertical tick re-arms the guard to this many -/// ticks and every horizontal tick spends one instead of scrolling, which -/// makes horizontal EARN its way back: it has to land this many ticks in a row -/// with no vertical among them. 3, because drift arrives in ones and twos — -/// at 1 or 2 a doubled drift tick mid-swipe still gets through, and much -/// higher starts eating deliberate swipes. Set 0 to disable the heuristic. -/// -/// It costs nothing at rest: the guard is only armed by vertical scrolling, so -/// a horizontal swipe that starts from a still view moves on its FIRST tick. -/// Note this applies to a tilt wheel too, where "recent vertical" is a much -/// weaker signal of accident — a mouse would rather not have it. Living with -/// that is deliberate: the only honest fix is a per-device flag out of the -/// shell (libinput/SDL know which is which, vaxis does not), and paying for a -/// device-detection layer to spare a tilt wheel three clicks after a scroll is -/// a worse trade than the three clicks. +// A vertical wheel tick suppresses this many subsequent horizontal ticks. pub const wheel_guard_ticks: u8 = 3; -/// The whole guard, as one state machine, so it can be tested as one thing: -/// fold a wheel tick into `guard` and answer whether it scrolls. The core owns -/// the counter (Pardes.wheel_guard) because the gesture belongs to the DEVICE, -/// not to whichever pane the pointer happens to sit over. pub fn wheelTick(guard: *u8, vertical: bool) bool { if (vertical) { guard.* = wheel_guard_ticks; @@ -685,107 +280,53 @@ pub fn wheelTick(guard: *u8, vertical: bool) bool { return false; } -// written to hold for ANY tuning of wheel_guard_ticks, since tuning it by hand -// is what this file is for — a test that pinned the number 3 would just be a -// second place to edit it test "wheel drift guard" { var g: u8 = 0; - // from rest, horizontal moves on the first tick — nothing to prove try std.testing.expect(wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); if (wheel_guard_ticks == 0) return; // guard disabled: nothing left to check - // a vertical swipe with drift mixed in: every sideways tick is swallowed, - // because each vertical tick re-arms the guard in full for (0..4) |_| try std.testing.expect(wheelTick(&g, true)); try std.testing.expect(!wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, true)); try std.testing.expect(!wheelTick(&g, false)); - // the deliberate horizontal swipe that follows pays off the rest of the - // guard tick by tick, then runs free for (1..wheel_guard_ticks) |_| try std.testing.expect(!wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); } -// ---- layout numbers that are taste ---- - -/// What a terminal pane runs until someone says otherwise (the Shell builtin, -/// or a `Shell ` line in the config file). A bare NAME, resolved against -/// the usual bin directories at spawn time — so a machine without it falls -/// back rather than opening a pane that dies at exec. pub const default_shell = "fish"; -/// the file pane's line-number gutter, in columns +// Minimum file gutter width, including the space after the line number. pub const PREFIX_W: u16 = 5; -/// Display width of a literal tab in every Surface-backed frontend. This is a -/// compile-time setting: edit it and rebuild; zero cannot advance the renderer. pub const tab_width: u16 = 4; comptime { if (tab_width == 0) @compileError("config.tab_width must be greater than zero"); } -/// soft wrap (the Wrap builtin): the glyph a wrapped row ends with, in the one -/// column bodyText keeps free for it. A break is the one thing about a wrapped -/// line you cannot see — the text simply continues, and a missing line number -/// on the row below is an absence, which is a poor thing to read a document by. -/// So the break says so at the point it happens, in the chrome's own colour -/// because it is not in the file. pub const wrap_marker = "↩"; -/// vim 'scrolloff': keyboard cursor moves keep this many context rows visible -/// above/below the cursor (clamped at file boundaries and short panes), and -/// the same count of COLUMNS horizontally +// Cursor motion preserves context in both rows and columns when space permits. pub const scroll_off = 3; -/// the pane's left chrome: scrollbar + the layout box in the tag row. The -/// scrollbar PAINTS only the first of these columns; the second is the pane's -/// own background, so the bar reads as one column with a column of page -/// between it and the text. Layout is untouched by that — the gutter is still -/// GUTTER columns and a click anywhere in them still scrolls; only the ink -/// narrowed. A half-block glyph in the second column was tried and rejected. pub const GUTTER: u16 = 2; -/// a pane never shrinks past this (the h-handle can still take it to its tag -/// row alone, which is BOX_H and a structural fact, not this) pub const MINW: u16 = 10; pub const MINH: u16 = 3; -// ============================================================================ -// PART 2 — Look/Exec syntax: how a click on text is SPELLED. What the -// resolution then DOES with it is look.zig. -// ============================================================================ - -/// file-ish word chars (acme's isfilec): alnum + these. This set is the whole -/// definition of "the word under the cursor" for Look, Execute, the tag chord -/// and every search result row. +// Accept every UTF-8 byte so word boundaries never split a codepoint. pub fn isFileChar(c: u8) bool { - // Non-ASCII bytes belong to their UTF-8 word as a unit. Bounds are byte - // offsets, so accepting every high byte keeps Unicode paths/identifiers - // intact instead of returning a slice through one codepoint. return c >= 0x80 or std.ascii.isAlphanumeric(c) or switch (c) { '.', '-', '+', '/', ':', '@', '_', '~' => true, else => false, }; } -/// `` @`ls -la` `` — a word that names a COMMAND to run rather than a file to -/// open. Both halves are named here because they are a CHOICE: the `@` marks -/// it as ours (it is already a file char, so it can never split a path) and -/// the backquotes hold a command line with spaces in it, which is the whole -/// point — a file-ish word cannot. Respell them here and nowhere else. pub const cmd_open = "@`"; pub const cmd_close: u8 = '`'; -/// The command inside `` @`...` ``, or null when `w` is not one. pub fn commandWord(w: []const u8) ?[]const u8 { if (w.len <= cmd_open.len or !std.mem.startsWith(u8, w, cmd_open)) return null; if (w[w.len - 1] != cmd_close) return null; return w[cmd_open.len .. w.len - 1]; } -/// The bounds of the word at `col` in `line` — THE expansion a no-drag -/// look/execute click and the tag chord both use. -/// -/// A `` @`...` `` run is taken WHOLE and wins outright: a backtick is not a -/// file char, so the plain scan below would stop dead inside one and hand a -/// look the fragment `ls` out of `` @`ls -la` ``. Acme does exactly this for -/// its own `<`/`|`/`>` command words. Otherwise it is the file-ish word. +// A complete command word wins over the ordinary file-character scan. pub fn wordBounds(line: []const u8, col: usize) struct { lo: usize, hi: usize } { var i: usize = 0; while (std.mem.indexOfPos(u8, line, i, cmd_open)) |o| { @@ -800,58 +341,19 @@ pub fn wordBounds(line: []const u8, col: usize) struct { lo: usize, hi: usize } return .{ .lo = lo, .hi = hi }; } -/// separates a path from its LINE and COL: `main.zig:100:7`. Must be a member -/// of isFileChar or the suffix would not be part of the word in the first -/// place. pub const line_col_sep: u8 = ':'; -/// ...and separates that spot from the END of a RANGE. A look at a ranged path -/// SELECTS the span rather than just parking on its first cell, which is what -/// lets a search result carry the text it matched and `n` land ON it. -/// -/// Three spellings. The long one subsumes the other two, but the short ones -/// are what a person actually types and what a grep-alike emits, so all three -/// parse: -/// main.zig:412-418 lines 412 through 418, whole -/// main.zig:412:9-21 line 412, columns 9 through 21 -/// main.zig:412:9-418:1 line 412 column 9 through line 418 column 1 -/// Both ends are INCLUSIVE and 1-based, like the spot they extend — `412-418` -/// reads as seven lines, not six. `main.zig:412` and `main.zig:412:9` keep -/// meaning exactly what they always did. -/// -/// Must be an isFileChar member, same as the separator above, or a click would -/// expand to half a range. That is also why reading it is FUSSY (look.zig, -/// parsePathLine): ordinary paths are full of dashes, so the suffix counts as -/// a range only when a NUMBER follows the dash — `my-file:10` and `build-2` -/// stay the paths they are. +// Ranges are inclusive and 1-based: path:2-4, path:2:3-7, path:2:3-4:1. pub const range_sep: u8 = '-'; -/// `@p7:10:5` — pane 7, line 10, column 5. The one look target that names a -/// live pane instead of a path, because terminals and output buffers have no -/// file for a location to point at. Both the writer (a `/` result row) and the -/// reader (look.resolve) spell it from here. +// @p7:10:5 addresses pane 7, line 10, column 5. pub const pane_addr = "@p"; -/// a word starting with one of these is a URL and leaves the app entirely: no -/// filesystem can answer it pub const url_schemes = [_][]const u8{ "http://", "https://" }; -/// a path ending in one of these opens an image pane instead of a file pane pub const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" }; -/// What `Ctrl-c` (comment_toggle) puts at the front of a line, by file -/// EXTENSION — which is how src/syntax.zig already tells one language from -/// another, so this is that same notion and not a second one. A pane whose -/// path matches nothing here (and every terminal, which has no path at all) -/// gets `comment_token_default`. -/// -/// `#` as the default is not a guess: it is helix's own DEFAULT_COMMENT_TOKEN, -/// which is what a helix buffer with no language configured comments with — -/// and therefore what the differential oracle answers, since the harness runs -/// with zero language configs. -/// Languages with no LINE comment at all (css, html, json, ocaml) are absent -/// on purpose: helix leaves those buffers on its default too, and inventing a -/// token for them would be a divergence nothing asked for. +// Match Helix's fallback for unknown languages and languages without line comments. pub const comment_token_default = "#"; pub const comment_tokens: []const struct { exts: []const []const u8, token: []const u8 } = &.{ .{ .token = "//", .exts = &.{ ".zig", ".zon", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".rs", ".go", ".java", ".scala", ".sc", ".kt", ".kts", ".cs", ".csx", ".php", ".pas", ".pp", ".p", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".typ", ".typst", ".swift", ".dart" } }, @@ -863,37 +365,16 @@ pub const comment_tokens: []const struct { exts: []const []const u8, token: []co .{ .token = "\"", .exts = &.{ ".vim", ".vimrc" } }, }; -/// What an armed search writes into the tag tail — and the ONLY record of -/// which search it is: Enter reads the marker back (submitSearch) instead of -/// pardes carrying a second piece of pane state per command. The pattern is -/// everything past the `/`, so a pattern may itself contain slashes; the word -/// before it is the builtin's own name, so the armed tag reads as the command -/// it will run. The bare `/` has no name — it searches the pane itself. pub const search_marker = " /"; pub const find_marker = " Find /"; pub const grep_marker = " Grep /"; pub const rename_marker = " Rename /"; pub const symbol_marker = " WsSymbols /"; -/// Save on a pane with no file of its own yet — an output buffer or a terminal: -/// the tail is the whole PATH to write (no `/` separator, since a path is made -/// of them), prefilled with the pane's directory so only a filename need be -/// typed. pub const save_marker = " Save "; -/// helix `s` / `S`. The only two markers whose word is NOT a builtin — there -/// is no Select/Split command to run from a tag, they name the key that armed -/// the input so the tag still reads as what it is about to do. They also mark -/// the one input that previews as you type (pardes.zig, previewSelRegex). pub const select_marker = " Select /"; pub const split_marker = " Split /"; -/// The `|` prompt is not an executable tag word: the marker only makes the -/// pending shell filter visible, and everything after it is preserved as the -/// exact command passed to `/bin/sh -c`. pub const pipe_marker = " |"; -/// Output-buffer names (acme's +Errors). Cosmetic now, and deliberately so: a -/// buffer is DERIVED from the command that opened it (output_pane.traits), and -/// nothing identifies one by matching this text any more — renaming any of -/// these changes only what you read in a tag. pub const search_buffer = "+Search"; pub const help_buffer = "+Help"; pub const config_buffer = "+Config"; @@ -906,62 +387,25 @@ pub const hover_buffer = "+Hover"; pub const lsp_buffer = "+Lsp"; pub const changelog_buffer = "+Changelog"; pub const messages_buffer = "+Messages"; -/// What `9p ` opens a remote file into. NOT the remote path: an -/// output buffer's name comes off the command that filled it, and the path is -/// the command's ARGUMENT, which is what makes two remote files two panes. -pub const ninep_buffer = "+9p"; -/// The two memory windows a bare-metal build's Peek and Hexdump render. Absent -/// from every hosted build along with the builtins that name them. pub const peek_buffer = "+Peek"; pub const hexdump_buffer = "+Hexdump"; pub const gpio_buffer = "+Gpio"; -/// The empty buffer New and Newcol open: no file behind it yet, so Save asks -/// for a path (prefilled with the inherited directory). pub const scratch_buffer = "+New"; -/// acme's own `+Errors`, and the one output buffer no keystroke opens: a -/// script writes it, through a pane's `errors` file or the top-level `cons` -/// (src/acmefs.zig). pub const errors_buffer = "+Errors"; -// ============================================================================ -// PART 3 — THE HELIX KEYMAP. READ THIS BEFORE RETARGETING ANYTHING BELOW. -// -// These are not free choices. `zig build hxdiff` (481 cases) and -// `zig build hxparity` (561 cases) are DIFFERENTIAL suites: they drive a real -// helix and this core with the same keystrokes and compare the results, and a -// mismatch is a failure, not a diff to accept. Moving a key here therefore -// breaks the build until the divergence is written down as a waiver with a -// reason — which is the correct workflow for a DELIBERATE divergence (Alt-c -// above is one) and an alarm for an accidental one. -// -// Everything in PART 1 is outside that contract and free to move. -// ============================================================================ - -// ---- modal prefixes ---- - -// These are STORED — pane.pending / pending2 / find_op hold the codepoint -// ITSELF until the next key completes the sequence, and the continuation reads -// it back — so they are bare codepoints rather than Chords, and pardes.zig -// matches them with `isPrefix` instead of `hit`. A prefix must therefore be an -// unmodified printable key. Untyped so they compare against both the u21 and -// the u8 fields that hold them. +// Modal bindings below are checked against Helix by hxdiff and hxparity. pub const goto_prefix = 'g'; pub const view_prefix = 'z'; pub const match_prefix = 'm'; pub const replace_prefix = 'r'; pub const next_prefix = ']'; pub const prev_prefix = '['; -// f/F/t/T: the stored byte IS the operator — `f`/`t` mean forward and `t`/`T` -// mean stop short — so the four are read back as values, not just matched. pub const find_char_fwd = 'f'; pub const find_char_back = 'F'; pub const till_char_fwd = 't'; pub const till_char_back = 'T'; -/// repeat the last f/F/t/T pub const repeat_find: []const Chord = &.{.{ .cp = '.', .alt = true }}; -// ---- motion ---- - pub const move_left: []const Chord = &.{ .{ .cp = 'h' }, .{ .cp = Key.left } }; pub const move_right: []const Chord = &.{ .{ .cp = 'l' }, .{ .cp = Key.right } }; pub const move_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; @@ -975,31 +419,21 @@ pub const next_long_word_end: []const Chord = &.{.{ .cp = 'E' }}; pub const line_start: []const Chord = &.{ .{ .cp = '0' }, .{ .cp = Key.home } }; pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } }; pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }}; -/// helix goto_line: only acts WITH a count (bare G is a no-op; `ge` is -/// goto-last-line) +// Bare G does nothing; ge reaches the last line. pub const goto_line: []const Chord = &.{.{ .cp = 'G' }}; -/// grapheme motion in a ONE-LINE context (a tag, the topbar): the arrows only. -/// A tagline spends h/l on the layout (window_keys) and the topbar answers -/// them itself (topbar_left/right), so the letters are not in this vocabulary. pub const line_move_left: []const Chord = &.{.{ .cp = Key.left }}; pub const line_move_right: []const Chord = &.{.{ .cp = Key.right }}; -// ---- paging and the view ---- - pub const half_page_down: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; pub const half_page_up: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; pub const page_down: []const Chord = &.{ .{ .cp = 'f', .ctrl = true }, .{ .cp = Key.page_down } }; pub const page_up: []const Chord = &.{ .{ .cp = 'b', .ctrl = true }, .{ .cp = Key.page_up } }; -/// under `z`: put the cursor's line at the top / centre / bottom of the view pub const view_top: []const Chord = &.{.{ .cp = 't' }}; pub const view_center: []const Chord = &.{ .{ .cp = 'z' }, .{ .cp = 'c' } }; pub const view_bottom: []const Chord = &.{.{ .cp = 'b' }}; -/// under `z`: scroll the view one line, cursor snapped to the scrolloff edge pub const view_scroll_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; pub const view_scroll_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } }; -// ---- under `g` (goto) ---- - pub const goto_file_start: []const Chord = &.{.{ .cp = 'g' }}; pub const goto_last_line: []const Chord = &.{.{ .cp = 'e' }}; pub const goto_line_start: []const Chord = &.{.{ .cp = 'h' }}; @@ -1008,43 +442,27 @@ pub const goto_first_nonws: []const Chord = &.{.{ .cp = 's' }}; pub const goto_line_down: []const Chord = &.{.{ .cp = 'j' }}; pub const goto_line_up: []const Chord = &.{.{ .cp = 'k' }}; pub const goto_column: []const Chord = &.{.{ .cp = '|' }}; -/// view-relative rows (helix goto_window) pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; -// helix's five LSP gotos, all under `g` and nowhere else. They are motions, -// not builtins — a motion has no business being a word you can middle-click, -// which is why they are not in the language group under `SPC l`. pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; pub const goto_implementation: []const Chord = &.{.{ .cp = 'i' }}; pub const goto_references: []const Chord = &.{.{ .cp = 'r' }}; -// ---- under `m` (match) ---- - -/// `mm` acts at once, so it is an ordinary chord pub const match_bracket: []const Chord = &.{.{ .cp = 'm' }}; -// The other five are SUB-prefixes: each waits for a textobject or surround -// character (`mi(`, `mr[{`), so pardes stores them the way it stores `m` and -// they are bare codepoints for the same reason as the block above. pub const match_inside = 'i'; pub const match_around = 'a'; pub const surround_add = 's'; pub const surround_replace = 'r'; pub const surround_delete = 'd'; -// ---- under `]` / `[` ---- - pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }}; pub const add_newline: []const Chord = &.{.{ .cp = ' ' }}; -/// step the diagnostics list, asking for one if it is not up yet pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }}; -/// ]D / [D — the last / the first pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }}; -// ---- insert entry ---- - pub const insert: []const Chord = &.{.{ .cp = 'i' }}; pub const append: []const Chord = &.{.{ .cp = 'a' }}; pub const insert_line_start: []const Chord = &.{.{ .cp = 'I' }}; @@ -1052,8 +470,6 @@ pub const insert_line_end: []const Chord = &.{.{ .cp = 'A' }}; pub const open_below: []const Chord = &.{.{ .cp = 'o' }}; pub const open_above: []const Chord = &.{.{ .cp = 'O' }}; -// ---- selection ---- - pub const select_mode: []const Chord = &.{.{ .cp = 'v' }}; pub const select_line: []const Chord = &.{.{ .cp = 'x' }}; pub const select_line_bounds: []const Chord = &.{.{ .cp = 'X' }}; @@ -1062,13 +478,6 @@ pub const collapse_selection: []const Chord = &.{.{ .cp = ';' }}; pub const flip_selection: []const Chord = &.{.{ .cp = ';', .alt = true }}; pub const select_all: []const Chord = &.{.{ .cp = '%' }}; -// ---- multiple cursors ---- -// -// helix's Selection is a LIST of ranges with a primary index, and these ten -// keys are the ones that act on the list rather than on the text: every other -// key is replayed once per range instead (pardes.zig, replaySels). Alt-C is -// Alt-SHIFT-c and so does not collide with pane_to_new_column's Alt-c — `hit` -// compares the codepoint, and `C` is `C`. pub const copy_sel_below: []const Chord = &.{.{ .cp = 'C' }}; pub const copy_sel_above: []const Chord = &.{.{ .cp = 'C', .alt = true }}; pub const keep_primary_sel: []const Chord = &.{.{ .cp = ',' }}; @@ -1080,22 +489,9 @@ pub const merge_sels: []const Chord = &.{.{ .cp = '-', .alt = true }}; pub const merge_consecutive_sels: []const Chord = &.{.{ .cp = '_', .alt = true }}; pub const trim_sels: []const Chord = &.{.{ .cp = '_' }}; -// The other two list-making keys: a REGEX turns each range into many. Both -// arm the tag input above (select_marker / split_marker) instead of doing -// anything immediately, so `s` and `S` are the only normal-mode keys whose -// effect lands a keystroke later, on Enter — or live, as you type. -// `s` is free here despite `gs` (goto_first_nonws) also being `s`: a pending -// prefix is matched by the stored codepoint, never by these chords. pub const select_regex: []const Chord = &.{.{ .cp = 's' }}; pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; -// ---- edits ---- -// -// Every one of these reads or writes the DEFAULT register and only that. -// The system clipboard is five separate words on `SPC y Y p P R`, which is -// helix's split and the reason `d` cannot silently eat what you copied out of -// a browser. See leader_path above and builtins.zig's clipboard section. - pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; pub const change: []const Chord = &.{.{ .cp = 'c' }}; @@ -1109,35 +505,720 @@ pub const to_uppercase: []const Chord = &.{.{ .cp = '`', .alt = true }}; pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; -/// helix's format_selections — its neighbour on the keyboard and in the keymap pub const format: []const Chord = &.{.{ .cp = '=' }}; pub const increment: []const Chord = &.{.{ .cp = 'a', .ctrl = true }}; pub const decrement: []const Chord = &.{.{ .cp = 'x', .ctrl = true }}; pub const undo: []const Chord = &.{.{ .cp = 'u' }}; pub const redo: []const Chord = &.{.{ .cp = 'U' }}; -/// helix `toggle_comments`: comment or uncomment every line the selection -/// touches, with `comment_tokens` above choosing the token. Ctrl-c reaches a -/// terminal pane only in NORMAL mode — raw tty forwards it to the program, -/// where it is still SIGINT. pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }}; -/// In body normal mode, clear modal residue and run Last (the same builtin as -/// `SPC j j`): the pane you were in before this one, whichever it was. Held -/// down it alternates between two panes — two files, or a file and its shell. -/// Elsewhere: leave insert mode; abandon a leader path, tag, armed search or -/// the topbar; raw tty mode forwards it to the program. +// Body-normal Esc runs Last; other modes cancel input or leave insert mode. pub const escape: []const Chord = &.{.{ .cp = Key.escape }}; -// ---- insert mode ---- - -// The three helix aliases: normalized to the base key and re-dispatched, so -// they behave identically to it everywhere downstream. pub const insert_backspace_alias: []const Chord = &.{.{ .cp = 'h', .ctrl = true }}; pub const insert_enter_alias: []const Chord = &.{.{ .cp = 'j', .ctrl = true }}; pub const insert_delete_alias: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; -/// helix insert-mode kills. Ctrl-w is also the WINDOW prefix in normal/tty — -/// insert mode wins it, which is helix's own arrangement. pub const delete_word_backward: []const Chord = &.{ .{ .cp = 'w', .ctrl = true }, .{ .cp = Key.backspace, .alt = true } }; pub const delete_word_forward: []const Chord = &.{ .{ .cp = 'd', .alt = true }, .{ .cp = Key.delete, .alt = true } }; pub const kill_to_line_start: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; pub const kill_to_line_end: []const Chord = &.{.{ .cp = 'k', .ctrl = true }}; + +pub const Runtime = struct { + theme: usize = 0, + colors: bool = true, + wrap: bool = true, + tag_bottom: bool = false, + debug: bool = false, + + // Effective values change only after a host acknowledges the request. + shell: struct { + requested: Text(255) = .{}, + effective: Text(limits.host_path_cap) = .{}, + pending: bool = true, + } = .{}, + + font: struct { + requested_path: Text(limits.host_path_cap) = .{}, + requested_name: Text(255) = .{}, + effective_name: Text(255) = .{}, + pending: bool = false, + effective_size_hundredths: u16 = 0, + effective_size_unit: FontSizeUnit = .unknown, + tagline_percent: u8 = 100, + } = .{}, + + panel_transition: layout.Transition = .off, + scene_effects: layout.SceneEffect = .{}, + + pub fn toggleTransition(state: *Runtime, effect: layout.Transition) void { + std.debug.assert(effect != .off); + state.panel_transition = if (state.panel_transition == effect) .off else effect; + } + + pub const tagline_percent_min: u8 = 1; + pub const tagline_percent_max: u8 = 100; + + pub fn Text(comptime capacity: usize) type { + return struct { + bytes: [capacity]u8 = @splat(0), + len: std.math.IntFittingRange(0, capacity) = 0, + + pub fn get(value: *const @This()) []const u8 { + return value.bytes[0..value.len]; + } + + pub fn set(value: *@This(), text: []const u8) bool { + if (text.len > capacity) return false; + @memcpy(value.bytes[0..text.len], text); + value.len = @intCast(text.len); + return true; + } + + pub fn clear(value: *@This()) void { + value.len = 0; + } + }; + } + + pub const FontSizeUnit = enum { unknown, pixels, points }; + + // Validate both strings before changing either member of the request. + pub fn requestFont(state: *Runtime, path: []const u8, name: []const u8) bool { + if (path.len > state.font.requested_path.bytes.len or + name.len > state.font.requested_name.bytes.len) return false; + std.debug.assert(state.font.requested_path.set(path)); + std.debug.assert(state.font.requested_name.set(name)); + state.font.pending = true; + return true; + } + + pub const Capabilities = struct { + font_picker: bool, + panel_transitions: bool, + scene_shaders: bool, + tagline_font_size: bool, + }; + + pub const Capability = std.meta.FieldEnum(Capabilities); + + pub const Toggle = enum { colors, wrap, tag_bottom, debug }; + pub const Scene = std.meta.FieldEnum(layout.SceneEffect); + + pub const Action = union(enum) { + toggle: Toggle, + shell, + theme, + font, + tagline_size, + transition: layout.Transition, + scene: Scene, + }; + + pub const Setting = struct { + word: []const u8, + action: Action, + availability: ?Capability = null, + + pub fn takesArg(setting: Setting) bool { + return switch (setting.action) { + .shell, .theme, .font, .tagline_size => true, + else => false, + }; + } + + pub fn enabled(setting: Setting, capabilities: Capabilities) bool { + const capability = setting.availability orelse return true; + return switch (capability) { + inline else => |field| @field(capabilities, @tagName(field)), + }; + } + }; + + // The builtin registry and Config report share this command table. + pub const settings = [_]Setting{ + .{ .word = "Colors", .action = .{ .toggle = .colors } }, + .{ .word = "Wrap", .action = .{ .toggle = .wrap } }, + .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } }, + .{ .word = "Debug", .action = .{ .toggle = .debug } }, + .{ .word = "Theme", .action = .theme }, + .{ .word = "Shell", .action = .shell }, + .{ .word = "Font", .action = .font, .availability = .font_picker }, + .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker }, + .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions }, + .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions }, + .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions }, + .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions }, + .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions }, + .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions }, + .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions }, + .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions }, + .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions }, + .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, + .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, + .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, + .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, + .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, + }; + + pub fn find(name: []const u8) ?Setting { + for (settings) |setting| if (std.mem.eql(u8, setting.word, name)) return setting; + return null; + } + + pub fn findAction(action: Action) ?Setting { + for (settings) |setting| if (std.meta.eql(setting.action, action)) return setting; + return null; + } + + fn actionCount(comptime action: Action) comptime_int { + var count = 0; + for (settings) |setting| count += @intFromBool(std.meta.eql(setting.action, action)); + return count; + } + + comptime { + @setEvalBranchQuota(20_000); + for (settings, 0..) |setting, i| { + if (setting.word.len == 0) @compileError("runtime setting has an empty command word"); + for (settings[i + 1 ..]) |later| if (std.mem.eql(u8, setting.word, later.word)) + @compileError("duplicate runtime setting command word: " ++ setting.word); + switch (setting.action) { + .font, .tagline_size => if (setting.availability != .font_picker) + @compileError("native font settings must use the font-picker capability"), + .transition => if (setting.availability != .panel_transitions) + @compileError("panel effects must use the panel-transition capability"), + .scene => if (setting.availability != .scene_shaders) + @compileError("scene effects must use the scene-shader capability"), + else => if (setting.availability != null) + @compileError("unconditional settings cannot carry a backend capability"), + } + } + for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1) + @compileError("runtime toggle must occur exactly once: " ++ @tagName(field)); + if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or + actionCount(.tagline_size) != 1) + @compileError("Shell, Theme, Font, and TaglineSize actions must each occur exactly once"); + for (std.enums.values(layout.Transition)) |effect| { + const expected: comptime_int = @intFromBool(effect != .off); + if (actionCount(.{ .transition = effect }) != expected) + @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect)); + } + for (std.enums.values(Scene)) |effect| { + if (actionCount(.{ .scene = effect }) != 1) + @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); + if (@FieldType(layout.SceneEffect, @tagName(effect)) != bool) + @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); + } + } + + pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool { + switch (setting.action) { + .toggle => |field| switch (field) { + .colors => state.colors = !state.colors, + .wrap => state.wrap = !state.wrap, + .tag_bottom => state.tag_bottom = !state.tag_bottom, + .debug => state.debug = !state.debug, + }, + .shell => { + const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + if (value.len == 0 or !state.shell.requested.set(value)) return false; + state.shell.pending = true; + }, + .tagline_size => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + const percent = std.fmt.parseInt(u16, text, 10) catch return false; + if (percent < tagline_percent_min or percent > tagline_percent_max) return false; + state.font.tagline_percent = @intCast(percent); + }, + .transition => |effect| state.toggleTransition(effect), + .scene => |effect| switch (effect) { + inline else => |field| { + const value = &@field(state.scene_effects, @tagName(field)); + value.* = !value.*; + }, + }, + .theme, .font => return false, + } + return true; + } + + // Slices are borrowed for one writeReport call. + pub const ReportContext = struct { + startup_config_path: ?[]const u8, + platform: []const u8, + theme_name: []const u8, + compiled_default_shell: []const u8, + gui_shader_source_mode: ?[]const u8 = null, + hover_delay_frames: ?u16, + native_images: bool, + capabilities: Capabilities, + state: *const Runtime, + }; + + fn onOff(value: bool) []const u8 { + return if (value) "on" else "off"; + } + + fn shown(text: []const u8) []const u8 { + return if (text.len == 0) "(none)" else text; + } + + fn transitionSettingName(transition: layout.Transition) []const u8 { + if (transition == .off) return "off"; + return findAction(.{ .transition = transition }).?.word; + } + + pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { + const state = context.state; + var wrote_transition = false; + for (settings) |setting| switch (setting.action) { + .toggle => |field| { + const value = switch (field) { + .colors => state.colors, + .wrap => state.wrap, + .tag_bottom => state.tag_bottom, + .debug => state.debug, + }; + try out.print("{s}: {s}\n", .{ setting.word, onOff(value) }); + }, + .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }), + .shell => { + const chosen = state.shell.requested.get(); + try out.print( + "{s} requested (new panes): {s}{s}\n" ++ + "{s} effective (last spawn): {s}\n" ++ + "{s} pending: {s}\n", + .{ + setting.word, + if (chosen.len == 0) context.compiled_default_shell else chosen, + if (chosen.len == 0) " (default)" else "", + setting.word, + shown(state.shell.effective.get()), + setting.word, + onOff(state.shell.pending), + }, + ); + }, + .font => if (!setting.enabled(context.capabilities)) + try out.print("{s}: unsupported\n", .{setting.word}) + else + try out.print( + "{s} requested: {s}\n" ++ + "{s} requested path: {s}\n" ++ + "{s} effective: {s}\n" ++ + "{s} pending: {s}\n" ++ + "{s} effective size: {d}.{d:0>2} {s}\n", + .{ + setting.word, + shown(state.font.requested_name.get()), + setting.word, + shown(state.font.requested_path.get()), + setting.word, + shown(state.font.effective_name.get()), + setting.word, + onOff(state.font.pending), + setting.word, + state.font.effective_size_hundredths / 100, + state.font.effective_size_hundredths % 100, + @tagName(state.font.effective_size_unit), + }, + ), + .tagline_size => if (!context.capabilities.tagline_font_size) + try out.print("{s}: unsupported\n", .{setting.word}) + else if (!setting.enabled(context.capabilities)) + try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent }) + else + try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }), + .transition => { + if (wrote_transition) continue; + wrote_transition = true; + if (!setting.enabled(context.capabilities)) + try out.writeAll("Panel transition: unsupported\n") + else + try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)}); + }, + .scene => |effect| { + if (!setting.enabled(context.capabilities)) { + try out.print("{s}: unsupported\n", .{setting.word}); + continue; + } + const enabled = switch (effect) { + inline else => |field| @field(state.scene_effects, @tagName(field)), + }; + try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) }); + }, + }; + + if (context.startup_config_path) |path| + try out.print("Startup config: {s}\n", .{path}) + else + try out.writeAll("Startup config: no per-user config path\n"); + try out.print( + "Platform: {s}\n" ++ + "Compiled default shell: {s}\n", + .{ context.platform, context.compiled_default_shell }, + ); + if (context.gui_shader_source_mode) |mode| + try out.print("GUI shader source: {s}\n", .{mode}); + if (context.hover_delay_frames) |frames| + try out.print("Look hover delay: {d} frames\n", .{frames}) + else + try out.writeAll("Look hover delay: off\n"); + try out.print("Native images: {s}\n", .{onOff(context.native_images)}); + } + + test "setting names are unique and argument metadata follows actions" { + for (settings, 0..) |setting, i| { + try std.testing.expect(setting.word.len > 0); + for (settings[i + 1 ..]) |later| + try std.testing.expect(!std.mem.eql(u8, setting.word, later.word)); + try std.testing.expectEqual(switch (setting.action) { + .shell, .theme, .font, .tagline_size => true, + else => false, + }, setting.takesArg()); + } + } + + test "simple setting application mutates only its plain field" { + var state: Runtime = .{}; + try std.testing.expect(apply(&state, find("Colors").?, null)); + try std.testing.expect(!state.colors); + try std.testing.expect(apply(&state, find("Shell").?, " fish\n")); + try std.testing.expectEqualStrings("fish", state.shell.requested.get()); + try std.testing.expect(state.shell.pending); + try std.testing.expect(apply(&state, find("PanelAscii").?, null)); + try std.testing.expectEqual(layout.Transition.ascii, state.panel_transition); + try std.testing.expect(apply(&state, find("PanelAscii").?, null)); + try std.testing.expectEqual(layout.Transition.off, state.panel_transition); + try std.testing.expect(apply(&state, find("Crt").?, null)); + try std.testing.expect(state.scene_effects.crt); + } + + test "tagline size validates before mutating live state" { + const setting = find("TaglineSize").?; + var state: Runtime = .{}; + + for ([_][]const u8{ "1", " 82\n", "100" }) |argument| { + try std.testing.expect(apply(&state, setting, argument)); + try std.testing.expectEqual(try std.fmt.parseInt(u8, std.mem.trim(u8, argument, " \t\r\n"), 10), state.font.tagline_percent); + } + + state.font.tagline_percent = 67; + for ([_]?[]const u8{ null, "", "0", "101", "-1", "50%", "999999999999999999999" }) |argument| { + try std.testing.expect(!apply(&state, setting, argument)); + try std.testing.expectEqual(@as(u8, 67), state.font.tagline_percent); + } + } + + test "font request tuple rejects atomically" { + var state: Runtime = .{}; + try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old")); + var too_long: [256]u8 = @splat('x'); + try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long)); + try std.testing.expectEqualStrings("/fonts/old.ttf", state.font.requested_path.get()); + try std.testing.expectEqualStrings("Old", state.font.requested_name.get()); + } + + test "Config report observes every simple setting and all live context" { + var state: Runtime = .{}; + var storage: [4096]u8 = undefined; + const context: ReportContext = .{ + .startup_config_path = "/tmp/pardes/init", + .platform = "gui", + .theme_name = "acme", + .compiled_default_shell = "/bin/sh", + .gui_shader_source_mode = "live GLSL compiled during this build", + .hover_delay_frames = 18, + .native_images = true, + .capabilities = .{ + .font_picker = true, + .panel_transitions = true, + .scene_shaders = true, + .tagline_font_size = true, + }, + .state = &state, + }; + + for (settings) |setting| { + switch (setting.action) { + .theme, .font => continue, + else => {}, + } + const argument: ?[]const u8 = switch (setting.action) { + .shell => "fish", + .tagline_size => "73", + else => null, + }; + try std.testing.expect(apply(&state, setting, argument)); + + var out: std.Io.Writer = .fixed(&storage); + try writeReport(&out, context); + const report = storage[0..out.end]; + const expected = switch (setting.action) { + .toggle => |field| switch (field) { + .colors => "Colors: off\n", + .wrap => "Wrap: off\n", + .tag_bottom => "Tagbottom: on\n", + .debug => "Debug: on\n", + }, + .shell => "Shell requested (new panes): fish\n", + .tagline_size => "TaglineSize: 73%\n", + .transition => |transition| switch (transition) { + .off => unreachable, + .slide => "Panel transition: PanelSlide\n", + .zoom => "Panel transition: PanelZoom\n", + .dissolve => "Panel transition: PanelDissolve\n", + .ascii => "Panel transition: PanelAscii\n", + .vertical => "Panel transition: PanelVertical\n", + .edges => "Panel transition: PanelEdges\n", + .fall => "Panel transition: PanelFall\n", + .wave => "Panel transition: PanelWave\n", + .curtain => "Panel transition: PanelCurtain\n", + .scramble => "Panel transition: PanelScramble\n", + .typewriter => "Panel transition: PanelType\n", + }, + .scene => |effect| switch (effect) { + .crt => "Crt: on\n", + .ripple => "Ripple: on\n", + .glitch => "Glitch: on\n", + }, + .theme, .font => unreachable, + }; + try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); + } + + try std.testing.expect(state.font.requested_name.set("Wanted Mono")); + try std.testing.expect(state.font.requested_path.set("/fonts/wanted.ttf")); + try std.testing.expect(state.font.effective_name.set("Effective Mono")); + state.font.pending = true; + state.font.effective_size_hundredths = 1375; + state.font.effective_size_unit = .points; + state.font.tagline_percent = 82; + + var out: std.Io.Writer = .fixed(&storage); + try writeReport(&out, context); + const report = storage[0..out.end]; + for ([_][]const u8{ + "Theme: acme\n", + "Font requested: Wanted Mono\n", + "Font requested path: /fonts/wanted.ttf\n", + "Font effective: Effective Mono\n", + "Font pending: on\n", + "Font effective size: 13.75 points\n", + "TaglineSize: 82%\n", + "Startup config: /tmp/pardes/init\n", + "Platform: gui\n", + "Compiled default shell: /bin/sh\n", + "GUI shader source: live GLSL compiled during this build\n", + "Look hover delay: 18 frames\n", + "Native images: on\n", + }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); + + var defaults: Runtime = .{}; + var defaults_context = context; + defaults_context.startup_config_path = null; + defaults_context.platform = "tty"; + defaults_context.gui_shader_source_mode = null; + defaults_context.hover_delay_frames = null; + defaults_context.native_images = false; + defaults_context.capabilities = .{ + .font_picker = false, + .panel_transitions = true, + .scene_shaders = false, + .tagline_font_size = false, + }; + defaults_context.state = &defaults; + out = .fixed(&storage); + try writeReport(&out, defaults_context); + const defaults_report = storage[0..out.end]; + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); + + defaults_context.platform = "web"; + defaults_context.capabilities.panel_transitions = false; + defaults_context.capabilities.tagline_font_size = true; + out = .fixed(&storage); + try writeReport(&out, defaults_context); + const web_report = storage[0..out.end]; + try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null); + } +}; + +pub const User = struct { + const max_bytes = 1024 * 1024; + + pub const init_name = "init"; + pub const builtin_themes_subdir = "themes/builtin"; + + // Relative XDG_CONFIG_HOME values are ignored. + pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8 { + if (builtin.os.tag == .windows) { + if (env.get("LOCALAPPDATA")) |base| if (base.len != 0) + return try std.fs.path.join(gpa, &.{ base, "pardes" }); + if (env.get("USERPROFILE")) |home| if (home.len != 0) + return try std.fs.path.join(gpa, &.{ home, "AppData", "Local", "pardes" }); + return null; + } + + if (env.get("XDG_CONFIG_HOME")) |base| if (base.len != 0 and std.fs.path.isAbsolute(base)) + return try std.fs.path.join(gpa, &.{ base, "pardes" }); + + const home = env.get("HOME") orelse return null; + if (home.len == 0) return null; + if (builtin.os.tag == .macos) + return try std.fs.path.join(gpa, &.{ home, "Library", "Application Support", "pardes" }); + return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" }); + } + + // The caller's allocator owns these slices, including paths when init is absent. + pub const Found = struct { + dir: ?[]const u8 = null, + path: ?[]const u8 = null, + bytes: ?[]const u8 = null, + }; + + pub fn load( + io: std.Io, + gpa: std.mem.Allocator, + env: *const std.process.Environ.Map, + ) Found { + const config_dir = (path(gpa, env) catch return .{}) orelse return .{}; + const config_path = std.fs.path.join(gpa, &.{ config_dir, init_name }) catch return .{ .dir = config_dir }; + return .{ + .dir = config_dir, + .path = config_path, + .bytes = std.Io.Dir.cwd().readFileAlloc(io, config_path, gpa, .limited(max_bytes)) catch null, + }; + } + + test "config path honors XDG and rejects a relative XDG directory" { + if (builtin.os.tag == .windows) return; + + var env: std.process.Environ.Map = .init(std.testing.allocator); + defer env.deinit(); + try env.put("HOME", "/home/pardes-test"); + try env.put("XDG_CONFIG_HOME", "/var/tmp/pardes-xdg"); + + const xdg = (try path(std.testing.allocator, &env)).?; + defer std.testing.allocator.free(xdg); + try std.testing.expectEqualStrings("/var/tmp/pardes-xdg/pardes", xdg); + + try env.put("XDG_CONFIG_HOME", "relative/config"); + const fallback = (try path(std.testing.allocator, &env)).?; + defer std.testing.allocator.free(fallback); + const expected = if (builtin.os.tag == .macos) + "/home/pardes-test/Library/Application Support/pardes" + else + "/home/pardes-test/.config/pardes"; + try std.testing.expectEqualStrings(expected, fallback); + } + + test "config loader reads init inside the config directory" { + if (builtin.os.tag == .windows) return; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const base_len = try tmp.dir.realPath(std.testing.io, &base_buf); + + var env: std.process.Environ.Map = .init(std.testing.allocator); + defer env.deinit(); + try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]); + + const missing = load(std.testing.io, std.testing.allocator, &env); + defer std.testing.allocator.free(missing.dir.?); + defer std.testing.allocator.free(missing.path.?); + const expected_dir = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" }); + defer std.testing.allocator.free(expected_dir); + const expected = try std.fs.path.join(std.testing.allocator, &.{ expected_dir, init_name }); + defer std.testing.allocator.free(expected); + try std.testing.expectEqualStrings(expected_dir, missing.dir.?); + try std.testing.expectEqualStrings(expected, missing.path.?); + try std.testing.expect(missing.bytes == null); + const source = "Theme dark\nUnknown command\nTheme acme\n"; + try tmp.dir.createDir(std.testing.io, "pardes", .default_dir); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes/init", .data = source }); + const found = load(std.testing.io, std.testing.allocator, &env); + defer std.testing.allocator.free(found.dir.?); + defer std.testing.allocator.free(found.path.?); + defer std.testing.allocator.free(found.bytes.?); + try std.testing.expectEqualStrings(expected_dir, found.dir.?); + try std.testing.expectEqualStrings(source, found.bytes.?); + } + + // Replace generated theme files; preserve unrelated user files. + pub fn dumpThemes( + io: std.Io, + gpa: std.mem.Allocator, + config_dir: []const u8, + theme_values: anytype, + ) ![]u8 { + const out_dir = try std.fs.path.join(gpa, &.{ config_dir, builtin_themes_subdir }); + errdefer gpa.free(out_dir); + try std.Io.Dir.cwd().createDirPath(io, out_dir); + + for (theme_values) |theme_value| { + var encoded: std.Io.Writer.Allocating = .init(gpa); + defer encoded.deinit(); + try std.zon.stringify.serialize(theme_value, .{ .whitespace = true }, &encoded.writer); + + const filename = try std.fmt.allocPrint(gpa, "{s}.zon", .{theme_value.name}); + defer gpa.free(filename); + const output_path = try std.fs.path.join(gpa, &.{ out_dir, filename }); + defer gpa.free(output_path); + try std.Io.Dir.cwd().writeFile(io, .{ + .sub_path = output_path, + .data = encoded.written(), + }); + } + return out_dir; + } + + test "theme dump creates the builtin subdirectory and ZON files" { + const io = std.testing.io; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const base_len = try tmp.dir.realPath(io, &base_buf); + const Sample = struct { name: []const u8, rgb: [3]u8 }; + const samples = [_]Sample{ + .{ .name = "one", .rgb = .{ 1, 2, 3 } }, + .{ .name = "two", .rgb = .{ 4, 5, 6 } }, + }; + const output = try dumpThemes(io, gpa, base_buf[0..base_len], &samples); + defer gpa.free(output); + const expected = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], builtin_themes_subdir }); + defer gpa.free(expected); + try std.testing.expectEqualStrings(expected, output); + + const one_path = try std.fs.path.join(gpa, &.{ output, "one.zon" }); + defer gpa.free(one_path); + const bytes = try std.Io.Dir.cwd().readFileAlloc(io, one_path, gpa, .limited(4096)); + defer gpa.free(bytes); + const source = try gpa.dupeZ(u8, bytes); + defer gpa.free(source); + const parsed = try std.zon.parse.fromSliceAlloc(Sample, gpa, source, null, .{}); + defer std.zon.parse.free(gpa, parsed); + try std.testing.expectEqualStrings("one", parsed.name); + try std.testing.expectEqual([3]u8{ 1, 2, 3 }, parsed.rgb); + } +}; + +test { + _ = Runtime; + _ = User; +} diff --git a/src/crash.zig b/src/crash.zig index d9c47715..10d38591 100644 --- a/src/crash.zig +++ b/src/crash.zig @@ -26,7 +26,7 @@ const pardes = @import("pardes.zig"); /// Beside `init`, so `Config` opens the directory that holds both. pub const name = "crashes"; -/// The config directory `user_config.load` resolved, COPIED rather than +/// The config directory `config.User.load` resolved, COPIED rather than /// borrowed: main.zig hands over an arena slice that outlives the process, but /// the AppKit host's lives in a `config_arena` its own `errdefer` frees on a /// failed init and its teardown frees at quit — and a panic after either would diff --git a/src/detached/client.zig b/src/detached/client.zig index f317b2e2..0350ecf7 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -40,7 +40,7 @@ //! DISPLAY: a daemon nobody is looking at has no clipboard and no browser. //! The answer to `read_clipboard` is not a reply message: it is an ordinary //! `Event.paste` sent back through `send`, which is the same asynchronous -//! shape `pull_read_clipboard` already has in-process. +//! shape `read_clipboard` already has in-process. //! * `refuse` is followed by the session closing the connection, and `quit` //! means the session itself has ended. //! The switch in `next` is exhaustive over that set on purpose: putting a @@ -481,26 +481,7 @@ pub fn resolve(buf: *[server.path_max]u8, requested: []const u8) Resolved { return .{ .name = buf[0..len] }; } -/// How long a frontend's attached loop waits on the socket before it goes back -/// to whatever else it owns. It lives HERE, beside the `wait` it parameterises, -/// because both frontends need it and both had defined it for themselves — -/// which is how a measured number drifts from the thing it was measured -/// against. -/// -/// A frontend cannot hand `poll(2)` one descriptor for the session and one for -/// its own input: vaxis delivers the terminal's events on a reader thread into -/// a mutex/condvar queue, and SDL has its own pump, so neither has a -/// descriptor. `wait` takes a timeout for exactly that reason. -/// -/// 8 ms is half a 60 Hz frame: a keystroke waits at most one of those before it -/// is on the wire (4 ms on average), and the frame it causes needs no wait at -/// all — it lands in the poll the moment the session writes it. The price is -/// 125 poll rounds a second on a frontend nobody is touching, measured below -/// the noise of what an idle pardes already costs: on an i7-11700 at 100 Hz -/// jiffies an idle attached frontend used 0.16% of one core over 60 s and 0.18% -/// over 120 s, against 0.11% and 0.31% for an idle in-process session on the -/// same screen over the same windows. Reach for an eventfd and a waker thread — -/// fuse.zig's `pollLoop` is the pattern — only if that stops being true. +// Input arrives through frontend queues, so attached clients bound socket waits to half a frame. pub const poll_ms: u32 = 8; /// One round of waiting for the greeting, and how many of them. The COUNT is @@ -541,22 +522,8 @@ pub const Attempt = union(enum) { lost: anyerror, }; -/// Resolve a name, connect to it, and WAIT FOR THE WELCOME. On every failure -/// path this closes whatever it opened, so a caller that gets anything but -/// `.greeted` has nothing to clean up. -/// -/// The waiting is the point, and it is why this function exists rather than -/// each frontend calling `resolve` and `open` in turn. `open` is not a -/// handshake — it connects and writes the hello, and the `welcome` or the -/// `refuse` arrives later through this loop. A frontend that treats a -/// successful `connect(2)` as proof of attachment will tear its local session -/// down — reap its pane shells, unmount its control filesystem, free every -/// undo history — and only then discover `refuse .version`, which is the -/// routine case: `zig build` replaces the binary under a running session, so -/// two protocol versions on one machine is expected rather than exotic. The -/// contract the `Attach` word owes is that a failed attach changes NOTHING, and -/// that contract can only be kept by a caller that has the welcome in hand -/// before it starts destroying things. +/// Wait for welcome before the caller replaces its session; connect alone can +/// still lead to a version refusal. Every non-greeted result owns no resources. pub fn attempt(gpa: std.mem.Allocator, requested: []const u8, cols: u16, rows: u16) Attempt { var buf: [server.path_max]u8 = undefined; const name = switch (resolve(&buf, requested)) { @@ -648,7 +615,16 @@ const Harness = struct { errdefer h.arena.deinit(); // `io` is not optional on `Session`: the daemon does the file watching // and the theme scan itself now, and both of those take a `std.Io`. - h.session = .{ .gpa = testing.allocator, .io = std.testing.io, .core = h.core, .cols = cols, .rows = rows }; + h.session = .{ + .gpa = testing.allocator, + .worker_gpa = testing.allocator, + .io = std.testing.io, + .core = h.core, + .cols = cols, + .rows = rows, + }; + try h.session.initAsync(); + errdefer h.session.deinit(); h.name = "s"; try testing.expect(h.session.listen(h.name)); // The pre-loop drain tty.zig has, for its reason: the startup spawns are @@ -683,12 +659,12 @@ const Harness = struct { /// for hosts whose worker threads post from off the loop. fn pump(h: *Harness) !void { const host = h.session.host(); - host.vtable.pull_wait_input.?(host.ctx, 20); + host.vtable.wait_input.?(host.ctx, 20); while (h.core.nextEffect()) |e| h.core.perform(e); if (h.core.quit) return; _ = h.arena.reset(.retain_capacity); const surface = try h.core.render(h.arena.allocator()); - host.vtable.push_present.?(host.ctx, surface); + host.vtable.present.?(host.ctx, surface); } /// The round budget every `pumpUntil*` below shares. A round moves at most @@ -833,6 +809,71 @@ test "detached session: input from a frontend reaches the core and comes back as try h.pumpUntilShowsCore(&c); } +test "detached Restore keeps attached frontends and follows queued frames with a full replacement" { + var h: Harness = undefined; + try h.init(60, 16); + defer h.deinit(); + _ = try h.core.setTestFile("saved body\n"); + var c = try h.attach(60, 16); + defer c.deinit(); + _ = try h.pumpUntil(&c, .frame); + try h.pumpUntilShowsCore(&c); + + const before = h.core; + const fd = h.session.clients[c.slot].fd; + try testing.expectError(error.BadDumpMagic, h.session.restore( + ".{ .magic = \"not-a-pardes-dump\", .theme = \"dark\", .screen = .{ .cols = 60, .rows = 16 } }", + )); + try testing.expectEqual(before, h.session.core); + try testing.expectEqual(fd, h.session.clients[c.slot].fd); + try testing.expect(h.session.clients[c.slot].attached); + + try h.core.dumpState(); + const saved = try testing.allocator.dupe(u8, h.core.dump_out.?); + defer testing.allocator.free(saved); + while (h.core.nextEffect()) |_| {} + _ = try h.core.setTestFile("changed after dump\n"); + try h.session.restore(saved); + h.core = h.session.core; + try testing.expect(h.core != before); + try testing.expectEqual(fd, h.session.clients[c.slot].fd); + try testing.expect(h.session.clients[c.slot].attached); + try testing.expectEqualStrings("saved body\n", h.core.panes[0].?.file.?.content); + try testing.expect(h.session.clients[c.slot].need_full); + var wake = [_]libc.pollfd{.{ .fd = h.session.mailbox.wake[0], .events = poll_in, .revents = 0 }}; + try testing.expectEqual(@as(c_int, 1), libc.poll(&wake, wake.len, 0)); + for (0..Harness.rounds) |_| { + if ((try h.pumpUntil(&c, .frame)).frame.kind == .full) break; + } else return error.NoFullReplacement; + try h.pumpUntilShowsCore(&c); +} + +test "detached selection pipe runs off the loop and returns through the attached frontend" { + var h: Harness = undefined; + try h.init(60, 16); + defer h.deinit(); + const pane = try h.core.setTestFile("one\ntwo\n"); + pane.cur_row = 0; + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + var client = try h.attach(60, 16); + defer client.deinit(); + _ = try h.pumpUntil(&client, .frame); + + try client.send(.{ .event = .{ .key = .{ .cp = '|' } } }); + try client.send(.{ .event = .{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } } }); + try client.send(.{ .event = .{ .key = .{ .cp = pardes.Key.enter } } }); + for (0..Harness.rounds) |_| { + try h.pump(); + try client.wait(5); + while (try client.next()) |_| {} + if (std.mem.eql(u8, pane.file.?.content, "ONE\ntwo\n")) break; + } else return error.PipeDidNotComplete; + try testing.expect(h.core.pipe_wait == null); + try testing.expectEqual(@as(usize, 0), h.session.pipe_tasks.len); + try h.pumpUntilShowsCore(&client); +} + test "detached session: two frontends share one screen at the smallest common grid" { var h: Harness = undefined; try h.init(80, 24); @@ -1031,26 +1072,21 @@ test "detached session: the seam's own routing rules, per surviving effect" { const host = h.session.host(); // BROADCAST: the yank register is a fact about the session, so every // display it is being watched on gets it. - host.vtable.push_set_clipboard.?(host.ctx, "yank"); + host.vtable.set_clipboard.?(host.ctx, "yank"); try expectBoth(&h, &a, &b, .set_clipboard); - // ORIGIN, ELSE PRIMARY. This is the "only one frontend is asked" rule that - // the shell-forking and file-writing messages used to demonstrate; the - // daemon does that work itself now, so the same claim is made about the two - // effects that still travel. `read_clipboard` is asked ONCE — two frontends - // answering would paste twice for one Ctrl-V, which is the rule host.zig - // states. + // Ask one frontend: two clipboard answers would paste twice. h.session.origin = 1; - host.vtable.pull_read_clipboard.?(host.ctx); + host.vtable.read_clipboard.?(host.ctx); try expectOnly(&h, &b, &a, .read_clipboard); // `open_link` follows the same origin: the browser that opens is the one on // the display of the human who clicked, not the oldest attachment's. - host.vtable.push_open_link.?(host.ctx, "https://x"); + host.vtable.open_link.?(host.ctx, "https://x"); try expectOnly(&h, &b, &a, .open_link); // ...and with no origin it falls back to the primary, which is what a link // opened by something other than a keystroke gets. h.session.origin = 0; - host.vtable.push_open_link.?(host.ctx, "https://y"); + host.vtable.open_link.?(host.ctx, "https://y"); try expectOnly(&h, &a, &b, .open_link); } @@ -1185,17 +1221,14 @@ test "detached session: a frontend that stops reading is dropped, not waited for } try testing.expect(h.session.clients[1].attached); - // Broadcast enough control traffic to pass `out_backlog`. A clipboard - // mirror is the honest vehicle: it is a real `push_` that reaches every - // frontend and carries the yank register, so this is a session yanking a - // lot rather than a synthetic poke. + // Clipboard updates broadcast to every frontend. const text = try testing.allocator.alloc(u8, 256 * 1024); defer testing.allocator.free(text); @memset(text, 'y'); const host = h.session.host(); for (0..24) |_| { if (!h.session.clients[1].attached) break; - host.vtable.push_set_clipboard.?(host.ctx, text); + host.vtable.set_clipboard.?(host.ctx, text); // Read `good` back to EMPTY before the next mirror, rather than // pumping once and taking whatever one write fitted. One pump moves at // most one socket buffer, and that buffer is 8 KiB here diff --git a/src/detached/server.zig b/src/detached/server.zig index db93e2e4..8b49bcb2 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -1,617 +1,306 @@ -//! THE DETACHED CORE: one `Pardes` instance in a process with no terminal, -//! serving N frontends over one unix socket. -//! -//! THIS SIDE OWNS THE CORE, AND EVERYTHING UNDER IT. `Session` is a `host.Host` -//! implementation that performs the machine-local half of a host itself — it -//! forks the pane shells, writes the files, watches the paths — and whose -//! `pull_wait_input` is one `poll(2)` over the listener, every attached -//! frontend, every pane's pty master and the inotify descriptor. A frontend owns -//! a screen and a keyboard and nothing else (client.zig). So the `Pardes` is -//! here, `update` is called from here, the shells are forked from here, and the -//! same screen is on every attached frontend at once — `screen -x`, not N -//! sessions. -//! -//! WHAT THIS SIDE SERVES ITSELF, WHICH IS NOW ALL OF IT. A unix socket means -//! the core and its frontends are on the SAME machine, so there is no question -//! of whose process table, whose disk or whose inotify descriptor a call is -//! about — and given that, the process that must hold them is the long-lived -//! one. A shell forked by a frontend dies with that frontend, and a session -//! whose whole promise is outliving the frontend attached to it cannot keep its -//! panes that way. So this file forks the pane shells (`host_io.forkShell`), -//! writes the files (`host_io.writeFileBytes`), marks the directories -//! (file_watch.zig) and drains the pty masters in its own `poll(2)`. THE PANE -//! SHELLS OUTLIVE EVERY FRONTEND: attach, detach, kill the terminal, attach -//! from another one, and the build that was running in pane 3 is still running -//! and has been scrolling into the core the whole time. -//! -//! Only what this vtable leaves null falls through to the core's own -//! `host.Fallback` — and host.zig says in as many words that a zero-method host -//! is a complete pardes. What a frontend can still do BETTER is exactly what -//! needs the human's own display, and nothing else: put a yank on the clipboard -//! in front of them, take a paste off it, open a link in their browser. Three -//! messages, which is why the routing table below is as short as it is. -//! -//! ROUTING, and it is not "push means broadcast". A push reaches every HOST -//! (host.zig's rule, which `Fanout.isPull` enforces); this is ONE host that -//! happens to be backed by several frontends, and how it spreads a call inside -//! itself is its own business. Two rules over four messages: -//! * BROADCAST — the frame, and `set_clipboard`. Every screen must show the -//! same thing, and a yank in a shared session is a session-wide fact that -//! every attached desktop is entitled to. -//! * ORIGIN, ELSE PRIMARY — `read_clipboard` (the one `pull_` on the wire), -//! `open_link`, and `detach`. Each answers a thing a HUMAN just did, and the -//! answer belongs to that human: the paste must come from the keyboard that -//! asked for it, a link must open in front of the person who clicked it, and -//! a `Detach` typed in one frontend must send THAT frontend away and leave -//! the others painting. `origin` is the frontend whose event was applied -//! most recently. Effects drain after a whole batch of events (pardes.zig -//! `pump`), so in the rare case where two frontends type in the same -//! millisecond the second one wins; the fallback to `primary` — the lowest -//! attached slot, i.e. the oldest surviving attachment, a rule that is -//! stable while frontends come and go and needs no election — covers an -//! effect that no input caused at all. -//! -//! `detach` is the odd one and is worth naming as such: it is not an EFFECT the -//! session performs on the world, it is SESSION CONTROL — one frontend asking to -//! stop being a frontend. That is why wire.zig gives it 0x05, in the -//! 0x01..0x0f session range beside `quit`, rather than a number in the 0x10.. -//! range where every tag is one `push_` method that reaches a disk, a clipboard -//! or a browser. And it is why this side does nothing but send it: see `detach`. -//! There is no third rule, and the class of message it used to serve is gone: -//! `spawn`, `pty_write`, `pty_resize`, `write_file`, `write_dump`, `watch_file`, -//! `watch_theme` and `dump_themes` were routed to ONE frontend precisely -//! because each has one real resource behind it, and every one of them is now -//! performed HERE, once, by the process that owns the resource. Two frontends -//! can no longer fork two shells for pane 3 or race each other writing one -//! path, because neither of them writes anything. -//! -//! FAIRNESS, and why no client — and no shell — can stall the core or another -//! client. The property the bullets below add up to is worth stating as one -//! sentence, because it is what a detached session is FOR: there is no path on -//! which this process blocks indefinitely. Every descriptor it holds is -//! non-blocking, the single `poll(2)` is the only place it sleeps, and every -//! queue that could grow without bound has a ceiling with a stated answer for -//! reaching it. A daemon nobody is looking at cannot be made to stop looking -//! after the shells nobody else is keeping. -//! * ONE `poll(2)` per pump covers the listener, all `max_clients` frontends, -//! all `pardes.MAX_PANES` pty masters and the inotify descriptor: -//! `poll_slots` descriptors, one syscall, no thread per client and none per -//! pty. Putting the shells in the poll set the clients were already in is -//! what lets a daemon own sixteen of them and stay single-threaded. -//! * one read per pty per round, which is `receive`'s rule for clients -//! applied to shells: a `yes` in pane 1 gets one turn and the loop moves on -//! to the other panes, the frontends and the frame. -//! * EVERY descriptor is non-blocking, sockets and pty masters alike, and a -//! pane owes its bytes the same way a client does. A blocking write to a -//! master was the one hole this file's own comment used to argue was safe — -//! "the peer on a pty is a shell this process forked, not a stranger who can -//! stop reading on purpose" — and that was wrong, because the peer is -//! whatever program the human ran in that pane. `sleep 3600` plus a paste -//! larger than the pty's input buffer parked the WHOLE daemon inside -//! `write(2)`: no frame to any frontend, fifteen other masters unread, no -//! `accept`, no `expire`, no inotify drain. So a pane has an out-queue and a -//! POLLOUT, on the descriptor that was already in the set. See `ptyWrite`. -//! * FRAMES ARE NOT QUEUED. A client with bytes still owed to the kernel is -//! SKIPPED for this frame and its mirror is left alone, so the next frame -//! it does get is a diff against what it actually has. A slow frontend -//! therefore sees fewer, larger frames instead of a growing queue, and -//! coalescing costs no byte surgery at all. -//! * what is left in a client's out-queue is control messages, and it is -//! capped (`out_backlog`). The cap is checked BEFORE an append, so a single -//! oversized message still goes out whole and what gets refused is a client -//! that has stopped draining: it is closed. Its session and its peers are -//! untouched, and it may reattach and be sent a full frame. -//! * `max_clients` is a REFUSAL, not a queue — the same shape and the same -//! number as fuse.zig's park table, and for the same reason: the listener -//! is always accepted from even when the table is full, because a -//! level-triggered `poll` on a backlog nobody accepts returns ready -//! forever and spins a core. Bounded per round all the same (`accept`), and -//! a connection that never says `hello` loses its slot -//! (`greet_deadline_ms`) — a slot held by silence is the same denial as a -//! queue, arrived at from the other end. -//! * the TABLE is accounted, not just each client (`session_backlog`), and a -//! drained client gives its buffers back (`idle_retain`): 32 slots each -//! holding one 4 MiB paste is 128 MiB of a daemon nobody is looking at. -//! -//! THE SOCKET follows nested.zig's conventions exactly, and they ARE -//! nested.zig's: `socketDir`, `ensureSocketDir`, `statNoFollow` and -//! `setCloexec` are imported from it rather than copied, because one directory -//! vetted by two predicates is how the two go out of step. `$XDG_RUNTIME_DIR` -//! else `~/.local/state/pardes` created 0700 and vetted (never /tmp), -//! `chmod 0600` before `listen(2)`, CLOEXEC on the listener and on every -//! accepted connection. The NAME differs on purpose: -//! `pardes-detached-.sock` rather than `pardes-.sock`, so that -//! nested.zig's sweeper — which only recognises all-digit pids — never unlinks -//! a live detached session, and so that a person can say `--detach=work` -//! instead of learning a pid. -//! -//! WHO MAY BIND A NAME, and this side is not allowed to guess. `bind(2)` on a -//! unix socket is an atomic exclusive create, so it decides: a name whose -//! socket ANSWERS is a live session and `listen` refuses rather than taking it -//! (an unconditional unlink-before-bind is how a second `--detach=work` used -//! to steal the socket out from under every frontend attached to the first). -//! The only file this process unlinks is one it proved dead — a connect that -//! was REFUSED — and `alive` is the single place that judgement is made, for -//! `listen` and for the sweep both. -//! -//! ...and both ends do the vetting. `vetted` is the frontend's half: a socket -//! at a path anyone could plant receives every keystroke that frontend -//! collects, so the client checks the directory and the socket before it -//! connects, exactly as this side checks them before it binds. +const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const posix = std.posix; const pardes = @import("../pardes.zig"); -const host_api = @import("../host.zig"); const wire = @import("wire.zig"); -/// The machine-local half of a host — fork a shell onto a pty, put bytes on a -/// disk — shared verbatim with the tty shell, and the sharing is the point: -/// `spawn` and `writeFile` below are the same two operations tty.zig performs, -/// and having them in one file is what keeps a daemon's pane and a terminal's -/// pane the same pane. See host_io.zig's header for why the daemon is the side -/// that performs them. const host_io = @import("../host_io.zig"); +const selection_pipe = @import("../selection_pipe.zig"); -/// ...and the inotify half, likewise shared: `applyEffect` is the mark-then- -/// reconcile transaction the tty and sdl shells run, and this session runs the -/// identical one. All that differs is who waits on the descriptor — a thread -/// there, `waitInput`'s poll set here. const file_watch = @import("../file_watch.zig"); -/// acme's control filesystem, which a detached session had no way to serve -/// until now: `push_fs_reply` was the one machine-local effect this host left -/// null, so a script could drive a tty or an SDL session and not a daemon — -/// the configuration whose whole promise is outliving the terminal. -/// -/// It costs less here than it does in the desktop shells. They need a thread -/// blocked on `poll()` to notice a request and wake their loop -/// (`fs_service.wake`); this process already owns a `poll(2)` over everything -/// else it waits on, so `/dev/fuse` is one more descriptor in that set and -/// there is no thread at all. `Source.fuse` is the arm; `pollFrame` is the -/// drain, at the same point in the frame that tty.zig drains. -const fuse = @import("../fuse.zig"); -const fs_service = @import("../fs_service.zig"); -/// ...and the SECOND transport onto that same tree, on a unix socket of its -/// own. `Source.ninep_listener` and `Source.ninep` are its arms; `pollFrame` -/// is its drain, beside the mount's, at the same point in the frame. -const fs9_service = @import("../fs9_service.zig"); - -/// Host-lifetime storage for the OSC 133 rc files a forked shell sources, held -/// by `Session` because a Session is exactly one host's lifetime. -const shell_bin = @import("../shell_bin.zig"); - -/// `shellCwd` for a pane's shell, `ttyTaken` for a pane the core is about to -/// type a command line into, `readFile` for `run`'s `--load`. -const look = @import("../look.zig"); +const ninep_io = @import("../9p_io.zig"); -/// The "saved " / "dumped themes " message row, stamped the way -/// every other host stamps it — one clock format across every frontend. -const message = @import("../message.zig"); +const look = @import("../look.zig"); -/// `Dump themes` writes the reference set out as .zon, into the core's own -/// `opts.config_dir`. -const user_config = @import("../user_config.zig"); +const message = pardes.Pardes.Message; -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). The -// same constant the tty, gui and macos shells spell, for the same reason. const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); -/// Diagnostics for whoever is running the daemon. Every one of these is a -/// `debug`, and the level is not a judgement about how bad the thing is: -/// main.zig's logFn drops this scope entirely unless PARDES_LOG is set, so what -/// decides whether a human sees it is that variable and not the level. Reaching -/// for `warn` instead would change exactly one thing — a TEST binary does not -/// go through logFn, and its stderr is the build runner's failure signal. const log = std.log.scoped(.detached); -/// nested.zig owns the socket conventions this file shares — the directory, -/// its vetting, the stat that will not follow a symlink, CLOEXEC — and its -/// module comment carries the reasoning for each. Imported and not copied: -/// see the module header. -const nested = @import("../nested.zig"); - -/// `pub` for client.zig, which needs the same platform answer for the same -/// reason: SIGPIPE is per-write on linux and per-socket on darwin. -pub const darwin = nested.darwin; - -/// Same two ingredients as nested.zig needs, minus the ancestor walk: unix -/// sockets and a per-user runtime directory. Anywhere else there is no detached -/// session and `listen` says so. -const supported = nested.supported; - -/// `sun_path` is 108 bytes on linux and 104 on darwin, taken from the struct so -/// that the buffers, the fit checks and the memcpy cannot disagree with the -/// kernel or with each other. -const sun_path_len = nested.sun_path_len; - -/// How many frontends may be attached at once. The number and the shape are -/// fuse.zig's park table: 32 slots, and overflow is a refusal rather than a -/// queue. A session with 32 frontends on it is not a session, it is a mistake, -/// and the 33rd gets told so instead of waiting in a backlog nobody drains. +pub const darwin = ninep_io.darwin; + +const supported = ninep_io.supported; + +const sun_path_len = ninep_io.sun_path_len; + pub const max_clients = 32; -/// Bytes of un-drained CONTROL messages a client may owe before it is closed. -/// Frames are not in here (see the module header), so this bounds a backlog of -/// the three things that are still on the wire — a welcome, a clipboard mirror, -/// a link to open — and a frontend that has not taken 1 MiB of those has -/// stopped reading its socket. Checked before an append rather than after, so -/// one oversized message is never the thing that trips it. const out_backlog = 1 << 20; -/// One read per client per poll round (see `receive`). 16 KiB is two orders of -/// magnitude past a keystroke and small enough to sit on the loop's stack; a -/// 4 MiB paste arrives across several rounds, which is the point. const read_chunk = 16 * 1024; -/// Bytes taken off one pane's pty per poll round. 64 KiB is what every other -/// host's pty reader uses (`readPty` in tty.zig, gui.zig and macos.zig), and it -/// sits on `readPty`'s own frame rather than the loop's. Nothing is copied out -/// of it: `Event.output` borrows the buffer for one `update` call, so a daemon -/// serving a shell that is printing a build log asks the allocator for nothing. const pty_chunk = 64 * 1024; -/// Descriptors in the ONE poll this process runs: the listener, every frontend, -/// every pane's pty master, the inotify descriptor behind every watch, -/// `/dev/fuse`, the 9P listener, and every 9P connection. That is -/// 1 + 32 + 16 + 1 + 1 + 1 + 4 = 56 on a full house, and one syscall covers -/// all of them. -const poll_slots = 1 + max_clients + pardes.MAX_PANES + 2 + 1 + fs9_service.max_conns; +const poll_slots = 2 + max_clients + pardes.MAX_PANES + 2 + 2 + @as(usize, @intFromBool(ninep_io.quic_enabled)) + ninep_io.max_conns; -/// How many times `reloadWatched` will honour `file_watch.reloadChanged`'s -/// request for another pass within one round. See `reloadWatched`. const reload_retries = 4; -/// Bytes of client traffic — every in-queue and out-queue together — this -/// session may hold before it starts closing the peers holding it. -/// `out_backlog` bounds ONE slot and this bounds the table, which is not the -/// same ceiling: a client's `out` tops out at `out_backlog` plus the one -/// oversized message allowed through whole, so `out_backlog` alone permits -/// 32 * (1 + 1.6) MiB, about 83 MiB of a daemon nobody is looking at. -/// -/// DERIVED, and the derivation IS the fix. This was the literal `4 << 20`, -/// which was by coincidence the exact value of tty.zig's `max_paste_bytes` — -/// and `in` grows to hold one WHOLE message, so a frontend assembling the very -/// paste wire.zig names as one of the two messages that set `max_payload` -/// crossed the table's ceiling while still receiving it. The session then -/// closed the only frontend it had, mid-paste, with `.backlog`, which is the -/// diagnostic for a peer that STOPPED reading. The documented maximum paste -/// could not complete. Two whole `max_payload`s is the smallest number that is -/// headroom rather than another coincidence: one peer may legitimately be -/// assembling a message of the largest size `framed` will accept while the rest -/// of the table holds frames, and past 32 MiB the fattest peer is the peer that -/// stopped draining. Neither the mirrors nor the pane queues are in this -/// number: a mirror is this session's own bookkeeping for a client it chose to -/// serve, and a pane is bounded per pane by `pty_backlog` because it is not a -/// peer and cannot be closed to reclaim anything. const session_backlog = 2 * @as(usize, wire.max_payload); -/// Bytes of un-drained INPUT one pane's shell may owe before more is refused. -/// -/// A pane is not a client, so the answer cannot be `out_backlog`'s: a client -/// that stops draining is closed, and the thing at the other end of a pty is a -/// program the human is running. This refuses the write and says so on the -/// pane's message row instead, which is the only honest answer left — dropping -/// input silently loses half a command line, and killing a shell to reclaim a -/// megabyte destroys work. -/// -/// Checked BEFORE the append, exactly as `queue` checks `out_backlog`, and that -/// is what makes 1 MiB enough: any single write lands whole, so a maximum paste -/// into an empty queue is never truncated. What gets refused is MORE input typed -/// at a program that has stopped reading its input at all — `sleep 3600`, a -/// stopped job, anything blocked on its own output. const pty_backlog = 1 << 20; -/// How long a connection has to say `hello`, and the ONE number both ends of -/// this transport time the handshake against. `pub` because a frontend that -/// waited longer than the session is willing to hold its slot would report a -/// timeout for a slot that had already been taken back, and a frontend that -/// waited less would give up on a session that was still going to answer — two -/// halves of one deadline, and two literals is how they drift apart. -/// -/// The `Session` field it initialises is a field and not this constant for -/// exactly one reason: the test for expiry would otherwise have to sleep five -/// seconds. See `Session.greet_deadline_ms`. pub const greet_deadline_default_ms: u32 = 5_000; -/// What a DRAINED client is allowed to keep. `in` grows to hold one whole -/// message, so a single 4 MiB paste otherwise leaves 4 MiB resident in that -/// slot for the life of the session — 128 MiB across a full table, for -/// something that happened once. Anything above one `read_chunk` is handed -/// back the moment the buffer empties, and the next message pays one -/// allocation for it; below that it is kept, so a session of keystrokes never -/// asks the allocator at all. const idle_retain = read_chunk; -/// How long the listener is left out of the poll set after an `accept` that -/// failed for a reason that persists (EMFILE above all). See `accept`: the -/// alternative was sleeping 100 ms inside the core. const accept_pause_ms = 100; -/// Why a client's connection ended. Only ever logged (`PARDES_LOG=1`), and -/// spelled out because "connection closed" is the one diagnostic that has never -/// helped anybody. const Closed = enum { bye, peer, protocol, backlog, silent, write, read, oom, refused, quitting }; const Client = struct { fd: c_int = -1, - /// The `hello` landed and was accepted. Before that the connection exists - /// but votes on nothing and is sent no frames: its geometry is unknown. attached: bool = false, - /// A `welcome` is owed, and is sent once this round's geometry has settled - /// so the number in it is the one the next frame will use. greet: bool = false, - /// This frontend's own window, as its last `hello`/`resize` said. One vote - /// in `reconcile`'s minimum, never the session's grid by itself. cols: u16 = 0, rows: u16 = 0, - /// Bytes read and not yet a whole message. in: std.ArrayListUnmanaged(u8) = .empty, - /// Bytes owed to the kernel. out: std.ArrayListUnmanaged(u8) = .empty, - /// What this client's grid holds, so the next frame can be a diff. Advanced - /// only when a frame is actually queued for it, which is what makes a - /// skipped frame correct rather than lost. mirror: std.ArrayListUnmanaged(pardes.Cell) = .empty, - /// The next frame must be full: freshly attached, or the session geometry - /// moved under it. need_full: bool = true, - /// Monotonic milliseconds at `accept`, and the only thing an un-greeted - /// connection is timed against. See `Session.greet_deadline_ms`. accepted_ms: i64 = 0, }; -/// A pane's shell: forked by THIS process, drained by its poll set, reaped by -/// it. -/// -/// There is no owner here and nothing is owed, and the absence is the whole -/// change. This struct used to record which frontend had been asked to fork a -/// pane and re-ask the next arrival when that frontend left, because the pty -/// lived in the frontend that forked it; and a `--detach`, whose panes always -/// exist before its socket does, had nobody to ask at all and had to remember -/// the request instead. Both were one problem, and forking here dissolves both: -/// a startup layout's shells are forked during `run`'s pre-loop drain with -/// nobody attached, and they are still those same shells when the tenth -/// frontend attaches an hour later. const Pty = struct { - /// The pty master, non-negative exactly when this pane has a live shell. - /// While it is here it is in the poll set (`waitInput`), NON-BLOCKING like - /// every other descriptor this file holds — `spawn` flips it, because - /// `forkpty` hands it back blocking and tty.zig's streaming reader wants it - /// that way. fd: c_int = -1, - /// Kept past the fork for `look.shellCwd` and `look.ttyTaken`, both of which - /// ask /proc about this pid rather than about the descriptor. pid: posix.pid_t = 0, - /// Bytes owed to this shell's stdin, drained by POLLOUT and bounded by - /// `pty_backlog`. The same shape as `Client.out`, for the same reason: the - /// thing on the far side may not be reading, and this process must not wait - /// to find out. See `ptyWrite`. + kill_at: i64 = 0, out: std.ArrayListUnmanaged(u8) = .empty, }; -/// What one descriptor in `waitInput`'s poll set is. A tagged union rather than -/// the bare slot index this loop used to carry alongside its `pollfd`s, because -/// the set now holds four different kinds of thing and a `u8` cannot say which. +const RetiredShell = struct { pid: posix.pid_t = 0, kill_at: i64 = 0 }; + +const Completion = union(enum) { + lsp: struct { id: u32, rows: ?[]u8 }, + pipe: selection_pipe.Response, + + fn deinit(completion: *Completion, gpa: std.mem.Allocator) void { + switch (completion.*) { + .lsp => |result| if (result.rows) |rows| gpa.free(rows), + .pipe => |*result| result.deinit(gpa), + } + } +}; + +const Mailbox = struct { + const capacity = selection_pipe.Tasks.capacity + 1; + const Batch = struct { + items: [capacity]Completion = undefined, + len: usize = 0, + status: ?[]u8 = null, + }; + + mutex: std.atomic.Mutex = .unlocked, + batch: Batch = .{}, + wake: [2]c_int = .{ -1, -1 }, + + fn post(box: *Mailbox, completion: Completion) void { + while (!box.mutex.tryLock()) std.atomic.spinLoopHint(); + // Tasks retain their slot until the owner consumes their one completion. + std.debug.assert(box.batch.len < capacity); + box.batch.items[box.batch.len] = completion; + box.batch.len += 1; + box.mutex.unlock(); + box.signal(); + } + + fn signal(box: *Mailbox) void { + while (libc.send(box.wake[1], "w", 1, nosignal) < 0) { + if (libc.errno(-1) != .INTR) break; + } + } + + fn take(box: *Mailbox) Batch { + var bytes: [128]u8 = undefined; + while (libc.recv(box.wake[0], &bytes, bytes.len, 0) > 0) {} + while (!box.mutex.tryLock()) std.atomic.spinLoopHint(); + defer box.mutex.unlock(); + const batch = box.batch; + box.batch.len = 0; + box.batch.status = null; + return batch; + } +}; + const Source = union(enum) { listener, + completion, client: u8, pty: u8, inotify, - /// The acme filesystem's descriptor. Its arm does one thing only: notice - /// that the connection has gone. Being in the set is otherwise the whole - /// point, because a readable `/dev/fuse` must END THE SLEEP so that - /// `pollFrame` — which runs after `pull_wait_input` returns — reaches the - /// drain. - /// - /// The drain is not done HERE, and the reason is not re-entrancy: both - /// `pull_wait_input` and `push_poll_frame` are called from inside - /// `Pardes.pump`, so either would re-enter. It is that `dispatch` is - /// mid-iteration over the `fds[0..n]`/`src[0..n]` SNAPSHOT `waitInput` - /// built, and a filesystem request reaches `core.perform`, which drains the - /// whole effect ring — including a `push_spawn`, whose `Session.spawn` - /// closes a pane's master and forks a new one. A later `.pty` entry in the - /// same pass would then apply the old descriptor's `revents` to a brand new - /// one, and the `fd < 0` guard cannot see it because the fd is valid, - /// merely different. Same hazard as the client slots, same reason. - fuse, - /// The 9P listener, and one arm per connection on it. Same shape and same - /// hazard as `fuse` above, and stated separately only because the hazard - /// is WORSE here: a 9P `Twrite` to `ctl` reaches `core.perform` exactly as - /// a FUSE write does, so it can `push_spawn` and close a pane's master - /// under a later `.pty` entry in this same pass. So `dispatch` moves BYTES - /// — accept, read into `push`, drain `output` — and never serves a - /// request; `pollFrame` does the serving, after the snapshot is done with. ninep_listener, - /// A connection's index in `Listener.conns`. + ninep_quic, ninep: u8, }; pub const Session = struct { gpa: std.mem.Allocator, - /// The Io every filesystem read this host performs goes through: - /// file_watch.zig's reload of a changed pane, and `user_config.dumpThemes`. - /// Required and not optional — a session that owns the disk work cannot be - /// handed a null disk. + worker_gpa: std.mem.Allocator, io: std.Io, core: *pardes.Pardes, - /// -1 when nothing is bound: an unsupported platform, or a bind that - /// failed. A session with no listener is a session nobody can attach to, - /// which still runs. listener: c_int = -1, - /// The bound path, kept so teardown unlinks exactly what was created and - /// nothing else — guarded on the fd, like nested.zig's `unlisten`. path_buf: [sun_path_len]u8 = undefined, path_len: usize = 0, clients: [max_clients]Client = @splat(.{}), - /// The session grid: the smallest common one across attached frontends. - /// Seeded from the core's own startup size so the first attach of an - /// identically sized frontend posts no resize at all. cols: u16, rows: u16, - /// Whose input was applied last, for the two calls that must go back to one - /// particular frontend. See the module header. origin: ?u8 = null, - /// One encode buffer, reused. Grown to whatever the largest message so far - /// needed rather than sized from `wire.max_payload`, which would be 16 MiB - /// of resident memory for a session whose frames are six kilobytes. scratch: std.ArrayListUnmanaged(u8) = .empty, - /// Each pane's shell. Forked here, drained by the poll set, reaped by - /// `harvest`. See `Pty`. ptys: [pardes.MAX_PANES]Pty = @splat(.{}), - /// The OSC 133 rc files a forked shell sources, staged once for the life of - /// this host exactly as tty.zig stages them for the life of a terminal: - /// `shell_bin.resolve` hands a child pointers into these buffers and the - /// child holds them until it execs, so they must not live in a stack frame. - /// The default is the empty one, which `resolve` reads as "this shell gets - /// no prompt marks"; `run` supplies a staged one. - prompt_rcs: shell_bin.PromptRcs = .{}, - /// The one inotify descriptor behind every watch this session holds, and the - /// last member of the poll set. Opened lazily — see `inotify`. + retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), + mailbox: Mailbox = .{}, + lsp_task: ?host_io.Lsp.Task = null, + pipe_tasks: selection_pipe.Tasks = .{}, + prompt_rcs: host_io.Shell.PromptFiles = .{}, inotify_fd: c_int = -1, - /// acme's control filesystem, or null when `--fs` was not asked for or the - /// mount failed. Owned here rather than by `run` so that `deinit` unmounts - /// on every path out, including the error ones. - fs: ?*fuse.Fs = null, - /// The last drain stopped at `max_batch` with requests still in the kernel. - /// Same role as `check_files`: nothing else will wake us, because no - /// acknowledgement has gone back, so the next round must not sleep. - fs_pending: bool = false, - /// The same tree on a unix socket, or null when `--fs9` was not asked for - /// or the bind failed. Owned here, like `fs`, so that `deinit` closes the - /// socket and unlinks its path on every way out. - ninep: ?*fs9_service.Listener = null, - /// A 9P drain stopped with work still owed. `fs_pending`'s twin, and it - /// needs its own field rather than sharing that one: they are cleared by - /// different drains, and or'ing them into one flag would make a busy 9P - /// script keep the FUSE drain's `pending` set forever. + ninep: ?*ninep_io.Listener = null, ninep_pending: bool = false, - /// WHICH TRANSPORT THE REQUEST BEING SERVED CAME FROM, for the whole of - /// one `fs_service.drain` and never outside one. - /// - /// A filesystem reply reaches its transport through `push_fs_reply`, which - /// is a HOST method: the core answers a `fs_req` and does not know, and - /// must not know, that this process has two filesystems on one tree. This - /// field is the routing origin docs/9p.typ's layering table says a second - /// listener costs, and it is one pointer set by the drain that already - /// knows the answer. - /// - /// Null outside a drain, and `fsReply` then falls back to the mount. That - /// fallback is for a reply the core produced with no request outstanding, - /// which `Fs.reply` drops on a slot lookup; it is NOT a routing guess, and - /// a 9P reply cannot reach it — every 9P request is answered inside the - /// `step` that made it, which is inside the drain that set this. - fs_origin: ?fs_service.Transport = null, - /// Which directory mark belongs to which pane, and the generation the core - /// has already accepted from each. file_watch.zig owns the shape and the - /// transaction; this host owns only the descriptor and the wake. watches: file_watch.Table = @splat(null), - /// A reconcile pass is due: a watched directory had an edge, or the last - /// pass asked for another one. Consumed at the end of `waitInput`, which is - /// where a core change still makes the current frame — `Pardes.pump` renders - /// after `pull_wait_input` returns. check_files: bool = false, - /// False during `run`'s pre-loop effect drain. Read in exactly one place: - /// `Pardes.loadThemeFile` animates an interactive theme change and must not - /// animate a startup one, and a `ThemeFile` in a boot layout is a startup - /// one. tty.zig spells the same distinction `threads_ok`. in_loop: bool = false, - /// How long a connection may stay silent before the session takes its slot - /// back. `Client.open` writes its `hello` in the same call that connects, - /// so a peer that has said nothing for five seconds is not a frontend that - /// was slow, and thirty-two of them used to fill the table and lock every - /// real frontend out with a `refuse .full`. - /// - /// A field rather than a constant for exactly one reason: the test for that - /// would otherwise have to sleep five seconds. Nothing else changes it, and - /// the number itself is `greet_deadline_default_ms`, which the frontend half - /// of this transport reads too. greet_deadline_ms: u32 = greet_deadline_default_ms, - /// Monotonic milliseconds until which the LISTENER is left out of the poll - /// set, because an `accept` failed for a reason that persists. See `accept`. accept_paused_ms: i64 = 0, - // ---- lifetime --------------------------------------------------------- + pub fn initAsync(s: *Session) !void { + var pair: [2]c_int = undefined; + if (libc.socketpair(libc.AF.UNIX, libc.SOCK.STREAM, 0, &pair) != 0) return error.SocketFailed; + errdefer for (pair) |fd| { + _ = libc.close(fd); + }; + for (pair) |fd| { + if (libc.fcntl(fd, libc.F.SETFD, @as(c_int, 1)) < 0) return error.SocketOptionFailed; + const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); + if (flags < 0) return error.SocketOptionFailed; + var options: libc.O = @bitCast(@as(u32, @bitCast(flags))); + options.NONBLOCK = true; + if (libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(options))))) < 0) + return error.SocketOptionFailed; + if (comptime darwin) { + const on: c_int = 1; + if (libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)) != 0) + return error.SocketOptionFailed; + } + } + s.mailbox.wake = pair; + pardes.lsp.setStatusSink(s, lspStatus); + } - pub fn deinit(s: *Session) void { - // First, and before the pane shells: a script blocked on `event` is - // holding a kernel request, and `Fs.deinit` answers everything still - // parked and ABORTS THE CONNECTION before unmounting, so that reader - // wakes with ENODEV while its own shell is still alive to run its exit - // path. After `closePty` it would be woken by a hangup instead, with - // nothing left to exit into. - // - // Not, as this comment first claimed, because the harvest takes time: - // `harvest` is `waitpid(WNOHANG)` in a loop and blocks for nothing. The - // one step here that CAN take milliseconds is this one, because - // `Fs.deinit` forks `fusermount3` and waits for it untimed — which is - // also why the `.quit` owed to every frontend now queues behind a - // subprocess. Worth knowing; not worth reordering, because the shells - // matter more than the milliseconds. - if (s.fs) |f| { - f.deinit(); - s.fs = null; + fn cancelWorkers(s: *Session) void { + if (s.lsp_task) |*task| { + task.future.cancel(s.io) catch {}; + s.lsp_task = null; + } + s.pipe_tasks.cancelAll(s.io); + _ = s.drainCompletions(false); + } + + fn drainCompletions(s: *Session, apply_results: bool) bool { + var batch = s.mailbox.take(); + for (batch.items[0..batch.len]) |*completion| { + defer completion.deinit(s.worker_gpa); + if (!apply_results) continue; + switch (completion.*) { + .lsp => |result| { + s.core.update(.{ .lsp_resp = .{ .id = result.id, .rows = result.rows } }); + if (s.lsp_task) |*task| if (task.id == result.id) { + task.future.await(s.io) catch {}; + s.lsp_task = null; + }; + }, + .pipe => |result| { + s.core.update(.{ .pipe_resp = .{ + .id = result.id, + .success = result.success, + .outputs = result.outputs, + .failure = result.failure, + } }); + s.pipe_tasks.finish(s.io, result.id); + }, + } + } + if (batch.status) |status| { + defer s.worker_gpa.free(status); + if (apply_results) { + var buf: [256]u8 = undefined; + s.core.setStatus(s.core.active, message.stamp(&buf, "lsp", status)); + } } - // ...and the 9P socket, for the mount's reason above: a script blocked - // on `event` over 9P is woken by the EOF its own connection closing - // produces, while its shell is still alive to run its exit path. The - // orphaned fids are not pumped — the core they would report releases to - // is going with them. + return batch.len != 0 or batch.status != null; + } + + pub fn restore(s: *Session, bytes: []const u8) !void { + const replacement = try s.core.restore(bytes); + s.cancelWorkers(); + for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); + s.harvest(); + for (0..pardes.MAX_PANES) |pane| + file_watch.watchPane(s.inotify_fd, &s.watches, @intCast(pane), null, 0, .{ .text = 0 }); + _ = file_watch.applyThemeEffect(s.core, s.gpa, s.inotify_fd, &s.watches, 0, false, false); + s.check_files = false; + if (s.ninep) |listener| listener.reset(s.core); + s.ninep_pending = false; + replacement.host = s.host(); + s.core.deinit(); + s.core = replacement; + for (&s.clients) |*client| if (client.attached) { + client.need_full = true; + }; + s.mailbox.signal(); + } + + pub fn deinit(s: *Session) void { + if (s.mailbox.wake[0] >= 0) pardes.lsp.setStatusSink(null, null); + s.cancelWorkers(); + for (s.mailbox.wake) |fd| if (fd >= 0) { + _ = libc.close(fd); + }; + s.mailbox.wake = .{ -1, -1 }; if (s.ninep) |l| { l.deinit(s.gpa); s.ninep = null; } - // Tell everyone the session is over before the socket disappears, so a - // frontend exits on a `quit` rather than on a read error whose meaning - // it has to guess. Best effort by construction: these descriptors are - // non-blocking, so a frontend that is not reading gets the EOF instead - // — which is a case it has to handle regardless. for (&s.clients) |*c| if (c.attached) s.send(c, .quit); for (&s.clients) |*c| if (c.fd >= 0) s.close(c, .quitting); s.unlisten(); s.scratch.deinit(s.gpa); - // The pane shells go with the SESSION and not with a frontend, which is - // this file's whole change. Closing a master is what hangs its shell up; - // `harvest` collects whatever has already exited, and the process is - // about to leave, so anything slower than that is the kernel's job. for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); - s.harvest(); - // Every mark dies with the descriptor, so there is nothing to unmark. + for (s.retired_shells) |shell| if (shell.pid > 0) { + _ = libc.kill(shell.pid, libc.SIG.KILL); + }; + for (s.ptys) |pty| if (pty.pid > 0) { + _ = libc.kill(pty.pid, libc.SIG.KILL); + }; + for (&s.retired_shells) |*shell| if (shell.pid > 0) { + while (libc.waitpid(shell.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + shell.* = .{}; + }; + for (&s.ptys) |*pty| if (pty.pid > 0) { + while (libc.waitpid(pty.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + pty.pid = 0; + }; if (s.inotify_fd >= 0) { _ = libc.close(s.inotify_fd); s.inotify_fd = -1; } - // Unlinks the two rc files staged for this host's shells. s.prompt_rcs.deinit(); } - /// Bind and listen. False when there is no socket, and a session without - /// one is simply one nobody can attach to — the same posture nested.zig - /// takes, and for the same reason: a failed bind must not cost a launch. pub fn listen(s: *Session, name: []const u8) bool { if (comptime !supported) return false; var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse return false; - if (!nested.ensureSocketDir(dir)) return false; + const dir = ninep_io.socketDir(&dir_buf) orelse return false; + if (!ninep_io.ensureSocketDir(dir)) return false; sweep(dir); const path = socketPath(&s.path_buf, dir, name) orelse return false; var addr: libc.sockaddr.un = .{ .path = @splat(0) }; @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); if (fd < 0) return false; - nested.setCloexec(fd); - // `bind` IS the exclusive create — it fails with EADDRINUSE the moment - // the path exists — so it, and nothing else, decides who owns a name. - // There is no unlink before it: unlinking unconditionally is how a - // second `pardes --detach=work` took the socket away from a live - // session, leaving every frontend attached to a file no new frontend - // could reach. + ninep_io.setCloexec(fd); if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { - // The one case that is not a collision: a session killed rather - // than quit ran no teardown, so its file outlived it. `alive` is - // the only thing that may say so, and it says so only about a - // connect that was REFUSED. if (alive(path)) { log.debug("a detached session is already listening on {s}", .{path}); _ = libc.close(fd); @@ -623,12 +312,7 @@ pub const Session = struct { return false; } } - // Owner-only, and BEFORE listen(2), which is the first moment anyone - // could connect. The directory is already private; this is the second - // wall, and this socket carries keystrokes into a live editor. _ = libc.chmod(path, 0o600); - // A backlog of max_clients: past that the kernel refuses the connect - // itself, which is the same answer `accept` would give. if (libc.listen(fd, max_clients) != 0) { _ = libc.close(fd); return false; @@ -643,15 +327,13 @@ pub const Session = struct { if (s.listener < 0) return; _ = libc.close(s.listener); s.listener = -1; - // Guarded on the fd, so a bind that FAILED cannot unlink a path this - // process never created. var z: [sun_path_len:0]u8 = undefined; @memcpy(z[0..s.path_len], s.path_buf[0..s.path_len]); z[s.path_len] = 0; _ = libc.unlink(z[0..s.path_len :0]); } - pub fn host(s: *Session) host_api.Host { + pub fn host(s: *Session) host_io.Host { return .{ .ctx = s, .vtable = &vtable }; } @@ -659,60 +341,97 @@ pub const Session = struct { return @ptrCast(@alignCast(ctx.?)); } - /// Eighteen methods, and NOT the fullest host in the tree — that claim - /// stood here, was believed, and was copied into docs/detached.md before an - /// audit counted the others. The tty and SDL shells fill TWENTY each - /// (everything but `pull_gpio_toggle` and `push_detach`) and macOS fifteen, - /// so this host is the only one that implements `push_detach` and otherwise - /// the least complete of the three desktop hosts. What is true is narrower - /// and is the point anyway: it performs every MACHINE-LOCAL effect there is, - /// and the four of host.zig's twenty-two it leaves null are null because - /// there is nothing here for them to do. Two of those four are real losses a - /// person can notice — no `pull_lsp` and no `pull_pipe`, because both want - /// the worker pool this deliberately single-threaded loop does not have. The - /// other two are not losses at all: `push_post_present` marks the moment a - /// frame reached a screen and this process has no screen, and - /// `pull_gpio_toggle` wants pads. - /// - /// `push_fs_reply` was the third real loss until this commit. It was null - /// because the daemon mounted no /dev/fuse, and the consequence was that a - /// detached session — the configuration whose whole promise is outliving the - /// terminal — was the one configuration no script could drive. It mounts one - /// now; see `fs` and `pollFrame`. - /// - /// `push_detach` is the one entry here that is not an effect. See `detach`. - const vtable: host_api.Host.VTable = .{ - .pull_wait_input = waitInput, - .push_present = present, - .push_poll_frame = pollFrame, - .push_spawn = spawn, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .push_detach = detach, - .push_fs_reply = fsReply, + const vtable: host_io.Host.VTable = .{ + .wait_input = waitInput, + .present = present, + .poll_frame = pollFrame, + .spawn = spawn, + .pty_write = ptyWrite, + .pty_resize = ptyResize, + .pty_signal = ptySignal, + .tty_taken = ttyTaken, + .write_file = writeFile, + .write_dump = writeDump, + .watch_file = watchFile, + .watch_theme = watchTheme, + .dump_themes = dumpThemes, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, + .detach = detach, + .lsp = lspRequest, + .pipe = pipeRequest, }; - // ---- routing ---------------------------------------------------------- + fn lspRequest(ctx: ?*anyopaque, request: host_io.Lsp.Request) void { + const s = of(ctx); + _ = s.drainCompletions(true); + if (s.lsp_task) |*task| { + task.future.cancel(s.io) catch {}; + s.lsp_task = null; + _ = s.drainCompletions(true); + } + const job = host_io.Lsp.snapshot(s.worker_gpa, s.core, request) catch |err| { + s.core.update(.{ .lsp_resp = .{ .id = request.id, .rows = null } }); + return s.core.reportError(request.pane, "lsp", err); + }; + const future = s.io.concurrent(lspWorker, .{ s.worker_gpa, job, &s.mailbox }) catch |err| { + job.free(s.worker_gpa); + s.core.update(.{ .lsp_resp = .{ .id = request.id, .rows = null } }); + return s.core.reportError(request.pane, "lsp", err); + }; + s.lsp_task = .{ .id = request.id, .future = future }; + } + + fn lspWorker(gpa: std.mem.Allocator, job: *host_io.Lsp.Job, box: *Mailbox) anyerror!void { + host_io.Lsp.work(gpa, job, box, deliverLspRows); + } + + fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { + const box: *Mailbox = @ptrCast(@alignCast(ctx.?)); + box.post(.{ .lsp = .{ .id = id, .rows = rows } }); + } + + fn lspStatus(ctx: ?*anyopaque, text: []const u8) void { + const s = of(ctx); + const copy = s.worker_gpa.dupe(u8, text) catch return; + while (!s.mailbox.mutex.tryLock()) std.atomic.spinLoopHint(); + if (s.mailbox.batch.status) |old| s.worker_gpa.free(old); + s.mailbox.batch.status = copy; + s.mailbox.mutex.unlock(); + s.mailbox.signal(); + } + + fn pipeRequest(ctx: ?*anyopaque, id: u32) void { + const s = of(ctx); + _ = s.drainCompletions(true); + if (s.pipe_tasks.full()) { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return; + } + const request = s.core.pipeRequest(id) orelse return; + const job = selection_pipe.Job.copy(s.worker_gpa, request) catch |err| { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", err); + }; + const future = s.io.concurrent(pipeWorker, .{ s.io, s.worker_gpa, job, &s.mailbox }) catch |err| { + job.deinit(s.worker_gpa); + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", err); + }; + std.debug.assert(s.pipe_tasks.add(.{ .id = id, .future = future })); + } + + fn pipeWorker(io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, box: *Mailbox) anyerror!void { + defer job.deinit(gpa); + box.post(.{ .pipe = selection_pipe.runJob(gpa, io, job) }); + } - /// The oldest surviving attachment. No election and no state: slots are - /// filled lowest-first, so the lowest attached one is the oldest that is - /// still here. fn primary(s: *Session) ?*Client { for (&s.clients) |*c| if (c.attached) return c; return null; } - /// ...and the frontend whose input we are answering, when there is one. fn origins(s: *Session) ?*Client { if (s.origin) |i| { const c = &s.clients[i]; @@ -721,8 +440,6 @@ pub const Session = struct { return s.primary(); } - /// Which slot this client is. From the pointer because every caller here - /// holds a `*Client` and not its index. fn slotOf(s: *Session, c: *const Client) u8 { return @intCast(@divExact(@intFromPtr(c) - @intFromPtr(&s.clients[0]), @sizeOf(Client))); } @@ -731,87 +448,36 @@ pub const Session = struct { for (&s.clients) |*c| if (c.attached) s.send(c, msg); } - // ---- the host methods: pseudo-terminals ------------------------------- - fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = of(ctx); if (pane >= s.ptys.len) return; // the core indexes its own panes - // The in-process host's reaping rule and its reason, verbatim from - // tty.zig `spawn`: the core reuses pane ids and there is no close - // effect, so a deleted pane's shell lives in its slot until a respawn - // lands here. s.closePty(pane); - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - if (cwd.len > 0 and cwd.len < cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } + s.harvest(); + if (s.ptys[pane].pid != 0) return s.core.reportError(pane, "shell", error.ShellClosing); + for (s.retired_shells) |shell| { + if (shell.pid == 0) break; + } else return s.core.reportError(pane, "shell", error.ShellClosing); const child = host_io.forkShell( s.core, pane, &s.prompt_rcs, s.core.shellBin(), - cwd_z, - // The SESSION grid — which `reconcile` already made the smallest - // common one across everyone attached, and which survives every - // frontend leaving, so a shell forked into an empty session is - // still sized like the pane the core reflowed. + cwd, s.core.screen_h, s.core.screen_w, - s.fs, - ); - // A `forkpty` that failed left `master` holding a number this process - // does not own. The shells get away with not checking because they hand - // the descriptor to a reader task that simply ends; this one would go - // into `poll(2)`, come back POLLNVAL, and be closed out from under - // whoever really owns it. - if (child.pid < 0) return; + s.ninep, + ) catch |err| return s.core.reportError(pane, "shell", err); s.ptys[pane] = .{ .fd = child.file.handle, .pid = child.pid }; - // ...and the master joins the rule every other descriptor in this file - // obeys. `forkpty` hands it back BLOCKING, and host_io.zig leaves it that - // way because tty.zig streams it from a thread that wants a blocking - // read; a poll loop wants the opposite, and one blocking `write(2)` here - // is the whole session parked. Only this side of the pty is affected — - // the master and the slave are separate open file descriptions, so the - // shell's own stdin stays exactly as `forkpty` made it. setNonblock(child.file.handle); - // The pane's starting directory, for the tags. `pollFrame` keeps it - // current after a `cd`; this is the one before the first frame. - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); - } - - /// Keystrokes and pastes into the shell, QUEUED and never blocked on. - /// - /// This was one blocking `host_io.writeFd`, and the comment defending it - /// argued that "the peer is a shell this process forked rather than a - /// stranger who can stop reading on purpose". The peer is whatever program - /// the human ran in the pane: `sleep 3600`, a job stopped with ^Z, anything - /// blocked writing its own output. Any of those plus a paste larger than the - /// pty's input buffer — four kilobytes, and a frontend is entitled to send a - /// four-MEGABYTE paste — put this single-threaded process to sleep inside - /// `write(2)` with the whole session behind it: no frame to any frontend, - /// fifteen other masters unread, no `accept`, no `expire`, no inotify drain. - /// - /// So a pane owes bytes the way a client does, and the answer is the shape - /// this file already had for exactly this problem. What differs is what - /// happens when the queue will not drain: a client that stops reading is - /// CLOSED, and a pane cannot be, because closing it kills a program the - /// human is running. See `pty_backlog` — the write is refused and said out - /// loud on the pane's own message row. + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); + } + fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = of(ctx); if (pane >= s.ptys.len) return; const pt = &s.ptys[pane]; - // A pane with no shell swallows what is typed at it, which is exactly - // what the core does with a null method. if (pt.fd < 0) return; - // BEFORE the append, which is `queue`'s rule and gives `queue`'s - // guarantee: one write always lands whole, so the biggest paste anyone - // can send is never truncated on arrival, and what is refused is the - // NEXT one typed at a program that has read nothing. if (pt.out.items.len > pty_backlog) { var mbuf: [256]u8 = undefined; const text = std.fmt.bufPrint( @@ -822,13 +488,8 @@ pub const Session = struct { return s.core.setMessage(pane, text); } pt.out.appendSlice(s.gpa, bytes) catch { - // Out of memory for a keystroke. The shell is fine and the session - // is fine; this one write is not, and saying so is all there is. return s.core.setMessage(pane, "input refused: out of memory"); }; - // Try immediately. On an idle pty this empties the queue in one write and - // the descriptor never asks for a POLLOUT at all, which keeps a session - // of keystrokes exactly as cheap as it was. s.flushPty(pane); } @@ -841,56 +502,32 @@ pub const Session = struct { _ = posix.system.ioctl(fd, TIOCSWINSZ, @intFromPtr(&ws)); } - /// `pty/ctl`'s `sig` — and the host where it matters most, because these - /// shells outlive every frontend: a script that signals a build in a - /// detached session is signalling a process nobody has a terminal on. - /// `fd < 0` is a pane with no shell, the same silence `ptyWrite` gives it. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = of(ctx); if (pane >= s.ptys.len) return; const pt = s.ptys[pane]; if (pt.fd < 0) return; - look.signalTty(pt.pid, pt.fd, sig); + host_io.signalTty(pt.pid, pt.fd, sig); } - /// Is this pane's tty still the prompt we forked, or has a program taken it? - /// - /// Answerable at all only because the pty is HERE. While a pane's shell - /// lived in a frontend this method had to stay null, and a null one means - /// the core types every `Exec` at the shell — into vim, into a pager, into - /// an agent waiting on stdin. Lazy by construction (host.zig): it runs where - /// the core is about to type a command line, so the /proc walk costs an - /// ordinary frame nothing. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = of(ctx); if (pane >= s.ptys.len) return false; const pt = s.ptys[pane]; if (pt.fd < 0) return false; - return look.ttyTaken(pt.pid, pt.fd); + return host_io.ttyTaken(pt.pid, pt.fd); } - // ---- the host methods: the filesystem --------------------------------- - fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, "save", err); - // Our own write is about to come back as an inotify edge: restamp from - // the bytes we just put there so the reconcile reads as "no change". - // Only when this IS the pane's watched file — a `Save ` must - // not silence a real change to the file the pane has open. Six lines - // shared with tty.zig `writeFile` over the same `file_watch.Table`, - // which is what makes a save in a detached pane behave like a save in a - // terminal one. if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, .pdf => {}, }; }; - // ...and say so on the pane's message row. AFTER the write, not beside - // it: the early return above is a save that did not happen and must not - // be reported as one. var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -899,22 +536,13 @@ pub const Session = struct { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return s.core.reportError(0, "dump", err); - // Where it landed, which is what puts `Restore ` in the topbar - // (pardes.zig `write_dump`). A dump of a detached session now lands in - // the same directory a terminal session's does, rather than in whatever - // directory the frontend that happened to be primary was started from. + filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } - fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void { + fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { const s = of(ctx); - // The path argument is unused because `applyEffect` takes it off the - // core's own pane, together with the serial and the generation that make - // the reconcile safe. That is the one thing a frontend could not do — it - // had no core — and it is why the frontend's copy of this method needed a - // second table of pathnames to go with the watch table. - if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify(), &s.watches, pane, on)) + if (file_watch.applyEffect(s.core, s.io, s.inotify(), &s.watches, pane, on, mode)) s.check_files = true; } @@ -926,10 +554,8 @@ pub const Session = struct { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); - // A session started without one has nowhere to put them; the core's - // options are the only place that answer lives. const config_dir = s.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = pardes.config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { s.core.reportError(pane, "dump themes", err); return; }; @@ -938,182 +564,59 @@ pub const Session = struct { s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } - // ---- the host methods: the desktop ------------------------------------ - fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { const s = of(ctx); - // Mirrored into the core's own clipboard ALWAYS, not only when nobody - // is attached: `readClipboard` answers from it when there is no - // frontend, and a frontend can leave between the yank and the paste. A - // yank that a detached session then pasted as the previous yank is the - // bug this one line is. s.core.fallback.setClipboard(text); s.broadcast(.{ .set_clipboard = text }); } - /// The one `pull_` that crosses the wire, and it stays a pull for exactly - /// the reason host.zig gives: two frontends answering would paste the - /// clipboard twice for one Ctrl-V. fn readClipboard(ctx: ?*anyopaque) void { const s = of(ctx); if (s.origins()) |c| return s.send(c, .read_clipboard); - // Nobody attached. This method being non-null means the core will NOT - // reach for its own fallback, so an unanswered request would leave - // `clip_pending` armed forever — host.zig's note that a null method - // answers immediately is the obligation being met here by hand. s.core.update(.{ .paste = s.core.fallback.clipboard.items }); } fn openLink(ctx: ?*anyopaque, url: []const u8) void { const s = of(ctx); if (s.origins()) |c| return s.send(c, .{ .open_link = url }); - // No desktop in reach, so the link goes where a host with no browser - // puts it: the core's record of the last one asked for, which is what - // `Fallback.setLink` is and what the acme filesystem reads back. s.core.fallback.setLink(url); } - // ---- the host methods: session control -------------------------------- - - /// `Detach` in an attached frontend: that frontend leaves, the session and - /// every other frontend carry on. tmux's `detach-client`. - /// - /// A `send` and NOTHING ELSE, and each of the three things it does not do is - /// deliberate. It does not quit — the whole point is that the session - /// survives, and a detach that took the daemon with it would be `quit` under - /// another name. It does not touch the core — no pane closes, no shell dies, - /// no frame changes; the grid is retaken by `reconcile` from the frontends - /// that remain, on the ordinary path, because a frontend leaving is already a - /// case this file handles. And it does not close the connection: the frontend - /// closes its own socket when it reads the message, and the peer-hangup path - /// then frees the slot exactly as it does for a frontend somebody killed. - /// Closing from this side would race the frontend's own teardown for no gain. - /// - /// It is therefore the one vtable entry here that is not an effect on the - /// world but SESSION CONTROL — one frontend asking to stop being a frontend - /// — which is why wire.zig numbers it 0x05, in the session range beside - /// `quit`, rather than in 0x10.. where every tag reaches a disk, a clipboard - /// or a browser. The module header's routing table says the same. - /// - /// ORIGIN, ELSE PRIMARY, for `read_clipboard`'s and `open_link`'s reason: it - /// answers something one particular human just typed, so it has to reach that - /// human's screen and not somebody else's — sending a detach to the wrong - /// frontend takes away a session from a person who did not ask. Nobody - /// attached at all is a no-op, and correctly so: there is no frontend to - /// detach, and the core has nothing to record about one. fn detach(ctx: ?*anyopaque) void { const s = of(ctx); if (s.origins()) |c| s.send(c, .detach); } - /// The core's answer to one filesystem request, handed straight back to the - /// transport holding it. `bytes` was resolved by `pardes.fsPayload` inside - /// `perform` and is borrowed only for this call, so a body read is a window - /// onto the pane's live text and copies nothing. `.again` needs no case: - /// both transports read the status and re-park the request themselves. - /// - /// WHICH transport is `fs_origin`, set by the drain that asked. This used - /// to be `s.fs` unconditionally, which was right while a mount was the only - /// answer there was and became a silent misroute the moment `--fs9` gave - /// the session a second one: `Fs.reply` looks the tag up in ITS park table, - /// finds nothing, and returns — so a 9P `Tattach` was answered into the - /// void and its client waited forever. Measured against plan9port's `9p`. - fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void { - const s = of(ctx); - if (s.fs_origin) |t| return t.reply(reply, bytes); - if (s.fs) |f| f.reply(reply, bytes); - } - - // ---- pane shells ------------------------------------------------------ - - /// Each pane's live cwd, for the tags. One readlink of /proc per pane that - /// has a shell, per frame, which is what tty.zig's `pollFrame` costs — and - /// why the far more expensive question, whether a program has taken the - /// pane's tty, is a pull asked at the `Exec` that cares (`ttyTaken`) - /// instead of polled here. - /// - /// This could not exist before. A detached session's shells lived in a - /// frontend, and a frontend has no core to report a cwd TO, so a `cd` in a - /// detached pane never reached its tag no matter how many frontends were - /// watching. The pids are here now, so it does. fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); - // acme's filesystem first in the pass, for the reason tty.zig gives at - // its own call site: an edit a script just made through `body` belongs - // in the surface this frame composes, not the next one. The flag is - // read by `waitInput`, which must not sleep while the kernel still has - // requests we have not acknowledged. - if (s.fs) |f| { - s.fs_origin = f.transport(); - s.fs_pending = fs_service.drain(s.fs_origin.?, s.core).pending; - s.fs_origin = null; - } - // ...and the 9P connections, in the same breath and for the same - // reason. One connection is one `Transport`, so this is - // `fs_service.drain` per connection per frame, with `fs_origin` naming - // the one being served so its replies come back to it (see `fsReply`). - // HERE rather than in `dispatch` for `Source.ninep`'s reason: a - // `Twrite` to `ctl` can `push_spawn`, and `dispatch` is mid-iteration - // over a descriptor snapshot when it runs. - if (s.ninep) |l| { - // Before the drain, so a slot held by silence is taken back on the - // same frame it expires rather than one drain later. - l.expire(); - var pending = false; - for (0..fs9_service.max_conns) |i| { - const t = l.transport(@intCast(i)) orelse continue; - s.fs_origin = t; - const d = fs_service.drain(t, s.core); - s.fs_origin = null; - if (l.settle(@intCast(i), d)) pending = true; - } - s.ninep_pending = pending; - } + if (s.ninep) |l| s.ninep_pending = if (ninep_io.quic_enabled and l.quic != null) l.tick(s.core).pending else l.drain(s.core).pending; for (&s.ptys, 0..) |*pt, pane| { if (pt.fd < 0) continue; - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(pane, cwd); + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(pane, cwd); } } - /// Drop a pane's shell: out of the poll set, out of the process. Closing the - /// master is what hangs the shell up — which is true only because - /// `host_io.forkShell` puts FD_CLOEXEC on it, so no LATER pane's shell is - /// still holding a copy open. The pid is left to `harvest`, because a - /// `waitpid` here would return 0 for a shell that has not noticed the hangup - /// yet and that answer is worth nothing. - /// - /// The core is NOT told. Its two callers are `spawn` — a respawn, where the - /// core is the thing that asked — and `deinit`, where there is no core left - /// to tell. The path that does tell it is `paneEof`. fn closePty(s: *Session, pane: u8) void { const pt = &s.ptys[pane]; if (pt.fd < 0) return; _ = libc.close(pt.fd); - // Before the reset, or the queue's allocation goes with the slot: what - // is in it is input a program that is not reading never took, and there - // is nobody left to hand it to. + pt.fd = -1; pt.out.deinit(s.gpa); - pt.* = .{}; - } - - /// Push what the kernel will take of what this pane owes its shell, and - /// leave the rest for a POLLOUT. `flush`'s body, on a pty instead of a - /// socket, down to the `retire` that hands a drained megabyte back. - /// - /// The one difference is what an error means. A failed write to a SOCKET - /// closes a client; a failed write to a master means the slave side is gone, - /// which is the same event as a read of 0. It is NOT the same moment, - /// though, and that is why the error arm reads the pane before it ends it: - /// linux's `n_tty_write` returns EIO the instant the slave has no open - /// descriptors left, while `n_tty_read` on that same master still hands back - /// what the shell wrote before it went — so the write fails while the last - /// line is still retrievable, and ending the pane first would throw it away. - /// That is the very thing the dispatch's `.pty` branch protects against when - /// it takes POLLIN before POLLHUP, and it has to hold here too, because two - /// paths reach this arm with no read of their own in between: the dispatch - /// runs POLLOUT before POLLIN, and `ptyWrite` calls this during `perform`, - /// after this round's `readPty` has already been and gone. + pt.out = .empty; + if (pt.pid == 0) return; + _ = libc.kill(pt.pid, libc.SIG.HUP); + pt.kill_at = monotonicMs() + 100; + s.harvest(); + if (pt.pid == 0) return; + for (&s.retired_shells) |*shell| if (shell.pid == 0) { + shell.* = .{ .pid = pt.pid, .kill_at = pt.kill_at }; + pt.pid = 0; + pt.kill_at = 0; + return; + }; + } + fn flushPty(s: *Session, pane: u8) void { const pt = &s.ptys[pane]; var off: usize = 0; @@ -1121,22 +624,13 @@ pub const Session = struct { const n = libc.write(pt.fd, pt.out.items.ptr + off, pt.out.items.len - off); if (n < 0) switch (libc.errno(n)) { .INTR => continue, - // The pty's input buffer is full: the rest waits for POLLOUT, - // and this is the case the whole change exists for. .AGAIN => break, else => { - // `readPty` either takes that last chunk or reaches the end - // itself and has already ended the pane; the guard is what - // stops the second `paneEof` from being a double-end. s.readPty(pane); if (s.ptys[pane].fd >= 0) s.paneEof(pane); return; }, }; - // No progress and no error. host_io.zig's `writeFd` says why this is - // a `break` and never a retry: looping on a zero-byte write is a - // spin, and a spin in here is the whole session at 100% of a core - // with no syscall for a signal to interrupt. if (n == 0) break; off += @intCast(n); } @@ -1149,119 +643,51 @@ pub const Session = struct { pt.out.items.len -= off; } - /// One read per readable pty per round — `receive`'s rule for clients, - /// applied to shells: a `yes` in pane 1 gets one turn and the loop moves on - /// to the other panes, the frontends and the frame. - /// - /// Nothing is copied. `Event.output` borrows the buffer for the length of - /// one `update` call, which is the same borrow window every other host gives - /// a pty chunk — tty.zig frees its duplicate the line after the update — - /// except that this one never allocated a duplicate to free. A daemon - /// serving sixteen shells printing build logs asks the allocator for - /// nothing. fn readPty(s: *Session, pane: u8) void { var buf: [pty_chunk]u8 = undefined; const got = libc.read(s.ptys[pane].fd, &buf, buf.len); if (got == 0) return s.paneEof(pane); if (got < 0) return switch (libc.errno(got)) { - // A master that said POLLIN and then had nothing is not an error; - // the next round asks again. .INTR, .AGAIN => {}, - // EIO is how linux reports the slave side going away, which is the - // ordinary end of a shell rather than a fault. else => s.paneEof(pane), }; s.core.update(.{ .output = .{ .pane = pane, .bytes = buf[0..@intCast(got)] } }); } - /// The shell in `pane` is gone. The descriptor leaves the poll set BEFORE - /// the core is told, because an `eof` is what makes the core offer a respawn - /// and a respawn into a slot still holding the old fd would leak it. fn paneEof(s: *Session, pane: u8) void { s.closePty(pane); s.harvest(); s.core.update(.{ .eof = .{ .pane = pane } }); } - /// Collect every child that has exited. - /// - /// `waitpid(-1)` and not a pid list, because the only children this process - /// LEAVES UNREAPED are pane shells (`host_io.forkShell`) — so "any exited - /// child" and "an exited pane shell" are the same set — and because the pids - /// a list would hold are exactly the ones it cannot help with: a respawn - /// closes a master, and the shell that gets the hangup exits some - /// milliseconds later with its slot already reused by a different shell. - /// Anything else this process forks — `fusermount3`, from `Fs.mount`, - /// `sweepStale` and `Fs.deinit` — is reaped by its own spawner with a - /// pid-specific blocking wait before control returns here, so the set this - /// sees is still only shells. A future worker that forks and does not wait - /// would break that, and this is the sentence it has to come back and edit. - /// - /// Nothing here waits, so a session whose shells are all running pays one - /// syscall that returns 0. Called once per poll round and again wherever a - /// shell is dropped, which is what keeps a daemon that runs for a week and - /// spawns a thousand shells free of zombies — the one bookkeeping cost a - /// long-lived process pays that a frontend, which exits, never did. - fn harvest(_: *Session) void { - while (true) { - // 0: there are children and none has exited. -1: no children at all. - if (libc.waitpid(-1, null, libc.W.NOHANG) <= 0) return; + fn harvest(s: *Session) void { + const now = monotonicMs(); + for (&s.retired_shells) |*shell| reapShell(&shell.pid, &shell.kill_at, now); + for (&s.ptys) |*pty| if (pty.fd < 0) reapShell(&pty.pid, &pty.kill_at, now); + } + + fn reapShell(pid: *posix.pid_t, kill_at: *i64, now: i64) void { + if (pid.* == 0) return; + const result = libc.waitpid(pid.*, null, libc.W.NOHANG); + if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) { + pid.* = 0; + kill_at.* = 0; + } else if (kill_at.* != 0 and now >= kill_at.*) { + _ = libc.kill(pid.*, libc.SIG.KILL); + kill_at.* = 0; } } - // ---- watched files ---------------------------------------------------- - - /// The one inotify descriptor behind every watch, opened on first use. - /// - /// Lazy for two reasons pointing the same way: a `Session` is built as a - /// struct literal (client.zig's test harness is one) and so has no init hook - /// to open it in, and a session whose panes are all shells never watches a - /// path and has no use for one. -1 on anything but linux and on a failed - /// `inotify_init1`, which file_watch.zig reads as "mark nothing" — the core - /// then keeps its own record of what was asked and simply never gets a - /// reload, which is what a host with no watcher has always done. - /// - /// `polled` is true because this descriptor is drained from `poll`, not - /// from a thread parked in a wait (tty.zig `watchFiles`): `drainInotify` - /// must be able to stop. On linux that is IN_NONBLOCK; on macos a kqueue - /// needs nothing, since the timeout argument to `kevent(2)` decides. fn inotify(s: *Session) c_int { if (s.inotify_fd >= 0) return s.inotify_fd; s.inotify_fd = file_watch.init(true); return s.inotify_fd; } - /// A directory this session marked had an edge. The CONTENTS are discarded - /// on purpose, exactly as tty.zig's watcher thread discards them: a record - /// names a mark and a filename, and reconciling every mark against the - /// generation the core accepted is both cheaper and safer than deciding from - /// the record which pane it meant. - /// - /// DRAINED TO EMPTY, in a loop, and one read was a real cost rather than the - /// coalescing this comment used to claim. The descriptor is level-triggered, - /// so a queue left partly full makes `poll` return ready again immediately — - /// and each of those rounds is a whole `pump`: `reloadChanged` over all 17 - /// slots, every watched text pane re-read from disk and re-hashed, a render, - /// a present. A `git checkout` can queue the kernel's whole 16384 events; at - /// roughly 128 records per 4 KiB that was ~128 spin rounds and some two - /// thousand whole-file reads for one command, at 100% of a core, while every - /// frontend got a frame per round it could not use. The fd is IN_NONBLOCK - /// (`inotify`), so the loop ends on EAGAIN. fn drainInotify(s: *Session) void { if (file_watch.drain(s.inotify_fd)) s.check_files = true; } - /// Reconcile every marked pane and the theme file. Called at the END of - /// `waitInput`, which is what puts a reload in THIS frame: `Pardes.pump` - /// renders after `pull_wait_input` returns. - /// - /// The loop is `reloadChanged`'s contract. It asks for another pass when a - /// PDF's pathname changed between the stat before MuPDF reopened it and the - /// stat after — a save that landed mid-reconcile, where committing either - /// identity would lose a generation. tty.zig posts that request back into - /// its event queue; this loop has no queue, so it is retried here and - /// BOUNDED, because a file being rewritten in a loop must not hold the core. - /// What is left over is picked up by the next directory edge. fn reloadWatched(s: *Session) void { if (!s.check_files) return; s.check_files = false; @@ -1270,16 +696,10 @@ pub const Session = struct { } } - // ---- the frame -------------------------------------------------------- - fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = of(ctx); for (&s.clients) |*c| { if (!c.attached) continue; - // A client that has not drained what it already owes does not get - // this frame, and its mirror is deliberately left where it is: the - // next frame it does get is a diff against what it really has. A - // slow frontend gets fewer, larger frames rather than a queue. if (c.out.items.len != 0) continue; s.sendFrame(c, surface); } @@ -1289,8 +709,6 @@ pub const Session = struct { const cells = surface.cells; const want = wire.frameBound(surface.cols, surface.rows); s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom); - // Nothing comparable on the far side is the LATE JOINER and the RESIZE - // in one test: either way the whole grid has to be described. const prev: []const pardes.Cell = if (c.need_full or c.mirror.items.len != cells.len) &.{} else @@ -1307,61 +725,31 @@ pub const Session = struct { cells, prev, ) catch |err| { - // A frame this protocol cannot carry is a grid past `max_cols` / - // `max_rows`, or a cursor the core placed outside its own surface. - // Dropping the frame keeps the session alive with a stale screen, - // which is strictly better than dropping the frontend — a frontend - // REFUSES such a frame and hangs up — and the log says which. log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; }; s.queue(c, bytes); if (c.fd < 0) return; // the queue closed it; the mirror went with it - // The mirror advances only now, and only because the bytes are on the - // wire or in the kernel's buffer for it. c.mirror.resize(s.gpa, cells.len) catch return s.close(c, .oom); @memcpy(c.mirror.items, cells); c.need_full = false; } - // ---- the loop --------------------------------------------------------- - - /// The only place this process sleeps, which is what `pull_wait_input`'s - /// comment in host.zig requires of whoever serves it, and the only place it - /// waits on ANYTHING: one `poll(2)` over the listener, every attached - /// frontend, every pane's pty master and the inotify descriptor. No thread - /// per client, no thread per shell, no watcher thread, and nothing here - /// blocks on a single peer. - /// - /// That the shells are in this set and not on threads of their own is what - /// lets a daemon own sixteen of them and stay a single-threaded state - /// machine — and it costs the shells nothing, because a pty master is - /// pollable and a pane's output has nowhere to go but the core this loop is - /// driving anyway. fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); - // Push what the kernel will take before sleeping: a client that becomes - // writable while we are inside poll(2) would otherwise be a frame late, - // and a frame late is a frame skipped (see `present`). + const completed = s.drainCompletions(true); for (&s.clients) |*c| if (c.fd >= 0) s.flush(c); - // The session grid, retaken BEFORE the sleep as well as after it. A - // client can leave OUTSIDE this function — a `set_clipboard` broadcast - // whose write failed during `perform` closes it — and the minimum across - // attached frontends would then stay sized for a frontend that is gone - // until some descriptor happened to become readable, which on an idle - // session is never. That is what this call buys, and `regridded` is what - // it costs: a round that has just told the core to reflow must not then - // sleep on it, because the frame carrying that reflow is the one - // `Pardes.pump` composes the moment this returns. const regridded = s.reconcile(); const now = monotonicMs(); var fds: [poll_slots]libc.pollfd = undefined; var src: [poll_slots]Source = undefined; var n: usize = 0; - // The listener is left OUT of the set while accepting is paused, which - // is how an EMFILE is waited out without the core sleeping (see - // `accept`). Every frontend already attached goes on being served. + if (s.mailbox.wake[0] >= 0) { + fds[n] = .{ .fd = s.mailbox.wake[0], .events = poll_in, .revents = 0 }; + src[n] = .completion; + n += 1; + } const watching_listener = s.listener >= 0 and now >= s.accept_paused_ms; if (watching_listener) { fds[n] = .{ .fd = s.listener, .events = poll_in, .revents = 0 }; @@ -1378,68 +766,39 @@ pub const Session = struct { src[n] = .{ .client = @intCast(i) }; n += 1; } - // The pane shells, and note what is NOT conditional on a frontend: a - // session with nobody attached still polls these, still reads them and - // still feeds the core. That is the difference between a detach that - // pauses your build and a detach that does not. for (&s.ptys, 0..) |*pt, pane| { if (pt.fd < 0) continue; fds[n] = .{ .fd = pt.fd, - // POLLOUT only while this pane owes its shell bytes, which is - // the same rule and the same reason as a client's: asking for it - // unconditionally makes every idle pty a ready descriptor and - // turns the poll into a spin. .events = if (pt.out.items.len != 0) poll_in | poll_out else poll_in, .revents = 0, }; src[n] = .{ .pty = @intCast(pane) }; n += 1; } - // Opened only once something asked to be watched, so an unwatched - // session simply has one fewer descriptor here (see `inotify`). if (s.inotify_fd >= 0) { fds[n] = .{ .fd = s.inotify_fd, .events = poll_in, .revents = 0 }; src[n] = .inotify; n += 1; } - // ...and acme's filesystem, when there is one. Its arm in `dispatch` - // does nothing: this descriptor is here to END THE SLEEP, so that the - // `pollFrame` after `pull_wait_input` returns reaches the drain. The - // desktop shells buy the same wake with a thread; one poll slot is - // cheaper and cannot race the loop. - // ...and NOT once it is dead. `fuse_dev_poll` answers `EPOLLERR` as soon - // as the connection is gone, POSIX reports `POLLERR` whatever the events - // mask asked for, and this arm cannot consume it — so an external - // `fusermount3 -u`, a sysfs abort, or systemd taking `/run/user/$UID` - // away at final logout (exactly when a detached session is supposed to - // keep running) would make `poll(2)` return instantly, forever, and burn - // a whole core for the life of the daemon. Measured at 100% of one CPU - // before this guard. fuse.zig's own poll thread has carried the - // equivalent check all along, which is why the desktop shells never - // showed it and this loop did. - if (s.fs) |f| if (f.fd >= 0 and !f.dead) { - fds[n] = .{ .fd = f.fd, .events = poll_in, .revents = 0 }; - src[n] = .fuse; - n += 1; - }; - // ...and the 9P socket, when there is one: the listener, plus one - // descriptor per live connection. POLLOUT only while a connection owes - // bytes, which is the same rule and the same reason as a client's and - // a pty's — asking for it unconditionally makes every idle socket a - // ready descriptor and turns the poll into a spin. if (s.ninep) |l| { - // `accepting`, not `fd >= 0`: a listener paused after an EMFILE - // must leave the set, or the backlog it could not drain reports - // ready on every poll and spins the core. `nextDue` carries the - // moment it comes back. if (l.accepting()) { - fds[n] = .{ .fd = l.fd, .events = poll_in, .revents = 0 }; - src[n] = .ninep_listener; - n += 1; + for ([_]c_int{ l.fd, l.tcp_fd }) |fd| { + if (fd < 0) continue; + fds[n] = .{ .fd = fd, .events = poll_in, .revents = 0 }; + src[n] = .ninep_listener; + n += 1; + } } - for (0..fs9_service.max_conns) |i| { - if (!l.live(@intCast(i))) continue; + if (comptime ninep_io.quic_enabled) { + if (l.quic) |*listener| { + fds[n] = listener.poll(); + src[n] = .ninep_quic; + n += 1; + } + } + for (0..ninep_io.max_conns) |i| { + if (l.conns[i].fd < 0) continue; fds[n] = .{ .fd = l.conns[i].fd, .events = if (l.owes(@intCast(i))) poll_in | poll_out else poll_in, @@ -1449,108 +808,38 @@ pub const Session = struct { n += 1; } } - // A session with no listener, no clients, no shells and no watches has - // no event source at all. Returning immediately would spin the outer - // `while (!core.quit)` at full speed, so sleep the interval the core - // offered and, when it offered none, a frame's worth. - // - // Nothing is owed on this path. `check_files` is only ever set by a - // watch, and a watch means the inotify descriptor is in the set; - // `reconcile` posts a resize only when a client is ATTACHED, which means - // its socket is in the set; and `fs_pending` is only ever set by a drain, - // which runs only when `fs` is live, which puts `/dev/fuse` in the set. - // So `n == 0` implies `!regridded` and `!fs_pending` too. `--fs9` is - // the ONE exception, and it is why `ninep_pending` is named on the - // `timeout = 0` line below rather than here: a hung-up 9P connection - // still owing the core its orphaned fids has no descriptor at all, so - // it can be the only work left with `n == 0`. It is also finite — one - // release per fid — so the 16 ms nap that path takes costs it a couple - // of frames and never a stall. if (n == 0) return nap(if (timeout_ms == 0) 16 else timeout_ms); - // Zero is the core's word for "sleep until something happens" (see - // pardes.zig `pump`: it passes a frame interval only while an animation - // is running). poll spells that -1. var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int))); - // Two things here are due on a CLOCK rather than on a descriptor: a - // handshake that has to expire, and a paused listener that has to come - // back. An indefinite poll would sit through both — and thirty-two - // peers that connect and then say nothing, with the session otherwise - // idle, IS the denial `greet_deadline_ms` exists to answer — so the - // wait is clamped to whichever is due first. if (s.nextWake(now)) |due| timeout = if (timeout < 0) due else @min(timeout, due); - // ...and two things are due on nothing at all rather than on a - // descriptor: a reconcile pass a watch effect asked for (`watchFile` ran - // during `perform`, outside this function) and a regrid this round has - // already performed. Both are consumed before this function returns, so - // the round must not sleep before reaching them. - if (s.check_files or regridded or s.fs_pending or s.ninep_pending) timeout = 0; + if (completed or s.check_files or regridded or s.ninep_pending) timeout = 0; const ready = libc.poll(&fds, @intCast(n), timeout); - // A timeout is an ordinary frame boundary and EINTR is a signal we do not - // handle here. Neither skips anything below any more: what used to be an - // early `return` here is why a client closed without any descriptor being - // readable — which is every `expire` — left the session grid sized for a - // frontend that had gone, until the next readable event, on an idle - // session possibly hours later. if (ready > 0) s.dispatch(fds[0..n], src[0..n]); - // AFTER the dispatch, and that ordering is itself a fix. `expire` frees a - // client slot and `accept` — which runs INSIDE the dispatch — fills the - // lowest free one, so an expire that ran first could hand a slot to a new - // connection within this same round and the dispatch would then apply the - // OLD connection's `revents` to the new descriptor: a POLLHUP from the - // peer that left, closing the peer that just arrived. The dispatch's - // `c.fd < 0` guard cannot see that, because the fd is perfectly valid — - // it is simply a different fd. Expiring after means a freed slot is - // refilled no earlier than the next round, which builds a fresh `fds` for - // it. It fixes a smaller thing for free, too: a connection whose `hello` - // arrived in THIS round is attached before its deadline is judged, - // instead of being taken back with its handshake still unread. + _ = s.drainCompletions(true); s.expire(monotonicMs()); - // Unconditional, and not only where a shell is noticed to have died: a - // shell whose master `spawn` closed on a respawn exits after that close, - // with no descriptor left for anyone to see it on. See `harvest`. s.harvest(); - // Both before this function returns, so a file that changed on disk and a - // frontend that left during this round are in the frame `Pardes.pump` - // composes next rather than the one after it. s.reloadWatched(); _ = s.reconcile(); } - /// One pass over the descriptors `poll` reported ready. Split out of - /// `waitInput` for one reason: everything that must happen AFTER it — - /// `expire`, `harvest`, `reloadWatched`, `reconcile` — is then stated once, - /// in one order, where no early return can skip it. An early return past - /// that list is exactly what findings 5 and 6 were. fn dispatch(s: *Session, fds: []const libc.pollfd, src: []const Source) void { for (fds, src) |pfd, source| switch (source) { .listener => if (pfd.revents != 0) s.accept(), + .completion => {}, .client => |i| { const c = &s.clients[i]; - // A slot closed earlier in this same pass (its peer hung up, a - // decode failed) must not be touched through a stale revents. if (c.fd < 0) continue; if (pfd.revents & poll_out != 0) s.flush(c); if (c.fd < 0) continue; if (pfd.revents & poll_in != 0) { s.receive(c, i); } else if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) { - // POLLIN wins when both are set: a peer that wrote and then - // closed has bytes still worth reading. s.close(c, .peer); } }, .pty => |pane| { if (s.ptys[pane].fd < 0) continue; - // What this pane still owes its shell, which is the whole of - // finding 1's drain: `ptyWrite` queued it and stopped at EAGAIN - // rather than sleeping, and this is where the rest goes. if (pfd.revents & poll_out != 0) s.flushPty(pane); - // `flushPty` ends the pane when the slave side has gone. if (s.ptys[pane].fd < 0) continue; - // The same precedence as a client's, and it matters more here: a - // shell that printed its last line and exited reports - // POLLIN|POLLHUP together, and taking the hangup first would - // throw that line away. `readPty` reaches the EOF by reading 0. if (pfd.revents & poll_in != 0) { s.readPty(pane); } else if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) { @@ -1558,23 +847,10 @@ pub const Session = struct { } }, .inotify => if (pfd.revents & poll_in != 0) s.drainInotify(), - // The only revents worth a word: a dead connection must leave the - // set, or the `POLLERR` it reports on every future poll spins the - // loop. `Fs.next` would set `dead` on its first failed read anyway; - // saying it here costs nothing and saves the one spinning round. - .fuse => if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) { - if (s.fs) |f| f.dead = true; - }, .ninep_listener => if (pfd.revents != 0) { if (s.ninep) |l| l.accept(); }, - // BYTES ONLY. The requests those bytes decode into are served by - // `pollFrame`, after this snapshot is done with — see - // `Source.ninep`. POLLOUT before POLLIN so a reply the last frame - // could not finish writing goes before more work arrives, and - // POLLIN before the hangup because a script that wrote a `Tclunk` - // and closed has bytes still worth reading; the `live` guard is the - // client slots' `c.fd < 0`, for its reason. + .ninep_quic => {}, .ninep => |i| if (s.ninep) |l| { if (!l.live(i)) continue; if (pfd.revents & poll_out != 0) l.flush(i); @@ -1588,13 +864,17 @@ pub const Session = struct { }; } - /// Milliseconds until the next deadline that is kept by the CLOCK rather - /// than by a descriptor, or null when there is none. Floored at zero, so a - /// deadline already past polls once without blocking instead of blocking - /// forever on a negative timeout. fn nextWake(s: *const Session, now: i64) ?c_int { if (now == 0) return null; // no clock; see `monotonicMs` var due: ?i64 = null; + for (s.retired_shells) |shell| if (shell.pid != 0) { + due = now + 10; + break; + }; + for (s.ptys) |pty| if (pty.fd < 0 and pty.pid != 0) { + due = now + 10; + break; + }; for (&s.clients) |*c| { if (c.fd < 0 or c.attached) continue; const at = c.accepted_ms + @as(i64, s.greet_deadline_ms); @@ -1602,8 +882,6 @@ pub const Session = struct { } if (s.listener >= 0 and s.accept_paused_ms > now) due = if (due) |d| @min(d, s.accept_paused_ms) else s.accept_paused_ms; - // ...and the 9P listener's own greet deadline, for the reason its - // `expire` gives: four slots is a cheaper denial than thirty-two. if (s.ninep) |l| { if (l.nextDue()) |ms| { const at9 = now + ms; @@ -1614,10 +892,6 @@ pub const Session = struct { return @intCast(@max(0, @min(at - now, std.math.maxInt(c_int)))); } - /// Take the slots of connections that never said `hello` back. A connection - /// that holds a slot in silence denies a real frontend exactly as a queue - /// would, and `Client.open` writes its hello in the same call that - /// connects, so there is nothing legitimate to wait for. fn expire(s: *Session, now: i64) void { if (now == 0) return; // no clock: enforce nothing rather than everything for (&s.clients) |*c| { @@ -1626,55 +900,27 @@ pub const Session = struct { } } - /// Always accept, even with a full table: the tempting alternative — stop - /// accepting and let the kernel hold the surplus — is a spin, because - /// `poll` is level triggered and an unaccepted backlog reports ready - /// forever. fuse.zig's park table learned that as a deadlock; here it is - /// 100% of a core. - /// - /// BOUNDED all the same. `max_clients + 1` is enough to fill an empty table - /// and refuse one more, and past that the surplus waits in the backlog for - /// the next round — one pump later, with every frontend drawn in between. - /// The `while (true)` this replaces let a peer dialling in a loop hold the - /// core inside `accept` for as long as it kept dialling, and the core is - /// what draws every other frontend's screen. fn accept(s: *Session) void { for (0..max_clients + 1) |_| { const fd = libc.accept(s.listener, null, null); if (fd < 0) { switch (libc.errno(fd)) { - // The backlog is empty, which is this loop's ordinary exit. .AGAIN, .INTR, .CONNABORTED => return, - // Anything else — EMFILE above all — persists until some - // other descriptor is freed, and `poll` is LEVEL - // triggered: coming straight back means poll reports the - // listener ready again immediately and the core spins at - // 100% until the condition clears. The old answer was a - // 100 ms nanosleep, which parks the CORE — every attached - // frontend stops being drawn for a tenth of a second - // because a descriptor ran out. So the LISTENER is dropped - // from the poll set for that beat instead, and the session - // goes on serving the frontends it has. else => { s.accept_paused_ms = monotonicMs() + accept_pause_ms; return; }, } } - nested.setCloexec(fd); + ninep_io.setCloexec(fd); setNonblock(fd); if (comptime darwin) { - // linux says MSG_NOSIGNAL per write; darwin says it once per - // socket. Either way a frontend that dies mid-frame must not - // take the session down with SIGPIPE. const on: c_int = 1; _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); } const slot = for (&s.clients, 0..) |*c, i| { if (c.fd < 0) break i; } else { - // Refused, and told why, on a connection accepted purely so - // that the listener stays quiet. s.refuseFd(fd, .full); _ = libc.close(fd); continue; @@ -1683,10 +929,6 @@ pub const Session = struct { } } - /// One read per client per round. A frontend that never stops talking gets - /// one turn and then the loop moves on to the others and to the frame — - /// which is fuse.zig's `retry` rule (one attempt per parked request per - /// frame) applied to sockets. fn receive(s: *Session, c: *Client, slot: u8) void { var buf: [read_chunk]u8 = undefined; const got = libc.read(c.fd, &buf, buf.len); @@ -1696,9 +938,6 @@ pub const Session = struct { else => s.close(c, .read), }; c.in.appendSlice(s.gpa, buf[0..@intCast(got)]) catch return s.close(c, .oom); - // The table's own ceiling, checked where the table grows: a peer that - // sends the first half of a 16 MiB message and stops is holding memory - // no per-message check can see. See `session_backlog`. s.account(); if (c.fd < 0) return; // it was this one s.consume(c, slot); @@ -1709,9 +948,6 @@ pub const Session = struct { while (true) { const found = wire.framed(c.in.items[off..]) catch return s.close(c, .protocol); const msg = found orelse break; - // The decoded Event BORROWS these bytes, so the buffer is not - // compacted until every message already in it has been applied — - // the same borrow window the tty host gives a pty chunk. s.apply(c, slot, msg.tag, msg.payload) catch return s.close(c, .protocol); if (c.fd < 0) return; // apply closed it, buffers and all off += msg.total; @@ -1726,21 +962,7 @@ pub const Session = struct { } fn apply(s: *Session, c: *Client, slot: u8, tag: u8, payload: []const u8) wire.Error!void { - // `Hello.version` BEFORE the payload is decoded, which is the whole - // point of wire.zig putting it first at a fixed offset: a mismatch has to - // stay diagnosable when the rest of the layout is the part that changed. - // Checking it inside the `.hello` arm defeated exactly that guarantee — - // `decodeClient` refuses a cols/rows this build does not like and refuses - // trailing bytes, so a v2 hello with one extra field came back as - // `.protocol` and the `refuse .version` the frontend needs to say - // something useful was never sent. `wire.helloVersion` reads the one - // field without decoding the rest, and lives in the file that owns the - // layout. if (tag == @intFromEnum(wire.ClientTag.hello)) { - // A second hello on one connection is not a resize; it is a peer - // that is not speaking this protocol. Judged here rather than in the - // arm below so that a repeat hello is a protocol error whatever - // version it claims. if (c.attached) return error.BadValue; const claimed = try wire.helloVersion(payload); if (claimed != wire.version) { @@ -1755,20 +977,12 @@ pub const Session = struct { c.rows = h.rows; c.attached = true; c.need_full = true; - // Greeted after `reconcile`, so the geometry in the welcome is - // the one this client's first frame will actually use. c.greet = true; }, .bye => s.close(c, .bye), .event => |ev| { - // Input before a handshake has no geometry behind it and no - // version agreement either. if (!c.attached) return error.BadValue; switch (ev) { - // A frontend's resize is about ITS window. The core only - // ever sees the smallest common grid, which `reconcile` - // posts once per round when it moves — forwarding this raw - // would let whichever frontend resized last win. .resize => |r| { c.cols = r.cols; c.rows = r.rows; @@ -1782,14 +996,6 @@ pub const Session = struct { } } - /// Settle the session grid and greet whoever arrived, once per poll round - /// rather than once per message: three frontends attaching in the same - /// round are one resize, not three reflows of every pane. - /// - /// True when the CORE was told to reflow, which is the one thing a caller - /// has to react to: the frame carrying that reflow is the next one - /// `Pardes.pump` composes, so a `waitInput` that hears true must not go to - /// sleep before returning. See its `regridded`. fn reconcile(s: *Session) bool { var cols: u16 = 0; var rows: u16 = 0; @@ -1798,17 +1004,10 @@ pub const Session = struct { cols = if (cols == 0) c.cols else @min(cols, c.cols); rows = if (rows == 0) c.rows else @min(rows, c.rows); } - // Nobody attached: keep the grid we had. A detached session is not a - // session of no size, it is one nobody is looking at, and reflowing - // every pane to nothing for zero readers is work with no reader. var regridded = false; if (cols != 0 and (cols != s.cols or rows != s.rows)) { s.cols = cols; s.rows = rows; - // Every mirror is now the wrong shape. `encodeFrame` reaches the - // same conclusion from the cell count alone, but saying it here is - // what makes a reshape with the SAME cell count (80x24 -> 48x40) - // safe too. for (&s.clients) |*c| c.need_full = true; s.core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); regridded = true; @@ -1821,18 +1020,10 @@ pub const Session = struct { return regridded; } - // ---- bytes ------------------------------------------------------------ - fn send(s: *Session, c: *Client, msg: wire.ServerMsg) void { const want = wire.serverBound(msg); s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom); const bytes = wire.encodeServer(s.scratch.allocatedSlice()[0..want], msg) catch |err| { - // The only reachable case is a payload past `max_payload`, and with - // every effect that carried a whole file gone from this protocol the - // only payload that can still get there is a yank of more than - // 16 MiB. The session keeps it — `setClipboard` put it in the core's - // own clipboard before this was ever queued — and the frontends' - // desktop clipboards do not get it, out loud rather than silently. log.debug("message {t} not encodable: {t}", .{ msg, err }); return; }; @@ -1840,36 +1031,13 @@ pub const Session = struct { } fn queue(s: *Session, c: *Client, bytes: []const u8) void { - // BEFORE the append, so one oversized message always goes out whole and - // what this refuses is a client that has stopped draining. if (c.out.items.len > out_backlog) return s.close(c, .backlog); - // ...and the table as a whole, which `out_backlog` does not bound: 32 - // slots one byte under it each, plus a frame apiece. See - // `session_backlog`. s.account(); if (c.fd < 0) return; // the fattest peer was this one c.out.appendSlice(s.gpa, bytes) catch return s.close(c, .oom); - // Try immediately: on a local socket this empties the queue in one - // write, and `present` skips a client whose queue is not empty. s.flush(c); } - /// Close the peer holding the most of the table when the table as a whole - /// is over `session_backlog`. One peer per call, and the fattest one, - /// because this is only ever asked when the total is already over and the - /// peer holding the most of it is the peer that stopped reading. The next - /// append asks again, so a second offender is closed a message later rather - /// than in a loop that could empty the table on one bad frame. - /// - /// `items.len` and NOT `capacity`, which was half of the bug in - /// `session_backlog`'s history. An ArrayList grows geometrically, so a - /// client's `in.capacity` crossed a 4 MiB ceiling while it was still - /// assembling a paste of roughly 2.8 MiB — the peer was punished for the - /// allocator's rounding rather than for anything it held. What this is - /// asking is "how much is a peer making this session hold RIGHT NOW", and - /// that is `items.len`; capacity above it is transient by construction, - /// because `retire` hands back anything over `idle_retain` the moment a - /// buffer empties. fn account(s: *Session) void { var total: usize = 0; var worst: ?*Client = null; @@ -1893,8 +1061,6 @@ pub const Session = struct { const n = libc.send(c.fd, c.out.items.ptr + off, c.out.items.len - off, nosignal); if (n < 0) switch (libc.errno(n)) { .INTR => continue, - // The kernel's buffer is full: the rest waits for POLLOUT, and - // this client is skipped for frames until it drains. .AGAIN => break, else => return s.close(c, .write), }; @@ -1910,18 +1076,11 @@ pub const Session = struct { c.out.items.len -= off; } - /// Say why, then hang up. The refusal is written with a plain blocking - /// write on a socket nobody has sent anything on yet: it is six bytes, and - /// queueing it would mean keeping a slot for a connection being rejected. fn refuse(s: *Session, c: *Client, why: wire.Refusal) void { s.refuseFd(c.fd, why); s.close(c, .refused); } - /// Writes only. The descriptor belongs to the caller — `refuse` hands it to - /// `close`, and the full-table path in `accept` closes it itself — because - /// closing here as well is a double close, and the number is reusable the - /// instant the first one lands. fn refuseFd(_: *Session, fd: c_int, why: wire.Refusal) void { var buf: [wire.header_len + 1]u8 = undefined; const bytes = wire.encodeServer(&buf, .{ .refuse = why }) catch unreachable; @@ -1934,10 +1093,6 @@ pub const Session = struct { } } - /// Free one slot. A frontend dying takes NOTHING with it: not the core, not - /// the listener, not another frontend's frames, and — since this file - /// forks — not its panes' shells either. Its buffers go back and the slot is - /// reusable on the next connect. fn close(s: *Session, c: *Client, why: Closed) void { if (c.fd < 0) return; log.debug("frontend detached: {t}", .{why}); @@ -1946,49 +1101,23 @@ pub const Session = struct { c.out.deinit(s.gpa); c.mirror.deinit(s.gpa); const gone = s.slotOf(c); - // Which slot this is, so a departing frontend cannot leave `origin` - // pointing at it and send the next `read_clipboard` to a stranger. if (s.origin) |i| if (i == gone) { s.origin = null; }; - // ...and that is the whole of it. A frontend used to take its panes' - // shells with it and leave them owed to whoever attached next, because - // the ptys were in its process; a pane that survived a detach looked - // alive, produced nothing and swallowed everything typed into it. The - // shells are here now, so a frontend leaving is a screen going away and - // nothing else. c.* = .{}; } }; -// --------------------------------------------------------------------------- -// the process -// --------------------------------------------------------------------------- - -/// `pardes --detach[=]`: one core, no terminal, a socket. The loop is the -/// core's own `pump`, exactly as the tty and gui shells run it — this frontend -/// simply has no window of its own. -/// -/// The pre-loop effect drain is here for the same reason tty.zig has one, and -/// it is no longer half a promise: the startup spawns are already queued and are -/// PERFORMED here, on this process's own process table. So a session binds its -/// socket with every pane's shell already forked and already in the poll set, -/// and the first frontend to attach — whether that is a second later or the -/// next morning — is sent a frame of shells that have been printing into the -/// core since before it existed. Nothing is remembered for a later frontend, -/// because nothing is owed to one. pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void { const gpa = init.gpa; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.deinit(); var options = opts; options.image_allocator = allocs.image; options.pdf_allocator = allocs.pdf; options.tree_sitter_allocator = allocs.tree_sitter; options.frame_allocator = allocs.frame; - // The core's own subsystems, not host work: a detached session syntax - // highlights and decodes images exactly like an attached one. pardes.image.start(init.io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); @@ -1999,86 +1128,144 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void } const core = if (options.load_path) |lp| blk: { - const bytes = try look.readFile(gpa, lp); + const bytes = try filesystem.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes); } else try pardes.Pardes.init(allocs.pardes, options); - defer core.deinit(); - var session: Session = .{ .gpa = gpa, + .worker_gpa = allocs.lsp, .io = init.io, .core = core, .cols = options.cols, .rows = options.rows, - // Staged before the first fork and owned by the Session for exactly as - // long as it can fork: `shell_bin.resolve` hands a child pointers into - // these buffers, and the child holds them until it execs. - // - // `prepareForFork` and not `PromptRcs.init` alone: this host forks bash - // through the same `resolve` its siblings do and was the one that never - // silenced Apple's zsh-migration banner, so every pane in a detached - // session on macOS opened with it printed across the top. It also had - // no `adoptSystemPath`, which a daemon needs more than anyone — it is - // the host most likely to be started by launchd. - .prompt_rcs = shell_bin.prepareForFork(), + .prompt_rcs = host_io.Shell.prepare(), }; + defer session.core.deinit(); defer session.deinit(); + try session.initAsync(); if (!session.listen(name)) { - // Loud, and on stderr rather than through the log: a `--detach` whose - // socket did not bind is a session nobody will ever find, and exiting - // is the only honest answer. try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: could not bind a detached session socket\n"); return error.NoSocket; } - // Before the host is installed and before the startup drain, so a script - // that races the daemon's launch finds a tree whose panes already exist. - // Null on every failure — no fuse3, no `user_allow_other`, a kernel without - // FUSE — and a failure must cost the operator their scripting, never their - // session. `fs_service.start` has already said so on pane 0's message row. - // - // NO `fs_service.wake`. That call exists to start a thread that blocks on - // `poll()` and pokes a loop the thread does not otherwise share; this - // process polls `/dev/fuse` itself, in the same syscall as everything else. - // See `Source.fuse`. - session.fs = fs_service.start(gpa, core); - // ...and the same tree on a unix socket, independently: `--fs` and `--fs9` - // are two transports and neither is the other's prerequisite, so a daemon - // may serve one, both or neither. Null on every failure, for - // `fs_service.start`'s reason — a transport that will not bind must cost - // the operator their scripting, never their session — and NOT a - // `return error` the way the frontend socket above is, because a session - // whose frontend socket did not bind is one nobody can ever find, while - // this one is merely one nobody can script over 9P. - // - // A bare `--fs9` is named by the SESSION rather than by the pid: the - // operator typed that name to find the daemon again, and having to look up - // a pid to reach its filesystem would undo it. `--fs9=` wins. - if (opts.fs9) |named| session.ninep = fs9_service.open(gpa, named, name); + session.ninep = ninep_io.listen(gpa, opts.ninep_name, name, opts.ninep_tcp, opts.ninep_quic); + if (session.ninep == null) return error.ListenFailed; + core.fs.socket_path = session.ninep.?.path(); + core.fs.tcp_address = session.ninep.?.tcp_address; + core.fs.quic_address = session.ninep.?.quic_address; const h = session.host(); core.host = h; while (core.nextEffect()) |effect| core.perform(effect); - // Past the startup drain: a `ThemeFile` reload from here on is a human's - // and animates. See `in_loop`. session.in_loop = true; - while (!core.quit) try core.pump(h); + while (!session.core.quit) { + try session.core.pump(h); + if (session.core.quit) break; + if (session.core.takeRestore()) |path| restore: { + const bytes = filesystem.readFile(gpa, path) catch |err| { + session.core.reportError(session.core.active, "Restore", err); + break :restore; + }; + defer gpa.free(bytes); + session.restore(bytes) catch |err| session.core.reportError(session.core.active, "Restore", err); + } + } } -// --------------------------------------------------------------------------- -// the socket, nested.zig's way -// --------------------------------------------------------------------------- +test "detached queued results preserve current requests and are discarded before Restore" { + const gpa = std.testing.allocator; + var s: Session = .{ + .gpa = gpa, + .worker_gpa = gpa, + .io = std.testing.io, + .core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }), + .cols = 40, + .rows = 12, + }; + defer s.core.deinit(); + defer s.deinit(); + try s.initAsync(); + while (s.core.nextEffect()) |_| {} + _ = try s.core.setTestFile("saved body\n"); + s.core.lspRequest(0, .status, ""); + const old_id = s.core.lsp_wait.?.id; + s.core.lspRequest(0, .status, ""); + const current_id = s.core.lsp_wait.?.id; + s.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } }; + s.mailbox.post(.{ .lsp = .{ .id = old_id, .rows = try gpa.dupe(u8, "old result\n") } }); + try std.testing.expect(s.drainCompletions(true)); + try std.testing.expectEqual(current_id, s.lsp_task.?.id); + try std.testing.expectEqual(current_id, s.core.lsp_wait.?.id); + + try s.core.dumpState(); + const saved = try gpa.dupe(u8, s.core.dump_out.?); + defer gpa.free(saved); + while (s.core.nextEffect()) |_| {} + s.mailbox.post(.{ .lsp = .{ .id = current_id, .rows = try gpa.dupe(u8, "queued before restore\n") } }); + const outputs = try gpa.alloc([]u8, 1); + outputs[0] = try gpa.dupe(u8, "old filter output\n"); + try std.testing.expect(s.pipe_tasks.add(.{ .id = 77, .future = .{ .any_future = null, .result = {} } })); + s.mailbox.post(.{ .pipe = .{ .id = 77, .success = true, .outputs = outputs } }); + Session.lspStatus(&s, "old status"); + try s.restore(saved); + try std.testing.expect(s.lsp_task == null); + try std.testing.expectEqual(@as(usize, 0), s.pipe_tasks.len); + try std.testing.expect(!s.drainCompletions(true)); + try std.testing.expectEqualStrings("saved body\n", s.core.panes[0].?.file.?.content); + + s.core.lspRequest(0, .status, ""); + const restored_id = s.core.lsp_wait.?.id; + try std.testing.expect(restored_id > current_id); + s.lsp_task = .{ .id = restored_id, .future = .{ .any_future = null, .result = {} } }; + s.mailbox.post(.{ .lsp = .{ .id = current_id, .rows = try gpa.dupe(u8, "late old result\n") } }); + _ = s.drainCompletions(true); + try std.testing.expectEqual(restored_id, s.lsp_task.?.id); + try std.testing.expectEqual(restored_id, s.core.lsp_wait.?.id); + s.mailbox.post(.{ .lsp = .{ .id = restored_id, .rows = &.{} } }); + const host = s.host(); + host.vtable.wait_input.?(host.ctx, 1); + try std.testing.expect(s.lsp_task == null); + try std.testing.expect(s.core.lsp_wait == null); +} + +test "detached worker setup failure completes requests without changing document bytes" { + const gpa = std.testing.allocator; + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + var s: Session = .{ + .gpa = gpa, + .worker_gpa = failing.allocator(), + .io = std.testing.io, + .core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }), + .cols = 40, + .rows = 12, + }; + defer s.core.deinit(); + defer s.deinit(); + try s.initAsync(); + while (s.core.nextEffect()) |_| {} + const pane = try s.core.setTestFile("one\n"); + s.core.host = s.host(); + s.core.lspRequest(0, .status, ""); + while (s.core.nextEffect()) |effect| s.core.perform(effect); + try std.testing.expect(s.core.lsp_wait == null); + try std.testing.expect(s.lsp_task == null); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + s.core.update(.{ .key = .{ .cp = '|' } }); + s.core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + s.core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + try std.testing.expect(s.core.pipe_wait != null); + while (s.core.nextEffect()) |effect| s.core.perform(effect); + try std.testing.expect(s.core.pipe_wait == null); + try std.testing.expectEqual(@as(usize, 0), s.pipe_tasks.len); + try std.testing.expectEqualStrings("one\n", pane.file.?.content); + try std.testing.expect(failing.has_induced_failure); +} -/// Re-exported so the frontend half of this transport (client.zig) has ONE -/// import for the socket conventions, and so that the file which owns the -/// convention is the file it asks. The definition and its reasoning are -/// nested.zig's. -pub const setCloexec = nested.setCloexec; +pub const setCloexec = ninep_io.setCloexec; -/// Every descriptor in this transport is non-blocking, on both sides: the core -/// must never park on a peer (`waitInput`), and a frontend must never park on -/// the session (client.zig `wait`). `pub` for that second caller. pub fn setNonblock(fd: c_int) void { const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); if (flags < 0) return; @@ -2087,11 +1274,6 @@ pub fn setNonblock(fd: c_int) void { _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); } -/// A dead peer must never kill this process, and that is as true of a frontend -/// whose session ended as of a session whose frontend died — so client.zig -/// takes this one too. linux says it per write, darwin once per socket (see -/// `accept`); the `if (darwin)` is what keeps `MSG.NOSIGNAL`, which darwin's -/// headers do not have, out of that build. pub const nosignal: u32 = if (darwin) 0 else libc.MSG.NOSIGNAL; pub const poll_in: i16 = @intCast(libc.POLL.IN); @@ -2100,35 +1282,17 @@ pub const poll_hup: i16 = @intCast(libc.POLL.HUP); pub const poll_err: i16 = @intCast(libc.POLL.ERR); pub const poll_nval: i16 = @intCast(libc.POLL.NVAL); -/// Give a drained buffer's memory back, and only a big one's: see -/// `idle_retain`. Called where a queue empties rather than on a timer, because -/// that is the one moment the capacity is provably unused. fn retire(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged(u8)) void { if (list.items.len != 0 or list.capacity <= idle_retain) return; list.clearAndFree(gpa); } -/// Monotonic milliseconds, the clock macos.zig's fling already times with and -/// for its reason: MONOTONIC and not REALTIME, because a handshake that expired -/// because NTP stepped the wall clock backwards is a bug nobody reproduces. -/// -/// Zero on failure, and every caller treats zero as "no clock" and enforces no -/// deadline at all — a session that cannot read a clock keeps every slot rather -/// than dropping every slot. -/// -/// `pub` for the same reason `setNonblock`, `nosignal` and the `poll_*` -/// constants are: this file owns the transport's conventions and BOTH ends of -/// it, and the clock a handshake is timed against is one of them. client.zig -/// times its wait for a `welcome` on this and against -/// `greet_deadline_default_ms`, so the two ends cannot disagree about how long -/// the handshake is allowed to take. pub fn monotonicMs() i64 { var ts: libc.timespec = undefined; if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); } -/// Sleep, for the one case that has no descriptor to wait on (see `waitInput`). fn nap(ms: u32) void { var ts: libc.timespec = .{ .sec = @intCast(ms / 1000), @@ -2137,14 +1301,7 @@ fn nap(ms: u32) void { _ = libc.nanosleep(&ts, null); } -/// `/pardes-detached-.sock`. The prefix differs from nested.zig's -/// `pardes-.sock` on purpose: that file's sweeper unlinks the socket of any -/// name whose digits name a dead pid, and a session called `work` must never -/// look like one. The buffer is sun_path-sized, so a name that does not fit is -/// no address at all rather than a truncated one pointing somewhere else. pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 { - // A name is one path component and nothing clever: a `/` would put the - // socket somewhere else entirely, and a NUL would truncate the address. if (name.len == 0) return null; if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null; return std.fmt.bufPrintSentinel(buf, "{s}/" ++ prefix ++ "{s}.sock", .{ dir, name }, 0) catch null; @@ -2152,78 +1309,33 @@ pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[: const prefix = "pardes-detached-"; -/// The path a FRONTEND connects to for a session called `name`. Derived here -/// rather than in client.zig because this file owns the convention, and the -/// side that binds and the side that connects must not be able to disagree -/// about it. `path_max` is the buffer a caller has to supply. pub const path_max = sun_path_len; pub fn sessionPath(buf: *[path_max]u8, name: []const u8) ?[:0]const u8 { if (comptime !supported) return null; var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse return null; + const dir = ninep_io.socketDir(&dir_buf) orelse return null; return socketPath(buf, dir, name); } -/// The FRONTEND's half of the vetting this file does before it binds, and the -/// reason it is here rather than in client.zig: one convention, one predicate, -/// one file that owns both. -/// -/// Until this, the server refused a directory anyone else could write and a -/// socket anyone else could talk to, and the client connected to whatever it -/// found at the path it derived — which is the asymmetry this module's header -/// condemns in as many words. A socket planted at a path a frontend derives -/// from `$XDG_RUNTIME_DIR` receives every keystroke that frontend collects, and -/// answers with frames of its choosing. -/// -/// Checked and then connected, in that order, which is a TOCTOU only for -/// somebody who can already write the directory — and the directory is the -/// first thing this refuses. pub fn vetted(path: [:0]const u8) bool { if (comptime !supported) return false; var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse return false; - if (!ours(nested.statNoFollow(dir) orelse return false, s_ifdir)) return false; - return ours(nested.statNoFollow(path) orelse return false, s_ifsock); + const dir = ninep_io.socketDir(&dir_buf) orelse return false; + if (!ours(ninep_io.statNoFollow(dir) orelse return false, s_ifdir)) return false; + return ours(ninep_io.statNoFollow(path) orelse return false, s_ifsock); } const s_ifmt: u32 = 0o170000; const s_ifdir: u32 = 0o040000; const s_ifsock: u32 = 0o140000; -/// Is this a `kind` we own, with nothing granted to group or other? The three -/// questions `nested.ensureSocketDir` asks of the directory, asked of the -/// SOCKET too: the two walls are the directory's mode and the file's, and a -/// frontend that checks only one of them has checked neither. -fn ours(st: nested.DirFacts, kind: u32) bool { +fn ours(st: ninep_io.FileFacts, kind: u32) bool { if (st.mode & s_ifmt != kind) return false; if (st.uid != libc.getuid()) return false; return st.mode & 0o077 == 0; } -/// Is something LISTENING at `path`? The one place this file decides whether a -/// socket file is a corpse, asked by `listen` before it takes a name over and -/// by `sweep` before it unlinks anything. -/// -/// nested.zig can ask `kill(0)` because its filenames carry a pid; a detached -/// session is named by a PERSON, so the question is put to the socket: a -/// connect to a bound path with no listener is refused (ECONNREFUSED), and that -/// refusal is the ONLY evidence of death this accepts. Everything else is life, -/// including the case a blocking connect used to turn into a hang — a live -/// session busy inside the core has a full backlog and answers EAGAIN, which is -/// why this socket is NON-BLOCKING. EPERM, a socket() that failed and a path -/// that no longer fits are all "not proven dead" too, and leave the file alone. -/// -/// THE WINDOW THIS CANNOT SEE, stated because it is real: a session between its -/// own `bind` and its `listen(2)` also answers ECONNREFUSED and is alive. It is -/// two syscalls wide, it is only ever entered by another `pardes --detach` -/// starting in the same instant, and what the loser loses is a NAME (its -/// `listen` fails and it says so) rather than a session. Closing it needs a -/// lock file per session, which is a second thing to leak. -/// -/// The successful-connect case costs the live session one slot for one round: -/// closing this descriptor immediately turns the pending connection into an -/// EOF, which `receive` reads as a frontend that left. fn alive(path: [:0]const u8) bool { var addr: libc.sockaddr.un = .{ .path = @splat(0) }; if (path.len + 1 > addr.path.len) return true; @@ -2231,18 +1343,13 @@ fn alive(path: [:0]const u8) bool { const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); if (fd < 0) return true; defer _ = libc.close(fd); - nested.setCloexec(fd); + ninep_io.setCloexec(fd); setNonblock(fd); const rc = libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))); if (rc == 0) return true; return libc.errno(rc) != .CONNREFUSED; } -/// Unlink the sockets of detached sessions that are gone — our own litter, -/// which the bare `Attach`'s "whichever session is there" would otherwise count -/// as a session (client.zig `resolve`). `alive` is the whole of the judgement. -/// -/// Bounded: one readdir of a directory only we write to, one connect each. fn sweep(dir: [:0]const u8) void { const d = libc.opendir(dir) orelse return; defer _ = libc.closedir(d); diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 8281fc63..ab19bcfb 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -1,227 +1,28 @@ -//! THE DETACHED-SESSION WIRE FORMAT: one `Event` and one `Host.VTable` call per -//! message, byte for byte, with nothing native about the bytes. -//! -//! WHO OWNS THE CORE. The `Pardes` instance lives in the DETACHED process -//! (server.zig). A frontend (client.zig) owns a terminal and a socket and -//! nothing else: it sends the input it collects and draws the frames it is -//! sent. One core per session, N frontends attached to it, all looking at the -//! same screen — `screen -x`, not N sessions. -//! -//! WHY A CODEC AT ALL, when nested.zig's socket carries a builtin command line -//! and has nothing to version: a command line cannot carry a frame, and frames -//! and input are this transport's entire content. -//! -//! ARCHITECTURE-NEUTRAL, and not as decoration: the frontend on the other end -//! may be riscv32-freestanding (the ESP32-P4 board) while the core is x86_64 -//! linux. So: -//! * every integer is an explicit width, little-endian. No `usize` reaches -//! the wire — a pointer-sized field is 4 bytes on the board and 8 here, and -//! every field after it would then be read at the wrong offset. -//! * no native struct is ever blitted. `@bitCast`/`std.mem.asBytes` of a Zig -//! struct puts this compiler's field order and padding on a socket; every -//! field below is written and read by hand. -//! * every union and every enum gets a tag chosen HERE (`ClientTag`, -//! `ServerTag`, `ColorTag`, ...) and never `@intFromEnum` of a core type, -//! so reordering `Event` or `CellStyle.ul` cannot silently redefine the -//! protocol. The mapping switches are exhaustive: adding a variant to the -//! core is a compile error in this file, which is the point of them. -//! * every variable-length payload carries an explicit length prefix, and -//! `max_payload` bounds the lot. This is a parser on a socket: a malformed -//! frame must be REFUSED, never indexed past. -//! * a bool is one byte, 0 or 1. Any other value is a decode error rather -//! than "nonzero is true": a byte this protocol cannot mean is evidence -//! the stream is not the stream it claims to be. -//! * floats travel as their IEEE-754 binary32 bit pattern inside an explicit -//! u32. Both ends agree about binary32; neither agrees about struct layout. -//! -//! BUILD-NEUTRAL for the same reason. `Event.resize.cell_pixels` exists only -//! when native PDF placement is compiled in (pardes.zig `CellPixels`), and a -//! frontend must not have to have been built with the core's options — so it is -//! ALWAYS on the wire and dropped on arrival by a build with nowhere to put it. -//! -//! WHAT IS NOT HERE. The seam has twenty-two methods; this carries FIVE of -//! them — `push_present` as `frame`, `push_set_clipboard`, -//! `pull_read_clipboard`, `push_open_link` and `push_detach` — and the -//! seventeen it does not are named here with their reasons. The five are -//! spelled out because this arithmetic has now gone stale twice in one day, -//! once when the machine-local eight moved into the daemon and once when -//! `detach` arrived, and a count nobody can check against a list is a comment -//! that rots quietly. -//! * The nine machine-local ones — `push_spawn`, `push_pty_write`, -//! `push_pty_resize`, `push_pty_signal`, `push_write_file`, -//! `push_write_dump`, `push_watch_file`, `push_watch_theme`, -//! `push_dump_themes` — are -//! performed by the detached core ITSELF, through `host_io.zig`. A unix -//! socket means it is on the same machine, so there is no question of -//! whose disk or whose process table is meant, and a pane's shell has to -//! outlive the frontend that asked for it or a detached session is a -//! promise it cannot keep. The `ServerTag` doc below carries the whole of -//! that argument; this line exists so the count at the top of the file -//! agrees with it. -//! * `pull_wait_input` IS the server's poll loop, not a message. -//! * `push_poll_frame` and `push_post_present` carry no information. They are -//! per-frame bookkeeping ticks, and `frame` already arrives exactly once -//! per pump at the same place in the order — a frontend does its per-frame -//! work when a frame lands. Two more messages per frame per client would -//! say nothing the frame does not already say. -//! * `pull_tty_taken` and `pull_gpio_toggle` are answers the CALLER waits -//! for, and `pull_lsp`/`pull_pipe` are work dispatched off the loop. A -//! round trip inside `update` is the one thing this transport must never -//! do: the core would block on a socket, and `pull_wait_input`'s own -//! comment is that it is the only place this process may sleep. The -//! process that owns the core answers all four. -//! * `push_fs_reply` cannot be a broadcast. host.zig's rule is that the -//! transport which asked is the one holding the request; with N frontends, -//! N-1 would receive the answer to a request they never made. So the acme -//! mount stays in the detached process, where the `Event.fs_req` that -//! starts it is raised, and neither half of that pair is on the wire — -//! which is also why `Event.fs_req` has no `ClientTag`. +//! Detached input and frames use fixed-width little-endian fields and explicit tags. +//! Native struct layout never reaches the wire. const std = @import("std"); const pardes = @import("../pardes.zig"); -/// Bumped whenever any layout below changes. Checked on connect and refused -/// loudly (see `Refusal.version`): two builds of pardes are routinely on one -/// machine — `zig build` replaces the binary under a running session — and a -/// frontend decoding another version's frame layout would paint garbage and -/// blame the terminal. -/// -/// 2: the layout did not move, but what a frontend is ALLOWED TO ASK FOR did. -/// A frontend now clamps its window to `max_cols` x `max_rows` instead of -/// sending it raw (client.zig), and a 512x128 grid is a full frame a v1 daemon -/// PANICS encoding — its run length overflowed a u16 by exactly one cell, see -/// `run_max`. A v1 session refused that geometry outright, so nothing was ever -/// lost by refusing the connection instead; a v1 daemon meeting a v2 frontend -/// answers `Refusal.version`, which says so, rather than dying with every pane -/// shell it owns. This is the case the paragraph above is about: `zig build` -/// replaces the binary under a running session. pub const version: u16 = 2; -pub const Error = error{ - /// The message ended inside a field. - Truncated, - /// A length prefix, a run, or a grid dimension larger than this protocol - /// admits. Refused before anything is allocated or indexed. - Overlong, - /// A tag byte no version of this protocol has ever defined. - BadTag, - /// A tag this protocol does define, carrying a value it cannot mean: a - /// 3-in-a-bool, a zero-column resize, a pane past MAX_PANES. - BadValue, - /// The payload was decoded and bytes were left over. A message that says - /// more than its layout has room for is not this message. - Trailing, - /// The encoder ran out of caller-supplied buffer. - NoSpace, -}; - -// --------------------------------------------------------------------------- -// bounds -// --------------------------------------------------------------------------- +pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; -/// The largest grid this protocol carries. `Surface.cols`/`rows` are u16, so -/// these are protocol bounds rather than type bounds, and they exist because -/// `max_payload` below is derived from them: a decoder that accepts 65535 -/// columns accepts a 25 GiB frame prefix. The board's own grid is 56x14 and a -/// terminal's is usually near 200x50. -/// -/// NOT a ceiling above every real display, which is what this comment used to -/// claim: a 4K window at the SDL shell's minimum 8-pixel font is around 768 -/// columns by 216 rows, and a tty on the same screen passes 128 rows at any -/// ordinary line height. Those windows attach at 512x128 and letterbox the -/// rest (client.zig clamps), rather than being refused as they were. Raising -/// the pair instead would have been a bigger change than it looks: `frameBound` -/// stays well inside `max_payload`, but a themed full frame at 512x128 is -/// already ~0.9 MiB against server.zig's 1 MiB `out_backlog`. pub const max_cols: u16 = 512; pub const max_rows: u16 = 128; +pub const max_payload: u32 = 16 << 20; +pub const header_len = 5; // tag:u8, payload length:u32le -/// One cell at its largest: `default` false, a 7-byte grapheme, two rgb colors, -/// the attribute byte, the underline style and the font role. Written as the -/// sum of the fields rather than a number so that adding a field to `Cell` -/// moves it. const cell_max = 1 + 1 + 7 + 4 + 4 + 1 + 1 + 1; - -/// `start:u32 + count:u16`. A run's cost, and therefore the break-even the -/// encoder coalesces against (see `encodeFrame`). -const run_header = 4 + 2; - -/// ...and the longest run that `count:u16` can describe, which is EXACTLY ONE -/// SHORT of the largest grid this protocol carries: `max_cols * max_rows` is -/// 512*128 = 65536, and `maxInt(u16)` is 65535. -/// -/// A full frame of that grid is ONE run over all of it whenever the theme has -/// a background: `render` fills the surface and every pane then repaints its -/// text area, and `Surface.set` clears `default`, so `sendCell`'s `!default` -/// holds for every cell. (Under a theme with `bg = null` — `dark` — untouched -/// body cells stay default and a stretch of six of them breaks the run, so the -/// overflow was theme-dependent as well as geometry-dependent, which is the -/// worst kind of latent.) `encodeFrame`'s `@intCast(run_end - start)` then -/// panicked in a safe build and was illegal behaviour in a fast one — LLVM -/// happens to truncate to zero, which the far side refuses as `BadValue`, but -/// nothing promises that. That grid is what a frontend with a big window now -/// asks for (client.zig clamps to it), so the meeting point went from -/// unreachable to routine, and the encoder splits the run instead. +const run_header = 4 + 2; // start:u32, count:u16 const run_max = std.math.maxInt(u16); - -/// `kind:u8 + cols:u16 + rows:u16 + cursor(6) + nruns:u32`. const frame_head = 1 + 2 + 2 + 6 + 4; -/// The longest legal payload, and therefore the length prefix a decoder will -/// accept before it refuses the stream. Two messages set it: -/// * a full frame of the largest grid, worst case one run per cell: -/// 512*128 * (6 + 20) = 1.6 MiB. -/// * one paste, which the tty frontend already caps at 4 MiB (tty.zig -/// `max_paste_bytes`) on the grounds that anything larger is a mis-click. -/// 16 MiB is past every source file anyone edits in this editor and is still a -/// buffer the receiving side can simply hold. A larger message is not sent and -/// a larger prefix is not read. -pub const max_payload: u32 = 16 << 20; - -/// Every message is `tag:u8, len:u32le, payload[len]`. A u32 because a full -/// frame and a paste both pass 64 KiB; a u16 would have needed the frame split -/// across messages, which is a second framing layer for no gain. -pub const header_len = 5; - -/// Bytes `encodeFrame` may need for this grid, worst case: every cell changed, -/// every cell in a run of its own, every cell at `cell_max`. The server sizes -/// one buffer from this per geometry rather than guessing. +// Worst case: every cell changed, each in its own run. pub fn frameBound(cols: u16, rows: u16) usize { return header_len + frame_head + @as(usize, cols) * @as(usize, rows) * (run_header + cell_max); } -// --------------------------------------------------------------------------- -// tags -// --------------------------------------------------------------------------- - -/// Frontend -> core. Exhaustive on purpose, which is the opposite of -/// fuse.zig's `Opcode`: there, a newer KERNEL adds opcodes and a non-exhaustive -/// enum is the only way to receive one without undefined behaviour. Here both -/// ends are pardes and an unknown tag is not a newer peer — `version` already -/// refused that — so it is a corrupt or hostile stream and must be rejected. -/// `std.enums.fromInt` is how, at the one place a byte becomes a tag. -/// -/// The numbers are the PROTOCOL's, grouped session/input rather than derived -/// from `Event`'s declaration order, so reordering the union changes nothing. -/// -/// EVERY TAG HERE IS SOMETHING A HUMAN DID, and that is the whole set: a -/// handshake, a goodbye, and what a keyboard, a mouse, a trackpad or a window -/// manager produces. Six numbers are missing from the input run — 0x13..0x17 -/// and 0x1e — and the gaps are left rather than tidied away, because -/// renumbering is a change every deployed frontend feels. They were `output`, -/// `eof`, `lsp_resp`, `pipe_resp`, `file_changed` and `tick`: the -/// MACHINE-LOCAL host's own reports, which stopped being a frontend's business -/// when the daemon took the disk and the process table (host_io.zig, -/// file_watch.zig). No frontend ever produced one — tty.zig's attached loop -/// swallowed them by name and gui.zig never handed `Input.post` one — and -/// leaving them DECODABLE was not merely dead weight: server.zig's `apply` -/// routes any decoded non-resize event straight into `core.update`, so an -/// attached peer could forge a pane's output, forge an `eof` for a shell that -/// was still running (and unlike the daemon's own `paneEof` the wire path never -/// called `closePty`, so the master stayed open and the shell was orphaned for -/// the life of the session), or replace a pane's text with bytes the next -/// `Save` would write to disk. client.zig's header says a machine-local effect -/// cannot return to the wire; deleting these is what makes that true in BOTH -/// directions instead of only core -> frontend. +// Stable wire numbers; gaps are retired tags. Unknown tags are rejected by the decoder. pub const ClientTag = enum(u8) { hello = 0x01, bye = 0x02, @@ -237,23 +38,7 @@ pub const ClientTag = enum(u8) { pointer_leave = 0x1d, }; -/// Core -> frontend. 0x01..0x0f is the session; 0x10.. is one `push_` method -/// each, in `Host.VTable`'s own order so the two lists can be read side by -/// side. -/// -/// There are only THREE of those left, and which three is the whole design. -/// The daemon performs every effect that needs a disk or a process table -/// itself (see `host_io.zig`): a unix socket means it is on the same machine, -/// so there is no question of whose disk is meant, and a pane's shell has to -/// outlive the frontend that asked for it or a detached session is a promise -/// it cannot keep. What is left on the wire is what a process nobody is -/// looking at genuinely cannot do — put something on THIS human's clipboard, -/// read it back, and open a link in front of the person who clicked it. -/// -/// `detach` is in the SESSION range and not among those three on purpose: it is -/// not an effect the core wants performed, it is the session telling one -/// frontend that it is done. `quit` is its sibling — same shape, opposite -/// meaning about whether anything survives. +// Session control precedes display-local effects. pub const ServerTag = enum(u8) { welcome = 0x01, refuse = 0x02, @@ -880,15 +665,7 @@ fn sendCell(cells: []const pardes.Cell, prev: []const pardes.Cell, full: bool, i fn clientTag(msg: ClientMsg) ClientTag { return switch (msg) { .event => |ev| switch (ev) { - // SEVEN `Event`s a frontend cannot produce, so no `ClientTag` - // exists for them and this arm is where the compiler says so. - // `fs_req` is the acme mount, raised in the same process that - // answers it. The other six are the machine-local host's own - // reports — a pty's output and its EOF, a language or pipe worker's - // answer, a watched file's new bytes, an animation tick — and after - // the daemon took the disk and the process table every one of them - // is raised by the process that already holds the core. See - // `ClientTag` for what putting them back would let a peer forge. + // Machine-local reports and 9P requests belong to the session owner. .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable, inline else => |_, t| @field(ClientTag, @tagName(t)), }, diff --git a/src/dump.zig b/src/dump.zig index f8740cdb..bb692481 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const limits = @import("limits.zig"); +const limits = @import("memory.zig").limits; // scoped, not bare std.log: main.zig's logFn drops the unscoped .default scope // wholesale (ghostty and uucode log there too), and a corrupt dump's parse @@ -74,13 +74,12 @@ pub const File = struct { path: []const u8 = "", content: []const u8 = "", content_b64: []const u8 = "", - /// non-empty = an output buffer (no file behind the name): the WORD of the - /// command that opened it, plus that command's argument — see - /// output_pane.Origin. A word rather than an integer for the reason every - /// other command in here is a word: reordering builtins.zig stays free, - /// and a dump stays something a person can read. + dirty: bool = false, + // Builtin names remain stable when enum ordinals change. origin: []const u8 = "", origin_arg: []const u8 = "", + mini_source: []const u8 = "", + mini_colors_b64: []const u8 = "", }; pub const ImagePalette = enum { @@ -119,6 +118,8 @@ pub const Column = struct { panes: []const usize = &.{}, }; +pub const Mount = struct { name: []const u8, dial: []const u8 }; + pub const State = struct { magic: []const u8 = magic, version: u32 = version, @@ -128,6 +129,7 @@ pub const State = struct { theme: []const u8 = "dark", columns: []const Column = &.{}, panes: []const Pane = &.{}, + mounts: []const Mount = &.{}, }; pub fn validate(state: State) !void { @@ -136,6 +138,15 @@ pub fn validate(state: State) !void { if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes; if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns; if (state.active >= state.panes.len) return error.BadDumpActive; + if (state.mounts.len > 8) return error.BadDumpMounts; + for (state.mounts, 0..) |mount, i| { + if (mount.name.len == 0 or mount.name.len > 255 or mount.dial.len == 0 or + std.mem.indexOfScalar(u8, mount.dial, 0) != null) return error.BadDumpMounts; + if (std.mem.eql(u8, mount.name, ".") or std.mem.eql(u8, mount.name, "..") or + std.mem.eql(u8, mount.name, "os") or std.mem.eql(u8, mount.name, "self")) return error.BadDumpMounts; + for (mount.name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-' and c != '.') return error.BadDumpMounts; + for (state.mounts[0..i]) |previous| if (std.mem.eql(u8, mount.name, previous.name)) return error.BadDumpMounts; + } for (state.columns) |col| { if (!std.math.isFinite(col.weight) or col.weight <= 0) return error.BadDumpColumns; if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns; @@ -151,6 +162,11 @@ pub fn validate(state: State) !void { return error.BadDumpTagTail; if (pane.file) |file| if (file.origin_arg.len > max_origin_arg) return error.BadDumpOriginArg; + if (pane.file) |file| { + if (file.mini_source.len > 4096 or std.mem.indexOfScalar(u8, file.mini_source, 0) != null or + (file.mini_source.len == 0 and file.mini_colors_b64.len != 0) or + (file.mini_source.len != 0 and !std.mem.eql(u8, file.origin, "Mini"))) return error.BadDumpMini; + } switch (pane.kind) { .terminal => if (pane.terminal == null) return error.BadDumpPaneKind, .file => if (pane.file == null) return error.BadDumpPaneKind, @@ -201,42 +217,37 @@ pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: St try file.writeStreamingAll(io, out.written()); } -pub fn readFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !State { - const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 * 1024 * 1024)); - defer gpa.free(bytes); - return readZon(gpa, bytes, path); -} +pub const Parsed = struct { + value: State, + arena: std.heap.ArenaAllocator, + + pub fn deinit(parsed: *Parsed) void { + parsed.arena.deinit(); + } +}; -pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !State { +pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !Parsed { + var parsed: Parsed = .{ .value = undefined, .arena = .init(gpa) }; + errdefer parsed.deinit(); + const arena = parsed.arena.allocator(); const source = try gpa.dupeZ(u8, bytes); defer gpa.free(source); - return readZonZ(gpa, source, label); -} -pub fn readZonZ(gpa: std.mem.Allocator, source: [:0]const u8, label: []const u8) !State { - if (builtin.os.tag == .emscripten or builtin.os.tag == .freestanding) { - const state = std.zon.parse.fromSliceAlloc(State, gpa, source, null, .{}) catch |err| { - log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); + parsed.value = if (builtin.os.tag == .emscripten or builtin.os.tag == .freestanding) + std.zon.parse.fromSliceAlloc(State, arena, source, null, .{ .free_on_error = false }) catch |err| { + if (err == error.ParseZon) log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); + return err; + } + else blk: { + var diag: std.zon.parse.Diagnostics = .{}; + defer diag.deinit(arena); + break :blk std.zon.parse.fromSliceAlloc(State, arena, source, &diag, .{ .free_on_error = false }) catch |err| { + if (err == error.ParseZon) log.err("parse dump {s}: {f}", .{ label, diag }); return err; }; - errdefer std.zon.parse.free(gpa, state); - try validate(state); - return state; - } - - var diag: std.zon.parse.Diagnostics = .{}; - defer diag.deinit(gpa); - const state = std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}) catch |err| { - log.err("parse dump {s}: {f}", .{ label, diag }); - return err; }; - errdefer std.zon.parse.free(gpa, state); - try validate(state); - return state; -} - -pub fn free(gpa: std.mem.Allocator, state: State) void { - std.zon.parse.free(gpa, state); + try validate(parsed.value); + return parsed; } pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 { @@ -276,7 +287,7 @@ test "dump zon roundtrip" { .body = " 1 alpha", .cols = 20, .rows = 5, - .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64 }, + .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64, .dirty = true }, }, }; const col_panes = [_]usize{ 0, 1 }; @@ -292,18 +303,14 @@ test "dump zon roundtrip" { var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); - const source = try out.toOwnedSliceSentinel(0); - defer gpa.free(source); - - var diag: std.zon.parse.Diagnostics = .{}; - defer diag.deinit(gpa); - const parsed = try std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}); - defer std.zon.parse.free(gpa, parsed); - try validate(parsed); + var result = try readZon(gpa, out.written(), "roundtrip"); + defer result.deinit(); + const parsed = result.value; try std.testing.expectEqual(@as(usize, 2), parsed.panes.len); try std.testing.expectEqualStrings("dark", parsed.theme); try std.testing.expectEqualStrings("old\nhello\nworld", parsed.panes[0].terminal.?.stream); try std.testing.expectEqualStrings("alpha\nbeta\n", parsed.panes[1].file.?.content); + try std.testing.expect(parsed.panes[1].file.?.dirty); { const bytes = try decodeBytes(gpa, parsed.panes[0].terminal.?.stream_b64); defer gpa.free(bytes); @@ -316,6 +323,116 @@ test "dump zon roundtrip" { } } +test "omitted dump defaults roundtrip without borrowing input" { + const gpa = std.testing.allocator; + const fixture = + \\.{ + \\ .screen = .{ .cols = 80, .rows = 24 }, + \\ .columns = .{.{ .panes = .{0, 1, 2} }}, + \\ .panes = .{ + \\ .{ .kind = .terminal, .tag = "terminal", .body = "", .terminal = .{} }, + \\ .{ .kind = .file, .tag = "file", .body = "", .file = .{ .path = "file.zig", .content = "const café = 1;" } }, + \\ .{ .kind = .image, .tag = "image", .body = "", .image = .{} }, + \\ }, + \\} + ; + const input = try gpa.dupe(u8, fixture); + defer gpa.free(input); + var parsed = try readZon(gpa, input, "omitted-defaults"); + defer parsed.deinit(); + @memset(input, 'x'); + const value = parsed.value; + try std.testing.expectEqualStrings(magic, value.magic); + try std.testing.expectEqualStrings("dark", value.theme); + try std.testing.expectEqualStrings("", value.topbar); + try std.testing.expectEqual(@as(usize, 0), value.mounts.len); + try std.testing.expectEqual(@as(usize, 3), value.panes.len); + try std.testing.expectEqualStrings("file.zig", value.panes[1].file.?.path); + try std.testing.expectEqualStrings("const café = 1;", value.panes[1].file.?.content); + try std.testing.expect(!value.panes[1].file.?.dirty); + try std.testing.expect(value.panes[2].image.?.ascii); + + var encoded: std.Io.Writer.Allocating = .init(gpa); + defer encoded.deinit(); + try std.zon.stringify.serialize(value, .{}, &encoded.writer); + var again = try readZon(gpa, encoded.written(), "default-roundtrip"); + defer again.deinit(); + try std.testing.expectEqualDeep(value, again.value); +} + +test "dump parser releases its arena at every allocation failure" { + const Check = struct { + fn run(gpa: std.mem.Allocator) !void { + var parsed = try readZon(gpa, + \\.{ + \\ .screen = .{ .cols = 80, .rows = 24 }, + \\ .columns = .{.{ .panes = .{0} }}, + \\ .panes = .{.{ .kind = .file, .tag = "file", .body = "", .file = .{ .content = "owned" } }}, + \\} + , "allocation-cleanup"); + defer parsed.deinit(); + try std.testing.expectEqualStrings("owned", parsed.value.panes[0].file.?.content); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Check.run, .{}); +} + +test "dump mount validation bounds names ownership inputs and duplicates" { + const panes = [_]Pane{.{ .kind = .file, .tag = "", .body = "", .file = .{} }}; + const ids = [_]usize{0}; + const columns = [_]Column{.{ .panes = &ids }}; + var state: State = .{ + .screen = .{ .cols = 80, .rows = 24 }, + .columns = &columns, + .panes = &panes, + }; + try validate(state); + for ([_]Mount{ + .{ .name = "peer", .dial = "/tmp/peer.sock" }, + .{ .name = "build-1.local", .dial = "tcp!127.0.0.1!5640" }, + }) |mount| { + state.mounts = &.{mount}; + try validate(state); + } + for ([_]Mount{ + .{ .name = "", .dial = "/tmp/peer.sock" }, + .{ .name = ".", .dial = "/tmp/peer.sock" }, + .{ .name = "..", .dial = "/tmp/peer.sock" }, + .{ .name = "os", .dial = "/tmp/peer.sock" }, + .{ .name = "self", .dial = "/tmp/peer.sock" }, + .{ .name = "two/parts", .dial = "/tmp/peer.sock" }, + .{ .name = "two parts", .dial = "/tmp/peer.sock" }, + .{ .name = "peer", .dial = "" }, + .{ .name = "peer", .dial = "unix!/tmp/peer\x00.sock" }, + }) |mount| { + state.mounts = &.{mount}; + try std.testing.expectError(error.BadDumpMounts, validate(state)); + } + const duplicate = Mount{ .name = "peer", .dial = "/tmp/peer.sock" }; + state.mounts = &.{ duplicate, duplicate }; + try std.testing.expectError(error.BadDumpMounts, validate(state)); + var name: [256]u8 = @splat('x'); + state.mounts = &.{.{ .name = name[0..255], .dial = "/tmp/peer.sock" }}; + try validate(state); + state.mounts = &.{.{ .name = &name, .dial = "/tmp/peer.sock" }}; + try std.testing.expectError(error.BadDumpMounts, validate(state)); + const mounts = [_]Mount{ + .{ .name = "a", .dial = "/tmp/a" }, + .{ .name = "b", .dial = "/tmp/b" }, + .{ .name = "c", .dial = "/tmp/c" }, + .{ .name = "d", .dial = "/tmp/d" }, + .{ .name = "e", .dial = "/tmp/e" }, + .{ .name = "f", .dial = "/tmp/f" }, + .{ .name = "g", .dial = "/tmp/g" }, + .{ .name = "h", .dial = "/tmp/h" }, + .{ .name = "i", .dial = "/tmp/i" }, + }; + state.mounts = mounts[0..8]; + try validate(state); + state.mounts = &mounts; + try std.testing.expectError(error.BadDumpMounts, validate(state)); +} + test "version-one image records default old fields and roundtrip new state" { const gpa = std.testing.allocator; const legacy = @@ -335,13 +452,13 @@ test "version-one image records default old fields and roundtrip new state" { \\ }}, \\} ; - const old = try readZon(gpa, legacy, "legacy-image"); - defer free(gpa, old); - const old_image = old.panes[0].image.?; + var old = try readZon(gpa, legacy, "legacy-image"); + defer old.deinit(); + const old_image = old.value.panes[0].image.?; try std.testing.expect(!old_image.petscii); try std.testing.expectEqual(ImagePalette.commodore, old_image.palette); try std.testing.expect(old_image.ascii); - try std.testing.expect(old.panes[0].tag_tail == null); + try std.testing.expect(old.value.panes[0].tag_tail == null); const pane = Pane{ .kind = .image, @@ -366,14 +483,14 @@ test "version-one image records default old fields and roundtrip new state" { var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); - const parsed = try readZon(gpa, out.written(), "new-image"); - defer free(gpa, parsed); - const restored = parsed.panes[0].image.?; + var parsed = try readZon(gpa, out.written(), "new-image"); + defer parsed.deinit(); + const restored = parsed.value.panes[0].image.?; try std.testing.expect(restored.petscii); try std.testing.expectEqual(ImagePalette.terminal, restored.palette); try std.testing.expect(!restored.ascii); - try std.testing.expect(parsed.panes[0].tag_tail != null); - try std.testing.expectEqualStrings("", parsed.panes[0].tag_tail.?); + try std.testing.expect(parsed.value.panes[0].tag_tail != null); + try std.testing.expectEqualStrings("", parsed.value.panes[0].tag_tail.?); } test "validation bounds pane restore state" { diff --git a/src/effect_sources.zig b/src/effect_sources.zig index d4f0e38b..3ca15c3f 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -1,100 +1,18 @@ -//! Build-embedded source behind EffectCode. -//! -//! Several builtins intentionally share one shader pass. Returning segments -//! makes that sharing visible instead of copying or manufacturing a pretend -//! per-effect program. - const std = @import("std"); const build_config = @import("pardes_config"); -const runtime_config = @import("runtime_config.zig"); - -const panel_math = @embedFile("panel_animation.zig"); -const pardes_core = @embedFile("pardes.zig"); -const tty_compositor = @embedFile("tty/panel_compositor.zig"); -const panel_vertex = @embedFile("effect-source-ui.vert.glsl"); -const panel_fragment = @embedFile("effect-source-ui.frag.glsl"); -const image_vertex = @embedFile("effect-source-image.vert.glsl"); -const image_fragment = @embedFile("effect-source-image.frag.glsl"); -const scene_vertex = @embedFile("effect-source-crt.vert.glsl"); -const scene_fragment = @embedFile("effect-source-crt.frag.glsl"); -const gui_scene_mapping = @embedFile("gui/crt.zig"); -const gui_host = @embedFile("gui/gui.zig"); -const mac_scene = @embedFile("effect-source-crt.ci.metal"); -const mac_postprocessor = @embedFile("macos/Sources/ScenePostprocessor.swift"); -const mac_view = @embedFile("macos/Sources/PardesView.swift"); - -fn sourceSection( - comptime source: []const u8, - comptime begin_marker: []const u8, - comptime end_marker: []const u8, -) []const u8 { - // gui.zig is intentionally embedded as the source actually compiled, then - // narrowed to its marked host paths. The comptime byte scan is larger than - // Zig's small default quota but contributes no runtime work. - @setEvalBranchQuota(1_000_000); - const begin = std.mem.indexOf(u8, source, begin_marker) orelse - @compileError("EffectCode source begin marker is missing"); - const body = begin + begin_marker.len; - const end = std.mem.indexOfPos(u8, source, body, end_marker) orelse - @compileError("EffectCode source end marker is missing"); - return source[body..end]; -} +const config = @import("config.zig"); +const filesystem = @import("fs.zig"); -const gui_panel_host = sourceSection( - gui_host, - "// EFFECT_CODE_PANEL_HOST_BEGIN\n", - "// EFFECT_CODE_PANEL_HOST_END", -); -const gui_native_panel = sourceSection( - gui_host, - "// EFFECT_CODE_NATIVE_PANEL_BEGIN\n", - "// EFFECT_CODE_NATIVE_PANEL_END", -); -const gui_frame_submission = sourceSection( - gui_host, - "// EFFECT_CODE_FRAME_SUBMISSION_BEGIN\n", - "// EFFECT_CODE_FRAME_SUBMISSION_END", -); -const gui_cell_instance = sourceSection( - gui_host, - "// EFFECT_CODE_CELL_INSTANCE_BEGIN\n", - "// EFFECT_CODE_CELL_INSTANCE_END", -); -const ascii_diff = sourceSection( - pardes_core, - "// EFFECT_CODE_ASCII_DIFF_BEGIN\n", - "// EFFECT_CODE_ASCII_DIFF_END", -); -const ascii_compositor = sourceSection( - pardes_core, - " // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN\n", - " // EFFECT_CODE_ASCII_COMPOSITOR_END", -); - -const ui_vertex_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/ui.vert.glsl" -else - "shaders/ui.vert.glsl"; -const ui_fragment_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/ui.frag.glsl" -else - "shaders/ui.frag.glsl"; -const image_vertex_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/image.vert.glsl" -else - "shaders/image.vert.glsl"; -const image_fragment_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/image.frag.glsl" +const shader_dir = if (build_config.gui_shader_sources_prebuilt) + "shaders/prebuilt/" else - "shaders/image.frag.glsl"; -const scene_vertex_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/crt.vert.glsl" -else - "shaders/crt.vert.glsl"; -const scene_fragment_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/crt.frag.glsl" -else - "shaders/crt.frag.glsl"; + "shaders/"; +const ui_vertex_path = shader_dir ++ "ui.vert.glsl"; +const ui_fragment_path = shader_dir ++ "ui.frag.glsl"; +const image_vertex_path = shader_dir ++ "image.vert.glsl"; +const image_fragment_path = shader_dir ++ "image.frag.glsl"; +const scene_vertex_path = shader_dir ++ "crt.vert.glsl"; +const scene_fragment_path = shader_dir ++ "crt.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; @@ -121,120 +39,72 @@ pub fn guiShaderSourceMode() ?GuiShaderSourceMode { .live; } -pub const Segment = struct { - path: []const u8, - source: []const u8, +pub const files = switch (backend) { + .tty => [_]filesystem.Source{ + .{ .path = "src/tty/panel_compositor.zig", .contents = @embedFile("tty/panel_compositor.zig") }, + }, + .gui => [_]filesystem.Source{ + .{ .path = "src/gui/gui.zig", .contents = @embedFile("gui/gui.zig") }, + .{ .path = "src/gui/crt.zig", .contents = @embedFile("gui/crt.zig") }, + .{ .path = ui_vertex_path, .contents = @embedFile("effect-source-ui.vert.glsl") }, + .{ .path = ui_fragment_path, .contents = @embedFile("effect-source-ui.frag.glsl") }, + .{ .path = image_vertex_path, .contents = @embedFile("effect-source-image.vert.glsl") }, + .{ .path = image_fragment_path, .contents = @embedFile("effect-source-image.frag.glsl") }, + .{ .path = scene_vertex_path, .contents = @embedFile("effect-source-crt.vert.glsl") }, + .{ .path = scene_fragment_path, .contents = @embedFile("effect-source-crt.frag.glsl") }, + }, + .macos => [_]filesystem.Source{ + .{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") }, + .{ .path = "src/macos/Sources/ScenePostprocessor.swift", .contents = @embedFile("macos/Sources/ScenePostprocessor.swift") }, + .{ .path = "shaders/crt.ci.metal", .contents = @embedFile("effect-source-crt.ci.metal") }, + }, + .web, .esp32p4 => [_]filesystem.Source{}, }; -const tty_panel = [_]Segment{ - .{ .path = "src/panel_animation.zig", .source = panel_math }, - .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff }, - .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor }, - .{ .path = "src/tty/panel_compositor.zig", .source = tty_compositor }, -}; -const gui_panel = [_]Segment{ - .{ .path = "src/panel_animation.zig", .source = panel_math }, - .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff }, - .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor }, - .{ .path = "src/gui/gui.zig#panel-host", .source = gui_panel_host }, - .{ .path = "src/gui/gui.zig#native-panel", .source = gui_native_panel }, - .{ .path = "src/gui/gui.zig#frame-submission", .source = gui_frame_submission }, - .{ .path = "src/gui/gui.zig#cell-instance", .source = gui_cell_instance }, - .{ .path = ui_vertex_path, .source = panel_vertex }, - .{ .path = ui_fragment_path, .source = panel_fragment }, - .{ .path = image_vertex_path, .source = image_vertex }, - .{ .path = image_fragment_path, .source = image_fragment }, -}; -const gui_scene = [_]Segment{ - .{ .path = "src/gui/crt.zig", .source = gui_scene_mapping }, - .{ .path = "src/gui/gui.zig#frame-submission", .source = gui_frame_submission }, - .{ .path = scene_vertex_path, .source = scene_vertex }, - .{ .path = scene_fragment_path, .source = scene_fragment }, -}; -const mac_panel = [_]Segment{ - .{ .path = "src/panel_animation.zig", .source = panel_math }, - .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff }, - .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor }, - .{ .path = "src/macos/Sources/PardesView.swift", .source = mac_view }, - .{ .path = "src/macos/Sources/ScenePostprocessor.swift", .source = mac_postprocessor }, - .{ .path = "shaders/crt.ci.metal", .source = mac_scene }, -}; -const mac_scene_segments = [_]Segment{ - .{ .path = "src/macos/Sources/PardesView.swift", .source = mac_view }, - .{ .path = "src/macos/Sources/ScenePostprocessor.swift", .source = mac_postprocessor }, - .{ .path = "shaders/crt.ci.metal", .source = mac_scene }, -}; - -/// Sources for the backend this executable was built for. Keeping the backend -/// compile-time and out of the call signature is important: a TTY binary must -/// not carry the macOS host and Metal source merely because another switch arm -/// could have returned them. -pub fn forSetting(setting: runtime_config.Setting) ?[]const Segment { +pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 { return switch (setting.action) { .transition => switch (backend) { - .tty => &tty_panel, - .gui => &gui_panel, - .macos => &mac_panel, + .tty => &.{ "src/layout.zig", "src/pardes.zig", "src/tty/panel_compositor.zig" }, + .gui => &.{ "src/layout.zig", "src/pardes.zig", "src/gui/gui.zig", ui_vertex_path, ui_fragment_path, image_vertex_path, image_fragment_path }, + .macos => &.{ "src/layout.zig", "src/pardes.zig", "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .web, .esp32p4 => null, }, .scene => switch (backend) { - .gui => &gui_scene, - .macos => &mac_scene_segments, + .gui => &.{ "src/gui/crt.zig", "src/gui/gui.zig", scene_vertex_path, scene_fragment_path }, + .macos => &.{ "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .tty, .web, .esp32p4 => null, }, else => null, }; } -test "every current-backend effect exposes embedded implementation code" { - for (runtime_config.settings) |setting| switch (setting.action) { - .transition => if (backend != .web) { - const segments = forSetting(setting) orelse return error.MissingTransitionSource; - for (segments) |segment| { - try std.testing.expect(segment.path.len > 0); - try std.testing.expect(segment.source.len > 0); - } - } else try std.testing.expect(forSetting(setting) == null), - .scene => if (backend == .gui or backend == .macos) { - const segments = forSetting(setting) orelse return error.MissingSceneSource; - for (segments) |segment| { - try std.testing.expect(segment.path.len > 0); - try std.testing.expect(segment.source.len > 0); - } - } else try std.testing.expect(forSetting(setting) == null), - else => {}, - }; -} - -test "GUI scene EffectCode includes both runtime shader stages" { - if (comptime backend == .gui) { - try std.testing.expectEqual(@as(usize, 4), gui_scene.len); - try std.testing.expectEqualStrings("src/gui/crt.zig", gui_scene[0].path); - try std.testing.expectEqualStrings("src/gui/gui.zig#frame-submission", gui_scene[1].path); - try std.testing.expectEqualStrings(scene_vertex_path, gui_scene[2].path); - try std.testing.expectEqualStrings(scene_fragment_path, gui_scene[3].path); - try std.testing.expect(std.mem.indexOf(u8, gui_scene[0].source, "pub fn mapScene") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_scene[1].source, "SDL_PushGPUFragmentUniformData") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_scene[2].source, "gl_VertexIndex") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_scene[3].source, "u_crt.effects") != null); +test "EffectCode paths resolve uniquely to this backend's archived inputs" { + for (config.Runtime.settings) |setting| { + const available = switch (setting.action) { + .transition => backend == .tty or backend == .gui or backend == .macos, + .scene => backend == .gui or backend == .macos, + else => false, + }; + const paths = forSetting(setting); + try std.testing.expectEqual(available, paths != null); + if (paths) |listed| for (listed, 0..) |path, i| { + const bytes = filesystem.sourceBytes(path) orelse return error.MissingEffectSource; + try std.testing.expect(bytes.len > 0); + for (listed[0..i]) |previous| try std.testing.expect(!std.mem.eql(u8, path, previous)); + }; } -} - -test "GUI panel EffectCode includes the actual host paint and submission path" { - if (comptime backend == .gui) { - try std.testing.expect(std.mem.indexOf(u8, ascii_diff, "pub const PanelCellDiff") != null); - try std.testing.expect(std.mem.indexOf(u8, ascii_compositor, "fn composeAsciiTransitions") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn makePaintPlan") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn setTransitionFields") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "fn prepareNativeImages") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "fn drawNativeImagesGpu") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "active.visualBox()") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_frame_submission, "for (paint_plan.batches") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "fn emitInstance") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "setTransitionFields") != null); - try std.testing.expect(std.mem.indexOf(u8, panel_math, "frame_count") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_frame_submission, "active.effect == .dissolve") != null); + for (files) |file| { + const archived = filesystem.sourceBytes(file.path) orelse return error.MissingEffectSource; + try std.testing.expectEqualStrings(file.contents, archived); + var matches: usize = 0; + for (filesystem.sources) |source| if (std.mem.eql(u8, source.path, file.path)) { + matches += 1; + }; + try std.testing.expectEqual(@as(usize, 1), matches); } + try std.testing.expectEqual(backend == .tty, filesystem.sourceBytes("src/tty/panel_compositor.zig") != null); + try std.testing.expectEqual(backend == .gui, filesystem.sourceBytes("src/gui/gui.zig") != null); + try std.testing.expectEqual(backend == .macos, filesystem.sourceBytes("src/macos/Sources/PardesView.swift") != null); } test "shader source provenance is GUI-only and follows the build input" { @@ -247,22 +117,3 @@ test "shader source provenance is GUI-only and follows the build input" { try std.testing.expect(expected.label().len > 0); } else try std.testing.expect(guiShaderSourceMode() == null); } - -test "mac EffectCode includes the runtime panel and scene wiring" { - if (comptime backend == .macos) { - // `extern "C" [[stitchable]]` is what the runtime compile requires: - // CIKernel.kernels(withMetalString:) looks for stitchable functions and - // rejects the whole source without them, so ScenePostprocessor.init? - // returns nil and every scene effect and panel transition silently - // degrades to the plain CoreText draw. The spelling is pinned on - // purpose; relaxing these three matches is how that comes back. - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" [[stitchable]] float4 pardesPanelClear") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" [[stitchable]] float4 pardesPanel") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" [[stitchable]] float4 pardesScene") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "already composed in Pardes core") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_view, "hideCursor: !tracks.isEmpty") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelClearKernel.apply") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelKernel.apply") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "sceneKernel.apply") != null); - } -} diff --git a/src/esp32p4.zig b/src/esp32p4.zig index 8eab57e2..0d532e1e 100644 --- a/src/esp32p4.zig +++ b/src/esp32p4.zig @@ -1,8 +1,7 @@ //! The ESP32-P4 firmware shell: pardes as one freestanding object, bytes in and bytes out. //! -//! This is the fourth platform, and the only one that is not an executable. `zig build -//! -Dplatform=esp32p4 -Dtarget=riscv32-freestanding` emits this file as a single object exporting the C -//! ABI below; the `zig-p4` package links it beside its own `_start`, its generated linker script, +//! `zig build -Dplatform=esp32p4` emits this file as a single object exporting the C +//! ABI below; the sibling `05-zig-p4` toolchain links it beside `_start`, its generated linker script, //! and its UART driver. Nothing here knows what a UART is. //! //! **Why an object and not a module.** The obvious arrangement was for zig-p4 to declare this @@ -123,7 +122,7 @@ pub const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) c /// /// The board's side, not the editor's, because a correct toggle is the IO MUX, the GPIO matrix, the /// pad's own bits and the output enable - four register files behind a per-pin table that the -/// firmware already has and checks against ESP-IDF. See `Host.VTable.pull_gpio_toggle`. +/// firmware already has and checks against ESP-IDF. See `Host.VTable.gpio_toggle`. pub const GpioFn = *const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) callconv(.c) bool; // ------------------------------------------------------------------- the allocator, rebuilt @@ -280,7 +279,7 @@ export fn pardes_esp32p4_init( .y_pixel = 0, }; - const allocs = pardes.allocators.init(a); + const allocs = pardes.memory.init(a); // `std.Io.failing` and not a real Io: every path in the core that would perform I/O is behind // the Host vtable, and the ones that are not are the ones this platform does not have. pardes.image.start(std.Io.failing, allocs.image); @@ -608,7 +607,7 @@ export fn pardes_esp32p4_quit() callconv(.c) bool { // ------------------------------------------------------------------------------------ the host -const pardes_host: pardes.Host.VTable = .{ .push_present = present, .pull_gpio_toggle = gpioToggle }; +const pardes_host: pardes.Host.VTable = .{ .present = present, .gpio_toggle = gpioToggle }; /// The `Gpio` word's one seam to the board. Nothing here knows what a pad is; it forwards, and /// answers false when the firmware brought none, which is what puts "gpio: NoPads" on the message diff --git a/src/esp32p4/app.zig b/src/esp32p4/app.zig index a9cf627d..ae5679f3 100644 --- a/src/esp32p4/app.zig +++ b/src/esp32p4/app.zig @@ -17,7 +17,7 @@ //! What stayed behind is everything a second application would also want, and none of it is //! duplicated here: the SoC and HAL, the translate-c register layer, the coalescing heap, `std.Io` //! for this chip, the app descriptor, the generated linker script, the image builder, the flasher -//! and the interactive console. Those arrive as the `zig_p4` dependency, and this file imports +//! and the interactive console. The sibling `05-zig-p4` build supplies them; this file imports //! exactly four of its modules - `soc`, `hal`, `heap` and `config` - plus two sibling files, //! `uart.zig` and `input_rescue.zig`, which are the editor's own. //! @@ -33,8 +33,7 @@ //! give the firmware two ways to reach the editor. And above all it would make the OBJECT path a //! second arrangement, tested separately: that path is what `05-zig-p4 -Dpardes -Dpardes-obj=...` //! builds, it is what every measurement in that repository's `experiments/` was taken through, and -//! it is a supported way to build this board. With the extern kept, both builds link the same eight -//! symbols against the same object file, so neither can drift and neither is the better-tested one. +//! it is the firmware build path. Its externs link against that object file. //! The reasons the seam is a file at all - a nested `build.zig.zon` dependency broke every build in //! the toolchain repository - are recorded in `src/esp32p4.zig:8-15` and `05-zig-p4/build.zig:238-260`. //! diff --git a/src/esp32p4/input_rescue.zig b/src/esp32p4/input_rescue.zig index 01dd4820..3f1c0268 100644 --- a/src/esp32p4/input_rescue.zig +++ b/src/esp32p4/input_rescue.zig @@ -37,10 +37,8 @@ //! same-named module of `zig build selftest`'s on-die root (`:437-438`). Both spell //! `../02-pardes-code/src/esp32p4/input_rescue.zig`, so there is nothing to keep in step. //! -//! Tested from here, both ways: the host checks at the bottom run as their own `addTest` under -//! `zig build unit-test` (`02-pardes-code/build.zig:1727-1732` - no `link_libc`, because `std` is -//! the whole import list), and the same source runs against UART0 on the die under -//! `zig build esp32p4-test`. +//! Host checks run under `zig build unit-test` here. In `../05-zig-p4`, +//! `zig build selftest` flashes and runs the on-die checks against UART0. const std = @import("std"); diff --git a/src/esp32p4/selftest.zig b/src/esp32p4/selftest.zig index 0d692d98..97b43059 100644 --- a/src/esp32p4/selftest.zig +++ b/src/esp32p4/selftest.zig @@ -35,22 +35,18 @@ //! //! Four of its modules and no more: `soc` (mask-ROM printf, the cycle counter), `hal` (UART0, the //! systimer, clock/reset), `config` (this build's `cpu_mhz`) and `heap` (the coalescing allocator). -//! They arrive as the `zig_p4` dependency, which also supplies what makes this an image at all - -//! the generated linker script, `ENTRY(_start)` and the app descriptor via `firmware(...).attach`, -//! then `ImageStep`, `FlashStep` and `SelftestStep`. +//! The sibling `05-zig-p4` build supplies these modules and the firmware image tooling. //! //! `input_rescue` is the fifth import and is NOT that package's: it is the sibling file in this //! directory, handed over as a named MODULE rather than imported as a path. Deliberately so - the //! `pub` on `FakePort`'s methods below is what lets that module reach them by duck typing across the -//! boundary, and both builds, this repository's `esp32p4-test` and the toolchain's `selftest`, wire -//! the identical root the identical way. One file, one arrangement, nothing to drift. +//! boundary. The toolchain's `selftest` step wires this root and that module together. //! //! Not a `zig test` binary, deliberately. Zig's test runner wants an OS, and `std.testing.allocator` //! is a debug allocator over the page allocator, which on freestanding is either a compile error or //! a lie. A hand-rolled harness is thirty lines and answers to nobody. //! -//! Run with: zig build esp32p4-test -Dplatform=esp32p4 -Desp32p4-firmware (from here) -//! or: zig build selftest (from ../05-zig-p4) +//! From `../05-zig-p4`, `zig build selftest` flashes and runs this suite on hardware. const std = @import("std"); const soc = @import("soc"); diff --git a/src/esp32p4/uart.zig b/src/esp32p4/uart.zig index 53ee29df..15599423 100644 --- a/src/esp32p4/uart.zig +++ b/src/esp32p4/uart.zig @@ -1,4 +1,4 @@ -//! UART0 as the editor's terminal: bytes out, bytes in, and nothing else. +//! UART0 transport for the editor and standalone GPIO 9P firmware. //! //! This is the whole of the firmware's I/O. There is no framebuffer and no keyboard; the board //! emits ANSI and consumes ANSI, and the terminal emulator on the far end of the CH340 does the @@ -37,13 +37,8 @@ //! with nothing readable left to explain it. Everything else here touches FIFO offset 0x000 and the //! status register, and nothing else. //! -//! The DIVIDER is the one exception, and it was carved out for the second image rather than for this -//! one: `docs/registry.typ` `BOARD-1`. The editor's console is opened by a human at 115200 and the -//! firmware inherits that divider (which is why the paragraph above used to say "not the divider"); -//! the 9P image (`nine.zig`) has a program on the far end that opens the port at whatever rate the -//! image was built for, and eight times the baud is eight times less latency on every `Tread`. So -//! `setBaud` exists, this file still never calls it, and `app.zig` still never calls it — the only -//! caller is the image whose host side is opened to match. +//! The editor inherits the bootloader's 115200 divider. `src/esp32p4_9p.zig` +//! calls `setBaud` for its 921600 protocol connection; the host must match it. const hal = @import("hal"); const input_rescue = @import("input_rescue.zig"); diff --git a/src/esp32p4_9p.zig b/src/esp32p4_9p.zig index dd0346ef..4aa559a1 100644 --- a/src/esp32p4_9p.zig +++ b/src/esp32p4_9p.zig @@ -1,149 +1,13 @@ -//! THE BOARD AS A 9P SERVER, and nothing else: the reset entry, one UART, and a pump. -//! -//! This is the SECOND ESP32-P4 image and it is not a second role for the first one. `app.zig` is -//! the editor — a real `pardes.Pardes` core with vaxis on top, emitting ANSI down UART0 to a -//! terminal emulator on the far end. This image links none of that. Same board, same UART, same -//! flash partition, one at a time, because the editor owns UART0 bidirectionally and JP1 exposes no -//! second P4 UART (`docs/registry.typ` `9P-11`: "The board is either an editor or a filesystem at -//! any one time. Say that plainly rather than implying both"). -//! -//! THE UART CARRIES ONLY 9P. That is the whole difference from the other image and it is the point. -//! No ANSI, no vaxis, no escape sequences, no `MARK` boot markers, no `soc.rom.print` — not even -//! the heap report `app.zig:320-329` prints on every boot, which would be the single most useful -//! line here and is still not allowed, because a byte on this wire that is not part of a 9P message -//! is a byte that desynchronises whatever is parsing it. The proof that this image booted is that it -//! answers `Tversion`. -//! -//! The one thing that had to be said in some other language is a PANIC and a TRAP, and they are said -//! in 9P too: an `Rerror` carrying the message, tagged `NOTAG`. No client is waiting for that tag, -//! so `9p` reports it as an unexpected reply and prints the string — which is exactly the diagnosis -//! wanted ("the board died, here is why") delivered without putting one non-protocol byte on the -//! wire. See `panicImpl` and `trapReport`. -//! -//! ## What it serves -//! -//! `src/board9p.zig`, which is the board's own capabilities as a tree: `gpio/pinout` is the JP1 -//! drawing the editor's `Gpio` word prints, and `gpio//value` is one pad's driven level, readable -//! and writable. Both come out of a comptime table, and adding a capability to that table adds files -//! here with no code in this file changing at all. -//! -//! Deliberately NOT `src/acmefs.zig`, and the reason is the same one that makes this a second image. -//! That file is the EDITOR's control filesystem: every operation in it is about a pane, and a pane -//! only exists because a `pardes.Pardes` exists. Serving it would mean linking the editor object -//! (809,536 B of image) and instantiating the core, at which point this is `app.zig` with a -//! different output encoding rather than a 9P server. It compiles for this target — `llvm-nm` finds -//! 21,548 B of `acmefs.*` in `zig-out/pardes-esp32p4.o` — and that fact is what made this image -//! worth building, because it is what proved the filesystem layer has no host dependency. The ABI is -//! what got reused, not the tree: `src/9p.zig`'s `Server` is a generic over the filesystem, and -//! `board9p` implements `acmefs`'s `Op`/`Status`/`Req`/`Reply` verbatim, so the same server serves -//! either one and neither knows about the other. -//! -//! ## The loop -//! -//! Four lines, and every one of them is a `Server` method doing what its doc comment says: -//! -//! read bytes off the UART -> srv.push(bytes) -//! pump -> srv.retry() / srv.next() -> fsys.handle(req) -> srv.reply(...) -//! write what is queued -> srv.wrote(uart.writeSome(srv.output())) -//! -//! NOTHING BLOCKS. `uart.read` is non-blocking, `uart.writeSome` hands over what the transmit FIFO -//! has room for and answers how much, and `Server.wrote(n)` takes a partial write as an ordinary -//! answer rather than an error (`src/9p.zig:2248-2257`). So a client that stops reading cannot stall -//! this loop, and a reply larger than the 128-byte FIFO leaves over several trips round it. That is -//! the same sans-io contract `src/fs9_service.zig` gives the desktop's unix socket; the difference -//! is that there is no `poll` here and no need for one, because there is exactly one connection and -//! it is the wire. -//! -//! ## The numbers, measured rather than costed -//! -//! `.bss` IS THE WHOLE RAM BILL, because this image has no allocator: not a heap, not an arena, and -//! the 384 KiB span the editor's image hands `heapmod` is not even mapped by anything here. So the -//! board's ≈336 KB of free heap (`docs/registry.typ` `FIX-2`) is untouched at 100%, and what this -//! program spends is the 240 KiB of low L2MEM that `9P-11`'s built note names as the real binding -//! constraint. `llvm-size` on the ELF says `.bss` is 16,656 B, and every byte of it is accounted -//! for: -//! -//! 9,192 `srv` — `Server(Tree(Pads))` on riscv32. `9P-11` measured 9,488 on the -//! host; a 32-bit target's slices are half the width, and the park -//! table has thirty-two of them. -//! 1,024 `in_buf` — one msize -//! 2,048 `out_buf` — two, so no reply can fail to be queued -//! 4,108 the rescue ring — `input_rescue.Ring` inside `uart.zig`, which comes with the UART -//! 148 `fsys` — the whole tree: one 143-byte answer buffer and a counter -//! 128 `stage` -//! ------ -//! 16,648 + 8 of alignment and `uart.dropped` = 16,656 -//! -//! Add the 32,768-byte `.stack` the shared linker script gives every image built through -//! `firmware()` and the low-L2MEM total is 49,424 B, 20% of the 240 KiB — against the editor's -//! 75,236 B (20,408 `.data` + 22,060 `.bss` + the same stack). The stack is the largest single item -//! and it is inherited rather than chosen: 32 KiB is sized for the CORE's recursive layout pass -//! (`build.zig:1088-1090`), and nothing in this image recurses at all. -//! -//! FLASH: the image is 88,080 B of the 1,536,000 B partition — 5.7%, against the editor image's -//! 812,688 B (52.9%). Only 28,066 B of that is content (22,504 `.flash.text`, 5,562 B of real -//! `.flash.rodata`, 80 B of image header and checksum); the rest is the gap between the end of the -//! rodata segment and the 64 KiB-aligned origin the code segment must start on, because the ESP32 -//! flash MMU maps in 64 KiB pages and the two segments cannot share one. A tiny image pays up to -//! 64 KiB for that and there is nothing to be done about it here — it is the generated linker -//! script's arithmetic (`05-zig-p4/build.zig`), and it is why the estimate of "≈39 KiB" in `9P-11` -//! was closer to the CONTENT than to the image. -//! -//! ## Build it, flash it, talk to it -//! -//! zig build -Dplatform=esp32p4 -Desp32p4-firmware -Desp32p4-9p esp32p4-9p-flash -//! zig build -Dplatform=esp32p4 -Desp32p4-firmware -Desp32p4-9p esp32p4-9p-size # no board needed -//! -//! There is no `esp32p4-9p-attach`, and that absence is the design: what belongs on the far end of -//! this wire is a 9P client opened at `baud`, not a terminal. `9p` and `9pfuse` speak to a SOCKET, -//! so reaching this board with either means a program that copies bytes between the tty and a unix -//! socket in both directions — which is nine lines of anything and is not this file's business. -//! pardes's own client (`src/fs9_client.zig`) needs no such bridge, because a tty is already a -//! bidirectional byte stream and that is all 9P has ever asked for (`docs/registry.typ` `9P-19`). -//! -//! FLASHING THIS REPLACES THE EDITOR. Both images are written to `img.opts`'s one offset, on -//! purpose: there is one partition and the board is one thing at a time. `zig build esp32p4-flash` -//! puts the editor back. - +//! Standalone GPIO 9P firmware over UART0; the editor is not linked. +//! UART0 carries protocol bytes only, including fatal diagnostics. const std = @import("std"); const soc = @import("soc"); const hal = @import("hal"); const config = @import("config"); -// PATH imports, not named modules, and that is what lets any builder root an -// image here: the toolchain repository links this file with the four platform -// modules it owns (`soc`, `hal`, `config`, `heap`) and nothing else, so a -// `@import("ninep")` here was a module only pardes's own build.zig knew to -// inject — and the image stopped building the moment that build.zig stopped -// linking it. See `src/board9p.zig`'s note on the same change. const ninep = @import("9p.zig"); -const board9p = @import("board9p.zig"); +const gpio = @import("esp32p4_gpio.zig"); const uart = @import("esp32p4/uart.zig"); -/// THE PADS, and this is the whole seam between the tree and the silicon. -/// -/// The same four `hal.gpio` calls `src/esp32p4/app.zig:200-209` makes for the editor's `Gpio` word, -/// for the reason that file gives at length: a toggle is not a write to GPIO_OUT. `configureOutput` -/// points the pad's IO MUX at the GPIO function, routes the GPIO matrix's output to it, sets the -/// drive strength and input buffer, clears the pulls and only then enables the driver — four register -/// files indexed by a per-pin table, which live in the toolchain package where `zig build diff` -/// checks their numbers against ESP-IDF's own headers. A second copy would be a second copy under no -/// test. This is a second CALLER, which is the opposite thing. -/// -/// `getDrivenLevel` and not `getLevel`: the answer is the level this board is DRIVING, which is -/// defined for every pin including one with nothing attached, where the pad's own level is whatever -/// the air says. `readback = true` enables the input buffer anyway, so a client that wants the pad -/// rather than the register has something to compare against. -/// -/// SPLIT INTO `level` AND `drive` rather than the editor's single `toggle`, because a file can say -/// which level it wants and a keystroke cannot. `Gpio 20` has one argument and has to mean "the -/// other one"; `echo 1 > gpio/20/value` says 1, which is what makes it idempotent and therefore -/// scriptable. Writing the level a pad is already at still calls `configureOutput`, and that is not -/// a wasted write: on a freshly booted board it is the call that makes the pad an output at all. -/// -/// BOTH ARE `pub` AND HAVE TO BE, for the same reason `src/esp32p4/selftest.zig:44-46` says its -/// `FakePort`'s methods are: `board9p` is a MODULE here, and duck typing across a module boundary -/// still needs the declaration to be visible from outside the file it is in. Nothing else in this -/// image is `pub`. const Pads = struct { pub fn level(pin: u8) u1 { return hal.gpio.getDrivenLevel(pin); @@ -156,132 +20,58 @@ const Pads = struct { }; comptime { - // Every pin the tree generates has to be a pad this chip package has, and the check belongs here - // rather than in `board9p.zig`: `max_pin` is 56 on this package and lives in the toolchain - // repository, which a host-testable tree cannot import. A JP1 row edited to name GPIO 60 is a - // compile error in this image instead of an out-of-bounds register index on the die. - for (board9p.pins) |pin| { + for (gpio.Header.gpio_pins) |pin| { if (pin > hal.gpio.max_pin) @compileError("JP1 names a pad this chip package does not have"); } } -/// The board's tree, over the real pads. -const Fs = board9p.Tree(Pads); -const Server = ninep.Server(Fs); +const Fs = gpio.Fs(Pads); +const Server = ninep.Server(Fs, ninep.board_fids); + +comptime { + std.debug.assert(@typeInfo(@FieldType(Server, "fids")).array.len == 32); + std.debug.assert(@sizeOf(Server) <= 10 * 1024); +} -/// THE msize, and it is 1,024 rather than the 4,096 everything else in this tree assumes. -/// -/// The 4,096 floor is the LINUX KERNEL's and nobody else's: `linux/net/9p/client.c:840-843` refuses -/// to mount below it, which is why `9p.min_msize` is 4,096 and why the desktop daemon serves that. -/// Plan 9's devmnt, plan9port's `9p` and pardes's own client all accept 512 -/// (`docs/registry.typ` `9P-11`), and no Linux kernel is ever going to mount this image: the far end -/// of this wire is a serial port, and a `mount -t 9p` needs a socket or a virtio channel, neither of -/// which a CH340 is. So the floor that applies here is `9p.msize_min` — 217 bytes, DERIVED from the -/// largest reply whose size the client does not choose (`src/9p.zig:1873-1881`). -/// -/// 1,024 and not 217, because the number to size against is the widest DIRECTORY READ. `gpio/` has -/// twelve entries, a `stat` record in a directory read is 49 bytes of fixed fields plus the name plus -/// three copies of the client's `uname` (`src/9p.zig:3251-3260`), so a `goblin` reading `ls gpio/` -/// wants 12 × ~73 = ~880 bytes to get the listing in ONE round trip. At 217 it would take five, and -/// each one costs a `Tread` and an `Rread` on a wire. Everything else here is tiny: the largest file -/// in the tree is the 468-byte JP1 drawing and the largest write is two bytes. -/// -/// What it costs: `in` is one msize and `out` is two — one message going out and one being built, -/// which is what makes every reply in the server infallible — so 3,072 B for the buffers against -/// 12,288 B at a 4,096 msize. Nine kilobytes of the board's low L2MEM for a round trip nobody needs. const msize: u32 = 1024; -/// One whole T-message, and the ceiling on the msize this connection will agree to. var in_buf: [msize]u8 = undefined; -/// Two, for the reason above. `Server.hasRoom` reserves one msize before it hands any request to the -/// filesystem, which is what makes back-pressure land on `next()` returning null instead of on a -/// half-written reply. var out_buf: [2 * msize]u8 = undefined; -/// Bytes off the receiver on their way into the server, and the ONE buffer in this file. -/// -/// 128 is the transmit and receive FIFO depth (the toolchain package's `src/hal/uart.zig:52`), so one -/// `uart.read` can never leave more behind than one FIFO's worth, and the tail that `push` would not -/// take is re-offered next time round the loop. It is not a reassembly buffer — `Server.in` is that, -/// and it holds a whole message — it is the handover between a driver that fills a slice and a server -/// that takes what it has room for. var stage: [128]u8 = undefined; -/// The wire's rate, and the host must be opened to match or nothing works and nothing says so. -/// -/// 921600 rather than the 115200 the bootloader leaves behind: `docs/registry.typ` `BOARD-1`. One -/// `UART_CLKDIV_SYNC` write on the existing 40 MHz XTAL, int 43 frag 6, +0.064% error, and it takes -/// a byte from 86.8 µs to 10.85 µs — which on this loop is a warm `cat gpio/20/value` going from -/// 10.8 ms to 1.35 ms and a 1 KiB `Tread` from 89 ms to 11 ms. 2 Mbaud is representable and this -/// CH340 is unreliable there, corroborated by the flasher's own choice at `build.zig:1136-1138`. -/// -/// It is programmed before the first reply and after the input drain, which is the one moment when -/// there can be nothing in either FIFO to be corrupted by the change. const baud: u32 = 921600; -/// The server and the tree, both in `.bss` and both fixed for the life of the image. No allocator -/// exists in this program at all — not a heap, not an arena, not the `heapmod` the editor's image -/// hands over 384 KiB to — so `zig build esp32p4-9p-size` reporting `.bss` is reporting the whole -/// of what this server costs in RAM. var srv: Server = undefined; var fsys: Fs = .{}; export fn zig_main() noreturn { - // FIRST, before anything reads `.rodata`, exactly as `app.zig:275` does it and for the same - // reason: the JP1 drawing this image serves is 468 bytes of `.rodata` in flash, and a read of it - // through a stale cache returns whatever was there at reset. soc.flushFlashCache(); - // The same clock the editor's image runs at, so a latency measured on one is a latency on the - // other. A divider change that disturbs neither UART0 (XTAL) nor the flash interface (SPLL). if (config.cpu_mhz != 90) hal.clkrst.setCpuFreq(switch (config.cpu_mhz) { 180 => .mhz180, 360 => .mhz360, else => .mhz90, }); - // The RTC watchdog is armed at reset and this loop never feeds anything. Without this the board - // resets a few seconds in, which over a wire that carries only 9P looks exactly like a client - // that cannot reach it. _ = hal.rwdt.disable(); - // WHAT THE BOOTLOADER LEFT ON THE WIRE, discarded before the divider changes: its own chatter - // has already been echoed at the host, and the host bridge injects a synthetic window-size - // report before this program exists. Neither is 9P, and either would be the first bytes of a - // message that never was. _ = uart.drainInput(); - // The rate, then. A refusal is not fatal and must not be: an unreachable divider leaves 115200 - // in place, which is a slow board rather than a silent one, and a client opened at the wrong rate - // finds out immediately because `Tversion` gets no answer it can parse. _ = uart.setBaud(baud); - srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = board9p.root }); + srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = gpio.root }); - // THE PUMP. `stage_len` is the only state outside the server. var stage_len: usize = 0; while (true) { - // IN. Non-blocking, rescued bytes first (`uart.read`), and never more than the staging - // buffer's room, so a burst larger than one FIFO simply arrives over two iterations. if (stage_len < stage.len) stage_len += uart.read(stage[stage_len..]); if (stage_len != 0) { - // A SHORT PUSH IS NORMAL AND IS NOT A LOSS: it is the only back-pressure a sans-io - // server has (`src/9p.zig:2229-2233`). What it would not take stays here and is offered - // again after the pump has made room by finishing a message. const took = srv.push(stage[0..stage_len]); if (took != stage_len) std.mem.copyForwards(u8, stage[0 .. stage_len - took], stage[took..stage_len]); stage_len -= took; } - // PUMP, in the order `src/fs_service.zig:209-222` requires: every parked request offered - // once, then everything the wire has, both loops to null. - // - // NOTHING ON THIS BOARD PARKS — the answer to "what level is this pad" is a register read, - // and there is no `event` file and no reader to block — so `retry()` answers null on the - // first call, every time. It is here because the contract is the contract, and because the - // first capability that does block (an interrupt-driven `gpio//edge`) needs this line to - // already exist rather than to be remembered. while (srv.retry()) |req| { const a = fsys.handle(req); srv.reply(&a.reply, a.bytes); @@ -291,18 +81,9 @@ export fn zig_main() noreturn { srv.reply(&a.reply, a.bytes); } - // OUT. Whatever fits in the transmitter right now, and the server keeps the rest. const queued = srv.output(); if (queued.len != 0) srv.wrote(uart.writeSome(queued)); - // THE STREAM WAS NOT 9P, and there is no resynchronising from that: a `size` no encoder - // could have produced, an R-message from something that thought it was the server, a - // message larger than the negotiated msize. On a socket the answer is to close the - // connection and let the client notice; on a wire that cannot be closed, the answer is to - // reset it — pay the filesystem whatever `release`s the dead fids owe it, throw away every - // byte in flight in both directions, and start a fresh connection in the same silence a - // reboot would have. A client resynchronises by sending `Tversion`, which is what a client - // does after any failure anyway. if (srv.dead) { srv.hangup(); while (srv.next()) |req| { @@ -311,25 +92,11 @@ export fn zig_main() noreturn { } _ = uart.drainInput(); stage_len = 0; - srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = board9p.root }); + srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = gpio.root }); } } } -// --------------------------------------------------------------------------- dying in protocol - -/// A message this image is about to die with, as an `Rerror` on `NOTAG`. -/// -/// THE ONE PLACE A NON-REPLY IS SENT, and it is still a legal 9P message, which is the whole trick. -/// `NOTAG` is the tag of the `Tversion` exchange and no client has a request outstanding under it, so -/// `9p` and pardes's own client both report an unexpected reply AND PRINT THE STRING — "the board -/// panicked at 0x4000a1b8", delivered through a parser rather than past it. The alternative is what -/// the editor's image does, `MARK PARDES_PANIC` in plain text, which on this wire would be a frame -/// header of 0x4b52414d followed by garbage: an unrecoverable stream instead of a diagnosis. -/// -/// Blocking `uart.write` and not `writeSome`, because there is no loop left to come back round: this -/// is the last thing the image does, and a bounded spin that gets the whole message out is worth -/// more here than one that returns. fn die(msg: []const u8) noreturn { var buf: [ninep.errmax + ninep.header_len + 2]u8 = undefined; const bytes = ninep.encode( @@ -341,9 +108,6 @@ fn die(msg: []const u8) noreturn { while (true) {} } -/// Eight hex digits into `buf`, computed arithmetically. Hand-rolled rather than `std.fmt`, for the -/// reason `uart.dumpWord` gives: this runs in a trap handler, where the less of the image it depends -/// on the more likely it is to run at all. fn hex8(buf: *[8]u8, v: u32) void { var shift: u5 = 28; for (buf) |*slot| { @@ -353,11 +117,6 @@ fn hex8(buf: *[8]u8, v: u32) void { } } -/// `mtvec` is set in DIRECT mode by `_start`, so every trap and every interrupt lands here. -/// -/// A trap handler exists for the reason `app.zig:432-441` gives — the mask ROM's "Guru Meditation" -/// only prints while ITS handler is installed, and a silent fault over a serial line is -/// indistinguishable from an infinite loop — and it reports through 9P for the reason `die` gives. export fn trapEntry() linksection(".text.entry") callconv(.naked) noreturn { asm volatile ("j trapReport"); } @@ -372,8 +131,6 @@ export fn trapReport() noreturn { const mtval = asm volatile ("csrr %[o], mtval" : [o] "=r" (-> u32), ); - // The three registers that name a RISC-V fault, in the order a reader wants them: what happened, - // where, and to which address. var msg = "trap mcause=00000000 mepc=00000000 mtval=00000000".*; hex8(msg[12..20], mcause); hex8(msg[26..34], mepc); @@ -381,21 +138,6 @@ export fn trapReport() noreturn { die(&msg); } -// --------------------------------------------------------------- the root's own duties -// -// This is a ROOT, so it owns std's configuration for this compilation unit. The editor's image has -// two of these (`app.zig` and `src/esp32p4.zig`, one per object); this image is one object and has -// one. - -/// `page_size_min`/`max`: no MMU and no pages here, but std derives alignment from them, and 4 KiB -/// is this chip's cache and DMA granularity. -/// -/// `logFn` is not cosmetic and it is not optional. std's default log implementation reaches -/// `std.debug_io`, which instantiates `std.Io.Threaded` — a thread pool, `getrandom`, `IOV_MAX`, -/// `mremap` — and one `log.warn` anywhere in the graph drags all of it into the image. This one -/// DISCARDS, which is the only honest thing it can do: there is nowhere for a log line to go on a -/// wire that carries only 9P, and a log line that went out anyway would break the connection it was -/// trying to explain. Nothing in this image's graph logs; this is the wall that keeps it that way. pub const std_options: std.Options = .{ .page_size_min = 4096, .page_size_max = 4096, @@ -412,9 +154,6 @@ fn logFn( pub const panic = std.debug.FullPanic(panicImpl); fn panicImpl(msg: []const u8, first_trace_addr: ?usize) noreturn { - // The address is what makes it actionable — `addr2line` against the ELF in zig-out turns it into - // a source line — so it goes in front of the message, where `errmax`'s 128-byte truncation - // cannot reach it. A panic message names a KIND of failure; the address names which one. var buf: [ninep.errmax]u8 = undefined; @memcpy(buf[0..7], "panic 0"); buf[7] = 'x'; @@ -425,14 +164,6 @@ fn panicImpl(msg: []const u8, first_trace_addr: ?usize) noreturn { die(buf[0 .. 17 + n]); } -/// Reset entry, identical in shape to `app.zig:528-546` and for the identical reasons: the bootloader -/// hands over with an unspecified stack pointer and the FPU off, so enable the F extension -/// (`mstatus.FS`), establish a stack, install the trap vector, clear `.bss`, and jump into Zig. -/// -/// `.bss` MATTERS MORE HERE THAN ANYWHERE. Everything this image owns is in it — the server, its two -/// buffers, the tree, the staging buffer — so this loop is what makes the fid table empty and the -/// msize zero, and skipping it would start the server mid-connection with a client that does not -/// exist. export fn _start() linksection(".text.entry") callconv(.naked) noreturn { asm volatile ( \\ li t0, 1 << 13 diff --git a/src/esp32p4_gpio.zig b/src/esp32p4_gpio.zig new file mode 100644 index 00000000..92468581 --- /dev/null +++ b/src/esp32p4_gpio.zig @@ -0,0 +1,576 @@ +//! ESP32-P4 JP1 GPIO data and its freestanding filesystem. +const std = @import("std"); + +// JP1: JC-ESP32P4-M3-DEV schematic, sheet 2 "Expand IO": +// 01-esp32p4-m3/docs/schematics/2_EXPAND_IO&BAT.png +pub const Header = struct { + pub const Pad = union(enum) { + none, + gpio: u8, + // C6 and power nets are not P4 GPIOs; the schematic gives no GPIO numbers for ES_I2C. + net: []const u8, + + fn label(comptime p: Pad) []const u8 { + return switch (p) { + .none => "--", + .gpio => |n| std.fmt.comptimePrint("GPIO {d}", .{n}), + .net => |s| s, + }; + } + }; + + pub const Row = struct { left: Pad, right: Pad }; + + pub const rows = [13]Row{ + .{ .left = .{ .net = "3V3" }, .right = .{ .net = "5V" } }, + .{ .left = .{ .net = "3V3" }, .right = .{ .net = "5V" } }, + .{ .left = .{ .net = "GND" }, .right = .{ .net = "GND" } }, + .{ .left = .{ .gpio = 1 }, .right = .none }, + .{ .left = .{ .gpio = 2 }, .right = .{ .gpio = 47 } }, + .{ .left = .{ .gpio = 3 }, .right = .{ .gpio = 46 } }, + .{ .left = .{ .gpio = 4 }, .right = .{ .gpio = 45 } }, + .{ .left = .{ .gpio = 5 }, .right = .{ .net = "GND" } }, + .{ .left = .{ .gpio = 20 }, .right = .{ .net = "3V3" } }, + .{ .left = .{ .gpio = 32 }, .right = .{ .net = "C6_U0RXD" } }, + .{ .left = .{ .gpio = 33 }, .right = .{ .net = "C6_U0TXD" } }, + .{ .left = .{ .net = "ES_I2C_SDA" }, .right = .{ .net = "C6_IO9" } }, + .{ .left = .{ .net = "ES_I2C_SCL" }, .right = .{ .net = "C6_CHIP_PU" } }, + }; + + const row_format = "{s:>10} | {d:>2} | {d:>2} | {s}\n"; + + const border = " +---------+\n"; + + pub const text = rendered: { + var out: []const u8 = + \\JP1 header - 26 pins, pin 1 top left. + \\Every number here is DECIMAL. + \\ + \\ + ; + out = out ++ border; + for (rows, 0..) |row, i| out = out ++ std.fmt.comptimePrint( + row_format, + .{ row.left.label(), 2 * i + 1, 2 * i + 2, row.right.label() }, + ); + break :rendered out ++ border ++ + \\ + \\Gpio flips one: 0->1 or 1->0. + \\ + ; + }; + + pub const gpio_pins = pins: { + var found: [2 * rows.len]u8 = undefined; + var n: usize = 0; + for (rows) |row| for ([2]Pad{ row.left, row.right }) |p| switch (p) { + .gpio => |g| { + found[n] = g; + n += 1; + }, + else => {}, + }; + std.mem.sort(u8, found[0..n], {}, std.sort.asc(u8)); + break :pins found[0..n].*; + }; + + test "the rendered header is the drawing the console has always printed" { + try std.testing.expectEqualStrings( + \\JP1 header - 26 pins, pin 1 top left. + \\Every number here is DECIMAL. + \\ + \\ +---------+ + \\ 3V3 | 1 | 2 | 5V + \\ 3V3 | 3 | 4 | 5V + \\ GND | 5 | 6 | GND + \\ GPIO 1 | 7 | 8 | -- + \\ GPIO 2 | 9 | 10 | GPIO 47 + \\ GPIO 3 | 11 | 12 | GPIO 46 + \\ GPIO 4 | 13 | 14 | GPIO 45 + \\ GPIO 5 | 15 | 16 | GND + \\ GPIO 20 | 17 | 18 | 3V3 + \\ GPIO 32 | 19 | 20 | C6_U0RXD + \\ GPIO 33 | 21 | 22 | C6_U0TXD + \\ES_I2C_SDA | 23 | 24 | C6_IO9 + \\ES_I2C_SCL | 25 | 26 | C6_CHIP_PU + \\ +---------+ + \\ + \\Gpio flips one: 0->1 or 1->0. + \\ + , text); + } + + test "the header's own GPIOs, and only those" { + try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4, 5, 20, 32, 33, 45, 46, 47 }, &gpio_pins); + try std.testing.expectEqual(Pad.none, rows[3].right); + try std.testing.expectEqualStrings("C6_IO9", rows[11].right.net); + } +}; + +pub const E = struct { + pub const NOENT: u16 = 2; + pub const NOTDIR: u16 = 20; + pub const INVAL: u16 = 22; +}; + +pub const root: u64 = 1; +const gpio_node: u64 = 16; +const pinout_node: u64 = 17; + +const Entry = struct { + node: u64, + parent: u64, + name: []const u8, + kind: enum { directory, pinout, value }, + pin: u8 = 0, +}; + +const entries = table: { + var result: [3 + 2 * Header.gpio_pins.len]Entry = undefined; + result[0] = .{ .node = root, .parent = root, .name = "/", .kind = .directory }; + result[1] = .{ .node = gpio_node, .parent = root, .name = "gpio", .kind = .directory }; + result[2] = .{ .node = pinout_node, .parent = gpio_node, .name = "pinout", .kind = .pinout }; + for (Header.gpio_pins, 0..) |pin, i| { + const directory = 18 + i; + result[3 + 2 * i] = .{ + .node = directory, + .parent = gpio_node, + .name = std.fmt.comptimePrint("{d}", .{pin}), + .kind = .directory, + }; + result[4 + 2 * i] = .{ + .node = (2 + i) * 16 + 1, + .parent = directory, + .name = "value", + .kind = .value, + .pin = pin, + }; + } + break :table result; +}; + +const dirent_fixed = 10; +const out_capacity = size: { + var result: usize = 2; + for (entries) |directory| { + if (directory.kind != .directory) continue; + var bytes: usize = 0; + for (entries) |entry| { + if (entry.parent == directory.node and entry.node != directory.node) + bytes += dirent_fixed + entry.name.len; + } + result = @max(result, bytes); + } + break :size result; +}; + +pub fn Fs(comptime Pads: type) type { + return struct { + const Self = @This(); + pub const name_capacity = 28; + + pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir }; + + pub const Status = enum(u8) { ok, again, err }; + + pub const Req = struct { + tag: u64, + op: Op, + node: u64, + handle: u32 = 0, + off: u64 = 0, + size: u32 = 0, + data: []const u8 = &.{}, + truncate: bool = false, + }; + + pub const Reply = struct { + tag: u64, + status: Status = .ok, + errno: u16 = 0, + attr: Attr = .{}, + handle: u32 = 0, + written: u32 = 0, + + pub const Attr = struct { + name: []const u8 = "", + node: u64 = 0, + dir: bool = false, + size: u64 = 0, + mode: u16 = 0o600, + }; + + pub fn fail(tag: u64, e: u16) Reply { + return .{ .tag = tag, .status = .err, .errno = e }; + } + }; + + // Reply bytes are borrowed until the next handle call. + pub const Answer = struct { reply: Reply, bytes: []const u8 = "" }; + + out: [out_capacity]u8 = undefined, + + fn find(node: u64) ?*const Entry { + for (&entries) |*entry| if (entry.node == node) return entry; + return null; + } + + fn attributes(entry: *const Entry) Reply.Attr { + return .{ + .name = entry.name, + .node = entry.node, + .dir = entry.kind == .directory, + .mode = switch (entry.kind) { + .directory => 0o500, + .pinout => 0o400, + .value => 0o600, + }, + .size = switch (entry.kind) { + .directory => 0, + .pinout => Header.text.len, + .value => 2, + }, + }; + } + + pub fn handle(fs: *Self, req: Req) Answer { + const entry = find(req.node) orelse return .{ .reply = .fail(req.tag, E.NOENT) }; + switch (req.op) { + .lookup => { + if (entry.kind != .directory) return .{ .reply = .fail(req.tag, E.NOTDIR) }; + if (std.mem.eql(u8, req.data, "..")) + return .{ .reply = .{ .tag = req.tag, .attr = attributes(find(entry.parent).?) } }; + for (&entries) |*child| { + if (child.parent != req.node or child.node == req.node) continue; + if (!std.mem.eql(u8, child.name, req.data)) continue; + return .{ .reply = .{ .tag = req.tag, .attr = attributes(child) } }; + } + return .{ .reply = .fail(req.tag, E.NOENT) }; + }, + .getattr => return .{ .reply = .{ .tag = req.tag, .attr = attributes(entry) } }, + .setattr => { + // GPIO register views have no storage to truncate. + if (entry.kind == .directory) return .{ .reply = .fail(req.tag, E.INVAL) }; + return .{ .reply = .{ .tag = req.tag, .attr = attributes(entry) } }; + }, + .open => return .{ .reply = .{ .tag = req.tag, .handle = 1 } }, + .release => return .{ .reply = .{ .tag = req.tag } }, + .read => { + const bytes = switch (entry.kind) { + .directory => return .{ .reply = .fail(req.tag, E.INVAL) }, + .pinout => Header.text, + .value => value: { + fs.out[0] = '0' + @as(u8, Pads.level(entry.pin)); + fs.out[1] = '\n'; + break :value fs.out[0..2]; + }, + }; + const off: usize = @intCast(@min(req.off, bytes.len)); + return .{ .reply = .{ .tag = req.tag }, .bytes = bytes[off..][0..@min(bytes.len - off, req.size)] }; + }, + .write => { + if (entry.kind != .value or req.off != 0) return .{ .reply = .fail(req.tag, E.INVAL) }; + const value = std.mem.trimEnd(u8, req.data, "\r\n"); + if (value.len != 1 or (value[0] != '0' and value[0] != '1')) + return .{ .reply = .fail(req.tag, E.INVAL) }; + Pads.drive(entry.pin, @intCast(value[0] - '0')); + return .{ .reply = .{ .tag = req.tag, .written = @intCast(req.data.len) } }; + }, + .readdir => { + if (entry.kind != .directory) return .{ .reply = .fail(req.tag, E.NOTDIR) }; + var len: usize = 0; + var skip = req.off; + for (entries) |child| { + if (child.parent != req.node or child.node == req.node) continue; + if (skip != 0) { + skip -= 1; + continue; + } + std.mem.writeInt(u64, fs.out[len..][0..8], child.node, .little); + fs.out[len + 8] = @intFromBool(child.kind == .directory); + fs.out[len + 9] = @intCast(child.name.len); + @memcpy(fs.out[len + dirent_fixed ..][0..child.name.len], child.name); + len += dirent_fixed + child.name.len; + } + return .{ .reply = .{ .tag = req.tag }, .bytes = fs.out[0..len] }; + }, + } + } + }; +} + +const testing = std.testing; + +const StubPads = struct { + var driven: [64]u1 = @splat(0); + var log: [16]Call = undefined; + var log_len: usize = 0; + + const Call = struct { pin: u8, level: u1 }; + + fn reset() void { + driven = @splat(0); + log_len = 0; + } + + fn level(pin: u8) u1 { + return driven[pin]; + } + + fn drive(pin: u8, want: u1) void { + driven[pin] = want; + log[log_len] = .{ .pin = pin, .level = want }; + log_len += 1; + } +}; + +const Board = Fs(StubPads); + +fn walk(t: *Board, path: []const []const u8) !Board.Reply.Attr { + var at: u64 = root; + var attr: Board.Reply.Attr = .{ .node = root, .dir = true, .mode = 0o500 }; + for (path) |name| { + const a = t.handle(.{ .tag = 1, .op = .lookup, .node = at, .data = name }); + if (a.reply.status == .err) return switch (a.reply.errno) { + E.NOENT => error.NoEntry, + E.NOTDIR => error.NotDirectory, + else => error.Refused, + }; + attr = a.reply.attr; + at = attr.node; + } + return attr; +} + +fn readAll(t: *Board, node: u64) !Board.Answer { + const open = t.handle(.{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Board.Status.ok, open.reply.status); + return t.handle(.{ .tag = 2, .op = .read, .node = node, .handle = open.reply.handle, .size = 65535 }); +} + +test "GPIO: the generated tree has exactly the header's pins, and nothing else" { + var t: Board = .{}; + + try testing.expect((try walk(&t, &.{"gpio"})).dir); + try testing.expect(!(try walk(&t, &.{ "gpio", "pinout" })).dir); + + for (Header.gpio_pins, 0..) |pin, i| { + var name: [4]u8 = undefined; + const dir = try std.fmt.bufPrint(&name, "{d}", .{pin}); + const directory = try walk(&t, &.{ "gpio", dir }); + try testing.expect(directory.dir); + try testing.expectEqual(@as(u64, 18 + i), directory.node); + const value = try walk(&t, &.{ "gpio", dir, "value" }); + try testing.expect(!value.dir); + try testing.expectEqual(@as(u64, (2 + i) * 16 + 1), value.node); + try testing.expectEqual(@as(u16, 0o600), value.mode); + } + + try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "6" })); + try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "21" })); + try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "20", "level" })); + try testing.expectError(error.NoEntry, walk(&t, &.{"mem"})); +} + +test "GPIO: a read of gpio/pinout is the bytes the Gpio word draws" { + var t: Board = .{}; + const at = try walk(&t, &.{ "gpio", "pinout" }); + const a = try readAll(&t, at.node); + try testing.expectEqualStrings(Header.text, a.bytes); + try testing.expectEqual(Header.text.len, at.size); + try testing.expectEqual(@as(u16, 0o400), at.mode); + const w = t.handle(.{ .tag = 3, .op = .write, .node = at.node, .data = "x" }); + try testing.expectEqual(E.INVAL, w.reply.errno); +} + +test "GPIO: writing 1 then 0 drives the pad twice, through the seam" { + StubPads.reset(); + var t: Board = .{}; + const at = try walk(&t, &.{ "gpio", "20", "value" }); + + const before = try readAll(&t, at.node); + try testing.expectEqualStrings("0\n", before.bytes); + + const one = t.handle(.{ .tag = 4, .op = .write, .node = at.node, .data = "1" }); + try testing.expectEqual(Board.Status.ok, one.reply.status); + try testing.expectEqual(@as(u32, 1), one.reply.written); + try testing.expectEqualStrings("1\n", (try readAll(&t, at.node)).bytes); + + const zero = t.handle(.{ .tag = 5, .op = .write, .node = at.node, .data = "0\n" }); + try testing.expectEqual(@as(u32, 2), zero.reply.written); + try testing.expectEqualStrings("0\n", (try readAll(&t, at.node)).bytes); + + try testing.expectEqual(@as(usize, 2), StubPads.log_len); + try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 1 }, StubPads.log[0]); + try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 0 }, StubPads.log[1]); +} + +test "GPIO: a pad takes 0 and 1 and refuses everything else, without touching the pads" { + StubPads.reset(); + var t: Board = .{}; + const at = try walk(&t, &.{ "gpio", "45", "value" }); + + for ([_][]const u8{ "2", "", "01", "x", "true", "high", "\n", "1 ", " 1", "10" }) |bad| { + const a = t.handle(.{ .tag = 6, .op = .write, .node = at.node, .data = bad }); + try testing.expectEqual(Board.Status.err, a.reply.status); + try testing.expectEqual(E.INVAL, a.reply.errno); + } + try testing.expectEqual(@as(usize, 0), StubPads.log_len); + + const off = t.handle(.{ .tag = 7, .op = .write, .node = at.node, .off = 1, .data = "1" }); + try testing.expectEqual(E.INVAL, off.reply.errno); + try testing.expectEqual(@as(usize, 0), StubPads.log_len); +} + +test "GPIO: node parents are explicit, not derived from node bits" { + for (&entries) |*e| { + const serial = e.node >> 4; + const file = e.node & 0xF; + const derived: u64 = if (e.node == root or serial == 0 or file == 0) root else serial << 4; + if (derived == e.parent) continue; + try testing.expectEqualStrings("value", e.name); + var t: Board = .{}; + const a = t.handle(.{ .tag = 8, .op = .getattr, .node = derived }); + try testing.expectEqual(E.NOENT, a.reply.errno); + } +} + +test "GPIO: parent lookup follows every generated directory" { + var t: Board = .{}; + for (&entries) |*entry| { + if (entry.kind != .directory) continue; + const answer = t.handle(.{ .tag = 1, .op = .lookup, .node = entry.node, .data = ".." }); + try testing.expectEqual(Board.Status.ok, answer.reply.status); + try testing.expectEqual(entry.parent, answer.reply.attr.node); + try testing.expect(answer.reply.attr.dir); + } +} + +test "GPIO: a directory read lists what the table generated, in table order" { + var t: Board = .{}; + + try testing.expectEqualStrings("gpio", (try names(&t, root, 0))[0]); + try testing.expectEqual(@as(usize, 1), (try names(&t, root, 0)).len); + + const gpio = (try walk(&t, &.{"gpio"})).node; + const listing = try names(&t, gpio, 0); + try testing.expectEqual(Header.gpio_pins.len + 1, listing.len); + try testing.expectEqualStrings("pinout", listing[0]); + for (Header.gpio_pins, 0..) |pin, i| { + var buf: [4]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "{d}", .{pin}), listing[i + 1]); + } + + const rest = try names(&t, gpio, 5); + try testing.expectEqual(Header.gpio_pins.len + 1 - 5, rest.len); + try testing.expectEqualStrings("5", rest[0]); +} + +var name_slots: [32][]const u8 = undefined; +fn names(t: *Board, node: u64, skip: u64) ![][]const u8 { + const a = t.handle(.{ .tag = 9, .op = .readdir, .node = node, .off = skip, .size = 65535 }); + try testing.expectEqual(Board.Status.ok, a.reply.status); + var n: usize = 0; + var i: usize = 0; + while (i < a.bytes.len) { + const len = a.bytes[i + 9]; + name_slots[n] = a.bytes[i + 10 ..][0..len]; + n += 1; + i += 10 + len; + } + return name_slots[0..n]; +} + +test "GPIO: the whole tree costs one buffer, and the table says how big" { + try testing.expectEqual(@as(usize, 143), out_capacity); + try testing.expectEqual(@as(usize, 143), @sizeOf(Board)); + try testing.expectEqual(@as(usize, 28), Board.name_capacity); + try testing.expect(Header.text.len > out_capacity); +} + +test "GPIO: stat and truncation preserve the register value and reads respect offsets" { + StubPads.reset(); + var fs: Board = .{}; + const value = try walk(&fs, &.{ "gpio", "20", "value" }); + for (0..2) |_| { + const written = fs.handle(.{ .tag = 1, .op = .write, .node = value.node, .data = "1\r\n" }); + try testing.expectEqual(@as(u32, 3), written.reply.written); + } + try testing.expectEqual(@as(usize, 2), StubPads.log_len); + const stat = fs.handle(.{ .tag = 2, .op = .getattr, .node = value.node }); + try testing.expectEqual(@as(u64, 2), stat.reply.attr.size); + const truncated = fs.handle(.{ .tag = 3, .op = .setattr, .node = value.node, .truncate = true }); + try testing.expectEqual(Board.Status.ok, truncated.reply.status); + try testing.expectEqual(@as(u1, 1), StubPads.driven[20]); + const part = fs.handle(.{ .tag = 4, .op = .read, .node = value.node, .off = 1, .size = 1 }); + try testing.expectEqualStrings("\n", part.bytes); + const eof = fs.handle(.{ .tag = 5, .op = .read, .node = value.node, .off = 2, .size = 1 }); + try testing.expectEqual(@as(usize, 0), eof.bytes.len); + const pinout = try walk(&fs, &.{ "gpio", "pinout" }); + const text = fs.handle(.{ .tag = 6, .op = .read, .node = pinout.node, .off = 10, .size = 12 }); + try testing.expectEqualStrings(Header.text[10..22], text.bytes); +} + +const ninep = @import("9p.zig"); + +test "GPIO: a real 9P client reads a pin's value off this tree" { + StubPads.reset(); + const Server = ninep.Server(Board, ninep.board_fids); + var in: [1024]u8 = undefined; + var out: [2048]u8 = undefined; + var fsys: Board = .{}; + var srv = Server.init(.{ .in = &in, .out = &out, .root = root }); + + var scratch: [256]u8 = undefined; + const send = struct { + fn call(s: *Server, f: *Board, buf: []u8, tag: u16, msg: ninep.Msg) !void { + const bytes = try ninep.encode(msg, tag, buf); + try testing.expectEqual(bytes.len, s.push(bytes)); + while (s.retry()) |req| { + const a = f.handle(req); + s.reply(&a.reply, a.bytes); + } + while (s.next()) |req| { + const a = f.handle(req); + s.reply(&a.reply, a.bytes); + } + } + }.call; + const reap = struct { + fn call(s: *Server) !ninep.Decoded { + const queued = s.output(); + const len = ninep.frameLen(queued) orelse return error.NoReply; + const got = try ninep.decode(queued[0..len]); + s.wrote(len); + return got; + } + }.call; + + try send(&srv, &fsys, &scratch, ninep.notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }); + const v = try reap(&srv); + try testing.expectEqual(@as(u32, 1024), v.msg.rversion.msize); + + try send(&srv, &fsys, &scratch, 1, .{ .tattach = .{ .fid = 0, .afid = ninep.nofid, .uname = "goblin", .aname = "" } }); + try testing.expectEqual(root, (try reap(&srv)).msg.rattach.qid.path); + + var wname: [ninep.max_welem][]const u8 = @splat(""); + wname[0] = "gpio"; + wname[1] = "20"; + wname[2] = "value"; + try send(&srv, &fsys, &scratch, 2, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 3, .wname = wname } }); + try testing.expectEqual(@as(u16, 3), (try reap(&srv)).msg.rwalk.nwqid); + + try send(&srv, &fsys, &scratch, 3, .{ .topen = .{ .fid = 1, .mode = ninep.ordwr } }); + _ = try reap(&srv); + + try send(&srv, &fsys, &scratch, 4, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "1\n" } }); + try testing.expectEqual(@as(u32, 2), (try reap(&srv)).msg.rwrite.count); + try testing.expectEqual(@as(u1, 1), StubPads.driven[20]); + + try send(&srv, &fsys, &scratch, 5, .{ .tread = .{ .fid = 1, .offset = 0, .count = 512 } }); + try testing.expectEqualStrings("1\n", (try reap(&srv)).msg.rread.data); + + try send(&srv, &fsys, &scratch, 6, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "on" } }); + try testing.expectEqualStrings(ninep.errString(E.INVAL), (try reap(&srv)).msg.rerror.ename); + try testing.expectEqual(@as(usize, 1), StubPads.log_len); +} diff --git a/src/file_pane.zig b/src/file_pane.zig deleted file mode 100644 index 9b828030..00000000 --- a/src/file_pane.zig +++ /dev/null @@ -1,1059 +0,0 @@ -//! File panes: everything a Pane does BECAUSE it has `file: ?State` set — the -//! disk read, the content swap undo/redo commits through, the tree-sitter -//! highlight window, and the two render passes only a file has (the line -//! number gutter and the syntax recolor). The rest of a file pane's behaviour -//! is the pane machinery in pardes.zig, which does not care what kind it is. -const std = @import("std"); -const vaxis = @import("vaxis"); -const pardes = @import("pardes.zig"); -const config = @import("config.zig"); -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const modal = @import("modal.zig"); -const look = @import("look.zig"); -const output_pane = @import("output_pane.zig"); -const syntax = @import("syntax.zig"); -const tracy = @import("tracy.zig"); -const term_pane = @import("term_pane.zig"); -const dump = @import("dump.zig"); -const limits = @import("limits.zig"); - -const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; - -/// Content and primary selection at one file edit boundary. Keeping only the -/// primary avoids putting pardes.MAX_SELS ranges in every history entry. -pub const Snapshot = struct { - content: []u8, - cur_row: i32, - cur_col: i32, - vsel: pardes.CharSel, -}; - -/// A file pane's backing: owned content, its derived caches, and undo history. -pub const State = struct { - path: []u8, - content: []u8, - /// Monotonic content identity for asynchronous edits. Every content swap - /// goes through setContent, which bumps this; a pipe completion accepted - /// against another revision would overwrite intervening work. - revision: u32 = 0, - /// Revision last known to match disk, after Save or an external reload. - /// Equal means the screen matches disk. - saved_revision: u32 = 0, - /// Non-null for a generated output buffer rather than an on-disk file. - output: ?output_pane.Output = null, - scroll: usize = 0, - /// `line_starts[i]` is line i's byte offset. Empty means not built yet. - line_starts: []usize = &.{}, - /// One tree_sitter_gpa-owned syntax.Syn byte per highlighted source byte. - highlights: []u8 = &.{}, - highlight_start: usize = 0, - syntax_dirty: bool = true, - undo: [limits.undo_max]Snapshot = undefined, - undo_len: usize = 0, - redo: [limits.undo_max]Snapshot = undefined, - redo_len: usize = 0, -}; - -/// Serialize file-owned bytes and identity; output origin vocabulary is -/// supplied by output_pane at the core dispatch edge. -pub fn dumpPane( - arena: std.mem.Allocator, - pane: *const Pane, - file: *const State, - tag: []const u8, - body: []const u8, - scroll: usize, - origin: []const u8, - origin_arg: []const u8, -) !dump.Pane { - return .{ - .kind = .file, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .file = .{ - .path = file.path, - .content = file.content, - .content_b64 = try dump.encodeBytes(arena, file.content), - .origin = origin, - .origin_arg = origin_arg, - }, - }; -} - -pub fn graphemeDisplayWidth(grapheme: []const u8) usize { - if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; - // A one-byte printable ASCII grapheme is one cell, and saying so here rather than asking - // `gwidth` costs a comparison instead of a Unicode table walk. `gwidth` was 6.9% of a profiled - // keystroke at the P4's geometry, essentially all of it answering this question about `y`. - // Bounded to 0x20..0x7e on purpose: DEL and the C0 controls are not one printable cell, and - // `gwidth` is still the authority on them. - if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1; - return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); -} - -pub fn byteDisplayWidth(byte: u8) usize { - return if (byte == '\t') config.tab_width else 1; -} - -pub fn displayWidth(text: []const u8) usize { - var width: usize = 0; - var at: usize = 0; - while (at < text.len) { - const end = modal.nextGrapheme(text, at); - width +|= graphemeDisplayWidth(text[at..end]); - at = end; - } - return width; -} - -/// Source byte at a zero-based display column. Every cell occupied by a tab -/// maps back to that one tab byte. -pub fn byteAtDisplay(text: []const u8, display_col: usize) usize { - var col: usize = 0; - var at: usize = 0; - while (at < text.len) { - const end = modal.nextGrapheme(text, at); - const next = col +| graphemeDisplayWidth(text[at..end]); - if (display_col < next) return at; - col = next; - at = end; - } - return text.len; -} - -/// File cursor columns may live past EOL. Tabs expand before that boundary; -/// every virtual column after it remains one screen cell. -pub fn rawDisplayCol(line_text: []const u8, raw_col: usize) usize { - const bounded = modal.graphemeStart(line_text, @min(raw_col, line_text.len)); - return displayWidth(line_text[0..bounded]) +| (raw_col -| line_text.len); -} - -pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize { - const width = displayWidth(line_text); - if (display_col > width) return line_text.len +| (display_col - width); - return byteAtDisplay(line_text, display_col); -} - -pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: usize) usize { - if (from_raw >= line_text.len) return from_raw +| display_col; - const from = modal.graphemeStart(line_text, from_raw); - return from +| rawAtDisplay(line_text[from..], display_col); -} - -pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 { - const from_display = rawDisplayCol(line_text, from_raw); - const to_display = rawDisplayCol(line_text, to_raw); - if (to_display >= from_display) return @intCast(to_display - from_display); - return -@as(i32, @intCast(from_display - to_display)); -} - -pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 { - const start = lineDisplayOffset(line_text, from_raw, at_raw); - if (at_raw >= line_text.len) return start; - const at = modal.graphemeStart(line_text, at_raw); - const end = modal.nextGrapheme(line_text, at); - return start + @as(i32, @intCast(graphemeDisplayWidth(line_text[at..end]))) - 1; -} - -pub fn sourceLine(pane: *const Pane, row: i32) []const u8 { - const f = pane.file orelse return ""; - if (row < 0) return ""; - return modal.lineSlice(f.content, @intCast(row)); -} - -pub fn displayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 { - const line_text = sourceLine(pane, row); - const from: usize = @intCast(@max(0, from_raw)); - const to: usize = @intCast(@max(0, to_raw)); - return lineDisplayOffset(line_text, from, to); -} - -pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 { - const line_text = sourceLine(pane, row); - return lineDisplayEndOffset(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, at_raw))); -} - -pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 { - const line_text = sourceLine(pane, row); - return @intCast(byteAtDisplayFrom(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, display_col)))); -} - -/// Convert between rendered cells and UTF-8 byte columns. Tag rows always -/// need grapheme conversion; file body rows additionally skip PREFIX_W. -pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize { - if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col); - if (pane.file == null) return rawAtDisplay(line_text, display_col); - const prefix = @min(@as(usize, config.PREFIX_W), line_text.len); - if (display_col <= prefix) return display_col; - return prefix +| rawAtDisplay(line_text[prefix..], display_col - prefix); -} - -pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize { - if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col); - if (pane.file == null) return rawDisplayCol(line_text, byte_col); - const prefix = @min(@as(usize, config.PREFIX_W), line_text.len); - if (byte_col <= prefix) return byte_col; - return prefix +| rawDisplayCol(line_text[prefix..], byte_col - prefix); -} - -test "display columns map complete Unicode graphemes" { - const text = "é界e\u{301}x"; - try std.testing.expectEqual(@as(usize, 5), displayWidth(text)); - try std.testing.expectEqual(@as(usize, 0), byteAtDisplay(text, 0)); - try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 1)); - try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 2)); - try std.testing.expectEqual(@as(usize, 5), byteAtDisplay(text, 3)); - try std.testing.expectEqual(@as(usize, 8), byteAtDisplay(text, 4)); - try std.testing.expectEqual(text.len, byteAtDisplay(text, 5)); - try std.testing.expectEqual(@as(usize, 3), rawDisplayCol(text, 5)); - try std.testing.expectEqual(@as(usize, 5), rawAtDisplay(text, 3)); - try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("👩\u{200d}🚀")); -} - -test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" { - // The arm claims a one-byte printable ASCII grapheme is one cell without asking `gwidth`. That - // is only worth having if the two never disagree, so ask both for every byte the arm can see - - // including \t, \r, the rest of the C0 controls and DEL, which the range test excludes and - // which must therefore still come back from `gwidth` (or, for the tab, from the config). - const ref = struct { - fn width(grapheme: []const u8) usize { - if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; - return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); - } - }.width; - - var one: [1]u8 = undefined; - var b: u8 = 0; - while (b < 0x80) : (b += 1) { - one[0] = b; - try std.testing.expectEqual(ref(one[0..1]), graphemeDisplayWidth(one[0..1])); - } - // Multi-byte clusters never reach the arm (len != 1), so they pin that it does not widen its - // claim: a combining sequence and a ZWJ emoji are one and two cells, a CJK glyph is two, and - // an invalid byte is the one cell `gwidth` reports for U+FFFD-shaped input. - for ([_][]const u8{ - "e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}", - "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", - }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g)); -} - -test "the ASCII run in fitEnd survives an exhaustive byte sweep" { - // The case list in the test above is hand-picked; this one is not. Every byte 0x00..0x7f is - // placed next to every neighbour that can change the answer - a combining mark, a ZWJ - // sequence, a spacing mark, a variation selector, a wide glyph, and a bad start byte, a - // truncated tail and a bad continuation - and every break column is compared against the - // grapheme walk. An off-by-one column here moves text between wrapped rows, so equality is - // exact, not approximate. - const reference = struct { - fn fitEnd(text: []const u8, start: usize, width: usize) usize { - var end = start; - var used: usize = 0; - while (end < text.len) { - const next_end = modal.nextGrapheme(text, end); - const next_used = used +| graphemeDisplayWidth(text[end..next_end]); - if (next_used > width) return if (end == start) next_end else end; - used = next_used; - end = next_end; - } - return end; - } - }.fitEnd; - - const neighbours = [_][]const u8{ - "", "z", "\u{301}", "\u{200d}\u{1f680}", - "\u{903}", "\u{fe0f}", "\u{4e16}", "\u{1f642}", - "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", - }; - var buf: [16]u8 = undefined; - // The same pair again behind an ASCII prefix, so a break can land exactly at the run boundary - // as well as before it and inside the multi-byte cluster that follows it. - var prefixed: [18]u8 = undefined; - prefixed[0] = 'a'; - prefixed[1] = 'b'; - var b: u8 = 0; - while (b < 0x80) : (b += 1) { - buf[0] = b; - for (neighbours) |tail| { - @memcpy(buf[1..][0..tail.len], tail); - const pair = buf[0 .. 1 + tail.len]; - @memcpy(prefixed[2..][0..pair.len], pair); - for ([_][]const u8{ pair, prefixed[0 .. 2 + pair.len] }) |text| { - var width: usize = 0; - while (width <= text.len + 3) : (width += 1) { - var start: usize = 0; - while (start <= text.len) : (start += 1) { - std.testing.expectEqual( - reference(text, start, width), - fitEnd(text, start, width), - ) catch |e| { - std.debug.print("fitEnd({any}, {d}, {d})\n", .{ text, start, width }); - return e; - }; - } - } - } - } - } -} - -fn fitEnd(text: []const u8, start: usize, width: usize) usize { - var end = start; - var used: usize = 0; - // ASCII RUN. This is the loop a wrapped line pays per character, and it asks two function calls - // to learn what arithmetic knows: `modal.nextGrapheme` and `graphemeDisplayWidth` each answer - // ASCII in constant time, but they answer once per character and a 640-column line asks 640 - // times. A printable ASCII byte whose successor is also ASCII is a complete grapheme cluster one - // column wide - the same guard, and the same reason, as `Surface.print` and `modal.nextGrapheme` - // - so consume the run here and leave anything else to the general path below. - while (used < width and end < text.len) { - const b = text[end]; - if (b < 0x20 or b >= 0x7f) break; - if (end + 1 < text.len and text[end + 1] >= 0x80) break; - used += 1; - end += 1; - } - while (end < text.len) { - const next_end = modal.nextGrapheme(text, end); - const next_used = used +| graphemeDisplayWidth(text[end..next_end]); - if (next_used > width) return if (end == start) next_end else end; - used = next_used; - end = next_end; - } - return end; -} - -test "the ASCII run in fitEnd cuts where the grapheme walk would" { - // fitEnd decides where a wrapped row BREAKS, so a fast path that is off by one column moves - // text on screen. This pins it to the general walk it replaces rather than to a transcribed - // expectation: same inputs, both routes, every width from 0 past the end of the string. - const reference = struct { - fn fitEnd(text: []const u8, start: usize, width: usize) usize { - var end = start; - var used: usize = 0; - while (end < text.len) { - const next_end = modal.nextGrapheme(text, end); - const next_used = used +| graphemeDisplayWidth(text[end..next_end]); - if (next_used > width) return if (end == start) next_end else end; - used = next_used; - end = next_end; - } - return end; - } - }.fitEnd; - - const cases = [_][]const u8{ - "", - "hello world", - // the fast path must hand over at the first non-ASCII byte, mid-run - "abc\u{00e9}def", - // a wide glyph is two columns, so a width boundary can land inside it - "ab\u{4e16}\u{754c}cd", - // a cluster the fast path must not split - "a\u{0301}bc", - // tabs and controls are excluded from the fast path by the range test - "ab\tcd", - "ab\rcd", - // an ASCII byte followed by a continuation byte is NOT its own cluster - "e\u{0301}x", - "\u{1f1e6}\u{1f1e7}ok", - }; - for (cases) |text| { - var width: usize = 0; - while (width <= text.len + 3) : (width += 1) { - var start: usize = 0; - while (start <= text.len) : (start += 1) { - try std.testing.expectEqual( - reference(text, start, width), - fitEnd(text, start, width), - ); - } - } - } -} - -pub fn lineCount(content: []const u8) usize { - return std.mem.count(u8, content, "\n") + 1; -} - -/// THE LINE INDEX, built on demand: `line_starts[i]` is the byte offset where -/// line i begins and its length is the line count. Without it, every question -/// about lines is a scan from byte 0, and a file pane asks several of them per -/// keystroke — the scrollbar's total, the scroll clamp, the syntax window's -/// bounds, the body's first visible line. On a 300k-line file that was ~35% of -/// the whole frame, and it is what made a single `j` cost 25ms. -/// -/// INVALIDATION — the part that rots if nobody says it out loud. The index is -/// dropped in EXACTLY ONE PLACE: setContent, immediately below, which is the -/// funnel every content swap in the editor already goes through (typing, undo, -/// redo, a save's normalisation, an output buffer refilling itself). A State -/// built by a struct literal starts with an empty index, and empty reads as -/// "not built yet" — a real index always has at least one entry, because a -/// file always has at least one line. So there is one and only one way to make -/// this wrong: assign `f.content` without going through setContent. Don't. -/// -/// Fails only when the index could not be allocated. nlines and lineStart -/// swallow that and scan the old way, so OOM there is slow rather than wrong; -/// callers that need the whole table say `try` and drop the keystroke, which -/// is what they already did when their own arena ran out. -pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize { - if (f.line_starts.len > 0) return f.line_starts; - // Exact allocation: deinitPane frees `line_starts` itself, so the stored - // slice must span the complete allocation rather than spare capacity. - const starts = try gpa.alloc(usize, lineCount(f.content)); - starts[0] = 0; - var i: usize = 1; - var off: usize = 0; - while (std.mem.indexOfScalarPos(u8, f.content, off, '\n')) |nl| { - off = nl + 1; - starts[i] = off; - i += 1; - } - f.line_starts = starts; - return starts; -} - -/// line count, O(1) once the index is warm -pub fn nlines(gpa: std.mem.Allocator, f: *State) usize { - const idx = lineIndex(gpa, f) catch return lineCount(f.content); - return idx.len; -} - -/// byte offset of line `row`, or content.len past the end — modal -/// .lineStartOffset's contract exactly, without its walk -pub fn lineStart(gpa: std.mem.Allocator, f: *State, row: usize) usize { - const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row); - return if (row >= idx.len) f.content.len else idx[row]; -} - -pub fn cursorLines(arena: std.mem.Allocator, pane: *Pane, f: *State) ![]const []const u8 { - const index = try lineIndex(pane.gpa, f); - const lines = try arena.alloc([]const u8, index.len); - for (index, 0..) |start, i| { - const end = if (i + 1 < index.len) index[i + 1] - 1 else f.content.len; - lines[i] = f.content[start..end]; - } - return lines; -} - -/// Use the file's line index only when `text` is its complete live content. -/// Edit-buffer fragments and other temporary text retain modal's scan path. -fn contentIndex(pane: *Pane, text: []const u8) ?[]const usize { - const f = if (pane.file) |*file| file else return null; - if (text.ptr != f.content.ptr or text.len != f.content.len) return null; - return lineIndex(pane.gpa, f) catch null; -} - -pub fn textOffset(pane: *Pane, text: []const u8, cursor: modal.Cursor) usize { - const index = contentIndex(pane, text) orelse return modal.hxOff(text, cursor); - const row = @min(cursor.row, index.len - 1); - const start = index[row]; - const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; - return start + modal.graphemeStart(text[start..end], @min(cursor.col, end - start)); -} - -pub fn textLineStart(pane: *Pane, text: []const u8, row: usize) usize { - const index = contentIndex(pane, text) orelse return modal.lineStartOffset(text, row); - return if (row >= index.len) text.len else index[row]; -} - -pub fn textLineCount(pane: *Pane, text: []const u8) usize { - const index = contentIndex(pane, text) orelse return modal.hxLineCount(text); - return index.len; -} - -pub fn textPosition(pane: *Pane, text: []const u8, offset: usize) modal.Cursor { - const index = contentIndex(pane, text) orelse return modal.hxPos(text, offset); - const bounded = @min(offset, text.len); - const row = std.sort.upperBound(usize, index, bounded, struct { - fn cmp(key: usize, item: usize) std.math.Order { - return std.math.order(key, item); - } - }.cmp) - 1; - const start = index[row]; - const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; - return .{ .row = row, .col = modal.graphemeStart(text[start..end], @min(bounded - start, end - start)) }; -} - -pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { - const content = try look.readFile(p.gpa, path); - errdefer p.gpa.free(content); - const path_copy = try p.gpa.dupe(u8, path); - errdefer p.gpa.free(path_copy); - const pane = try p.newDocPane(id); - const total = lineCount(content); - const scroll: usize = if (line > 0 and line <= total) line - 1 else 0; - pane.file = .{ .path = path_copy, .content = content, .scroll = scroll }; - pane.cur_pinned = true; - pane.cur_row = @intCast(scroll); - // watches follow pane lifetime: this is the only place a real file is read - // off disk, and deinitPane is the only place one goes away - p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); - return pane; -} - -/// Rebuild a dumped file or output buffer. Byte ownership, output identity, -/// cursor projection, and file watching are all properties of this payload; -/// column registration and custom tag restoration remain core invariants. -pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane { - const saved = src.file.?; - const content: []u8 = if (saved.content_b64.len > 0) - try dump.decodeBytes(p.gpa, saved.content_b64) - else - try p.gpa.dupe(u8, saved.content); - errdefer p.gpa.free(content); - const path = try p.gpa.dupe(u8, saved.path); - errdefer p.gpa.free(path); - - const output: ?output_pane.Output = if (output_pane.fromWord(saved.origin)) |origin| blk: { - var value: output_pane.Output = .{ .from = origin }; - try output_pane.setArg(&value, saved.origin_arg); - break :blk value; - } else null; - - const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content, .output = output, .scroll = src.scroll }; - pane.cur_pinned = true; - pane.cur_row = @intCast(src.scroll); - pane.cols = @max(1, src.cols); - pane.rows = @max(1, src.rows); - // A restored file is watched exactly like one opened from disk. Its dump - // bytes may differ from disk; the first external write reconciles them and - // leaves the restored version one undo away. Output buffers have no file. - if (output == null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); - return pane; -} - -/// Release the complete file payload while the owning pane is still installed -/// (the slot is needed to identify a disappearing file watch). Common pane -/// overlays and the shared terminal stub remain the core's responsibility. -pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void { - if (file.output == null) for (p.panes, 0..) |slot, id| { - if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); - }; - p.gpa.free(file.path); - p.gpa.free(file.content); - if (file.line_starts.len > 0) p.gpa.free(file.line_starts); - if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights); - for (file.undo[0..file.undo_len]) |snap| p.gpa.free(snap.content); - for (file.redo[0..file.redo_len]) |snap| p.gpa.free(snap.content); -} - -/// TELL A SCRIPT WHAT CHANGED, when one is listening. -/// -/// acme reports edits from the two places that make them — `textinsert` and -/// `textdelete`, which already know their range — so a replacement arrives as -/// a `D` record and then an `I`. pardes has no such pair: every edit lands -/// here as a whole new buffer, so the range is recovered by DIFFING, and -/// `acmefs.noteReplace` owns both the diff and the D-then-I order. -/// -/// The cost is two vectorised scans of the content, and it is paid only while -/// a script holds an `event` file open (`p.fs.listeners`); the editor nobody -/// is scripting does one branch. The pane lookup is a walk of at most -/// MAX_PANES slots comparing the FILE pointer — a file pane's state is stored -/// inline in its pane, so that identifies the pane exactly. -fn reportEdit(p: *Pardes, f: *State, new: []const u8) void { - if (p.fs.listeners == 0) return; - const id = for (p.panes, 0..) |slot, i| { - const pane = slot orelse continue; - if (pane.file) |*state| if (state == f) break i; - } else return; - pardes.acmefs.noteReplace(p, id, false, f.content, new); -} - -/// The ONE content swap. Everything that edits a file pane lands here, which -/// is what lets the line index above have a single invalidation point — and -/// is why one diff HERE is every body edit a script can be told about. -pub fn setContent(p: *Pardes, f: *State, new: []u8) void { - reportEdit(p, f, new); - p.gpa.free(f.content); - f.content = new; - f.revision +%= 1; - if (f.line_starts.len > 0) p.gpa.free(f.line_starts); - f.line_starts = &.{}; - // the highlights go too, and not just because they are stale: their byte - // range is what refreshHighlights tests a scroll against, and a range - // measured on the OLD content would let it skip a re-parse it needs - if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); - f.highlights = &.{}; - f.highlight_start = 0; - f.syntax_dirty = true; -} - -/// undo/redo restores the selection recorded with the snapshot (helix keeps -/// selections in its history transactions), clamped: the content it was taken -/// against may be shorter than the one it is being restored onto. -pub fn restoreSnap(pane: *Pane, f: *State, snap: Snapshot) void { - const n = nlines(pane.gpa, f); - const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1); - const llen = modal.lineSlice(f.content, row).len; - pane.cur_row = @intCast(row); - pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); - pane.vsel = snap.vsel; - pane.msel.active = false; - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.ensureCursorVisible(); -} - -fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Snapshot) void { - if (len.* == slots.len) { - gpa.free(slots[0].content); - std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); - len.* -= 1; - } - slots[len.*] = snap; - len.* += 1; -} - -pub fn pushUndo(p: *Pardes, pane: *Pane) void { - const f = if (pane.file) |*file| file else return; - if (f.undo_len > 0 and std.mem.eql(u8, f.undo[f.undo_len - 1].content, f.content)) return; - const snap: Snapshot = .{ - .content = p.gpa.dupe(u8, f.content) catch return, - .cur_row = pane.cur_row, - .cur_col = pane.cur_col, - .vsel = pane.vsel, - }; - pushHistory(p.gpa, &f.undo, &f.undo_len, snap); - for (f.redo[0..f.redo_len]) |item| p.gpa.free(item.content); - f.redo_len = 0; -} - -pub fn undo(p: *Pardes, pane: *Pane) void { - const f = if (pane.file) |*file| file else return; - if (f.undo_len == 0) return; - const current: Snapshot = .{ - .content = p.gpa.dupe(u8, f.content) catch return, - .cur_row = pane.cur_row, - .cur_col = pane.cur_col, - .vsel = pane.vsel, - }; - pushHistory(p.gpa, &f.redo, &f.redo_len, current); - f.undo_len -= 1; - const previous = f.undo[f.undo_len]; - setContent(p, f, previous.content); - restoreSnap(pane, f, previous); -} - -pub fn redo(p: *Pardes, pane: *Pane) void { - const f = if (pane.file) |*file| file else return; - if (f.redo_len == 0) return; - const current: Snapshot = .{ - .content = p.gpa.dupe(u8, f.content) catch return, - .cur_row = pane.cur_row, - .cur_col = pane.cur_col, - .vsel = pane.vsel, - }; - pushHistory(p.gpa, &f.undo, &f.undo_len, current); - f.redo_len -= 1; - const next = f.redo[f.redo_len]; - setContent(p, f, next.content); - restoreSnap(pane, f, next); -} - -/// Commit an externally rewritten file onto the same undo history as typed -/// edits. Unsaved work remains one `u` away; there is no third merge state. -pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void { - const pane = p.panes[id] orelse return; - const f = if (pane.file) |*file| file else return; - if (std.mem.eql(u8, f.content, bytes)) return; - const new = p.gpa.dupe(u8, bytes) catch return; - pushUndo(p, pane); - setContent(p, f, new); - // These bytes came from the watched path, so the new on-screen revision - // is already saved. Undoing back to displaced local work bumps revision - // again and makes that restored edit dirty, as it should. - f.saved_revision = f.revision; - // restoreSnap only consumes cursor/selection from this synthetic snapshot. - restoreSnap(pane, f, .{ - .content = undefined, - .cur_row = pane.cur_row, - .cur_col = pane.cur_col, - .vsel = pane.vsel, - }); -} - -/// re-highlight the visible window of any file whose syntax went stale -/// (edit, scroll, load) — visible-range-first so big files stay snappy -pub fn refreshHighlights(p: *Pardes) void { - const tz = tracy.zone(@src(), "refreshHighlights"); - defer tz.end(); - for (p.panes) |slot| { - const pane = slot orelse continue; - if (pane.file == null) continue; - const f = &pane.file.?; - if (!f.syntax_dirty) continue; - if (!p.settings.colors) { - if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); - f.highlights = &.{}; - f.highlight_start = 0; - f.syntax_dirty = false; - continue; - } - // What the screen needs coloured right now. If the last parse still - // covers it, this scroll is free — and that is the whole point of the - // slack below. Highlights only ever survive while the CONTENT does: - // setContent throws them away, so these byte offsets cannot be stale. - const need_start = lineStart(p.gpa, f, f.scroll); - const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS)); - if (f.highlights.len > 0 and need_start >= f.highlight_start and - need_end <= f.highlight_start + f.highlights.len) - { - f.syntax_dirty = false; - continue; - } - // How much MORE than the screen to parse. An edit or a fresh open has - // no previous parse to widen (setContent throws the highlights away), - // and slack would be pure loss there: every keystroke of typing pays - // this parse and none of it is amortised over anything. A SCROLL that - // outran the covered range is the opposite case — take a screenful - // above and below and the next ~pane.rows rows cost nothing at all. - // Scrolling used to re-parse the visible window on every single row, - // which on a file with 8000-column lines is a third of a megabyte per - // keypress. Three screens once beats one screen forty times. - // - // The slack also means those lines are parsed with real context above - // them, so a construct that opens off-screen now colours correctly — - // a fidelity gain, and one that cannot reach a file shown from the top - // (scroll 0 clamps the window to exactly what it always was). - const slack: usize = if (f.highlights.len == 0) 0 else pane.rows; - // A RESULTS BUFFER IS COLOURED WHOLE, ONCE. Its rows are independent — - // each is parsed in isolation against its own grammar — so a window - // pass buys no fidelity, only amortisation, and pays for it with a - // burst on every scroll that outran the covered range: a fresh parser, - // a fresh query cursor and a tree per row, plus the first compile of - // any grammar the new rows introduce, all inside `render`. Colouring - // the whole buffer when it is FILLED makes the covered-range check - // above true forever after, so scrolling one costs nothing at all. - // Bounded by what fills them: `look.find_max_hits` caps a grep at 512 - // rows, and a rendering is never typed into. - const whole = pane.colorAlgo() == .locations; - const start = if (whole) 0 else lineStart(p.gpa, f, f.scroll -| slack); - const end = if (whole) - f.content.len - else - @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack)); - const new_highlights = (switch (pane.colorAlgo()) { - .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end), - .locations => syntax.highlightLocations(p.tree_sitter_gpa, f.content, start, end), - else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end), - }) catch { - f.syntax_dirty = false; - continue; - }; - if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); - f.highlights = new_highlights; - f.highlight_start = if (f.highlights.len > 0) start else 0; - f.syntax_dirty = false; - } -} - -/// The width a wrapped row of THIS pane holds, in cells, or 0 when the pane is -/// not wrapping — the render decision, named once so motion cannot disagree -/// with paint. One column is left for the break marker: a row that filled its -/// last cell would have nowhere to say it continues. A pane taller than the -/// map refuses to wrap rather than record part of itself (see Pane.wrap_line). -pub fn wrapWidth(pane: *const Pane, wrap: bool) usize { - if (!wrap or pane.rows > pane.wrap_line.len) return 0; - return @max(1, @as(usize, pane.cols -| config.PREFIX_W) -| 1); -} - -pub const VisualRow = struct { start: usize, end: usize }; - -/// The visual row of `line` holding byte `col`: `[start, end)`, where `end` is -/// where the next visual row of the same line begins and equals `line.len` on -/// the last one. This is the same walk `fillBody` renders with, so `gj`/`gk` -/// step exactly the breaks a reader sees. `width == 0` (not wrapping) makes -/// the whole line one visual row, which is what collapses visual motion onto -/// textual motion instead of special-casing it upstream. -pub fn visualRow(line: []const u8, col: usize, width: usize) VisualRow { - if (width == 0) return .{ .start = 0, .end = line.len }; - var start: usize = 0; - while (true) { - const end = fitEnd(line, start, width); - if (col < end or end >= line.len) return .{ .start = start, .end = end }; - start = end; - } -} - -test "visual rows partition a line at the breaks the body renders" { - const line = "abcdefgh"; - try std.testing.expectEqual(VisualRow{ .start = 0, .end = line.len }, visualRow(line, 5, 0)); - try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 0, 3)); - try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 2, 3)); - try std.testing.expectEqual(VisualRow{ .start = 3, .end = 6 }, visualRow(line, 3, 3)); - // Past the end (a cursor on the newline) names the LAST row, and a short - // line is one row however narrow the pane is. - try std.testing.expectEqual(VisualRow{ .start = 6, .end = 8 }, visualRow(line, line.len, 3)); - try std.testing.expectEqual(VisualRow{ .start = 0, .end = 0 }, visualRow("", 0, 3)); - // A grapheme wider than the row still occupies exactly one row. - try std.testing.expectEqual(VisualRow{ .start = 0, .end = 4 }, visualRow("👩x", 0, 1)); -} - -/// the body a file pane renders: `pane.rows` SCREEN rows from the scroll -/// offset, each behind its right-aligned line number, then cut by hscroll. -/// -/// With `wrap` on a line too long for the pane takes several rows instead of -/// running off the right edge, and this is where that happens — it is a render -/// property, and the one thing the rest of the editor reads back is the map: -/// which line each row showed and at which byte column it began, recorded into -/// pane.wrap_line/wrap_col as the rows are built. `wrap_n` stays 0 for an -/// unwrapped body, and that is the value the readers treat as "rows are -/// lines", so the off path never consults an array. -pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 { - const width = wrapWidth(pane, wrap); - pane.wrap_n = 0; - - // Count the exact rendered bytes first. Unwrapped source lines are not - // bounded by the pane width, so a rows*cols buffer would either truncate - // them or quietly restore a growable builder under another name. - const len = fillBody(null, pane, f, width, false); - const out = try arena.alloc(u8, len); - const filled = fillBody(out, pane, f, width, true); - std.debug.assert(filled == out.len); - return out; -} - -/// Run the file-body row walk. With no destination it is the exact sizing -/// pass; with one it fills that allocation and records the wrapping map. -fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) usize { - if (record_wrap) pane.wrap_n = 0; - // start ON the first visible line instead of walking the file to it: this - // walk was O(f.scroll) and recolorSyntax below ran the identical one again - var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n'); - // scrolled past EOF (an edit shortened the file under a stale scroll): the - // old walk left the iterator dry, so drop the one empty line a slice split - // still yields, or the body grows a phantom numbered row - if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next(); - // the line the NEXT row comes from and the byte column of it that row - // starts at — the two the map records, walked forward by the loop - var abs: i32 = @intCast(f.scroll); - var at: usize = 0; - var cur = flines.next(); - var written: usize = 0; - for (0..pane.rows) |i| { - if (i > 0) { - if (dst) |out| out[written] = '\n'; - written += 1; - } - if (width > 0 and record_wrap) { - pane.wrap_line[i] = abs; - pane.wrap_col[i] = @intCast(at); - pane.wrap_n = @intCast(i + 1); - } - if (cur) |text| { - var lbuf: [16]u8 = undefined; - // unsigned: {d} prints a leading '+' for signed ints - const lineno: usize = @intCast(abs + 1); - // the number belongs to the LINE, so only its first row carries - // one — repeated down a wrapped line it would read as several - // lines, which is exactly what this is not - const prefix = if (at > 0) - " " - else - std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " "; - if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix); - written += prefix.len; - - // Wrap and horizontal-scroll cuts are always grapheme boundaries. - // Source columns remain byte offsets, while widths are terminal - // cells; keeping the conversion here prevents a view operation - // from manufacturing malformed UTF-8. - const end = if (width == 0) text.len else fitEnd(text, at, width); - const take = end - at; - const cut = if (pane.hscroll > 0 and width == 0) - modal.graphemeStart(text[at..end], @min(@as(usize, @intCast(pane.hscroll)), take)) - else - 0; - const shown = text[at + cut .. end]; - if (dst) |out| @memcpy(out[written..][0..shown.len], shown); - written += shown.len; - if (width > 0 and end < text.len) { - at = end; - } else { - abs += 1; - at = 0; - cur = flines.next(); - } - } else abs += 1; - } - return written; -} - -/// line-number gutter: mute the first PREFIX_W columns. Cheap chrome, not -/// gated on settings.colors; selection/cursor passes still win. The cursor row's -/// number takes the tag style (same row math as renderPane's cursor pass) so -/// the eye finds the current line. -pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { - const s = &p.surface; - const ch = p.chromeTheme(); - const goff = pane.scroll(); - const gcur = term_pane.gridCursor(pane); - const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff; - // the cursor's LINE, not its row: wrapped, one line owns a run of rows and - // the number sits on the first of them, so the whole run lights up — the - // gutter is naming the line you are on, and that is still one line - const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32); - // the body's first row, the way renderPane derives it (Tagbottom) - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - const row_line: i32 = if (pane.wrap_n == 0) - goff + @as(i32, vr) - else if (vr < pane.wrap_n) pane.wrap_line[vr] else std.math.maxInt(i32); - const on_cursor = row_line == cur_line; - var c: u16 = 0; - while (c < config.PREFIX_W and c < tw) : (c += 1) { - const cell = s.at(tx + c, body_y + vr); - cell.default = false; // paints blank gutter rows too - if (on_cursor) { - cell.style.fg = .{ .rgb = ch.tag_fg }; - cell.style.bg = .{ .rgb = ch.tag_bg }; - } else cell.style.fg = .{ .rgb = ch.lineno }; - } - } -} - -const SynStyle = struct { fg: [3]u8, bold: bool }; - -fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { - return switch (sy) { - .none => null, - .keyword => .{ .fg = p.theme().kw, .bold = true }, - .string => .{ .fg = p.theme().str, .bold = false }, - .number => .{ .fg = p.theme().num, .bold = false }, - .comment => .{ .fg = p.theme().comment, .bold = true }, - }; -} - -/// syntax colors: recolor each content cell from its tree-sitter style byte; -/// content starts after the lineno gutter -pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void { - if (f.highlights.len == 0) return; - const s = &p.surface; - const tz_recolor = tracy.zone(@src(), "synRecolor"); - defer tz_recolor.end(); - // indexed start, same as bodyText — an empty tail simply paints nothing - var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); - const total = nlines(p.gpa, f); - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - // A colour has to land on the byte it belongs to, so this walk reads - // the same map the body was built from: wrapped, the screen row names - // its own line and the byte column it began at, and it ends where the - // NEXT row of that line begins. Unwrapped the rows ARE the lines in - // order and the split iterator is the cheaper walk. - var base: usize = undefined; - var line: []const u8 = undefined; - var hs: usize = @intCast(@max(0, pane.hscroll)); - var limit: usize = undefined; - if (pane.wrap_n == 0) { - line = flines.next() orelse break; - base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); - limit = line.len; - } else { - if (vr >= pane.wrap_n) break; - const lrow: usize = @intCast(@max(0, pane.wrap_line[vr])); - if (lrow >= total) break; - base = lineStart(p.gpa, f, lrow); - const lend = if (lrow + 1 < total) lineStart(p.gpa, f, lrow + 1) -| 1 else f.content.len; - line = f.content[base..lend]; - hs = @intCast(pane.wrap_col[vr]); - limit = if (vr + 1 < pane.wrap_n and pane.wrap_line[vr + 1] == pane.wrap_line[vr]) - @min(line.len, @as(usize, @intCast(pane.wrap_col[vr + 1]))) - else - line.len; - } - hs = modal.graphemeStart(line, @min(hs, line.len)); - var c: usize = 0; - var screen_c: usize = 0; - while (hs + c < limit and config.PREFIX_W + screen_c < tw) { - const grapheme_end = @min(limit, modal.nextGrapheme(line, hs + c)); - const cells = graphemeDisplayWidth(line[hs + c .. grapheme_end]); - const idx = base + hs + c; - if (idx >= f.highlight_start) { - const hidx = idx - f.highlight_start; - if (hidx < f.highlights.len) { - if (synStyle(p, @enumFromInt(f.highlights[hidx]))) |ss| { - var fill: usize = 0; - while (fill < cells and config.PREFIX_W + screen_c + fill < tw) : (fill += 1) { - const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + screen_c + fill)), body_y + vr); - if (cell.default) continue; - cell.style.fg = .{ .rgb = ss.fg }; - cell.style.bold = ss.bold; - } - } - } - } - screen_c += cells; - c = grapheme_end - hs; - } - } -} - -/// Mark every visible wrapped row which continues onto the next screen row. -/// This is file chrome: it follows syntax recoloring and precedes the shared -/// selection passes, so neither source ink nor the marker can win over a user -/// selection. -pub fn drawWrapMarkers( - p: *Pardes, - pane: *const Pane, - r: pardes.Rect, - tx: u16, - tw: u16, - body_h: u16, - pane_bg: pardes.Color, -) void { - if (tw <= config.PREFIX_W + 1) return; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const marker_fg = p.chromeTheme().lineno; - var row: u16 = 0; - while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) { - if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue; - p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{ - .fg = .{ .rgb = marker_fg }, - .bg = pane_bg, - }); - } -} - -/// Paint one logical file word through the last frame's wrap map. Unlike a -/// rectangular mouse selection, a path may cross continuation rows without -/// highlighting unrelated cells between its endpoints. -pub fn paintWordSelection( - p: *Pardes, - pane: *Pane, - r: pardes.Rect, - row: i32, - word_lo: i32, - word_hi: i32, - bg: [3]u8, -) void { - const tx = r.x + config.GUTTER; - const tw = r.w - config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - var vr: i32 = 0; - while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) { - const here = pane.wrapAt(vr); - if (here.line != row) continue; - var hi = word_hi; - const next = pane.wrapAt(vr + 1); - if (next.line == row) hi = @min(hi, next.at); - const lo = @max(word_lo, here.at); - if (hi <= lo) continue; - const c0 = @as(i32, config.PREFIX_W) + displayOffset(pane, row, here.at, lo); - const c1 = @as(i32, config.PREFIX_W) + displayEndOffset(pane, row, here.at, hi - 1); - var col = @max(@as(i32, config.PREFIX_W), c0); - while (col <= c1 and col < @as(i32, tw)) : (col += 1) { - const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); - cell.default = false; - cell.style.bg = .{ .rgb = bg }; - } - } -} diff --git a/src/file_watch.zig b/src/file_watch.zig index dfdc0556..b2b7e380 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -13,8 +13,8 @@ const builtin = @import("builtin"); const libc = std.c; const linux = std.os.linux; const pardes = @import("pardes.zig"); -const look = @import("look.zig"); -const message = @import("message.zig"); +const filesystem = @import("fs.zig"); +const message = pardes.Pardes.Message; pub const Identity = struct { inode: std.Io.File.INode, @@ -51,8 +51,8 @@ pub const Generation = union(enum) { /// rename-over gave the pathname a new inode, without every caller between it /// and a host having to carry the descriptor. /// -/// `generation` is the last snapshot the core accepted, not merely one a host -/// observed; serial prevents a reused pane slot from committing stale data. +/// `generation` is the reconciled disk snapshot; Restore establishes this +/// baseline without replacing its saved buffer. Serial rejects reused slots. pub const Watch = struct { wd: c_int, file_wd: c_int = -1, @@ -319,7 +319,7 @@ fn markFile(kq: c_int, path_z: [:0]const u8) c_int { fn remarkFile(live: *Watch, path: ?[]const u8) void { if (comptime builtin.os.tag != .macos) return; if (live.kq < 0) return; - const watched = path orelse return; + const watched = filesystem.localPath(path orelse return) orelse return; var path_buf: [4096:0]u8 = undefined; if (watched.len >= path_buf.len) return; @memcpy(path_buf[0..watched.len], watched); @@ -380,8 +380,10 @@ fn watchPath( watches[slot] = null; unmark(fd, old, !shared); } - const watched_path = path orelse return; - const dir = std.fs.path.dirname(watched_path) orelse "."; + const declared_path = path orelse return; + const watched_path = filesystem.localPath(declared_path) orelse return; + const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), watched_path, .{}) catch null; + const dir = if (stat != null and stat.?.kind == .directory) watched_path else std.fs.path.dirname(watched_path) orelse "."; var dir_buf: [4096:0]u8 = undefined; if (dir.len >= dir_buf.len) return; @memcpy(dir_buf[0..dir.len], dir); @@ -391,7 +393,7 @@ fn watchPath( watches[slot] = .{ .wd = wd, .kq = fd, .serial = serial, .generation = generation }; // The file half, which only macos has and only for a path that exists yet. if (comptime builtin.os.tag == .macos) { - if (watches[slot]) |*live| remarkFile(live, watched_path); + if (watches[slot]) |*live| remarkFile(live, declared_path); } } @@ -416,7 +418,6 @@ pub fn watchPane( pub fn reloadPane( core: *pardes.Pardes, io: std.Io, - gpa: std.mem.Allocator, watches: *Table, id: usize, announce: bool, @@ -434,8 +435,8 @@ pub fn reloadPane( } if (pane.file) |file| { - const bytes = look.readFile(gpa, file.path) catch return false; - defer gpa.free(bytes); + const bytes = filesystem.read(core, file.path) catch return false; + defer core.gpa.free(bytes); const hash = std.hash.Wyhash.hash(0, bytes); switch (watched.generation) { .text => |accepted| if (accepted == hash) return false, @@ -485,11 +486,11 @@ pub fn reloadPane( pub fn applyEffect( core: *pardes.Pardes, io: std.Io, - gpa: std.mem.Allocator, fd: c_int, watches: *Table, id: u8, on: bool, + mode: pardes.WatchMode, ) bool { var path: ?[]const u8 = null; var serial: u32 = 0; @@ -505,7 +506,15 @@ pub fn applyEffect( } }; watchPane(fd, watches, id, path, serial, generation); - return on and watches[id] != null and reloadPane(core, io, gpa, watches, id, false); + if (on and mode == .baseline_disk) if (watches[id]) |*watched| { + const pane = core.panes[id] orelse return false; + const file = pane.file orelse return reloadPane(core, io, watches, id, false); + const bytes = filesystem.read(core, file.path) catch return false; + defer core.gpa.free(bytes); + watched.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }; + return false; + }; + return on and watches[id] != null and reloadPane(core, io, watches, id, false); } /// Reconcile every mark after a coalesced directory wake. @@ -517,7 +526,7 @@ pub fn reloadChanged( ) bool { var retry = false; for (watches[0..pardes.MAX_PANES], 0..) |slot, id| { - if (slot != null) retry = reloadPane(core, io, gpa, watches, id, true) or retry; + if (slot != null) retry = reloadPane(core, io, watches, id, true) or retry; } if (watches[theme_slot] != null) retry = reloadTheme(core, gpa, watches, true) or retry; @@ -539,7 +548,7 @@ pub fn applyThemeEffect( watchPath(fd, watches, theme_slot, null, 0, .{ .text = 0 }); if (!on) return false; const request = core.themeFileRequest(generation) orelse return false; - const bytes = look.readFile(gpa, request.path) catch |err| { + const bytes = filesystem.readFile(gpa, request.path) catch |err| { core.failThemeFile(generation, err); return false; }; @@ -567,7 +576,7 @@ pub fn reloadTheme( if (comptime builtin.os.tag == .macos) { if (watches[theme_slot]) |*entry| remarkFile(entry, request.path); } - const bytes = look.readFile(gpa, request.path) catch |err| { + const bytes = filesystem.readFile(gpa, request.path) catch |err| { core.failThemeFile(watched.serial, err); return false; }; @@ -589,7 +598,8 @@ pub fn reloadTheme( } pub fn identify(io: std.Io, path: []const u8) !Identity { - const stat = try std.Io.Dir.cwd().statFile(io, path, .{}); + const native = filesystem.localPath(path) orelse return error.NonLocalPath; + const stat = try std.Io.Dir.cwd().statFile(io, native, .{}); if (stat.kind != .file) return error.NotFile; return .{ .inode = stat.inode, @@ -599,6 +609,129 @@ pub fn identify(io: std.Io, path: []const u8) !Identity { }; } +test "restored file watches preserve snapshots and accept later disk changes" { + if (comptime !supported or !filesystem.platform_has_fs) return; + const io = std.testing.io; + const gpa = std.testing.allocator; + for ([_]struct { dirty: bool, exists: bool }{ + .{ .dirty = false, .exists = true }, + .{ .dirty = true, .exists = true }, + .{ .dirty = true, .exists = false }, + }) |case| { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + if (case.exists) try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "disk baseline\n" }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path}); + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + while (core.nextEffect()) |_| {} + const id: u8 = @intCast(core.freeSlot().?); + const pane = try pardes.panes.File.restore(core, id, .{ + .kind = .file, + .tag = "", + .body = "", + .file = .{ .path = path, .content = "restored snapshot\n", .dirty = case.dirty }, + }); + const fd = init(true); + if (fd < 0) return error.NoWatcher; + defer _ = libc.close(fd); + var watches: Table = @splat(null); + defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 }); + var armed = false; + while (core.nextEffect()) |effect| switch (effect) { + .watch => |watch| if (watch.pane == id and watch.on) { + try std.testing.expectEqual(.baseline_disk, watch.mode); + try std.testing.expect(!applyEffect(core, io, fd, &watches, id, watch.on, watch.mode)); + armed = true; + }, + else => {}, + }; + try std.testing.expect(armed and watches[id] != null); + try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); + try std.testing.expectEqual(case.dirty, pane.file.?.revision != pane.file.?.saved_revision); + _ = reloadChanged(core, io, gpa, &watches); + try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); + + try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "later disk save\n" }); + _ = reloadChanged(core, io, gpa, &watches); + try std.testing.expectEqualStrings("later disk save\n", pane.file.?.content); + pardes.panes.File.undo(core, pane); + try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); + _ = reloadChanged(core, io, gpa, &watches); + try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); + } +} + +test "ordinary file watches still reconcile changes between open and watch" { + if (comptime !supported or !filesystem.platform_has_fs) return; + const io = std.testing.io; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "opened\n" }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path}); + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + while (core.nextEffect()) |_| {} + const id: u8 = @intCast(core.freeSlot().?); + const pane = try pardes.panes.File.open(core, id, path, 0); + try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "changed before watch\n" }); + const fd = init(true); + if (fd < 0) return error.NoWatcher; + defer _ = libc.close(fd); + var watches: Table = @splat(null); + defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 }); + var armed = false; + while (core.nextEffect()) |effect| switch (effect) { + .watch => |watch| if (watch.pane == id and watch.on) { + try std.testing.expectEqual(.reconcile, watch.mode); + try std.testing.expect(!applyEffect(core, io, fd, &watches, id, watch.on, watch.mode)); + armed = true; + }, + else => {}, + }; + try std.testing.expect(armed and watches[id] != null); + try std.testing.expectEqualStrings("changed before watch\n", pane.file.?.content); + pardes.panes.File.undo(core, pane); + try std.testing.expectEqualStrings("opened\n", pane.file.?.content); +} + +test "explicit OS directory watches refresh prefix-preserving listings" { + if (comptime !supported or !filesystem.platform_has_fs) return; + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "first", .data = "first\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(std.testing.io, &directory_buf)]; + var path_buf: [4102]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "/n/os{s}", .{directory}); + const pane_id = core.freeSlot().?; + const pane = try pardes.panes.File.open(core, pane_id, path, 0); + const fd = init(true); + if (fd < 0) return error.NoWatcher; + defer _ = libc.close(fd); + var watches: Table = @splat(null); + defer watchPane(fd, &watches, @intCast(pane_id), null, pane.serial, .{ .text = 0 }); + _ = applyEffect(core, std.testing.io, fd, &watches, @intCast(pane_id), true, .reconcile); + try std.testing.expect(watches[pane_id] != null); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "second", .data = "second\n" }); + _ = reloadChanged(core, std.testing.io, gpa, &watches); + try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "/second\n") != null); + var rows = std.mem.tokenizeScalar(u8, pane.file.?.content, '\n'); + while (rows.next()) |row| { + try std.testing.expect(std.mem.startsWith(u8, row, path)); + const bytes = try filesystem.read(core, row); + defer gpa.free(bytes); + try std.testing.expect(std.mem.endsWith(u8, bytes, "\n")); + } +} + test "file identity changes for in-place and rename-over writes" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); diff --git a/src/fs.zig b/src/fs.zig new file mode 100644 index 00000000..14b6a76a --- /dev/null +++ b/src/fs.zig @@ -0,0 +1,4677 @@ +const std = @import("std"); +const limits = @import("memory.zig").limits; +const libc = std.c; +const pardes = @import("pardes.zig"); +const config = @import("config.zig"); +const lsp = @import("lsp/lsp.zig"); +const ninep_io = @import("9p_io.zig"); +const panes = @import("panes.zig"); +const mvzr = @import("mvzr"); +const modal = @import("modal.zig"); +const look = @import("look.zig"); + +pub const name_capacity: usize = 255; + +pub const Source = struct { path: []const u8, contents: []const u8 }; +pub const sources: []const Source = if (limits.embedded_sources) + &(source_files ++ @import("effect_sources.zig").files) +else + &.{}; +const source_files = [_]Source{ + .{ .path = "build.zig", .contents = @embedFile("root-build.zig") }, + .{ .path = "build.zig.zon", .contents = @embedFile("root-build.zig.zon") }, + .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, + .{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") }, + .{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") }, + .{ .path = "src/fs.zig", .contents = @embedFile("fs.zig") }, + .{ .path = "src/look.zig", .contents = @embedFile("look.zig") }, + .{ .path = "src/9p.zig", .contents = @embedFile("9p.zig") }, + .{ .path = "src/9p_io.zig", .contents = @embedFile("9p_io.zig") }, + .{ .path = "src/host_io.zig", .contents = @embedFile("host_io.zig") }, + .{ .path = "src/config.zig", .contents = @embedFile("config.zig") }, + .{ .path = "src/main.zig", .contents = @embedFile("main.zig") }, + .{ .path = "src/builtins.zig", .contents = @embedFile("builtins.zig") }, + .{ .path = "src/grammar_manifest.zig", .contents = @embedFile("grammar_manifest.zig") }, + .{ .path = "src/CHANGELOG.md", .contents = @embedFile("CHANGELOG.md") }, +}; + +pub fn sourceBytes(path: []const u8) ?[]const u8 { + for (sources) |entry| if (std.mem.eql(u8, entry.path, path)) return entry.contents; + return null; +} + +pub const WriteError = error{ + PathTooLong, + PermissionDenied, + IsDirectory, + ReadOnlyFilesystem, + NoSpaceLeft, + OpenFailed, + WriteFailed, +}; + +pub fn writeFile(path: []const u8, bytes: []const u8) WriteError!void { + var pathbuf: [4096:0]u8 = undefined; + if (path.len >= pathbuf.len) return error.PathTooLong; + if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; + @memcpy(pathbuf[0..path.len], path); + pathbuf[path.len] = 0; + const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return switch (libc.errno(fd)) { + .ACCES, .PERM => error.PermissionDenied, + .ISDIR => error.IsDirectory, + .ROFS => error.ReadOnlyFilesystem, + .NOSPC, .DQUOT => error.NoSpaceLeft, + .NAMETOOLONG => error.PathTooLong, + else => error.OpenFailed, + }; + var off: usize = 0; + var wrote = true; + while (off < bytes.len) { + const n = libc.write(fd, bytes[off..].ptr, bytes.len - off); + if (n < 0 and libc.errno(n) == .INTR) continue; + if (n <= 0) { + wrote = false; + break; + } + off += @intCast(n); + } + const closed = libc.close(fd) == 0; + if (!wrote or !closed) return error.WriteFailed; +} + +extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; + +pub const Mount = struct { name: []const u8, dial: []const u8 }; +pub const max_mounts = 8; +pub const Resolved = struct { path: []const u8, dir: bool = false }; +pub const OsPath = struct { node: u64, path: []const u8 }; +pub const archive_node: u64 = 1 << 61; + +pub fn archiveNode(path: []const u8) ?u64 { + if (path.len == 0) return archive_node; + if (path.len >= 4096) return null; + for (sources, 0..) |source, i| { + if (std.mem.eql(u8, source.path, path) or + (source.path.len > path.len and source.path[path.len] == '/' and std.mem.startsWith(u8, source.path, path))) + return archive_node | (@as(u64, i) << 12) | path.len; + } + return null; +} + +fn archiveInfo(node: u64) ?struct { path: []const u8, contents: []const u8, dir: bool } { + if (node == archive_node) return .{ .path = "", .contents = "", .dir = true }; + const index = (node & ~archive_node) >> 12; + if (index >= sources.len) return null; + const source = sources[@intCast(index)]; + const len: usize = @intCast(node & 4095); + if (len > source.path.len) return null; + return .{ .path = source.path[0..len], .contents = source.contents, .dir = len < source.path.len }; +} + +pub fn stageArchive(p: *pardes.Pardes, out: *std.ArrayList(u8), path: []const u8, skip: *u64) void { + var seen: [sources.len][]const u8 = undefined; + var count: usize = 0; + for (sources, 0..) |source, i| { + if (path.len != 0 and + (source.path.len <= path.len or source.path[path.len] != '/' or !std.mem.startsWith(u8, source.path, path))) continue; + const start = if (path.len == 0) 0 else path.len + 1; + const rest = source.path[start..]; + const len = std.mem.indexOfScalar(u8, rest, '/') orelse rest.len; + const name = rest[0..len]; + var duplicate = false; + for (seen[0..count]) |previous| if (std.mem.eql(u8, name, previous)) { + duplicate = true; + break; + }; + if (duplicate) continue; + seen[count] = name; + count += 1; + if (skip.* > 0) { + skip.* -= 1; + continue; + } + const node = archive_node | (@as(u64, i) << 12) | (start + len); + stageDirent(out, p.gpa, node, len < rest.len, name); + } +} + +pub fn archiveHandle(p: *pardes.Pardes, req: Req) Reply { + const info = archiveInfo(req.node) orelse return Reply.fail(req.tag, E.NOENT); + const attr: Reply.Attr = .{ + .node = req.node, + .name = std.fs.path.basename(info.path), + .dir = info.dir, + .size = if (info.dir) 0 else info.contents.len, + .mode = if (info.dir) 0o500 else 0o400, + }; + switch (req.op) { + .getattr => return .{ .tag = req.tag, .attr = attr }, + .open => return .{ .tag = req.tag, .handle = 1 }, + .release => return .{ .tag = req.tag }, + .lookup => { + if (!info.dir) return Reply.fail(req.tag, E.NOTDIR); + if (std.mem.eql(u8, req.data, "..")) { + const parent = std.fs.path.dirname(info.path) orelse ""; + if (parent.len == 0) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) }); + const node = archiveNode(parent) orelse return Reply.fail(req.tag, E.NOENT); + return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); + } + if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); + var buf: [4096]u8 = undefined; + const path = if (info.path.len == 0) req.data else std.fmt.bufPrint(&buf, "{s}/{s}", .{ info.path, req.data }) catch return Reply.fail(req.tag, E.NOENT); + const node = archiveNode(path) orelse return Reply.fail(req.tag, E.NOENT); + return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); + }, + .readdir => { + if (!info.dir) return Reply.fail(req.tag, E.NOTDIR); + const out = p.fs.stage(p.gpa); + var skip = req.off; + stageArchive(p, out, info.path, &skip); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; + }, + .read => { + if (info.dir) return Reply.fail(req.tag, E.PERM); + const off: usize = @intCast(@min(req.off, info.contents.len)); + const bytes = info.contents[off..][0..@min(req.size, info.contents.len - off)]; + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, bytes) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; + }, + else => return Reply.fail(req.tag, E.PERM), + } +} + +fn osPath(p: *pardes.Pardes, node: u64) ?[]const u8 { + if (node == os_root) return "/"; + for (p.fs.os_paths.items) |entry| if (entry.node == node) return entry.path; + return null; +} + +fn osNode(p: *pardes.Pardes, path: []const u8) !u64 { + if (std.mem.eql(u8, path, "/")) return os_root; + const node = os_node | (std.hash.Wyhash.hash(0, path) & (os_node - 1)); + for (p.fs.os_paths.items) |entry| { + if (std.mem.eql(u8, entry.path, path)) return entry.node; + if (entry.node == node) return error.NodeCollision; + } + if (p.fs.os_paths.items.len == 4096) return error.TooManyFiles; + const saved = try p.gpa.dupe(u8, path); + errdefer p.gpa.free(saved); + try p.fs.os_paths.append(p.gpa, .{ .node = node, .path = saved }); + return node; +} + +pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { + if (comptime !platform_has_fs) return Reply.fail(req.tag, E.NOENT); + const path = osPath(p, req.node) orelse return Reply.fail(req.tag, E.NOENT); + if (req.op == .release) return .{ .tag = req.tag }; + const io = std.Io.Threaded.global_single_threaded.io(); + const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT); + const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o500 else 0o600 }; + switch (req.op) { + .getattr => return .{ .tag = req.tag, .attr = attr }, + .open => { + if (stat.kind != .file and stat.kind != .directory) return Reply.fail(req.tag, E.PERM); + return .{ .tag = req.tag, .handle = 1 }; + }, + .lookup => { + if (stat.kind != .directory) return Reply.fail(req.tag, E.NOTDIR); + if (req.node == os_root and std.mem.eql(u8, req.data, "..")) + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = namespace_root }); + if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); + var buf: [4096]u8 = undefined; + const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), req.data }) catch return Reply.fail(req.tag, E.NOENT); + var normalized_buf: [4096]u8 = undefined; + const normalized = resolveOs(joined, &normalized_buf) orelse return Reply.fail(req.tag, E.NOENT); + const node = osNode(p, normalized.path) catch return Reply.fail(req.tag, E.NFILE); + return osHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); + }, + .readdir => { + var dir = std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }) catch return Reply.fail(req.tag, E.NOTDIR); + defer dir.close(io); + var it = dir.iterate(); + const out = p.fs.stage(p.gpa); + var skip = req.off; + while (it.next(io) catch return Reply.fail(req.tag, E.IO)) |entry| { + var buf: [4096]u8 = undefined; + const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), entry.name }) catch continue; + var normalized_buf: [4096]u8 = undefined; + const normalized = resolveOs(joined, &normalized_buf) orelse continue; + const child = dir.statFile(io, entry.name, .{}) catch continue; + if (skip > 0) { + skip -= 1; + continue; + } + const node = if (std.mem.eql(u8, normalized.path, "/")) os_root else os_node | (std.hash.Wyhash.hash(0, normalized.path) & (os_node - 1)); + stageDirent(out, p.gpa, node, child.kind == .directory, entry.name); + if (out.items.len >= @max(req.size, 512)) break; + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; + }, + .read, .write, .setattr => { + if (stat.kind != .file) return Reply.fail(req.tag, E.PERM); + var z: [4096]u8 = undefined; + const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return Reply.fail(req.tag, E.NOENT); + const fd = libc.open(path_z, .{ .ACCMODE = if (req.op == .read) .RDONLY else .WRONLY, .NONBLOCK = true, .CLOEXEC = true }); + if (fd < 0) return Reply.fail(req.tag, E.PERM); + defer _ = libc.close(fd); + if (req.op == .setattr) { + if (!req.truncate or req.off != 0 or libc.ftruncate(fd, 0) != 0) return Reply.fail(req.tag, E.INVAL); + var truncated = attr; + truncated.size = 0; + return .{ .tag = req.tag, .attr = truncated }; + } + if (req.off > std.math.maxInt(i64)) return Reply.fail(req.tag, E.INVAL); + if (libc.lseek(fd, @intCast(req.off), libc.SEEK.SET) < 0) return Reply.fail(req.tag, E.IO); + if (req.op == .write) { + const written = libc.write(fd, req.data.ptr, req.data.len); + if (written < 0) return Reply.fail(req.tag, E.IO); + return .{ .tag = req.tag, .written = @intCast(written) }; + } + const out = p.fs.stage(p.gpa); + out.resize(p.gpa, @min(req.size, 65536)) catch return Reply.fail(req.tag, E.NOMEM); + const got = libc.read(fd, out.items.ptr, out.items.len); + if (got < 0) return Reply.fail(req.tag, E.IO); + out.shrinkRetainingCapacity(@intCast(got)); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(got) } }; + }, + else => return Reply.fail(req.tag, E.PERM), + } +} + +pub fn validMountName(name: []const u8) bool { + if (name.len == 0 or name.len > name_capacity or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return false; + if (std.mem.eql(u8, name, "os") or std.mem.eql(u8, name, "self")) return false; + for (name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-' and c != '.') return false; + return true; +} + +test "mounts own their names and dials and reject duplicate or reserved names" { + const gpa = std.testing.allocator; + var ns: Namespace = .{}; + defer ns.deinit(gpa); + var name = "peer".*; + var dial = "/tmp/peer.sock".*; + try ns.mount(gpa, &name, &dial); + @memset(&name, 'x'); + @memset(&dial, 'x'); + try std.testing.expectEqualStrings("peer", ns.mounts.items[0].name); + try std.testing.expectEqualStrings("/tmp/peer.sock", ns.mounts.items[0].dial); + try std.testing.expectError(error.AlreadyMounted, ns.mount(gpa, "peer", "/tmp/other.sock")); + for ([_][]const u8{ "", ".", "..", "os", "self", "a/b", "with space" }) |bad| + try std.testing.expectError(error.BadMountName, ns.mount(gpa, bad, "/tmp/peer.sock")); + for ([_][]const u8{ "", "a\x00b", "tcp!127.0.0.1!0", "tcp!localhost!564" }) |bad| + try std.testing.expectError(error.BadDial, ns.mount(gpa, "valid", bad)); + for (1..max_mounts) |i| { + var buf: [16]u8 = undefined; + try ns.mount(gpa, try std.fmt.bufPrint(&buf, "peer{d}", .{i}), "/tmp/peer.sock"); + } + try std.testing.expectEqual(max_mounts, ns.mounts.items.len); + try std.testing.expectError(error.TooManyMounts, ns.mount(gpa, "full", "/tmp/peer.sock")); +} + +test "mount allocation failures preserve prior mounts and release partial copies" { + const Check = struct { + fn run(gpa: std.mem.Allocator) !void { + var ns: Namespace = .{}; + defer ns.deinit(gpa); + try ns.mount(gpa, "first", "/tmp/first.sock"); + ns.mount(gpa, "second", "/tmp/second.sock") catch |err| { + try std.testing.expectEqual(@as(usize, 1), ns.mounts.items.len); + try std.testing.expectEqualStrings("first", ns.mounts.items[0].name); + try std.testing.expectEqualStrings("/tmp/first.sock", ns.mounts.items[0].dial); + return err; + }; + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Check.run, .{}); +} + +test "unmount refuses pane paths inherited directories and queued save targets" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + try p.fs.mount(gpa, "peer", "/tmp/peer.sock"); + const pane = try p.setTestFile("unsaved\n"); + for ([_][]const u8{ "/n/peer", "/n/peer/file", "/n/peer/dir/file" }) |path| { + gpa.free(pane.file.?.path); + pane.file.?.path = try gpa.dupe(u8, path); + try std.testing.expectError(error.MountInUse, unmount(p, "peer")); + } + gpa.free(pane.file.?.path); + pane.file.?.path = try gpa.dupe(u8, "/n/peer2/file"); + const shell = try p.newShell(1, "/n/peer/dir"); + p.setCwd(1, "/n/peer/dir"); + try std.testing.expectError(error.MountInUse, unmount(p, "peer")); + pane.cwd = .{ .inherited = shell }; + try std.testing.expectError(error.MountInUse, unmount(p, "peer")); + p.setCwd(1, "/"); + while (p.nextEffect()) |_| {} + p.emit(.{ .save_text = .{ .pane = 0, .serial = pane.serial, .path = .from("/n/peer/pending") } }); + try std.testing.expectError(error.MountInUse, unmount(p, "peer")); + while (p.nextEffect()) |_| {} + try unmount(p, "peer"); + try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); + try std.testing.expectError(error.NotMounted, unmount(p, "peer")); + try p.fs.mount(gpa, "peer", "/tmp/other.sock"); + try std.testing.expectEqualStrings("/tmp/other.sock", p.fs.mounts.items[0].dial); +} + +test "virtual writes enforce permissions even when contents are empty" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + for ([_][]const u8{ "", "bytes" }) |bytes| { + for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/src/fs.zig", "/n/self/src/fs.zig" }) |path| + try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes)); + try std.testing.expectError(error.IsDirectory, write(p, "/virtual/src", bytes)); + try std.testing.expectError(error.FileNotFound, write(p, "/virtual/missing", bytes)); + } + for (p.fs.snapshots) |snapshot| try std.testing.expect(snapshot.node == 0); +} + +test "direct self reads and writes use the same dot paths as Look" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("old contents\n"); + var path_buf: [256]u8 = undefined; + for ([_][]const u8{ "/virtual", "/n/self" }) |root| { + const path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/{d}/../{d}/body/./", .{ root, pane.serial, pane.serial }); + try write(p, path, "replacement\n"); + try std.testing.expectEqualStrings("replacement\n", pane.file.?.content); + const bytes = try read(p, path); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(pane.file.?.content, bytes); + var resolved: [4096]u8 = undefined; + const canonical = resolve(p, path, "/", &resolved).?; + try std.testing.expectEqual(resolveSelf(p, canonical.path[9..]), resolveSelf(p, path[root.len..])); + try std.testing.expectEqual(@as(?u64, @intFromEnum(SelfFile.root)), resolveSelf(p, "./pane/../../")); + const index_path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/../index", .{root}); + try std.testing.expectError(error.ReadOnlyFilesystem, write(p, index_path, "")); + const index = try read(p, index_path); + defer gpa.free(index); + try std.testing.expect(std.mem.indexOf(u8, index, "/test.txt") != null); + } + var overlong: [4110]u8 = @splat('x'); + @memcpy(overlong[0..9], "/virtual/"); + var resolved: [4096]u8 = undefined; + try std.testing.expect(resolve(p, &overlong, "/", &resolved) == null); + try std.testing.expect(resolveSelf(p, overlong[9..]) == null); + try std.testing.expectError(error.FileNotFound, write(p, &overlong, "")); +} + +test "self files share the regular file limit without preallocating it" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const contents = try gpa.alloc(u8, limits.max_stream_bytes + 17); + defer gpa.free(contents); + @memset(contents, 'x'); + const pane = try p.setTestFile(contents); + var path_buf: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{pane.serial}); + const bytes = try read(p, path); + defer gpa.free(bytes); + try std.testing.expectEqualSlices(u8, contents, bytes); +} + +test "bounded reads accept exact OS file lengths and empty files" { + if (!platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var contents: [8209]u8 = @splat('x'); + contents[contents.len - 1] = '\n'; + for ([_][]const u8{ &contents, "" }) |expected| { + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "limited.txt", .data = expected }); + var path_buf: [4096]u8 = undefined; + const native = path_buf[0..try tmp.dir.realPathFile(std.testing.io, "limited.txt", &path_buf)]; + var explicit_buf: [4096]u8 = undefined; + const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{native}); + for ([_][]const u8{ native, explicit }) |path| { + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualSlices(u8, expected, bytes); + if (expected.len > 0) { + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); + } + } + } + try tmp.dir.createDir(std.testing.io, "empty", .default_dir); + var path_buf: [4096]u8 = undefined; + const native = path_buf[0..try tmp.dir.realPathFile(std.testing.io, "empty", &path_buf)]; + var explicit_buf: [4096]u8 = undefined; + const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{native}); + const empty = try readLimit(p, explicit, 0); + defer gpa.free(empty); + try std.testing.expectEqual(@as(usize, 0), empty.len); + try std.testing.expectEqual(@as(usize, 0), p.fs.os_paths.items.len); +} + +test "bounded reads apply the same limits to self bodies and embedded sources" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + p.fs.socket_path = "/tmp/pardes-limited-in-process.sock"; + try p.fs.mount(gpa, "own", p.fs.socket_path); + var contents: [8209]u8 = @splat('x'); + contents[contents.len - 1] = '\n'; + for ([_][]const u8{ &contents, "" }) |expected| { + const pane = try p.setTestFile(expected); + for ([_][]const u8{ "/virtual", "/n/self", "/n/own/self" }) |prefix| { + if (!platform_has_fs and std.mem.eql(u8, prefix, "/n/own/self")) continue; + var path_buf: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/pane/{d}/body", .{ prefix, pane.serial }); + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualSlices(u8, expected, bytes); + if (expected.len > 0) { + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); + } + try std.testing.expectEqualSlices(u8, expected, pane.file.?.content); + } + } + if (limits.embedded_sources) { + const source = findEmbeddedSource("src/look.zig", false).?; + for ([_][]const u8{ "/virtual/src/look.zig", "/n/self/src/look.zig", "/n/own/self/src/look.zig" }) |path| { + if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own/")) continue; + const bytes = try readLimit(p, path, source.contents.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(source.contents, bytes); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, source.contents.len - 1)); + } + const bytes = try readFileLimit(gpa, "/virtual/src/look.zig", source.contents.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(source.contents, bytes); + try std.testing.expectError(error.FileTooLarge, readFileLimit(gpa, "/virtual/src/look.zig", source.contents.len - 1)); + } +} + +test "bounded reads count rendered directory paths and unknown self lengths" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + p.fs.socket_path = "/tmp/pardes-limited-in-process.sock"; + try p.fs.mount(gpa, "own", p.fs.socket_path); + for ([_][]const u8{ "/n", "/virtual", "/n/self", "/n/own", "/n/own/self", "/virtual/listeners" }) |path| { + if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own")) continue; + const expected = try read(p, path); + defer gpa.free(expected); + try std.testing.expect(expected.len > 0); + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(expected, bytes); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); + } + try std.testing.expectEqual(@as(usize, 0), p.fs.os_paths.items.len); +} + +test "bounded reads release terminal snapshots after success and size refusal" { + if (!pardes.terminal_panes) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + var path_buf: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{serialOf(p)}); + const empty = try readLimit(p, path, 0); + defer gpa.free(empty); + try std.testing.expectEqual(@as(usize, 0), empty.len); + p.update(.{ .output = .{ .pane = 0, .bytes = "limited terminal output" } }); + const expected = "limited terminal output"; + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(expected, bytes); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + for (p.fs.snapshots) |snapshot| { + try std.testing.expectEqual(@as(u64, 0), snapshot.node); + try std.testing.expect(snapshot.bytes == null); + } +} + +test "bounded reads probe size-unknown proc files at the exact limit" { + if (!platform_has_fs or @import("builtin").os.tag != .linux) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const expected = read(p, "/proc/version") catch |err| return switch (err) { + error.FileNotFound, error.PermissionDenied => error.SkipZigTest, + else => err, + }; + defer gpa.free(expected); + try std.testing.expect(expected.len > 0); + var storage: [256 * 1024]u8 = undefined; + var fixed = std.heap.FixedBufferAllocator.init(&storage); + const bounded = try readFileLimit(fixed.allocator(), "/proc/version", limits.max_stream_bytes); + try std.testing.expectEqualStrings(expected, bounded); + fixed.allocator().free(bounded); + for ([_][]const u8{ "/proc/version", "/n/os/proc/version" }) |path| { + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(expected, bytes); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); + } +} + +test "self file reads release partial allocations when memory runs out" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const contents = try gpa.alloc(u8, 65537); + defer gpa.free(contents); + @memset(contents, 'x'); + const pane = try p.setTestFile(contents); + var path_buf: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{pane.serial}); + const Read = struct { + fn run(allocator: std.mem.Allocator, core: *Pardes, name: []const u8) !void { + const original = core.gpa; + core.gpa = allocator; + defer core.gpa = original; + const bytes = read(core, name) catch |err| return switch (err) { + error.WriteFailed => error.OutOfMemory, + else => err, + }; + defer allocator.free(bytes); + try std.testing.expectEqual(@as(usize, 65537), bytes.len); + } + }; + try std.testing.checkAllAllocationFailures(gpa, Read.run, .{ p, path }); + try std.testing.expectEqualSlices(u8, contents, pane.file.?.content); +} + +test "oversized OS files fail before allocating their contents" { + if (!platform_has_fs) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const file = try tmp.dir.createFile(std.testing.io, "oversized", .{}); + defer file.close(std.testing.io); + try file.setLength(std.testing.io, limits.max_file_bytes + 1); + var path_buf: [4096]u8 = undefined; + const path_len = try tmp.dir.realPathFile(std.testing.io, "oversized", &path_buf); + var failing: std.testing.FailingAllocator = .init(std.testing.allocator, .{ .fail_index = 0 }); + try std.testing.expectError(error.FileTooLarge, readFile(failing.allocator(), path_buf[0..path_len])); + try std.testing.expectEqual(@as(usize, 0), failing.allocations); +} + +test "filesystem roots list their namespaces without dialing remote mounts" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + try p.fs.mount(gpa, "peer", "unavailable-session"); + for ([_][]const u8{ "/n", "/n/", "/n///" }) |path| { + const contents = try read(p, path); + defer gpa.free(contents); + try std.testing.expectEqualStrings("/n/os/\n/n/self/\n/n/peer/\n", contents); + var buf: [4096]u8 = undefined; + try std.testing.expectEqualStrings("/n", resolve(p, path, "/", &buf).?.path); + try std.testing.expectError(error.IsDirectory, write(p, path, "")); + } + const bare = try read(p, "/virtual"); + defer gpa.free(bare); + const slashed = try read(p, "/virtual/"); + defer gpa.free(slashed); + try std.testing.expectEqualStrings(bare, slashed); + try std.testing.expect(std.mem.indexOf(u8, bare, "/virtual/index\n") != null); + try std.testing.expectError(error.IsDirectory, write(p, "/virtual", "")); +} + +test "virtual body writes can read their input from the same pane" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("same pane contents\n"); + const event_node = Node.of(pane.serial, .event); + _ = handle(p, .{ .tag = 0, .op = .open, .node = event_node }); + defer _ = handle(p, .{ .tag = 0, .op = .release, .node = event_node }); + var path_buffer: [64]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{pane.serial}); + try write(p, path, pane.file.?.content); + try std.testing.expectEqualStrings("same pane contents\n", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 2), pane.file.?.history.undo_len); + const events = &p.fs.panes[p.active].events; + try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "ED")); + events.pop(); + try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "EI")); + events.pop(); + try std.testing.expect(events.empty()); + try write(p, path, pane.file.?.content[5..]); + try std.testing.expectEqualStrings("pane contents\n", pane.file.?.content); +} + +test "same-core mount directories preserve their mount prefix across reads" { + if (!platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const socket_path = "/tmp/pardes-in-process-mount.sock"; + p.fs.socket_path = socket_path; + try p.fs.mount(gpa, "own", socket_path); + const roots = try read(p, "/n/own"); + defer gpa.free(roots); + try std.testing.expectEqualStrings("/n/own/os/\n/n/own/self/\n", roots); + const self = try read(p, "/n/own/self"); + defer gpa.free(self); + try std.testing.expect(std.mem.indexOf(u8, self, "/n/own/self/index\n") != null); + try std.testing.expectError(error.NotADirectory, read(p, "/n/own/self/index/..")); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + for (0..520) |i| { + var name: [32]u8 = undefined; + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = try std.fmt.bufPrint(&name, "entry-{d:0>4}.txt", .{i}), .data = "child\n" }); + } + var directory_buffer: [4096]u8 = undefined; + const directory = directory_buffer[0..try tmp.dir.realPath(std.testing.io, &directory_buffer)]; + const held_node = try osNode(p, directory); + const path = try std.fmt.allocPrint(gpa, "/n/own/os{s}", .{directory}); + defer gpa.free(path); + const listing = try read(p, path); + defer gpa.free(listing); + var entries = std.mem.tokenizeScalar(u8, listing, '\n'); + var count: usize = 0; + while (entries.next()) |entry| { + try std.testing.expect(std.mem.startsWith(u8, entry, path)); + const child = try read(p, entry); + defer gpa.free(child); + try std.testing.expectEqualStrings("child\n", child); + try std.testing.expectEqual(@as(usize, 1), p.fs.os_paths.items.len); + count += 1; + } + try std.testing.expectEqual(@as(usize, 520), count); + try std.testing.expectEqual(held_node, p.fs.os_paths.items[0].node); + try std.testing.expectEqualStrings(directory, p.fs.os_paths.items[0].path); +} + +pub fn isVirtual(path: []const u8) bool { + return std.mem.eql(u8, path, "/virtual") or std.mem.startsWith(u8, path, "/virtual/") or + std.mem.eql(u8, path, "/n") or std.mem.startsWith(u8, path, "/n/"); +} + +pub fn localPath(path: []const u8) ?[]const u8 { + if (std.mem.eql(u8, path, "/n/os")) return "/"; + if (std.mem.startsWith(u8, path, "/n/os/")) return path[5..]; + if (isVirtual(path)) return null; + return path; +} + +test "explicit OS paths retain their namespace until an OS boundary" { + for ([_]struct { declared: []const u8, native: ?[]const u8 }{ + .{ .declared = "/n/os", .native = "/" }, + .{ .declared = "/n/os/project/file", .native = "/project/file" }, + .{ .declared = "/n/os/virtual/index", .native = "/virtual/index" }, + .{ .declared = "/n/os/n/self/index", .native = "/n/self/index" }, + .{ .declared = "/n/self/index", .native = null }, + .{ .declared = "/n/peer/os/project/file", .native = null }, + .{ .declared = "/virtual/index", .native = null }, + .{ .declared = "/project/file", .native = "/project/file" }, + }) |case| { + const native = localPath(case.declared); + if (case.native) |expected| try std.testing.expectEqualStrings(expected, native.?) else try std.testing.expect(native == null); + } +} + +pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[4096]u8) ?Resolved { + if (word.len == 0 or std.mem.indexOfScalar(u8, word, 0) != null) return null; + var joined_buf: [4096]u8 = undefined; + const joined = if (word[0] == '/') word else std.fmt.bufPrint(&joined_buf, "{s}/{s}", .{ cwd, word }) catch return null; + if (std.mem.eql(u8, std.mem.trimEnd(u8, joined, "/"), "/n")) return .{ .path = "/n" }; + + if (std.mem.startsWith(u8, joined, "/n/")) { + const explicit = joined[3..]; + const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; + const name = explicit[0..cut]; + const path = if (cut < explicit.len) explicit[cut..] else "/"; + if (std.mem.eql(u8, name, "os")) { + var native_buf: [4096]u8 = undefined; + const native = resolveOs(path, &native_buf) orelse return null; + return .{ .path = std.fmt.bufPrint(out, "/n/os{s}", .{native.path}) catch return null }; + } + if (std.mem.eql(u8, name, "self")) return resolveVirtual(p, path, out); + const core = p orelse return null; + for (core.fs.mounts.items) |mount| { + if (!std.mem.eql(u8, mount.name, name)) continue; + const normalized = normalizeVirtualPath(path, out) orelse return null; + var remote_path: [4096]u8 = undefined; + const saved = std.fmt.bufPrint(&remote_path, "/n/{s}/{s}", .{ name, normalized }) catch return null; + @memcpy(out[0..saved.len], saved); + return .{ .path = out[0..saved.len] }; + } + return null; + } + if (std.mem.eql(u8, joined, "/virtual")) return resolveVirtual(p, "/", out); + if (std.mem.startsWith(u8, joined, "/virtual/")) return resolveVirtual(p, joined[8..], out); + if (resolveOs(joined, out)) |found| return found; + if (resolveVirtual(p, joined, out)) |found| return found; + if (resolveVirtual(p, word, out)) |found| return found; + if (resolveEmbedded(word, cwd, out)) |source| { + const path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null; + return .{ .path = path }; + } + return null; +} + +pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved { + if (comptime !platform_has_fs) return null; + var z: [4096]u8 = undefined; + const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return null; + const resolved = realpath(path_z, out) orelse return null; + return .{ .path = std.mem.span(resolved), .dir = isDir(resolved) }; +} + +fn resolveVirtual(p: ?*pardes.Pardes, path: []const u8, out: *[4096]u8) ?Resolved { + var normalized_buf: [4096]u8 = undefined; + const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return null; + if (p) |core| if (resolveSelf(core, normalized) != null) + return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; + if (findEmbeddedSource(normalized, false)) |source| + return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null }; + if (archiveNode(normalized) != null) + return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; + return null; +} + +pub fn read(p: *pardes.Pardes, path: []const u8) ![]u8 { + return readLimit(p, path, limits.max_file_bytes); +} + +pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 { + const limit = @min(max_bytes, limits.max_file_bytes); + if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + if (limit < "/n/os/\n/n/self/\n".len) return error.FileTooLarge; + try out.writer.writeAll("/n/os/\n/n/self/\n"); + for (p.fs.mounts.items) |mount| { + if (mount.name.len + 5 > limit - out.written().len) return error.FileTooLarge; + try out.writer.print("/n/{s}/\n", .{mount.name}); + } + return out.toOwnedSlice(); + } + if (std.mem.eql(u8, path, "/virtual")) return readNode(p, @intFromEnum(SelfFile.root), path, limit); + if (std.mem.startsWith(u8, path, "/n/")) { + const explicit = path[3..]; + const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; + const name = explicit[0..cut]; + const remote_path = if (cut < explicit.len) explicit[cut..] else "/"; + if (std.mem.eql(u8, name, "os")) { + var native_buf: [4096]u8 = undefined; + const native = resolveOs(remote_path, &native_buf) orelse return readFileLimit(p.gpa, path, limit); + if (!native.dir) return readFileLimit(p.gpa, path, limit); + const saved_paths = p.fs.os_paths.items.len; + defer { + for (p.fs.os_paths.items[saved_paths..]) |temporary| p.gpa.free(temporary.path); + p.fs.os_paths.shrinkRetainingCapacity(saved_paths); + } + const node = try osNode(p, native.path); + return readNode(p, node, path, limit); + } + if (std.mem.eql(u8, name, "self")) { + const node = resolveSelf(p, remote_path) orelse return error.FileNotFound; + return readNode(p, node, path, limit); + } + for (p.fs.mounts.items) |mount| { + if (!std.mem.eql(u8, name, mount.name)) continue; + if (ninep_io.Client.sameSession(mount.dial, p.fs.socket_path, p.fs.tcp_address, p.fs.quic_address)) { + const saved_paths = p.fs.os_paths.items.len; + defer { + for (p.fs.os_paths.items[saved_paths..]) |temporary| p.gpa.free(temporary.path); + p.fs.os_paths.shrinkRetainingCapacity(saved_paths); + } + var node = namespace_root; + var directory = true; + var parts = std.mem.tokenizeScalar(u8, remote_path, '/'); + while (parts.next()) |part| { + if (!directory) return error.NotADirectory; + if (std.mem.eql(u8, part, ".")) continue; + const reply = handle(p, .{ .tag = 0, .op = .lookup, .node = node, .data = part }); + if (reply.status != .ok) return error.FileNotFound; + node = reply.attr.node; + directory = reply.attr.dir; + } + return readNode(p, node, path, limit); + } + return ninep_io.Client.readLimit(p.gpa, mount.dial, remote_path, path, limit); + } + return error.FileNotFound; + } + if (std.mem.startsWith(u8, path, "/virtual/")) { + const name = path[9..]; + if (resolveSelf(p, name)) |node| return readNode(p, node, path, limit); + } + return readFileLimit(p.gpa, path, limit); +} + +fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize) ![]u8 { + var node = initial_node; + const attr = handle(p, .{ .tag = 0, .op = .getattr, .node = node }); + if (attr.status != .ok) return error.FileNotFound; + if (attr.attr.dir) { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + var index: u64 = 0; + const max_bytes = @min(limit, limits.max_stream_bytes); + const prefix = std.mem.trimEnd(u8, path, "/"); + while (true) { + const reply = handle(p, .{ .tag = 0, .op = .readdir, .node = node, .off = index, .size = 8192 }); + if (reply.status != .ok) return error.ReadFailed; + const entries = p.fsPayload(reply); + if (entries.len == 0) return out.toOwnedSlice(); + var off: usize = 0; + while (off + 10 <= entries.len) { + const len: usize = entries[off + 9]; + if (off + 10 + len > entries.len) return error.ReadFailed; + const row_len = prefix.len + len + 2 + @as(usize, @intFromBool(entries[off + 8] != 0)); + if (row_len > max_bytes - out.written().len) return error.FileTooLarge; + try out.writer.print("{s}/{s}", .{ prefix, entries[off + 10 ..][0..len] }); + if (entries[off + 8] != 0) try out.writer.writeByte('/'); + try out.writer.writeByte('\n'); + off += 10 + len; + index += 1; + } + if (off != entries.len) return error.ReadFailed; + } + } + if (attr.attr.size > limit) return error.FileTooLarge; + const opened = handle(p, .{ .tag = 0, .op = .open, .node = node }); + if (opened.status != .ok) return error.OpenFailed; + if (opened.attr.node != 0) node = opened.attr.node; + defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + while (true) { + const want: u32 = @intCast(@min(8192, limit + 1 - out.written().len)); + const reply = handle(p, .{ .tag = 0, .op = .read, .node = node, .handle = opened.handle, .off = out.written().len, .size = want }); + if (reply.status == .again) return error.NotAFile; + if (reply.status != .ok) return error.ReadFailed; + const bytes = p.fsPayload(reply); + if (bytes.len == 0) return out.toOwnedSlice(); + if (bytes.len > limit - out.written().len) return error.FileTooLarge; + try out.writer.writeAll(bytes); + } +} + +pub fn write(p: *pardes.Pardes, path: []const u8, bytes: []const u8) !void { + if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) return error.IsDirectory; + var self_path: ?[]const u8 = null; + if (std.mem.eql(u8, path, "/virtual")) self_path = ""; + if (std.mem.startsWith(u8, path, "/virtual/")) self_path = path[9..]; + if (std.mem.startsWith(u8, path, "/n/")) { + const explicit = path[3..]; + const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; + const name = explicit[0..cut]; + const remote_path = if (cut < explicit.len) explicit[cut..] else "/"; + if (std.mem.eql(u8, name, "os")) return writeFile(remote_path, bytes); + if (std.mem.eql(u8, name, "self")) { + self_path = remote_path; + } else { + for (p.fs.mounts.items) |mount| { + if (!std.mem.eql(u8, name, mount.name)) continue; + if (ninep_io.Client.sameSession(mount.dial, p.fs.socket_path, p.fs.tcp_address, p.fs.quic_address)) { + var buf: [4096]u8 = undefined; + const local = try std.fmt.bufPrint(&buf, "/n{s}", .{remote_path}); + return write(p, local, bytes); + } + return ninep_io.Client.write(p.gpa, mount.dial, remote_path, bytes); + } + return error.FileNotFound; + } + } + if (self_path) |name| { + var node = resolveSelf(p, name) orelse return error.FileNotFound; + const attributes = handle(p, .{ .tag = 0, .op = .getattr, .node = node }); + if (attributes.status != .ok) return error.FileNotFound; + if (attributes.attr.dir) return error.IsDirectory; + if (attributes.attr.mode & 0o200 == 0) return error.ReadOnlyFilesystem; + const opened = handle(p, .{ .tag = 0, .op = .open, .node = node }); + if (opened.status != .ok) return error.OpenFailed; + if (opened.attr.node != 0) node = opened.attr.node; + defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); + var preserved: ?[]u8 = null; + defer if (preserved) |copy| p.gpa.free(copy); + const target = Node.target(node); + if (target != null and target.? == .pane and target.?.pane.file == .body) { + preserved = try p.gpa.dupe(u8, bytes); + const trunc = handle(p, .{ .tag = 0, .op = .setattr, .node = node, .truncate = true }); + if (trunc.status != .ok) return error.WriteFailed; + } + const contents = preserved orelse bytes; + var off: usize = 0; + while (off < contents.len) { + const reply = handle(p, .{ .tag = 0, .op = .write, .node = node, .off = off, .data = contents[off..] }); + if (reply.status != .ok or reply.written == 0) return error.WriteFailed; + off += reply.written; + } + return; + } + return writeFile(path, bytes); +} + +fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?Source { + var wordbuf: [4096]u8 = undefined; + const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null; + if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true); + + var joined: [4096]u8 = undefined; + if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate| + if (normalizeVirtualPath(candidate, scratch)) |normalized| + if (findEmbeddedSource(normalized, true)) |source| return source; + return findEmbeddedSource(normalized_word, false); +} + +fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { + if (std.mem.indexOfScalar(u8, path, 0) != null) return null; + var len: usize = 0; + var parts = std.mem.tokenizeAny(u8, path, "/\\"); + while (parts.next()) |part| { + if (std.mem.eql(u8, part, ".")) continue; + if (std.mem.eql(u8, part, "..")) { + while (len > 0 and out[len - 1] != '/') len -= 1; + if (len > 0) len -= 1; + continue; + } + const extra = part.len + @intFromBool(len != 0); + if (len + extra > out.len) return null; + if (len != 0) { + out[len] = '/'; + len += 1; + } + @memcpy(out[len..][0..part.len], part); + len += part.len; + } + return out[0..len]; +} + +fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?Source { + for (sources) |source| + if (std.mem.eql(u8, source.path, path)) return source; + if (!allow_root_suffix) return null; + for (sources) |source| { + if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue; + if (std.mem.endsWith(u8, path, source.path)) return source; + } + return null; +} + +pub const platform_has_fs = !pardes.isolated and switch (pardes.platform) { + .tty, .gui, .macos => true, + .web, .esp32p4 => false, +}; + +const find_max_hits = 512; +const find_max_depth = 16; +const find_max_steps = 100_000; +pub const search_max_output_bytes = pardes.MAX_PANES * find_max_hits * (4096 + 320); + +const find_skip = [_][]const u8{ + ".git", ".jj", "target", "node_modules", + ".venv", "__pycache__", ".zig-cache", "zig-out", +}; + +pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8) !usize { + var hits: [find_max_hits][]const u8 = undefined; + var hits_len: usize = 0; + if (platform_has_fs) { + const io = std.Io.Threaded.global_single_threaded.io(); + var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); + defer root.close(io); + var w = try root.walkSelectively(arena); + defer w.deinit(); + var steps: usize = 0; + walk: while (steps < find_max_steps and hits_len < hits.len) { + steps += 1; // an unreadable dir burns a step too, so it cannot spin + const e = (try w.next(io)) orelse break; + if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null) { + hits[hits_len] = try arena.dupe(u8, e.path); + hits_len += 1; + } + if (e.kind != .directory or e.depth() >= find_max_depth) continue; + for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; + try w.enter(io, e); + } + } else { + for (sources) |s| { + if (hits_len >= hits.len) break; + if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null) { + hits[hits_len] = s.path; + hits_len += 1; + } + } + } + std.mem.sort([]const u8, hits[0..hits_len], {}, struct { + fn lt(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lt); + var written: usize = 0; + for (hits[0..hits_len]) |h| { + if (h.len + 1 > out.len - written) break; + @memcpy(out[written..][0..h.len], h); + written += h.len; + out[written] = '\n'; + written += 1; + } + return written; +} + +const grep_max_bytes = 256 * 1024; +const grep_max_files = 20_000; + +const GrepResult = struct { bytes: usize, hits: usize }; + +fn grepText(path: []const u8, text: []const u8, pat: []const u8, out: []u8, budget: usize) GrepResult { + var result: GrepResult = .{ .bytes = 0, .hits = 0 }; + var line: usize = 0; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |raw| { + line += 1; + if (result.hits >= budget) break; + const at = std.ascii.indexOfIgnoreCase(raw, pat) orelse continue; + const ln = std.mem.trimEnd(u8, raw, " \t\r"); + var cut = @min(ln.len, 200); + while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1; + const row = std.fmt.bufPrint(out[result.bytes..], "{s}:{d}:{d}{c}{d} {s}\n", .{ + path, line, at + 1, config.range_sep, at + pat.len, ln[0..cut], + }) catch break; + result.bytes += row.len; + result.hits += 1; + } + return result; +} + +pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8) !usize { + var hits: usize = 0; + var written: usize = 0; + if (!platform_has_fs) { + for (sources) |s| { + if (hits >= find_max_hits or written == out.len) break; + const result = grepText(s.path, s.contents, pat, out[written..], find_max_hits - hits); + hits += result.hits; + written += result.bytes; + } + return written; + } + const root_path = std.mem.trimEnd(u8, dir, "/"); + const home = std.mem.trimEnd(u8, base, "/"); + const files = try arena.alloc([]const u8, grep_max_files); + var files_len: usize = 0; + { + const io = std.Io.Threaded.global_single_threaded.io(); + var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); + defer root.close(io); + var w = try root.walkSelectively(arena); + defer w.deinit(); + var steps: usize = 0; + walk: while (steps < find_max_steps and files_len < files.len) { + steps += 1; + const e = (try w.next(io)) orelse break; + if (e.kind == .directory) { + if (e.depth() >= find_max_depth) continue; + for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; + try w.enter(io, e); + continue; + } + if (e.kind != .file) continue; + files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }); + files_len += 1; + } + } + std.mem.sort([]const u8, files[0..files_len], {}, struct { + fn lt(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lt); + const buf = try gpa.alloc(u8, grep_max_bytes); + defer gpa.free(buf); + for (files[0..files_len]) |path| { + if (hits >= find_max_hits or written == out.len) break; + var pathbuf: [4096]u8 = undefined; + const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; + const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .NONBLOCK = true }); + if (fd < 0) continue; + var len: usize = 0; + var readable = true; + while (len < buf.len) { + const n = libc.read(fd, buf[len..].ptr, buf.len - len); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + readable = false; + break; + } + if (n == 0) break; + len += @intCast(n); + } + _ = libc.close(fd); + if (!readable) continue; + const text = buf[0..len]; + if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; + const shown = lsp.rel(home, path); + const result = grepText(shown, text, pat, out[written..], find_max_hits - hits); + hits += result.hits; + written += result.bytes; + } + return written; +} + +test "grep skips a file it cannot read instead of abandoning the search" { + if (!platform_has_fs) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir = base_buf[0..try tmp.dir.realPath(std.testing.io, &base_buf)]; + + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a-locked.txt", .data = "needle here\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "b-open.txt", .data = "needle here\n" }); + var locked_buf: [std.fs.max_path_bytes]u8 = undefined; + const locked = try std.fmt.bufPrintSentinel(&locked_buf, "{s}/a-locked.txt", .{dir}, 0); + if (libc.chmod(locked, 0) != 0) return; + const probe = libc.open(locked, .{ .ACCMODE = .RDONLY }); + if (probe >= 0) { + _ = libc.close(probe); + _ = libc.chmod(locked, 0o644); + return error.SkipZigTest; + } + + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const out = try gpa.alloc(u8, 64 * 1024); + defer gpa.free(out); + const n = try grep(arena.allocator(), gpa, dir, dir, "needle", out); + _ = libc.chmod(locked, 0o644); // so `tmp.cleanup` can remove it + + try std.testing.expect(std.mem.indexOf(u8, out[0..n], "b-open.txt") != null); + try std.testing.expect(std.mem.indexOf(u8, out[0..n], "a-locked.txt") == null); +} + +fn isDir(path: [*:0]const u8) bool { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); + if (fd < 0) return false; + _ = libc.close(fd); + return true; +} + +pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { + return readFileLimit(gpa, path, limits.max_file_bytes); +} + +fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { + if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; + if (!platform_has_fs or std.mem.startsWith(u8, path, "/virtual/")) { + const archive_path = if (std.mem.startsWith(u8, path, "/virtual/")) path[9..] else path; + var normalized_buf: [4096]u8 = undefined; + const normalized = normalizeVirtualPath(archive_path, &normalized_buf) orelse return error.OpenFailed; + const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed; + if (source.contents.len > limit) return error.FileTooLarge; + return gpa.dupe(u8, source.contents); + } + var pathbuf: [4096]u8 = undefined; + const native = localPath(path) orelse return error.OpenFailed; + const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{native}, 0) catch return error.PathTooLong; + const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .NONBLOCK = true }); + if (fd < 0) return switch (libc.errno(fd)) { + .ACCES, .PERM => error.PermissionDenied, + .NOENT => error.FileNotFound, + .ISDIR => error.IsDirectory, + .NAMETOOLONG => error.PathTooLong, + else => error.OpenFailed, + }; + defer _ = libc.close(fd); + + const end = libc.lseek(fd, 0, libc.SEEK.END); + if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile; + const size: usize = if (end < 0) 0 else @intCast(end); + if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; + if (size == 0) { + const max_bytes = @min(limit, limits.max_stream_bytes); + var stream: std.Io.Writer.Allocating = .init(gpa); + errdefer stream.deinit(); + var chunk: [16 * 1024]u8 = undefined; + while (true) { + const n = libc.read(fd, &chunk, @min(chunk.len, max_bytes - stream.written().len + 1)); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + if (libc.errno(n) == .AGAIN) { + if (stream.written().len == 0) return error.NotAFile; + return stream.toOwnedSlice(); + } + return error.ReadFailed; + } + if (n == 0) return stream.toOwnedSlice(); + const received: usize = @intCast(n); + if (received > max_bytes - stream.written().len) return error.FileTooLarge; + try stream.writer.writeAll(chunk[0..received]); + } + } + if (size > limit) return error.FileTooLarge; + var buf = try gpa.alloc(u8, size); + errdefer gpa.free(buf); + var len: usize = 0; + while (len < buf.len) { + const n = libc.read(fd, buf[len..].ptr, buf.len - len); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return error.ReadFailed; + } + if (n == 0) break; + len += @intCast(n); + } + if (len != buf.len) buf = try gpa.realloc(buf, len); + return buf; +} + +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const MAX_PANES = pardes.MAX_PANES; + +pub const namespace_root: u64 = 1 << 63; +pub const namespace_panes: u64 = namespace_root + 1; +pub const os_root: u64 = namespace_root + 2; +pub const os_node: u64 = 1 << 62; +const factory_base: u64 = namespace_root + 256; + +pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { + var path_buf: [4096]u8 = undefined; + const normalized = normalizeVirtualPath(path, &path_buf) orelse return null; + var parts = std.mem.tokenizeScalar(u8, normalized, '/'); + const first = parts.next() orelse return @intFromEnum(SelfFile.root); + if (!std.mem.eql(u8, first, "pane")) { + const top = topFileNamed(first) orelse return archiveNode(normalized); + if (parts.next()) |name| { + if (top != .new or parts.next() != null) return null; + const file = paneFileNamed(name) orelse return null; + if (file.inPty()) return null; + return factory_base + @intFromEnum(file); + } + return @intFromEnum(top); + } + const serial = serialNamed(parts.next() orelse return namespace_panes) orelse return null; + const id = p.paneBySerial(serial) orelse return null; + const file = paneFileNamed(parts.next() orelse return Node.of(serial, .dir)) orelse return null; + if (file.inPty() and !p.panes[id].?.isTerminal()) return null; + if (parts.next()) |name| { + if (file != .pty) return null; + const child = ptyFileNamed(name) orelse return null; + if (parts.next() != null) return null; + return Node.of(serial, child); + } + return Node.of(serial, file); +} + +fn namespace(p: *Pardes, req: Req) Reply { + switch (req.op) { + .getattr => return .{ .tag = req.tag, .attr = .{ .node = req.node, .dir = true, .mode = 0o500 } }, + .open => return .{ .tag = req.tag, .handle = 1 }, + .release => return .{ .tag = req.tag }, + .lookup => { + if (std.mem.eql(u8, req.data, "..")) return handle(p, .{ + .tag = req.tag, + .op = .getattr, + .node = if (req.node == namespace_root) namespace_root else @intFromEnum(SelfFile.root), + }); + if (req.node == namespace_root) { + if (std.mem.eql(u8, req.data, "self")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) }); + if (std.mem.eql(u8, req.data, "os")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = os_root }); + } else { + const serial = serialNamed(req.data) orelse return Reply.fail(req.tag, E.NOENT); + if (serial == 0) return Reply.fail(req.tag, E.NOENT); + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = Node.of(serial, .dir) }); + } + return Reply.fail(req.tag, E.NOENT); + }, + .readdir => { + const out = p.fs.stage(p.gpa); + var skip = req.off; + if (req.node == namespace_root) { + if (skip == 0) stageDirent(out, p.gpa, os_root, true, "os") else skip -= 1; + if (skip == 0) stageDirent(out, p.gpa, @intFromEnum(SelfFile.root), true, "self"); + } else { + var last: u32 = 0; + while (nextSerialAfter(p, last)) |serial| { + last = serial; + if (skip > 0) { + skip -= 1; + continue; + } + var buf: [16]u8 = undefined; + const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable; + stageDirent(out, p.gpa, Node.of(serial, .dir), true, name); + } + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; + }, + else => return Reply.fail(req.tag, E.PERM), + } +} + +pub const Op = enum(u8) { + lookup, + getattr, + setattr, + open, + read, + write, + release, + readdir, +}; + +pub const Status = enum(u8) { + ok, + again, + err, +}; + +pub const Req = struct { + tag: u64, + op: Op, + node: u64, + handle: u32 = 0, + off: u64 = 0, + size: u32 = 0, + data: []const u8 = &.{}, + truncate: bool = false, + + pub fn changesPane(req: Req) bool { + return switch (req.op) { + .write, .setattr => true, + .lookup => req.node == @intFromEnum(SelfFile.new) and !std.mem.eql(u8, req.data, ".."), + .open => req.node >= factory_base and req.node < factory_base + 16, + .getattr, .read, .release, .readdir => false, + }; + } +}; + +pub const Reply = struct { + tag: u64, + status: Status = .ok, + errno: u16 = 0, + attr: Attr = .{}, + handle: u32 = 0, + payload: Payload = .none, + written: u32 = 0, + + pub const Attr = struct { + name: []const u8 = "", + node: u64 = 0, + dir: bool = false, + size: u64 = 0, + mode: u16 = 0o600, + }; + + pub const Payload = union(enum) { + none, + staged: u32, + region: struct { pane: u8, serial: u32, off: u32, len: u32 }, + }; + + pub fn fail(tag: u64, e: u16) Reply { + return .{ .tag = tag, .status = .err, .errno = e }; + } +}; + +pub const E = struct { + pub const PERM: u16 = 1; + pub const NOENT: u16 = 2; + pub const IO: u16 = 5; + pub const NOMEM: u16 = 12; + pub const NOTDIR: u16 = 20; + pub const INVAL: u16 = 22; + pub const NFILE: u16 = 23; + pub const NOSPC: u16 = 28; + pub const NOSYS: u16 = 38; +}; + +pub const PaneFile = enum(u4) { + dir = 0, + addr, + body, + ctl, + data, + errors, + event, + tag, + xdata, + rdsel, + wrsel, + pty, + pty_ctl, + pty_status, + pty_data, + + pub fn name(f: PaneFile) []const u8 { + return switch (f) { + .dir => ".", + .pty_ctl => "ctl", + .pty_status => "status", + .pty_data => "data", + else => @tagName(f), + }; + } + + pub fn mode(f: PaneFile) u16 { + return switch (f) { + .dir, .pty => 0o500, + .errors, .wrsel, .pty_ctl => 0o200, + .rdsel, .pty_status => 0o400, + else => 0o600, + }; + } + + pub fn isDir(f: PaneFile) bool { + return f == .dir or f == .pty; + } + + pub fn inPty(f: PaneFile) bool { + return switch (f) { + .pty, .pty_ctl, .pty_status, .pty_data => true, + else => false, + }; + } +}; + +pub const SelfFile = enum(u4) { + root = 1, + index = 2, + cons = 3, + new = 4, + screen = 5, + listeners = 6, + + pub fn name(f: SelfFile) []const u8 { + return if (f == .root) "." else @tagName(f); + } + + pub fn mode(f: SelfFile) u16 { + return switch (f) { + .root, .new => 0o500, + .index, .screen, .listeners => 0o400, + .cons => 0o200, + }; + } + + pub fn dir(f: SelfFile) bool { + return f == .root or f == .new; + } +}; + +pub const Node = packed struct(u64) { + file: u4 = 0, + serial: u60 = 0, + + pub fn of(serial: u32, file: PaneFile) u64 { + std.debug.assert(serial != 0); + return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial }); + } + + pub fn target(node: u64) ?Target { + const n: Node = @bitCast(node); + if (n.serial == 0) { + return .{ .top = std.enums.fromInt(SelfFile, n.file) orelse return null }; + } + return .{ .pane = .{ + .serial = std.math.cast(u32, n.serial) orelse return null, + .file = std.enums.fromInt(PaneFile, n.file) orelse return null, + } }; + } +}; + +pub const Target = union(enum) { + top: SelfFile, + pane: struct { serial: u32, file: PaneFile }, +}; + +pub const out_reserve = 4 * 1024; + +pub const queue_cap = 64 * 1024; + +pub const Queue = struct { + buf: std.ArrayList(u8) = .empty, + head: usize = 0, + + pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void { + q.buf.deinit(gpa); + q.head = 0; + } + + pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void { + if (record.len > std.math.maxInt(u32)) return; + while (q.buf.items.len - q.head + record.len + 4 > queue_cap) { + if (q.peek() == null) return; + q.pop(); + } + q.compact(); + var head: [4]u8 = undefined; + std.mem.writeInt(u32, &head, @intCast(record.len), .little); + q.buf.appendSlice(gpa, &head) catch return; + q.buf.appendSlice(gpa, record) catch { + q.buf.shrinkRetainingCapacity(q.buf.items.len - 4); + return; + }; + } + + pub fn peek(q: *const Queue) ?[]const u8 { + const rest = q.buf.items[@min(q.head, q.buf.items.len)..]; + if (rest.len < 4) return null; + const len = std.mem.readInt(u32, rest[0..4], .little); + if (rest.len < 4 + len) return null; + return rest[4 .. 4 + len]; + } + + pub fn pop(q: *Queue) void { + const record = q.peek() orelse return; + q.head += 4 + record.len; + if (q.head == q.buf.items.len) { + q.buf.clearRetainingCapacity(); + q.head = 0; + } + } + + pub fn popFront(q: *Queue, n: usize) void { + const record = q.peek() orelse return; + if (n >= record.len) return q.pop(); + q.head += n; + std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little); + } + + fn compact(q: *Queue) void { + if (q.head == 0 or q.head * 2 < q.buf.items.len) return; + const rest = q.buf.items.len - q.head; + std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]); + q.buf.shrinkRetainingCapacity(rest); + q.head = 0; + } + + pub fn empty(q: *const Queue) bool { + return q.peek() == null; + } + + pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void { + q.buf.clearAndFree(gpa); + q.head = 0; + } +}; + +pub const PaneState = struct { + addr: Range = .{}, + limit: ?Range = null, + readers: u16 = 0, + events: Queue = .{}, + nomark: bool = false, + noscroll: bool = false, + tag_snap: std.ArrayList(u8) = .empty, + pty_readers: u16 = 0, + pty_out: Queue = .{}, + + pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; + + fn deinit(pf: *PaneState, gpa: std.mem.Allocator) void { + pf.events.deinit(gpa); + pf.pty_out.deinit(gpa); + pf.tag_snap.deinit(gpa); + pf.* = .{}; + } +}; + +pub const Namespace = struct { + socket_path: []const u8 = "", + tcp_address: ?std.Io.net.IpAddress = null, + quic_address: ?std.Io.net.IpAddress = null, + mounts: std.ArrayList(Mount) = .empty, + node_name: [16]u8 = undefined, + os_paths: std.ArrayList(OsPath) = .empty, + snapshots: [32]struct { node: u64 = 0, bytes: ?[]const u8 = null } = @splat(.{}), + out: std.ArrayList(u8) = .empty, + panes: [MAX_PANES]PaneState = @splat(.{}), + listeners: u16 = 0, + origin: u8 = 'K', + + pub fn deinit(st: *Namespace, gpa: std.mem.Allocator) void { + for (st.mounts.items) |entry| { + gpa.free(entry.name); + gpa.free(entry.dial); + } + st.mounts.deinit(gpa); + for (st.os_paths.items) |entry| gpa.free(entry.path); + st.os_paths.deinit(gpa); + for (st.snapshots) |snapshot| if (snapshot.bytes) |bytes| gpa.free(bytes); + for (&st.panes) |*pf| pf.deinit(gpa); + st.out.deinit(gpa); + } + + pub fn mount(st: *Namespace, gpa: std.mem.Allocator, name: []const u8, dial: []const u8) !void { + if (!validMountName(name)) return error.BadMountName; + if (comptime pardes.hosted) try ninep_io.Client.validateDial(dial); + if (dial.len == 0 or std.mem.indexOfScalar(u8, dial, 0) != null) return error.BadDial; + for (st.mounts.items) |entry| if (std.mem.eql(u8, name, entry.name)) return error.AlreadyMounted; + if (st.mounts.items.len == max_mounts) return error.TooManyMounts; + const saved_name = try gpa.dupe(u8, name); + errdefer gpa.free(saved_name); + const saved_dial = try gpa.dupe(u8, dial); + errdefer gpa.free(saved_dial); + try st.mounts.append(gpa, .{ .name = saved_name, .dial = saved_dial }); + } + + pub fn stage(st: *Namespace, gpa: std.mem.Allocator) *std.ArrayList(u8) { + st.out.clearRetainingCapacity(); + st.out.ensureTotalCapacity(gpa, out_reserve) catch {}; + return &st.out; + } + + pub fn forget(st: *Namespace, gpa: std.mem.Allocator, id: usize) void { + if (id >= MAX_PANES) return; + st.listeners -= @min(st.listeners, st.panes[id].readers); + st.panes[id].deinit(gpa); + } + + pub fn scripted(st: *const Namespace, id: usize) bool { + return id < MAX_PANES and st.panes[id].readers != 0; + } +}; + +pub fn unmount(p: *Pardes, name: []const u8) !void { + for (p.fs.mounts.items, 0..) |entry, index| { + if (!std.mem.eql(u8, name, entry.name)) continue; + var prefix_buf: [name_capacity + 4]u8 = undefined; + const prefix = try std.fmt.bufPrint(&prefix_buf, "/n/{s}", .{name}); + for (p.panes) |slot| { + const pane = slot orelse continue; + const paths = [_][]const u8{ + if (pane.file) |file| file.path else "", + if (comptime pardes.pdf_enabled) (if (pane.pdf) |pdf| pdf.path else "") else "", + if (pane.image) |image| image.path else "", + Pardes.paneDir(pane), + }; + for (paths) |path| if (std.mem.startsWith(u8, path, prefix) and + (path.len == prefix.len or path[prefix.len] == '/')) return error.MountInUse; + } + for (0..p.effects_len) |i| { + const effect = p.effects[(p.effects_head + i) % p.effects.len]; + if (effect != .save_text) continue; + const path = effect.save_text.path.slice(); + if (std.mem.startsWith(u8, path, prefix) and + (path.len == prefix.len or path[prefix.len] == '/')) return error.MountInUse; + } + const removed = p.fs.mounts.orderedRemove(index); + p.gpa.free(removed.name); + p.gpa.free(removed.dial); + return; + } + return error.NotMounted; +} + +pub const max_record_text = 256; + +pub const Action = enum(u8) { + body_delete = 'D', + tag_delete = 'd', + body_insert = 'I', + tag_insert = 'i', + body_look = 'L', + tag_look = 'l', + body_exec = 'X', + tag_exec = 'x', + + pub fn char(a: Action) u8 { + return @intFromEnum(a); + } + + pub fn fromChar(c: u8) ?Action { + return std.enums.fromInt(Action, c); + } + + pub fn onTag(a: Action) bool { + return @intFromEnum(a) >= 'a'; + } +}; + +pub const flag_builtin: u32 = 1; +pub const flag_expansion: u32 = 2; +pub const flag_filename: u32 = 4; +pub const flag_chorded: u32 = 8; + +pub fn formatRecord( + buf: []u8, + origin: u8, + action: Action, + q0: u32, + q1: u32, + flag: u32, + text: []const u8, +) []const u8 { + const sent = if (text.len >= max_record_text) text[0..0] else text; + return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{ + origin, + action.char(), + q0, + q1, + flag, + sent.len, + sent, + }) catch buf[0..0]; +} + +pub const Span = struct { at: u32, removed: u32, inserted: u32 }; + +pub fn diffSpan(old: []const u8, new: []const u8) Span { + const both = @min(old.len, new.len); + const stride = 64; + var head: usize = 0; + while (head + stride <= both and + std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride; + while (head < both and old[head] == new[head]) head += 1; + var tail: usize = 0; + const rest = both - head; + while (tail + stride <= rest and std.mem.eql( + u8, + old[old.len - tail - stride ..][0..stride], + new[new.len - tail - stride ..][0..stride], + )) tail += stride; + while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1; + return .{ + .at = @intCast(head), + .removed = @intCast(old.len - tail - head), + .inserted = @intCast(new.len - tail - head), + }; +} + +pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void { + if (!p.fs.scripted(id)) return; + const span = diffSpan(old, new); + if (span.removed == 0 and span.inserted == 0) return; + if (span.removed > 0) _ = noteAction( + p, + id, + if (on_tag) .tag_delete else .body_delete, + span.at, + span.at + span.removed, + 0, + "", + ); + if (span.inserted > 0) _ = noteAction( + p, + id, + if (on_tag) .tag_insert else .body_insert, + span.at, + span.at + span.inserted, + 0, + new[span.at..][0..span.inserted], + ); +} + +pub fn noteAction( + p: *Pardes, + id: usize, + action: Action, + q0: u32, + q1: u32, + flag: u32, + text: []const u8, +) bool { + if (!p.fs.scripted(id)) return false; + var buf: [max_record_text + 64]u8 = undefined; + const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text); + p.fs.panes[id].events.push(p.gpa, record); + return true; +} + +pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { + if (id >= MAX_PANES or bytes.len == 0) return; + const pf = &p.fs.panes[id]; + if (pf.pty_readers == 0) return; + var off: usize = 0; + while (off < bytes.len) { + const n = @min(bytes.len - off, queue_cap / 2); + pf.pty_out.push(p.gpa, bytes[off..][0..n]); + off += n; + } +} + +pub fn handle(p: *Pardes, req: Req) Reply { + if (req.node >= factory_base and req.node < factory_base + 16) { + const file = std.enums.fromInt(PaneFile, req.node - factory_base) orelse return Reply.fail(req.tag, E.NOENT); + if (req.op == .getattr) return .{ .tag = req.tag, .attr = .{ .node = req.node, .name = file.name(), .mode = file.mode() } }; + if (req.op != .open) return Reply.fail(req.tag, E.PERM); + const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); + const node = Node.of(serial, file); + var reply = handle(p, .{ .tag = req.tag, .op = .open, .node = node }); + reply.attr.node = node; + return reply; + } + if (req.node == namespace_root or req.node == namespace_panes) return namespace(p, req); + if (req.node == os_root or req.node & os_node != 0) return osHandle(p, req); + if (req.node & archive_node != 0) return archiveHandle(p, req); + const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); + if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { + .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), + .top => 'F', + }; + return switch (req.op) { + .lookup => lookup(p, req, target), + .getattr => switch (attrOf(p, target)) { + .ok => |a| .{ .tag = req.tag, .attr = a }, + .missing => Reply.fail(req.tag, E.NOENT), + }, + .setattr => setattr(p, req, target), + .open => open(p, req, target), + .release => release(p, req), + .readdir => readdir(p, req, target), + .read => handleRead(p, req, target), + .write => handleWrite(p, req, target), + }; +} + +const AttrResult = union(enum) { ok: Reply.Attr, missing }; + +fn attrOf(p: *Pardes, target: Target) AttrResult { + switch (target) { + .top => |f| return .{ .ok = .{ + .name = if (f == .root) "self" else f.name(), + .node = @intFromEnum(f), + .dir = f.dir(), + .mode = f.mode(), + .size = topSize(p, f), + } }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return .missing; + if (t.file.inPty() and !p.panes[id].?.isTerminal()) return .missing; + return .{ .ok = .{ + .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.name(), + .node = Node.of(t.serial, t.file), + .dir = t.file.isDir(), + .mode = t.file.mode(), + .size = paneFileSize(p, id, t.file), + } }; + }, + } +} + +fn topSize(p: *Pardes, f: SelfFile) u64 { + return switch (f) { + .root, .new, .cons, .screen, .listeners => 0, + .index => indexLen(p), + }; +} + +fn paneFileSize(p: *Pardes, id: usize, f: PaneFile) u64 { + const pane = p.panes[id] orelse return 0; + return switch (f) { + .body, .data, .xdata => bodyLen(p, pane), + .tag => tagLen(p, pane), + .dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 0, + .pty, .pty_ctl, .pty_status, .pty_data => 0, + }; +} + +fn bodyOf(pane: *const Pane) []const u8 { + if (pane.file) |*f| return f.content; + return ""; +} + +fn fileOf(pane: *Pane) ?*panes.File.State { + return if (pane.file) |*f| f else null; +} + +fn tagOf(p: *Pardes, pane: *Pane) []const u8 { + return p.tagText(p.scratch.allocator(), pane) catch ""; +} + +fn dirOf(pane: *Pane) []const u8 { + if (pane.file) |*f| return std.fs.path.dirname(f.path) orelse "/"; + const cwd = pane.cwdSlice(); + return if (cwd.len > 0) cwd else "/"; +} + +fn dirtyOf(pane: *const Pane) bool { + const f = if (pane.file) |*x| x else return false; + if (!panes.Output.fileTraits(f.output).saves) return false; + return f.revision != f.saved_revision; +} + +fn clip(n: usize) u32 { + return std.math.cast(u32, n) orelse std.math.maxInt(u32); +} + +fn cellOf(row: i32, col: i32) modal.Cursor { + return .{ .row = @intCast(@max(0, row)), .col = @intCast(@max(0, col)) }; +} + +fn firstLine(s: []const u8) []const u8 { + return s[0 .. std.mem.indexOfScalar(u8, s, '\n') orelse s.len]; +} + +fn dotOf(pane: *Pane) PaneState.Range { + const text = bodyOf(pane); + const head = modal.offsetAt(text, cellOf(pane.cur_row, pane.cur_col)); + if (!pane.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; + const anchor = modal.offsetAt(text, cellOf(pane.vsel.row, pane.vsel.col)); + var hi = @max(head, anchor); + if (hi < text.len) hi = modal.nextGrapheme(text, hi); + return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; +} + +fn setDot(pane: *Pane, r: PaneState.Range) void { + const text = bodyOf(pane); + const q0 = @min(@as(usize, r.q0), text.len); + const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); + const a = modal.positionAt(text, q0); + pane.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; + const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0); + pane.cur_row = @intCast(h.row); + pane.cur_col = @intCast(h.col); + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.msel.active = false; + pane.ensureCursorVisible(); +} + +fn showOffset(pane: *Pane, off: usize) void { + const text = bodyOf(pane); + const c = modal.positionAt(text, @min(off, text.len)); + pane.cur_row = @intCast(c.row); + pane.cur_col = @intCast(c.col); + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); +} + +fn clampAddr(pf: *PaneState, len: usize) void { + const n = clip(len); + pf.addr.q0 = @min(pf.addr.q0, n); + pf.addr.q1 = @min(pf.addr.q1, n); + if (pf.limit) |*l| { + l.q0 = @min(l.q0, n); + l.q1 = @min(l.q1, n); + } +} + +fn shiftBy(r: PaneState.Range, at: u32, removed: u32, inserted: u32) PaneState.Range { + return .{ .q0 = shiftOne(r.q0, at, removed, inserted), .q1 = shiftOne(r.q1, at, removed, inserted) }; +} + +fn shiftOne(v: u32, at: u32, removed: u32, inserted: u32) u32 { + if (v <= at) return v; + if (v <= at +| removed) return at +| inserted; + return v - removed +| inserted; +} + +fn wholeUtf8(data: []const u8) usize { + var i = data.len; + var back: usize = 0; + while (i > 0 and back < 4) : (back += 1) { + i -= 1; + const c = data[i]; + if (c < 0x80) return data.len; + if (c & 0xC0 == 0xC0) { + const need = std.unicode.utf8ByteSequenceLength(c) catch return data.len; + if (i + need <= data.len or i == 0) return data.len; + return i; + } + } + return data.len; +} + +fn paneFileNamed(name: []const u8) ?PaneFile { + const f = std.meta.stringToEnum(PaneFile, name) orelse return null; + if (f == .dir) return null; + return if (f.inPty() and f != .pty) null else f; +} + +fn ptyFileNamed(name: []const u8) ?PaneFile { + if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; + if (std.mem.eql(u8, name, "status")) return .pty_status; + if (std.mem.eql(u8, name, "data")) return .pty_data; + return null; +} + +fn topFileNamed(name: []const u8) ?SelfFile { + const f = std.meta.stringToEnum(SelfFile, name) orelse return null; + return if (f == .root) null else f; +} + +fn serialNamed(name: []const u8) ?u32 { + if (name.len == 0 or name.len > 10) return null; + for (name) |c| if (c < '0' or c > '9') return null; + return std.fmt.parseInt(u32, name, 10) catch null; +} + +fn nextSerialAfter(p: *Pardes, after: u32) ?u32 { + var best: ?u32 = null; + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.serial <= after) continue; + if (best == null or pane.serial < best.?) best = pane.serial; + } + return best; +} + +fn newPane(p: *Pardes) ?u32 { + const slot = p.freeSlot() orelse return null; + p.newScratchBelow(p.active); + const pane = p.panes[slot] orelse return null; + return pane.serial; +} + +fn lookup(p: *Pardes, req: Req, target: Target) Reply { + const name = req.data; + if (std.mem.eql(u8, name, "..")) { + const parent: u64 = switch (target) { + .top => |top| if (top == .root) namespace_root else @intFromEnum(SelfFile.root), + .pane => |t| if (t.file == .pty) Node.of(t.serial, .dir) else namespace_panes, + }; + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent }); + } + if (name.len == 0 or std.mem.indexOfScalar(u8, name, '/') != null) return Reply.fail(req.tag, E.NOENT); + const node: u64 = switch (target) { + .top => |f| switch (f) { + .root => root: { + if (std.mem.eql(u8, name, "pane")) return .{ .tag = req.tag, .attr = .{ .node = namespace_panes, .dir = true, .mode = 0o500 } }; + if (topFileNamed(name)) |t| break :root @intFromEnum(t); + if (archiveNode(name)) |node| return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); + return Reply.fail(req.tag, E.NOENT); + }, + .new => new: { + const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT); + if (want.inPty()) return Reply.fail(req.tag, E.NOENT); + const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); + break :new Node.of(serial, want); + }, + else => return Reply.fail(req.tag, E.NOTDIR), + }, + .pane => |t| pane: { + _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const f = switch (t.file) { + .dir => paneFileNamed(name), + .pty => ptyFileNamed(name), + else => return Reply.fail(req.tag, E.NOTDIR), + } orelse return Reply.fail(req.tag, E.NOENT); + break :pane Node.of(t.serial, f); + }, + }; + return switch (attrOf(p, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT))) { + .ok => |a| .{ .tag = req.tag, .attr = a }, + .missing => Reply.fail(req.tag, E.NOENT), + }; +} + +pub fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void { + if (name.len == 0 or name.len > 255) return; + var head: [10]u8 = undefined; + std.mem.writeInt(u64, head[0..8], node, .little); + head[8] = @intFromBool(dir); + head[9] = @intCast(name.len); + out.appendSlice(gpa, &head) catch return; + out.appendSlice(gpa, name) catch return; +} + +fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void { + inline for (comptime std.enums.values(PaneFile)) |f| { + if (comptime f == .dir or (f.inPty() and f != .pty)) continue; + const present = f != .pty or terminal; + if (present) { + if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.name()); + } + } +} + +fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void { + inline for (comptime std.enums.values(PaneFile)) |f| { + if (comptime !f.inPty() or f == .pty) continue; + if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.name()); + } +} + +fn readdir(p: *Pardes, req: Req, target: Target) Reply { + const out = p.fs.stage(p.gpa); + var skip = req.off; + switch (target) { + .top => |f| switch (f) { + .root => { + inline for (.{ SelfFile.index, SelfFile.cons, SelfFile.new }) |t| { + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.name()); + } + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, namespace_panes, true, "pane"); + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.screen), false, "screen"); + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.listeners), false, "listeners"); + stageArchive(p, out, "", &skip); + }, + .new => {}, + else => return Reply.fail(req.tag, E.NOTDIR), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const terminal = p.panes[id].?.isTerminal(); + switch (t.file) { + .dir => stagePaneFiles(p, out, t.serial, terminal, &skip), + .pty => { + if (!terminal) return Reply.fail(req.tag, E.NOENT); + stagePtyFiles(p, out, t.serial, &skip); + }, + else => return Reply.fail(req.tag, E.NOTDIR), + } + }, + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +fn open(p: *Pardes, req: Req, target: Target) Reply { + const snapshot = switch (target) { + .top => |f| f == .screen, + .pane => |t| t.file == .body and if (p.paneBySerial(t.serial)) |id| p.panes[id].?.isTerminal() else false, + }; + if (snapshot) { + for (&p.fs.snapshots, 0..) |*slot, i| { + if (slot.node != 0) continue; + const bytes = if (target == .top) screenSnapshot(p) catch return Reply.fail(req.tag, E.NOMEM) else null; + slot.* = .{ .node = req.node, .bytes = bytes }; + return .{ .tag = req.tag, .handle = @intCast(i + 1) }; + } + return Reply.fail(req.tag, E.NFILE); + } + switch (target) { + .top => {}, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pf = &p.fs.panes[id]; + if (t.file.inPty() and !p.panes[id].?.isTerminal()) return Reply.fail(req.tag, E.NOENT); + switch (t.file) { + .ctl => pf.limit = null, + .addr => { + pf.addr = .{}; + pf.limit = null; + }, + .event => { + pf.readers +|= 1; + p.fs.listeners +|= 1; + }, + .pty_data => pf.pty_readers +|= 1, + else => {}, + } + }, + } + return .{ .tag = req.tag, .handle = 1 }; +} + +fn release(p: *Pardes, req: Req) Reply { + if (req.handle > 0 and req.handle <= p.fs.snapshots.len) { + const snapshot = &p.fs.snapshots[req.handle - 1]; + if (snapshot.node == req.node) { + if (snapshot.bytes) |bytes| p.gpa.free(bytes); + snapshot.* = .{}; + return .{ .tag = req.tag }; + } + } + const target = Node.target(req.node) orelse return .{ .tag = req.tag }; + switch (target) { + .top => {}, + .pane => |t| { + if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; + const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; + const pf = &p.fs.panes[id]; + if (t.file == .pty_data) { + if (pf.pty_readers == 0) return .{ .tag = req.tag }; + pf.pty_readers -= 1; + if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa); + return .{ .tag = req.tag }; + } + if (pf.readers == 0) return .{ .tag = req.tag }; + pf.readers -= 1; + p.fs.listeners -|= 1; + if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa); + }, + } + return .{ .tag = req.tag }; +} + +fn setattr(p: *Pardes, req: Req, target: Target) Reply { + if (req.truncate) switch (target) { + .pane => |t| switch (t.file) { + .body, .data, .xdata => { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pane = p.panes[id].?; + if (fileOf(pane) != null) { + _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse + return Reply.fail(req.tag, E.NOMEM); + p.fs.panes[id].addr = .{}; + setDot(pane, .{}); + } + }, + else => {}, + }, + else => {}, + }; + return switch (attrOf(p, target)) { + .ok => |a| .{ .tag = req.tag, .attr = a }, + .missing => Reply.fail(req.tag, E.NOENT), + }; +} + +fn staged(p: *Pardes, req: Req) Reply { + const out = &p.fs.out; + const off = @min(req.off, out.items.len); + const n = @min(out.items.len - off, req.size); + if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]); + out.shrinkRetainingCapacity(n); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; +} + +fn handleRead(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| return switch (f) { + .index => readIndex(p, req), + .listeners => listeners: { + var buf: [512]u8 = undefined; + var text = std.Io.Writer.fixed(&buf); + if (p.fs.socket_path.len != 0) + text.print("unix!{s}\n", .{p.fs.socket_path}) catch break :listeners Reply.fail(req.tag, E.IO); + for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| { + const address = maybe orelse continue; + switch (address) { + .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch + break :listeners Reply.fail(req.tag, E.IO), + .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch + break :listeners Reply.fail(req.tag, E.IO), + } + } + const bytes = text.buffered(); + const off = @min(req.off, bytes.len); + const len = @min(bytes.len - off, req.size); + p.fs.stage(p.gpa).appendSlice(p.gpa, bytes[off..][0..len]) catch break :listeners Reply.fail(req.tag, E.NOMEM); + break :listeners .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; + }, + .screen => readSnapshot(p, req, null), + .cons, .root, .new => Reply.fail(req.tag, E.PERM), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pane = p.panes[id].?; + const pf = &p.fs.panes[id]; + if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); + return switch (t.file) { + .addr => readAddr(p, req, pf, pane), + .body => readBody(p, req, id, pane), + .ctl => readCtl(p, req, pane), + .data => readData(req, id, pane, pf, false), + .xdata => readData(req, id, pane, pf, true), + .tag => readTag(p, req, pane), + .event => readQueue(p, req, &pf.events), + .rdsel => readRdsel(req, id, pane), + .pty_status => readPtyStatus(p, req, id, pane), + .pty_data => readPtyData(p, req, pf), + .dir, .errors, .wrsel, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM), + }; + }, + } +} + +fn screenSnapshot(p: *Pardes) ![]u8 { + var arena: std.heap.ArenaAllocator = .init(p.gpa); + defer arena.deinit(); + const surface = try p.render(arena.allocator()); + var styles: std.ArrayList(pardes.CellStyle) = .empty; + var indices: std.ArrayList(usize) = .empty; + for (surface.cells) |cell| { + const style: pardes.CellStyle = if (cell.default) .{} else cell.style; + const index = for (styles.items, 0..) |previous, i| { + if (std.meta.eql(style, previous)) break i; + } else new: { + try styles.append(arena.allocator(), style); + break :new styles.items.len - 1; + }; + try indices.append(arena.allocator(), index); + } + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + const writer = &out.writer; + try writer.print("{{\"cols\":{d},\"rows\":{d},\"cursor\":", .{ surface.cols, surface.rows }); + try std.json.Stringify.value(surface.cursor, .{}, writer); + try writer.writeAll(",\"styles\":"); + try std.json.Stringify.value(styles.items, .{ .emit_strings_as_arrays = true }, writer); + try writer.writeAll(",\"cells\":["); + for (surface.cells, indices.items, 0..) |cell, index, i| { + if (i != 0) try writer.writeByte(','); + try std.json.Stringify.value(.{ if (cell.default) " " else cell.grapheme(), index }, .{}, writer); + } + try writer.writeAll("]}\n"); + return out.toOwnedSlice(); +} + +const ctl_fields = 5 * 12; + +fn stageCtlNumbers(p: *Pardes, out: *std.ArrayList(u8), pane: *Pane) void { + out.print(p.gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ + pane.serial, + tagOf(p, pane).len, + bodyOf(pane).len, + @as(u32, 0), + @intFromBool(dirtyOf(pane)), + }) catch {}; +} + +fn readIndex(p: *Pardes, req: Req) Reply { + const out = p.fs.stage(p.gpa); + var last: u32 = 0; + while (nextSerialAfter(p, last)) |s| { + last = s; + const pane = p.panes[p.paneBySerial(s).?].?; + stageCtlNumbers(p, out, pane); + out.appendSlice(p.gpa, firstLine(tagOf(p, pane))) catch {}; + out.append(p.gpa, '\n') catch {}; + } + return staged(p, req); +} + +fn readAddr(p: *Pardes, req: Req, pf: *PaneState, pane: *Pane) Reply { + clampAddr(pf, bodyOf(pane).len); + const out = p.fs.stage(p.gpa); + out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {}; + return staged(p, req); +} + +fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + if (pane.file != null) { + const text = bodyOf(pane); + const off = @min(req.off, text.len); + const n = @min(text.len - off, req.size); + return .{ .tag = req.tag, .payload = .{ .region = .{ + .pane = @intCast(id), + .serial = pane.serial, + .off = clip(off), + .len = clip(n), + } } }; + } + if (req.handle != 0 and pane.isTerminal()) return readSnapshot(p, req, pane); + const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch + return Reply.fail(req.tag, E.NOMEM); + defer p.gpa.free(text); + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); + return staged(p, req); +} + +fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply { + if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL); + const snapshot = &p.fs.snapshots[req.handle - 1]; + if (snapshot.node == 0 or snapshot.node != req.node) return Reply.fail(req.tag, E.INVAL); + if (snapshot.bytes == null) { + const terminal = pane orelse return Reply.fail(req.tag, E.INVAL); + snapshot.bytes = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); + } + const bytes = snapshot.bytes.?; + const off = @min(req.off, bytes.len); + const len = @min(bytes.len - off, req.size); + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, bytes[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; +} + +fn fontName(p: *Pardes) []const u8 { + const name = p.settings.font.effective_name.get(); + return if (name.len == 0) "default" else name; +} + +fn stageQuoted(out: *std.ArrayList(u8), gpa: std.mem.Allocator, s: []const u8) void { + const plain = s.len > 0 and for (s) |c| { + if (c <= ' ' or c == '\'') break false; + } else true; + if (plain) { + out.appendSlice(gpa, s) catch {}; + return; + } + out.append(gpa, '\'') catch {}; + for (s) |c| { + if (c == '\'') out.append(gpa, '\'') catch {}; + out.append(gpa, c) catch {}; + } + out.append(gpa, '\'') catch {}; +} + +fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { + const out = p.fs.stage(p.gpa); + stageCtlNumbers(p, out, pane); + out.print(p.gpa, "{d:>11} ", .{pane.cols}) catch {}; + stageQuoted(out, p.gpa, fontName(p)); + out.print(p.gpa, " {d:>11} ", .{config.tab_width}) catch {}; + return staged(p, req); +} + +fn readTag(p: *Pardes, req: Req, pane: *Pane) Reply { + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, tagOf(p, pane)) catch {}; + return staged(p, req); +} + +fn readData(req: Req, id: usize, pane: *Pane, pf: *PaneState, stop_at_end: bool) Reply { + const text = bodyOf(pane); + clampAddr(pf, text.len); + const q0: usize = pf.addr.q0; + const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len; + var end = @min(hi, q0 +| req.size); + end = @max(q0, modal.graphemeStart(text, end)); + pf.addr.q0 = clip(end); + if (!stop_at_end) pf.addr.q1 = clip(end); + if (pane.file == null) return .{ .tag = req.tag }; + return .{ .tag = req.tag, .payload = .{ .region = .{ + .pane = @intCast(id), + .serial = pane.serial, + .off = clip(q0), + .len = clip(end - q0), + } } }; +} + +fn readRdsel(req: Req, id: usize, pane: *Pane) Reply { + if (pane.file == null) return .{ .tag = req.tag }; + const text = bodyOf(pane); + const d = dotOf(pane); + const lo = @min(@as(usize, d.q0), text.len); + const hi = @max(lo, @min(@as(usize, d.q1), text.len)); + const off = @min(req.off, hi - lo); + const n = @min(hi - lo - off, req.size); + return .{ .tag = req.tag, .payload = .{ .region = .{ + .pane = @intCast(id), + .serial = pane.serial, + .off = clip(lo + off), + .len = clip(n), + } } }; +} + +fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { + const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; + if (req.size < record.len) return Reply.fail(req.tag, E.INVAL); + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM); + q.pop(); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +fn readPtyStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + const out = p.fs.stage(p.gpa); + out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ + pane.cols, + pane.rows, + @intFromBool(p.hostTtyTaken(id)), + }) catch {}; + return staged(p, req); +} + +fn readPtyData(p: *Pardes, req: Req, pf: *PaneState) Reply { + if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; + const out = p.fs.stage(p.gpa); + while (out.items.len < req.size) { + const chunk = pf.pty_out.peek() orelse break; + const n = @min(chunk.len, req.size - out.items.len); + out.appendSlice(p.gpa, chunk[0..n]) catch break; + pf.pty_out.popFront(n); + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +fn handleWrite(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| return switch (f) { + .cons => if (appendErrors(p, p.active, req.data)) |took| + .{ .tag = req.tag, .written = @intCast(took) } + else + Reply.fail(req.tag, E.IO), + else => Reply.fail(req.tag, E.PERM), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pane = p.panes[id].?; + if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); + return switch (t.file) { + .addr => writeAddr(p, req, id, pane), + .body => writeBody(p, req, id, pane), + .ctl => writeCtl(p, req, t.serial), + .data, .xdata => writeData(p, req, id, pane), + .tag => writeTag(p, req, pane), + .event => writeEvent(p, req, id), + .wrsel => writeWrsel(p, req, id, pane), + .errors => if (appendErrors(p, id, req.data)) |took| + .{ .tag = req.tag, .written = @intCast(took) } + else + Reply.fail(req.tag, E.IO), + .pty_ctl => writePtyCtl(p, req, id), + .pty_data => writePtyData(p, req, id), + .dir, .rdsel, .pty, .pty_status => Reply.fail(req.tag, E.PERM), + }; + }, + } +} + +fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, bytes: []const u8) ?usize { + const f = fileOf(pane) orelse return null; + const take = if (bytes.len == 0) 0 else wholeUtf8(bytes); + const lo = @min(q0, f.content.len); + const hi = @max(lo, @min(q1, f.content.len)); + const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null; + @memcpy(new[0..lo], f.content[0..lo]); + @memcpy(new[lo..][0..take], bytes[0..take]); + @memcpy(new[lo + take ..], f.content[hi..]); + if (!p.fs.panes[id].nomark) panes.File.pushUndo(p, pane); + panes.File.setContent(p, f, new); + return take; +} + +fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + if (pane.file == null) { + const take = wholeUtf8(req.data); + p.emitWrite(id, req.data[0..take]); + return .{ .tag = req.tag, .written = @intCast(take) }; + } + const at = bodyOf(pane).len; + const take = spliceBody(p, id, pane, at, at, req.data) orelse + return Reply.fail(req.tag, E.NOMEM); + if (!p.fs.panes[id].noscroll) showOffset(pane, at + take); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply { + if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + p.seedTail(pane); + const room = pane.tag_tail.len - pane.tag_tail_len; + if (room == 0) return Reply.fail(req.tag, E.NOSPC); + const take = wholeUtf8(req.data[0..@min(req.data.len, room)]); + @memcpy(pane.tag_tail[pane.tag_tail_len..][0..take], req.data[0..take]); + pane.tag_tail_len += take; + pane.tag_init = true; + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +fn writeData(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + const pf = &p.fs.panes[id]; + clampAddr(pf, bodyOf(pane).len); + const q0: usize = pf.addr.q0; + const q1: usize = @max(q0, @as(usize, pf.addr.q1)); + const before = dotOf(pane); + const take = spliceBody(p, id, pane, q0, q1, req.data) orelse + return Reply.fail(req.tag, E.NOMEM); + setDot(pane, shiftBy(before, clip(q0), clip(q1 - q0), clip(take))); + pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }; + if (!pf.noscroll) showOffset(pane, q0 + take); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +fn writeWrsel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + const d = dotOf(pane); + const q0: usize = d.q0; + const q1: usize = @max(q0, @as(usize, d.q1)); + const take = spliceBody(p, id, pane, q0, q1, req.data) orelse + return Reply.fail(req.tag, E.NOMEM); + setDot(pane, .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + const pf = &p.fs.panes[id]; + const text = bodyOf(pane); + clampAddr(pf, text.len); + const expr = std.mem.trimEnd(u8, req.data, "\n"); + var a: Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; + const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL); + if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL); + pf.addr = r; + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn safePattern(pat: []const u8) bool { + var i: usize = 0; + while (i < pat.len) : (i += 1) { + if (pat[i] != '\\') continue; + if (i + 1 >= pat.len) return false; + i += 1; + } + return true; +} + +const Addr = struct { + text: []const u8, + lim: ?PaneState.Range, + expr: []const u8, + i: usize = 0, + depth: u8 = 0, + + const max_depth = 32; + const Size = enum { char, line }; + + fn address(a: *Addr, ar_in: PaneState.Range) ?PaneState.Range { + const start = a.i; + var ar = ar_in; + var r = ar_in; + var dir: u8 = 0; + var size: Size = .line; + var c: u8 = 0; + while (a.i < a.expr.len) { + const prevc = c; + c = a.expr[a.i]; + a.i += 1; + switch (c) { + ',', ';' => { + if (c == ';') ar = r; + if (prevc == 0) r.q0 = 0; // lhs defaults to 0 + if (a.i >= a.expr.len) { + r.q1 = clip(a.text.len); // rhs defaults to $ + } else { + if (a.depth >= max_depth) return null; + a.depth += 1; + const nr = a.address(ar) orelse return null; + a.depth -= 1; + r.q1 = nr.q1; + } + return r; + }, + '+', '-' => { + if (prevc == '+' or prevc == '-') { + const nc = if (a.i < a.expr.len) a.expr[a.i] else 0; + if (nc != '#' and nc != '/' and nc != '?') + r = a.number(r, 1, prevc, .line) orelse return null; + } + dir = c; + }, + '.', '$' => { + if (a.i != start + 1) { + a.i -= 1; + return r; + } + r = if (c == '.') ar else .{ .q0 = clip(a.text.len), .q1 = clip(a.text.len) }; + dir = if (a.i < a.expr.len) '+' else 0; + }, + '#', '0'...'9' => { + var digit = c; + if (c == '#') { + if (a.i >= a.expr.len or a.expr[a.i] < '0' or a.expr[a.i] > '9') { + a.i -= 1; + return r; + } + digit = a.expr[a.i]; + a.i += 1; + size = .char; + } + var n: u64 = digit - '0'; + while (a.i < a.expr.len) : (a.i += 1) { + const d = a.expr[a.i]; + if (d < '0' or d > '9') break; + n = @min(n * 10 + (d - '0'), std.math.maxInt(u32)); + } + r = a.number(r, @intCast(n), dir, size) orelse return null; + dir = 0; + size = .line; + }, + '/', '?' => { + const back = c == '?'; + r = a.regexp(r, a.pattern(c), back) orelse return null; + dir = 0; + size = .line; + }, + else => { + a.i -= 1; + return r; + }, + } + } + if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null; + return r; + } + + fn pattern(a: *Addr, delim: u8) []const u8 { + const s = a.i; + while (a.i < a.expr.len) { + const c = a.expr[a.i]; + if (c == '\n') break; + a.i += 1; + if (c == '\\') { + if (a.i < a.expr.len) a.i += 1; + continue; + } + if (c == delim) return a.expr[s .. a.i - 1]; + } + return a.expr[s..a.i]; + } + + fn number(a: *Addr, r_in: PaneState.Range, n: u32, dir: u8, size: Size) ?PaneState.Range { + var r = r_in; + if (size == .char) { + var off: i64 = n; + if (dir == '+') { + off = @as(i64, r.q1) + n; + } else if (dir == '-') { + if (r.q0 == 0 and n > 0) r.q0 = clip(a.text.len); + off = @as(i64, r.q0) - n; + } + if (off < 0 or off > @as(i64, @intCast(a.text.len))) return null; + const g = clip(modal.graphemeStart(a.text, @intCast(off))); + return .{ .q0 = g, .q1 = g }; + } + var line: i64 = n; + var q0: usize = r.q0; + var q1: usize = r.q1; + switch (dir) { + '-' => { + if (q0 < a.text.len) while (q0 > 0 and a.text[q0 - 1] != '\n') { + q0 -= 1; + }; + q1 = q0; + while (line > 0 and q0 > 0) { + if (a.text[q0 - 1] == '\n') { + line -= 1; + q1 = q0; + } + q0 -= 1; + } + if (line > 1) return null; + while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1; + return .{ .q0 = clip(q0), .q1 = clip(q1) }; + }, + '+' => { + if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') { + q1 += 1; + }; + q0 = q1; + }, + else => { + q0 = 0; + q1 = 0; + }, + } + while (line > 0 and q1 < a.text.len) { + const ch = a.text[q1]; + q1 += 1; + if (ch == '\n' or q1 == a.text.len) { + line -= 1; + if (line > 0) q0 = q1; + } + } + if (line > 0) return null; + return .{ .q0 = clip(q0), .q1 = clip(q1) }; + } + + fn regexp(a: *Addr, r: PaneState.Range, pat: []const u8, back: bool) ?PaneState.Range { + if (pat.len == 0 or !safePattern(pat)) return null; + const re = mvzr.compile(pat) orelse return null; + if (back) { + const hi = @min(@as(usize, r.q0), a.text.len); + var best: ?mvzr.Match = null; + var at: usize = 0; + while (at < hi) { + const m = re.matchPos(at, a.text[0..hi]) orelse break; + best = m; + at = if (m.end > m.start) m.end else m.end + 1; + } + const m = best orelse return null; + return .{ .q0 = clip(m.start), .q1 = clip(m.end) }; + } + const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len; + const from = @min(@as(usize, r.q1), hi); + const m = re.match(a.text[from..hi]) orelse return null; + return .{ .q0 = clip(from + m.start), .q1 = clip(from + m.end) }; + } +}; + +const Verb = enum { + @"addr=dot", + clean, + cleartag, + del, + delete, + dirty, + @"dot=addr", + get, + @"limit=addr", + mark, + nomark, + noscroll, + put, + scroll, + show, +}; + +const refused_verbs = [_][]const u8{ "dump", "dumpdir", "font", "lock", "menu", "nomenu", "unlock" }; + +fn verbIs(line: []const u8, word: []const u8) bool { + if (!std.mem.startsWith(u8, line, word)) return false; + return line.len == word.len or line[word.len] == ' '; +} + +fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { + for ([2]bool{ false, true }) |apply| { + var dirty = if (p.paneBySerial(serial)) |id| dirtyOf(p.panes[id].?) else false; + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); + const errno = ctlVerb(p, live, line, apply, &dirty); + if (errno != 0) return Reply.fail(req.tag, errno); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) u16 { + const pane = p.panes[id] orelse return E.INVAL; + const pf = &p.fs.panes[id]; + + if (verbIs(line, "look")) { + if (line.len <= 5) return E.INVAL; + const word = std.mem.trim(u8, line[5..], " \t"); + if (word.len == 0) return E.INVAL; + for (word) |c| if (c < ' ') return E.INVAL; + if (apply) p.lookAt(id, word); + return 0; + } + if (verbIs(line, "name")) { + if (line.len <= 5) return E.INVAL; + const name = std.mem.trim(u8, line[5..], " \t"); + if (name.len == 0) return E.INVAL; + for (name) |c| if (c <= ' ') return E.INVAL; + const f = fileOf(pane) orelse return 0; + const full = std.fs.path.resolvePosix(p.gpa, &.{ Pardes.paneDir(pane), name }) catch return E.NOMEM; + defer p.gpa.free(full); + if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return E.INVAL; + if (std.mem.eql(u8, f.path, full)) return 0; + if (panes.Output.fileTraits(f.output).saves) dirty.* = true; + if (!apply) return 0; + const copy = p.gpa.dupe(u8, full) catch return E.NOMEM; + p.gpa.free(f.path); + f.path = copy; + if (panes.Output.fileTraits(f.output).saves) { + f.output = null; + pane.clearCwd(); + pane.tag_init = false; + pane.tag_tail_len = 0; + f.saved_revision = f.revision -% 1; + f.watch_after_save = localPath(full) != null; + } + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + f.highlights = &.{}; + f.highlight_start = 0; + f.syntax_dirty = true; + p.invalidateLookHover(id); + p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + return 0; + } + for (refused_verbs) |w| if (verbIs(line, w)) return E.INVAL; + + const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL; + if (v == .del and dirty.*) return E.INVAL; + switch (v) { + .dirty => dirty.* = true, + .clean, .get, .put => dirty.* = false, + else => {}, + } + if (!apply) return 0; + + switch (v) { + .@"addr=dot" => pf.addr = dotOf(pane), + .@"dot=addr" => { + clampAddr(pf, bodyOf(pane).len); + setDot(pane, pf.addr); + }, + .@"limit=addr" => { + clampAddr(pf, bodyOf(pane).len); + pf.limit = pf.addr; + }, + .clean => if (fileOf(pane)) |f| { + f.saved_revision = f.revision; + }, + .dirty => if (fileOf(pane)) |f| { + f.saved_revision = f.revision -% 1; + }, + .cleartag => { + pane.tag_tail_len = 0; + pane.tag_init = true; + }, + .del, .delete => p.removePane(id) catch return E.NOMEM, + .put => { + const serial = pane.serial; + if (fileOf(pane)) |f| { + if (f.output == null) { + p.perform(.{ .save_file = .{ .pane = @intCast(id) } }); + const current = p.paneBySerial(serial) orelse return E.NOENT; + if (dirtyOf(p.panes[current].?)) return E.IO; + } else _ = p.executeBuiltinLine(id, "Save"); + } else _ = p.executeBuiltinLine(id, "Save"); + }, + .get => if (fileOf(pane)) |f| { + if (panes.Output.fileTraits(f.output).saves) { + if (read(p, f.path)) |bytes| { + panes.File.pushUndo(p, pane); + panes.File.setContent(p, f, bytes); + f.saved_revision = f.revision; + } else |err| return switch (err) { + error.FileNotFound => E.NOENT, + else => E.IO, + }; + } + }, + .mark => { + pf.nomark = false; + panes.File.pushUndo(p, pane); + }, + .nomark => pf.nomark = true, + .noscroll => pf.noscroll = true, + .scroll => pf.noscroll = false, + .show => showOffset(pane, dotOf(pane).q0), + } + return 0; +} + +const PtyVerb = enum { winsize, sig, exec }; + +fn ptyDimension(word: []const u8) ?u16 { + if (word.len == 0 or word.len > 5) return null; + for (word) |c| if (c < '0' or c > '9') return null; + const n = std.fmt.parseInt(u16, word, 10) catch return null; + return if (n == 0) null else n; +} + +fn ptySignalNamed(word: []const u8) ?pardes.PtySignal { + if (std.mem.eql(u8, word, "INT")) return .int; + if (std.mem.eql(u8, word, "TERM")) return .term; + if (std.mem.eql(u8, word, "HUP")) return .hup; + if (std.mem.eql(u8, word, "QUIT")) return .quit; + if (std.mem.eql(u8, word, "KILL")) return .kill; + return null; +} + +fn writePtyCtl(p: *Pardes, req: Req, id: usize) Reply { + for ([2]bool{ false, true }) |apply| { + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + if (!ptyVerb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn ptyVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { + const pane = p.panes[id] orelse return false; + var words = std.mem.tokenizeAny(u8, line, " \t"); + const v = std.meta.stringToEnum(PtyVerb, words.next() orelse return false) orelse return false; + switch (v) { + .winsize => { + const cols = ptyDimension(words.next() orelse return false) orelse return false; + const rows = ptyDimension(words.next() orelse return false) orelse return false; + if (words.next() != null) return false; + if (!apply) return true; + p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); + }, + .sig => { + const which = ptySignalNamed(words.next() orelse return false) orelse return false; + if (words.next() != null) return false; + if (!apply) return true; + p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); + }, + .exec => { + if (words.next() != null) return false; + if (pane.cwdSlice().len > pardes.effect_path_cap) return false; + if (!apply) return true; + p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); + }, + } + return true; +} + +fn writePtyData(p: *Pardes, req: Req, id: usize) Reply { + if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + const take = wholeUtf8(req.data); + p.emitWrite(id, req.data[0..take]); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +const EventRecord = struct { action: Action, q0: u32, q1: u32 }; + +const EventReader = struct { + data: []const u8, + i: usize = 0, + + fn next(er: *EventReader) ?EventRecord { + if (er.i >= er.data.len) return null; + var i = er.i; + if (i + 2 > er.data.len) return null; + i += 1; + const action = Action.fromChar(er.data[i]) orelse return null; + i += 1; + const q0 = scanNumber(er.data, &i) orelse return null; + const q1 = scanNumber(er.data, &i) orelse return null; + while (i < er.data.len and er.data[i] == ' ') i += 1; + if (i >= er.data.len or er.data[i] != '\n') return null; + er.i = i + 1; + return .{ .action = action, .q0 = q0, .q1 = q1 }; + } +}; + +fn scanNumber(data: []const u8, i: *usize) ?u32 { + while (i.* < data.len and data[i.*] == ' ') i.* += 1; + const s = i.*; + var n: u64 = 0; + while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1) + n = @min(n * 10 + (data[i.*] - '0'), std.math.maxInt(u32)); + if (i.* == s) return null; + return @intCast(n); +} + +fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { + const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT); + const serial = pane0.serial; + { + const body = bodyOf(pane0); + const tag = tagOf(p, pane0); + var check: EventReader = .{ .data = req.data }; + while (check.next()) |r| { + switch (r.action) { + .body_look, .tag_look, .body_exec, .tag_exec => {}, + else => return Reply.fail(req.tag, E.INVAL), + } + const n = if (r.action.onTag()) tag.len else body.len; + if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL); + } + if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL); + } + var run: EventReader = .{ .data = req.data }; + while (run.next()) |r| { + const now = p.paneBySerial(serial) orelse break; + const pane = p.panes[now].?; + const whole = if (r.action.onTag()) tagOf(p, pane) else bodyOf(pane); + const lo = @min(@as(usize, r.q0), whole.len); + const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); + const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; + switch (r.action) { + .body_exec, .tag_exec => _ = p.execute(now, text), + .body_look, .tag_look => p.lookAt(now, text), + else => unreachable, + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { + if (text.len == 0) return 0; + const pane = p.panes[id] orelse return null; + const dir = dirOf(pane); + for (p.panes, 0..) |slot, i| { + const q = slot orelse continue; + const qf = fileOf(q) orelse continue; + const o = qf.output orelse continue; + if (std.meta.activeTag(o.from) != .errors) continue; + if (!std.mem.eql(u8, std.fs.path.dirname(qf.path) orelse "", dir)) continue; + return spliceBody(p, i, q, qf.content.len, qf.content.len, text); + } + const free = p.freeSlot() orelse return null; + const content = p.gpa.dupe(u8, text) catch return null; + const np = panes.Output.open(p, free, dir, .errors, "", content) catch { + p.gpa.free(content); + return null; + }; + p.placeDoc(id, free, np); + return text.len; +} + +fn indexLen(p: *Pardes) u64 { + var n: u64 = 0; + for (p.panes) |slot| { + const pane = slot orelse continue; + n += ctl_fields + firstLine(tagOf(p, pane)).len + 1; + } + return n; +} + +fn bodyLen(p: *Pardes, pane: *Pane) u64 { + _ = p; + return bodyOf(pane).len; +} + +fn tagLen(p: *Pardes, pane: *Pane) u64 { + return tagOf(p, pane).len; +} + +test "filesystem inspection preserves pending and displayed Look hover" { + const delay = config.look_preview_delay_frames orelse return; + const gpa = std.testing.allocator; + const p = try withFile(gpa, "alpha beta gamma\n"); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + p.update(.{ .mouse = .{ + .button = .none, + .kind = .motion, + .col = rect.x + config.GUTTER + config.PREFIX_W + 7, + .row = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, + } }); + try testing.expect(p.look_hover_wait != null); + const body = Node.of(serialOf(p), .body); + const screen = @intFromEnum(SelfFile.screen); + for (0..2) |phase| { + if (phase == 1) { + for (0..delay) |_| p.update(.tick); + try testing.expect(p.look_hover_preview != null); + } + const waiting = p.look_hover_wait; + const preview = p.look_hover_preview; + const requests = [_]Req{ + .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.root), .data = "screen" }, + .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = ".." }, + .{ .tag = 2, .op = .getattr, .node = body }, + .{ .tag = 3, .op = .open, .node = body }, + .{ .tag = 4, .op = .read, .node = body, .size = 5 }, + .{ .tag = 5, .op = .release, .node = body, .handle = 1 }, + .{ .tag = 6, .op = .readdir, .node = @intFromEnum(SelfFile.root), .size = 4096 }, + .{ .tag = 7, .op = .open, .node = screen }, + .{ .tag = 8, .op = .read, .node = screen, .handle = 1, .size = 32 }, + .{ .tag = 9, .op = .release, .node = screen, .handle = 1 }, + }; + for (requests) |req| { + const answer = call(p, req); + try testing.expectEqual(Status.ok, answer.reply.status); + try testing.expect(std.meta.eql(waiting, p.look_hover_wait)); + try testing.expect(std.meta.eql(preview, p.look_hover_preview)); + try testing.expect(p.raw_hover_intent); + } + } + try testing.expectEqual(Status.ok, wr(p, body, "changed").reply.status); + try testing.expect(p.look_hover_wait == null); + try testing.expect(p.look_hover_preview == null); + try testing.expect(!p.raw_hover_intent); +} + +test "filesystem pane creation and truncation cancel Look hover" { + const requests = [_]Req{ + .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = "body" }, + .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }, + .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, + }; + for (requests) |request| { + const p = try withFile(testing.allocator, "word\n"); + defer p.deinit(); + p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = serialOf(p) }; + p.raw_hover_intent = true; + var req = request; + if (req.op == .setattr) req.node = Node.of(serialOf(p), .body); + try testing.expectEqual(Status.ok, call(p, req).reply.status); + try testing.expect(p.look_hover_wait == null); + try testing.expect(p.look_hover_preview == null); + try testing.expect(!p.raw_hover_intent); + } +} + +test "terminal body handles keep one history snapshot across fragmented reads" { + const gpa = std.testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const node = Node.of(serialOf(p), .body); + p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } }); + while (p.nextEffect()) |_| {} + const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa); + defer gpa.free(original); + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, opened.reply.status); + const first = call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 3 }); + try testing.expectEqualStrings(original[0..3], first.bytes); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3J\x1b[2J\x1b[Hnew output" } }); + while (p.nextEffect()) |_| {} + var offset: usize = 3; + while (offset < original.len) { + const part = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = offset, .size = 3 }); + try testing.expectEqualStrings(original[offset..][0..@min(3, original.len - offset)], part.bytes); + offset += part.bytes.len; + } + try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes); + _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle }); + const newer = call(p, .{ .tag = 6, .op = .open, .node = node }); + try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); + _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle }); +} + +test "an empty terminal body snapshot stays empty while output continues" { + const p = try withTerm(std.testing.allocator); + defer p.deinit(); + const node = Node.of(serialOf(p), .body); + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); + try testing.expect(p.fs.snapshots[opened.reply.handle - 1].bytes != null); + p.update(.{ .output = .{ .pane = 0, .bytes = "new output" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); + const newer = call(p, .{ .tag = 5, .op = .open, .node = node }); + try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); + _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle }); +} + +test "terminal body snapshots are lazy bounded and released after the pane closes" { + const gpa = std.testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const node = Node.of(serialOf(p), .body); + var handles: [32]u32 = undefined; + for (&handles) |*opened_handle| { + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, opened.reply.status); + opened_handle.* = opened.reply.handle; + } + for (p.fs.snapshots) |snapshot| { + try testing.expectEqual(node, snapshot.node); + try testing.expect(snapshot.bytes == null); + } + try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = node }).errno()); + const scratch = call(p, .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }); + try testing.expectEqual(Status.ok, scratch.reply.status); + const scratch_id = p.paneBySerial(Node.target(scratch.reply.attr.node).?.pane.serial).?; + try testing.expect(p.panes[scratch_id].?.file != null); + _ = call(p, .{ .tag = 2, .op = .release, .node = scratch.reply.attr.node, .handle = scratch.reply.handle }); + p.update(.{ .output = .{ .pane = 0, .bytes = "latest output" } }); + while (p.nextEffect()) |_| {} + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.gpa = failing.allocator(); + const failed = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = handles[0], .size = 32 }); + p.gpa = gpa; + try testing.expectEqual(E.NOMEM, failed.errno()); + try testing.expect(p.fs.snapshots[handles[0] - 1].bytes == null); + const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 }); + try testing.expectEqualStrings("latest output", first.bytes); + const saved = p.fs.snapshots[handles[0] - 1].bytes.?; + failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.gpa = failing.allocator(); + const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 }); + p.gpa = gpa; + try testing.expectEqualStrings("output", second.bytes); + try testing.expect(!failing.has_induced_failure); + try testing.expectEqual(saved.ptr, p.fs.snapshots[handles[0] - 1].bytes.?.ptr); + const screen = @intFromEnum(SelfFile.screen); + try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen, .handle = handles[0], .size = 32 }).errno()); + _ = call(p, .{ .tag = 7, .op = .release, .node = screen, .handle = handles[0] }); + try testing.expectEqual(node, p.fs.snapshots[handles[0] - 1].node); + try p.removePane(0); + for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle }); + for (p.fs.snapshots) |snapshot| { + try testing.expectEqual(@as(u64, 0), snapshot.node); + try testing.expect(snapshot.bytes == null); + } +} + +test "screen snapshots preserve rendered cells and styles until their handle is released" { + const gpa = std.testing.allocator; + const p = try withFile(gpa, "const value = 1;\n"); + defer p.deinit(); + const node = @intFromEnum(SelfFile.screen); + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, opened.reply.status); + const snapshot = p.fs.snapshots[opened.reply.handle - 1].bytes.?; + const original = try gpa.dupe(u8, snapshot); + defer gpa.free(original); + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, original, .{}); + defer parsed.deinit(); + const data = parsed.value.object; + try testing.expectEqual(@as(i64, p.screen_w), data.get("cols").?.integer); + try testing.expectEqual(@as(i64, p.screen_h), data.get("rows").?.integer); + try testing.expectEqual(@as(usize, p.screen_w) * p.screen_h, data.get("cells").?.array.items.len); + try testing.expect(data.get("styles").?.array.items.len > 0); + _ = wr(p, Node.of(serialOf(p), .body), "changed\n"); + const newer = call(p, .{ .tag = 2, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, newer.reply.status); + try testing.expect(!std.mem.eql(u8, original, p.fs.snapshots[newer.reply.handle - 1].bytes.?)); + var off: usize = 0; + while (off < original.len) { + const result = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = off, .size = 13 }); + try testing.expectEqual(Status.ok, result.reply.status); + try testing.expectEqualSlices(u8, original[off..][0..@min(13, original.len - off)], result.bytes); + off += result.bytes.len; + } + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); + _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = newer.reply.handle }); + for (p.fs.snapshots) |slot| try testing.expect(slot.node == 0); +} + +test "screen inspection preserves acknowledged presentation and the next real frame" { + const gpa = testing.allocator; + for (std.enums.values(pardes.layout.Transition)) |transition| { + errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)}); + const control = try withFile(gpa, "const value = 1;\n"); + defer control.deinit(); + const inspected = try withFile(gpa, "const value = 1;\n"); + defer inspected.deinit(); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + for ([_]*Pardes{ control, inspected }) |p| { + p.settings.panel_transition = .off; + p.update(.tick); + p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); + p.settings.panel_transition = transition; + } + for (0..4) |phase| { + for ([_]*Pardes{ control, inspected }) |p| switch (phase) { + 0 => {}, + 1 => p.update(.{ .command = "New" }), + 2 => { + p.update(.tick); + try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .body), "changed cells\n").reply.status); + }, + 3 => { + for (0..transition.frames() + 1) |_| p.update(.tick); + p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); + p.update(.{ .command = "Del" }); + }, + else => unreachable, + }; + const screen = @intFromEnum(SelfFile.screen); + for (0..2) |_| { + const opened = call(inspected, .{ .tag = 1, .op = .open, .node = screen }); + try testing.expectEqual(Status.ok, opened.reply.status); + const captured = call(inspected, .{ .tag = 2, .op = .read, .node = screen, .handle = opened.reply.handle, .size = 32 }); + try testing.expectEqual(Status.ok, captured.reply.status); + try testing.expect(captured.bytes.len > 0); + try testing.expectEqual(Status.ok, call(inspected, .{ + .tag = 3, + .op = .release, + .node = screen, + .handle = opened.reply.handle, + }).reply.status); + } + const a = &control.presentation; + const b = &inspected.presentation; + try testing.expectEqualDeep(a.shown, b.shown); + try testing.expectEqualDeep(a.shown_tracks, b.shown_tracks); + try testing.expectEqualDeep(a.shown_closing[0..a.shown_closing_len], b.shown_closing[0..b.shown_closing_len]); + try testing.expectEqual(a.pending, b.pending); + try testing.expectEqual(a.acknowledged, b.acknowledged); + try testing.expectEqual(a.previous_valid, b.previous_valid); + try testing.expectEqual(a.previous_cols, b.previous_cols); + try testing.expectEqual(a.previous_rows, b.previous_rows); + try testing.expectEqualDeep(a.previous_layout, b.previous_layout); + try testing.expectEqual(a.previous_cells.len, b.previous_cells.len); + for (a.previous_cells, b.previous_cells) |*expected, *actual| + try testing.expect(expected.visuallyEqual(actual)); + + const expected = try control.render(arena.allocator()); + const actual = try inspected.render(arena.allocator()); + try testing.expectEqual(expected.cols, actual.cols); + try testing.expectEqual(expected.rows, actual.rows); + try testing.expectEqualDeep(expected.cursor, actual.cursor); + try testing.expectEqualDeep(expected.panelTracks(), actual.panelTracks()); + try testing.expectEqualDeep(expected.cell_diffs, actual.cell_diffs); + try testing.expectEqual(expected.previous_cells.len, actual.previous_cells.len); + try testing.expectEqual(expected.cells.len, actual.cells.len); + try testing.expectEqual(@as(usize, 0), expected.nimages); + try testing.expectEqual(@as(usize, 0), actual.nimages); + for (expected.cells, actual.cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); + for (expected.previous_cells, actual.previous_cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); + if (phase == 1 and transition != .off) try testing.expect(expected.panelTracks().len > 0); + if (phase == 1 and transition.needsPreviousGrid()) try testing.expect(expected.hasPanelDiff()); + if (phase == 3 and transition == .vertical) try testing.expect(a.closing_len > 0); + control.acknowledgePanelPresentation(expected.panelTracks()); + inspected.acknowledgePanelPresentation(actual.panelTracks()); + try testing.expectEqualDeep(a.shown, b.shown); + try testing.expectEqualDeep(a.previous_layout, b.previous_layout); + _ = arena.reset(.retain_capacity); + } + } +} + +const testing = std.testing; + +const Answer = struct { + reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO }, + bytes: []const u8 = "", + saved: bool = false, + watch: ?bool = null, + pty_buf: [256]u8 = undefined, + pty_len: usize = 0, + winsize: ?struct { cols: u16, rows: u16 } = null, + signal: ?pardes.PtySignal = null, + spawned: bool = false, + + fn pty(a: *const Answer) []const u8 { + return a.pty_buf[0..a.pty_len]; + } + + fn errno(a: Answer) u16 { + return if (a.reply.status == .err) a.reply.errno else 0; + } +}; + +fn call(p: *Pardes, req: Req) Answer { + p.update(.{ .fs_req = req }); + var ans: Answer = .{}; + while (p.nextEffect()) |e| switch (e) { + .fs_reply => |r| { + ans.reply = r; + ans.bytes = p.fsPayload(r); + }, + .save_file, .save_text => ans.saved = true, + .watch => |w| ans.watch = w.on, + .write => |w| { + const b = w.bytes.slice(); + const n = @min(b.len, ans.pty_buf.len - ans.pty_len); + @memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]); + ans.pty_len += n; + }, + .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows }, + .signal_pty => |s| ans.signal = s.sig, + .spawn => ans.spawned = true, + else => {}, + }; + return ans; +} + +fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { + return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size }); +} + +fn wr(p: *Pardes, node: u64, data: []const u8) Answer { + return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data }); +} + +fn rdir(p: *Pardes, node: u64, skip: u64) Answer { + return call(p, .{ .tag = 4, .op = .readdir, .node = node, .off = skip, .size = 4096 }); +} + +fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { + return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); +} + +fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + errdefer p.deinit(); + while (p.nextEffect()) |_| {} + _ = try p.setTestFile(text); + while (p.nextEffect()) |_| {} + return p; +} + +fn withTerm(gpa: std.mem.Allocator) !*Pardes { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + errdefer p.deinit(); + while (p.nextEffect()) |_| {} + std.debug.assert(p.panes[0].?.isTerminal()); + return p; +} + +fn serialOf(p: *Pardes) u32 { + return p.panes[0].?.serial; +} + +const Dirent = struct { node: u64, dir: bool, name: []const u8 }; + +fn dirents(bytes: []const u8, out: []Dirent) []Dirent { + var n: usize = 0; + var i: usize = 0; + while (i + 10 <= bytes.len and n < out.len) { + const node = std.mem.readInt(u64, bytes[i..][0..8], .little); + const kind = bytes[i + 8]; + const len = bytes[i + 9]; + i += 10; + if (i + len > bytes.len) break; + out[n] = .{ .node = node, .dir = kind == 1, .name = bytes[i .. i + len] }; + i += len; + n += 1; + } + return out[0..n]; +} + +fn nameAt(list: []const Dirent, want: []const u8) ?Dirent { + for (list) |d| if (std.mem.eql(u8, d.name, want)) return d; + return null; +} + +test "listener addresses are readable canonical dials with bounded partial reads" { + const gpa = testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + p.fs.socket_path = "/tmp/a socket.sock"; + p.fs.tcp_address = .{ .ip4 = .loopback(5640) }; + p.fs.quic_address = .{ .ip6 = .loopback(5641) }; + const expected = "unix!/tmp/a socket.sock\ntcp!127.0.0.1!5640\nquic!::1!5641\n"; + for ([_][]const u8{ "/virtual/listeners", "/n/self/listeners" }) |path| { + const bytes = try read(p, path); + defer gpa.free(bytes); + try testing.expectEqualStrings(expected, bytes); + try testing.expectError(error.ReadOnlyFilesystem, write(p, path, "")); + } + const node = @intFromEnum(SelfFile.listeners); + var off: usize = 0; + while (off < expected.len) { + const part = rd(p, node, off, 3); + try testing.expectEqual(Status.ok, part.reply.status); + try testing.expectEqualStrings(expected[off..][0..@min(3, expected.len - off)], part.bytes); + off += part.bytes.len; + } + try testing.expectEqual(@as(usize, 0), rd(p, node, off, 3).bytes.len); +} + +test "readdir lists the root, a pane directory, and new/ without creating anything" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + var buf: [32]Dirent = undefined; + + const root = rdir(p, @intFromEnum(SelfFile.root), 0); + try testing.expectEqual(Status.ok, root.reply.status); + const top = dirents(root.bytes, &buf); + try testing.expect(top.len >= 5); + try testing.expectEqualStrings("index", top[0].name); + try testing.expectEqualStrings("cons", top[1].name); + try testing.expectEqualStrings("new", top[2].name); + try testing.expect(top[2].dir and !top[0].dir); + try testing.expectEqualStrings("pane", top[3].name); + try testing.expect(top[3].dir); + try testing.expectEqual(namespace_panes, top[3].node); + var idbuf: [16]u8 = undefined; + try testing.expect(nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); + try testing.expect(nameAt(top, "src") != null); + + const rest = rdir(p, @intFromEnum(SelfFile.root), 3); + try testing.expectEqual(top.len - 3, dirents(rest.bytes, &buf).len); + const eof = rdir(p, @intFromEnum(SelfFile.root), 99); + try testing.expectEqual(Status.ok, eof.reply.status); + try testing.expectEqual(@as(usize, 0), eof.bytes.len); + + const dir = rdir(p, Node.of(serial, .dir), 0); + const files = dirents(dir.bytes, &buf); + try testing.expectEqual(@as(usize, 10), files.len); + try testing.expect(nameAt(files, "addr") != null); + try testing.expect(nameAt(files, "xdata") != null); + try testing.expect(nameAt(files, ".") == null); + try testing.expectEqual(Node.of(serial, .body), nameAt(files, "body").?.node); + + const before = p.next_serial; + const new = rdir(p, @intFromEnum(SelfFile.new), 0); + try testing.expectEqual(Status.ok, new.reply.status); + try testing.expectEqual(@as(usize, 0), new.bytes.len); + try testing.expectEqual(before, p.next_serial); + + try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); +} + +test "lookup resolves top files, pane serials and pane files" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + const root = @intFromEnum(SelfFile.root); + + try testing.expectEqual(@as(u64, @intFromEnum(SelfFile.index)), look_up(p, root, "index").reply.attr.node); + try testing.expect(look_up(p, root, "new").reply.attr.dir); + try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); + + var idbuf: [16]u8 = undefined; + const serial_name = try std.fmt.bufPrint(&idbuf, "{d}", .{serial}); + try testing.expectEqual(E.NOENT, look_up(p, root, serial_name).errno()); + const dir = look_up(p, namespace_panes, serial_name); + try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); + try testing.expect(dir.reply.attr.dir); + try testing.expectEqual(E.NOENT, look_up(p, namespace_panes, "99999").errno()); + + const body = look_up(p, Node.of(serial, .dir), "body"); + try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node); + const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) }); + try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size); + try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno()); + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); +} + +test "a lookup inside new/ creates a pane and resolves that pane's file" { + const gpa = testing.allocator; + const p = try withFile(gpa, "first\n"); + defer p.deinit(); + const before = serialOf(p); + + try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.new), "bogus").errno()); + try testing.expectEqual(before, p.next_serial); + + const a = look_up(p, @intFromEnum(SelfFile.new), "body"); + try testing.expectEqual(Status.ok, a.reply.status); + const made: Node = @bitCast(a.reply.attr.node); + try testing.expect(made.serial != before); + try testing.expectEqual(@intFromEnum(PaneFile.body), made.file); + + _ = wr(p, a.reply.attr.node, "hi"); + const id = p.paneBySerial(@intCast(made.serial)).?; + try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); +} + +test "index prints winctlprint's five fields then the tag" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\nthere\n"); + defer p.deinit(); + const pane = p.panes[0].?; + + const a = rd(p, @intFromEnum(SelfFile.index), 0, 4096); + try testing.expectEqual(Status.ok, a.reply.status); + var got: [512]u8 = undefined; + @memcpy(got[0..a.bytes.len], a.bytes); + const line = got[0..a.bytes.len]; + + const tag = tagOf(p, pane); + var want: std.ArrayList(u8) = .empty; + defer want.deinit(gpa); + try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s}\n", .{ + pane.serial, tag.len, @as(usize, "hello\nthere\n".len), 0, 0, firstLine(tag), + }); + try testing.expectEqualStrings(want.items, line); + try testing.expectEqual(@as(usize, 60), std.mem.indexOf(u8, line, firstLine(tag)).?); + + const mid = rd(p, @intFromEnum(SelfFile.index), 60, 5); + try testing.expectEqualStrings(firstLine(tag)[0..5], mid.bytes); + + pane.file.?.saved_revision = pane.file.?.revision -% 1; + const dirty = rd(p, @intFromEnum(SelfFile.index), 48, 12); + try testing.expectEqualStrings(" 1 ", dirty.bytes); +} + +test "ctl read is index's five fields plus width in cells, font and tab width" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const pane = p.panes[0].?; + + const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096); + try testing.expectEqual(Status.ok, a.reply.status); + var want: std.ArrayList(u8) = .empty; + defer want.deinit(gpa); + try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{ + pane.serial, tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, + }); + try testing.expectEqualStrings(want.items, a.bytes); + + var quoted: std.ArrayList(u8) = .empty; + defer quoted.deinit(gpa); + stageQuoted("ed, gpa, "DejaVu Sans Mono"); + try testing.expectEqualStrings("'DejaVu Sans Mono'", quoted.items); + quoted.clearRetainingCapacity(); + stageQuoted("ed, gpa, "it's"); + try testing.expectEqualStrings("'it''s'", quoted.items); +} + +test "body reads at any offset and writes append" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const body = Node.of(serial, .body); + + try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 100).bytes); + try testing.expectEqualStrings("two\n", rd(p, body, 4, 100).bytes); + try testing.expectEqualStrings("wo", rd(p, body, 5, 2).bytes); + try testing.expectEqualStrings("", rd(p, body, 999, 2).bytes); + try testing.expect(rd(p, body, 0, 100).bytes.ptr == p.panes[0].?.file.?.content.ptr); + + const w = call(p, .{ .tag = 5, .op = .write, .node = body, .off = 0, .data = "three\n" }); + try testing.expectEqual(@as(u32, 6), w.reply.written); + try testing.expectEqualStrings("one\ntwo\nthree\n", p.panes[0].?.file.?.content); + + const short = wr(p, body, "a\xC3"); + try testing.expectEqual(@as(u32, 1), short.reply.written); + try testing.expectEqualStrings("one\ntwo\nthree\na", p.panes[0].?.file.?.content); +} + +test "a body write to a terminal pane types at its shell" { + const gpa = testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.isTerminal()); + + const a = wr(p, Node.of(pane.serial, .body), "ls -l\r"); + try testing.expectEqual(@as(u32, 6), a.reply.written); + try testing.expectEqualStrings("ls -l\r", a.pty()); + + const r = rd(p, Node.of(pane.serial, .body), 0, 64); + try testing.expectEqual(Status.ok, r.reply.status); +} + +test "tag reads the whole tag and writes append to the editable tail" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const pane = p.panes[0].?; + const node = Node.of(pane.serial, .tag); + + const whole = rd(p, node, 0, 4096); + try testing.expect(std.mem.startsWith(u8, whole.bytes, "/test.txt")); + try testing.expect(std.mem.indexOf(u8, whole.bytes, "Del") != null); + + const before = rd(p, node, 0, 4096).bytes.len; + const w = wr(p, node, " Mine"); + try testing.expectEqual(@as(u32, 5), w.reply.written); + try testing.expect(std.mem.endsWith(u8, pane.tag_tail[0..pane.tag_tail_len], " Mine")); + const after = rd(p, node, 0, 4096); + try testing.expectEqual(before + 5, after.bytes.len); + try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine")); + + pane.tag_tail_len = pane.tag_tail.len; + try testing.expectEqual(E.NOSPC, wr(p, node, "x").errno()); +} + +test "the address language, form by form" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\nthree\n"); + defer p.deinit(); + const serial = serialOf(p); + const addr = Node.of(serial, .addr); + + const Case = struct { expr: []const u8, q0: u32, q1: u32 }; + for ([_]Case{ + .{ .expr = "#0", .q0 = 0, .q1 = 0 }, + .{ .expr = "#5", .q0 = 5, .q1 = 5 }, + .{ .expr = "0", .q0 = 0, .q1 = 0 }, + .{ .expr = "1", .q0 = 0, .q1 = 4 }, + .{ .expr = "2", .q0 = 4, .q1 = 8 }, + .{ .expr = "$", .q0 = 14, .q1 = 14 }, + .{ .expr = ",", .q0 = 0, .q1 = 14 }, + .{ .expr = "1,2", .q0 = 0, .q1 = 8 }, + .{ .expr = "#1,#4", .q0 = 1, .q1 = 4 }, + .{ .expr = "2+1", .q0 = 8, .q1 = 14 }, + .{ .expr = "$-1", .q0 = 8, .q1 = 14 }, + .{ .expr = "/two/", .q0 = 4, .q1 = 7 }, + .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 }, + .{ .expr = "1\n", .q0 = 0, .q1 = 4 }, + }) |c| { + _ = wr(p, addr, "#0"); + const w = wr(p, addr, c.expr); + try testing.expectEqual(Status.ok, w.reply.status); + const got = rd(p, addr, 0, 64); + var want: [32]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&want, "{d:>11} {d:>11} ", .{ c.q0, c.q1 }), + got.bytes, + ); + } + + _ = wr(p, addr, "1"); + _ = wr(p, addr, "."); + try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); + try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q1); + + _ = wr(p, addr, "$"); + _ = wr(p, addr, "?o?"); + try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two" + try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1); + + _ = wr(p, addr, "1"); + _ = wr(p, Node.of(serial, .ctl), "limit=addr\n"); + _ = wr(p, addr, "#0"); + try testing.expectEqual(E.INVAL, wr(p, addr, "/three/").errno()); + _ = wr(p, Node.of(serial, .ctl), "clean\n"); + _ = call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) }); + try testing.expect(p.fs.panes[0].limit == null); + _ = wr(p, addr, "#0"); + try testing.expectEqual(Status.ok, wr(p, addr, "/three/").reply.status); + + for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { + _ = wr(p, addr, "#0"); + try testing.expectEqual(E.INVAL, wr(p, addr, bad).errno()); + } + + const nested = "," ** 4096; + _ = wr(p, addr, "#0"); + try testing.expectEqual(E.INVAL, wr(p, addr, nested).errno()); +} + +test "data and xdata read from addr, move it, and write through it" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const addr = Node.of(serial, .addr); + const data = Node.of(serial, .data); + const xdata = Node.of(serial, .xdata); + + _ = wr(p, addr, "#0"); + try testing.expectEqualStrings("one", rd(p, data, 0, 3).bytes); + try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q0); + try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); + + _ = wr(p, addr, "1"); + try testing.expectEqualStrings("one\n", rd(p, xdata, 0, 100).bytes); + _ = wr(p, addr, "1"); + try testing.expectEqualStrings("one\ntwo\n", rd(p, data, 0, 100).bytes); + + _ = wr(p, addr, "1"); + const w = wr(p, data, "ONE\n"); + try testing.expectEqual(@as(u32, 4), w.reply.written); + try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); + try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q0); +} + +test "data never splits a grapheme, in either direction" { + const gpa = testing.allocator; + const p = try withFile(gpa, "\u{00e9}x\n"); + defer p.deinit(); + const serial = serialOf(p); + _ = wr(p, Node.of(serial, .addr), "#0"); + try testing.expectEqualStrings("", rd(p, Node.of(serial, .data), 0, 1).bytes); + _ = wr(p, Node.of(serial, .addr), "#0"); + try testing.expectEqualStrings("\u{00e9}", rd(p, Node.of(serial, .data), 0, 2).bytes); + + _ = wr(p, Node.of(serial, .addr), "#0"); + try testing.expectEqual(@as(u32, 1), wr(p, Node.of(serial, .data), "a\xC3").reply.written); +} + +test "rdsel reads the selection and wrsel replaces it" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl = Node.of(serial, .ctl); + + _ = wr(p, Node.of(serial, .addr), "#0,#3"); + try testing.expectEqual(Status.ok, wr(p, ctl, "dot=addr\n").reply.status); + try testing.expectEqualStrings("one", rd(p, Node.of(serial, .rdsel), 0, 100).bytes); + + _ = wr(p, ctl, "addr=dot\n"); + try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); + try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); + + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .wrsel), "ONE").reply.status); + try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); + _ = wr(p, Node.of(serial, .wrsel), "!"); + try testing.expectEqualStrings("ONE!\ntwo\n", p.panes[0].?.file.?.content); +} + +test "every ctl verb, and every refusal" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl = Node.of(serial, .ctl); + const pane = p.panes[0].?; + const pf = &p.fs.panes[0]; + + try testing.expectEqual(Status.ok, wr(p, ctl, "nomark\nnoscroll\ndirty\n").reply.status); + try testing.expect(pf.nomark and pf.noscroll and dirtyOf(pane)); + try testing.expectEqual(Status.ok, wr(p, ctl, "mark\nscroll\nclean\n").reply.status); + try testing.expect(!pf.nomark and !pf.noscroll and !dirtyOf(pane)); + + _ = wr(p, ctl, "cleartag\n"); + try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); + + _ = wr(p, Node.of(serial, .addr), "2"); + _ = wr(p, ctl, "limit=addr\n"); + try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); + _ = wr(p, ctl, "dot=addr\nshow\n"); + try testing.expectEqual(@as(i32, 1), pane.cur_row); + + try testing.expectEqual(Status.ok, wr(p, ctl, "name /tmp/renamed.txt\n").reply.status); + try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); + try testing.expectEqual(E.INVAL, wr(p, ctl, "name two words\n").errno()); + try testing.expectEqual(E.INVAL, wr(p, ctl, "name\n").errno()); + try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); + + try testing.expectEqual(Status.ok, wr(p, ctl, "put\n").reply.status); + try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); + + for ([_][]const u8{ + "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", + }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl, bad).errno()); + + try testing.expect(!dirtyOf(pane)); + try testing.expectEqual(E.INVAL, wr(p, ctl, "dirty\nbogus\n").errno()); + try testing.expect(!dirtyOf(pane)); +} + +test "ctl look opens spaced paths and locations without editing the source pane" { + if (!platform_has_fs) return error.SkipZigTest; + const gpa = testing.allocator; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" }); + var path_buf: [4096]u8 = undefined; + const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)]; + const p = try withFile(gpa, "source stays intact\n"); + defer p.deinit(); + const source = p.panes[0].?; + const ctl = Node.of(source.serial, .ctl); + const revision = source.file.?.revision; + const undo_len = source.file.?.history.undo_len; + source.cur_col = 3; + var command: [4200]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status); + const opened = p.panes[p.active].?; + try testing.expect(opened != source); + try testing.expectEqualStrings(path, opened.file.?.path); + try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content); + try testing.expectEqual(@as(i32, 1), opened.cur_row); + try testing.expectEqual(@as(i32, 2), opened.cur_col); + try testing.expectEqualStrings("source stays intact\n", source.file.?.content); + try testing.expectEqual(revision, source.file.?.revision); + try testing.expectEqual(undo_len, source.file.?.history.undo_len); + try testing.expectEqual(@as(i32, 3), source.cur_col); + + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status); + try testing.expect(p.panes[p.active].? == opened); + try testing.expectEqual(@as(i32, 2), opened.cur_row); + try testing.expectEqual(@as(i32, 1), opened.cur_col); +} + +test "ctl look validates the whole batch before opening virtual files" { + const gpa = testing.allocator; + const p = try withFile(gpa, "source\n"); + defer p.deinit(); + const source = p.panes[0].?; + const ctl = Node.of(source.serial, .ctl); + const serial = p.next_serial; + for ([_][]const u8{ + "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n", + }) |invalid| { + try testing.expectEqual(E.INVAL, wr(p, ctl, invalid).errno()); + try testing.expectEqual(serial, p.next_serial); + try testing.expectEqual(@as(usize, 0), p.active); + try testing.expectEqualStrings("source\n", source.file.?.content); + } + try testing.expectEqual(Status.ok, wr(p, ctl, "look /n/self/index\n").reply.status); + const opened = p.panes[p.active].?; + try testing.expectEqualStrings("/virtual/index", opened.file.?.path); + try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null); + try testing.expectEqualStrings("source\n", source.file.?.content); +} + +test "ctl name promotes a scratch without changing its body or undo history" { + const gpa = testing.allocator; + const p = try withFile(gpa, "opener\n"); + defer p.deinit(); + _ = look_up(p, @intFromEnum(SelfFile.new), "ctl"); + const pane = p.panes[p.active].?; + const ctl = Node.of(pane.serial, .ctl); + const body = Node.of(pane.serial, .body); + _ = wr(p, body, "scratch "); + _ = wr(p, body, "work\n"); + const undo_len = pane.file.?.history.undo_len; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "renamed.zig", .data = "existing target\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + var path_buffer: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buffer, "{s}/renamed.zig", .{directory}); + var command: [4104]u8 = undefined; + const renamed = wr(p, ctl, try std.fmt.bufPrint(&command, "name {s}\n", .{path})); + try testing.expectEqual(Status.ok, renamed.reply.status); + try testing.expectEqual(false, renamed.watch.?); + try testing.expectEqualStrings("scratch work\n", pane.file.?.content); + try testing.expectEqual(undo_len, pane.file.?.history.undo_len); + try testing.expect(pane.file.?.output == null and dirtyOf(pane)); + try testing.expect(pane.file.?.watch_after_save); + const target = try readFile(gpa, path); + defer gpa.free(target); + try testing.expectEqualStrings("existing target\n", target); + const saved = wr(p, ctl, "put\n"); + try testing.expectEqual(Status.ok, saved.reply.status); + try testing.expectEqual(true, saved.watch.?); + try testing.expect(!dirtyOf(pane) and !pane.file.?.watch_after_save); + try testing.expectEqualStrings("scratch work\n", p.fallback.get(path).?); +} + +test "ctl name refreshes cached syntax for unchanged contents" { + if (!pardes.syntax.enabled) return error.SkipZigTest; + pardes.syntax.start(testing.allocator); + defer pardes.syntax.stop(); + const p = try withFile(testing.allocator, "fn check() void {}\n"); + defer p.deinit(); + const pane = p.panes[0].?; + panes.File.refreshHighlights(p); + try testing.expectEqual(@as(usize, 0), pane.file.?.highlights.len); + try testing.expectEqual(Status.ok, wr(p, Node.of(pane.serial, .ctl), "name renamed.zig\n").reply.status); + panes.File.refreshHighlights(p); + try testing.expect(pane.file.?.highlights.len >= 2); + try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[0]); + try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[1]); + try testing.expectEqualStrings("fn check() void {}\n", pane.file.?.content); +} + +test "ctl relative names use the file directory and can name a new target" { + const gpa = testing.allocator; + const p = try withFile(gpa, "retained body\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from pane directory\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + const pane = p.panes[0].?; + const ctl = Node.of(pane.serial, .ctl); + var command: [4140]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name {s}/old.txt\n", .{directory})).reply.status); + try testing.expectEqual(Status.ok, wr(p, ctl, "name child/../note.txt\nget\n").reply.status); + try testing.expectEqualStrings("from pane directory\n", pane.file.?.content); + try testing.expectEqualStrings(directory, Pardes.paneDir(pane)); + try testing.expectEqual(Status.ok, wr(p, ctl, "name ./created.txt\nput\n").reply.status); + try testing.expect(!dirtyOf(pane)); + var expected_buf: [4096]u8 = undefined; + const expected = try std.fmt.bufPrint(&expected_buf, "{s}/created.txt", .{directory}); + try testing.expectEqualStrings(expected, pane.file.?.path); + try testing.expectEqualStrings(pane.file.?.content, p.fallback.get(expected).?); +} + +test "ctl relative names follow inherited scratch and virtual directories" { + const p = try withFile(testing.allocator, "source body\n"); + defer p.deinit(); + const source = p.panes[0].?; + try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .ctl), "name /project/src/source.zig\n").reply.status); + _ = look_up(p, @intFromEnum(SelfFile.new), "ctl"); + const scratch = p.panes[p.active].?; + const ctl = Node.of(scratch.serial, .ctl); + try testing.expectEqualStrings("/project/src", Pardes.paneDir(scratch)); + try testing.expectEqual(Status.ok, wr(p, ctl, "name ../out/./notes.txt\n").reply.status); + try testing.expectEqualStrings("/project/out/notes.txt", scratch.file.?.path); + try testing.expectEqualStrings("/project/out", Pardes.paneDir(scratch)); + var command: [256]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name /virtual/pane/{d}/./body\nget\n", .{source.serial})).reply.status); + try testing.expectEqualStrings(source.file.?.content, scratch.file.?.content); + const unchanged = wr(p, ctl, "name ./body\n"); + try testing.expectEqual(Status.ok, unchanged.reply.status); + try testing.expect(unchanged.watch == null); + try testing.expect(!dirtyOf(scratch)); + try testing.expect(!scratch.file.?.watch_after_save); +} + +test "ctl get reloads the pane from disk and del honours a dirty body" { + const gpa = testing.allocator; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + var path_buf: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory}); + + const p = try withFile(gpa, "in memory\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl = Node.of(serial, .ctl); + const pane = p.panes[0].?; + + var name: [std.fs.max_path_bytes + 8]u8 = undefined; + _ = wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}\n", .{path})); + try testing.expectEqual(Status.ok, wr(p, ctl, "get\n").reply.status); + try testing.expectEqualStrings("from disk\n", pane.file.?.content); + try testing.expect(!dirtyOf(pane)); + try testing.expect(pane.file.?.history.undo_len > 0); + + _ = wr(p, ctl, "dirty\n"); + try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno()); + try testing.expect(p.paneBySerial(serial) != null); + _ = look_up(p, @intFromEnum(SelfFile.new), "body"); + try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status); + try testing.expect(p.paneBySerial(serial) == null); +} + +test "ctl get reports missing files without losing dirty contents or applying del" { + const p = try withFile(testing.allocator, "unsaved contents\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + var name: [4140]u8 = undefined; + const ctl = Node.of(serialOf(p), .ctl); + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}/missing.txt\ndirty\n", .{directory})).reply.status); + try testing.expectEqual(E.NOENT, wr(p, ctl, "get\ndel\n").errno()); + const pane = p.panes[0].?; + try testing.expectEqualStrings("unsaved contents\n", pane.file.?.content); + try testing.expect(dirtyOf(pane)); +} + +test "ctl put waits for one host write and stops before del when saving fails" { + const Refusing = struct { + p: *Pardes, + calls: usize = 0, + + fn writeFile(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + self.calls += 1; + self.p.saveFailed(id, "save", error.PermissionDenied); + } + }; + const p = try withFile(testing.allocator, "retained contents\n"); + defer p.deinit(); + var refusing: Refusing = .{ .p = p }; + p.host = .{ .ctx = &refusing, .vtable = &.{ .write_file = Refusing.writeFile } }; + const serial = serialOf(p); + const ctl = Node.of(serial, .ctl); + for ([_][]const u8{ "clean\n", "dirty\n" }, 0..) |initial, i| { + _ = wr(p, ctl, initial); + const result = wr(p, ctl, "put\ndel\n"); + try testing.expectEqual(E.IO, result.errno()); + try testing.expect(!result.saved); + try testing.expectEqual(i + 1, refusing.calls); + const pane = p.panes[p.paneBySerial(serial).?].?; + try testing.expect(dirtyOf(pane)); + try testing.expectEqualStrings("retained contents\n", pane.file.?.content); + } +} + +test "self directories walk to their namespace parents" { + const p = try withFile(testing.allocator, "contents\n"); + defer p.deinit(); + const root = @intFromEnum(SelfFile.root); + for ([_]struct { node: u64, parent: u64 }{ + .{ .node = root, .parent = namespace_root }, + .{ .node = @intFromEnum(SelfFile.new), .parent = root }, + .{ .node = namespace_panes, .parent = root }, + .{ .node = Node.of(serialOf(p), .dir), .parent = namespace_panes }, + }) |case| { + const result = look_up(p, case.node, ".."); + try testing.expectEqual(Status.ok, result.reply.status); + try testing.expectEqual(case.parent, result.reply.attr.node); + } +} + +test "errors and cons append to one +Errors buffer per directory" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + + const live = for (p.panes) |slot| { + if (slot) |q| if (q.file) |f| if (f.output) |o| if (std.meta.activeTag(o.from) == .errors) break q; + } else null; + try testing.expect(live == null); + + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "boom\n").reply.status); + _ = wr(p, @intFromEnum(SelfFile.cons), "again\n"); + + var found: usize = 0; + for (p.panes) |slot| { + const q = slot orelse continue; + const f = q.file orelse continue; + const o = f.output orelse continue; + if (std.meta.activeTag(o.from) != .errors) continue; + found += 1; + try testing.expectEqualStrings("boom\nagain\n", f.content); + try testing.expectEqualStrings("/+Errors", f.path); + } + try testing.expectEqual(@as(usize, 1), found); +} + +test "setattr truncation empties the body and answers fresh attributes" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + + const a = call(p, .{ .tag = 7, .op = .setattr, .node = Node.of(serial, .body), .truncate = true }); + try testing.expectEqual(Status.ok, a.reply.status); + try testing.expectEqual(@as(u64, 0), a.reply.attr.size); + try testing.expectEqualStrings("", p.panes[0].?.file.?.content); + + _ = wr(p, Node.of(serial, .body), "new text\n"); + try testing.expectEqualStrings("new text\n", p.panes[0].?.file.?.content); + + const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) }); + try testing.expectEqual(@as(u64, 9), noop.reply.attr.size); +} + +test "event records are acme's bytes, one per read, and .again when empty" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg fs-ran\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + + _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg "); + try testing.expect(p.fs.panes[0].events.empty()); + + const h = call(p, .{ .tag = 10, .op = .open, .node = event }); + try testing.expect(h.reply.handle != 0); + try testing.expectEqual(@as(u16, 1), p.fs.listeners); + + try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); + + p.fs.origin = 'M'; + _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell"); + _ = noteAction(p, 0, .body_delete, 0, 3, 0, ""); + try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes); + try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes); + try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); + + _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one"); + try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno()); + try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes); + + const big = "z" ** max_record_text; + _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big); + try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes); + + _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); +} + +fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 { + var used: usize = 0; + var n: usize = 0; + while (n < out.len) { + const a = rd(p, node, 0, 4096); + if (a.reply.status != .ok) break; + @memcpy(store[used..][0..a.bytes.len], a.bytes); + out[n] = store[used..][0..a.bytes.len]; + used += a.bytes.len; + n += 1; + } + return out[0..n]; +} + +test "a write through the filesystem is reported once, attributed to the file it came through" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + _ = call(p, .{ .tag = 40, .op = .open, .node = event }); + var store: [4096]u8 = undefined; + var slots: [16][]const u8 = undefined; + _ = drainEvents(p, event, &store, &slots); + + _ = wr(p, Node.of(serial, .body), "three\n"); + const body_recs = drainEvents(p, event, &store, &slots); + try testing.expect(body_recs.len >= 1); + try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]); + for (body_recs[1..]) |r| { + try testing.expectEqual(@as(u8, 'E'), r[0]); + try testing.expect(Action.fromChar(r[1]).?.onTag()); + } + + _ = wr(p, Node.of(serial, .addr), "1"); + _ = wr(p, Node.of(serial, .data), "ONE\n"); + const data_recs = drainEvents(p, event, &store, &slots); + try testing.expectEqual(@as(usize, 2), data_recs.len); + try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]); + try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]); + try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); +} + +test "two event readers each count once, and the second closing leaves the first" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const event = Node.of(serialOf(p), .event); + + _ = call(p, .{ .tag = 12, .op = .open, .node = event }); + _ = call(p, .{ .tag = 13, .op = .open, .node = event }); + try testing.expectEqual(@as(u16, 2), p.fs.panes[0].readers); + try testing.expectEqual(@as(u16, 2), p.fs.listeners); + + _ = call(p, .{ .tag = 14, .op = .release, .node = event }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].readers); + try testing.expect(p.fs.scripted(0)); + + _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) }); + try testing.expectEqual(@as(u16, 1), p.fs.listeners); + + _ = call(p, .{ .tag = 16, .op = .release, .node = event }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + try testing.expect(!p.fs.scripted(0)); + _ = call(p, .{ .tag = 17, .op = .release, .node = event }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); +} + +test "a pane deleted while its event file is open leaves no suppression behind" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + _ = look_up(p, @intFromEnum(SelfFile.new), "body"); + + const a = call(p, .{ .tag = 18, .op = .open, .node = event }); + const b = call(p, .{ .tag = 19, .op = .open, .node = event }); + try testing.expectEqual(@as(u16, 2), p.fs.listeners); + + _ = wr(p, Node.of(serial, .ctl), "delete\n"); + try testing.expect(p.paneBySerial(serial) == null); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + + _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); + _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + + try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); + try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno()); + try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); +} + +test "writing an event record back performs the action it names" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg fs-ran\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + const pane = p.panes[0].?; + + const w = wr(p, event, "FX0 10\n"); + try testing.expectEqual(Status.ok, w.reply.status); + try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); + + pane.msg_len = 0; + try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status); + try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); + + pane.msg_len = 0; + for ([_][]const u8{ + "FX0 10\nFQ0 1\n", // unknown type character + "FX0 999\n", // out of range + "FX0 10", // no newline + "FX5 1\n", // q0 > q1 + "FD0 3\n", // a report, not a request + "F\n", + }) |bad| { + try testing.expectEqual(E.INVAL, wr(p, event, bad).errno()); + try testing.expectEqual(@as(usize, 0), pane.msg_len); + } + + _ = call(p, .{ .tag = 23, .op = .open, .node = event }); + p.fs.origin = 'K'; + _ = wr(p, event, "KX0 10\n"); + try testing.expectEqual(@as(u8, 'F'), p.fs.origin); +} + +test "a pane that is not a terminal has no pty/ at all" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + const dir = Node.of(serial, .dir); + + try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno()); + try testing.expectEqual(E.NOENT, call(p, .{ + .tag = 1, + .op = .getattr, + .node = Node.of(serial, .pty), + }).errno()); + try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno()); + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno()); + try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno()); + try testing.expectEqual(E.NOENT, call(p, .{ + .tag = 2, + .op = .open, + .node = Node.of(serial, .pty_data), + }).errno()); + try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); + + var buf: [32]Dirent = undefined; + const files = dirents(rdir(p, dir, 0).bytes, &buf); + try testing.expectEqual(@as(usize, 10), files.len); + try testing.expect(nameAt(files, "pty") == null); + + try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); + try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno()); + + const before = p.next_serial; + try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.new), "pty").errno()); + try testing.expectEqual(before, p.next_serial); +} + +test "a terminal pane's pty/ holds exactly ctl, status and data" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const serial = serialOf(p); + const dir = Node.of(serial, .dir); + + const pty = look_up(p, dir, "pty"); + try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node); + try testing.expect(pty.reply.attr.dir); + try testing.expectEqual(@as(u16, 0o500), pty.reply.attr.mode); + + var buf: [32]Dirent = undefined; + const files = dirents(rdir(p, dir, 0).bytes, &buf); + try testing.expectEqual(@as(usize, 11), files.len); + try testing.expect(nameAt(files, "pty").?.dir); + + const inside = dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); + try testing.expectEqual(@as(usize, 3), inside.len); + try testing.expectEqualStrings("ctl", inside[0].name); + try testing.expectEqualStrings("status", inside[1].name); + try testing.expectEqualStrings("data", inside[2].name); + for (inside) |d| try testing.expect(!d.dir); + try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); + + const ctl = look_up(p, Node.of(serial, .pty), "ctl"); + try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node); + try testing.expectEqual(@as(u16, 0o200), ctl.reply.attr.mode); + try testing.expectEqual(@as(u16, 0o400), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno()); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno()); + + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno()); + try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno()); + try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno()); + try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno()); + try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno()); +} + +test "every pty/ctl verb, and every refusal" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const ctl = Node.of(serialOf(p), .pty_ctl); + + const pane = p.panes[0].?; + const cols = pane.cols; + const rows = pane.rows; + const ws = wr(p, ctl, "winsize 132 44\n"); + try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written); + try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols); + try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); + try testing.expectEqual(cols, pane.cols); + try testing.expectEqual(rows, pane.rows); + + for ([_]struct { line: []const u8, want: pardes.PtySignal }{ + .{ .line = "sig INT", .want = .int }, + .{ .line = "sig TERM", .want = .term }, + .{ .line = "sig HUP", .want = .hup }, + .{ .line = "sig QUIT", .want = .quit }, + .{ .line = "sig KILL", .want = .kill }, + }) |c| { + const a = wr(p, ctl, c.line); + try testing.expectEqual(Status.ok, a.reply.status); + try testing.expectEqual(c.want, a.signal.?); + } + + const ex = wr(p, ctl, "exec\n"); + try testing.expectEqual(Status.ok, ex.reply.status); + try testing.expect(ex.spawned); + + const both = wr(p, ctl, "winsize 100 30\nsig TERM"); + try testing.expectEqual(@as(u16, 100), both.winsize.?.cols); + try testing.expectEqual(pardes.PtySignal.term, both.signal.?); + + for ([_][]const u8{ + "winsize", // no arguments + "winsize 80", // one argument + "winsize 80 24 extra", // three + "winsize 0 24", // zero is "unknown", never a width + "winsize 80 0", + "winsize -1 24", // not a decimal + "winsize 999999 24", // wider than a u16 + "sig", // no name + "sig INT TERM", // two + "sig SIGINT", + "sig int", // lower case + "sig 9", // a number is one platform's number + "sig USR1", // a real signal, deliberately not offered + "exec /bin/sh", // the effect carries no argv; refused, never ignored + "raw", + "cooked", + "winsize 80 24\nbogus", // a good verb beside a bad one + "bogus\nwinsize 80 24", + "name x", // a `ctl` verb; the two files share no vocabulary + "del", + }) |bad| { + const a = wr(p, ctl, bad); + try testing.expectEqual(E.INVAL, a.errno()); + try testing.expect(a.winsize == null); + try testing.expect(a.signal == null); + try testing.expect(!a.spawned); + } + + const spaced = wr(p, ctl, "\n winsize 90 20 \n\n"); + try testing.expectEqual(Status.ok, spaced.reply.status); + try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols); + try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status); +} + +const FakeTty = struct { + taken: bool, + + const vtable: pardes.Host.VTable = .{ .tty_taken = answer }; + + fn answer(ctx: ?*anyopaque, pane: u8) bool { + _ = pane; + const f: *FakeTty = @ptrCast(@alignCast(ctx.?)); + return f.taken; + } +}; + +test "owned cwd pty exec rejects long paths before applying its batch" { + const p = try withTerm(testing.allocator); + defer p.deinit(); + const pane = p.panes[0].?; + const ctl = Node.of(pane.serial, .pty_ctl); + var path: [1025]u8 = @splat('d'); + path[0] = '/'; + p.setCwd(0, &path); + for ([_][]const u8{ "exec\n", "winsize 100 30\nexec\n", "sig TERM\nexec\n" }) |command| { + const result = wr(p, ctl, command); + try testing.expectEqual(E.INVAL, result.errno()); + try testing.expect(!result.spawned); + try testing.expect(result.signal == null and result.winsize == null); + } + try testing.expectEqualStrings(&path, pane.cwdSlice()); + p.setCwd(0, path[0..pardes.effect_path_cap]); + const accepted = wr(p, ctl, "exec\n"); + try testing.expectEqual(Status.ok, accepted.reply.status); + try testing.expect(accepted.spawned); +} + +test "owned cwd name promotion releases the former directory" { + const p = try withTerm(testing.allocator); + defer p.deinit(); + p.newScratchBelow(0); + const id = p.active; + const pane = p.panes[id].?; + try pane.setOwnedCwd("/old/directory"); + const result = wr(p, Node.of(pane.serial, .ctl), "name saved.txt\n"); + try testing.expectEqual(Status.ok, result.reply.status); + try testing.expect(pane.cwd == .none); + try testing.expect(pane.file.?.output == null); + try testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); + try testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane)); +} + +test "pty/status reports the grid and who holds the tty" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const pane = p.panes[0].?; + const status = Node.of(pane.serial, .pty_status); + + const a = rd(p, status, 0, 256); + try testing.expectEqual(Status.ok, a.reply.status); + var want: [64]u8 = undefined; + const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); + try testing.expectEqualStrings(whole, a.bytes); + try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); + try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); + + var probe: FakeTty = .{ .taken = true }; + p.host = .{ .ctx = &probe, .vtable = &FakeTty.vtable }; + const held = rd(p, status, 0, 256); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), + held.bytes, + ); +} + +test "pty/data writes at the shell and reads the raw stream" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const serial = serialOf(p); + const data = Node.of(serial, .pty_data); + + const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" }); + try testing.expectEqual(@as(u32, 6), w.reply.written); + try testing.expectEqualStrings("ls -l\r", w.pty()); + try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written); + try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written); + + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + _ = call(p, .{ .tag = 5, .op = .open, .node = data }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + try testing.expect(!p.fs.scripted(0)); + + p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes); + try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes); + p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes); + + p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } }); + while (p.nextEffect()) |_| {} + _ = call(p, .{ .tag = 6, .op = .release, .node = data }); + try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); + try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + _ = call(p, .{ .tag = 7, .op = .open, .node = data }); + _ = call(p, .{ .tag = 8, .op = .open, .node = data }); + _ = call(p, .{ .tag = 9, .op = .release, .node = data }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); + p.update(.{ .output = .{ .pane = 0, .bytes = "still" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); +} + +test "the pty queue drops the oldest at its cap" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const data = Node.of(serialOf(p), .pty_data); + _ = call(p, .{ .tag = 5, .op = .open, .node = data }); + + const oldest: [4096]u8 = @splat('A'); + const rest: [4096]u8 = @splat('B'); + notePtyOutput(p, 0, &oldest); + for (0..queue_cap / rest.len + 4) |_| notePtyOutput(p, 0, &rest); + const q = &p.fs.panes[0].pty_out; + try testing.expect(q.buf.items.len - q.head <= queue_cap); + + var seen: usize = 0; + while (true) { + const a = rd(p, data, 0, 1 << 16); + if (a.reply.status == .again) break; + try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null); + if (a.bytes.len == 0) break; + seen += a.bytes.len; + } + try testing.expect(seen > 0 and seen <= queue_cap); +} diff --git a/src/fs9_client.zig b/src/fs9_client.zig deleted file mode 100644 index ad46dee4..00000000 --- a/src/fs9_client.zig +++ /dev/null @@ -1,695 +0,0 @@ -//! `9p `: one pardes reading a file out of another pardes's tree. -//! -//! `src/fs9_service.zig`'s MIRROR, and the other half of `9P-2`: that file is a -//! listener with connections and hands each one a `ninep.Server`, this one -//! dials a single socket and drives a `ninep.Client` over it. They share the -//! socket NAMING and nothing else — `socketPath` is imported verbatim, so a -//! bare `9p work /1/body` resolves to exactly the path a `pardes --fs9 work` -//! bound, which is the whole point of having one spelling of it. -//! -//! WHAT IS HERE, and it is the same four things any non-blocking byte stream -//! needs: connect, read into `push`, `output` out through `send` and back -//! through `wrote`, and close. Everything above that is `src/9p.zig`, which is -//! freestanding and knows about neither sockets nor panes. -//! -//! IT BLOCKS, BRIEFLY AND BOUNDED, and that is a decision rather than an -//! oversight. `src/look.zig`'s `readFile` already blocks the frame on a disk -//! read — opening a file pane is a person waiting for a file — and a remote -//! read over a unix socket on the same machine is the same wait with a context -//! switch in it. What makes it safe to say that is the BUDGET: `budget_ms` is -//! the deadline for the whole transaction, `poll` is what waits, and the -//! descriptor is non-blocking, so a peer that stops answering costs one -//! `budget_ms` pause and a message on the message row rather than a wedged -//! editor. The alternative — a request queued into the frame loop, a state -//! machine per outstanding fetch, a pane that fills in later — is an async -//! runtime, and `docs/9p.typ` §12.5 is explicit that this design does not get -//! one. -//! -//! WHY THE HOST AND NOT THE CORE. A socket is `std.c`, and `src/9p.zig` must -//! keep compiling for `wasm32-freestanding` and the board's -//! `riscv32-freestanding`; the same `ninep.Client` runs over -//! `src/esp32p4/uart.zig` with no line of this file involved. So the split is -//! the one the server half already made: protocol in the freestanding file, -//! descriptor here. -//! -//! Linux and darwin, like every other unix socket in the tree. Anywhere else -//! `supported` is false and the word is not registered at all. -const std = @import("std"); -const libc = std.c; -const nested = @import("nested.zig"); -const ninep = @import("9p.zig"); -const fs9_service = @import("fs9_service.zig"); -const pardes = @import("pardes.zig"); -const Pardes = pardes.Pardes; -const output_pane = @import("output_pane.zig"); - -/// Unix sockets, which is all this needs — `fs9_service`'s own predicate, so a -/// build that can serve 9P can dial it and one that cannot has neither. -pub const supported = fs9_service.supported; - -/// `sun_path`, from the kernel's struct. See `nested.sun_path_len`. -const sun_path_len = nested.sun_path_len; - -/// The deadline for the WHOLE transaction: connect, handshake, attach, walk, -/// open, every read, clunk. -/// -/// TWO SECONDS, and the number is about the human rather than about the wire. -/// On a local socket the whole exchange is six round trips and some memcpys — -/// microseconds — so any wait long enough to notice means the far end is not -/// answering, and the useful thing to do about that is say so. Two seconds is -/// long enough that a busy editor on the other side finishing its frame is -/// never mistaken for a dead one, and short enough that a mistyped socket name -/// on a path that happens to exist does not feel like a hang. -pub const budget_ms: i64 = 2000; - -/// The most bytes one `9p` will carry into a pane. -/// -/// A MEGABYTE, which is a quarter of `look.zig`'s cap for a virtual file -/// (`read_stream_max_bytes`) and for a sharper reason: what is on the other end -/// is a synthetic tree of live editor state, where the biggest file is one -/// pane's `body`. A megabyte of it is a large source file; ten megabytes is -/// somebody pointing this word at a `/dev/zero` equivalent, and a read loop -/// with no cap would spend the whole budget filling the heap. -pub const max_bytes: u64 = 1 << 20; - -/// The deepest path this word will walk. -/// -/// `MAXWELEM` is sixteen elements per `Twalk` and a deeper path is legal — the -/// client splits it into chunks and `transact` does — so this is not a protocol -/// bound. It is a bound on the ARGUMENT: acme's tree is two deep (`/1/body`), -/// two chunks is thirty-two, and a path with more elements than that is a typo -/// or a loop rather than a file. Refused rather than truncated, because a -/// truncated path names a different file. -pub const max_depth: usize = 2 * ninep.max_welem; - -/// What the far end reports in `Rstat`'s three name fields (`uid`, `gid`, -/// `muid`) for everything we touch, because `ninep.Server` records the -/// attach's `uname` and quotes it back. -/// -/// A CONSTANT AND NOT `$USER`: the socket's permissions are the identity here -/// (0600, in a 0700 per-user directory — docs/9p.typ §10), so this string is -/// not a credential and cannot become one. What it is for is the operator -/// reading `ls -l` on the far side, and "pardes" tells them which program -/// walked their tree, which a login name they already share with it does not. -const uname = "pardes"; - -/// Everything this word can refuse, and each one is a different thing to do -/// about it. -pub const Error = error{ - MissingDial, - MissingPath, - /// More than `max_depth` elements. - PathTooDeep, - /// The dial names no address we can form: an empty name, a name with a - /// separator or a NUL in it, a path past `sun_path`, or no runtime - /// directory to resolve a bare name against. - BadDial, - /// `socket(2)` or `connect(2)` said no: nothing is listening on that - /// socket, or its permissions are not ours. The overwhelmingly common - /// case, and it means "that pardes is not running with `--fs9`". - Dial, - /// The peer closed mid-transaction. - Hangup, - /// `budget_ms` elapsed. See there. - Timeout, - /// The stream stopped being 9P: `ninep.Client` went dead, or a reply - /// arrived whose shape does not answer the request it was tagged for. - /// Nothing can be resynchronised from here. - Botch, - /// The far end answered `Rerror`. Its own string goes on the message row — - /// see `fetch` — so this value only says "reported already". - Remote, - /// The path names a directory. A directory READ is a run of `stat` - /// records rather than text, so opening it in a pane would show a person - /// the wire format; `9p` names files. - IsDirectory, - /// The walk stopped short: some element of the path is not there. Distinct - /// from `Remote` because a partial walk is a SUCCESSFUL `Rwalk` with fewer - /// qids and carries no message to report. - NotFound, - /// Past `max_bytes`. - FileTooLarge, - /// The pane that asked went away while this was in flight. - MissingPane, -}; - -/// The far end's own words, copied out of the client's input buffer before the -/// connection is torn down and the buffer with it. `ninep.errmax` is the buffer -/// a Plan 9 client has for an error string, so it is the right size for one. -const RemoteError = struct { - buf: [ninep.errmax]u8 = undefined, - len: usize = 0, - - /// Returns the error so that every call site is `return remote.set(e)`. - fn set(r: *RemoteError, msg: []const u8) error{Remote} { - r.len = @min(msg.len, r.buf.len); - @memcpy(r.buf[0..r.len], msg[0..r.len]); - return error.Remote; - } - - fn text(r: *const RemoteError) []const u8 { - return r.buf[0..r.len]; - } -}; - -/// `9p ` — walk to a remote file, read it, and open the bytes in a -/// pane. -/// -/// The pane is an ORDINARY OUTPUT BUFFER, which is what `src/board_memory.zig` -/// puts a hexdump in and what `Grep` puts its rows in: a file pane with an -/// `output` origin, so every motion, chord, search and Look works on it for -/// free. Deliberately NOT a real file pane, even though the bytes came from -/// `look.readFile`'s own kind of read: `file_pane.open` arms a file WATCH on -/// the path it was given, and there is no local path here for `inotify` to -/// watch — the bytes live in another process's memory. An output buffer is the -/// existing answer to "text with no file behind it". -/// -/// Identified by the WHOLE argument, so `9p work /1/body` and `9p work /2/body` -/// are two panes and running either again refills its own. -pub fn fetch(p: *Pardes, id: usize, argument: []const u8) !void { - const a = std.mem.trim(u8, argument, " \t\r\n"); - const args = try parse(a); - var names: [max_depth][]const u8 = undefined; - const n = try elements(args.path, &names); - - var sock_buf: [sun_path_len]u8 = undefined; - const sock = resolve(&sock_buf, args.dial) orelse return Error.BadDial; - - var remote: RemoteError = .{}; - const content = fetchBytes(p.gpa, sock, names[0..n], &remote) catch |err| { - if (err != Error.Remote) return err; - // The far end's own wording, which is the whole error ABI in base - // 9P2000 (docs/registry.typ `9P-4`) — reported verbatim rather than - // mapped to one of ours, because it is the only thing that says which - // of the eight operations the other side objected to and why. - var buf: [ninep.errmax + 8]u8 = undefined; - p.setMessage(id, std.fmt.bufPrint(&buf, "9p: {s}", .{remote.text()}) catch "9p: refused"); - return; - }; - const pane = p.panes[id] orelse { - p.gpa.free(content); - return Error.MissingPane; - }; - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - try output_pane.fillResults(p, id, dir, .{ .cmd = .@"9p" }, a, content, null); -} - -const Args = struct { dial: []const u8, path: []const u8 }; - -/// ` `, split at the FIRST run of whitespace and not tokenized. -/// -/// The path keeps its spaces, because a pane's name in acme's tree can have -/// them and a path is the last argument: `9p work /1/tag` and -/// `9p work /a name/body` both have exactly one reading. The dial cannot have -/// them, and does not need to — it is a socket name or a socket path. -fn parse(a: []const u8) Error!Args { - if (a.len == 0) return Error.MissingDial; - const cut = std.mem.indexOfAny(u8, a, " \t") orelse return Error.MissingPath; - const path = std.mem.trim(u8, a[cut..], " \t\r\n"); - if (path.len == 0) return Error.MissingPath; - return .{ .dial = a[0..cut], .path = path }; -} - -/// A path into `Twalk` elements. Separators are collapsed and a trailing one is -/// dropped, so `/1/body`, `1/body` and `//1/body/` are one file — the -/// normalisation every shell already does, done here because 9P has no -/// pathnames at all and a client that forwarded an empty element would be -/// asking for a file called "". -/// -/// ZERO ELEMENTS is the root, which is legal and is a directory; `transact` -/// refuses it there, where every other directory is refused too. -fn elements(path: []const u8, out: *[max_depth][]const u8) Error!usize { - var n: usize = 0; - var it = std.mem.tokenizeScalar(u8, path, '/'); - while (it.next()) |name| { - if (n == out.len) return Error.PathTooDeep; - out[n] = name; - n += 1; - } - return n; -} - -/// The dial, as an address. -/// -/// TWO SPELLINGS, told apart by a separator, and the distinction is the one a -/// person already makes: a NAME is what `pardes --fs9 work` was started with, -/// and it resolves through `fs9_service.socketPath` — the same function that -/// bound it, so the two can never drift. A PATH is taken as given, which is -/// what you need for a socket somewhere else entirely: a bind-mounted -/// container, a different user's runtime directory, an `ssh -L` forward. -fn resolve(buf: *[sun_path_len]u8, dial: []const u8) ?[:0]const u8 { - if (dial.len == 0) return null; - if (std.mem.indexOfScalar(u8, dial, '/') != null) { - if (std.mem.indexOfScalar(u8, dial, 0) != null) return null; - return std.fmt.bufPrintSentinel(buf, "{s}", .{dial}, 0) catch null; - } - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse return null; - return fs9_service.socketPath(buf, dir, dial); -} - -/// One dialled connection: the descriptor, the deadline, the client and its -/// three buffers. -/// -/// HEAP-ALLOCATED by `fetchBytes`, for `fs9_service.Listener`'s reason and one -/// more: `cl.in` and `cl.out` are slices INTO this struct, so it must never be -/// moved once `cl` is initialised, and at three msizes it is 24 KiB, which does -/// not belong on the frame's stack. -/// -/// The msize is `fs9_service.msize`, the one number the serving side is already -/// sized from. A client on the same machine reading the same tree has no reason -/// to pick a different one, and picking the same one means the handshake never -/// clamps. -const Session = struct { - fd: c_int, - /// `nowMs()` past which every wait gives up. - deadline: i64, - cl: ninep.Client = undefined, - in: [fs9_service.msize]u8 = undefined, - out: [fs9_service.msize]u8 = undefined, - /// A frame-local staging buffer rather than a read straight into the - /// client's tail: advancing `in_len` is `push`'s business, and reaching - /// past it to do it here would make this file a second author of - /// `9p.zig`'s invariants for the sake of one memcpy per 8 KiB. Exactly - /// `fs9_service.fill`'s reasoning, from the other side. - stage: [fs9_service.msize]u8 = undefined, - - /// Wait for `events` on the descriptor, or give up. THE ONLY PLACE THIS - /// FILE BLOCKS, and the only place the budget is spent. - fn wait(s: *Session, events: i16) Error!void { - while (true) { - const left = s.deadline - nowMs(); - if (left <= 0) return Error.Timeout; - var fds = [1]libc.pollfd{.{ .fd = s.fd, .events = events, .revents = 0 }}; - const ready = libc.poll(&fds, 1, @intCast(@min(left, budget_ms))); - if (ready < 0) { - if (libc.errno(ready) == .INTR) continue; - return Error.Hangup; - } - if (ready == 0) return Error.Timeout; - // What we asked for wins over HUP: a peer that wrote a reply and - // then closed reports both at once, and those bytes are ours. - if (fds[0].revents & events != 0) return; - return Error.Hangup; - } - } - - /// Push everything the client owes the wire, and nothing else. Split out of - /// `settle` so that `dropNoWait` can send a message it will never collect a - /// reply for. Bounded by the same deadline `wait` enforces. - fn flush(s: *Session) Error!void { - while (s.cl.output().len != 0) { - try s.wait(poll_out); - const bytes = s.cl.output(); - const sent = libc.send(s.fd, bytes.ptr, bytes.len, nosignal); - if (sent < 0) switch (libc.errno(sent)) { - .INTR, .AGAIN => continue, - else => return Error.Hangup, - }; - // No progress and no error: looping on it is a spin, and a - // spin in here is the editor at 100% of a core. - if (sent == 0) return Error.Hangup; - s.cl.wrote(@intCast(sent)); - } - } - - /// Drive the client until the one outstanding request answers: flush what - /// we owe, collect if a reply is already buffered, otherwise wait and read. - /// - /// LOCK-STEP, deliberately, and it is worth saying why given that - /// `ninep.Client` allows sixteen requests in flight. A `9p` word is one - /// person waiting for one file, and its round trips are strictly ordered - /// anyway — you cannot read a fid you have not opened, or open one you have - /// not walked to. The one place pipelining would pay is the read loop, and - /// on a local socket at an 8 KiB msize a megabyte is 128 round trips of a - /// few microseconds each; buying that back would cost this file a request - /// window, an out-of-order reassembly buffer and a reason for both. The - /// CLIENT is where the sixteen tags live, so the board's runtime and any - /// future caller get them without this file having spent them. - fn settle(s: *Session) Error!ninep.Client.Done { - while (true) { - try s.flush(); - if (s.cl.take()) |done| return done; - if (s.cl.dead) return Error.Botch; - try s.wait(poll_in); - const room = s.cl.in.len - s.cl.in_len; - // Cannot happen: one reply is at most one msize and the buffer is - // exactly that, so a full buffer with nothing to take would mean - // the far end sent a frame it told us it would not. - if (room == 0) return Error.Botch; - const got = libc.read(s.fd, &s.stage, @min(room, s.stage.len)); - if (got == 0) return Error.Hangup; - if (got < 0) switch (libc.errno(got)) { - .INTR, .AGAIN => continue, - else => return Error.Hangup, - }; - const n = s.cl.push(s.stage[0..@intCast(got)]); - // The read was clamped to the room, so this cannot be short; it is - // asserted rather than ignored because silently dropping wire - // bytes desynchronises the stream, which is the one failure 9P - // cannot resynchronise from. - std.debug.assert(n == @as(usize, @intCast(got))); - } - } - - /// One request, one reply, and the two answers that are not the one asked - /// for folded into errors here so that `transact` reads as a script. - fn ask(s: *Session, req: ninep.Client.Request, remote: *RemoteError) Error!ninep.Client.Result { - _ = s.cl.submit(req) catch return Error.Botch; - const done = try s.settle(); - if (done.result == .fail) return remote.set(done.result.fail); - // The client already refuses a reply whose shape does not match the - // request's op (it kills the connection), so this can only be an - // `Rerror` we have just handled. Checked anyway: a `switch` here would - // be a second copy of that table. - if (std.mem.eql(u8, @tagName(done.result), @tagName(std.meta.activeTag(req)))) return done.result; - return Error.Botch; - } - - /// Clunk a fid and WAIT for the answer, because the caller is about to - /// reuse the number. `transact`'s walk alternates between fids 1 and 2, and - /// in-order processing is the only thing that makes that safe. - /// - /// Best effort otherwise: a refused clunk still frees the fid on both sides - /// (`clunk(5)`), so there is nothing here worth failing a fetch over. - fn drop(s: *Session, fid: u32) void { - _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; - _ = s.settle() catch {}; - } - - /// Clunk a fid and do NOT wait. For the last one, where the descriptor is - /// closed on the next line and closing it frees every fid the connection - /// held — so the `Rclunk` is not merely unwanted, it is unobservable. - /// - /// Waiting for it cost the whole budget against a peer that answers - /// everything else and ignores clunks: measured at 2.005 s to deliver a - /// file that was already in hand, and 2.003 s to report an error decided - /// 1.4 ms in. The bytes still go out — a well-behaved peer gets its clunk - /// and frees the fid immediately rather than at hangup — but nothing here - /// reads the reply. - fn dropNoWait(s: *Session, fid: u32) void { - _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; - s.flush() catch {}; - } -}; - -/// The whole transaction, and the only function here that knows 9P's order of -/// operations: version, attach, walk, open, read to the end, clunk. -fn transact( - s: *Session, - names: []const []const u8, - out: *std.Io.Writer.Allocating, - remote: *RemoteError, -) !void { - // The handshake. A server that answers "unknown" has no dialect in common - // with us and leaves `msize` at zero, which is a connection nothing can be - // submitted on — reported as a botch, because there is no fallback ladder - // here to climb down. - _ = try s.ask(.{ .version = .{} }, remote); - if (s.cl.msize == 0) return Error.Botch; - - // Fid 0 is the root for the life of the connection; 1 and 2 alternate as - // the walk descends, so a chunked walk never needs a third. - const root: u32 = 0; - var here = (try s.ask(.{ .attach = .{ .fid = root, .uname = uname } }, remote)).attach; - var cur: u32 = root; - var next: u32 = 1; - - var i: usize = 0; - while (i < names.len) { - // `MAXWELEM` elements at a time, which is what makes a path deeper than - // sixteen work at all: every implementation refuses a seventeenth - // element, so a deep path is several walks with an intermediate fid. - const n = @min(ninep.max_welem, names.len - i); - const w = (try s.ask(.{ .walk = .{ - .fid = cur, - .newfid = next, - .names = names[i..][0..n], - } }, remote)).walk; - // A PARTIAL WALK IS A SUCCESS with fewer qids, and only a failure on - // the first element is an `Rerror`. So this comparison is the whole of - // "did the path exist", and skipping it is how a client ends up - // reading the wrong file. - if (w.nwqid != n) return Error.NotFound; - here = w.wqid[n - 1]; - if (cur != root) s.drop(cur); - cur = next; - next = if (next == 1) 2 else 1; - i += n; - } - defer s.dropNoWait(cur); - - // The qid the walk landed on already says what this is, so the refusal - // costs no round trip — and it catches the bare `/` too, which is zero - // elements and the root. - if (here.type & ninep.qtdir != 0) return Error.IsDirectory; - - _ = try s.ask(.{ .open = .{ .fid = cur, .mode = ninep.oread } }, remote); - - // Read to the end. 9P has no EOF flag: a reply SHORTER than the count is - // ordinary and means nothing, and a reply of ZERO bytes is the end of the - // file (`read(5)`). The offset advances by what came back and never by what - // was asked, which is the same rule a POSIX read loop follows. - var off: u64 = 0; - while (true) { - if (off >= max_bytes) return Error.FileTooLarge; - const want: u32 = @intCast(@min(@as(u64, s.cl.maxRead()), max_bytes - off)); - const data = (try s.ask(.{ .read = .{ .fid = cur, .offset = off, .count = want } }, remote)).read; - if (data.len == 0) return; - try out.writer.writeAll(data); - off += data.len; - } -} - -/// Dial, transact, and hand back the bytes — gpa-owned, the way -/// `look.readFile`'s are, so the pane adopts them with no second copy. -fn fetchBytes( - gpa: std.mem.Allocator, - sock: [:0]const u8, - names: []const []const u8, - remote: *RemoteError, -) ![]u8 { - if (comptime !supported) return Error.Dial; - // The deadline starts BEFORE the dial, because the dial is part of the - // transaction and used not to be bounded by anything at all. See `connect`. - const deadline = nowMs() +| budget_ms; - const fd = try connect(sock, deadline); - const s = gpa.create(Session) catch return error.OutOfMemory; - defer { - _ = libc.close(fd); - gpa.destroy(s); - } - s.* = .{ .fd = fd, .deadline = deadline }; - s.cl = .init(.{ .in = &s.in, .out = &s.out }); - - var out: std.Io.Writer.Allocating = .init(gpa); - errdefer out.deinit(); - try transact(s, names, &out, remote); - return out.toOwnedSlice(); -} - -/// Connect to a unix socket, non-blocking from the first moment there is -/// anything to wait for — which is the connect itself. -/// -/// This used to leave the connect BLOCKING, on the argument that a unix socket -/// either completes at once or refuses at once. That is true only while the -/// listener's accept queue has room. When it is full, Linux's -/// `unix_stream_connect` waits in `unix_wait_for_peer` for `sk_sndtimeo`, which -/// defaults to MAX_SCHEDULE_TIMEOUT — forever. The core is single-threaded, so -/// that is the whole editor: no frame, no keystroke, no filesystem request -/// served. Measured at 177 seconds against a peer that had called `listen` and -/// never `accept`, and it ended only because the peer was killed. Nothing in -/// pardes would have ended it, and the trigger needs no hostility — a peer that -/// is itself wedged does it, and a bare path names sockets pardes does not own. -/// -/// So the descriptor is non-blocking before the connect and the wait is spent -/// against the caller's deadline. Both refusals have to be handled and they are -/// different: on AF_UNIX a full backlog is EAGAIN, NOT the EINPROGRESS a TCP -/// connect would give, so EAGAIN retries until the deadline and EINPROGRESS -/// waits for POLLOUT and then asks SO_ERROR what actually happened. -fn connect(sock: [:0]const u8, deadline: i64) Error!c_int { - if (sock.len + 1 > sun_path_len) return Error.BadDial; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return Error.Dial; - nested.setCloexec(fd); - setNonblock(fd); - errdefer _ = libc.close(fd); - while (true) { - if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0) break; - switch (libc._errno().*) { - // The backlog is full. Nobody is obliged to drain it, so this is a - // poll on the clock rather than on the descriptor: there is no - // event to wait for, only room that may or may not appear. - @intFromEnum(libc.E.AGAIN), @intFromEnum(libc.E.INTR) => { - if (nowMs() >= deadline) return Error.Dial; - nap(2); - }, - // Someone is listening and the connect is under way. This one IS a - // descriptor event, so wait for it and then ask what it was. - @intFromEnum(libc.E.INPROGRESS), @intFromEnum(libc.E.ALREADY) => { - const left = deadline - nowMs(); - if (left <= 0) return Error.Dial; - var pfd: [1]libc.pollfd = .{.{ .fd = fd, .events = poll_out, .revents = 0 }}; - if (libc.poll(&pfd, 1, @intCast(@min(left, 1000))) <= 0) continue; - var err: c_int = 0; - var len: libc.socklen_t = @sizeOf(c_int); - if (libc.getsockopt(fd, libc.SOL.SOCKET, libc.SO.ERROR, @ptrCast(&err), &len) != 0) - return Error.Dial; - if (err == 0) break; - return Error.Dial; - }, - // Already connected by a previous round of this loop. - @intFromEnum(libc.E.ISCONN) => break, - else => return Error.Dial, - } - } - if (comptime nested.darwin) { - // linux says MSG_NOSIGNAL per write, darwin once per socket. A peer - // that dies mid-transaction must not take the editor down with it. - const on: c_int = 1; - _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); - } - return fd; -} - -/// The descriptor is non-blocking and `poll` does the waiting, because that is -/// the only shape in which the budget above is enforceable: a blocking `read` -/// has no deadline to give it. `fs9_service`'s own `setNonblock` is not reused -/// for its stated reason — importing a daemon into a path the tty and GUI -/// shells take would make a frontend transport a dependency of a builtin. -fn setNonblock(fd: c_int) void { - const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return; - var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); - o.NONBLOCK = true; - _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); -} - -/// Milliseconds on the MONOTONIC clock, which is the only clock a deadline may -/// be measured against: the wall clock can be stepped, and an NTP correction -/// landing mid-fetch would turn a two-second budget into a hang or into an -/// instant timeout depending on which way it went. -/// -/// A clock that will not answer is reported as THE END OF TIME, so the budget -/// expires on the first wait rather than never — the saturating `+|` at the one -/// call site that adds to it is what makes that safe. -fn nowMs() i64 { - var ts: libc.timespec = undefined; - if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return std.math.maxInt(i64); - return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); -} - -const poll_in: i16 = @intCast(libc.POLL.IN); -const poll_out: i16 = @intCast(libc.POLL.OUT); - -/// Sleep a couple of milliseconds while a full accept backlog drains. There is -/// no descriptor to wait on for that — the room either appears or the deadline -/// arrives — so this is the one place here that waits on the clock. `poll` with -/// no descriptors is the portable spelling and needs no `nanosleep` import. -fn nap(ms: c_int) void { - _ = libc.poll(&[0]libc.pollfd{}, 0, ms); -} - -/// A dead peer must never kill the editor. linux says it per write, darwin once -/// per socket (see `connect`). -const nosignal: u32 = if (nested.darwin) 0 else libc.MSG.NOSIGNAL; - -const testing = std.testing; - -test "the argument is a dial and then the rest of the line" { - const a = try parse("work /1/body"); - try testing.expectEqualStrings("work", a.dial); - try testing.expectEqualStrings("/1/body", a.path); - - // A pane's name in acme's tree may contain spaces, and the path is the last - // argument, so it keeps them. - const spaced = try parse("work /a name/body"); - try testing.expectEqualStrings("work", spaced.dial); - try testing.expectEqualStrings("/a name/body", spaced.path); - - // A socket path as the dial, which is the other spelling. - const p = try parse("/run/user/1000/pardes-9p-work.sock /index"); - try testing.expectEqualStrings("/run/user/1000/pardes-9p-work.sock", p.dial); - try testing.expectEqualStrings("/index", p.path); - - try testing.expectError(Error.MissingDial, parse("")); - try testing.expectError(Error.MissingPath, parse("work")); - try testing.expectError(Error.MissingPath, parse("work ")); -} - -test "a path becomes walk elements, normalised the way a shell would" { - var out: [max_depth][]const u8 = undefined; - try testing.expectEqual(@as(usize, 2), try elements("/1/body", &out)); - try testing.expectEqualStrings("1", out[0]); - try testing.expectEqualStrings("body", out[1]); - - // Leading, trailing and doubled separators are one file, not four. - try testing.expectEqual(@as(usize, 2), try elements("1/body", &out)); - try testing.expectEqual(@as(usize, 2), try elements("//1//body//", &out)); - try testing.expectEqual(@as(usize, 1), try elements("/index", &out)); - - // Zero elements is the root, which is legal here and refused as a - // directory where every other directory is. - try testing.expectEqual(@as(usize, 0), try elements("/", &out)); - - // Deeper than two full walks is a typo, and truncating it would name a - // different file. - var deep: [8 * max_depth]u8 = @splat('/'); - for (0..max_depth + 1) |i| deep[i * 2 + 1] = 'a'; - try testing.expectError(Error.PathTooDeep, elements(deep[0 .. (max_depth + 1) * 2], &out)); -} - -test "a bare dial resolves to the socket --fs9 binds, and a path is taken as given" { - if (comptime !supported) return error.SkipZigTest; - var buf: [sun_path_len]u8 = undefined; - - // The one spelling both halves share: this must be the same name - // `fs9_service.socketPath` produces, or the friendly form dials nothing. - const named = resolve(&buf, "work").?; - try testing.expect(std.mem.endsWith(u8, named, "/pardes-9p-work.sock")); - var expect: [sun_path_len]u8 = undefined; - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf).?; - try testing.expectEqualStrings(fs9_service.socketPath(&expect, dir, "work").?, named); - - // A separator makes it a path, verbatim. - const path = resolve(&buf, "/tmp/somewhere.sock").?; - try testing.expectEqualStrings("/tmp/somewhere.sock", path); - - // And the refusals: nothing to dial, and a NUL that would truncate the - // address into something else entirely. - try testing.expect(resolve(&buf, "") == null); - try testing.expect(resolve(&buf, "/tmp/a\x00b") == null); -} - -test "a dial with nothing listening is one error and not a wait" { - if (comptime !supported) return error.SkipZigTest; - // The overwhelmingly common failure — "that pardes is not running with - // --fs9" — and it must be immediate: `connect` on a unix socket with no - // listener is refused by the kernel with no timeout in it, which is why - // `budget_ms` is never spent here. - var names: [max_depth][]const u8 = undefined; - const n = try elements("/1/body", &names); - var remote: RemoteError = .{}; - const before = nowMs(); - try testing.expectError( - Error.Dial, - fetchBytes(testing.allocator, "/tmp/pardes-9p-no-such-socket.sock", names[0..n], &remote), - ); - try testing.expect(nowMs() - before < budget_ms); -} - -test "one fetch costs three msize buffers and nothing that grows" { - // The number this word adds to a session WHILE IT RUNS, and nothing after: - // the `Session` is freed before `fetch` returns and only the content - // survives, adopted by the pane. Heap rather than stack for the reason - // `Session` states. - try testing.expectEqual(@as(usize, fs9_service.msize), @as(usize, (Session{ .fd = -1, .deadline = 0 }).in.len)); - try testing.expect(@sizeOf(Session) <= 3 * fs9_service.msize + 256); - // The client's own state is a rounding error beside its buffers, which is - // the whole point of borrowing payloads out of `in` instead of copying - // them per tag. - try testing.expect(@sizeOf(ninep.Client) <= 256); -} diff --git a/src/fs9_service.zig b/src/fs9_service.zig deleted file mode 100644 index eebfe7bf..00000000 --- a/src/fs9_service.zig +++ /dev/null @@ -1,618 +0,0 @@ -//! `pardes --fs9`: what a native HOST has to decide to serve acme's control -//! filesystem over 9P2000 on a unix socket. -//! -//! `src/fs_service.zig`'s sibling, and deliberately a separate file: that one -//! is three decisions about a MOUNT (where to mount, when to drain, what a -//! pane shell is told), and this one is a listener with connections, buffers -//! and a socket path. They share the seam and nothing else — `Transport`, -//! `drain` and `Drained` all live there and are used verbatim here, which is -//! the whole point of `9P-2`: a second answer to the same three functions. -//! -//! WHAT IS HERE: a bound listening socket, a small fixed table of connections, -//! and the four things a non-blocking byte stream needs — accept, read into -//! `push`, `output` out through `write` and back through `wrote`, and hangup. -//! Everything above that is `src/9p.zig`, which is freestanding and knows -//! about neither sockets nor `acmefs.zig`; the instantiation -//! `ninep.Server(pardes.acmefs)` happens here and nowhere else. -//! -//! WHAT IS NOT HERE: the drain. One connection is one `Transport`, and the -//! host calls `fs_service.drain` per connection per frame at the same point in -//! the frame it drains the mount — see `drainAll`, and see -//! `detached/server.zig`'s `Source.ninep` for why a filesystem request must -//! not be served from inside a poll dispatch. -//! -//! Linux and darwin, like every other unix socket in the tree. On anything -//! else `open` returns null and the flag is quietly off. -const std = @import("std"); -const libc = std.c; -const nested = @import("nested.zig"); -const ninep = @import("9p.zig"); -const pardes = @import("pardes.zig"); -const fs_service = @import("fs_service.zig"); - -/// Diagnostics land where `fs_service`'s do and for its reason: stderr IS the -/// screen in the tty shell, so this is the `PARDES_LOG=1` copy. -const log = std.log.scoped(.fs9); - -/// Unix sockets, which is all this needs. `nested.supported` also demands a -/// way to name an arbitrary pid's executable, which no part of this asks. -pub const supported = builtin_unix; -const builtin_unix = @import("builtin").os.tag == .linux or nested.darwin; - -/// `sun_path`, from the kernel's struct. See `nested.sun_path_len`. -const sun_path_len = nested.sun_path_len; - -/// A THIRD prefix in the one per-user directory, beside nested.zig's -/// `pardes-.sock` and detached/server.zig's `pardes-detached-.sock`, -/// for the reason `nested.zig:39-44` gives: one directory vetted by different -/// predicates is exactly the divergence that naming prevents. That file's -/// sweeper unlinks any name whose digits name a dead pid, and this socket must -/// never look like one; the detached transport's `vetted` accepts only its own -/// prefix, so a 9P socket cannot be dialled by a frontend expecting `wire.zig` -/// either. -const prefix = "pardes-9p-"; - -/// The msize this host serves, and the ONE number both buffers are sized from. -/// -/// 8 KiB, which is `9P-17`'s clamp applied to the thing a client actually -/// reads: the largest single answer this tree produces is one pane's `body`, -/// and a client reading a megabyte of it does so in msize-sized `Tread`s -/// whatever this number is. So the only thing a larger msize buys is fewer -/// round trips on a LOCAL socket, and the only thing it costs is resident -/// memory in a daemon nobody is talking to. 8 KiB is two `Tread`s per screen -/// of text and comfortably above `ninep.min_msize` (4096), which is the floor -/// below which plan9port's `9p` and Linux's `v9fs` start refusing mounts with -/// `EINVAL` and no message. -pub const msize: u32 = 8192; - -/// Connections one session serves at once. -/// -/// FOUR, and it is not a guess about load: a 9P client here is a SCRIPT, and -/// the thing a script does is walk, read and clunk. What holds a connection -/// open for minutes is a blocked reader on `event` or `cons` — one per script -/// that is watching the editor — and beyond a handful of those the honest -/// answer is that somebody is using the wrong tool. The number is small on -/// purpose because a connection costs its buffers whether it is busy or idle: -/// MEASURED at 34,072 B each (a `Server` of 9,488 B plus `msize` in and twice -/// `msize` out) for 136,408 B of table, which is the whole of what `--fs9` -/// adds to a daemon's resident memory. A refused connect is also a diagnostic -/// a script author sees immediately, where a silently queued one is not. The -/// detached transport's `max_clients` is 32 because a frontend is a human's -/// window; this is not that. -pub const max_conns = 4; - -/// The 9P server, over the filesystem ABI `acmefs.zig` defines. This -/// instantiation is the only coupling between the freestanding protocol file -/// and the core, and it is a type parameter rather than an import for the -/// reason `9p.zig`'s `Server` doc comment gives. -const Srv = ninep.Server(pardes.acmefs); - -/// One connection: a socket, a server, and the server's two buffers. -/// -/// THE BUFFERS ARE FIELDS HERE, which is what `Srv`'s "no allocator" means -/// from the caller's side: `srv.in` and `srv.out` are slices INTO this struct, -/// so a `Conn` must never be moved or copied once `srv` is initialised. That -/// is why `Listener` is heap-allocated by `open` and held by pointer, and why -/// nothing below takes a `Conn` by value. -/// -/// `out` is twice `msize` because `Srv` requires it: one reply being written -/// out and one being built, which is what lets a reply be encoded the moment -/// the core answers with no "can I write yet" question anywhere in `9p.zig`. -const Conn = struct { - /// Non-negative exactly while the peer is connected. It goes to -1 the - /// moment the connection ends, which is BEFORE this slot is free: see - /// `draining`. - fd: c_int = -1, - /// The peer has gone and the server still owes the core `release` calls - /// for the fids it held. A dropped `event` fid without one leaves the - /// pane's reader count high forever (`acmefs.zig:1053-1061`), so the slot - /// stays occupied, with no descriptor, until `next()` runs dry. See - /// `Srv.hangup` and `drainAll`. - draining: bool = false, - /// When this peer connected, on the monotonic clock, or 0 when the clock - /// is unavailable. Read by `expire`: a connection that has not sent - /// `Tversion` within `greet_deadline_ms` is holding a slot by silence, - /// which with only four of them is a cheaper denial than the frontend - /// socket's thirty-two. `Server.msize == 0` is the "has not versioned yet" - /// flag, and version(5) requires `Tversion` before any other message, so - /// there is no legitimate client this can catch. - accepted_ms: i64 = 0, - /// Undefined until `accept` initialises it in place, which it may only do - /// through a pointer to this exact storage. - srv: Srv = undefined, - in: [msize]u8 = undefined, - out: [2 * msize]u8 = undefined, - - /// This connection's answer to `fs_service.Transport`. Thunked exactly - /// like `fuse.Fs.transport()`, and for its reason: a `*Conn` is not an - /// `*anyopaque` and a vtable cannot hold the typed function. - fn transport(c: *Conn) fs_service.Transport { - return .{ .ctx = c, .vtable = &transport_vtable }; - } - - const transport_vtable: fs_service.Transport.VTable = .{ - .retry = transportRetry, - .next = transportNext, - .reply = transportReply, - }; - - fn transportRetry(ctx: *anyopaque) ?pardes.acmefs.Req { - const c: *Conn = @ptrCast(@alignCast(ctx)); - return c.srv.retry(); - } - - fn transportNext(ctx: *anyopaque) ?pardes.acmefs.Req { - const c: *Conn = @ptrCast(@alignCast(ctx)); - return c.srv.next(); - } - - fn transportReply(ctx: *anyopaque, r: *const pardes.acmefs.Reply, bytes: []const u8) void { - const c: *Conn = @ptrCast(@alignCast(ctx)); - c.srv.reply(r, bytes); - } -}; - -/// How long the 9P listener stays out of the poll set after an `accept` that -/// failed for a reason that persists — EMFILE and ENFILE above all. The same -/// number and the same argument as the frontend listener's own pause: the -/// connection is still in the backlog, `poll` is level triggered, and coming -/// straight back spins the core until some unrelated descriptor is freed. -const accept_pause_ms: i64 = 100; - -/// The listening socket and its connections. Heap-allocated because a `Conn` -/// holds slices into itself (see there) and because at three buffers per -/// connection this is ≈100 KiB, which does not belong in a host's struct. -pub const Listener = struct { - fd: c_int = -1, - /// Do not accept before this moment on the monotonic clock. Set when - /// `accept(2)` fails for a reason that leaves the connection in the backlog - /// — EMFILE and ENFILE — because a level-triggered poll then reports the - /// listener ready forever and coming straight back spins the core. Zero - /// means accepting normally. - paused_ms: i64 = 0, - /// The bound path, kept so teardown unlinks exactly what was created — - /// guarded on the fd, like nested.zig's and detached/server.zig's - /// `unlisten`. - path_buf: [sun_path_len]u8 = undefined, - path_len: usize = 0, - conns: [max_conns]Conn = @splat(.{}), - - /// The socket path, so a host can tell the operator where to dial. - pub fn path(l: *const Listener) []const u8 { - return l.path_buf[0..l.path_len]; - } - - /// Accept whatever is waiting, bounded. - /// - /// BOUNDED for detached/server.zig's `accept` reason: `poll` is level - /// triggered, so an unaccepted backlog reports ready forever and a peer - /// dialling in a loop would otherwise hold the core in here. And always - /// accepting, even with a full table, for the same reason — the surplus is - /// accepted and closed rather than left to spin the poll. - pub fn accept(l: *Listener) void { - if (comptime !supported) return; - for (0..max_conns + 1) |_| { - const fd = libc.accept(l.fd, null, null); - if (fd < 0) switch (libc.errno(fd)) { - // The ordinary exit: nothing more is queued. - .AGAIN => return, - // Retry: a signal, or a peer that gave up between the poll and - // the accept. Neither says anything about our capacity. - .INTR, .CONNABORTED => continue, - // Out of descriptors. The connection STAYS in the backlog, so a - // level-triggered poll reports the listener ready again at once - // and coming straight back spins the core until something - // unrelated frees an fd — measured at 99.8% of one, sustained. - // The frontend listener one file over solves it the same way. - else => { - l.paused_ms = nowMs() +| accept_pause_ms; - log.warn("--fs9: accept failed; pausing the listener for {d} ms", .{accept_pause_ms}); - return; - }, - }; - nested.setCloexec(fd); - setNonblock(fd); - if (comptime nested.darwin) { - // linux says MSG_NOSIGNAL per write, darwin once per socket. A - // script that dies mid-reply must not take the editor down. - const on: c_int = 1; - _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); - } - const c = for (&l.conns) |*cand| { - if (cand.fd < 0 and !cand.draining) break cand; - } else { - // No slot. Closing is the whole refusal: a 9P client that - // reads EOF instead of an `Rversion` reports a dial failure, - // which is the honest thing for it to say. - log.debug("--fs9: refusing a connection, all {d} slots busy", .{max_conns}); - _ = libc.close(fd); - continue; - }; - c.fd = fd; - c.draining = false; - c.accepted_ms = nowMs(); - c.srv = .init(.{ - .in = &c.in, - .out = &c.out, - .root = @intFromEnum(pardes.acmefs.TopFile.root), - }); - } - } - - /// How long a connection may hold a slot without saying `Tversion`. The - /// same five seconds and the same argument as the frontend socket's - /// `greet_deadline_ms` (`detached/server.zig`): a slot held by silence is - /// the same denial as a full queue, arrived at from the other end. Cheaper - /// here, because there are four slots rather than thirty-two and no - /// handshake to fake. - pub const greet_deadline_ms: i64 = 5000; - - /// Take back any slot whose peer connected and then said nothing. Called - /// once per frame beside the drain; the host folds `nextDue` into its poll - /// timeout so the deadline is kept on an otherwise idle session rather than - /// whenever some other descriptor happens to wake it. - pub fn expire(l: *Listener) void { - if (comptime !supported) return; - const now = nowMs(); - if (now == 0) return; // no clock; see `nowMs` - for (&l.conns, 0..) |*c, i| { - if (c.fd < 0 or c.srv.msize != 0) continue; - if (now - c.accepted_ms < greet_deadline_ms) continue; - log.debug("--fs9: slot {d} never sent Tversion; taking it back", .{i}); - l.drop(@intCast(i)); - } - } - - /// Is the listener worth polling this round? False while it is paused after - /// a persistent `accept` failure — leaving it in the set is exactly the - /// spin the pause exists to stop. - pub fn accepting(l: *const Listener) bool { - if (comptime !supported) return false; - if (l.fd < 0) return false; - if (l.paused_ms == 0) return true; - const now = nowMs(); - return now == 0 or now >= l.paused_ms; - } - - /// Milliseconds until the earliest greet deadline, or null when nothing is - /// waiting on the clock. Floored at zero so a deadline already past polls - /// once without blocking instead of blocking on a negative timeout. - pub fn nextDue(l: *const Listener) ?i32 { - if (comptime !supported) return null; - const now = nowMs(); - if (now == 0) return null; - var due: ?i64 = null; - // The pause is a clock deadline like the greet ones: without it here, - // an idle session would sleep through the moment the listener is - // allowed back and only notice on the next unrelated wake. - if (l.paused_ms > now) due = l.paused_ms; - for (&l.conns) |*c| { - if (c.fd < 0 or c.srv.msize != 0) continue; - const at = c.accepted_ms + greet_deadline_ms; - due = if (due) |d| @min(d, at) else at; - } - const at = due orelse return null; - return @intCast(@max(0, at - now)); - } - - /// The monotonic clock in milliseconds, or 0 when there is none — which - /// every caller reads as "no deadlines this round" rather than as a time. - fn nowMs() i64 { - var ts: libc.timespec = undefined; - if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; - return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); - } - - /// Read one chunk off connection `i` and hand it to the server. - /// - /// ONE read per connection per round, which is detached/server.zig's - /// `receive` rule: a script in a `while true` loop gets one turn and then - /// the loop moves on to the other connections and to the frame. - /// - /// Sized to what the server can TAKE rather than to the socket, because - /// `push` returns short on back-pressure and bytes read past that point - /// would have nowhere to go. Zero room is not an error and not a hangup: - /// the buffer holds a message the core has not finished with, and the next - /// drain frees it. - pub fn fill(l: *Listener, i: u8) void { - if (comptime !supported) return; - const c = &l.conns[i]; - // FIRST, and before the room guard below, which is the trap: once - // `startFrame` gives up on the framing, `in_len` is stuck at `in.len` - // for good, so `room == 0` returns without reading, `poll` is level - // triggered, the descriptor reports ready again immediately, and the - // loop never sleeps. Measured at 99.7% of a core, sustained, reachable - // by any process with the uid in one `write(2)`. - if (c.srv.dead) return l.drop(i); - const room = c.srv.in.len - c.srv.in_len; - if (room == 0) return; - // A frame-local staging buffer rather than a read straight into the - // server's tail: advancing `in_len` is `push`'s business, and reaching - // past it to do it here would make this file a second author of - // `9p.zig`'s invariants for the sake of one memcpy per 8 KiB. - var buf: [msize]u8 = undefined; - const got = libc.read(c.fd, &buf, @min(room, buf.len)); - if (got == 0) return l.drop(i); // clean EOF: the script left - if (got < 0) return switch (libc.errno(got)) { - .INTR, .AGAIN => {}, - else => l.drop(i), - }; - const n = c.srv.push(buf[0..@intCast(got)]); - // The stream stopped being 9P. `Server.startFrame` sets `dead` when the - // framing is unrecoverable — a `size[4]` of zero, or one larger than the - // input buffer — and `push` then takes NOTHING, for good, because there - // is nowhere to resynchronise to in a protocol whose only frame marker - // is the length you were just lied to about. - // - // This has to be checked before the assert below, and the assert is why: - // it used to fire, and firing meant `unreachable` on the daemon's own - // thread — every pane, every attached frontend and the FUSE mount gone, - // reached by any client that sends one bad length and then one more - // byte. The socket is 0600 in a 0700 directory, but the whole point of - // `--fs9` is that other programs dial it, so a buggy one is enough. - if (c.srv.dead) return l.drop(i); - // NOW it cannot happen: the read was clamped to the room and the only - // other refusal is the one handled above. Asserted rather than ignored - // because silently dropping wire bytes desynchronises the stream, which - // is the one failure 9P cannot resynchronise from. - std.debug.assert(n == @as(usize, @intCast(got))); - } - - /// Push what the kernel will take of what this connection owes, and leave - /// the rest for a POLLOUT. detached/server.zig's `flush` on a 9P byte - /// FIFO instead of an `ArrayList`, and the rule it exists for is the same: - /// a peer that will not read must never block the editor. - pub fn flush(l: *Listener, i: u8) void { - if (comptime !supported) return; - const c = &l.conns[i]; - if (c.fd < 0) return; - while (true) { - const bytes = c.srv.output(); - if (bytes.len == 0) return; - const n = libc.send(c.fd, bytes.ptr, bytes.len, nosignal); - if (n < 0) switch (libc.errno(n)) { - .INTR => continue, - .AGAIN => return, - else => return l.drop(i), - }; - // No progress and no error. Looping on it is a spin, and a spin in - // here is the whole session at 100% of a core with no syscall for - // a signal to interrupt — host_io.zig's `writeFd` rule. - if (n == 0) return; - c.srv.wrote(@intCast(n)); - } - } - - /// Whether this connection wants POLLOUT: only while it owes bytes, which - /// is the same rule and the same reason as a client's and a pty's — asking - /// for it unconditionally makes every idle socket a ready descriptor and - /// turns the poll into a spin. - pub fn owes(l: *const Listener, i: u8) bool { - return l.conns[i].srv.output().len != 0; - } - - /// True when this slot has a live descriptor to poll. - pub fn live(l: *const Listener, i: u8) bool { - return l.conns[i].fd >= 0; - } - - /// This connection is over: out of the poll set, out of the process — but - /// NOT out of the table, because the server still owes the core a - /// `release` per open fid. See `Conn.draining`. - pub fn drop(l: *Listener, i: u8) void { - const c = &l.conns[i]; - if (c.fd >= 0) { - _ = libc.close(c.fd); - c.fd = -1; - } - if (c.draining) return; - c.srv.hangup(); - c.draining = true; - } - - /// This connection's `Transport`, or null when the slot has no work: the - /// peer never arrived, or it left and its fids are already released. - /// - /// THE HOST DRAINS, not this file, and that is not a style choice. A reply - /// reaches a transport through the host's `push_fs_reply`, so the host has - /// to know WHICH transport the request being served came from — the - /// "routing origin" docs/9p.typ's layering table (`O2 two listeners`) - /// names as the thing a second listener costs. Handing the transport out - /// here and taking the `Drained` back in `settle` is that origin made - /// explicit: the host sets it, calls `fs_service.drain`, clears it. A - /// `drainAll` that hid the loop in this file could not, and every 9P reply - /// went to the FUSE mount instead — measured, as a `Tattach` that never - /// came back. - pub fn transport(l: *Listener, i: u8) ?fs_service.Transport { - if (comptime !supported) return null; - const c = &l.conns[i]; - if (c.fd < 0 and !c.draining) return null; - return c.transport(); - } - - /// What one connection's drain came to: write the replies, and reclaim the - /// slot when a hung-up peer's last fid is released. Returns whether this - /// connection still owes work, which the caller or's into the flag that - /// keeps the loop from sleeping. - /// - /// The flush is HERE rather than left to a POLLOUT, so a reply the core - /// produced this frame is on the wire this frame; what the kernel would not - /// take waits for POLLOUT as usual. - pub fn settle(l: *Listener, i: u8, d: fs_service.Drained) bool { - if (comptime !supported) return false; - const c = &l.conns[i]; - if (c.draining) { - // A hung-up connection has no descriptor and therefore no event of - // its own: its orphaned fids are pumped out over successive frames, - // and the loop must stay hot until the pump runs dry. It does run - // dry — the debt is one release per fid. - if (d.count == 0) { - c.draining = false; - return false; - } - return true; - } - l.flush(i); - return d.pending; - } - - /// Every connection down, the socket closed, and the path unlinked. - /// - /// The orphaned fids are NOT pumped here: `deinit` runs when the session is - /// being torn down, and the core it would report the releases to is going - /// with it. `Fs.deinit` makes the same choice about the mount. - pub fn deinit(l: *Listener, gpa: std.mem.Allocator) void { - for (0..max_conns) |i| l.drop(@intCast(i)); - if (l.fd >= 0) { - _ = libc.close(l.fd); - l.fd = -1; - var z: [sun_path_len:0]u8 = undefined; - @memcpy(z[0..l.path_len], l.path_buf[0..l.path_len]); - z[l.path_len] = 0; - _ = libc.unlink(z[0..l.path_len :0]); - } - gpa.destroy(l); - } -}; - -/// `/pardes-9p-.sock`. A name is one path component and nothing -/// clever, for detached/server.zig's `socketPath` reason: a `/` would put the -/// socket somewhere else entirely and a NUL would truncate the address. The -/// buffer is `sun_path`-sized, so a name that does not fit is no address at -/// all rather than a truncated one pointing somewhere else. -pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 { - if (name.len == 0) return null; - if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null; - return std.fmt.bufPrintSentinel(buf, "{s}/" ++ prefix ++ "{s}.sock", .{ dir, name }, 0) catch null; -} - -/// Bind, listen, and hand back a listener — or null, which is the same answer -/// for "this platform has no unix sockets", "there is no runtime directory" -/// and "the bind failed". That is `fs_service.start`'s posture and -/// nested.zig's: a transport that will not come up must cost the operator -/// their scripting, never their session. -/// -/// `named` is `Options.fs9`: EMPTY means a bare `--fs9`, so `fallback` names -/// it (the session name in a daemon, this pid anywhere else), and anything -/// else is the name the user gave, which wins — scripts need a path they can -/// predict. -pub fn open(gpa: std.mem.Allocator, named: []const u8, fallback: []const u8) ?*Listener { - if (comptime !supported) return null; - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse { - log.warn("--fs9: no runtime directory for the socket", .{}); - return null; - }; - if (!nested.ensureSocketDir(dir)) return null; - const l = gpa.create(Listener) catch return null; - l.* = .{}; - // No sweep of the directory, unlike detached/server.zig's `listen`. That - // sweeper connects to every socket of its OWN prefix to retire dead ones; - // this prefix has no handshake to probe with, so the only stale file worth - // removing is the one this bind collides with, immediately below. - const p = socketPath(&l.path_buf, dir, if (named.len != 0) named else fallback) orelse { - gpa.destroy(l); - return null; - }; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. p.len + 1], p[0 .. p.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) { - gpa.destroy(l); - return null; - } - nested.setCloexec(fd); - // `bind` IS the exclusive create, so it and nothing else decides who owns - // a name — detached/server.zig's rule, and its reason: unlinking - // unconditionally is how a second daemon takes a live one's socket away. - // The one case that is not a collision is a session killed rather than - // quit, whose file outlived it, and `alive` is the only thing allowed to - // say so. - if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { - if (alive(p)) { - log.warn("--fs9: something is already listening on {s}", .{p}); - _ = libc.close(fd); - gpa.destroy(l); - return null; - } - _ = libc.unlink(p); - if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { - _ = libc.close(fd); - gpa.destroy(l); - return null; - } - } - // Owner-only, and BEFORE listen(2), which is the first moment anyone could - // connect. The directory is already 0700; this is the second wall, and - // this socket can write into every pane of a live editor. - _ = libc.chmod(p, 0o600); - if (libc.listen(fd, max_conns) != 0) { - _ = libc.close(fd); - gpa.destroy(l); - return null; - } - setNonblock(fd); - l.fd = fd; - l.path_len = p.len; - log.info("--fs9: serving 9P2000 on {s}", .{p}); - return l; -} - -/// Whether a socket file at `path` has a listener behind it. Only ever asked -/// about a bind that failed, and it answers on the CONNECT: a refusal means -/// the file outlived its process and may be unlinked, and anything else — -/// including a success — means somebody is there. -fn alive(path: [:0]const u8) bool { - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - if (path.len + 1 > sun_path_len) return true; // cannot ask; assume occupied - @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return true; - defer _ = libc.close(fd); - return libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0; -} - -/// Every descriptor here is non-blocking, for detached/server.zig's reason: -/// the core must never park on a peer. Its `setNonblock` is not reused because -/// importing the daemon into a module the tty and GUI shells may also serve -/// from would make a frontend transport a dependency of a filesystem. -fn setNonblock(fd: c_int) void { - const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return; - var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); - o.NONBLOCK = true; - _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); -} - -/// A dead script must never kill the editor. linux says it per write, darwin -/// once per socket (see `accept`). -const nosignal: u32 = if (nested.darwin) 0 else libc.MSG.NOSIGNAL; - -const testing = std.testing; - -test "the socket name is a third prefix in the shared directory" { - // Asserted rather than described: nested.zig's sweeper unlinks any name - // whose digits name a dead pid, and the detached transport's `vetted` - // accepts only its own prefix. A 9P socket must be invisible to both. - var buf: [sun_path_len]u8 = undefined; - const p = socketPath(&buf, "/run/user/1000", "t9srv").?; - try testing.expectEqualStrings("/run/user/1000/pardes-9p-t9srv.sock", p); - try testing.expect(!std.mem.startsWith(u8, std.fs.path.basename(p), "pardes-detached-")); -} - -test "a name that is not one path component is no address at all" { - var buf: [sun_path_len]u8 = undefined; - try testing.expect(socketPath(&buf, "/run", "") == null); - try testing.expect(socketPath(&buf, "/run", "a/b") == null); - try testing.expect(socketPath(&buf, "/run", "a\x00b") == null); -} - -test "one connection's buffers are sized from the one msize constant" { - // The `Srv` asserts both of these at `init`, where a violation is a panic - // in a live daemon; here it is a build failure instead. - try testing.expect(msize >= ninep.min_msize); - const c: Conn = .{}; - try testing.expectEqual(@as(usize, msize), c.in.len); - try testing.expectEqual(@as(usize, 2 * msize), c.out.len); -} - diff --git a/src/fs_service.zig b/src/fs_service.zig deleted file mode 100644 index b440c2c2..00000000 --- a/src/fs_service.zig +++ /dev/null @@ -1,324 +0,0 @@ -//! `pardes --fs`: what a native HOST has to decide to serve acme's control -//! filesystem. `acmefs.zig` owns the semantics and `fuse.zig` owns the kernel; -//! what is left, and lives here, is three decisions — WHERE to mount (derive a -//! per-session point, or take the one the user named), WHEN to drain (one -//! frame's batch, in the order fuse.zig's two queues require), and WHAT A PANE -//! SHELL IS TOLD about it (`PARDES_FS`/`PARDES_PANE`, exported before the -//! fork). -//! -//! It exists because tty.zig and gui.zig would otherwise each carry the same -//! forty lines through two different loops; the only thing that genuinely -//! differs between them is how a background thread wakes the loop, and that is -//! a function pointer. A session without `--fs` allocates nothing here, starts -//! no thread, and costs one null check per frame. -const std = @import("std"); -const libc = std.c; -const pardes = @import("pardes.zig"); -const fuse = @import("fuse.zig"); - -/// Diagnostics land on a pane's message row, not on stderr: in the tty shell -/// stderr IS the screen (see main.zig's logFn, which drops every scope for -/// exactly that reason). The log line is the `PARDES_LOG=1` copy, where the -/// mount point and the errno name are worth having. -const log = std.log.scoped(.fs); - -// std.c has getenv but neither setter, same as nested.zig. -extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -extern "c" fn unsetenv(name: [*:0]const u8) c_int; - -/// How many kernel requests one frame will answer before handing the loop back -/// to the renderer. A `find $PARDES_FS` or a script in a `while true` loop can -/// produce them faster than a frame takes, and an uncapped drain would render -/// only when the script paused. Hitting the cap is not a stall: `drain` says so -/// and the caller wakes its own loop, so the batch continues on the next pass -/// with one frame drawn in between. -const max_batch = 64; - -/// WHAT A TRANSPORT IS, to this file: three functions and a pointer. -/// -/// `drain` and `step` below never asked a `*fuse.Fs` for anything else — -/// `retry()`, `next()` and `reply()` are the whole of it — so the concrete -/// pointer was a coupling that bought nothing and forbade a second answer. -/// Naming the three makes the seam a thing a reader can see, and makes a 9P -/// listener beside the mount a matter of writing one more implementation -/// rather than of teaching this file about it. -/// -/// It is deliberately NOT a Zig interface with `anytype`: `drain` is ONE -/// function reached from four call sites (tty.zig, gui.zig twice, and the -/// daemon), and the second transport this seam exists for is chosen at RUN -/// time, so it has to be a value with a runtime type — which is a vtable, the -/// same shape and the same reasoning as `host.VTable`. Nothing holds a -/// `Transport` across a frame: every caller builds one inline from whatever it -/// has, which is why the thunks matter and the struct does not. -/// -/// The ORDER contract stays where it was, in `drain`, because it belongs to -/// the caller rather than to any implementor: `retry()` to null first, then -/// `next()` to null. An implementation with no parking answers `retry` null -/// forever and loses nothing. -pub const Transport = struct { - ctx: *anyopaque, - vtable: *const VTable, - - pub const VTable = struct { - /// The oldest parked request that is worth offering again, or null when - /// the round is over. Null also RESETS the round — see `drain`. - retry: *const fn (ctx: *anyopaque) ?pardes.acmefs.Req, - /// The next request off the wire, or null when there is nothing more. - /// That null is also the acknowledgement some transports owe a poller, - /// so a caller must reach it rather than stopping early. - next: *const fn (ctx: *anyopaque) ?pardes.acmefs.Req, - /// Answer one request. `bytes` is borrowed for the duration of the - /// call only. A `.again` status is the transport's business, not the - /// caller's: it re-parks the request itself. - reply: *const fn (ctx: *anyopaque, r: *const pardes.acmefs.Reply, bytes: []const u8) void, - }; - - pub fn retry(t: Transport) ?pardes.acmefs.Req { - return t.vtable.retry(t.ctx); - } - - pub fn next(t: Transport) ?pardes.acmefs.Req { - return t.vtable.next(t.ctx); - } - - pub fn reply(t: Transport, r: *const pardes.acmefs.Reply, bytes: []const u8) void { - t.vtable.reply(t.ctx, r, bytes); - } -}; - -/// Where per-session mounts live: `$XDG_RUNTIME_DIR/pardes` else -/// `~/.local/state/pardes`, and `/` is this session's mount point. -/// -/// NOT `nested.socketDir`, though it answers a related question. That one -/// returns `$XDG_RUNTIME_DIR` itself, because a socket is a FILE whose name -/// (`pardes-.sock`) already namespaces it. A mount point is a DIRECTORY -/// per pid, and `fuse.sweepStale` unmounts and removes every `` entry -/// it finds — so it needs a parent that contains nothing but our mounts, which -/// under `$XDG_RUNTIME_DIR` means one more level. The HOME fallback already has -/// that level, which is why the two strings coincide there and only there. -/// The two strings are parameters rather than `getenv` calls so the tests below -/// need not mutate the process environment. That is not fastidiousness: a test -/// binary shares one environ, and unsetting HOME here once took down an -/// unrelated subprocess test three files away. -fn parentFrom(buf: *[std.fs.max_path_bytes:0]u8, xdg: ?[]const u8, home: ?[]const u8) ?[:0]const u8 { - if (xdg) |x| return std.fmt.bufPrintSentinel(buf, "{s}/pardes", .{x}, 0) catch null; - const h = home orelse return null; - return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{h}, 0) catch null; -} - -fn envSlice(name: [*:0]const u8) ?[]const u8 { - return if (libc.getenv(name)) |v| std.mem.span(v) else null; -} - -fn parentDir(buf: *[std.fs.max_path_bytes:0]u8) ?[:0]const u8 { - return parentFrom(buf, envSlice("XDG_RUNTIME_DIR"), envSlice("HOME")); -} - -/// The mount point itself, from `Options.fs`: EMPTY means a bare `--fs`, so -/// derive `/`, and anything else is the `--fs=` the user -/// named, which wins verbatim — scripts and the snapshot harness need a name -/// they can predict. -/// -/// A named point must be absolute for the reason fuse.zig gives: the path is -/// handed to a setuid helper that resolves it against its OWN cwd, so a -/// relative one names somewhere else. Passing it through unresolved rather than -/// rooting it here keeps that one rule in one place; `Fs.mount` returns -/// `error.MountPathNotAbsolute`. -fn mountPoint(buf: *[std.fs.max_path_bytes:0]u8, named: []const u8, parent: ?[]const u8) ?[:0]const u8 { - if (named.len != 0) return std.fmt.bufPrintSentinel(buf, "{s}", .{named}, 0) catch null; - const dir = parent orelse return null; - // unsigned: {d} prints a leading '+' for a positive SIGNED int - return std.fmt.bufPrintSentinel(buf, "{s}/{d}", .{ dir, @as(u32, @intCast(libc.getpid())) }, 0) catch null; -} - -/// Sweep, derive, mount. Null when the session did not ask for a filesystem — -/// and also when it asked and the mount failed, which is deliberately the same -/// answer: a missing `fuse3`, a `user_allow_other`-less config or a kernel -/// without FUSE must cost the user their scripting, never their session. The -/// failure is reported once, on pane 0's message row, and everything else runs. -/// -/// Call after the core exists and before the first frame: the mount is live the -/// moment it returns, so a script racing startup finds a filesystem whose panes -/// are already there. -pub fn start(gpa: std.mem.Allocator, core: *pardes.Pardes) ?*fuse.Fs { - const named = core.opts.fs orelse return null; - var parent_buf: [std.fs.max_path_bytes:0]u8 = undefined; - const parent = parentDir(&parent_buf); - var buf: [std.fs.max_path_bytes:0]u8 = undefined; - const point = mountPoint(&buf, named, parent) orelse { - core.reportError(0, "fs mount", error.NoRuntimeDirectory); - return null; - }; - // Both of these are about a point we DERIVED. A `--fs=` the user named - // is not a directory we are entitled to unmount other things out of, its - // siblings are not ours to guess about, and it is not ours to remove on the - // way out either — `owns_dir` is what keeps `Fs.deinit` from rmdir'ing a - // directory the user made. - const derived = named.len == 0; - if (derived) if (parent) |dir| fuse.sweepStale(dir); - const fs = fuse.Fs.mount(gpa, .{ .mount = point, .owns_dir = derived }) catch |err| { - log.warn("--fs: cannot mount at {s}: {t}", .{ point, err }); - core.reportError(0, "fs mount", err); - return null; - }; - log.info("--fs: serving {s}", .{point}); - return fs; -} - -/// Start the one background thread, if there is a filesystem to start it for. -/// It waits for POLLIN on `/dev/fuse` and calls `wake(ctx)` — nothing else; it -/// never touches the core, the descriptor's data, or a request. Both hosts pass -/// a one-line callback that posts their own wake event, which is the ONLY thing -/// that differs between them here. -/// -/// A thread that will not spawn is not a filesystem that will not work: the -/// frame poll drains the same requests either way, so the loss is wake latency -/// (a script waits for the next event to arrive from anywhere) and the session -/// is not worth failing over it. That is also the documented no-parallelism -/// backend: skip this call entirely and everything still works. -pub fn wake(fs: ?*fuse.Fs, ctx: ?*anyopaque, callback: *const fn (?*anyopaque) void) void { - const f = fs orelse return; - f.wakeThread(ctx, callback) catch |err| - log.warn("--fs: no poll thread ({t}); draining once per frame instead", .{err}); -} - -/// What one frame's worth of filesystem work amounted to. Two separate facts, -/// because the two hosts need different ones: an interactive loop asks whether -/// to re-arm itself, while the headless grid harness asks whether anything -/// happened at all — its contract is one frame per event, and a request that -/// changed a pane IS an event. -pub const Drained = struct { - /// Requests answered, parked retries included. - count: usize = 0, - /// The cap stopped the batch with requests still waiting in the kernel. - pending: bool = false, -}; - -/// One frame's worth of filesystem work. -/// -/// The two loops are both to null and in this order, which is the TRANSPORT -/// contract rather than a preference — stated here because it belongs to the -/// caller, and every implementor inherits it: -/// -/// - `retry()`'s null ENDS AND RESETS the round, so a caller that took one -/// parked request per frame would leave the second-oldest blocked reader -/// waiting 32 frames. The round is bounded by the park table, so it needs -/// no cap of its own. -/// - `next()`'s null is what acknowledges the drain to whatever is waiting on -/// the descriptor. For the FUSE mount that is a poll thread, and the -/// handshake is what stops a level-triggered `poll()` from spinning a core; -/// which is why `pending` has to keep the loop hot: no ack has been sent, -/// so nothing else will wake us. -pub fn drain(t: Transport, core: *pardes.Pardes) Drained { - var d: Drained = .{}; - while (t.retry()) |req| { - step(t, core, req); - d.count += 1; - } - while (d.count < max_batch) { - const req = t.next() orelse return d; - step(t, core, req); - d.count += 1; - } - d.pending = true; - return d; -} - -/// One request, one answer, and nothing in between: `req.data` borrows storage -/// the next `next()` overwrites, and the `.fs_reply` this emits is drained -/// before the loop can move on — so the borrow window is a single step, exactly -/// as the design contract requires. The reply normally reaches the transport -/// through the host's `push_fs_reply`, because the payload bytes are resolved -/// by `pardes.fsPayload` inside `perform` and are only valid there. -/// -/// The exception is the `if` at the end. The core's effect ring is bounded and -/// `emit` DROPS on overflow, which for every other effect costs a repaint and -/// for this one costs a foreign process: an unanswered FUSE request leaves its -/// writer in uninterruptible sleep and its park slot used forever, and 32 of -/// those make the whole mount answer EAGAIN. One `ctl` write reaches the cap -/// (`put` emits a `.save_file` per line). So this loop, which is the only place -/// that knows a request is outstanding, watches the effects it performs for the -/// answer and invents an EIO when none came. -fn step(t: Transport, core: *pardes.Pardes, req: pardes.acmefs.Req) void { - core.update(.{ .fs_req = req }); - var answered = false; - while (core.nextEffect()) |e| { - if (e == .fs_reply and e.fs_reply.tag == req.tag) answered = true; - core.perform(e); - } - if (!answered) { - const eio = pardes.acmefs.Reply.fail(req.tag, pardes.acmefs.E.IO); - t.reply(&eio, ""); - } -} - -/// What a pane shell is told about the filesystem: `PARDES_FS` is the mount and -/// `PARDES_PANE` is this pane's serial, so a script run inside a pane addresses -/// its own window with no arguments. That pair is acme's `winid` (exec.c), and -/// the serial rather than the slot index because slots are reused and serials -/// never are — `$PARDES_FS/$PARDES_PANE/body` must not start naming somebody -/// else's pane after a close. -/// -/// Exported in the PARENT, immediately before the fork, and this is the one -/// place pardes cannot copy acme. acme calls `putenv` in the child, which is -/// safe there because `rfork(RFENVG)` has just given that child a private -/// environment group. A Linux fork has no such thing, and `setenv` between fork -/// and exec can deadlock on an allocator lock some other thread held at fork -/// time — the same rule that already forces `shell_bin.resolve` above the fork -/// in both hosts. The cost is that pardes's own environ carries the -/// last-spawned pane's number; nothing in pardes reads it, and a subprocess -/// that inherits it was spawned on behalf of a pane anyway. -/// -/// With no filesystem the pair is REMOVED rather than left alone. A pardes -/// started inside a pardes that does serve one inherits both variables from its -/// parent's pane shell, and a session with no mount of its own must not hand -/// its panes an address that resolves to a window in someone else's session. -pub fn exportPaneEnv(fs: ?*const fuse.Fs, serial: u32) void { - const f = fs orelse { - _ = unsetenv("PARDES_FS"); - _ = unsetenv("PARDES_PANE"); - return; - }; - _ = setenv("PARDES_FS", f.path.ptr, 1); - var buf: [16:0]u8 = undefined; - const id = std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0) catch return; - _ = setenv("PARDES_PANE", id.ptr, 1); -} - -const testing = std.testing; - -test "the mount point is one level below a per-user parent, named by our pid" { - // $XDG_RUNTIME_DIR is shared with every other program in the session, so - // the mounts need a `pardes/` of their own under it — the level - // nested.socketDir does not have, and the reason this is not that function. - // Asserted rather than merely described, because `fuse.sweepStale` unmounts - // and removes every `` entry in whatever directory it is handed. - var parent: [std.fs.max_path_bytes:0]u8 = undefined; - const dir = parentFrom(&parent, "/run/user/1000", "/home/tester").?; - try testing.expectEqualStrings("/run/user/1000/pardes", dir); - var buf: [std.fs.max_path_bytes:0]u8 = undefined; - var expect: [std.fs.max_path_bytes]u8 = undefined; - try testing.expectEqualStrings( - try std.fmt.bufPrint(&expect, "{s}/{d}", .{ dir, @as(u32, @intCast(libc.getpid())) }), - mountPoint(&buf, "", dir).?, - ); -} - -test "no XDG_RUNTIME_DIR falls back to the home state directory, which has the level already" { - var parent: [std.fs.max_path_bytes:0]u8 = undefined; - try testing.expectEqualStrings( - "/home/tester/.local/state/pardes", - parentFrom(&parent, null, "/home/tester").?, - ); -} - -test "a session with no filesystem removes an inherited address rather than passing it on" { - // PARDES_FS/PARDES_PANE are ours alone, and this leaves them the way an - // --fs-less session leaves them: absent. Nothing else in the test binary - // reads either name, which is why this is the one env-touching test here. - _ = setenv("PARDES_FS", "/run/user/1000/pardes/999", 1); - _ = setenv("PARDES_PANE", "7", 1); - exportPaneEnv(null, 3); - try testing.expect(libc.getenv("PARDES_FS") == null); - try testing.expect(libc.getenv("PARDES_PANE") == null); -} diff --git a/src/fuse.zig b/src/fuse.zig deleted file mode 100644 index 3bd263bd..00000000 --- a/src/fuse.zig +++ /dev/null @@ -1,2749 +0,0 @@ -//! The `/dev/fuse` transport for pardes's acme control filesystem: wire codec, -//! mount and unmount through `fusermount3`, one `poll()` thread, and the park -//! table that turns acme's blocking `event` read into "ask me again later". -//! -//! Raw protocol, no libfuse. libfuse is a thread pool, a request dispatcher and -//! a session lifetime — three things pardes already has and would have to fight. -//! What is left once those are removed is a struct layout and a read/write loop, -//! which is this file. It links nothing; the only external program it runs is -//! the setuid `fusermount3` helper, because an unprivileged process cannot -//! `mount(2)` in the initial user namespace and that helper exists precisely to -//! hand back a `/dev/fuse` descriptor for a mount it made on our behalf. -//! -//! The whole file is one side of a strict division of labour: -//! -//! - `acmefs.zig` owns the semantics and knows nothing about FUSE. It speaks -//! `Req`/`Reply` and never blocks. -//! - this file owns the kernel's opinions and knows nothing about panes. It -//! answers, in place, every request the core has no business seeing (INIT, -//! FORGET, INTERRUPT, DESTROY and the whole ENOSYS family), and translates -//! the eleven that remain. -//! - the host loop (tty/gui) owns the ordering: `retry()` to null, `next()` -//! to null, one `update()` per request, effects drained in between. -//! -//! THREADING. The main thread owns the descriptor for read and for write. The -//! poll thread never touches its data, never sees a `Req`, and never calls into -//! the core; it waits for POLLIN, calls the host's wake callback, and then -//! blocks until the main thread has drained. That last handshake is not -//! decoration: `poll()` is level triggered, so a poller that re-polls -//! immediately would spin a core at 100% for as long as one unanswered request -//! sits in the kernel queue. A host with no threads at all skips `wakeThread` -//! and drains from its frame poll; it loses wake latency and nothing else. -//! -//! BLOCKING. A FUSE server blocks a reader by simply not answering, and that is -//! the one and only way (the kernel gives no meaning to an EAGAIN reply). So -//! `Status.again` means "held": the request moves into the park table with its -//! bytes copied out of the read buffer, and `retry()` offers it back once per -//! frame until the core has something to say. Two obligations come with that: -//! -//! 1. a SIGKILLed reader whose request is never answered ends in -//! *uninterruptible* sleep (`fuse_dev`'s final `wait_event` is not -//! killable), so it survives its own kill until we reply. FUSE_INTERRUPT -//! is the escape hatch and is honoured below. -//! 2. teardown must answer everything still parked, and must abort the -//! connection by closing the descriptor before unmounting, or a reader -//! that raced the shutdown is stuck in D state with nobody left to wake -//! it. -//! -//! Linux only, guarded the way `file_watch.zig` guards inotify: every entry -//! point returns the inert answer off Linux, so a macOS or web build compiles -//! and mounts nothing. Only `mount()` can create an `Fs`, so off Linux no other -//! function in this file is ever reached. -//! -//! Verified against `/usr/include/linux/fuse.h` (7.45) and `fs/fuse/{dev,inode, -//! file,dir,readdir}.c`; the comptime size assertions below turn a header drift -//! into a compile error rather than a wedged mount nobody can unmount. -const std = @import("std"); -const builtin = @import("builtin"); -const libc = std.c; -const linux = std.os.linux; -const acmefs = @import("acmefs.zig"); -/// Only for `Transport`, the three-function shape this mount presents to the -/// host loop. This IS a cycle — `fs_service` imports this file back for -/// `Fs.mount`, `sweepStale` and `exportPaneEnv`, and still names `*Fs` in three -/// of its own signatures — and Zig accepts it because imports are analysed -/// lazily. What the seam removed is `drain`'s dependency on the concrete type, -/// not the file's dependency on this one. Do not read it as more than that. -const fs_service = @import("fs_service.zig"); - -/// Everything below the mount is Linux kernel ABI. Off Linux the module still -/// compiles (it is imported by the shared native shell) and does nothing. -const supported = builtin.os.tag == .linux; - -// --------------------------------------------------------------------------- -// wire protocol -// --------------------------------------------------------------------------- - -/// The protocol version this server speaks. A mismatch in the *major* aborts -/// the connection outright (`fuse_init_finish`: `arg->major != -/// FUSE_KERNEL_VERSION` -> `ok = false` -> the mount is dead on arrival), so -/// there is nothing to negotiate there. -const kernel_version: u32 = 7; - -/// The highest minor these structs were checked against (see the module -/// header). The INIT reply carries `@min(kernel_minor, what the kernel -/// offered)`: `fuse_init_finish` stores our number as `fc->minor`, and the -/// kernel then sizes the replies it reads back from us by it (the -/// `FUSE_COMPAT_*_SIZE` family in `fs/fuse/`), so echoing a *newer* kernel's -/// minor promises reply fields these structs do not have. Capping costs -/// nothing: with `flags = 0` no feature depends on the number. -const kernel_minor: u32 = 45; - -/// `fuse_dev_do_read` refuses to hand over a request when the server's read -/// buffer is smaller than this, and answers the *client* EIO instead: every -/// syscall through the mount fails and nothing says why. -const min_read_buffer: usize = 8192; - -/// `FUSE_REC_ALIGN`. A dirent record that is not a multiple of 8 desynchronises -/// the kernel's parse of the rest of the reply, so one bad name turns the whole -/// directory into garbage rather than into an error. -const rec_align: usize = 8; - -/// `FUSE_NAME_OFFSET` — the fixed part of a `fuse_dirent`, before the name. -const dirent_name_offset: usize = @sizeOf(fuse_dirent); - -fn recAlign(n: usize) usize { - return (n + rec_align - 1) & ~(rec_align - 1); -} - -/// The subset of `enum fuse_opcode` this server can receive. Non-exhaustive on -/// purpose: a newer kernel adds opcodes, and `@enumFromInt` of an unlisted -/// value into an exhaustive enum is undefined behaviour — the one bug in a -/// protocol decoder that cannot be diagnosed from the outside. -const Opcode = enum(u32) { - lookup = 1, - forget = 2, - getattr = 3, - setattr = 4, - readlink = 5, - symlink = 6, - mknod = 8, - mkdir = 9, - unlink = 10, - rmdir = 11, - rename = 12, - link = 13, - open = 14, - read = 15, - write = 16, - statfs = 17, - release = 18, - fsync = 20, - setxattr = 21, - getxattr = 22, - listxattr = 23, - removexattr = 24, - flush = 25, - init = 26, - opendir = 27, - readdir = 28, - releasedir = 29, - fsyncdir = 30, - getlk = 31, - setlk = 32, - setlkw = 33, - access = 34, - create = 35, - interrupt = 36, - bmap = 37, - destroy = 38, - ioctl = 39, - poll = 40, - notify_reply = 41, - batch_forget = 42, - fallocate = 43, - readdirplus = 44, - rename2 = 45, - lseek = 46, - copy_file_range = 47, - setupmapping = 48, - removemapping = 49, - syncfs = 50, - tmpfile = 51, - statx = 52, - copy_file_range_64 = 53, - _, -}; - -/// `FATTR_SIZE`. The only setattr bit this filesystem reads: without -/// `FUSE_ATOMIC_O_TRUNC` (which `flags = 0` deliberately does not negotiate) -/// the kernel strips `O_TRUNC` from the OPEN and issues a separate -/// `SETATTR(size = 0)`, so this bit *is* how `> file` reaches the core. -const FATTR_SIZE: u32 = 1 << 3; - -/// `FUSE_GETATTR_FH` — says the `fh` field of `fuse_getattr_in` is meaningful. -/// Reading `fh` without checking it hands the core a stale handle from an -/// unrelated open. -const FUSE_GETATTR_FH: u32 = 1 << 0; - -/// `FOPEN_DIRECT_IO`. Without it the kernel serves reads out of the page cache -/// and coalesces them, which for this filesystem is wrong in both directions: -/// a second `cat` of `index` would return the first one's bytes, and a blocking -/// `event` read would never reach us at all. -const FOPEN_DIRECT_IO: u32 = 1 << 0; - -const fuse_in_header = extern struct { - len: u32, - opcode: u32, - unique: u64, - nodeid: u64, - uid: u32, - gid: u32, - pid: u32, - total_extlen: u16, - padding: u16, -}; - -const fuse_out_header = extern struct { - len: u32, - @"error": i32, - unique: u64, -}; - -const fuse_init_in = extern struct { - major: u32, - minor: u32, - max_readahead: u32, - flags: u32, - flags2: u32, - unused: [11]u32, -}; - -const fuse_init_out = extern struct { - major: u32, - minor: u32, - max_readahead: u32, - flags: u32, - max_background: u16, - congestion_threshold: u16, - max_write: u32, - time_gran: u32, - max_pages: u16, - map_alignment: u16, - flags2: u32, - max_stack_depth: u32, - request_timeout: u16, - unused: [11]u16, -}; - -const fuse_attr = extern struct { - ino: u64, - size: u64, - blocks: u64, - atime: u64, - mtime: u64, - ctime: u64, - atimensec: u32, - mtimensec: u32, - ctimensec: u32, - mode: u32, - nlink: u32, - uid: u32, - gid: u32, - rdev: u32, - blksize: u32, - flags: u32, -}; - -const fuse_entry_out = extern struct { - nodeid: u64, - generation: u64, - entry_valid: u64, - attr_valid: u64, - entry_valid_nsec: u32, - attr_valid_nsec: u32, - attr: fuse_attr, -}; - -const fuse_attr_out = extern struct { - attr_valid: u64, - attr_valid_nsec: u32, - dummy: u32, - attr: fuse_attr, -}; - -const fuse_getattr_in = extern struct { - getattr_flags: u32, - dummy: u32, - fh: u64, -}; - -const fuse_setattr_in = extern struct { - valid: u32, - padding: u32, - fh: u64, - size: u64, - lock_owner: u64, - atime: u64, - mtime: u64, - ctime: u64, - atimensec: u32, - mtimensec: u32, - ctimensec: u32, - mode: u32, - unused4: u32, - uid: u32, - gid: u32, - unused5: u32, -}; - -const fuse_open_in = extern struct { - flags: u32, - open_flags: u32, -}; - -const fuse_open_out = extern struct { - fh: u64, - open_flags: u32, - backing_id: i32, -}; - -const fuse_read_in = extern struct { - fh: u64, - offset: u64, - size: u32, - read_flags: u32, - lock_owner: u64, - flags: u32, - padding: u32, -}; - -const fuse_write_in = extern struct { - fh: u64, - offset: u64, - size: u32, - write_flags: u32, - lock_owner: u64, - flags: u32, - padding: u32, -}; - -const fuse_write_out = extern struct { - size: u32, - padding: u32, -}; - -const fuse_release_in = extern struct { - fh: u64, - flags: u32, - release_flags: u32, - lock_owner: u64, -}; - -const fuse_flush_in = extern struct { - fh: u64, - unused: u32, - padding: u32, - lock_owner: u64, -}; - -const fuse_forget_in = extern struct { - nlookup: u64, -}; - -const fuse_batch_forget_in = extern struct { - count: u32, - dummy: u32, -}; - -const fuse_interrupt_in = extern struct { - unique: u64, -}; - -const fuse_kstatfs = extern struct { - blocks: u64, - bfree: u64, - bavail: u64, - files: u64, - ffree: u64, - bsize: u32, - namelen: u32, - frsize: u32, - padding: u32, - spare: [6]u32, -}; - -const fuse_statfs_out = extern struct { - st: fuse_kstatfs, -}; - -/// The `name` array is flexible in C and therefore absent here; this struct IS -/// `FUSE_NAME_OFFSET`, and `dirent_name_offset` is taken from its size so the -/// encoder and the kernel cannot disagree about where a name starts. -const fuse_dirent = extern struct { - ino: u64, - off: u64, - namelen: u32, - type: u32, -}; - -/// `DT_*` from `linux/dirent.h`, as `fuse_dirent.type` wants them. -const DT_DIR: u32 = 4; -const DT_REG: u32 = 8; - -/// `S_IFMT` bits. `Reply.Attr.mode` carries permissions only, so the format -/// nibble is ours to add; a `fuse_attr.mode` with no format bits is a file of -/// no type and `stat(2)` through the mount returns something no tool expects. -const S_IFDIR: u32 = 0o040000; -const S_IFREG: u32 = 0o100000; - -// A drifted header is a mount that hangs with no diagnostic, so every struct -// on the wire asserts its size here. These numbers are `sizeof` from -// /usr/include/linux/fuse.h at FUSE_KERNEL_MINOR_VERSION 45; they are frozen -// ABI and are not allowed to change under us silently. -comptime { - std.debug.assert(@sizeOf(fuse_in_header) == 40); - std.debug.assert(@sizeOf(fuse_out_header) == 16); - std.debug.assert(@sizeOf(fuse_init_in) == 64); - std.debug.assert(@sizeOf(fuse_init_out) == 64); - std.debug.assert(@sizeOf(fuse_attr) == 88); - std.debug.assert(@sizeOf(fuse_entry_out) == 128); - std.debug.assert(@sizeOf(fuse_attr_out) == 104); - std.debug.assert(@sizeOf(fuse_getattr_in) == 16); - std.debug.assert(@sizeOf(fuse_setattr_in) == 88); - std.debug.assert(@sizeOf(fuse_open_in) == 8); - std.debug.assert(@sizeOf(fuse_open_out) == 16); - std.debug.assert(@sizeOf(fuse_read_in) == 40); - std.debug.assert(@sizeOf(fuse_write_in) == 40); - std.debug.assert(@sizeOf(fuse_write_out) == 8); - std.debug.assert(@sizeOf(fuse_release_in) == 24); - std.debug.assert(@sizeOf(fuse_flush_in) == 24); - std.debug.assert(@sizeOf(fuse_forget_in) == 8); - std.debug.assert(@sizeOf(fuse_batch_forget_in) == 8); - std.debug.assert(@sizeOf(fuse_interrupt_in) == 8); - std.debug.assert(@sizeOf(fuse_kstatfs) == 80); - std.debug.assert(@sizeOf(fuse_statfs_out) == 80); - std.debug.assert(@sizeOf(fuse_dirent) == 24); - // The one field offset the codec depends on beyond struct sizes: the body - // of every request starts here, and 40 is a multiple of 8, which is what - // lets the parse point a struct at the read buffer instead of copying. - std.debug.assert(@sizeOf(fuse_in_header) % rec_align == 0); -} - -// --------------------------------------------------------------------------- -// the neutral readdir staging format -// --------------------------------------------------------------------------- - -/// How `acmefs` hands a directory listing to this file. The core is protocol -/// neutral by design, so it must not stage `fuse_dirent`s: those carry an -/// alignment rule, a cookie rule and a `DT_*` table that are the kernel's -/// business, not the editor's. It stages this instead, packed and repeated, -/// little endian, into `State.out`: -/// -/// node: u64 the acmefs node id of the entry, never 0 (see below) -/// kind: u8 0 = regular file, 1 = directory -/// namelen: u8 1..255, never 0 -/// name: [namelen]u8 -/// -/// `node` travels so that the `d_ino` a `getdents64` sees is the same number a -/// later `stat` reports. Synthesising one here instead would make `find -inum` -/// and every hardlink-detecting tool lie about this filesystem. -/// -/// `node` is never 0. It used to be, for the entries under `new/`: those name -/// panes that do not exist, because acme creates the pane when the name is -/// LOOKED UP. `new/` now stages nothing at all — every name in it is a -/// *creating* lookup, so any tool that stats what a readdir reported (`ls -l`, -/// `find`, tab completion) would make one pane per entry — which is why there -/// is no longer a sentinel `d_ino` for an unresolved name on the wire. -/// -/// The core stages entries starting at index `req.off` (the cookie the kernel -/// echoed back) in a stable order. This encoder assigns cookie `off = req.off + -/// n + 1` to the nth entry it emits, and may emit only a *prefix* of what was -/// staged when the kernel's requested `size` runs out — the remainder comes -/// back as another readdir at the higher cookie, so staging has to be -/// idempotent per cookie rather than a stream. Zero staged bytes means EOF; it -/// is not an error, and the kernel stops asking. -/// -/// No `.` or `..`: the kernel synthesises neither and needs neither, and a -/// filesystem that emits them has to answer `LOOKUP("..")` too. -pub const dirent_stage_prefix = 10; - -/// Encode staged entries into kernel `fuse_dirent` records. Returns the bytes -/// written to `out`. Pure: this is where the alignment and cookie rules live, -/// and it is tested directly. -fn encodeDirents(out: []u8, staged: []const u8, cookie: u64) usize { - var in: usize = 0; - var w: usize = 0; - var n: u64 = 0; - while (in + dirent_stage_prefix <= staged.len) { - const node = std.mem.readInt(u64, staged[in..][0..8], .little); - const kind = staged[in + 8]; - const namelen: usize = staged[in + 9]; - // A zero name length would make the record self-referential (the - // kernel would parse the padding as the next entry), and a truncated - // record means the core staged something we cannot read. Stop rather - // than guess: a short reply is a legal readdir, a malformed one is not. - if (namelen == 0 or in + dirent_stage_prefix + namelen > staged.len) break; - const name = staged[in + dirent_stage_prefix ..][0..namelen]; - const record = recAlign(dirent_name_offset + namelen); - if (w + record > out.len) break; - - // Written field by field rather than through a struct pointer: `out` - // is a caller's slice of unknown alignment, and one @alignCast that is - // wrong here is a misaligned store into a kernel-bound buffer. - std.mem.writeInt(u64, out[w..][0..8], node, .little); - std.mem.writeInt(u64, out[w + 8 ..][0..8], cookie + n + 1, .little); - std.mem.writeInt(u32, out[w + 16 ..][0..4], @intCast(namelen), .little); - std.mem.writeInt(u32, out[w + 20 ..][0..4], if (kind == 1) DT_DIR else DT_REG, .little); - @memcpy(out[w + dirent_name_offset ..][0..namelen], name); - // The kernel never shows the padding to anyone, but zeroing it keeps - // the wire deterministic, which is what the encoder test asserts on. - @memset(out[w + dirent_name_offset + namelen ..][0 .. record - dirent_name_offset - namelen], 0); - - in += dirent_stage_prefix + namelen; - w += record; - n += 1; - } - return w; -} - -// --------------------------------------------------------------------------- -// fusermount3 -// --------------------------------------------------------------------------- - -/// The environment variable `fusermount3` reads to find the socket it must send -/// the `/dev/fuse` descriptor back over. Spelled with the leading underscore in -/// libfuse (`FUSE_COMMFD_ENV`); it is a private contract between the two -/// programs, not a user knob. -const commfd_env = "_FUSE_COMMFD"; - -/// Where the helper might be. Arch puts it in /usr/bin with /usr/sbin a symlink -/// to it, Debian derivatives use /usr/bin, and a machine with only libfuse2 -/// installed spells it without the 3 — that binary speaks the same -/// socketpair/SCM_RIGHTS protocol, so it is a real fallback and not a guess. -/// Searched by absolute path rather than through PATH because the thing being -/// executed is setuid root: PATH is attacker-influenced input. -const fusermount_paths = [_][:0]const u8{ - "/usr/bin/fusermount3", - "/usr/sbin/fusermount3", - "/bin/fusermount3", - "/sbin/fusermount3", - "/usr/local/bin/fusermount3", - "/usr/bin/fusermount", - "/usr/sbin/fusermount", - "/bin/fusermount", -}; - -/// The `-o` string. Every option here is a deliberate refusal: -/// -/// - `fsname`/`subtype` are cosmetic but load bearing: they are what `mount`, -/// `df` and `/proc/self/mountinfo` show, and an unnamed fuse mount in a bug -/// report is indistinguishable from anyone else's. -/// - `nosuid,nodev` are what fusermount3 forces anyway; naming them keeps the -/// intent in the source rather than in someone else's default. -/// - NOT `allow_other`: it needs `user_allow_other` in /etc/fuse.conf, which -/// is commented out on a stock Arch install, and asking for it makes -/// fusermount3 fail the whole mount instead of ignoring the option. It -/// would also be wrong — this filesystem executes text on write. -/// - NOT `default_permissions`: with it the kernel enforces the mode bits we -/// report, which sounds like a free wall but moves access control from the -/// core (which knows that `cons` is write-only) into a mode field, so a -/// wrong nibble in a table becomes an EACCES nobody can explain. Same -/// reason INIT negotiates no flags: fewer kernel behaviours to honour. -fn mountOpts(buf: *[128:0]u8) [:0]const u8 { - return std.fmt.bufPrintSentinel(buf, "fsname=pardes,subtype=pardes,nosuid,nodev", .{}, 0) catch unreachable; -} - -/// `_FUSE_COMMFD=`, the child's end of the socketpair by number. libfuse -/// passes the descriptor this way rather than on the command line because -/// fusermount3 is setuid: its argv is world readable through /proc, its -/// environment is not. -fn commfdEnv(buf: *[32:0]u8, fd: c_int) [:0]const u8 { - return std.fmt.bufPrintSentinel(buf, commfd_env ++ "={d}", .{fd}, 0) catch unreachable; -} - -/// `fusermount3 -o -- `. The `--` is not optional: a -/// mountpoint that begins with a dash would otherwise be parsed as a flag by a -/// setuid program. -fn mountArgv( - argv: *[6:null]?[*:0]const u8, - prog: [*:0]const u8, - opts: [*:0]const u8, - mountpoint: [*:0]const u8, -) void { - argv.* = .{ prog, "-o", opts, "--", mountpoint, null }; -} - -/// `fusermount3 -u -q -z -- `. Lazy (`-z`) because the mount may -/// still have an open descriptor on it — a pane shell that inherited a cwd -/// inside the mount, say — and a non-lazy unmount would fail with EBUSY and -/// leave the mount behind for good. Quiet (`-q`) because the common case at -/// exit is a mount the kernel already tore down, and its complaint would be the -/// last thing on the user's terminal. -fn unmountArgv(argv: *[7:null]?[*:0]const u8, prog: [*:0]const u8, mountpoint: [*:0]const u8) void { - argv.* = .{ prog, "-u", "-q", "-z", "--", mountpoint, null }; -} - -/// CMSG_ALIGN/CMSG_LEN/CMSG_SPACE. Only ever evaluated on the Linux path, -/// where the alignment is `sizeof(size_t)`; other platforms align control -/// messages to 4 and would need their own numbers. -fn cmsgAlign(n: usize) usize { - const a: usize = @alignOf(usize); - return (n + a - 1) & ~(a - 1); -} -fn cmsgLen(n: usize) usize { - return cmsgAlign(@sizeOf(libc.cmsghdr)) + n; -} -fn cmsgSpace(n: usize) usize { - return cmsgAlign(@sizeOf(libc.cmsghdr)) + cmsgAlign(n); -} - -/// Build the child's environment: ours, plus `_FUSE_COMMFD`, minus any -/// `_FUSE_COMMFD` we inherited. The subtraction matters — `getenv` returns the -/// *first* match, so an inherited stale entry (pardes launched from inside -/// something that mounts) would win over the one we just appended and -/// fusermount3 would send the descriptor to a closed socket. -fn buildEnv(gpa: std.mem.Allocator, commfd: [:0]const u8) ![]?[*:0]const u8 { - var count: usize = 0; - while (libc.environ[count] != null) count += 1; - const env = try gpa.alloc(?[*:0]const u8, count + 2); - var n: usize = 0; - for (0..count) |i| { - const entry = libc.environ[i].?; - if (std.mem.startsWith(u8, std.mem.span(entry), commfd_env ++ "=")) continue; - env[n] = entry; - n += 1; - } - env[n] = commfd.ptr; - env[n + 1] = null; - return env[0 .. n + 2]; -} - -/// Resolve the helper once, by absolute path. Doing it in the parent rather -/// than by chaining execve attempts in the child keeps `argv[0]` honest (it is -/// what `ps` and fusermount3's own diagnostics print) and turns "fuse3 is not -/// installed" into its own error instead of an exit status. -fn findFusermount() ?[:0]const u8 { - for (fusermount_paths) |candidate| { - if (libc.access(candidate.ptr, libc.X_OK) == 0) return candidate; - } - return null; -} - -/// fork + execve the helper and wait for it. Not `std.process.Child`: that has -/// no way to hand a child an arbitrary descriptor, and the entire protocol here -/// is "the child writes to descriptor N". Everything the child does before -/// execve is async-signal-safe (close, execve, _exit) because the parent may -/// well be multithreaded by the time this runs. -fn spawnHelper( - prog: [*:0]const u8, - argv: [*:null]const ?[*:0]const u8, - envp: [*:null]const ?[*:0]const u8, - close_in_child: c_int, -) !u8 { - const pid = libc.fork(); - if (pid < 0) return error.ForkFailed; - if (pid == 0) { - // The parent's end of the socketpair. Left open, the parent's recvmsg - // could never see EOF when the helper dies without sending anything, - // and a refused mount would hang instead of failing. - if (close_in_child >= 0) _ = libc.close(close_in_child); - _ = libc.execve(prog, argv, envp); - // 127 is the shell's convention for "not found". Reachable only when - // the binary vanished between the access(2) above and now. - libc._exit(127); - } - var status: c_int = 0; - while (true) { - const got = libc.waitpid(pid, &status, 0); - if (got == pid) break; - if (got < 0 and libc.errno(got) == .INTR) continue; - // Reaped by somebody else's SIGCHLD handler: the status is gone, and - // the descriptor either arrived or it did not. Claim success and let - // the recvmsg be the judge. - return 0; - } - // WIFEXITED/WEXITSTATUS spelled out: std has no portable macro, and a - // helper killed by a signal is not a helper that refused the mount. - if (status & 0x7f != 0) return error.FusermountKilled; - return @intCast((status >> 8) & 0xff); -} - -/// Receive the `/dev/fuse` descriptor. fusermount3 sends it as an SCM_RIGHTS -/// control message alongside exactly one byte of ordinary data, and the byte is -/// not padding: a control message with no data attached may be dropped, so both -/// sides are required to send at least one. -/// -/// `MSG_CMSG_CLOEXEC` is the important flag. Every pane shell is forked from -/// this process and inherits open descriptors; a bash holding a copy of this -/// one keeps the FUSE connection alive after pardes exits, and the mount stays -/// up, unkillable, answering nothing, until that shell dies. -fn receiveFd(sock: c_int) !c_int { - var byte: [1]u8 = undefined; - var iov = [1]std.posix.iovec{.{ .base = &byte, .len = 1 }}; - var control: [cmsgSpace(@sizeOf(c_int))]u8 align(@alignOf(libc.cmsghdr)) = undefined; - while (true) { - var msg: libc.msghdr = .{ - .name = null, - .namelen = 0, - .iov = &iov, - .iovlen = 1, - .control = &control, - .controllen = @intCast(control.len), - .flags = 0, - }; - const n = libc.recvmsg(sock, &msg, linux.MSG.CMSG_CLOEXEC); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.CommSocketFailed; - } - // EOF: the helper exited without sending anything, which is what a - // refused mount looks like from here. - if (n == 0) return error.FusermountRefused; - if (@as(usize, @intCast(msg.controllen)) < cmsgLen(@sizeOf(c_int))) return error.NoDescriptor; - const cmsg: *const libc.cmsghdr = @ptrCast(&control); - if (cmsg.level != libc.SOL.SOCKET or cmsg.type != libc.SCM.RIGHTS) return error.NoDescriptor; - if (@as(usize, @intCast(cmsg.len)) < cmsgLen(@sizeOf(c_int))) return error.NoDescriptor; - var fd: c_int = -1; - @memcpy( - std.mem.asBytes(&fd), - control[cmsgAlign(@sizeOf(libc.cmsghdr))..][0..@sizeOf(c_int)], - ); - if (fd < 0) return error.NoDescriptor; - return fd; - } -} - -/// `mkdir -p` for the mount point, 0700. The leaf is this process's own pid -/// directory and the parent is `.../pardes`, which on a fresh machine does not -/// exist; without the -p the whole feature would switch itself off in silence -/// on exactly the machines that never used it before. Same shape as -/// `nested.zig`'s ensureSocketDir, and 0700 for the same reason: what lives -/// under here takes commands. -fn ensureDir(path: [:0]const u8) void { - var partial: [4096:0]u8 = undefined; - if (path.len >= partial.len) return; - @memcpy(partial[0 .. path.len + 1], path[0 .. path.len + 1]); - for (1..path.len) |i| { - if (path[i] != '/') continue; - partial[i] = 0; - _ = libc.mkdir(partial[0..i :0], 0o700); - partial[i] = '/'; - } - _ = libc.mkdir(path, 0o700); -} - -/// Unmount and remove `/` for every pid that is gone. A pardes killed -/// with SIGKILL runs no defer, so its mount outlives it as an ENOTCONN stump -/// that `ls` reports as a permission error and that nothing else will ever -/// clean up — the snapshot suite alone would leave one per aborted run. -/// Bounded: one readdir of a directory only we write to, one kill(0) each. -/// Mirrors nested.zig's socket sweep deliberately, including the ESRCH rule: -/// 0 means alive, EPERM means alive and someone else's, only ESRCH is a corpse. -pub fn sweepStale(dir: []const u8) void { - if (comptime !supported) return; - var dir_buf: [4096:0]u8 = undefined; - const dir_z = std.fmt.bufPrintSentinel(&dir_buf, "{s}", .{dir}, 0) catch return; - const d = libc.opendir(dir_z) orelse return; - defer _ = libc.closedir(d); - const me = libc.getpid(); - while (libc.readdir(d)) |ent| { - const name = std.mem.sliceTo(&ent.name, 0); - // Strictly digits: parseInt would accept `+7` and `-7`, and this - // function unmounts and removes whatever it answers about. - if (name.len == 0) continue; - for (name) |ch| if (!std.ascii.isDigit(ch)) break; - if (std.mem.indexOfNone(u8, name, "0123456789") != null) continue; - const pid = std.fmt.parseInt(libc.pid_t, name, 10) catch continue; - if (pid == me) continue; - const rc = libc.kill(pid, @enumFromInt(0)); - if (rc == 0 or libc.errno(rc) != .SRCH) continue; - var path_buf: [4096:0]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&path_buf, "{s}/{s}", .{ dir, name }, 0) catch continue; - // Always ours to remove: the name is a pid under a directory only - // pardes writes to, and taking the stump away is the point of a sweep. - unmountPath(path, true); - } -} - -/// Run the helper's unmount and, when the directory is ours, take it away. -/// Best effort in both halves: an already-unmounted point makes fusermount3 -/// complain (which -q swallows) and a non-empty one makes rmdir fail, and -/// neither is worth a diagnostic at exit. -/// -/// `remove_dir` is not a convenience. The *unmount* is always right — the mount -/// is ours whoever made the directory — but the *rmdir* is only right for a -/// point pardes derived itself (`/`, which `ensureDir` created). -/// A `--fs=` the user named is theirs, and removing it is the same -/// overreach `sweepStale` is already refused under an explicit `--fs` for. -fn unmountPath(path: [:0]const u8, remove_dir: bool) void { - if (findFusermount()) |prog| { - var argv: [7:null]?[*:0]const u8 = undefined; - unmountArgv(&argv, prog.ptr, path.ptr); - // A minimal environment: the helper wants nothing of ours, and the one - // variable that WOULD change its behaviour is the comm descriptor it - // must not find here. - const envp = [_:null]?[*:0]const u8{null}; - _ = spawnHelper(prog.ptr, &argv, &envp, -1) catch {}; - } - if (remove_dir) _ = libc.rmdir(path); -} - -// --------------------------------------------------------------------------- -// the park table -// --------------------------------------------------------------------------- - -/// How many kernel requests may be outstanding at once. Every slot is either in -/// flight (handed to the core, not yet answered) or parked (the core said -/// `.again`). In-flight slots are transient — the host answers each request -/// inside the same drain step — so in practice this counts BLOCKED READERS: one -/// slot per process sitting on `event` or `log`. A session with 32 of those has -/// 32 scripts watching it. -/// -/// Overflow is a refusal, not a queue: `take` answers EAGAIN and the descriptor -/// keeps being read. See its comment for why the tempting alternative (stop -/// reading and let the kernel hold the surplus) is a deadlock. -const max_slots = 32; - -/// Bytes of request payload a slot can own. A parked request's `data` cannot go -/// on borrowing the read buffer (the next `next()` overwrites it), so it is -/// copied in at parse time when it fits. This covers every payload that can -/// realistically block: a LOOKUP name is at most 255 bytes and a ctl verb line -/// or an event write-back is a few dozen. A WRITE larger than this is left -/// borrowed and answered EAGAIN if the core ever tries to park it — a write is -/// a transaction in this design and is not supposed to block, and growing this -/// table by 64 KiB a slot to make an impossible case zero-copy is the wrong -/// trade. -const park_data_max = 512; - -const Slot = struct { - used: bool = false, - /// The core answered `.again`; `retry()` will offer it back. - parked: bool = false, - /// Already offered in this retry round. Reset when a round finds nothing, - /// which is what gives every parked request exactly one attempt per frame - /// instead of letting the oldest one starve the rest. - retried: bool = false, - /// `req.data` points into `data` below rather than into the read buffer. - copied: bool = false, - /// Arrival order, so retries are FIFO: the reader that blocked first is - /// offered first. - seq: u64 = 0, - op: Opcode = @enumFromInt(0), - req: acmefs.Req = undefined, - data: [park_data_max]u8 = undefined, -}; - -// --------------------------------------------------------------------------- -// Fs -// --------------------------------------------------------------------------- - -pub const Fs = struct { - pub const Options = struct { - /// Absolute path of the mount point. Absolute because it is handed to a - /// setuid program that resolves it against its own cwd, and because the - /// unmount at exit must name the same place after any chdir. - mount: []const u8, - /// The largest WRITE payload the kernel may send in one request, and - /// therefore the size of the read buffer. 64 KiB matches what a `cp` - /// into `body` will use; smaller only splits the same bytes into more - /// round trips. - max_write: u32 = 64 * 1024, - /// Whether pardes made this directory and may therefore remove it at - /// exit. True for the derived `/`, false for a - /// `--fs=` the user named. See `unmountPath`. - owns_dir: bool = false, - }; - - gpa: std.mem.Allocator, - /// The `/dev/fuse` descriptor. -1 once torn down; every entry point checks - /// it, so a double deinit and a post-unmount drain are both no-ops. - fd: c_int = -1, - /// Set when the connection is gone (ENODEV/ECONNABORTED, or DESTROY). - /// `next()` stops reading; replies are still written because a slot may be - /// mid-flight and the write simply fails. - dead: bool = false, - path: [:0]u8, - /// Mirrors `Options.owns_dir`; gates the rmdir in `deinit`. - owns_dir: bool = false, - /// One request per read(2), so this is sized for the largest request that - /// exists: header + fuse_write_in + max_write. Below FUSE_MIN_READ_BUFFER - /// the kernel refuses to hand over requests at all and answers the client - /// EIO. 8-aligned so the parse can point structs at it. - buf: []align(8) u8, - /// Encoded `fuse_dirent`s. Separate from `buf` because a readdir reply is - /// built while its request is still being read from `buf`. - dirents: [8192]u8 align(8) = undefined, - - uid: u32, - gid: u32, - max_write: u32, - /// The minor the kernel offered, echoed back at INIT. Kept for the record: - /// it is the one number in this file that a future feature would consult. - minor: u32 = 0, - - slots: [max_slots]Slot = @splat(.{}), - seq: u64 = 0, - thread: ?std.Thread = null, - /// main -> poller, an `eventfd(2)`. The main thread adds 1 per completed - /// drain and the poller's blocking read takes the whole counter in one go, - /// which is the "collapse the acknowledgements that piled up while we were - /// not waiting" behaviour a pipe needed three functions and a nonblocking - /// toggle to fake. Not a condition variable, because the poller is blocked - /// in `poll()` most of the time and an fd is the only thing that both - /// `poll()` and a blocking read can wait on — which is what lets shutdown - /// break it out of either state. - /// - /// The counter cannot say "stop": a stop and a drain acknowledgement that - /// race are summed into one indistinguishable number. `stopping` is the - /// sticky half of the signal, and is re-read after every wake; the eventfd - /// only ever means "look again". The store/write and read/load pair is a - /// release/acquire edge over the eventfd's own wait-queue lock, so a poller - /// that observes the increment observes the flag with it. - ctl: c_int = -1, - stopping: std.atomic.Value(bool) = .init(false), - wake_ctx: ?*anyopaque = null, - wake_fn: ?*const fn (?*anyopaque) void = null, - - /// Mount, hand out the descriptor, and complete the INIT handshake. On - /// return the filesystem is live: the kernel will start sending lookups the - /// moment anything touches the directory. - pub fn mount(gpa: std.mem.Allocator, opts: Options) !*Fs { - if (comptime !supported) return error.Unsupported; - if (opts.mount.len == 0 or opts.mount[0] != '/') return error.MountPathNotAbsolute; - - const path = try gpa.dupeZ(u8, opts.mount); - errdefer gpa.free(path); - ensureDir(path); - - const buf_len = @max( - min_read_buffer, - @sizeOf(fuse_in_header) + @sizeOf(fuse_write_in) + @as(usize, opts.max_write), - ); - const buf = try gpa.alignedAlloc(u8, .@"8", buf_len); - errdefer gpa.free(buf); - - const fd = try mountFusermount(gpa, path); - errdefer _ = libc.close(fd); - - const fs = try gpa.create(Fs); - errdefer gpa.destroy(fs); - fs.* = .{ - .gpa = gpa, - .fd = fd, - .path = path, - .buf = buf, - .uid = libc.getuid(), - .gid = libc.getgid(), - .max_write = opts.max_write, - .owns_dir = opts.owns_dir, - }; - // Still blocking here on purpose: INIT is already queued (fusermount3 - // completed mount(2) before it sent us the descriptor), and a - // non-blocking read would make the handshake a spin loop. - try fs.handshake(); - try fs.setNonblocking(); - return fs; - } - - /// socketpair, fork the setuid helper, take the descriptor it sends back. - fn mountFusermount(gpa: std.mem.Allocator, path: [:0]const u8) !c_int { - const prog = findFusermount() orelse return error.FusermountMissing; - var sv: [2]c_int = undefined; - if (libc.socketpair(libc.AF.UNIX, libc.SOCK.STREAM, 0, &sv) != 0) return error.SocketPairFailed; - // Both ends close-on-exec first, then the child's end is un-marked just - // before the fork. The window in between is what any *other* thread's - // fork would inherit, and pane shells are forked with forkpty and - // inherit everything open. - setCloexec(sv[0]); - setCloexec(sv[1]); - errdefer _ = libc.close(sv[0]); - - var opts_buf: [128:0]u8 = undefined; - var commfd_buf: [32:0]u8 = undefined; - const opts = mountOpts(&opts_buf); - const commfd = commfdEnv(&commfd_buf, sv[1]); - - const envp = try buildEnv(gpa, commfd); - defer gpa.free(envp); - var argv: [6:null]?[*:0]const u8 = undefined; - mountArgv(&argv, prog.ptr, opts.ptr, path.ptr); - - clearCloexec(sv[1]); - const code = spawnHelper(prog.ptr, &argv, @ptrCast(envp.ptr), sv[0]) catch |err| { - _ = libc.close(sv[1]); - return err; - }; - // Ours to close either way: the child has its own copy, and while we - // hold one the recvmsg below can never see EOF when the helper dies. - _ = libc.close(sv[1]); - if (code == 127) return error.FusermountMissing; - - const fd = try receiveFd(sv[0]); - if (code != 0) { - _ = libc.close(fd); - return error.FusermountFailed; - } - _ = libc.close(sv[0]); - return fd; - } - - /// Read the kernel's INIT and answer it. Negotiating nothing is the design: - /// every flag is a kernel behaviour we would then have to honour forever, - /// and this filesystem wants none of them — no readdirplus (whose ENOSYS - /// has no fallback and would fail every getdents), no atomic O_TRUNC (so - /// `> file` arrives as a plain SETATTR the core already handles), no POSIX - /// or BSD locks (flags = 0 makes the kernel set `no_lock`/`no_flock` and - /// answer them itself). - fn handshake(fs: *Fs) !void { - const n = readFull(fs.fd, fs.buf); - if (n < @sizeOf(fuse_in_header) + @sizeOf(fuse_init_in)) return error.InitFailed; - const h: *const fuse_in_header = @ptrCast(fs.buf.ptr); - if (@as(Opcode, @enumFromInt(h.opcode)) != .init) return error.InitFailed; - const in: *const fuse_init_in = @ptrCast(@as([*]align(8) u8, @alignCast(fs.buf.ptr + @sizeOf(fuse_in_header)))); - // A major mismatch is fatal and there is nothing to negotiate: the - // kernel aborts the connection, and answering anyway just delays the - // failure to the first syscall through the mount. - if (in.major != kernel_version) return error.InitVersion; - fs.minor = in.minor; - - const out: fuse_init_out = .{ - .major = kernel_version, - // Capped, not echoed: see `kernel_minor`. - .minor = @min(in.minor, kernel_minor), - // Zero, not "some readahead": with FOPEN_DIRECT_IO there is no page - // cache to read ahead into, and a nonzero value here only invites - // the kernel to ask for bytes nobody wanted. - .max_readahead = 0, - .flags = 0, - // Left at zero so the kernel keeps its own defaults; a nonzero - // max_background is the one that silently caps concurrency. - .max_background = 0, - .congestion_threshold = 0, - .max_write = fs.max_write, - // 1 ns. Timestamps on this filesystem are all zero anyway, but a - // time_gran of 0 is not a legal granularity. - .time_gran = 1, - .max_pages = 0, - .map_alignment = 0, - .flags2 = 0, - .max_stack_depth = 0, - // 0 = no server timeout. A timeout would let the kernel abort the - // connection while a legitimately parked `event` read waits. - .request_timeout = 0, - .unused = @splat(0), - }; - fs.answer(h.unique, std.mem.asBytes(&out), &.{}); - return; - } - - fn setNonblocking(fs: *Fs) !void { - const flags = libc.fcntl(fs.fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return error.FcntlFailed; - var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); - o.NONBLOCK = true; - if (libc.fcntl(fs.fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))) < 0) - return error.FcntlFailed; - } - - /// Answer everything still held, abort the connection, unmount, remove the - /// directory. The order is not interchangeable: - /// - /// 1. reply -ENODEV to every slot, so a reader blocked on `event` gets an - /// error rather than being left in uninterruptible sleep. - /// 2. close the descriptor, which aborts the connection — the backstop - /// for anything that raced step 1, since the kernel then fails every - /// pending request itself. - /// 3. only then unmount, because a mount whose server is gone is exactly - /// what `fusermount3 -u -z` is for. - /// 4. remove the directory, but only when pardes made it: the derived - /// `/` is ours, a `--fs=` the user named is not. - pub fn deinit(fs: *Fs) void { - const gpa = fs.gpa; - fs.stopThread(); - if (fs.fd >= 0) { - for (&fs.slots) |*s| { - if (!s.used) continue; - fs.answerErr(s.req.tag, .NODEV); - s.* = .{}; - } - _ = libc.close(fs.fd); - fs.fd = -1; - } - if (comptime supported) unmountPath(fs.path, fs.owns_dir); - gpa.free(fs.path); - gpa.free(fs.buf); - gpa.destroy(fs); - } - - /// This mount as the three functions `fs_service` actually calls. The - /// adapter exists so that file needs no `@import("fuse.zig")` to drive a - /// filesystem: `retry`, `next` and `reply` were always its whole use of an - /// `Fs`, and naming them lets a second transport answer the same calls. - /// - /// The thunks are three lines each because a `*Fs` is not an `*anyopaque` - /// and a vtable cannot hold the typed function directly. That is the entire - /// cost of the seam. - pub fn transport(fs: *Fs) fs_service.Transport { - return .{ .ctx = fs, .vtable = &transport_vtable }; - } - - const transport_vtable: fs_service.Transport.VTable = .{ - .retry = transportRetry, - .next = transportNext, - .reply = transportReply, - }; - - fn transportRetry(ctx: *anyopaque) ?acmefs.Req { - const fs: *Fs = @ptrCast(@alignCast(ctx)); - return fs.retry(); - } - - fn transportNext(ctx: *anyopaque) ?acmefs.Req { - const fs: *Fs = @ptrCast(@alignCast(ctx)); - return fs.next(); - } - - fn transportReply(ctx: *anyopaque, r: *const acmefs.Reply, bytes: []const u8) void { - const fs: *Fs = @ptrCast(@alignCast(ctx)); - fs.reply(r, bytes); - } - - // -- request pump ------------------------------------------------------- - - /// Parse the next pending kernel request, or null when the descriptor is - /// drained. Call in a loop until null; the loop is the batch, and one wake - /// serves all of it. - /// - /// The returned `Req.data` borrows storage owned by this `Fs` and is valid - /// until the next `next()` call. The core copies whatever it keeps — the - /// same rule as `.pty_read`. - /// - /// Requests the core has no business seeing are answered here and the loop - /// continues, so a caller never observes them. - /// - /// Running this to null is also what acknowledges the batch to the poll - /// thread, so a host that stops early keeps the poller waiting and loses - /// wake latency until the next frame. It is not a correctness bug — the - /// remaining requests simply wait in the kernel — but the loop is the - /// contract. - pub fn next(fs: *Fs) ?acmefs.Req { - if (comptime !supported) return null; - // Only the EAGAIN arm below releases the poller, and deliberately so. - // Every other null return from here implies `dead`, which is write-once - // and means reads on the descriptor are failing: posting would send the - // poller back into `poll()` on a still-open fd that reports POLLIN - // forever, wake the host, drain to this same null, and spin two threads - // at 100%. Parking the poller in `consume()` is the right resting state - // for a connection that can never produce work again; `stopThread` - // releases it. `fd < 0` is unreachable here, since only `deinit` sets it - // and it joins the poller first. - if (fs.fd < 0 or fs.dead) return null; - while (true) { - const n = libc.read(fs.fd, fs.buf.ptr, fs.buf.len); - if (n < 0) switch (libc.errno(n)) { - .INTR => continue, - .AGAIN => { - // Drained: release the poller (see `post`). - fs.post(); - return null; - }, - // The request was interrupted or aborted between being queued - // and being read; there is nothing to answer. - .NOENT => continue, - // ENODEV (connection aborted, or we were unmounted from under - // ourselves) and ECONNABORTED are terminal. Anything else here - // is not a thing /dev/fuse does, and treating the unknown as - // terminal beats a loop that reads -1 forever. - else => { - fs.dead = true; - return null; - }, - }; - if (n == 0) { - fs.dead = true; - return null; - } - const total: usize = @intCast(n); - // Cannot happen (the kernel writes whole requests) but the parse - // below indexes on it. - if (total < @sizeOf(fuse_in_header)) continue; - if (fs.dispatch(total)) |req| return req; - } - } - - /// Offer parked requests back, one per call. Call in a loop until null, - /// once per frame, before `next()`: the null both ends the round and resets - /// it, so every parked request gets exactly one attempt per frame and a - /// permanently blocked reader cannot starve the others. - pub fn retry(fs: *Fs) ?acmefs.Req { - if (comptime !supported) return null; - if (fs.fd < 0) return null; - var best: ?usize = null; - for (&fs.slots, 0..) |*s, i| { - if (!s.used or !s.parked or s.retried) continue; - if (best == null or s.seq < fs.slots[best.?].seq) best = i; - } - const i = best orelse { - for (&fs.slots) |*s| s.retried = false; - return null; - }; - fs.slots[i].retried = true; - // In flight again: `reply()` re-parks it if the core still has nothing. - fs.slots[i].parked = false; - return fs.slots[i].req; - } - - /// Write the core's answer, or park the request when it said `.again`. - /// Called from the `.fs_reply` effect; `bytes` is the payload resolved by - /// `pardes.fsPayload` and is borrowed only for the duration of this call. - pub fn reply(fs: *Fs, r: *const acmefs.Reply, bytes: []const u8) void { - if (comptime !supported) return; - const i = fs.findSlot(r.tag) orelse return; // interrupted, or torn down - const s = &fs.slots[i]; - - if (r.status == .again) { - // The one case a park is refused: a payload too large to have been - // copied at parse time still borrows the read buffer, so parking it - // would park a dangling slice. EAGAIN is honest — the writer can - // retry — and by construction unreachable, since the core answers - // writes as transactions and only reads ever block. - if (!s.copied and s.req.data.len != 0) { - fs.answerErr(s.req.tag, .AGAIN); - fs.release(i); - return; - } - s.parked = true; - return; - } - - if (r.status == .err) { - fs.answerErr(s.req.tag, @enumFromInt(if (r.errno == 0) @intFromEnum(libc.E.IO) else r.errno)); - fs.release(i); - return; - } - - switch (s.req.op) { - .lookup => { - const out: fuse_entry_out = .{ - .nodeid = r.attr.node, - // Node ids are never reused in this filesystem (pane - // serials are monotonic), which is exactly the condition - // for a constant generation to be safe. - .generation = 0, - // No caching, at all. Every file here changes under the - // reader's feet, and a cached negative lookup would make - // `new/` (which CREATES a pane) work exactly once. - .entry_valid = 0, - .attr_valid = 0, - .entry_valid_nsec = 0, - .attr_valid_nsec = 0, - .attr = fs.attr(r.attr, r.attr.node), - }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - .getattr, .setattr => { - const out: fuse_attr_out = .{ - .attr_valid = 0, - .attr_valid_nsec = 0, - .dummy = 0, - .attr = fs.attr(r.attr, s.req.node), - }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - .open => { - const out: fuse_open_out = .{ - .fh = r.handle, - // Direct IO for files; nothing for directories, where the - // flag has no meaning and FOPEN_CACHE_DIR (which we do not - // set) is the caching knob. An uncached directory is the - // point: `new/` and the pane list change constantly. - .open_flags = if (s.op == .opendir) 0 else FOPEN_DIRECT_IO, - .backing_id = 0, - }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - .read => { - // Never more than was asked for: a read reply longer than - // `size` is a protocol error the kernel answers with EIO. - const len = @min(bytes.len, s.req.size); - fs.answer(s.req.tag, &.{}, bytes[0..len]); - }, - .readdir => { - const room = @min(@as(usize, s.req.size), fs.dirents.len); - const len = encodeDirents(fs.dirents[0..room], bytes, s.req.off); - fs.answer(s.req.tag, &.{}, fs.dirents[0..len]); - }, - .write => { - // The core's own count, not the request size: `data` refusing a - // partial grapheme is a real short write, and claiming the - // whole request would tell the writer its trailing bytes - // landed when they did not. Clamped anyway, because a count - // larger than what was offered makes the kernel advance a file - // offset past bytes that never existed. - const out: fuse_write_out = .{ - .size = @min(r.written, s.req.size), - .padding = 0, - }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - .release => fs.answer(s.req.tag, &.{}, &.{}), - .statfs => { - // Synthetic numbers, but not arbitrary ones: `namelen` is what - // pathconf(_PC_NAME_MAX) returns and a zero there makes some - // tools refuse to create any name at all, and `bsize` is what - // `stat` reports as the IO block size. - const out: fuse_statfs_out = .{ .st = .{ - .blocks = 0, - .bfree = 0, - .bavail = 0, - .files = 0, - .ffree = 0, - .bsize = 4096, - .namelen = 255, - .frsize = 4096, - .padding = 0, - .spare = @splat(0), - } }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - } - fs.release(i); - } - - /// Translate one request. Null means it was answered here. - fn dispatch(fs: *Fs, total: usize) ?acmefs.Req { - const h: *const fuse_in_header = @ptrCast(fs.buf.ptr); - // Bounded by the header's own length, not just by what the read - // returned. They agree on /dev/fuse, and taking the smaller of the two - // is what keeps a WRITE from claiming payload it did not bring even if - // some future kernel ever pads a request. - const end = @min(total, @max(@as(usize, h.len), @sizeOf(fuse_in_header))); - const body: []align(8) const u8 = @alignCast(fs.buf[@sizeOf(fuse_in_header)..end]); - const op: Opcode = @enumFromInt(h.opcode); - switch (op) { - // Already answered in the handshake. A second INIT cannot happen; - // answering it again is cheaper than a special case that could. - .init => { - fs.answerErr(h.unique, .INVAL); - return null; - }, - // NEVER replied to. The kernel does not track these as pending - // requests, so a reply carries a `unique` it will not recognise — - // -ENOENT at best, and at worst a reply matched against a *live* - // request that happens to share the number. Ignoring the refcount - // itself is fine: this filesystem's node table is bounded by the - // pane count, so nothing grows. - .forget, .batch_forget => return null, - // Answer the ORIGINAL with EINTR and drop it. This is the only - // thing standing between a SIGKILLed reader of `event` and - // permanent uninterruptible sleep: after the fatal signal the - // kernel's last wait is not killable, so the process survives its - // own kill until this reply lands. No reply to the interrupt - // itself — its unique is `original | 1` and the kernel keeps no - // pending entry for it, while answering -ENOSYS would switch - // interrupts off for the whole connection and take the escape - // hatch away. - .interrupt => { - if (body.len >= @sizeOf(fuse_interrupt_in)) { - const in: *const fuse_interrupt_in = @ptrCast(body.ptr); - if (fs.findSlot(in.unique)) |i| { - fs.answerErr(fs.slots[i].req.tag, .INTR); - fs.release(i); - } - } - return null; - }, - // A missing reply here hangs `umount` outright. - .destroy => { - fs.answer(h.unique, &.{}, &.{}); - fs.dead = true; - return null; - }, - // -ENOSYS rather than an empty reply: the kernel sets `no_flush` - // and stops sending them, so this costs one round trip for the - // whole connection instead of one per close(2). Nothing here has - // buffered state for a flush to commit. - .flush => { - fs.answerErr(h.unique, .NOSYS); - return null; - }, - .lookup => { - // The name is the whole body, NUL terminated. An empty name is - // not a lookup of anything. - const name = std.mem.sliceTo(body, 0); - if (name.len == 0) { - fs.answerErr(h.unique, .INVAL); - return null; - } - return fs.take(op, .{ - .tag = h.unique, - .op = .lookup, - .node = h.nodeid, - .data = name, - }); - }, - .getattr => { - const in = fs.arg(fuse_getattr_in, body) orelse return null; - return fs.take(op, .{ - .tag = h.unique, - .op = .getattr, - .node = h.nodeid, - // `fh` is only meaningful with the flag; reading it blind - // hands the core a handle from an unrelated open. - .handle = if (in.getattr_flags & FUSE_GETATTR_FH != 0) @truncate(in.fh) else 0, - }); - }, - .setattr => { - const in = fs.arg(fuse_setattr_in, body) orelse return null; - return fs.take(op, .{ - .tag = h.unique, - .op = .setattr, - .node = h.nodeid, - .handle = @truncate(in.fh), - // The `> file` path, and the only setattr this filesystem - // has an opinion about. A truncate to a nonzero length is - // not expressible in the core's ABI and is reported as no - // truncate at all: the reply still carries the current - // attributes, so ftruncate(fd, n) succeeds and changes - // nothing, which is what every synthetic file here wants. - .truncate = in.valid & FATTR_SIZE != 0 and in.size == 0, - }); - }, - .open, .opendir => { - // The flags are read only to reject a short body: this - // filesystem's permission model is the mode bits each synthetic - // file reports from GETATTR, which the kernel enforces itself, - // so the access mode has nothing left to say here. - if (fs.arg(fuse_open_in, body) == null) return null; - return fs.take(op, .{ - .tag = h.unique, - .op = .open, - .node = h.nodeid, - }); - }, - .read, .readdir => { - const in = fs.arg(fuse_read_in, body) orelse return null; - return fs.take(op, .{ - .tag = h.unique, - .op = if (op == .readdir) .readdir else .read, - .node = h.nodeid, - .handle = @truncate(in.fh), - .off = in.offset, - .size = in.size, - }); - }, - .write => { - const in = fs.arg(fuse_write_in, body) orelse return null; - const payload = body[@sizeOf(fuse_write_in)..]; - // Trust the header's length over the struct's: a `size` larger - // than what arrived would read past the request. - const len = @min(@as(usize, in.size), payload.len); - return fs.take(op, .{ - .tag = h.unique, - .op = .write, - .node = h.nodeid, - .handle = @truncate(in.fh), - .off = in.offset, - .size = @intCast(len), - .data = payload[0..len], - }); - }, - .release, .releasedir => { - const in = fs.arg(fuse_release_in, body) orelse return null; - return fs.take(op, .{ - .tag = h.unique, - .op = .release, - .node = h.nodeid, - .handle = @truncate(in.fh), - }); - }, - .statfs => return fs.take(op, .{ - .tag = h.unique, - .op = .statfs, - .node = h.nodeid, - }), - // Everything else. -ENOSYS is not a shrug: for most of these the - // kernel caches the answer and stops asking (`no_access`, - // `no_getxattr`, `no_statx`, `no_poll`, `no_lseek`, `no_create`), - // so one refusal switches the whole feature off for the connection. - // The mutations (mkdir, unlink, rename, link, symlink) are refused - // because this tree is generated: its shape follows the pane list - // and there is nothing for a user to create or remove in it. - // READDIRPLUS is not in this list by accident — it is unreachable, - // because INIT never sets FUSE_DO_READDIRPLUS, and it has to stay - // that way: its -ENOSYS has NO fallback in the kernel and would - // fail every getdents through the mount. - else => { - fs.answerErr(h.unique, .NOSYS); - return null; - }, - } - } - - /// Point a request struct at the read buffer. Null (and an EINVAL reply) - /// when the kernel sent less than the struct, which cannot happen but would - /// otherwise be a read past the buffer. - fn arg(fs: *Fs, comptime T: type, body: []align(8) const u8) ?*const T { - if (body.len < @sizeOf(T)) { - const h: *const fuse_in_header = @ptrCast(fs.buf.ptr); - fs.answerErr(h.unique, .INVAL); - return null; - } - return @ptrCast(body.ptr); - } - - /// Move a parsed request into a slot and hand it to the caller. Small - /// payloads are copied in here so that a later park has stable bytes; a - /// large one stays borrowed (see `park_data_max`). - /// - /// Null (and an EAGAIN reply) when the table is full. That is the whole - /// reason `next` reads unconditionally instead of gating on a free slot: - /// gating looks like polite backpressure and is a deadlock. With 32 readers - /// blocked on `event`, refusing to read the descriptor means the INTERRUPT - /// that would free a slot is never read either, so a SIGKILLed reader stays - /// in uninterruptible sleep forever and every unrelated `ls` of the mount - /// hangs behind it. Reading and answering EAGAIN keeps FORGET, INTERRUPT, - /// DESTROY and the ENOSYS family flowing — none of which need a slot — and - /// turns "too many blocked readers" into one failed syscall the caller can - /// see and retry. - fn take(fs: *Fs, op: Opcode, req: acmefs.Req) ?acmefs.Req { - const i = fs.freeSlot() orelse { - fs.answerErr(req.tag, .AGAIN); - return null; - }; - const s = &fs.slots[i]; - s.* = .{ - .used = true, - .seq = fs.seq, - .op = op, - .req = req, - }; - fs.seq += 1; - if (req.data.len != 0 and req.data.len <= park_data_max) { - @memcpy(s.data[0..req.data.len], req.data); - s.copied = true; - s.req.data = s.data[0..req.data.len]; - } - return s.req; - } - - fn freeSlot(fs: *Fs) ?usize { - for (&fs.slots, 0..) |*s, i| if (!s.used) return i; - return null; - } - - fn findSlot(fs: *Fs, tag: u64) ?usize { - for (&fs.slots, 0..) |*s, i| if (s.used and s.req.tag == tag) return i; - return null; - } - - fn release(fs: *Fs, i: usize) void { - fs.slots[i] = .{}; - } - - /// `Reply.Attr` -> `fuse_attr`. `node` is the fallback inode for replies - /// that do not name one (a getattr answers about a node the request already - /// identified); a zero `st_ino` is a value no filesystem is allowed to - /// report and some tools treat it as a deleted entry. - fn attr(fs: *const Fs, a: acmefs.Reply.Attr, node: u64) fuse_attr { - const ino = if (a.node != 0) a.node else node; - return .{ - .ino = ino, - .size = a.size, - // 512-byte units, as `stat` wants them. Rounded up so a nonempty - // file never reports zero blocks, which `du` reads as a hole. - .blocks = (a.size + 511) / 512, - .atime = 0, - .mtime = 0, - .ctime = 0, - .atimensec = 0, - .mtimensec = 0, - .ctimensec = 0, - .mode = (if (a.dir) S_IFDIR else S_IFREG) | @as(u32, a.mode), - // 2 for a directory (itself and `.`) is what every tool expects; - // `find` in particular uses it to decide whether to recurse. - .nlink = if (a.dir) 2 else 1, - // The mounting user owns everything: without `allow_other` nobody - // else can reach the mount at all, and reporting some other owner - // would only make `ls -l` lie. - .uid = fs.uid, - .gid = fs.gid, - .rdev = 0, - .blksize = 4096, - .flags = 0, - }; - } - - // -- reply framing ------------------------------------------------------ - - /// One `writev` per reply: header, then the op's fixed out struct, then the - /// payload. Split into iovecs rather than assembled in a buffer so that a - /// megabyte read out of a pane's text is written straight from the core's - /// bytes — the whole point of `Reply.Payload.region`. - fn answer(fs: *Fs, unique: u64, fixed: []const u8, payload: []const u8) void { - var header: fuse_out_header = .{ - .len = @intCast(@sizeOf(fuse_out_header) + fixed.len + payload.len), - .@"error" = 0, - .unique = unique, - }; - var iov: [3]std.posix.iovec_const = undefined; - var n: usize = 1; - iov[0] = .{ .base = std.mem.asBytes(&header).ptr, .len = @sizeOf(fuse_out_header) }; - if (fixed.len != 0) { - iov[n] = .{ .base = fixed.ptr, .len = fixed.len }; - n += 1; - } - if (payload.len != 0) { - iov[n] = .{ .base = payload.ptr, .len = payload.len }; - n += 1; - } - fs.writeReply(iov[0..n], header.len); - } - - /// An error reply is header-only: the kernel checks `nbytes == - /// sizeof(oh)` when `error != 0` and answers -EINVAL otherwise, which - /// leaves the original request pending forever. - fn answerErr(fs: *Fs, unique: u64, e: libc.E) void { - var header: fuse_out_header = .{ - .len = @sizeOf(fuse_out_header), - .@"error" = -@as(i32, @intFromEnum(e)), - .unique = unique, - }; - const iov = [1]std.posix.iovec_const{ - .{ .base = std.mem.asBytes(&header).ptr, .len = @sizeOf(fuse_out_header) }, - }; - fs.writeReply(&iov, header.len); - } - - fn writeReply(fs: *Fs, iov: []const std.posix.iovec_const, expect: u32) void { - if (fs.fd < 0) return; - while (true) { - const n = libc.writev(fs.fd, iov.ptr, @intCast(iov.len)); - if (n < 0) switch (libc.errno(n)) { - .INTR => continue, - // /dev/fuse writes never block, so this is not the usual - // EAGAIN; retrying is the only thing that can make progress and - // it cannot loop forever because the kernel is not waiting on - // us. - .AGAIN => continue, - // The request is no longer pending: it was interrupted or the - // connection was aborted between the read and this write. - // Dropping it is correct — there is nothing left to answer. - .NOENT => return, - else => { - fs.dead = true; - return; - }, - }; - // A short write to /dev/fuse is not a thing (the kernel takes the - // whole reply or none of it), so this can only mean the reply was - // malformed and the request is still pending. Nothing useful is - // left to do about it here, and pretending otherwise would hide it. - std.debug.assert(@as(u32, @intCast(n)) == expect); - return; - } - } - - // -- poll thread -------------------------------------------------------- - - /// Start the one background thread: it waits for POLLIN and calls `wake`. - /// It never touches the descriptor's data, never sees a request and never - /// calls the core; the host's `wake` is expected to do nothing but post an - /// event on the loop, exactly like the inotify thread's. - /// - /// Optional by design. A host with no threads simply does not call this and - /// drains from its frame poll instead; it loses wake latency and nothing - /// else, which is what makes the no-parallelism backend work unchanged. - pub fn wakeThread(fs: *Fs, ctx: ?*anyopaque, wake: *const fn (?*anyopaque) void) !void { - if (comptime !supported) return; - if (fs.thread != null) return; - // Blocking on purpose: `consume` is a blocking read on this descriptor. - // The write side cannot block anyway — an eventfd write only waits for - // a counter one short of `maxInt(u64)` to be drained, which is not - // reachable at one increment per drain. - const efd = libc.eventfd(0, linux.EFD.CLOEXEC); - if (efd < 0) return error.EventFdFailed; - fs.ctl = efd; - fs.wake_ctx = ctx; - fs.wake_fn = wake; - fs.thread = std.Thread.spawn(.{}, pollLoop, .{fs}) catch |err| { - _ = libc.close(efd); - fs.ctl = -1; - return err; - }; - } - - fn stopThread(fs: *Fs) void { - if (comptime !supported) return; - // `ctl` and `thread` are set and cleared together, so there is no - // descriptor to close on the path where no poller was ever started. - const t = fs.thread orelse return; - // The flag before the wake, never after: a poller that reads the - // increment must not then find `stopping` false and go back to sleep on - // a counter nobody will raise again. With this order every state the - // poller can be in ends in an exit — the loop condition, the `poll()` - // (the eventfd becomes readable) and the blocking wait for a drain - // acknowledgement (the read returns) all re-read the flag. - fs.stopping.store(true, .release); - fs.post(); - t.join(); - fs.thread = null; - _ = libc.close(fs.ctl); - fs.ctl = -1; - } - - /// Raise the counter by one: "the descriptor has been drained, you may poll - /// again", or during teardown "look at `stopping`". Without the drain half - /// of that handshake the poller re-polls a level-triggered descriptor that - /// is still readable and spins a core until the main thread catches up; - /// with it, one wake serves one batch. - fn post(fs: *Fs) void { - if (fs.ctl < 0) return; - const one: u64 = 1; - _ = libc.write(fs.ctl, std.mem.asBytes(&one), @sizeOf(u64)); - } - - fn pollLoop(fs: *Fs) void { - if (comptime !supported) return; - while (!fs.stopping.load(.acquire)) { - var fds = [2]libc.pollfd{ - .{ .fd = fs.fd, .events = libc.POLL.IN, .revents = 0 }, - .{ .fd = fs.ctl, .events = libc.POLL.IN, .revents = 0 }, - }; - const rc = libc.poll(&fds, 2, -1); - if (rc < 0) { - if (libc.errno(rc) == .INTR) continue; - return; - } - // Shutdown, or an acknowledgement for a drain that happened without - // us. Take the whole counter and re-poll either way: a leftover - // count would make the wait below return instantly and turn the - // next wake into a spin. - if (fds[1].revents != 0 and fs.consume()) return; - if (fds[0].revents & (libc.POLL.ERR | libc.POLL.HUP | libc.POLL.NVAL) != 0) return; - if (fds[0].revents & libc.POLL.IN == 0) continue; - - (fs.wake_fn.?)(fs.wake_ctx); - // Wait for the main thread to finish the batch. This is the whole - // anti-spin mechanism; see `post`. - if (fs.consume()) return; - } - } - - /// Block until the counter is nonzero, then take all of it. True when the - /// poller must exit, which is `stopping` and nothing else: the count itself - /// carries no meaning beyond "look again". - /// - /// The read blocks, including on the branch that reached here from a - /// `poll()` that only *said* the descriptor was readable. That is safe - /// because `stopThread` closes `ctl` after `join()` and never before: an - /// eventfd raises neither POLLERR nor POLLHUP, so the one revents value - /// that would be readable-but-not-readable is POLLNVAL, and a closed - /// descriptor is the only thing that produces it. - fn consume(fs: *Fs) bool { - var v: u64 = undefined; - while (true) { - const n = libc.read(fs.ctl, std.mem.asBytes(&v), @sizeOf(u64)); - // A short read and an EOF do not exist on an eventfd: the read - // returns 8 or -1. So anything but EINTR means this descriptor is - // not the one we opened, and exiting beats spinning on it. - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return true; - } - return fs.stopping.load(.acquire); - } - } -}; - -// --------------------------------------------------------------------------- -// descriptor flags -// --------------------------------------------------------------------------- - -fn setCloexec(fd: c_int) void { - const FD_CLOEXEC: c_int = 1; - _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); -} - -/// The child of the mount fork must KEEP this descriptor across execve — it is -/// the whole channel the setuid helper answers on. -fn clearCloexec(fd: c_int) void { - _ = libc.fcntl(fd, libc.F.SETFD, @as(c_int, 0)); -} - -fn setNonblock(fd: c_int) void { - const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return; - var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); - o.NONBLOCK = true; - _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); -} - -/// One blocking read, EINTR-safe. Used only for the INIT handshake, where the -/// descriptor is still blocking; every later read goes through `next()`. -fn readFull(fd: c_int, buf: []u8) usize { - while (true) { - const n = libc.read(fd, buf.ptr, buf.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return 0; - } - return @intCast(n); - } -} - -// --------------------------------------------------------------------------- -// tests -// --------------------------------------------------------------------------- -// -// No test here mounts anything: a real mount needs the setuid helper, a -// writable runtime directory and a kernel that will let go of it again, which -// is a snapshot test's job and not a unit test's. What is testable without a -// mount is everything that has ever actually been wrong in a FUSE server — -// struct sizes, dirent alignment, cookies, the INIT reply, the park table, and -// the argv handed to a setuid program. Those are what follows, driven through a -// socketpair standing in for /dev/fuse. - -const testing = std.testing; - -/// Build an `Fs` with no mount, wired to `fd`. The socketpair replaces -/// /dev/fuse for the codec tests: the kernel's side of the conversation is -/// written by hand and the reply is read back and compared byte for byte. -fn testFs(gpa: std.mem.Allocator, fd: c_int) !*Fs { - const fs = try gpa.create(Fs); - fs.* = .{ - .gpa = gpa, - .fd = fd, - .path = try gpa.dupeZ(u8, "/nonexistent"), - .buf = try gpa.alignedAlloc(u8, .@"8", min_read_buffer), - .uid = 1000, - .gid = 1000, - .max_write = 4096, - }; - return fs; -} - -fn testFsFree(fs: *Fs) void { - const gpa = fs.gpa; - gpa.free(fs.path); - gpa.free(fs.buf); - gpa.destroy(fs); -} - -/// Frame a request the way the kernel does and push it at the server. -fn pushRequest(fd: c_int, unique: u64, op: Opcode, nodeid: u64, body: []const u8) !void { - var buf: [4096]u8 align(8) = undefined; - const h: fuse_in_header = .{ - .len = @intCast(@sizeOf(fuse_in_header) + body.len), - .opcode = @intFromEnum(op), - .unique = unique, - .nodeid = nodeid, - .uid = 1000, - .gid = 1000, - .pid = 1, - .total_extlen = 0, - .padding = 0, - }; - @memcpy(buf[0..@sizeOf(fuse_in_header)], std.mem.asBytes(&h)); - @memcpy(buf[@sizeOf(fuse_in_header)..][0..body.len], body); - const total = @sizeOf(fuse_in_header) + body.len; - try testing.expectEqual(@as(isize, @intCast(total)), libc.write(fd, &buf, total)); -} - -/// Read one reply back off the socketpair. -fn readReply(fd: c_int, buf: []u8) ![]u8 { - const n = libc.read(fd, buf.ptr, buf.len); - try testing.expect(n >= @sizeOf(fuse_out_header)); - return buf[0..@intCast(n)]; -} - -fn outHeader(bytes: []const u8) fuse_out_header { - var h: fuse_out_header = undefined; - @memcpy(std.mem.asBytes(&h), bytes[0..@sizeOf(fuse_out_header)]); - return h; -} - -/// A socketpair standing in for /dev/fuse. SEQPACKET, not STREAM, and that is -/// the whole point: the kernel's character device hands over exactly one -/// request per read(2) and takes exactly one reply per write(2), and a stream -/// socket would coalesce three requests into one read and let a codec that -/// ignores `fuse_in_header.len` pass anyway. -/// -/// Both ends non-blocking. The server's end so `next()` meets EAGAIN where it -/// would on the real descriptor; the kernel's end so a test can assert that -/// NOTHING was written — which is what "a held request has no reply" and "a -/// FORGET is never answered" mean, and a blocking read would simply hang there -/// instead of failing. -fn testPair() ![2]c_int { - var sv: [2]c_int = undefined; - if (libc.socketpair(libc.AF.UNIX, libc.SOCK.SEQPACKET, 0, &sv) != 0) return error.SocketPairFailed; - setNonblock(sv[0]); - setNonblock(sv[1]); - return sv; -} - -test "lookup round trip: parse borrows the name, reply frames an entry" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - try pushRequest(sv[1], 100, .lookup, 1, "index\x00"); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.lookup, req.op); - try testing.expectEqual(@as(u64, 100), req.tag); - try testing.expectEqual(@as(u64, 1), req.node); - try testing.expectEqualStrings("index", req.data); - // Drained, and nothing else was invented. - try testing.expect(fs.next() == null); - - fs.reply(&.{ - .tag = 100, - .attr = .{ .node = 7, .size = 42, .mode = 0o444 }, - }, &.{}); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - const h = outHeader(got); - try testing.expectEqual(@as(u32, @sizeOf(fuse_out_header) + @sizeOf(fuse_entry_out)), h.len); - try testing.expectEqual(@as(u32, @intCast(got.len)), h.len); - try testing.expectEqual(@as(i32, 0), h.@"error"); - try testing.expectEqual(@as(u64, 100), h.unique); - - var entry: fuse_entry_out = undefined; - @memcpy(std.mem.asBytes(&entry), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_entry_out)]); - try testing.expectEqual(@as(u64, 7), entry.nodeid); - // Caching off in both directions, or `new/` creates a pane once and - // then serves the cached negative lookup forever. - try testing.expectEqual(@as(u64, 0), entry.entry_valid); - try testing.expectEqual(@as(u64, 0), entry.attr_valid); - try testing.expectEqual(@as(u64, 7), entry.attr.ino); - try testing.expectEqual(@as(u64, 42), entry.attr.size); - try testing.expectEqual(S_IFREG | @as(u32, 0o444), entry.attr.mode); - try testing.expectEqual(@as(u32, 1), entry.attr.nlink); - try testing.expectEqual(@as(u32, 1000), entry.attr.uid); - // The slot went back. - try testing.expect(fs.freeSlot() != null); - try testing.expectEqual(@as(?usize, null), fs.findSlot(100)); -} - -test "read reply is capped at the requested size and written as one frame" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 3, - .offset = 8, - .size = 4, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - try pushRequest(sv[1], 200, .read, 5, std.mem.asBytes(&in)); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.read, req.op); - try testing.expectEqual(@as(u32, 3), req.handle); - try testing.expectEqual(@as(u64, 8), req.off); - try testing.expectEqual(@as(u32, 4), req.size); - - // The core offers more than was asked for; a reply longer than `size` is - // answered EIO by the kernel, so it has to be clamped here. - fs.reply(&.{ .tag = 200, .payload = .{ .staged = 9 } }, "abcdefghi"); - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header) + 4), got.len); - try testing.expectEqual(@as(u32, @intCast(got.len)), outHeader(got).len); - try testing.expectEqualStrings("abcd", got[@sizeOf(fuse_out_header)..]); -} - -test "an error reply is header only" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - try pushRequest(sv[1], 300, .lookup, 1, "nope\x00"); - _ = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = 300, .status = .err, .errno = @intFromEnum(libc.E.NOENT) }, &.{}); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - // len MUST be exactly the header when error is set; anything else makes the - // kernel answer -EINVAL and leaves the request pending forever. - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header)), got.len); - const h = outHeader(got); - try testing.expectEqual(@as(u32, @sizeOf(fuse_out_header)), h.len); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.NOENT)), h.@"error"); -} - -test "opcodes the core never sees are answered here" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - var buf: [512]u8 = undefined; - - // FORGET and BATCH_FORGET get NO reply, ever: the kernel keeps no pending - // entry for them, so a reply would carry a unique it does not recognise. - const forget: fuse_forget_in = .{ .nlookup = 1 }; - try pushRequest(sv[1], 400, .forget, 7, std.mem.asBytes(&forget)); - const batch: fuse_batch_forget_in = .{ .count = 0, .dummy = 0 }; - try pushRequest(sv[1], 402, .batch_forget, 0, std.mem.asBytes(&batch)); - // ...and a mutation is refused, which is the first thing that produces a - // reply, proving nothing was written for the two above. - try pushRequest(sv[1], 404, .mkdir, 1, "x\x00"); - try testing.expect(fs.next() == null); - - const got = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header)), got.len); - const h = outHeader(got); - try testing.expectEqual(@as(u64, 404), h.unique); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.NOSYS)), h.@"error"); - - // DESTROY must be answered or umount hangs. - try pushRequest(sv[1], 406, .destroy, 0, &.{}); - try testing.expect(fs.next() == null); - const destroyed = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header)), destroyed.len); - try testing.expectEqual(@as(i32, 0), outHeader(destroyed).@"error"); - try testing.expectEqual(@as(u64, 406), outHeader(destroyed).unique); - - // FLUSH is refused so the kernel stops sending one per close(2). - fs.dead = false; - const flush: fuse_flush_in = .{ .fh = 1, .unused = 0, .padding = 0, .lock_owner = 0 }; - try pushRequest(sv[1], 408, .flush, 1, std.mem.asBytes(&flush)); - try testing.expect(fs.next() == null); - const flushed = try readReply(sv[1], &buf); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.NOSYS)), outHeader(flushed).@"error"); -} - -test "setattr size=0 is the truncate the kernel sends instead of O_TRUNC" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - var in: fuse_setattr_in = std.mem.zeroes(fuse_setattr_in); - in.valid = FATTR_SIZE; - in.size = 0; - try pushRequest(sv[1], 500, .setattr, 9, std.mem.asBytes(&in)); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.setattr, req.op); - try testing.expect(req.truncate); - - // A nonzero size is not a truncate this ABI can express, and must not be - // reported as one: the core would clear a pane on `ftruncate(fd, 10)`. - in.size = 10; - try pushRequest(sv[1], 502, .setattr, 9, std.mem.asBytes(&in)); - fs.reply(&.{ .tag = 500, .attr = .{ .node = 9 } }, &.{}); - const req2 = fs.next() orelse return error.NoRequest; - try testing.expect(!req2.truncate); - - fs.reply(&.{ .tag = 502, .attr = .{ .node = 9, .size = 3, .dir = true } }, &.{}); - var buf: [512]u8 = undefined; - _ = try readReply(sv[1], &buf); // the first reply - const got = try readReply(sv[1], &buf); - var out: fuse_attr_out = undefined; - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_attr_out)]); - try testing.expectEqual(S_IFDIR | @as(u32, 0o600), out.attr.mode); - try testing.expectEqual(@as(u32, 2), out.attr.nlink); - try testing.expectEqual(@as(u64, 0), out.attr_valid); -} - -test "open reports direct io for files and nothing for directories" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - var buf: [512]u8 = undefined; - - // O_WRONLY - const wr: fuse_open_in = .{ .flags = 1, .open_flags = 0 }; - try pushRequest(sv[1], 600, .open, 4, std.mem.asBytes(&wr)); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.open, req.op); - fs.reply(&.{ .tag = 600, .handle = 11 }, &.{}); - var got = try readReply(sv[1], &buf); - var open_out: fuse_open_out = undefined; - @memcpy(std.mem.asBytes(&open_out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_open_out)]); - try testing.expectEqual(@as(u64, 11), open_out.fh); - try testing.expectEqual(FOPEN_DIRECT_IO, open_out.open_flags); - - // O_RDONLY on a directory - const rd: fuse_open_in = .{ .flags = 0, .open_flags = 0 }; - try pushRequest(sv[1], 602, .opendir, 1, std.mem.asBytes(&rd)); - const dir_req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.open, dir_req.op); - fs.reply(&.{ .tag = 602, .handle = 12 }, &.{}); - got = try readReply(sv[1], &buf); - @memcpy(std.mem.asBytes(&open_out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_open_out)]); - // No FOPEN_CACHE_DIR either: the pane list changes between two `ls`. - try testing.expectEqual(@as(u32, 0), open_out.open_flags); -} - -test "write borrows the payload and reports the core's own count" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - var body: [@sizeOf(fuse_write_in) + 5]u8 = undefined; - const in: fuse_write_in = .{ - .fh = 2, - .offset = 0, - .size = 5, - .write_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - @memcpy(body[0..@sizeOf(fuse_write_in)], std.mem.asBytes(&in)); - @memcpy(body[@sizeOf(fuse_write_in)..], "hello"); - try pushRequest(sv[1], 700, .write, 6, &body); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.write, req.op); - try testing.expectEqualStrings("hello", req.data); - - fs.reply(&.{ .tag = 700, .written = 5 }, &.{}); - var buf: [512]u8 = undefined; - var got = try readReply(sv[1], &buf); - var out: fuse_write_out = undefined; - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_write_out)]); - try testing.expectEqual(@as(u32, 5), out.size); - - // A short count is a real answer — `data` refusing a partial grapheme — - // and must reach write(2) as a short write rather than as a full one. - try pushRequest(sv[1], 704, .write, 6, &body); - _ = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = 704, .written = 3 }, &.{}); - got = try readReply(sv[1], &buf); - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_write_out)]); - try testing.expectEqual(@as(u32, 3), out.size); - - // A count larger than what was offered would advance the file offset past - // bytes that never existed. - try pushRequest(sv[1], 706, .write, 6, &body); - _ = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = 706, .written = 99 }, &.{}); - got = try readReply(sv[1], &buf); - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_write_out)]); - try testing.expectEqual(@as(u32, 5), out.size); -} - -test "a write whose size lies about the payload is clamped to what arrived" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - var body: [@sizeOf(fuse_write_in) + 2]u8 = undefined; - var in: fuse_write_in = std.mem.zeroes(fuse_write_in); - in.size = 4096; // more than the two bytes that follow - @memcpy(body[0..@sizeOf(fuse_write_in)], std.mem.asBytes(&in)); - @memcpy(body[@sizeOf(fuse_write_in)..], "hi"); - try pushRequest(sv[1], 702, .write, 6, &body); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqualStrings("hi", req.data); - try testing.expectEqual(@as(u32, 2), req.size); -} - -test "dirent encoding: 8-byte records, cookies from the request offset" { - var staged: [64]u8 = undefined; - var w: usize = 0; - // node=2 kind=file name="addr" - std.mem.writeInt(u64, staged[w..][0..8], 2, .little); - staged[w + 8] = 0; - staged[w + 9] = 4; - @memcpy(staged[w + 10 ..][0..4], "addr"); - w += 14; - // node=3 kind=dir name="new" - std.mem.writeInt(u64, staged[w..][0..8], 3, .little); - staged[w + 8] = 1; - staged[w + 9] = 3; - @memcpy(staged[w + 10 ..][0..3], "new"); - w += 13; - - var out: [128]u8 = undefined; - const n = encodeDirents(&out, staged[0..w], 5); - // 24 + 4 -> 32; 24 + 3 -> 32. A record that is not a multiple of 8 - // desynchronises the kernel's parse of everything after it. - try testing.expectEqual(@as(usize, 64), n); - try testing.expectEqual(@as(u64, 0), n % rec_align); - - try testing.expectEqual(@as(u64, 2), std.mem.readInt(u64, out[0..8], .little)); - // Cookies continue from the request's offset: the kernel sends the last - // `off` it saw as the next request's offset, so restarting at 1 would loop - // the directory forever. - try testing.expectEqual(@as(u64, 6), std.mem.readInt(u64, out[8..16], .little)); - try testing.expectEqual(@as(u32, 4), std.mem.readInt(u32, out[16..20], .little)); - try testing.expectEqual(DT_REG, std.mem.readInt(u32, out[20..24], .little)); - try testing.expectEqualStrings("addr", out[24..28]); - // Padding zeroed, so the wire is deterministic. - try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, out[28..32]); - - try testing.expectEqual(@as(u64, 3), std.mem.readInt(u64, out[32..40], .little)); - try testing.expectEqual(@as(u64, 7), std.mem.readInt(u64, out[40..48], .little)); - try testing.expectEqual(DT_DIR, std.mem.readInt(u32, out[52..56], .little)); - try testing.expectEqualStrings("new", out[56..59]); -} - -test "dirent encoding stops cleanly when the reply buffer or the staging runs out" { - var staged: [64]u8 = undefined; - std.mem.writeInt(u64, staged[0..8], 9, .little); - staged[8] = 0; - staged[9] = 4; - @memcpy(staged[10..14], "body"); - std.mem.writeInt(u64, staged[14..22], 10, .little); - staged[22] = 0; - staged[23] = 4; - @memcpy(staged[24..28], "ctl!"); - - // Room for one record only: the second comes back at the higher cookie. - var out: [40]u8 = undefined; - try testing.expectEqual(@as(usize, 32), encodeDirents(&out, staged[0..28], 0)); - - // A truncated staging record is dropped rather than guessed at. - try testing.expectEqual(@as(usize, 32), encodeDirents(&out, staged[0..26], 0)); - // Zero staged bytes is EOF, not an error. - try testing.expectEqual(@as(usize, 0), encodeDirents(&out, &.{}, 4)); - // A zero name length would make the kernel parse the padding as an entry. - var bad: [10]u8 = @splat(0); - try testing.expectEqual(@as(usize, 0), encodeDirents(&out, &bad, 0)); -} - -test "readdir reply carries encoded dirents built from the staged names" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 1, - .offset = 0, - .size = 4096, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - try pushRequest(sv[1], 800, .readdir, 1, std.mem.asBytes(&in)); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.readdir, req.op); - - var staged: [16]u8 = undefined; - std.mem.writeInt(u64, staged[0..8], 4, .little); - staged[8] = 1; - staged[9] = 5; - @memcpy(staged[10..15], "panes"); - fs.reply(&.{ .tag = 800, .payload = .{ .staged = 15 } }, staged[0..15]); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header) + 32), got.len); - const rec = got[@sizeOf(fuse_out_header)..]; - try testing.expectEqual(@as(u64, 4), std.mem.readInt(u64, rec[0..8], .little)); - try testing.expectEqual(@as(u64, 1), std.mem.readInt(u64, rec[8..16], .little)); - try testing.expectEqual(DT_DIR, std.mem.readInt(u32, rec[20..24], .little)); - try testing.expectEqualStrings("panes", rec[24..29]); -} - -test "INIT reply negotiates nothing and caps the minor at ours" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - fs.max_write = 64 * 1024; - - const in: fuse_init_in = .{ - .major = 7, - .minor = 45, - .max_readahead = 131072, - // Everything the kernel is willing to do. The point of the test is that - // none of it comes back. - .flags = 0xffff_ffff, - .flags2 = 0xffff_ffff, - .unused = @splat(0), - }; - try pushRequest(sv[1], 1, .init, 0, std.mem.asBytes(&in)); - try fs.handshake(); - try testing.expectEqual(@as(u32, 45), fs.minor); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header) + @sizeOf(fuse_init_out)), got.len); - try testing.expectEqual(@as(u64, 1), outHeader(got).unique); - var out: fuse_init_out = undefined; - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_init_out)]); - try testing.expectEqual(@as(u32, 7), out.major); - try testing.expectEqual(@as(u32, 45), out.minor); - // The one assertion this test exists for. Every bit here is a kernel - // behaviour we would owe forever: readdirplus whose ENOSYS has no fallback, - // atomic O_TRUNC that would bypass the SETATTR the core handles, locks. - try testing.expectEqual(@as(u32, 0), out.flags); - try testing.expectEqual(@as(u32, 0), out.flags2); - try testing.expectEqual(@as(u32, 0), out.max_readahead); - try testing.expectEqual(@as(u32, 64 * 1024), out.max_write); - // A time granularity of zero is not a legal value. - try testing.expectEqual(@as(u32, 1), out.time_gran); - try testing.expectEqual(@as(u16, 0), out.request_timeout); - - // A newer kernel's minor is CAPPED, not echoed. `fc->minor` is our own - // declared level and it is what sizes the replies the kernel reads back - // from us, so claiming 7.99 on these structs promises fields they do not - // have. This assertion is the one the old `@min(in.minor, in.minor)` could - // not make. - const newer: fuse_init_in = .{ - .major = 7, - .minor = kernel_minor + 54, - .max_readahead = 0, - .flags = 0, - .flags2 = 0, - .unused = @splat(0), - }; - try pushRequest(sv[1], 2, .init, 0, std.mem.asBytes(&newer)); - try fs.handshake(); - const capped = try readReply(sv[1], &buf); - @memcpy(std.mem.asBytes(&out), capped[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_init_out)]); - try testing.expectEqual(kernel_minor, out.minor); - - // A foreign major is fatal, and answering it anyway only moves the failure - // to the first syscall through the mount. - const bad: fuse_init_in = .{ - .major = 8, - .minor = 0, - .max_readahead = 0, - .flags = 0, - .flags2 = 0, - .unused = @splat(0), - }; - try pushRequest(sv[1], 3, .init, 0, std.mem.asBytes(&bad)); - try testing.expectError(error.InitVersion, fs.handshake()); -} - -test "park table: again holds the request, retry offers it back once per round" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 1, - .offset = 0, - .size = 64, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - // Two blocked readers of `event`, in arrival order. - try pushRequest(sv[1], 900, .read, 20, std.mem.asBytes(&in)); - try pushRequest(sv[1], 902, .read, 21, std.mem.asBytes(&in)); - const a = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = a.tag, .status = .again }, &.{}); - const b = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = b.tag, .status = .again }, &.{}); - try testing.expect(fs.next() == null); - // Nothing was written: a held request has no reply, which is the only way - // FUSE expresses blocking. - var buf: [512]u8 = undefined; - try testing.expect(libc.read(sv[1], &buf, buf.len) < 0); - - // One round offers each parked request exactly once, oldest first, and then - // ends. Without the per-round flag the oldest would be offered forever and - // the second reader would never be looked at again. - const r1 = fs.retry() orelse return error.NoRetry; - try testing.expectEqual(@as(u64, 900), r1.tag); - fs.reply(&.{ .tag = r1.tag, .status = .again }, &.{}); - const r2 = fs.retry() orelse return error.NoRetry; - try testing.expectEqual(@as(u64, 902), r2.tag); - fs.reply(&.{ .tag = r2.tag, .status = .again }, &.{}); - try testing.expect(fs.retry() == null); - - // ...and the next round starts over. - const r3 = fs.retry() orelse return error.NoRetry; - try testing.expectEqual(@as(u64, 900), r3.tag); - fs.reply(&.{ .tag = r3.tag, .payload = .{ .staged = 3 } }, "ev\n"); - const got = try readReply(sv[1], &buf); - try testing.expectEqualStrings("ev\n", got[@sizeOf(fuse_out_header)..]); - // The answered one is gone; the other is still held. - try testing.expectEqual(@as(?usize, null), fs.findSlot(900)); - try testing.expect(fs.findSlot(902) != null); -} - -test "park table: interrupt answers the original with EINTR and drops it" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 1, - .offset = 0, - .size = 64, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - try pushRequest(sv[1], 1000, .read, 20, std.mem.asBytes(&in)); - try pushRequest(sv[1], 1002, .read, 21, std.mem.asBytes(&in)); - const a = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = a.tag, .status = .again }, &.{}); - const b = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = b.tag, .status = .again }, &.{}); - - // The kernel's interrupt names the ORIGINAL unique in its body; its own - // unique is `original | 1`, which is why it must not be echoed. - const intr: fuse_interrupt_in = .{ .unique = 1002 }; - try pushRequest(sv[1], 1002 | 1, .interrupt, 0, std.mem.asBytes(&intr)); - try testing.expect(fs.next() == null); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - // Exactly one reply, to the interrupted request, not to the interrupt. - // Getting this wrong leaves a SIGKILLed reader in uninterruptible sleep. - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header)), got.len); - const h = outHeader(got); - try testing.expectEqual(@as(u64, 1002), h.unique); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.INTR)), h.@"error"); - try testing.expectEqual(@as(?usize, null), fs.findSlot(1002)); - try testing.expect(fs.findSlot(1000) != null); - - // An interrupt for something we do not hold is ignored, not answered. - const stale: fuse_interrupt_in = .{ .unique = 4242 }; - try pushRequest(sv[1], 4243, .interrupt, 0, std.mem.asBytes(&stale)); - try testing.expect(fs.next() == null); - try testing.expect(libc.read(sv[1], &buf, buf.len) < 0); -} - -test "park table: a full table answers EAGAIN and keeps the descriptor flowing" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 1, - .offset = 0, - .size = 8, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - for (0..max_slots) |i| { - try pushRequest(sv[1], 2000 + i * 2, .read, 30, std.mem.asBytes(&in)); - const req = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = req.tag, .status = .again }, &.{}); - } - var buf: [512]u8 = undefined; - - // One more than the table holds. It is READ and refused, not left queued. - // Gating the read on a free slot is a deadlock dressed as backpressure: - // the INTERRUPT that frees a slot would never be read either, so a - // SIGKILLed reader would stay in uninterruptible sleep and every unrelated - // `ls` of the mount would hang behind the 32 blocked ones. Measured: that - // wedges a real mount. - try pushRequest(sv[1], 9998, .read, 30, std.mem.asBytes(&in)); - try testing.expect(fs.next() == null); - const refused = try readReply(sv[1], &buf); - try testing.expectEqual(@as(u64, 9998), outHeader(refused).unique); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.AGAIN)), outHeader(refused).@"error"); - - // And the requests that need no slot keep being answered with the table - // still full — DESTROY above all, since a missing reply to it hangs umount. - try pushRequest(sv[1], 9990, .access, 1, &.{}); - try testing.expect(fs.next() == null); - const nosys = try readReply(sv[1], &buf); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.NOSYS)), outHeader(nosys).@"error"); - - // An interrupt still lands, which is what lets a full table recover at all. - const intr: fuse_interrupt_in = .{ .unique = 2000 }; - try pushRequest(sv[1], 2001, .interrupt, 0, std.mem.asBytes(&intr)); - try testing.expect(fs.next() == null); - const killed = try readReply(sv[1], &buf); - try testing.expectEqual(@as(u64, 2000), outHeader(killed).unique); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.INTR)), outHeader(killed).@"error"); - - // ...and the freed slot takes the next request. - try pushRequest(sv[1], 9996, .read, 30, std.mem.asBytes(&in)); - const late = fs.next() orelse return error.NoRequest; - try testing.expectEqual(@as(u64, 9996), late.tag); -} - -test "park table: a payload too large to copy is refused rather than dangled" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const payload_len = park_data_max + 1; - var body: [@sizeOf(fuse_write_in) + payload_len]u8 = undefined; - var in: fuse_write_in = std.mem.zeroes(fuse_write_in); - in.size = payload_len; - @memcpy(body[0..@sizeOf(fuse_write_in)], std.mem.asBytes(&in)); - @memset(body[@sizeOf(fuse_write_in)..], 'z'); - try pushRequest(sv[1], 3000, .write, 6, &body); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(@as(usize, payload_len), req.data.len); - - // Parking this would park a slice of the read buffer, which the next - // `next()` overwrites. EAGAIN is the honest answer. - fs.reply(&.{ .tag = 3000, .status = .again }, &.{}); - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.AGAIN)), outHeader(got).@"error"); - try testing.expectEqual(@as(?usize, null), fs.findSlot(3000)); - - // A payload that fits IS copied, so parking it is safe even after the read - // buffer has been reused. - var small: [@sizeOf(fuse_write_in) + 4]u8 = undefined; - in.size = 4; - @memcpy(small[0..@sizeOf(fuse_write_in)], std.mem.asBytes(&in)); - @memcpy(small[@sizeOf(fuse_write_in)..], "keep"); - try pushRequest(sv[1], 3002, .write, 6, &small); - const kept = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = kept.tag, .status = .again }, &.{}); - // Something else lands in the read buffer... - try pushRequest(sv[1], 3004, .statfs, 1, &.{}); - _ = fs.next() orelse return error.NoRequest; - // ...and the parked bytes survived it. - const again = fs.retry() orelse return error.NoRetry; - try testing.expectEqualStrings("keep", again.data); -} - -test "a reply for a tag we no longer hold is dropped, not written" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - // The interrupt path already answered and freed this one; a second reply - // would carry a unique the kernel does not recognise, and could in - // principle be matched against a live request that reused the number. - fs.reply(&.{ .tag = 12345 }, &.{}); - var buf: [512]u8 = undefined; - try testing.expect(libc.read(sv[1], &buf, buf.len) < 0); -} - -test "statfs reports a usable namelen" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - try pushRequest(sv[1], 1100, .statfs, 1, &.{}); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.statfs, req.op); - fs.reply(&.{ .tag = 1100 }, &.{}); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - var out: fuse_statfs_out = undefined; - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_statfs_out)]); - // Zero here makes pathconf(_PC_NAME_MAX) return 0 and some tools then - // refuse to create any name at all. - try testing.expectEqual(@as(u32, 255), out.st.namelen); - try testing.expectEqual(@as(u32, 4096), out.st.bsize); -} - -test "the fusermount command line and environment" { - var opts_buf: [128:0]u8 = undefined; - const opts = mountOpts(&opts_buf); - try testing.expectEqualStrings("fsname=pardes,subtype=pardes,nosuid,nodev", opts); - // allow_other needs user_allow_other in /etc/fuse.conf, which is commented - // out on a stock install, and asking for it FAILS the whole mount rather - // than being ignored. default_permissions would move access control out of - // the core and into a mode nibble. - try testing.expect(std.mem.indexOf(u8, opts, "allow_other") == null); - try testing.expect(std.mem.indexOf(u8, opts, "default_permissions") == null); - - var env_buf: [32:0]u8 = undefined; - try testing.expectEqualStrings("_FUSE_COMMFD=7", commfdEnv(&env_buf, 7)); - - var argv: [6:null]?[*:0]const u8 = undefined; - mountArgv(&argv, "/usr/bin/fusermount3", opts.ptr, "/run/user/1000/pardes/42"); - try testing.expectEqualStrings("/usr/bin/fusermount3", std.mem.span(argv[0].?)); - try testing.expectEqualStrings("-o", std.mem.span(argv[1].?)); - try testing.expectEqualStrings("fsname=pardes,subtype=pardes,nosuid,nodev", std.mem.span(argv[2].?)); - // Without the `--` a mountpoint beginning with a dash is parsed as a flag - // by a setuid program. - try testing.expectEqualStrings("--", std.mem.span(argv[3].?)); - try testing.expectEqualStrings("/run/user/1000/pardes/42", std.mem.span(argv[4].?)); - try testing.expectEqual(@as(?[*:0]const u8, null), argv[5]); - - var uargv: [7:null]?[*:0]const u8 = undefined; - unmountArgv(&uargv, "/usr/bin/fusermount3", "/run/user/1000/pardes/42"); - try testing.expectEqualStrings("-u", std.mem.span(uargv[1].?)); - try testing.expectEqualStrings("-q", std.mem.span(uargv[2].?)); - // Lazy, or a pane shell with a cwd inside the mount makes the unmount fail - // with EBUSY and the mount outlives the editor. - try testing.expectEqualStrings("-z", std.mem.span(uargv[3].?)); - try testing.expectEqualStrings("--", std.mem.span(uargv[4].?)); - try testing.expectEqual(@as(?[*:0]const u8, null), uargv[6]); -} - -test "the child environment drops an inherited comm descriptor" { - if (comptime !supported) return; - const gpa = testing.allocator; - var buf: [32:0]u8 = undefined; - const commfd = commfdEnv(&buf, 5); - const env = try buildEnv(gpa, commfd); - defer gpa.free(env); - - // Exactly one _FUSE_COMMFD, and it is ours: getenv returns the FIRST match, - // so an inherited stale entry would win and fusermount3 would send the - // descriptor to a closed socket. - var seen: usize = 0; - var i: usize = 0; - while (env[i]) |entry| : (i += 1) { - if (std.mem.startsWith(u8, std.mem.span(entry), commfd_env ++ "=")) { - seen += 1; - try testing.expectEqualStrings("_FUSE_COMMFD=5", std.mem.span(entry)); - } - } - try testing.expectEqual(@as(usize, 1), seen); - try testing.expectEqual(@as(?[*:0]const u8, null), env[env.len - 1]); -} - -test "poll thread: one wake per drained batch, and stop joins from either state" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - var wakes: std.atomic.Value(u32) = .init(0); - const Sink = struct { - fn wake(ctx: ?*anyopaque) void { - const c: *std.atomic.Value(u32) = @ptrCast(@alignCast(ctx.?)); - _ = c.fetchAdd(1, .release); - } - }; - try fs.wakeThread(&wakes, Sink.wake); - - // One pending request, one wake. A FORGET is answered inside `next()` and - // never surfaces, so draining to null is the whole batch — and it is that - // null which raises the eventfd and lets the poller poll again. - const forget: fuse_forget_in = .{ .nlookup = 1 }; - try pushRequest(sv[1], 7000, .forget, 2, std.mem.asBytes(&forget)); - while (wakes.load(.acquire) == 0) std.Thread.yield() catch {}; - try testing.expectEqual(@as(?acmefs.Req, null), fs.next()); - - // The poller is now in one of the two states a stop has to break: still in - // the blocking wait, or back in `poll()` because the drain above beat the - // stop there. Which one is a race, deliberately unresolved — the assertion - // is that either joins, and a hang here is this test's only failure mode. - fs.stopThread(); - try testing.expect(fs.thread == null); - try testing.expectEqual(@as(c_int, -1), fs.ctl); -} - -test "poll thread: stop breaks a poller that never saw a request" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const Sink = struct { - fn wake(_: ?*anyopaque) void { - unreachable; // nothing is ever pending on this descriptor - } - }; - try fs.wakeThread(null, Sink.wake); - // Covers the two states with no acknowledgement in them at all: blocked in - // `poll()` with an idle descriptor, and not yet past the loop condition. - fs.stopThread(); - try testing.expect(fs.thread == null); -} - -test "mount refuses a relative point" { - if (comptime !supported) return; - try testing.expectError( - error.MountPathNotAbsolute, - Fs.mount(testing.allocator, .{ .mount = "relative/dir" }), - ); -} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 3876a222..a7d9d167 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1,30 +1,4 @@ -//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input -//! into core events, performs the core's effects (fork ptys, write them, -//! resize them — same duties as tty.zig, this is also native), and -//! rasterizes the core's Surface: one instanced quad per cell, glyphs from a -//! FreeType-hinted R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, -//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the -//! real renderer, drives input from stdin, and captures frames to PPM. -//! -//! ...AND THE SAME WINDOW WITH NO CORE IN IT. `--attach`, and the `Attach` -//! builtin, hand this window's screen to a detached session (src/detached/): -//! the `Pardes` lives in THAT process, and this one sends the input it collects -//! and paints the frames it is sent. The two modes share every line that -//! touches SDL — `dispatch`/`keyDown` translate an SDL_Event once, -//! `renderFrame` rasterizes a `Surface` once, `putClipboard`/`takeClipboard` -//! and `look.openLink` are the desktop once — and differ only in where a -//! translated event goes and where the cells came from. `Input` is that seam, -//! and a null `core` inside it is what "attached" MEANS here: every function -//! that reads pane rects or theme colours off the core takes an optional one -//! and falls back to the body grid, because the wire carries cells, not the -//! layout that produced them. -//! -//! An attached window does NO machine-local work whatsoever: it forks no shell, -//! writes no file and watches no path, because the session process owns all of -//! that now (src/host_io.zig, src/file_watch.zig, src/detached/server.zig). -//! The only effects still on that wire are the three that need a human's own -//! display — `set_clipboard`, `read_clipboard`, `open_link` — and those land on -//! THIS display. +const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; @@ -32,28 +6,17 @@ const libc = std.c; const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells const pardes = @import("../pardes.zig"); -const host_api = @import("../host.zig"); // LspRequest, the one host type not re-exported const config = @import("../config.zig"); const look = @import("../look.zig"); -const message = @import("../message.zig"); +const message = pardes.Pardes.Message; const file_watch = @import("../file_watch.zig"); -const user_config = @import("../user_config.zig"); const deck = @import("deck.zig"); const crt = @import("crt.zig"); const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); -const shell_bin = @import("../shell_bin.zig"); -const nested = @import("../nested.zig"); -const fuse = @import("../fuse.zig"); -const fs_service = @import("../fs_service.zig"); - -// The other half of `--detach`, and the reason this file has an `--attach` -// branch at all: the frontend side of a detached session is a window and a -// socket, and this file is already the one that owns a window. Just the one -// import: client.zig owns the frontend's whole side of this transport — the -// name resolution, the handshake, the poll interval and the decoded messages — -// so nothing here reaches past it to server.zig or wire.zig. +const ninep_io = @import("../9p_io.zig"); + const detached_client = @import("../detached/client.zig"); pub const c = @cImport({ @@ -62,13 +25,8 @@ pub const c = @cImport({ @cInclude("font.h"); }); -// The machine-local half of a host — fork a pane's shell, put bytes on a disk -// — is shared with the tty shell and the detached daemon. This file used to -// carry its own `forkShell`, `writeWholeFile` and `writeFd`, ten of eleven -// lines identical to that file's and one line short of its zero-write guard. const host_io = @import("../host_io.zig"); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); const log = std.log.scoped(.gui); @@ -83,32 +41,9 @@ const image_frag_spv = @embedFile("image.frag.spv"); const crt_vert_spv = @embedFile("crt.vert.spv"); const crt_frag_spv = @embedFile("crt.frag.spv"); -/// What the shell paints where the core painted nothing: behind the grid — the -/// strip left over when the window is not a whole number of cells tall — and -/// inside every cell the core left at its default background. The FALLBACK -/// for it, that is: the ground itself is the active theme's own background, -/// read off the core once a frame (see `ground`), and this is what a theme -/// with none of its own gets in an OPAQUE window. -/// -/// A theme declares no background (the curated `dark`, every vendored -/// `*_transparent`) to mean "wear whatever the terminal is wearing", and a -/// window has nothing to wear unless it is see-through. The AppKit shell -/// answers that by going transparent over an NSVisualEffectView -/// (pardes_theme_bg, docs/macos.md); this shell answers it with -/// `config.gui_transparent`, which asks SDL for a transparent window and lets -/// the compositor be the backdrop. That knob is off by default and costs a -/// readback per painted frame when it is on — the whole reason is written -/// where it is declared. Without it a themeless window keeps the -/// terminal-native dark it always wore. const bg_default = [3]u8{ 18, 18, 18 }; const fg_default = [3]u8{ 204, 204, 204 }; -/// The ground under this frame, and whether it is a colour at all. -/// -/// `clear` is the themeless case in a transparent window: nothing is painted -/// there, the desktop is. `rgb` still carries the fallback colour because a -/// reverse-video cell puts the ground in its FOREGROUND, where it is a real -/// colour that paints — the same rule PardesView.styleFor states on macOS. const Ground = struct { rgb: [3]u8, clear: bool, @@ -118,22 +53,11 @@ const Ground = struct { } }; -/// The ground under this frame. The theme's OWN background and not the -/// animated chrome colour: taglines fade between themes over a handful of -/// frames, document backgrounds switch the instant the theme does, and this is -/// one of those. Asked per frame, so a `Theme` command takes hold without a -/// relaunch — and asked at all because a hand-agreed constant was a black line -/// along the two edges of every light-themed window. fn ground(theme_bg: ?[3]u8, transparent: bool) Ground { if (theme_bg) |rgb| return .opaqueRgb(rgb); return .{ .rgb = bg_default, .clear = transparent }; } -// the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/ -// devmouse.c (Cursor arrow, 16x16 MSB-first): clr is the white outline, set -// the black ink, offset {-1,-1} puts the hot point at (1,1) in the bitmap. -// SDL_CreateCursor's scheme: data=1,mask=1 black; data=0,mask=1 white; -// mask=0 transparent — so data = set and mask = set|clr. const p9_arrow_clr = [32]u8{ 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C, 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04, @@ -152,7 +76,6 @@ const p9_arrow_mask = blk: { break :blk m; }; -// 2048 fits ~2500 glyphs at the 2x native cell (~20x40). const atlas_w: u32 = 2048; const atlas_h: u32 = 2048; const Slot = struct { u: u32, v: u32 }; @@ -161,9 +84,6 @@ const GlyphKey = struct { role: pardes.FontRole, }; -/// Raster parameters for the smaller tagline face. Taglines are a real second -/// grid: their glyph cell shrinks in both axes while pane geometry remains on -/// the body grid. const TaglineRaster = struct { scale: f32, width: u32, @@ -184,9 +104,6 @@ fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u return .{ .scale = scale, .width = @intCast(std.math.clamp(own_w, 1, fixed_w)), - // Tagline slots use only their first `height` texel rows. Centering is - // done by the cell quad, not baked into this baseline, so changing the - // live percentage changes the chrome band as well as the glyph ink. .baseline = std.math.clamp(own_ascent, 1, band_h), .height = @intCast(band_h), }; @@ -194,7 +111,6 @@ fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u const max_fallback_fonts = 1 + fonts.fallback_names.len; const LoadedFallback = struct { face: *c.UIFont, - /// Empty for embedded Adwaita; otherwise the owned bytes FreeType borrows. bytes: []u8 = &.{}, }; @@ -205,12 +121,6 @@ const touch_click_flash_max_frames: u8 = 14; const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; -/// SDL input can wake the loop faster than display cadence, so ticking once -/// per pass would make animation duration depend on pty traffic or mouse -/// motion — and `pump` deliberately spends no animation time of its own. This -/// monotonic gate keeps it near 60 Hz without sleeping the event loop. It is -/// consulted only AFTER a successful presentation: the current sample reaches -/// the screen before the display interval may advance it. const AnimationClock = struct { next_ns: u64 = 0, @@ -220,24 +130,19 @@ const AnimationClock = struct { return false; } if (clock.next_ns == 0) { - clock.next_ns = now_ns +| pardes.animation.frame_ns; + clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns; return false; } if (now_ns < clock.next_ns) return false; - clock.next_ns = now_ns +| pardes.animation.frame_ns; + clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns; return true; } }; -/// Commit exactly what the GPU accepted, then spend at most one display-clock -/// step. The order is the invariant: a persistent scene effect may keep -/// `AnimationClock` armed indefinitely, but it still cannot consume frame zero -/// of a panel/theme/hover animation created earlier in this loop before that -/// source sample is rendered. fn finishPresentedAnimationFrame( clock: *AnimationClock, core: *pardes.Pardes, - tracks: []const pardes.panel_animation.Track, + tracks: []const pardes.layout.Track, now_ns: u64, ) void { core.acknowledgePanelPresentation(tracks); @@ -248,9 +153,9 @@ test "GUI animation clock is active-only and cadence gated" { var clock: AnimationClock = .{}; try std.testing.expect(!clock.due(false, 100)); try std.testing.expect(!clock.due(true, 100)); - try std.testing.expect(!clock.due(true, 100 + pardes.animation.frame_ns - 1)); - try std.testing.expect(clock.due(true, 100 + pardes.animation.frame_ns)); - try std.testing.expect(!clock.due(false, 100 + 2 * pardes.animation.frame_ns)); + try std.testing.expect(!clock.due(true, 100 + pardes.layout.Animation.frame_ns - 1)); + try std.testing.expect(clock.due(true, 100 + pardes.layout.Animation.frame_ns)); + try std.testing.expect(!clock.due(false, 100 + 2 * pardes.layout.Animation.frame_ns)); try std.testing.expectEqual(@as(u64, 0), clock.next_ns); } @@ -261,13 +166,10 @@ test "a persistent scene presents a new panel's frame zero before advancing it" defer arena.deinit(); var clock: AnimationClock = .{}; - // The persistent effect arms the cadence gate before any panel animation - // exists—the state which used to make a due tick skip a new track's first - // sample in the old tick-before-render loop. core.settings.scene_effects.crt = true; const scene_frame = try core.render(arena.allocator()); finishPresentedAnimationFrame(&clock, core, scene_frame.panelTracks(), 100); - try std.testing.expectEqual(@as(u64, 100 + pardes.animation.frame_ns), clock.next_ns); + try std.testing.expectEqual(@as(u64, 100 + pardes.layout.Animation.frame_ns), clock.next_ns); core.settings.panel_transition = .slide; core.update(.{ .command = "Newcol" }); @@ -277,13 +179,11 @@ test "a persistent scene presents a new panel's frame zero before advancing it" try std.testing.expect(track_count > 0); for (first.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame); - // The cadence is already due, but finishPresentedAnimationFrame consumes - // it only after acknowledging the frame-zero records above. finishPresentedAnimationFrame( &clock, core, first.panelTracks(), - 100 + pardes.animation.frame_ns, + 100 + pardes.layout.Animation.frame_ns, ); _ = arena.reset(.retain_capacity); const second = try core.render(arena.allocator()); @@ -293,22 +193,6 @@ test "a persistent scene presents a new panel's frame zero before advancing it" const max_overlay_vertices: usize = 18 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); -// Ctrl+ / Ctrl-: how far one press moves g.px, and the two sizes it stops at. -// ONE size for the whole window, not one per pane: the core lays every pane -// out on a single uniform cell grid and Surface is one flat cols×rows array, -// so a second cell size would be a different core, not a different font. -// -// The step is 2 and not 1 because 1 is a press that sometimes does nothing — -// cell_w is round(advance × scale), the shipped face advances ~0.51px per px -// of size, and half the 1px steps therefore round to the same column width. A -// key that visibly works only every other press reads as a broken key. At 2 -// both axes move at every size in the range, on both parities of the ladder; -// that is what the test below walks. -// -// The ends are where a terminal stops being one. 8px is a 4×8 cell — the -// smallest thing with a glyph still in it — and 72px is 37×76, about thirty -// columns across a laptop screen. Past either the grid is not small or large, -// it is wedged, and there is no reset binding to get back out of it. const font_px_step: f32 = 2.0; const font_px_min: f32 = 8.0; const font_px_max: f32 = 72.0; @@ -316,8 +200,6 @@ const font_px_max: f32 = 72.0; test "every font size step moves the cell, and the ends are reachable exactly" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); - // Walk the whole range rather than only the 27px runtime ladder, so both - // odd and even rungs are exercised and a rounding stall cannot hide. var px = font_px_min + font_px_step; while (px <= font_px_max) : (px += 1.0) { var cw: c_int = 0; @@ -329,8 +211,6 @@ test "every font size step moves the cell, and the ends are reachable exactly" { c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px - font_px_step), &pw, &ph, &asc); try std.testing.expect(cw > pw and ch > ph); } - // ...and the clamp dispatch runs parks on each end instead of walking off - // it, from any size a press can leave g.px on try std.testing.expectEqual(font_px_max, std.math.clamp(font_px_max + font_px_step, font_px_min, font_px_max)); try std.testing.expectEqual(font_px_min, std.math.clamp(font_px_min - font_px_step, font_px_min, font_px_max)); } @@ -436,16 +316,11 @@ test "tagline glyph shrinks into its own cell and stays vertically centered" { const t = inkBounds(&tagline, @intCast(stride), @intCast(tag.width), @intCast(tag.height)).?; try std.testing.expect(t.max_x - t.min_x < b.max_x - b.min_x); try std.testing.expect(t.max_y - t.min_y < b.max_y - b.min_y); - // Compare doubled centers to avoid floating point. Hinting may move either - // glyph by a pixel, but the smaller one must not hug an edge of the slot. const body_center_x: isize = @intCast(b.min_x + b.max_x); const tag_center_x: isize = @intCast(t.min_x + t.max_x); const body_center_y: isize = @intCast(b.min_y + b.max_y); const centered_top: isize = @intCast((@as(u32, @intCast(cell_h)) - tag.height) / 2); const tag_center_y: isize = @as(isize, @intCast(t.min_y + t.max_y)) + centered_top * 2; - // Compare positions in their own grids. Both faces are centred within - // their natural monospace advance; the smaller face is not padded back - // out to a body-width cell. const body_slot_center: isize = @intCast(@as(u32, @intCast(cell_w)) - 1); const tag_slot_center: isize = @intCast(tag.width - 1); try std.testing.expect(@abs((tag_center_x - tag_slot_center) - (body_center_x - body_slot_center)) <= 2); @@ -470,7 +345,6 @@ test "tagline percentage changes measured band height without body metrics" { try std.testing.expect(small.height < configured.height); try std.testing.expect(configured.height <= full.height); try std.testing.expect(full.height <= @as(u32, @intCast(cell_h))); - // The body measurement is an input and remains the same one-row grid. try std.testing.expectEqual(body_scale, c.ui_font_scale_for_height(font, 27.0)); } @@ -493,17 +367,13 @@ test "installed Nerd Symbols fallback covers Yazi directory icons" { if (std.mem.eql(u8, font.name, "SymbolsNerdFont-Regular")) break font; } else return; - const bytes = try look.readFile(std.testing.allocator, candidate.path); + const bytes = try filesystem.readFile(std.testing.allocator, candidate.path); defer std.testing.allocator.free(bytes); const face = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; defer c.ui_font_free(face); - // Yazi's default directory icon ``. try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(face, 0xe5ff)); } -// One instance per body cell; a tagline cell adds its compact-grid foreground -// instance. The vertex shader expands each to a 2-triangle quad with -// gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1. const CellInstance = extern struct { x0: f32, y0: f32, @@ -560,18 +430,9 @@ const ImageInstance = extern struct { }; const initial_image_capacity: u32 = pardes.MAX_PANES; -/// `CellInstance.effect` is an effect id in its low bits and flags in its top -/// two. Both shaders that read the field mask the id off with `0x3fffffff`; -/// widening this pair means widening that mask with it. const old_layer_bit: u32 = 0x8000_0000; -/// This cell's background IS the see-through ground: emit the glyph and let -/// the compositor keep the rest. Only ever set when `Ground.clear` holds, so -/// an opaque window never reaches the branch. const clear_bg_bit: u32 = 0x4000_0000; -/// Image placement retained across pane destruction. Deliberately does not -/// contain ImagePlace.rgba: the producer owns those bytes, while the renderer -/// retains only the already-uploaded GPU texture identified by `key`. const SavedImagePlace = struct { key: pardes.ImageCacheKey, pane: u8, @@ -607,8 +468,8 @@ const SavedImagePlace = struct { const PreparedImage = struct { place: SavedImagePlace, texture: *c.SDL_GPUTexture, - track: ?pardes.panel_animation.Track = null, - clip: ?pardes.panel_animation.Box = null, + track: ?pardes.layout.Track = null, + clip: ?pardes.layout.Box = null, old_layer: bool = false, }; @@ -642,7 +503,7 @@ test "shader instance ABI carries aligned transition vectors" { try std.testing.expectEqual(@as(usize, 64), @offsetOf(ImageInstance, "effect")); try std.testing.expectEqual(@as(usize, 32), @sizeOf([8]f32)); - const tracks = [_]pardes.panel_animation.Track{ + const tracks = [_]pardes.layout.Track{ .{ .pane = 0, .phase = .opening, .effect = .slide }, .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, @@ -656,19 +517,19 @@ test "shader instance ABI carries aligned transition vectors" { } test "GUI paint plan snaps history effects without a frozen grid" { - const tracks = [_]pardes.panel_animation.Track{ + const tracks = [_]pardes.layout.Track{ .{ .pane = 0, .phase = .opening, .effect = .ascii }, .{ .pane = 1, .phase = .closing, .effect = .vertical }, }; try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len); const ready = makePaintPlan(&tracks, true); try std.testing.expectEqual(@as(usize, 3), ready.len); - try std.testing.expectEqual(pardes.panel_animation.Phase.opening, ready.batches[1].track.?.phase); - try std.testing.expectEqual(pardes.panel_animation.Phase.closing, ready.batches[2].track.?.phase); + try std.testing.expectEqual(pardes.layout.Phase.opening, ready.batches[1].track.?.phase); + try std.testing.expectEqual(pardes.layout.Phase.closing, ready.batches[2].track.?.phase); } test "closing tombstone overlays but never owns canonical cells" { - const closing: pardes.panel_animation.Track = .{ + const closing: pardes.layout.Track = .{ .pane = 0, .phase = .closing, .effect = .vertical, @@ -678,7 +539,7 @@ test "closing tombstone overlays but never owns canonical cells" { const plan = makePaintPlan(&.{closing}, true); try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4)); try std.testing.expectEqual(@as(usize, 2), plan.len); - try std.testing.expectEqual(pardes.panel_animation.Phase.closing, plan.batches[1].track.?.phase); + try std.testing.expectEqual(pardes.layout.Phase.closing, plan.batches[1].track.?.phase); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) bool { @@ -699,8 +560,6 @@ fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cel return true; } -// ---- touch: per-finger tracking + shared scroll/tap machines ---- - const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; const TouchPoint = struct { @@ -716,8 +575,6 @@ const TouchPoint = struct { const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 }; -/// A finger event with SDL's normalized 0..1 coordinates — the one shape both -/// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine. const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 }; const Touch = struct { @@ -770,8 +627,6 @@ const Touch = struct { return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 }; } - /// keep scroll state in sync with the set of active fingers: exactly two - /// active fingers begin (or re-key) a pair; anything else resets it. fn syncPair(t: *Touch) void { var ids: [2]u64 = .{ 0, 0 }; var count: usize = 0; @@ -793,8 +648,6 @@ const Touch = struct { t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} }; } - /// finger lift: a pair that never scrolled is a two-finger TAP — returns - /// its center (computed with the lifting finger's final position). fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint { if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; const tap = f.kind == .up and !t.scroll.scrolled; @@ -837,7 +690,6 @@ fn takeScrollTicks(accum: *f32) i32 { return ticks; } -/// The SDL boundary owns touch policy: two-finger scroll and tap-as-execute. fn handleFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { std.debug.assert(cell_w > 0 and cell_h > 0); return handlePairFinger(t, in, f, win_w, win_h, cell_w, cell_h, tagline_w); @@ -889,61 +741,54 @@ fn normCell(norm: f32, win: f32, cell: f32) u16 { return @intFromFloat(@max(0, @floor(px / cell))); } -// ---- pty plumbing: reader threads feed a mutex-protected queue ---- +const Pty = struct { + fd: c_int, + pid: libc.pid_t, + serial: u32, + kill_at: i64 = 0, + reader: ?std.Thread = null, + stop: [2]c_int = .{ -1, -1 }, +}; -const Pty = struct { fd: c_int, pid: libc.pid_t }; +const RetiredShell = struct { pid: libc.pid_t = 0, kill_at: i64 = 0 }; const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, - eof: struct { pane: u8, gen: u32, fd: c_int }, - /// a language query finished on its own thread (see lspThread) - lsp: struct { id: u32, rows: []u8 }, - /// a language SERVER changed state; narrated by the client's reader - /// threads through the status sink, lsp-allocator-owned + eof: struct { pane: u8, gen: u32, failure: ?anyerror = null }, + lsp: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, - /// a selection-filter worker finished; every stdout is gpa-owned pipe: selection_pipe.Response, - /// something happened in a watched directory (see watchThread) files_changed, - /// a pardes launched inside this one sent us a builtin command line (see - /// lookThread); gpa-owned, like `output` bytes - command: []u8, - /// `--fs`: the /dev/fuse descriptor has requests on it. Carries nothing — - /// the drain lives in pollFrame, and this only ends a blocking - /// SDL_WaitEventTimeout. Posted by the poll thread and, when a batch hits - /// its cap, by pollFrame itself. Lossy under backpressure on purpose: a - /// full queue already holds something that will wake the loop. fs_ready, fn deinit(m: Msg, gpa: std.mem.Allocator, lsp_allocator: std.mem.Allocator) void { switch (m) { .output => |o| gpa.free(o.bytes), - .lsp => |l| lsp_allocator.free(l.rows), + .lsp => |l| if (l.rows) |rows| lsp_allocator.free(rows), .lsp_status => |t| lsp_allocator.free(t), .pipe => |response_value| { var response = response_value; response.deinit(gpa); }, - .command => |line| gpa.free(line), .eof, .files_changed, .fs_ready => {}, } } }; -/// The shared snapshot/worker pair. This file carried its own `LspJob` with -/// "tty.zig's LspJob, and copied for the same reason" over the top; both copies -/// are now one module, and the AppKit shell — which had neither — uses it too. -const lsp_host = @import("../lsp_host.zig"); - const LspWorkers = struct { + const capacity = 16; active: std.atomic.Value(usize) = .init(0), - fn start(workers: *LspWorkers) void { - _ = workers.active.fetchAdd(1, .monotonic); + fn start(workers: *LspWorkers) bool { + var count = workers.active.load(.monotonic); + while (count < capacity) + count = workers.active.cmpxchgWeak(count, count + 1, .monotonic, .monotonic) orelse return true; + return false; } fn finish(workers: *LspWorkers) void { - _ = workers.active.fetchSub(1, .release); + const previous = workers.active.fetchSub(1, .release); + std.debug.assert(previous > 0); } fn wait(workers: *LspWorkers) void { @@ -955,17 +800,13 @@ const LspWorkers = struct { } }; -const max_pipe_tasks = 16; - -/// Moved to `selection_pipe.Tasks`, beside the Job it tracks — tty.zig carried -/// this same table verbatim. -const PipeTask = selection_pipe.Tasks.Task; const PipeTasks = selection_pipe.Tasks; const queue_capacity = 512; +const output_capacity = queue_capacity - pardes.MAX_PANES; const MessageBatch = struct { - items: [queue_capacity]Msg = undefined, + items: [queue_capacity + PipeTasks.capacity + 2]Msg = undefined, len: usize = 0, fn slice(batch: *MessageBatch) []Msg { @@ -978,16 +819,77 @@ const Queue = struct { lsp_allocator: std.mem.Allocator, lsp_workers: *LspWorkers, sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push - // 0.16 has no std.Thread.Mutex; critical sections here are a few - // instructions, so spinning on the lock-free std.atomic.Mutex is enough. - mutex: std.atomic.Mutex = .unlocked, + mutex: libc.pthread_mutex_t = .{}, + space: libc.pthread_cond_t = .{}, items: [queue_capacity]Msg = undefined, head: usize = 0, len: usize = 0, closed: bool = false, + files_changed: bool = false, + readers: [pardes.MAX_PANES]?u32 = @splat(null), + waiting: usize = 0, + lsp_id: ?u32 = null, + completions: [PipeTasks.capacity + 1]Msg = undefined, + completion_len: usize = 0, fn lock(q: *Queue) void { - while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); + std.debug.assert(libc.pthread_mutex_lock(&q.mutex) == .SUCCESS); + } + + fn unlock(q: *Queue) void { + std.debug.assert(libc.pthread_mutex_unlock(&q.mutex) == .SUCCESS); + } + + fn wake(q: *Queue) void { + if (q.sdl_wake) { + var sev = std.mem.zeroes(c.SDL_Event); + sev.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&sev); + } + } + + fn acceptReader(q: *Queue, pane: u8, gen: u32) void { + q.lock(); + defer q.unlock(); + std.debug.assert(q.readers[pane] == null); + q.readers[pane] = gen; + } + + fn cancelReader(q: *Queue, pane: u8, gen: u32) void { + q.lock(); + defer q.unlock(); + if (q.readers[pane] != gen) return; + q.readers[pane] = null; + var i: usize = 0; + while (i < q.len) { + const msg = q.items[(q.head + i) % q.items.len]; + const matches = switch (msg) { + .output => |o| o.pane == pane and o.gen == gen, + .eof => |e| e.pane == pane and e.gen == gen, + else => false, + }; + if (matches) q.removeAt(i).deinit(q.gpa, q.lsp_allocator) else i += 1; + } + std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); + } + + fn pushOutput(q: *Queue, pane: u8, gen: u32, bytes: []u8) bool { + q.lock(); + while (!q.closed and q.readers[pane] == gen and q.len >= output_capacity) { + q.waiting += 1; + std.debug.assert(libc.pthread_cond_wait(&q.space, &q.mutex) == .SUCCESS); + q.waiting -= 1; + } + if (q.closed or q.readers[pane] != gen) { + q.unlock(); + q.gpa.free(bytes); + return false; + } + q.items[(q.head + q.len) % q.items.len] = .{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }; + q.len += 1; + q.unlock(); + q.wake(); + return true; } fn removeAt(q: *Queue, offset: usize) Msg { @@ -999,54 +901,64 @@ const Queue = struct { return removed; } - /// Output and other refreshable work are lossy under sustained - /// backpressure. EOF and pipe completions are admitted by evicting queued - /// non-critical messages, so descriptors and futures reach the loop. + fn discardLsp(q: *Queue) void { + for (q.completions[0..q.completion_len], 0..) |msg, i| if (msg == .lsp) { + msg.deinit(q.gpa, q.lsp_allocator); + q.completion_len -= 1; + std.mem.copyForwards(Msg, q.completions[i..q.completion_len], q.completions[i + 1 .. q.completion_len + 1]); + return; + }; + } + fn push(q: *Queue, m: Msg) void { + if (m == .output) { + _ = q.pushOutput(m.output.pane, m.output.gen, m.output.bytes); + return; + } q.lock(); if (q.closed) { - q.mutex.unlock(); + q.unlock(); m.deinit(q.gpa, q.lsp_allocator); return; } - if (q.len == q.items.len) { - const incoming_critical = switch (m) { - .pipe, .eof => true, - else => false, - }; - if (!incoming_critical) { - q.mutex.unlock(); - m.deinit(q.gpa, q.lsp_allocator); - return; - } - var offset: usize = 0; - while (offset < q.len) : (offset += 1) { - const queued_critical = switch (q.items[(q.head + offset) % q.items.len]) { - .pipe, .eof => true, - else => false, - }; - if (!queued_critical) break; - } - if (offset == q.len) { - q.mutex.unlock(); - m.deinit(q.gpa, q.lsp_allocator); - return; - } - q.removeAt(offset).deinit(q.gpa, q.lsp_allocator); - } - q.items[(q.head + q.len) % q.items.len] = m; - q.len += 1; - q.mutex.unlock(); - if (q.sdl_wake) { - var sev = std.mem.zeroes(c.SDL_Event); - sev.type = c.SDL_EVENT_USER; - _ = c.SDL_PushEvent(&sev); + switch (m) { + .files_changed => q.files_changed = true, + .lsp, .pipe => { + if (m == .lsp) { + if (q.lsp_id != m.lsp.id) { + q.unlock(); + m.deinit(q.gpa, q.lsp_allocator); + return; + } + q.discardLsp(); + } + std.debug.assert(q.completion_len < q.completions.len); + q.completions[q.completion_len] = m; + q.completion_len += 1; + }, + else => { + if (m == .eof) { + if (q.readers[m.eof.pane] != m.eof.gen) { + q.unlock(); + return; + } + std.debug.assert(q.len < q.items.len); + } else if (q.len >= output_capacity) { + q.unlock(); + m.deinit(q.gpa, q.lsp_allocator); + return; + } + q.items[(q.head + q.len) % q.items.len] = m; + q.len += 1; + }, } + q.unlock(); + q.wake(); } fn take(q: *Queue) MessageBatch { q.lock(); - defer q.mutex.unlock(); + defer q.unlock(); var batch: MessageBatch = .{}; while (q.len > 0) { batch.items[batch.len] = q.items[q.head]; @@ -1055,139 +967,638 @@ const Queue = struct { q.len -= 1; } q.head = 0; + if (q.files_changed) { + batch.items[batch.len] = .files_changed; + batch.len += 1; + q.files_changed = false; + } + @memcpy(batch.items[batch.len..][0..q.completion_len], q.completions[0..q.completion_len]); + batch.len += q.completion_len; + q.completion_len = 0; + std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); return batch; } - fn close(q: *Queue, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32) void { + fn discardCompletions(q: *Queue) void { + q.lock(); + defer q.unlock(); + q.lsp_id = null; + for (q.completions[0..q.completion_len]) |msg| msg.deinit(q.gpa, q.lsp_allocator); + q.completion_len = 0; + } + + fn close(q: *Queue) void { q.lock(); - defer q.mutex.unlock(); q.closed = true; + q.files_changed = false; + q.readers = @splat(null); + std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); + q.unlock(); + q.discardCompletions(); + q.lock(); + defer q.unlock(); while (q.len > 0) { const m = q.items[q.head]; - switch (m) { - .eof => |e| { - _ = libc.close(e.fd); - if (gens[e.pane] == e.gen) { - if (ptys[e.pane]) |pt| if (pt.fd == e.fd) { - ptys[e.pane] = null; - }; - } - }, - else => m.deinit(q.gpa, q.lsp_allocator), - } + m.deinit(q.gpa, q.lsp_allocator); q.head = (q.head + 1) % q.items.len; q.len -= 1; } q.head = 0; } + + fn deinit(q: *Queue) void { + q.close(); + std.debug.assert(q.waiting == 0); + std.debug.assert(libc.pthread_cond_destroy(&q.space) == .SUCCESS); + std.debug.assert(libc.pthread_mutex_destroy(&q.mutex) == .SUCCESS); + } +}; + +test "GUI completion survives a full output queue" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 7 }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "output"), + } }); + for (0..PipeTasks.capacity) |id| queue.push(.{ .pipe = .{ + .id = @intCast(id), + .success = false, + .outputs = &.{}, + .failure = .{ .stderr = try gpa.dupe(u8, "pipe failure") }, + } }); + queue.push(.{ .lsp = .{ .id = 7, .rows = try gpa.dupe(u8, "completion") } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(output_capacity + PipeTasks.capacity + 1, batch.len); + for (batch.items[0..output_capacity]) |msg| { + try std.testing.expect(msg == .output); + try std.testing.expectEqualStrings("output", msg.output.bytes); + } + for (batch.items[output_capacity..][0..PipeTasks.capacity], 0..) |msg, id| { + try std.testing.expect(msg == .pipe); + try std.testing.expectEqual(@as(u32, @intCast(id)), msg.pipe.id); + } + var found = false; + for (batch.slice()) |msg| if (msg == .lsp) { + try std.testing.expectEqual(@as(u32, 7), msg.lsp.id); + try std.testing.expectEqualStrings("completion", msg.lsp.rows.?); + found = true; + }; + try std.testing.expect(found); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); +} + +const PtyTests = struct { + fn waitBlocked(queue: *Queue) !void { + const until = shellClock() + 2_000; + while (shellClock() < until) { + queue.lock(); + const waiting = queue.waiting; + queue.unlock(); + if (waiting != 0) return; + try std.testing.io.sleep(.fromMilliseconds(1), .awake); + } + return error.ReaderDidNotBlock; + } + + fn tail(queue: *Queue, bytes: []u8) void { + if (queue.pushOutput(0, 1, bytes)) queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); + } }; -/// The registered `lsp.setStatusSink` target, called from the protocol -/// client's reader threads: dupe with the concurrent lsp allocator, push to -/// the mutex queue. A push after close is disposed by the queue itself. +test "GUI PTY backpressure retains byte order and tail before EOF" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); + const tail = try gpa.dupe(u8, "tail"); + var thread: ?std.Thread = std.Thread.spawn(.{}, PtyTests.tail, .{ &queue, tail }) catch |err| { + gpa.free(tail); + return err; + }; + defer { + queue.cancelReader(0, 1); + if (thread) |owned| owned.join(); + } + try PtyTests.waitBlocked(&queue); + queue.push(.files_changed); + queue.push(.files_changed); + var first = queue.take(); + defer for (first.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(output_capacity + 1, first.len); + for (first.items[0..output_capacity]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + try std.testing.expect(first.items[output_capacity] == .files_changed); + thread.?.join(); + thread = null; + var last = queue.take(); + defer for (last.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 2), last.len); + try std.testing.expectEqualStrings("tail", last.items[0].output.bytes); + try std.testing.expect(last.items[1] == .eof); + try std.testing.expectEqual(@as(usize, 0), queue.waiting); +} + +test "GUI PTY backpressure cancellation and close release owned producers" { + const gpa = std.testing.allocator; + for ([_]bool{ false, true }) |close| { + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); + const tail = try gpa.dupe(u8, "cancelled"); + const thread = std.Thread.spawn(.{}, PtyTests.tail, .{ &queue, tail }) catch |err| { + gpa.free(tail); + return err; + }; + defer { + queue.cancelReader(0, 1); + thread.join(); + } + try PtyTests.waitBlocked(&queue); + if (close) queue.close() else queue.cancelReader(0, 1); + if (!close) { + queue.acceptReader(0, 2); + queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); + try std.testing.expect(queue.pushOutput(0, 2, try gpa.dupe(u8, "replacement"))); + queue.push(.{ .eof = .{ .pane = 0, .gen = 2 } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 2), batch.len); + try std.testing.expectEqualStrings("replacement", batch.items[0].output.bytes); + try std.testing.expectEqual(@as(u32, 2), batch.items[1].eof.gen); + } else try std.testing.expectEqual(@as(usize, 0), queue.take().len); + } +} + +test "GUI PTY EOF reserve cannot evict terminal output" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + for (0..pardes.MAX_PANES) |pane| queue.acceptReader(@intCast(pane), 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); + for (0..pardes.MAX_PANES) |pane| queue.push(.{ .eof = .{ .pane = @intCast(pane), .gen = 1 } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(queue_capacity, batch.len); + for (batch.items[0..output_capacity]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + for (batch.items[output_capacity..batch.len], 0..) |msg, pane| try std.testing.expectEqual(pane, msg.eof.pane); +} + +test "GUI PTY reader delivers a real shell tail before EOF and joins on close" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var shell: Shell = undefined; + shell.core = core; + shell.gpa = gpa; + shell.ptys = &ptys; + shell.gens = &gens; + shell.queue = &queue; + shell.retired_shells = @splat(.{}); + defer shell.shutdownPtys(); + const rcs: host_io.Shell.PromptFiles = .{}; + const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; + try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); + try std.testing.expect(host_io.writeFd(child.file.handle, "printf '\\120\\101\\122\\104\\105\\123\\055\\124\\101\\111\\114'; exit\n")); + var bytes: std.ArrayList(u8) = .empty; + defer bytes.deinit(gpa); + var eof = false; + const until = shellClock() + 2_000; + while (!eof and shellClock() < until) { + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + for (batch.slice()) |msg| switch (msg) { + .output => |o| { + try std.testing.expect(!eof); + try bytes.appendSlice(gpa, o.bytes); + }, + .eof => |e| { + try std.testing.expect(e.failure == null); + eof = true; + }, + else => return error.UnexpectedMessage, + }; + if (!eof) try std.testing.io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expect(eof); + try std.testing.expect(std.mem.indexOf(u8, bytes.items, "PARDES-TAIL") != null); + shell.closePty(0); + try std.testing.expect(queue.readers[0] == null); + try std.testing.expect(ptys[0] == null or ptys[0].?.reader == null); + try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); +} + +test "GUI PTY Restore joins a real reader waiting for queue space" { + const gpa = std.testing.allocator; + var core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + try core.dumpState(); + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + queue.acceptReader(1, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 1, .gen = 1, .bytes = try gpa.dupe(u8, "other pane") } }); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var shell: Shell = undefined; + shell.core = core; + shell.gpa = gpa; + shell.ptys = &ptys; + shell.gens = &gens; + shell.queue = &queue; + shell.retired_shells = @splat(.{}); + defer shell.shutdownPtys(); + const rcs: host_io.Shell.PromptFiles = .{}; + const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; + try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); + try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); + try PtyTests.waitBlocked(&queue); + const old_serial = core.panes[0].?.serial; + const replacement = try core.restore(core.dump_out.?); + shell.stopPtys(); + core.deinit(); + core = replacement; + shell.core = replacement; + try std.testing.expect(core.panes[0].?.serial != old_serial); + try std.testing.expectEqual(@as(usize, 0), queue.waiting); + try std.testing.expect(queue.readers[0] == null); + try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); + queue.cancelReader(1, 1); + queue.acceptReader(0, gens[0]); + queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); + try std.testing.expect(queue.pushOutput(0, gens[0], try gpa.dupe(u8, "fresh"))); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqualStrings("fresh", batch.items[0].output.bytes); +} + +test "GUI PTY deletion joins an idle reader and retains only its owned child for reaping" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var shell: Shell = undefined; + shell.core = core; + shell.gpa = gpa; + shell.ptys = &ptys; + shell.gens = &gens; + shell.queue = &queue; + shell.retired_shells = @splat(.{}); + defer shell.shutdownPtys(); + const rcs: host_io.Shell.PromptFiles = .{}; + const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; + try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); + try core.removePane(0); + shell.reconcilePtys(); + try std.testing.expect(queue.readers[0] == null); + try std.testing.expectEqual(@as(usize, 0), queue.waiting); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); + try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); + for (shell.retired_shells) |retired| try std.testing.expect(retired.pid == 0 or retired.pid == child.pid); + shell.shutdownPtys(); + try std.testing.expectEqual(@as(libc.pid_t, -1), libc.waitpid(child.pid, null, libc.W.NOHANG)); + try std.testing.expectEqual(libc.E.CHILD, libc.errno(-1)); +} + +test "GUI PTY file watcher stops and joins without closing its watched descriptor" { + if (!file_watch.supported) return error.SkipZigTest; + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + const fd = file_watch.init(true); + if (fd < 0) return error.WatchInitFailed; + defer _ = libc.close(fd); + const stop = try stopPipe(); + defer for (stop) |pipe_fd| { + _ = libc.close(pipe_fd); + }; + const thread = try std.Thread.spawn(.{}, watchThread, .{ fd, stop[0], &queue }); + _ = host_io.writeFd(stop[1], "x"); + thread.join(); + try std.testing.expect(libc.fcntl(fd, libc.F.GETFD) >= 0); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); +} + +test "GUI completion failure survives backlog and leaves the document unchanged" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + pane.cur_col = 1; + core.lspRequest(core.active, .completion, ""); + const id = core.lsp_wait.?.id; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = id }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "output"), + } }); + queue.push(.{ .lsp = .{ .id = id, .rows = null } }); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var shell: Shell = undefined; + shell.core = core; + shell.queue = &queue; + shell.gpa = gpa; + shell.lsp_allocator = gpa; + shell.gens = &gens; + shell.ptys = &ptys; + shell.retired_shells = @splat(.{}); + shell.saw_event = false; + shell.drainQueue(); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expect(shell.saw_event); +} + +test "GUI completions retain their arrival order across LSP and pipes" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 2 }; + defer queue.deinit(); + queue.push(.{ .pipe = .{ .id = 1, .success = true, .outputs = &.{} } }); + queue.push(.{ .lsp = .{ .id = 2, .rows = try gpa.dupe(u8, "second") } }); + queue.push(.{ .pipe = .{ .id = 3, .success = false, .outputs = &.{} } }); + queue.push(.{ .lsp = .{ .id = 1, .rows = try gpa.dupe(u8, "late") } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 3), batch.len); + try std.testing.expectEqual(@as(u32, 1), batch.items[0].pipe.id); + try std.testing.expectEqual(@as(u32, 2), batch.items[1].lsp.id); + try std.testing.expectEqual(@as(u32, 3), batch.items[2].pipe.id); +} + +test "GUI completion reset frees queued payloads and rejects late LSP workers" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 9 }; + defer queue.deinit(); + queue.acceptReader(0, 1); + queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "retained") } }); + queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "current") } }); + queue.push(.{ .lsp = .{ .id = 8, .rows = try gpa.dupe(u8, "late old request") } }); + try std.testing.expectEqualStrings("current", queue.completions[0].lsp.rows.?); + for (0..PipeTasks.capacity) |id| { + const outputs = try gpa.alloc([]u8, 1); + outputs[0] = try gpa.dupe(u8, "cancelled result"); + queue.push(.{ .pipe = .{ .id = @intCast(id), .success = true, .outputs = outputs } }); + } + queue.discardCompletions(); + queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "late before Restore") } }); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqualStrings("retained", batch.items[0].output.bytes); + + queue.lsp_id = 10; + queue.push(.{ .lsp = .{ .id = 10, .rows = null } }); + queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "late after Restore") } }); + var next = queue.take(); + defer for (next.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), next.len); + try std.testing.expectEqual(@as(u32, 10), next.items[0].lsp.id); + try std.testing.expect(next.items[0].lsp.rows == null); + + queue.push(.{ .lsp = .{ .id = 10, .rows = try gpa.dupe(u8, "closing result") } }); + queue.push(.{ .pipe = .{ + .id = 17, + .success = false, + .outputs = &.{}, + .failure = .{ .stderr = try gpa.dupe(u8, "closing failure") }, + } }); + queue.close(); + queue.push(.{ .lsp = .{ .id = 10, .rows = try gpa.dupe(u8, "late closed result") } }); + queue.push(.{ .pipe = .{ + .id = 18, + .success = false, + .outputs = &.{}, + .failure = .{ .stderr = try gpa.dupe(u8, "late closed failure") }, + } }); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); +} + fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); const copy = q.lsp_allocator.dupe(u8, text) catch return; q.push(.{ .lsp_status = copy }); } -fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void { +fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, stop: c_int, pane: u8, gen: u32, q: *Queue) void { var buf: [0x10000]u8 = undefined; + var failure: ?anyerror = null; while (true) { + var fds = [_]libc.pollfd{ + .{ .fd = stop, .events = libc.POLL.IN, .revents = 0 }, + .{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }, + }; + if (libc.poll(&fds, fds.len, -1) < 0) { + if (libc.errno(-1) == .INTR) continue; + failure = error.PollFailed; + break; + } + if (fds[0].revents != 0) return; + if (fds[1].revents == 0) continue; const n = libc.read(fd, &buf, buf.len); if (n < 0) { if (libc.errno(n) == .INTR) continue; - break; // EIO when the child exits: treat as EOF + if (libc.errno(n) != .IO) failure = error.ReadFailed; + break; } if (n == 0) break; - const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; - q.push(.{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }); + const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch |err| { + failure = err; + break; + }; + if (!q.pushOutput(pane, gen, bytes)) return; } - q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } }); + q.push(.{ .eof = .{ .pane = pane, .gen = gen, .failure = failure } }); } -fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) void { - const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, gen, q }) catch return; - th.detach(); +fn stopPipe() ![2]c_int { + var fds: [2]c_int = undefined; + if (libc.pipe(&fds) != 0) return error.PipeFailed; + errdefer for (fds) |fd| { + _ = libc.close(fd); + }; + for (fds) |fd| if (libc.fcntl(fd, libc.F.SETFD, @as(c_int, 1)) < 0) return error.PipeFailed; + return fds; } -/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the -/// events: the loop re-reads every watched pane anyway, so the only thing an -/// event carries that we need is THAT something happened, and parsing would -/// mean sharing the watch table with the thread that mutates it. Detached like -/// the pty readers, and ended the same way — teardown closes the fd, the read -/// fails, the thread returns. -fn watchThread(fd: c_int, q: *Queue) void { - // A kqueue cannot be read, so the macos arm parks in kevent(2) instead and - // is released by the teardown's `file_watch.stop`. See file_watch.wait. - if (comptime builtin.os.tag != .linux) { - while (file_watch.wait(fd)) q.push(.files_changed); - return; - } - var buf: [4096]u8 = undefined; - while (true) { - const n = libc.read(fd, &buf, buf.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - break; - } - if (n == 0) break; - q.push(.files_changed); - } +fn spawnReader(gpa: std.mem.Allocator, pt: *Pty, pane: u8, gen: u32, q: *Queue) !void { + std.debug.assert(pt.reader == null and pt.fd >= 0); + const stop = try stopPipe(); + errdefer for (stop) |fd| { + _ = libc.close(fd); + }; + q.acceptReader(pane, gen); + errdefer q.cancelReader(pane, gen); + pt.reader = try std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, stop[0], pane, gen, q }); + pt.stop = stop; } -/// Block on the nested-instance socket and hand the loop each command line a -/// pardes started inside this one sends. Detached like the pty readers and the -/// watcher — but NOT ended the way they are: close(2) does not release a -/// thread parked in accept4 on linux, so this one simply dies with the -/// process. The window that leaves is one connection accepted between the last -/// drain and process exit pushing into a queue nobody empties again; Queue -/// frees a push made after close(), and the process is on its way out anyway. -fn lookThread(gpa: std.mem.Allocator, fd: c_int, q: *Queue) void { - var buf: [nested.max_line]u8 = undefined; - while (nested.acceptLine(fd, &buf)) |line| { - const owned = gpa.dupe(u8, line) catch continue; - q.push(.{ .command = owned }); +fn watchThread(fd: c_int, stop: c_int, q: *Queue) void { + while (true) { + var fds = [_]libc.pollfd{ + .{ .fd = stop, .events = libc.POLL.IN, .revents = 0 }, + .{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }, + }; + if (libc.poll(&fds, fds.len, -1) < 0) { + if (libc.errno(-1) == .INTR) continue; + return; + } + if (fds[0].revents != 0) return; + if ((fds[1].revents & (libc.POLL.ERR | libc.POLL.HUP | libc.POLL.NVAL)) != 0) return; + if (fds[1].revents != 0 and file_watch.drain(fd)) q.push(.files_changed); } } -/// Answer a language query off the render loop and push the rows to the queue. -/// The snapshot and the query body are `lsp_host`'s; what stays here is this -/// shell's own plumbing — a detached thread, the refcount that teardown joins -/// on, and the mutex queue the pty readers already use. -fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *lsp_host.Job, q: *Queue) void { +fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *host_io.Lsp.Job, q: *Queue) void { defer workers.finish(); - lsp_host.work(lsp_allocator, job, q, pushLspRows); + host_io.Lsp.work(lsp_allocator, job, q, pushLspRows); } -fn pushLspRows(ctx: ?*anyopaque, id: u32, rows: []u8) void { +fn pushLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); q.push(.{ .lsp = .{ .id = id, .rows = rows } }); } -/// Copy the query out of the core and hand it to a thread. A detached thread -/// per query is fine at this rate: one keystroke, one query, and the queue -/// already tolerates a late push after close. -fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: host_api.LspRequest) void { +fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: host_io.Lsp.Request) void { const lsp_allocator = q.lsp_allocator; - const job = lsp_host.snapshot(lsp_allocator, core, e) orelse return; - q.lsp_workers.start(); - const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch { + q.lock(); + q.lsp_id = e.id; + q.discardLsp(); + q.unlock(); + if (!q.lsp_workers.start()) { + core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); + return core.reportError(e.pane, "lsp", error.WorkersBusy); + } + const job = host_io.Lsp.snapshot(lsp_allocator, core, e) catch |err| { + q.lsp_workers.finish(); + core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); + return core.reportError(e.pane, "lsp", err); + }; + const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch |err| { q.lsp_workers.finish(); job.free(lsp_allocator); - return; + core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); + return core.reportError(e.pane, "lsp", err); }; th.detach(); } +test "GUI LSP worker limit rejects before allocation and recovers after owned workers exit" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + pane.cur_col = 1; + var workers: LspWorkers = .{}; + var gate: std.Io.Event = .unset; + var threads: [LspWorkers.capacity]?std.Thread = @splat(null); + defer { + gate.set(std.testing.io); + for (threads) |thread| if (thread) |owned| owned.join(); + workers.wait(); + } + for (&threads) |*thread| { + try std.testing.expect(workers.start()); + thread.* = std.Thread.spawn(.{}, struct { + fn run(active: *LspWorkers, ready: *std.Io.Event) void { + defer active.finish(); + ready.waitUncancelable(std.testing.io); + } + }.run, .{ &workers, &gate }) catch |err| { + workers.finish(); + return err; + }; + } + try std.testing.expectEqual(@as(usize, LspWorkers.capacity), workers.active.load(.monotonic)); + try std.testing.expect(!workers.start()); + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = failing.allocator(), .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + core.lspRequest(core.active, .completion, ""); + const rejected = core.lsp_wait.?.id; + queue.lsp_id = rejected -% 1; + queue.push(.{ .lsp = .{ .id = queue.lsp_id.?, .rows = try gpa.dupe(u8, "obsolete queued result") } }); + spawnLsp(core, &queue, .{ + .id = rejected, + .kind = .completion, + .pane = @intCast(core.active), + .offset = 1, + .arg = "", + }); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "WorkersBusy") != null); + try std.testing.expectEqual(@as(usize, 0), failing.alloc_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(rejected, queue.lsp_id.?); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + queue.push(.{ .lsp = .{ .id = rejected -% 1, .rows = try gpa.dupe(u8, "obsolete late result") } }); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + try std.testing.expectEqual(@as(usize, LspWorkers.capacity), workers.active.load(.monotonic)); + + gate.set(std.testing.io); + workers.wait(); + try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); + queue.lsp_allocator = gpa; + core.lspRequest(core.active, .status, ""); + const accepted = core.lsp_wait.?.id; + spawnLsp(core, &queue, .{ + .id = accepted, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }); + workers.wait(); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqual(accepted, batch.items[0].lsp.id); + try std.testing.expect(batch.items[0].lsp.rows != null); + core.update(.{ .lsp_resp = .{ .id = accepted, .rows = batch.items[0].lsp.rows } }); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); + try std.testing.expectEqualStrings("abc", pane.file.?.content); +} + fn pipeThread(io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, q: *Queue) anyerror!void { defer job.deinit(gpa); const response = selection_pipe.runJob(gpa, io, job); q.push(.{ .pipe = response }); } -/// Copy every borrowed core byte before the tracked worker starts. The queue -/// owns the response and already has close-time disposal for a late answer. fn spawnPipe( core: *pardes.Pardes, io: std.Io, @@ -1201,15 +1612,78 @@ fn spawnPipe( return; } const view = core.pipeRequest(id) orelse return; - const job = selection_pipe.Job.copy(gpa, view) catch return; - const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch { + const job = selection_pipe.Job.copy(gpa, view) catch |err| { + core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return core.reportError(core.active, "pipe", err); + }; + const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch |err| { job.deinit(gpa); - return; + core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return core.reportError(core.active, "pipe", err); }; std.debug.assert(tasks.add(.{ .id = id, .future = future })); } -// ---- the renderer state ---- +test "GUI worker setup failures finish matching LSP and pipe requests" { + const gpa = std.testing.allocator; + var failing_vtable = std.testing.io.vtable.*; + failing_vtable.concurrent = std.Io.failingConcurrent; + const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; + for (0..2) |failure| { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + var failing = std.testing.FailingAllocator.init(gpa, .{ + .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), + }); + var workers: LspWorkers = .{}; + var queue: Queue = .{ + .gpa = gpa, + .lsp_allocator = failing.allocator(), + .lsp_workers = &workers, + .sdl_wake = false, + }; + defer queue.deinit(); + var tasks: PipeTasks = .{}; + core.lspRequest(core.active, .status, ""); + const req: host_io.Lsp.Request = .{ + .id = core.lsp_wait.?.id, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }; + if (failure == 0) { + queue.lsp_id = req.id -% 1; + queue.push(.{ .lsp = .{ .id = queue.lsp_id.?, .rows = try gpa.dupe(u8, "old queued result") } }); + spawnLsp(core, &queue, req); + try std.testing.expectEqual(req.id, queue.lsp_id.?); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + queue.push(.{ .lsp = .{ .id = req.id -% 1, .rows = try gpa.dupe(u8, "late old result") } }); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + } else { + var shell: Shell = undefined; + shell.core = core; + shell.threads_ok = false; + lsp(&shell, req); + } + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + core.update(.{ .key = .{ .cp = '|' } }); + core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = core.pipe_wait.?.id; + spawnPipe(core, failing_io, failing.allocator(), &queue, &tasks, pipe_id); + try std.testing.expect(core.pipe_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); + try std.testing.expectEqual(@as(usize, 0), tasks.len); + try std.testing.expectEqual(@as(usize, 0), queue.len); + } +} const Gui = struct { window: *c.SDL_Window, @@ -1237,21 +1711,11 @@ const Gui = struct { prepared_images: std.ArrayListUnmanaged(PreparedImage) = .empty, font: *c.UIFont, - /// the file behind `font`, when it is one the Font builtin loaded. Empty - /// for the font the binary ships with, which is @embedFile'd and not ours - /// to free — FreeType borrows these bytes for the face lifetime. font_bytes: []u8 = &.{}, font_name: [255]u8 = @splat(0), font_name_len: u8 = 0, - /// Faces are discovered and opened once at startup. `glyphs` below caches - /// the raster result by codepoint, so fallback probing happens once per - /// glyph/atlas epoch rather than once per cell or frame. fallbacks: [max_fallback_fonts]?LoadedFallback = @splat(null), fallback_count: usize = 0, - /// the cell height the metrics are asked for, in pixels. A field and not - /// the local constant it used to be because refitFont reads it: changing - /// the FACE has to re-ask at the same size, and changing the SIZE (the - /// Ctrl+/Ctrl- this leaves the path for) is writing here and calling that. px: f32, scale: f32, tagline_scale: f32, @@ -1263,9 +1727,6 @@ const Gui = struct { ascent: i32, tagline_baseline: i32, - // glyph atlas: CPU staging bitmap + codepoint/role → texel slot, pen-walk alloc. - // The slot is also the fallback-resolution cache: after first rasterization - // every cell/frame takes the hash hit without probing any face again. atlas_stage: []u8, glyphs: std.AutoHashMap(GlyphKey, Slot), pen_x: u32 = 0, @@ -1277,16 +1738,6 @@ const Gui = struct { live_ctrl: bool = false, live_alt: bool = false, - // Fractional wheel scroll, one pane at a time. SDL's exact floating-point - // distance is batched until the next render. The core still moves only at - // whole-row boundaries; scroll_lag retains the sub-row picture position. - // - // ponytail: one accumulator, so exactly one pane can be offset and only the - // MOUSE wheel fills it — the deck's left stick and a two-finger touch - // scroll still hand the core their whole rows on the spot (they have - // their own sub-tick accumulators, and neither aims well enough to miss - // the fractional rendering). Both are one call site each: point them at - // scroll_delta the way the wheel arm of dispatch does. scroll_pane: ?usize = null, scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, scroll_body_y: u16 = 0, // that rect's first BODY row (Tagbottom moves it) @@ -1297,26 +1748,16 @@ const Gui = struct { scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane scroll_edge_len: u16 = 0, - // Crt builtin: the scene renders into this texture, then a fullscreen - // CRT pass warps it onto the real target scene_tex: ?*c.SDL_GPUTexture = null, scene_tex_w: u32 = 0, scene_tex_h: u32 = 0, - /// Resize-time scene target creation can fail transiently. Present the - /// direct frame meanwhile, then stop retrying after a small bounded run. scene_failures: u8 = 0, scene_target_failed: bool = false, - /// Exact postprocess state used by the last submitted frame. Input maps - /// through this snapshot, not through config/time sampled a frame later. presented_scene: crt.Frame = .{}, - /// Physical pointer state is retained separately from its mapped grid - /// cell. Ripple/glitch can move the displayed source under a stationary - /// hand, so every accepted scene frame remaps this same window point. pointer_present: bool = false, pointer_mapped: bool = false, pointer_cell: ?MouseCell = null, - // PARDES_TEST frame capture (render into an offscreen target, dump PPM) capture: bool = false, capture_dir: []const u8 = "", capture_tex: ?*c.SDL_GPUTexture = null, @@ -1325,28 +1766,13 @@ const Gui = struct { capture_xfer: ?*c.SDL_GPUTransferBuffer = null, capture_xfer_size: u32 = 0, - // Software present. A Vulkan swapchain needs a presentable surface, which - // a compositor without linux-dmabuf cannot provide (p9wl and other - // software/remote Wayland stacks: the driver reports "this surface does - // not support presenting"). The GPU still renders, so render offscreen - // exactly like capture does and blit the readback through SDL_Renderer, - // which goes out over wl_shm. - // - // `config.gui_transparent` takes the same path deliberately rather than by - // failure: SDL's GPU API refuses to claim a transparent window at all, and - // SDL_Renderer is the presenter that does honour one. soft_present: bool = false, - /// The window was created with SDL_WINDOW_TRANSPARENT, so a themeless - /// ground is nothing at all instead of `bg_default`. Implies - /// `soft_present`; read per frame by `ground`. transparent: bool = false, soft_renderer: ?*c.SDL_Renderer = null, soft_texture: ?*c.SDL_Texture = null, soft_tex_w: u32 = 0, soft_tex_h: u32 = 0, - // Steam Deck: gamepad-driven virtual cursor in SDL window coordinates. - // Conversion to physical render pixels happens once in mouseCell. gamepad: ?*c.SDL_Gamepad = null, pad_x: f32 = 0, pad_y: f32 = 0, @@ -1356,10 +1782,6 @@ const Gui = struct { }; fn setGuiFontName(g: *Gui, fallback: []const u8) void { - // Config says "effective", so ask the rasterizer what it accepted rather - // than echoing the picker label (a filename stem which need not be the - // face's own identity). Some old/synthetic faces have neither a - // PostScript nor family name; only those retain the known-good label. const name = if (c.ui_font_name(g.font)) |name_z| std.mem.span(name_z) else fallback; const len = @min(name.len, g.font_name.len); @memcpy(g.font_name[0..len], name[0..len]); @@ -1381,8 +1803,6 @@ fn acknowledgeGuiFont(g: *const Gui, core: *pardes.Pardes) void { } fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { - // The shipped face is the first fallback whenever Font selects a narrower - // user face. A second FT_Face is cheap and keeps both lifetimes independent. if (c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len))) |face| { g.fallbacks[0] = .{ .face = face }; g.fallback_count = 1; @@ -1392,7 +1812,7 @@ fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { defer arena_state.deinit(); for (fonts.fallbacks(arena_state.allocator())) |candidate| { if (g.fallback_count == g.fallbacks.len) break; - const bytes = look.readFile(gpa, candidate.path) catch continue; + const bytes = filesystem.readFile(gpa, candidate.path) catch continue; const len = std.math.cast(c_int, bytes.len) orelse { gpa.free(bytes); continue; @@ -1420,11 +1840,9 @@ fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont { const face = loaded.?.face; if (c.ui_font_has_glyph(face, @intCast(cp)) != 0) return face; } - // Preserve FreeType's useful .notdef box when no face has the codepoint. return g.font; } -/// A cell's on-screen rect: exactly cell_w×cell_h at (0,0). const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; const MouseCell = struct { col: u16, row: u16 }; const WindowGeometry = struct { @@ -1449,12 +1867,6 @@ fn windowGeometry(window: *c.SDL_Window) WindowGeometry { }; } -/// This window in whole cells, which is the grid the core is asked to be. The -/// one derivation of it: `pollFrame` follows the window with it every frame, -/// `refitFont` re-asks after Ctrl+/Ctrl- has moved the cell under it, and both -/// attach paths tell the session what this window can show with it. A window -/// that is not a whole number of cells across has to round the same way in all -/// four places or the last row lands off the bottom edge. const GridCells = struct { cols: u16, rows: u16 }; fn windowCells(g: *const Gui) GridCells { @@ -1494,47 +1906,27 @@ fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout { return .{ .w = tag_w, .h = @floatFromInt(g.cell_h), - // emitInstance still receives the canonical surface column. Offset - // the smaller grid so its column zero is the pane's physical left. .x_off = origin_col * (body_w - tag_w), .y_off = 0, }; } -/// Where a tagline cell's compact band begins. The origin rule itself is -/// `pardes.taglineOriginCol` — moved to the core so the AppKit shell can call -/// the SAME rule over the C ABI instead of advancing its tag rows on body -/// pitch, which is the second copy of this that already went wrong once (see -/// `taglineBandOffset`). -/// -/// `core` is null in an attached window, and then EVERY tagline cell takes the -/// last line's fallback: the wire carries cells, not the pane rects that placed -/// them, so there is no band origin to compact against. That is the same answer -/// `gridCellAtDimensions` reaches for the same reason, which is what keeps the -/// two honest — a click lands on the glyph it was aimed at, because both sides -/// map through the body grid. The visible cost is one tagline row's worth of -/// loose tracking. fn taglineLayoutForCell( g: *const Gui, core: ?*const pardes.Pardes, col: u16, row: u16, - track: ?pardes.panel_animation.Track, + track: ?pardes.layout.Track, ) CellLayout { if (row < pardes.TOPBAR_H) return compactTaglineLayout(g, 0); const p = core orelse return compactTaglineLayout(g, @floatFromInt(col)); return compactTaglineLayout(g, pardes.taglineOriginCol(p, col, row, track)); } -/// `panel_animation.Box.contains` under this file's older name. Kept as an -/// alias rather than renamed at three call sites so the predicate has exactly -/// one definition — it was a fourth copy of the same half-open cell test the -/// core, `taglineOriginCol` and ScenePostprocessor.swift all make. -const boxContains = pardes.panel_animation.Box.contains; +const boxContains = pardes.layout.Box.contains; -// EFFECT_CODE_PANEL_HOST_BEGIN const PaintBatch = struct { - track: ?pardes.panel_animation.Track = null, + track: ?pardes.layout.Track = null, cell_start: u32 = 0, cell_count: u32 = 0, image_start: u32 = 0, @@ -1542,21 +1934,15 @@ const PaintBatch = struct { }; const PaintPlan = struct { - // One static batch plus live tracks and presentation-only closing - // tombstones. Slot reuse can legitimately expose both for one pane id. batches: [pardes.MAX_PANES * 2 + 1]PaintBatch = @splat(.{}), len: usize = 1, }; -/// Painter order shared by cells and pixel attachments. Core hit testing walks -/// the reverse order, so the visually top panel receives the click too. -fn makePaintPlan(tracks: []const pardes.panel_animation.Track, has_diff: bool) PaintPlan { +fn makePaintPlan(tracks: []const pardes.layout.Track, has_diff: bool) PaintPlan { var plan: PaintPlan = .{}; - for ([_]pardes.panel_animation.Phase{ .moving, .opening, .closing }) |phase| { + for ([_]pardes.layout.Phase{ .moving, .opening, .closing }) |phase| { for (tracks) |track| { if (!track.active() or track.phase != phase) continue; - // These effects have no honest fallback without the frozen grid. - // Render the canonical frame rather than materializing garbage. if (track.effect.needsPreviousGrid() and !has_diff) continue; std.debug.assert(plan.len < plan.batches.len); plan.batches[plan.len].track = track; @@ -1580,21 +1966,21 @@ fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize { return 0; } -fn panelCellCoord(track: pardes.panel_animation.Track, col: u16, row: u16) u32 { +fn panelCellCoord(track: pardes.layout.Track, col: u16, row: u16) u32 { const source = track.contentBox(); const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x))); const y0: u16 = @intFromFloat(@max(0.0, @floor(source.y))); return @as(u32, row -| y0) << 16 | @as(u32, col -| x0); } -fn panelGridSize(track: pardes.panel_animation.Track) u32 { +fn panelGridSize(track: pardes.layout.Track) u32 { const source = track.contentBox(); const cols: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.w)))); const rows: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.h)))); return @as(u32, rows) << 16 | @as(u32, cols); } -fn ndcBox(box: pardes.panel_animation.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 { +fn ndcBox(box: pardes.layout.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 { const px0 = layout.x_off + box.x * layout.w; const py0 = layout.y_off + box.y * layout.h; const px1 = px0 + box.w * layout.w; @@ -1609,7 +1995,7 @@ fn ndcBox(box: pardes.panel_animation.Box, layout: CellLayout, win_w: f32, win_h fn setTransitionFields( instance: anytype, - track: ?pardes.panel_animation.Track, + track: ?pardes.layout.Track, layout: CellLayout, win_w: f32, win_h: f32, @@ -1645,24 +2031,12 @@ fn setTransitionFields( instance.serial = active.serial; instance.cell_coord = cell_coord; } -// EFFECT_CODE_PANEL_HOST_END - -// ===================================================================== -// entry -// ===================================================================== pub const run = runNative; -/// `attach` is `--attach[=]`: empty means "the session there is" (see -/// `detached_client.resolve`). It is a parameter rather than an `Options` field -/// because it says nothing to the core — this process does not have one when it -/// is set. fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u8) !void { const gpa = init.gpa; const env = init.environ_map; - // PARDES_TEST_GRID owns the process when it is set, and it is headless. - // There is no window to hand to a session, so refuse the combination - // rather than silently dropping the flag a harness meant. if (env.get("PARDES_TEST_GRID") != null) { if (attach != null) { log.err("--attach needs a window; PARDES_TEST_GRID is headless", .{}); @@ -1678,14 +2052,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u return error.SdlInit; } - // The trackpads only exist through SDL's built-in HIDAPI Steam Deck - // driver (it registers the two touchpads and disables "lizard mode"). - // It's default-on on Linux; pin it so intent is explicit. NOTE: this is - // NOT enough in Game Mode — Steam Input there hands the app a - // touchpad-less virtual gamepad instead of the real Neptune controller, - // so the pads go dead no matter what the app does. The only fix is to - // set "Disable Steam Input" on pardes (Steam -> Properties -> Controller), - // after which the real controller enumerates and the touchpad events flow. _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); // SDL_HINT_JOYSTICK_HIDAPI _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); // ..._STEAMDECK if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) { @@ -1694,11 +2060,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u } var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; - // A see-through buffer, so a theme with no background of its own shows the - // compositor's backdrop instead of `bg_default`. Asked for at CREATION - // because that is the only time it can be: X11 picks the 32-bit visual - // here, and the Wayland backend decides here whether to keep an opaque - // region on the surface. if (config.gui_transparent) win_flags |= c.SDL_WINDOW_TRANSPARENT; const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); @@ -1707,28 +2068,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| { _ = c.SDL_SetCursor(cur); } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); - // Keep the window's mouse ungrabbed: desktop users must be able to move - // the pointer out normally. The deck's virtual pointer is clamped and - // warped explicitly only when its controls move it (see dispatch and - // pollGamepad), so it does not need window-wide confinement. const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; - // A failed claim is not fatal: it means the compositor has no presentable - // Vulkan surface (no linux-dmabuf), which is the normal case under p9wl and - // other software/remote Wayland compositors. Rendering still works, so keep - // the device and present the readback through SDL_Renderer instead. - // PARDES_SOFT_PRESENT=1 takes that path on a compositor that could present, - // which is how the path is exercised without a remote display. - // - // A transparent window does not even attempt the claim. It is not a - // compositor's shortcoming and there is nothing to retry: SDL_gpu.c fails - // SDL_ClaimWindowForGPUDevice for SDL_WINDOW_TRANSPARENT unconditionally, - // because D3D12 has no transparent swapchain and the API says no - // everywhere rather than only where it must. SDL_Renderer's own vulkan and - // opengl backends do honour one, and that is the presenter this path - // already had. var soft_present = false; var soft_renderer: ?*c.SDL_Renderer = null; const force_soft = if (env.get("PARDES_SOFT_PRESENT")) |raw| @@ -1748,7 +2091,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u }; soft_present = true; } - // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync const present_mode: c.SDL_GPUPresentMode = blk: { if (soft_present) break :blk c.SDL_GPU_PRESENTMODE_VSYNC; if (env.get("PARDES_SDL_PRESENT")) |raw| { @@ -1771,15 +2113,11 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u if (!soft_present) { _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); } - // Without a claimed window there is no swapchain format to ask for, so - // pick a colour-target format the device does support; the readback and - // the SDL_Texture agree on it below. const swapchain_format = if (soft_present) softTargetFormat(device) else c.SDL_GetGPUSwapchainTextureFormat(device, window); - // ---- font + cell metrics ---- const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { log.err("ui_font_new failed", .{}); return error.FontInit; @@ -1800,7 +2138,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u _ = c.SDL_SyncWindow(window); } - // ---- glyph atlas (R8) + pipelines ---- var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM; @@ -1904,35 +2241,15 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer c.ui_font_free(g.font); defer gpa.free(g.scroll_edge); // grown on demand by stepScroll - // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); g.pen_x = cell_w; - // `--attach`: this process has a window and NO core. Everything above is - // the window and the rasterizer, which an attached frontend needs exactly - // as much as a whole session does; everything below is the core, which - // lives in the detached process (src/detached/). The branch is here so both - // leave by the same door — the GPU objects, the glyph atlas and the face - // are put away by the defers above whichever mode ran. if (attach) |requested| return attachRequested(gpa, &g, requested); - // ...and the same handover arrived at from the other side: the `Attach` - // builtin gives this window to a session mid-flight. `localSession` returns - // a CONNECTED client only, and by the time it does every pane shell, watch - // and mount of the local session is already away. var attached: ?detached_client.Client = null; try localSession(init, &g, opts_in, test_mode, &attached); if (attached) |*client| return attachedLoop(gpa, &g, client); } -/// The session that lives in THIS process: the core, its pane shells, its -/// watches, its acme filesystem and the loop that pumps them. A function of its -/// own rather than the tail of `runNative` because that makes its teardown a -/// scope exit instead of a second copy of the same twelve defers — and the -/// `Attach` builtin needs exactly that teardown, in exactly that LIFO order, -/// before an attached loop may draw on the same window. -/// -/// `attached` is how a connected client leaves: it is set only after a -/// handshake is in flight, which is what makes a failed `Attach` a no-op. fn localSession( init: std.process.Init, g: *Gui, @@ -1942,8 +2259,8 @@ fn localSession( ) !void { const io = init.io; const gpa = init.gpa; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.deinit(); pardes.image.start(io, allocs.image); // stb_image allocator for image panes if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); @@ -1952,13 +2269,6 @@ fn localSession( if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); } - // Live sessions initialize at the default 80x24 grid; the real window size - // arrives as a resize EVENT on the first loop pass. The core defers the - // shell greeting until after the first resize (so `ls` wraps to the real - // pane width) — pre-sizing at init would mean no resize ever fires and the - // greeting never runs (panes sat blank until the first interaction). - // Dump loads pre-size instead: replayed panes never greet, and sizing at - // init avoids reflowing their replayed content twice. var opts = opts_in; opts.image_allocator = allocs.image; opts.pdf_allocator = allocs.pdf; @@ -1972,30 +2282,20 @@ fn localSession( opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); } var core = if (opts.load_path) |lp| blk: { - const bytes = try @import("../look.zig").readFile(gpa, lp); + const bytes = try @import("../fs.zig").readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes); } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); - // SDL is itself a native-pixel backend. This is deliberately set after - // construction: argv image panes no longer freeze the startup capability - // into their PETSCII preference, so their first render emits attachments. core.native_images = true; observeGuiFont(g, core); syncTaglineFont(g, core); - // PATH, the bash banner and the prompt rc files, in the one order that - // works. Children borrow only these stable in-struct path buffers. - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see the host's spawnPane) var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - }; var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, @@ -2005,42 +2305,28 @@ fn localSession( }; defer { lsp_workers.wait(); - queue.close(&ptys, &gens); + queue.deinit(); } var pipe_tasks: PipeTasks = .{}; defer pipe_tasks.cancelAll(io); - // One watcher for every watched pane, opened here — before any thread - // exists — so the pre-loop drain below can already mark the file a - // positional path argument opened. `false`: this host parks a thread in it - // rather than polling it. -1 where there is no watcher to make: watchPane - // goes quiet and the core simply never gets a file_changed event. - var inotify_fd: c_int = file_watch.init(false); + var inotify_fd: c_int = file_watch.init(true); + var watch_reader: ?std.Thread = null; + var watch_stop: [2]c_int = .{ -1, -1 }; defer if (inotify_fd >= 0) { - // `stop` releases a kqueue wait (macos); the close ends the blocking - // read (linux). Both leave watchThread on its way out. + if (watch_reader) |thread| { + _ = host_io.writeFd(watch_stop[1], "x"); + thread.join(); + } + for (watch_stop) |fd| if (fd >= 0) { + _ = libc.close(fd); + }; file_watch.stop(inotify_fd); _ = libc.close(inotify_fd); inotify_fd = -1; }; var watches: file_watch.Table = @splat(null); - // The socket a pardes launched inside this one connects to (nested.zig). - // --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. - const sock_fd: c_int = if (opts.nested) -1 else nested.listen(); - defer nested.unlisten(sock_fd); - - // `--fs`: mounted before the initial spawns (they are the shells that need - // PARDES_FS) and before any thread of ours exists (the mount forks the - // setuid fusermount3 helper). Null covers both "no --fs" and "--fs but the - // mount failed"; the second is reported on a message row inside `start` and - // the session runs on without a filesystem. Teardown answers everything - // held, aborts the connection, unmounts and removes `/`; the - // parent stays, like nested.zig's socket directory. - var fs = fs_service.start(gpa, core); - // Covers the error paths only: the ordinary exit unmounts at the END OF - // THE LOOP instead, see there. - defer if (fs) |f| f.deinit(); + var fs = ninep_io.start(gpa, core); + defer if (fs) |f| f.deinit(gpa); var shell: Shell = .{ .core = core, @@ -2058,63 +2344,34 @@ fn localSession( .fs = fs, .test_mode = test_mode, }; + defer shell.shutdownPtys(); const host = shell.host(); - // `pump` installs this every pass; the pre-loop drain below happens - // outside one, so the initial spawns would otherwise reach the core's own - // virtual ptys instead of forking. core.host = host; - // initial spawns BEFORE any worker thread exists: forkpty from a - // multithreaded process can wedge the child before exec (see tty.zig). while (core.nextEffect()) |e| core.perform(e); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); - // ...and the one file watcher. Started even with nothing marked yet: the fd - // already exists and an unwatched inotify instance just parks in read(2) — - // one thread for the process, however many panes come and go. - if (inotify_fd >= 0) if (std.Thread.spawn(.{}, watchThread, .{ inotify_fd, &queue })) |th| th.detach() else |_| {}; - // ...and the nested-instance listener, detached like every other blocking - // worker here - if (sock_fd >= 0) if (std.Thread.spawn(.{}, lookThread, .{ gpa, sock_fd, &queue })) |th| th.detach() else |_| {}; - // ...and the /dev/fuse poller, which is the same kind of thread again — - // except joined by `Fs.deinit` rather than detached, because fuse.zig gives - // it a control pipe that CAN wake it out of poll(). - fs_service.wake(fs, &queue, wakeFs); + for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| + try spawnReader(gpa, pt, @intCast(id), gens[id], &queue); + if (inotify_fd >= 0) { + watch_stop = try stopPipe(); + watch_reader = try std.Thread.spawn(.{}, watchThread, .{ inotify_fd, watch_stop[0], &queue }); + } + if (fs) |f| try f.wakeThread(&queue, wakeFs); _ = c.SDL_StartTextInput(g.window); if (test_mode) setStdinRaw() catch {}; shell.threads_ok = true; - // Server state narration: reader threads → queue → drainQueue → the - // message row. Unset before the queue closes (see the defer above it). pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); - // The core owns the loop. This owns the two things a pump cannot do from - // inside itself, because both replace the whole session and are only safe - // BETWEEN iterations: Restore swaps the `Pardes`, and Attach retires it. while (!core.quit) { try core.pump(host); if (core.quit) break; // a session that ended does not restore into one - // Attach builtin: hand this window's screen to a detached session. - // - // GREET FIRST, SWAP SECOND, and that order IS the feature. - // `detached_client.attempt` resolves, connects AND waits for the - // `welcome`, and closes whatever it opened on every other outcome — so - // when this returns anything but `.greeted`, nothing below has run and - // this instance is exactly as it was: every pane, every shell, every - // unsaved buffer, the whole undo history. It says why on the row of the - // pane that ran the word and the session goes on. A half-torn-down - // editor is the one outcome an attach must never have, and `open` alone - // cannot rule it out — a `refuse .version` from a session built by the - // last `zig build` arrives AFTER the connect. if (core.takeAttach()) |req| { const geom = windowCells(g); const outcome = detached_client.attempt(gpa, req.name, geom.cols, geom.rows); switch (outcome) { .greeted => |client| { - // Greeted, so this session is over: the `break` runs the - // filesystem unmount below and then every defer above, and - // `runNative` picks the client up on the far side. attached.* = client; break; }, @@ -2124,24 +2381,19 @@ fn localSession( }, } } - // Restore builtin: swap in a core rebuilt from the dump; kill the live - // shells (their detached readers wake on child death; gens bumped so - // the stale eofs close the old fds without touching the replay panes) if (core.takeRestore()) |rp| blk: { - const bytes = look.readFile(gpa, rp) catch break :blk; + const bytes = filesystem.readFile(gpa, rp) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; + }; defer gpa.free(bytes); - var o = core.opts; - o.cols = core.screen_w; - o.rows = core.screen_h; // pre-size: dump panes never greet - const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk; - for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.kill(pt.pid, libc.SIG.KILL); - slot.* = null; + const nc = core.restore(bytes) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; }; - for (&gens) |*g2| g2.* +%= 1; - // the replay core's pane ids mean new things, and the dying core's - // `watch off` effects go into a queue nobody drains — drop the lot - // here. The new core emits its own `on`s as it builds its panes. + pipe_tasks.cancelAll(io); + queue.discardCompletions(); + shell.stopPtys(); for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( inotify_fd, &watches, @@ -2156,6 +2408,7 @@ fn localSession( g.prepared_images.clearRetainingCapacity(); nc.native_images = true; nc.host = host; + if (fs) |f| f.reset(core); core.deinit(); core = nc; shell.core = nc; @@ -2165,32 +2418,15 @@ fn localSession( } } - // THE FILESYSTEM GOES FIRST, ahead of every deferred teardown below: a - // session that has decided to exit must not spend its teardown holding a - // mount nobody is serving, so a client blocked on `/event` when the - // last pane is deleted through `ctl` gets ENOTCONN at once. if (fs) |f| { - f.deinit(); + f.deinit(gpa); fs = null; shell.fs = null; } } -// ===================================================================== -// --attach: a window, a socket, and no core -// ===================================================================== - -/// What to say when an attach did not happen. One function for both callers -/// because it is one set of outcomes: `--attach` logs it to a terminal it has -/// not drawn over yet, the `Attach` word puts it on the pane's message row, and -/// neither should be inventing its own wording for `refuse .version`. -/// -/// `requested` is the word a person typed, empty for "the session that is -/// there" — which is the whole difference between "no session called work" and -/// "nothing is detached". fn attachFailure(buf: []u8, outcome: detached_client.Attempt, requested: []const u8) []const u8 { return switch (outcome) { - // The caller took this one and never asks. .greeted => unreachable, .no_session => if (requested.len != 0) std.fmt.bufPrint(buf, "Attach: no detached session called '{s}'", .{requested}) catch @@ -2210,12 +2446,6 @@ fn attachFailure(buf: []u8, outcome: detached_client.Attempt, requested: []const }; } -/// `--attach[=]`: this window is a frontend from its first frame. Split -/// from `attachedLoop` because the two arrive with different evidence — a -/// command line has a person at a terminal to tell when there is nothing to -/// attach to and a process exit status to carry it, while an `Attach` inside a -/// session has a pane's message row and a live editor to leave standing. Both -/// reach `attachedLoop` with a GREETED client and never with less. fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void { const geom = windowCells(g); const outcome = detached_client.attempt(gpa, requested, geom.cols, geom.rows); @@ -2225,13 +2455,8 @@ fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void return attachedLoop(gpa, g, &client); }, else => { - // The window exists but has drawn nothing, so stderr is still the - // only place a person is looking; the wording is the message row's, - // because it is the same set of outcomes. var mbuf: [256]u8 = undefined; log.err("{s}", .{attachFailure(&mbuf, outcome, requested)}); - // ...and the exit status keeps the distinction the sentence makes, - // for whatever launched this window. return switch (outcome) { .no_session => error.NoSession, .ambiguous => error.AmbiguousSession, @@ -2244,69 +2469,27 @@ fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void } } -/// The whole of an attached window: input and screen, and nothing else. SDL -/// events become `pardes.Event`s on the socket through the same `dispatch` a -/// local session uses; frames come back and go through the same `renderFrame`. -/// It forks no shell, writes no file and watches no path — the session process -/// does all of that now — so the only effects still arriving here are the three -/// that need a human's own display. fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client) !void { - // The `bye` is a courtesy: the session survives a frontend that simply - // dies, but seven bytes turn "the peer vanished" into "the peer left" in - // its log. defer client.detach(); - // The window may have arrived here from `localSession`, where this was - // already called; SDL_StartTextInput is idempotent, and calling it is what - // makes the `--attach`-from-startup path receive SDL_EVENT_TEXT_INPUT at - // all. _ = c.SDL_StartTextInput(g.window); var in: Input = .{ .client = client }; - // A frame is the only thing that makes this window redraw. There is no - // animation clock and no core asking for a tick — the session spends both - // and sends the result — so a pass that saw nothing new presents nothing. var dirty = false; var geom = windowCells(g); while (true) { const link = client.wait(detached_client.poll_ms); - // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in the - // same call that read the last bytes, and the last bytes are the - // session's `quit`: `fill` appends every chunk and only then sees the - // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly - // this reason, and honouring it is what makes an ordinary `Kill` close - // every attached window by the front door instead of leaving whichever - // one lost the race reporting a broken link. while (true) { const msg = (try client.next()) orelse break; switch (msg) { - // A greeting cannot arrive twice and a refusal cannot follow - // one at all — `detached_client.attempt` consumed the welcome - // before this loop was entered, and the union is exhaustive, so - // these two arms exist to say that rather than to do anything. - // A session that sent either here is not speaking this protocol. .welcome => {}, .refuse => |why| { log.err("session refused an already-greeted frontend: {t}", .{why}); return error.Refused; }, - // Applied too — `grid` and `cursor` are current by the time - // this lands, so all that is left is putting them on screen. .frame => dirty = true, - // THE SESSION ENDED (`Kill`): every frontend goes with it. .quit => return, - // ...and `Detach`: THIS frontend was asked to leave and the - // session is carrying on without it, panes and shells and undo - // history intact, with whatever other frontends are attached - // still looking at it. Leaving because a person asked is a - // SUCCESS — hence a plain return and not the `error.Refused` - // above — and the deferred `client.detach()` still sends the - // `bye`, so the session logs a peer that left rather than one - // that vanished. The window closes because `runNative` returns. .detach => return, .set_clipboard => |text| putClipboard(gpa, text), - // The answer is not a reply message: it is an ordinary paste - // event on the way back, which is the same asynchronous shape - // `pull_read_clipboard` already has in process. .read_clipboard => if (takeClipboard()) |text| { defer c.SDL_free(text.ptr); in.post(.{ .paste = text }); @@ -2319,15 +2502,9 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client var sev = std.mem.zeroes(c.SDL_Event); while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev); pollGamepad(g, &in); - // One check for the whole burst rather than one per event: `Input.post` - // stops sending at the first failure, so this is where a dead link is - // reported and there is nothing left in flight to lose. if (in.lost) |err| return err; if (in.quit) return; - // What this WINDOW can show, which is not a promise about the next - // frame: with several frontends attached the session grid is the - // smallest common one (client.zig GEOMETRY). const now = windowCells(g); if (now.cols != geom.cols or now.rows != geom.rows) { geom = now; @@ -2339,17 +2516,6 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client } } -/// The frame the session sent, through the renderer this window already has. -/// The `Surface` is built OVER the client's grid rather than copied into one: -/// `renderFrame` reads cells and never writes them, and a full frame of a large -/// grid is 1.6 MiB. -/// -/// Three of a session's own surface fields are absent here and each absence is -/// load-bearing. No panel tracks: a pane transition is composed by the process -/// that owns the panes and what arrives is the composed result, so `makePaintPlan` -/// builds its single static batch. No pixel attachments: this wire carries no -/// images. No previous cells: `hasPanelDiff` is therefore false and the whole -/// old/new layer machinery stays out of the plan. fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void { var surface: pardes.Surface = .{ .cols = client.cols, @@ -2357,28 +2523,15 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien .cells = client.grid.items, .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, }; - // Two of `renderFrame`'s arguments are chrome colours the session resolved - // off a theme that is not on the wire. Both want `chromeTheme().tag_bg`, - // and the frame carries it exactly: row zero IS a full-width band that - // pardes.zig fills with that colour unconditionally, which is what - // `frameChromeBg` reads. The topbar rule then wears the band's own colour, - // joining the two bands directly the way - // `config.gui_topbar_pane_border_px = 0` does — a rule whose colour we - // would have to invent is worse than no rule. const chrome = frameChromeBg(&surface); _ = renderFrame( g, gpa, null, &surface, - // No theme background either, so every cell the session left at its - // default wears this window's own ground — the same answer a terminal - // frontend gives by writing a default cell. null, chrome, chrome, - // Crt/Ripple/Glitch are core settings and the core is elsewhere; so is - // Debug, which is what the touch overlay hangs off. .{}, false, ) catch |err| blk: { @@ -2387,19 +2540,6 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien }; } -/// The tagline background this frame was painted with, read off the frame. An -/// attached window has no core to ask for `chromeTheme().tag_bg`, and -/// `renderFrame` wants it twice: as the chrome band under the topbar's compact -/// cells, and as the sub-cell strip `buildOverlay` extends below a bottom -/// tagline band when the window is not a whole number of cells tall. -/// -/// ROW ZERO is where it is read, and that is not a guess: the topbar is filled -/// edge to edge with `chrome.tag_bg` at `font_role = .tagline` on every frame -/// (pardes.zig `renderTopbar`), so its first tagline cell IS the colour. The -/// last row was the wrong place to look and cost a visibly dark band — a -/// session whose bottom row is pane BODY has no tagline cell there at all, so -/// the scan fell through to `bg_default` and painted the topbar's remainder -/// and every tag-cell gap near-black. fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { if (surface.rows == 0 or surface.cols == 0) return bg_default; for (surface.cells[0..surface.cols]) |cell| { @@ -2413,15 +2553,13 @@ fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { return bg_default; } -/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in, -/// the rendered Surface out as text frames (same framing as the prototype). fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { const io = init.io; const gpa = init.gpa; const env = init.environ_map; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.deinit(); pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); @@ -2443,26 +2581,19 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { opts.rows = grid_rows; } const core = if (opts.load_path) |lp| blk: { - const bytes = try @import("../look.zig").readFile(gpa, lp); + const bytes = try @import("../fs.zig").readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes); } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); - // The requested grid arrives as a resize EVENT (not init opts) so the core - // counts it; an integrated shell releases its greeting at OSC 133 B. if (opts.load_path == null) core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see the host's spawnPane) var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - }; var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, @@ -2472,19 +2603,13 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { }; defer { lsp_workers.wait(); - queue.close(&ptys, &gens); + queue.deinit(); } var pipe_tasks: PipeTasks = .{}; defer pipe_tasks.cancelAll(io); - // no inotify here on purpose: this mode's whole contract is one frame per - // scripted input event, and a reload that arrives on its own clock would - // put a frame in the stream nothing asked for. -1 makes watchPane a no-op. var watches: file_watch.Table = @splat(null); - // The filesystem IS served here, unlike the file watcher above: `--fs=` - // names a predictable mount point precisely so a snapshot can drive this - // mode through it. No poll thread though — see gridPollFrame. - const fs = fs_service.start(gpa, core); - defer if (fs) |f| f.deinit(); + const fs = ninep_io.start(gpa, core); + defer if (fs) |f| f.deinit(gpa); var shell: Shell = .{ .core = core, .io = io, @@ -2499,36 +2624,26 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { .watches = &watches, .fs = fs, }; + defer shell.shutdownPtys(); const host = shell.host(); - // The core owns the loop here too, but not the scripted stdin: EOF ends - // the session and nothing may be drawn after it, so this reads its own - // input and hands `pump` a pass that has already been fed. core.host = host; while (core.nextEffect()) |e| core.perform(e); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); + for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| + try spawnReader(gpa, pt, @intCast(id), gens[id], &queue); shell.threads_ok = true; pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort - // First frame before touching stdin, so `printf '' | pardes` still shows - // one. Its arena is released before the core's own ever allocates: both - // draw from the one stack-fallback buffer, and a live arena on top of it - // would push every later frame out to the heap. { var first: std.heap.ArenaAllocator = .init(allocs.frame); defer first.deinit(); const surface = try core.render(first.allocator()); try dumpGrid(gpa, surface); - // The grid protocol writes canonical cells; panel tracks are metadata - // for a compositor it deliberately does not run. core.acknowledgePanelPresentation(&.{}); } while (!core.quit) { - // The two halves of a pass's input, in the order the flat loop had - // them: the scripted feed, then whatever the reader threads handed - // over. `pump` has no `wait_input` to do it in — see `grid_vtable`. var in: Input = .{ .core = core }; const r = try shell.feed.pump(gpa, &in, null); if (r.eof) break; @@ -2537,11 +2652,6 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { try core.pump(host); if (shell.dump_err) |err| return err; } - // The last frame is outside `pump` for the same reason the first one is: - // `pump` returns before drawing a quitting pass, and this stream records - // the empty grid a closed session leaves behind. Same three host methods - // in the same order, so the idle rule and the acknowledgement stay in one - // place — only the render is out here. if (core.quit) { gridPollFrame(&shell); var last: std.heap.ArenaAllocator = .init(allocs.frame); @@ -2552,12 +2662,6 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { } } -// ===================================================================== -// test-mode stdin: terminal escape sequences (vaxis.Parser) → core events, -// plus the private synthetic finger OSC: -// ESC ] 777;finger;;;;; BEL (normalized 0..1) -// ===================================================================== - const StdinFeed = struct { parser: vaxis.Parser = .{}, cache: vaxis.GraphemeCache = .{}, @@ -2568,10 +2672,8 @@ const StdinFeed = struct { const Result = struct { eof: bool = false, n_events: usize = 0 }; - /// Poll stdin briefly and translate what arrived. `g` is null in grid mode. fn pump(f: *StdinFeed, gpa: std.mem.Allocator, in: *Input, g: ?*Gui) !Result { var out: Result = .{}; - // a pty stdin also carries the winsize; poll it in place of SIGWINCH var ws: posix.winsize = std.mem.zeroes(posix.winsize); if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and @@ -2600,7 +2702,6 @@ const StdinFeed = struct { var seq_start: usize = 0; while (seq_start < len) { - // private finger OSC first (vaxis would swallow it as unknown OSC) const prefix = "\x1b]777;finger;"; if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) { const body = f.buf[seq_start + prefix.len .. len]; @@ -2707,7 +2808,6 @@ const StdinFeed = struct { fn applyResize(f: *StdinFeed, in: *Input, g: ?*Gui, cols: u16, rows: u16) void { _ = f; if (g) |gp| { - // capture mode: resize the window; the frame loop resizes the core _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h)); _ = c.SDL_SyncWindow(gp.window); } else { @@ -2762,7 +2862,6 @@ fn setStdinRaw() !void { try posix.tcsetattr(0, .NOW, term); } -// PARDES_TEST_GRID: one text frame per render, prototype-compatible framing. fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { if (surface.cols == 0 or surface.rows == 0) return; const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; @@ -2809,55 +2908,22 @@ fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { _ = host_io.writeFd(1, frame); } -// ===================================================================== -// SDL event dispatch -// ===================================================================== - -/// Where a translated SDL event goes, and the only thing the input path knows -/// about the session it belongs to. The local shell hands events to the -/// `Pardes` in this process; an attached window puts them on a socket, because -/// the core is in the detached one. Everything between an SDL_Event and a -/// `pardes.Event` — the keycode table, the pointer/cell mapping, the touch -/// machine, the Steam Deck mapping — is ONE translation serving both, and this -/// is what keeps it from becoming two. const Input = struct { - /// Null in an attached window, and this is also the flag the renderer- and - /// pointer-side functions test: no core means no pane rects and no theme, - /// and each of those has a documented body-grid fallback. core: ?*pardes.Pardes = null, - /// Null in a local session. Exactly one of the two is ever set. client: ?*detached_client.Client = null, - /// Attached only: the window was closed. A local session says the same - /// thing by writing `core.quit`, which the core owns and this must not - /// shadow. quit: bool = false, - /// Attached only: a send failed, which means this window has lost its - /// session. Recorded rather than returned because `dispatch` is called from - /// inside an SDL drain with no error path, and a dead link does not need - /// reporting once per event in the burst. lost: ?anyerror = null, - /// One translated event on its way to the core, wherever the core is. fn post(in: *Input, ev: pardes.Event) void { if (in.core) |core| return core.update(ev); const client = in.client orelse return; - // Nothing more goes out after the first failure: the rest of this - // burst would each fail the same way, and the loop is about to leave. if (in.lost != null) return; client.send(.{ .event = ev }) catch |err| switch (err) { - // A message this protocol cannot carry is not a link that has - // died. The one event here that can reach `wire.max_payload` is a - // paste of a 16 MiB clipboard, and dropping it beats ending a - // session over it. error.Overlong, error.NoSpace => {}, else => in.lost = err, }; } - /// The window asked to close. In a session that ends the session; in an - /// attached window it ends this frontend and nothing else — the panes, the - /// shells and the undo history are in the other process and outlive it, - /// which is the whole point of `--detach`. fn close(in: *Input) void { if (in.core) |core| core.quit = true; in.quit = true; @@ -2872,42 +2938,9 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_mapped = false; in.post(.pointer_leave); }, - // window resizes are picked up by the per-frame grid check c.SDL_EVENT_KEY_DOWN => { g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; - // Ctrl+ / Ctrl-: the font size. Here rather than in keyDown - // because it is the shell's business and not the core's — the - // core has no font, and an attached window has no core at all yet - // still resizes its own text — and because this is the only side - // of the wall where `g` is in scope anyway. - // - // SIX keycodes for two keys, and every one of them is a key - // somebody actually presses: - // - `+` on a US layout IS Shift-`=`, and SDL reports the - // UNSHIFTED keycode, so Ctrl-+ arrives as SDLK_EQUALS. Binding - // only SDLK_PLUS is the usual way to ship this dead. - // - SDLK_PLUS is nonetheless real: on the German/Nordic layouts - // `+` is its own unshifted key. Same for `_` under `-`. - // - the numpad is separate. SDL_HINT_KEYCODE_OPTIONS defaults to - // "french_numbers,latin_letters" — no "hide_numpad" — so KP_+ - // stays SDLK_KP_PLUS (0x40000057) forever and never reaches - // keyDown's `sym < 128` line at all. - // - // Nothing is taken away from anyone by claiming these. forwardKey - // encodes Ctrl only for a-z, A-Z, `@` and `[`..`_`, and both `=` - // (0x3d) and `-` (0x2d) fall below that last range, so a pane in - // tty mode already sent the pty NO bytes for either — including - // Ctrl-Shift-minus, which arrives here as SDLK_MINUS and reached - // the core as `-`, never as the `_` that would have been 0x1f. No - // chord in config.zig pairs ctrl with any of these codepoints - // either (`=` is Format and `_` is trim_sels, both unmodified; - // Alt-- and Alt-_ are the selection merges). And the numpad pair - // did nothing at all: keyDown drops every sym above 128. - // - // Returning here is the whole interception, with no TEXT_INPUT - // twin to also swallow: SDL only sends text when neither ctrl nor - // alt is down (SDL_x11events.c, `!(SDL_GetModState() & (CTRL|ALT))`). const step: f32 = if (!g.live_ctrl) 0 else switch (sev.key.key) { c.SDLK_EQUALS, c.SDLK_PLUS, c.SDLK_KP_PLUS => font_px_step, c.SDLK_MINUS, c.SDLK_UNDERSCORE, c.SDLK_KP_MINUS => -font_px_step, @@ -2915,14 +2948,9 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { }; if (step != 0) { const want = std.math.clamp(g.px + step, font_px_min, font_px_max); - // at either end the key is inert rather than a re-raster of - // the size already on screen if (want != g.px) { g.px = want; refitFont(g, in.core); - // Attached, the new grid reaches the session as the - // ordinary window-geometry check on the next pass, and - // there is no local Font state to observe either. if (in.core) |core| observeGuiFont(g, core); } return; @@ -2939,13 +2967,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { while (tptr[tlen] != 0) : (tlen += 1) {} if (tlen == 0) return; const text: []const u8 = tptr[0..tlen]; - // ONE event is not one codepoint. An IME commit arrives whole — - // the entire phrase the candidate window was holding — and so does - // anything composed (dead keys, `Ctrl-Shift-u`, a compose-key - // sequence that resolves to more than one scalar). Decoding only - // text[0] dropped the rest on the floor, silently. Validate the - // whole string first so a truncated or malformed sequence costs - // nothing rather than half a phrase already forwarded. const view = std.unicode.Utf8View.init(text) catch return; var it = view.iterator(); var at: usize = 0; @@ -2966,8 +2987,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_present = true; const mc = mouseCell(g, in.core, b.x, b.y) orelse { g.pointer_mapped = false; - // A release outside the visible CRT tube still ends a drag at - // its last real cell; a press on black margin is inert. if (!b.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{ .button = button, .kind = .release, @@ -2985,20 +3004,15 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .kind = if (b.down) .press else .release, .col = mc.col, .row = mc.row, - // asked of SDL directly rather than read off g.live_ctrl: - // that one is bookkeeping from KEY events, and a ctrl-click - // with no key pressed since startup would miss it .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, }, }); }, c.SDL_EVENT_MOUSE_MOTION => { const m = sev.motion; - // pad cursor continues from wherever the pointer last was g.pad_x = m.x; g.pad_y = m.y; g.pointer_present = true; - // drag = motion with a button held (selection extension keys off it) const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0) .left else if ((m.state & c.SDL_BUTTON_MMASK) != 0) @@ -3036,20 +3050,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_cell = mc; if (w.y != 0 and std.math.isFinite(w.y)) { if (in.core) |core| { - // Preserve SDL's floating-point distance. Input events drained - // in this loop naturally form one render batch; stepScroll - // applies their exact sum and tells the core only about whole - // row boundaries. One accumulator belongs to one pane, so a - // wheel event over another pane first retires the old offset. const hit: ?usize = for (core.panes, 0..) |slot, i| { if (slot == null) continue; const r = core.rects[i]; if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; } else null; if (g.scroll_pane) |old| if (hit == null or hit.? != old) { - // There is one fractional overlay, not one per pane. Retire - // the old one at its already boundary-rounded core state; - // carrying its lag into `hit` would move the wrong pane. resetScroll(g); }; if (hit) |id| { @@ -3058,9 +3064,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { else false; if (pdf_target) { - // PDF placements live in physical document space, so - // preserve SDL's raw magnitude directly instead of - // quantizing through synthetic wheel buttons/rows. resetScroll(g); in.post(.{ .pdf_scroll = .{ .pane = @intCast(id), @@ -3074,13 +3077,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } } } else { - // ATTACHED: no pane rect ever reaches this window, so there - // is nothing to slide a fractional row against — the - // session owns the panes and composes what is painted here. - // Accumulate SDL's exact distance (a precision touchpad - // sends fractions of a row) in the same field `stepScroll` - // would have drained, and hand the session the whole rows, - // which is all `Event.mouse` has ever been able to say. g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); while (g.scroll_delta >= 1) : (g.scroll_delta -= 1) in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row } }); @@ -3127,8 +3123,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } }, c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }), - // ---- steamdeck: first gamepad drives a virtual mouse (buttons here, - // axes polled per frame in pollGamepad) ---- c.SDL_EVENT_GAMEPAD_ADDED => { if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which); }, @@ -3138,8 +3132,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.gamepad = null; } }, - // deck.zig maps buttons/triggers/trackpads to pointer, clicks, - // wheel, keys and rumble; this arm just applies its actions c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP, c.SDL_EVENT_GAMEPAD_AXIS_MOTION, @@ -3147,8 +3139,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION, c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP, => { - // `pad_input` and not `in`: this file's `Input` is the event sink - // above, and deck.Input is a controller reading. const pad_input: deck.Input = switch (sev.type) { c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{ .idx = sev.gbutton.button, @@ -3175,14 +3165,10 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .move => |mv| { const geometry = windowGeometry(g.window); const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry); - // deck.Action.move is in physical screen pixels. Keep the - // stored/warped cursor in SDL window coordinates. const dx = mv.dx * geometry.window_w / geometry.pixel_w; const dy = mv.dy * geometry.window_h / geometry.pixel_h; g.pad_x = std.math.clamp(g.pad_x + dx, 0, geometry.window_w - 1); g.pad_y = std.math.clamp(g.pad_y + dy, 0, geometry.window_h - 1); - // the SDL cursor (plan9 arrow) rides along, so the pad - // cursor and a hardware mouse are one visible pointer c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); g.pointer_present = true; const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse { @@ -3194,8 +3180,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_cell = mc; if (old) |previous| if (mc.col == previous.col and mc.row == previous.row) continue; - // moving with a click held drags, so selections stretch - // (a firm right-pad press drags-selects like a laptop pad) const held = heldPointerButton(g); in.post(.{ .mouse = .{ .button = held orelse .none, @@ -3213,9 +3197,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_present = true; const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse { g.pointer_mapped = false; - // Mirror hardware mouse releases: black CRT margins - // are inert for presses, but cannot strand a drag whose - // button was pressed over the visible tube. if (!ck.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{ .button = button, .kind = .release, @@ -3260,20 +3241,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .cap_n => .{ .cp = 'N', .text = "N" }, .enter => .{ .cp = pardes.Key.enter }, .tab => .{ .cp = pardes.Key.tab }, - // back paddle: flip tty mode. `--tty-toggle` moves the - // ctrl chord and is the SESSION's option, so an - // attached window — which cannot know it and has no - // core to ask — sends the spelling that cannot be - // reconfigured instead: `config.tty_toggle_alt`, which - // pardes.zig honours beside the ctrl chord for exactly - // the hosts that can express it. .tty_toggle => if (in.core) |core| .{ .cp = core.opts.tty_toggle, .ctrl = true } else .{ .cp = config.tty_toggle_alt[0].cp, .shift = true }, }, }), - // a brief gentle ack for execute/look, not a buzz .rumble => if (g.gamepad) |pad| { _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80); }, @@ -3283,8 +3256,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } } -/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as -/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly). fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; const alt = (mod & c.SDL_KMOD_ALT) != 0; @@ -3305,8 +3276,6 @@ fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { c.SDLK_PAGEDOWN => pardes.Key.page_down, c.SDLK_DELETE => pardes.Key.delete, else => blk: { - // letters / digits / punctuation only as a modifier shortcut; - // plain printable (incl. space) goes via TEXT_INPUT if (!(ctrl or alt or gui_mod)) break :blk 0; if (sym < 128 and sym >= ' ') break :blk @intCast(sym); break :blk 0; @@ -3321,13 +3290,6 @@ fn pixelCell(px: f32, cell: u32) u16 { return @intFromFloat(@min(idx, 10_000)); } -/// Which grid cell a physical point is in. The COLUMN rule is -/// `pardes.gridColAt` — the inverse of the compact tagline layout, and in the -/// core beside it so the two cannot be compacted independently. `core` is null -/// in an attached window, and then the pane loop inside it is skipped and the -/// body grid answers: the same fallback `taglineLayoutForCell` takes for the -/// same missing fact, which is what makes a click on an attached tagline land -/// on the glyph it was aimed at. fn gridCellAtDimensions( core: ?*const pardes.Pardes, x: f32, @@ -3385,8 +3347,6 @@ fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: return gridCellAtPixels(g, core, mapped.x, mapped.y); } -/// SDL window coords → the scene cell displayed at that physical point. -/// Mouse, touch and the Deck pointer all share the same CRT/ripple/glitch map. fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell { return mouseCellWithGeometry(g, core, x, y, windowGeometry(g.window)); } @@ -3399,9 +3359,6 @@ fn heldPointerButton(g: *const Gui) ?pardes.Mouse.Button { return null; } -/// Reconcile the retained physical point with the exact scene image the GPU -/// just accepted. Same-cell frames are deliberately silent: otherwise a 60 Hz -/// scene would continuously reset the core's hover debounce. fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void { if (!g.pointer_present) return; const mc = mouseCell(g, core, g.pad_x, g.pad_y) orelse { @@ -3423,28 +3380,19 @@ fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void { } }); } -/// steamdeck support: poll the sticks each frame, mirroring the trackpads — -/// RIGHT stick moves the virtual cursor at ~cell granularity (emits -/// button-less motion so hover works), LEFT stick accumulates into wheel -/// ticks (both axes: vertical + horizontal) at the cursor position. fn pollGamepad(g: *Gui, in: *Input) void { const pad = g.gamepad orelse return; const geometry = windowGeometry(g.window); const deadzone: f32 = 8000; - // right stick = pointer const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX)); const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY)); const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry); - // full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the - // mouse-move sensitivity knob — raise for a faster pointer) const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4; const speed_x = speed * geometry.window_w / geometry.pixel_w; const speed_y = speed * geometry.window_h / geometry.pixel_h; const pointer_moved = @abs(ax) > deadzone or @abs(ay) > deadzone; if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed_x, 0, geometry.window_w - 1); if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed_y, 0, geometry.window_h - 1); - // only on actual stick movement — an unconditional per-frame warp would - // pin the pointer and fight any hardware mouse if (pointer_moved) { g.pointer_present = true; c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); @@ -3467,7 +3415,6 @@ fn pollGamepad(g: *Gui, in: *Input) void { } }); } } - // left stick = scroll (both axes) const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX)); const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY)); if (@abs(ly) > deadzone) g.pad_scroll += ly / 32767.0 * 0.15; @@ -3484,110 +3431,140 @@ fn pollGamepad(g: *Gui, in: *Input) void { } } -// ===================================================================== -// the host seam — everything the core cannot do itself: ptys, files, the -// desktop, pixels, and the one place this process is allowed to sleep. -// ===================================================================== - -/// The state the host methods below need. `gui` is null in grid test mode: -/// no SDL, so no clipboard and no pixels, and the frames go out as text. const Shell = struct { - /// Reassigned by Restore, which is why the loop pumps rather than runs: - /// a swap is only safe BETWEEN iterations. core: *pardes.Pardes, gui: ?*Gui = null, io: std.Io, gpa: std.mem.Allocator, - /// acme's control filesystem for this session, or null when `--fs` was not - /// given (or its mount failed, or this is the headless grid harness, which - /// serves nothing). Owned by `run`. - fs: ?*fuse.Fs = null, + fs: ?*ninep_io.Listener = null, lsp_allocator: std.mem.Allocator, - prompt_rcs: *const shell_bin.PromptRcs, + prompt_rcs: *const host_io.Shell.PromptFiles, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, queue: *Queue, pipe_tasks: *PipeTasks, inotify_fd: c_int, watches: *file_watch.Table, - /// worker threads exist. The pre-loop drain forks before any of them do: - /// forkpty from a multithreaded process can wedge the child before exec. threads_ok: bool = false, - /// PARDES_TEST: input is stdin escape sequences, not SDL events test_mode: bool = false, feed: StdinFeed = .{}, - /// what the last present actually put on screen, and the frame it drew: - /// post_present may only acknowledge a frame the user has seen. presented: bool = false, surface: ?*pardes.Surface = null, - /// `pump` spends no animation time; this is where the display clock does. animation_clock: AnimationClock = .{}, - /// Whether this pass observed any input. Only the grid harness reads it: - /// its contract is one frame per scripted input event, so a pass that saw - /// nothing writes nothing. saw_event: bool = false, - /// Grid mode only: a failed write to the frame stream. Kept rather than - /// swallowed because a `present` cannot fail and the harness must. dump_err: ?anyerror = null, + retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), + + fn reap(s: *Shell) void { + const now = shellClock(); + for (&s.retired_shells) |*child| reapShell(&child.pid, &child.kill_at, now); + for (s.ptys) |*slot| if (slot.*) |*pt| if (pt.fd < 0) { + reapShell(&pt.pid, &pt.kill_at, now); + if (pt.pid == 0) slot.* = null; + }; + } + + fn closePty(s: *Shell, pane: u8) void { + const pt = if (s.ptys[pane]) |*pt| pt else return; + if (pt.fd < 0) return; + s.queue.cancelReader(pane, s.gens[pane]); + if (pt.reader) |thread| { + _ = host_io.writeFd(pt.stop[1], "x"); + thread.join(); + pt.reader = null; + for (pt.stop) |fd| _ = libc.close(fd); + pt.stop = .{ -1, -1 }; + } + _ = libc.close(pt.fd); + pt.fd = -1; + const now = shellClock(); + reapShell(&pt.pid, &pt.kill_at, now); + if (pt.pid == 0) { + s.ptys[pane] = null; + return; + } + _ = libc.kill(pt.pid, libc.SIG.HUP); + pt.kill_at = now + 100; + for (&s.retired_shells) |*child| if (child.pid == 0) { + child.* = .{ .pid = pt.pid, .kill_at = pt.kill_at }; + s.ptys[pane] = null; + return; + }; + } + + fn stopPtys(s: *Shell) void { + for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); + for (s.gens) |*gen| gen.* +%= 1; + s.reap(); + } + + fn shutdownPtys(s: *Shell) void { + s.stopPtys(); + for (s.retired_shells) |child| if (child.pid > 0) { + _ = libc.kill(child.pid, libc.SIG.KILL); + }; + for (s.ptys) |slot| if (slot) |pt| if (pt.pid > 0) { + _ = libc.kill(pt.pid, libc.SIG.KILL); + }; + for (&s.retired_shells) |*child| if (child.pid > 0) { + while (libc.waitpid(child.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + child.* = .{}; + }; + for (s.ptys) |*slot| if (slot.*) |pt| { + while (libc.waitpid(pt.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + slot.* = null; + }; + } + + fn reconcilePtys(s: *Shell) void { + s.reap(); + for (s.ptys, 0..) |slot, pane| if (slot) |pt| { + if (pt.fd < 0) continue; + const current = s.core.panes[pane]; + if (current == null or current.?.serial != pt.serial or !current.?.isTerminal()) + s.closePty(@intCast(pane)); + }; + } fn host(s: *Shell) pardes.Host { return .{ .ctx = s, .vtable = if (s.gui == null) &grid_vtable else &vtable }; } const vtable: pardes.Host.VTable = .{ - .pull_wait_input = waitInput, - .push_present = present, - .push_post_present = postPresent, - .push_poll_frame = pollFrame, - .push_spawn = spawnPane, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .pull_lsp = lsp, - .pull_pipe = pipe, - .push_fs_reply = fsReply, + .wait_input = waitInput, + .present = present, + .post_present = postPresent, + .poll_frame = pollFrame, + .spawn = spawnPane, + .pty_write = ptyWrite, + .pty_resize = ptyResize, + .pty_signal = ptySignal, + .tty_taken = ttyTaken, + .write_file = writeFile, + .write_dump = writeDump, + .watch_file = watchFile, + .watch_theme = watchTheme, + .dump_themes = dumpThemes, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, + .lsp = lsp, + .pipe = pipe, }; - /// The headless grid harness. It reads its scripted stdin itself, because - /// EOF ends the session and nothing may be drawn after it — so there is no - /// `wait_input` here, and this process never sleeps in grid mode. It starts - /// neither the watcher nor the nested listener, so `drainQueue`'s - /// `files_changed` and `command` arms cannot fire behind it. - /// - /// And it has NO SDL: `SDL_Init` is never called on this path, so the two - /// desktop-clipboard methods are nulled rather than left pointing at - /// functions that cannot answer. A null `pull_read_clipboard` is not a - /// missing feature, it is the core's OWN clipboard (host.zig: "Null - /// answers immediately from the in-process clipboard instead, so a request - /// never goes unanswered") — the same one `pardes-isolate` runs on. With - /// the methods present and returning on `s.gui == null`, `SPC y` went - /// nowhere and `SPC p` was answered by nobody, so paste was dead in the - /// one mode of this shell a test can drive. const grid_vtable: pardes.Host.VTable = vt: { var v = vtable; - v.pull_wait_input = null; - v.push_poll_frame = gridPollFrame; - v.push_present = gridPresent; - v.push_post_present = gridPostPresent; - v.push_set_clipboard = null; - v.pull_read_clipboard = null; + v.wait_input = null; + v.poll_frame = gridPollFrame; + v.present = gridPresent; + v.post_present = gridPostPresent; + v.set_clipboard = null; + v.read_clipboard = null; break :vt v; }; - /// What the detached workers handed this thread since the last pass. - /// Their bytes are borrowed for exactly one `update` call each. Each - /// message is something this pass observed — the grid harness draws a - /// frame only for a pass that observed something. fn drainQueue(s: *Shell) void { + s.reconcilePtys(); var msgs = s.queue.take(); var check_files = false; for (msgs.slice()) |m| switch (m) { @@ -3598,20 +3575,22 @@ const Shell = struct { s.saw_event = true; }, .eof => |e| { - _ = libc.close(e.fd); // the dead reader's master — stale or current if (s.gens[e.pane] == e.gen) { - s.ptys[e.pane] = null; - s.core.update(.{ .eof = .{ .pane = e.pane } }); + s.closePty(e.pane); + if (e.failure) |err| { + if (s.core.panes[e.pane]) |pane| { + pane.mode = .normal; + s.core.reportError(e.pane, "terminal reader", err); + } + } else s.core.update(.{ .eof = .{ .pane = e.pane } }); } s.saw_event = true; }, .lsp => |l| { s.core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); - s.lsp_allocator.free(l.rows); + if (l.rows) |rows| s.lsp_allocator.free(rows); s.saw_event = true; }, - // Server state on the transient message row of the ACTIVE pane — - // session news, same row and same stamp a completed save uses. .lsp_status => |text| { var mbuf: [256]u8 = undefined; s.core.setStatus(s.core.active, message.stamp(&mbuf, "lsp", text)); @@ -3630,17 +3609,7 @@ const Shell = struct { s.saw_event = true; s.pipe_tasks.finish(s.io, response_value.id); }, - .command => |line| { - s.core.update(.{ .command = line }); - s.gpa.free(line); - }, - // Coalesced on purpose: a burst of writes (a formatter, a build, a - // `git checkout`) collapses into ONE pass below, so it cannot queue - // a reload — or an undo entry — per write. .files_changed => check_files = true, - // A wake and nothing more; the requests behind it are drained in - // pollFrame, which is where a whole batch can be answered against - // one render instead of one render per request. .fs_ready => {}, }; if (check_files and file_watch.reloadChanged(s.core, s.io, s.gpa, s.watches)) @@ -3648,19 +3617,6 @@ const Shell = struct { } }; -/// The core's answer to one filesystem request, handed straight back to the -/// transport holding it. `bytes` was resolved by `pardes.fsPayload` inside -/// `perform` and is borrowed only for this call, so a megabyte body read copies -/// nothing. `.again` needs no case here: `Fs.reply` reads the status and -/// re-parks the request itself. -fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void { - const s = shellOf(ctx); - if (s.fs) |f| f.reply(reply, bytes); -} - -/// The /dev/fuse poller's wake. `Queue.push` is the thread-safe door and -/// already raises the SDL user event that ends a blocking WaitEventTimeout, so -/// this is the whole callback — the same shape as watchThread's. fn wakeFs(ctx: ?*anyopaque) void { const q: *Queue = @ptrCast(@alignCast(ctx.?)); q.push(.fs_ready); @@ -3670,16 +3626,9 @@ fn shellOf(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } -/// SDL first (blocking briefly for one event, then draining the burst), then -/// the scripted stdin feed, then the worker inbox, then the sticks. Never -/// blocks indefinitely even when the core offers to: cwd polling, the gamepad -/// and the test feed have no SDL event to wake them. fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = shellOf(ctx); const core = s.core; - // The one place a local session builds the sink: everything downstream of - // here — `dispatch`, the scripted feed, the sticks — is the same code an - // attached window runs with `client` set instead. var in: Input = .{ .core = core }; if (s.gui) |g| { var sev = std.mem.zeroes(c.SDL_Event); @@ -3690,9 +3639,6 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { } } if (s.test_mode) { - // A dead scripted feed ends the session HERE, before the inbox, the - // sticks and the frame: the pre-pump loop broke at this line, and a - // capture written after EOF is a frame no script asked for. const r = s.feed.pump(s.gpa, &in, s.gui) catch { core.quit = true; return; @@ -3706,35 +3652,19 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { if (s.gui) |g| pollGamepad(g, &in); } -/// Per-frame host bookkeeping with no event of its own, in the order the flat -/// loop had it: the tagline face, a font the core asked for, live cwds, the -/// grid following the window, and the wheel batch — applied LAST before the -/// render, while the previous frame is still the one on screen. fn pollFrame(ctx: ?*anyopaque) void { const s = shellOf(ctx); + s.reconcilePtys(); const core = s.core; - // acme's filesystem: one batch per frame, answered before anything else in - // the pass, so an edit a script just made through `body` is in the surface - // this frame composes. Ahead of the `s.gui orelse return` below because it - // has nothing to do with pixels. Hitting the cap means no ack reached the - // poll thread, so nothing else will wake us — re-arm the loop ourselves; - // `Queue.push` is lossy for this variant, which is correct, because a queue - // too full to take a wake is already holding one. - if (s.fs) |f| if (fs_service.drain(f.transport(), core).pending) s.queue.push(.fs_ready); + if (s.fs) |f| if (f.tick(core).pending) s.queue.push(.fs_ready); const g = s.gui orelse return; - // TaglineSize is pure renderer state: update the smaller face and its - // visual band immediately, without changing the body metrics or grid. syncTaglineFont(g, core); - // Font builtin: the core resolved a name to a path and asked for it — it - // cannot load a font itself, having no rasterizer, no atlas and no window. if (core.takeFontRequest()) |path| blk: { - const bytes = look.readFile(s.gpa, path) catch { + const bytes = filesystem.readFile(s.gpa, path) catch { core.rejectFont(); break :blk; }; const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse { - // FreeType turned it down. Keep wearing the one that works: a font - // pardes cannot rasterize is a blank window with no way back out. log.err("ui_font_new failed: {s}", .{path}); s.gpa.free(bytes); core.rejectFont(); @@ -3749,8 +3679,6 @@ fn pollFrame(ctx: ?*anyopaque) void { acknowledgeGuiFont(g, core); } pollCwds(core, s.ptys); - // Off g.cell_w/h, not the startup metrics: a font change moves them, and - // this is the line that would go on dividing by the old cell. const geom = windowCells(g); if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h)) resetScroll(g); stepScroll(g, core, s.gpa); @@ -3760,8 +3688,6 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = shellOf(ctx); const core = s.core; const g = s.gui orelse return; - // renderFrame consumes the frame the core just built; nothing here writes - // to it, and post_present needs the same one to acknowledge. const frame = @constCast(surface); s.surface = frame; const scene_requested = core.settings.scene_effects.crt or @@ -3791,16 +3717,11 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { } else if (scene_requested and s.presented) g.scene_failures = 0; } -/// A tick is spent only on a frame that was actually PRESENTED: a failed -/// renderFrame must not advance samples nobody saw. fn postPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); const g = s.gui orelse return; if (!s.presented) return; const frame = s.surface orelse return; - // Ripple/glitch move source cells under a stationary physical pointer. - // Re-feed only when that accepted scene maps to a new cell, using the same - // core mouse path a real motion event uses. refreshPresentedPointer(g, s.core); finishPresentedAnimationFrame( &s.animation_clock, @@ -3810,26 +3731,13 @@ fn postPresent(ctx: ?*anyopaque) void { ); } -/// The grid harness's own three seams. It has no window, no pointer and no -/// compositor, so what is left of a frame is the cwds a tagline draws, one -/// animation step, and the text of the grid itself. fn gridPollFrame(ctx: ?*anyopaque) void { const s = shellOf(ctx); - // The filesystem, drained on the pass rather than woken by a thread: this - // mode's contract is one frame per scripted event, and a poller posting on - // its own clock would put frames in the stream nothing asked for. fuse.zig - // supports exactly this — skip `wakeThread` and drain from the frame poll — - // and here it is not a degradation but the point. A request that changed - // something IS an event, so the pass renders: that is what lets a snapshot - // `wait` for text a script wrote through the mount. + s.reconcilePtys(); if (s.fs) |f| { - if (fs_service.drain(f.transport(), s.core).count != 0) s.saw_event = true; + if (f.tick(s.core).count != 0) s.saw_event = true; } pollCwds(s.core, s.ptys); - // The harness polls stdin at the same 16 ms cadence as native SDL, so a - // pass IS a frame interval and the tick is due here rather than behind a - // display clock. Advancing lets `stable` wait for exact endpoint colors; - // once inactive it resumes the event-only frame contract. if (s.core.animationActive()) { s.core.update(.tick); s.saw_event = true; @@ -3838,8 +3746,6 @@ fn gridPollFrame(ctx: ?*anyopaque) void { fn gridPresent(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = shellOf(ctx); - // Idle pass: nothing changed, so no frame. The stream is one frame per - // scripted input event and a repeat of the last grid would be read as one. s.presented = s.saw_event; s.saw_event = false; if (!s.presented) return; @@ -3852,80 +3758,58 @@ fn gridPresent(ctx: ?*anyopaque, surface: *const pardes.Surface) void { fn gridPostPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); if (!s.presented) return; - // The grid protocol writes canonical cells; panel tracks are metadata for - // a compositor it deliberately does not run. s.core.acknowledgePanelPresentation(&.{}); } fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = shellOf(ctx); - // the core reuses pane ids and there is no close effect: a deleted pane's - // shell lives in its slot until a respawn lands here. Kill it; its - // detached reader wakes on child death and the gen-guarded eof closes the - // old fd (not here — the reader still reads it). - if (s.ptys[pane]) |old| { - _ = libc.kill(old.pid, libc.SIG.KILL); - s.ptys[pane] = null; - } + s.reap(); + s.closePty(pane); + if (s.ptys[pane] != null) return s.core.reportError(pane, "shell", error.WorkersBusy); s.gens[pane] +%= 1; - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - if (cwd.len > 0 and cwd.len < cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - // The machine-local half is host_io.zig's, not this file's: the same fork - // the tty shell and the detached daemon do, including the CLOEXEC on the - // master that this copy used to be missing (a master a later shell inherits - // is never closed, so a deleted pane's shell never hangs up). - const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs); - const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid }; + const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err); + const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid, .serial = s.core.panes[pane].?.serial }; s.ptys[pane] = pt; - // report the pane's starting directory back to the core (tags); the slot - // needs no occupancy reset, nothing about it is remembered - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); - if (s.threads_ok) spawnReader(s.gpa, pt, pane, s.gens[pane], s.queue); + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); + if (s.threads_ok) spawnReader(s.gpa, &s.ptys[pane].?, pane, s.gens[pane], s.queue) catch |err| { + s.closePty(pane); + s.core.reportError(pane, "terminal reader", err); + }; } fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = shellOf(ctx); - if (s.ptys[pane]) |pt| _ = host_io.writeFd(pt.fd, bytes); + if (s.ptys[pane]) |pt| if (pt.fd >= 0) { + _ = host_io.writeFd(pt.fd, bytes); + }; } fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = shellOf(ctx); if (s.ptys[pane]) |pt| { + if (pt.fd < 0) return; const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws)); } } -/// `pty/ctl`'s `sig`. A pane with no pty of ours has nothing to signal, which -/// is the same silence `ptyWrite` above gives it. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = shellOf(ctx); - if (s.ptys[pane]) |pt| look.signalTty(pt.pid, pt.fd, sig); + if (s.ptys[pane]) |pt| if (pt.fd >= 0) host_io.signalTty(pt.pid, pt.fd, sig); } -/// Is a program (vim, a pager, an agent) holding this pane's tty instead of -/// the shell we forked? Asked by the core only where it is about to type a -/// command line, which is why the /proc walk behind it is not in pollCwds: -/// nothing draws this answer, and an Exec is a rare frame. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = shellOf(ctx); const pt = s.ptys[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.fd); + if (pt.fd < 0) return false; + return host_io.ttyTaken(pt.pid, pt.fd); } fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = shellOf(ctx); - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, "save", err); - // our own write is about to come back as a watch event: restamp from the - // bytes we just put there so it reads as "no change". Only for the pane's - // OWN file — a `Put` elsewhere is a change like any other. if (s.core.panes[pane]) |pane_state| if (pane_state.file) |f| { if (std.mem.eql(u8, f.path, path)) if (s.watches[pane]) |*w| if (w.serial == pane_state.serial) switch (w.generation) { @@ -3933,8 +3817,6 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo .pdf => {}, }; }; - // ...and say so on the pane's message row. AFTER the write, not beside it: - // a save that did not happen must not be reported as one. var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -3943,14 +3825,13 @@ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = shellOf(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return s.core.reportError(0, "dump", err); + filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } -fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void { +fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { const s = shellOf(ctx); - _ = path; // file_watch resolves it (and a PDF's) from the pane itself - if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, s.watches, pane, on)) + if (file_watch.applyEffect(s.core, s.io, s.inotify_fd, s.watches, pane, on, mode)) s.queue.push(.files_changed); } @@ -3970,7 +3851,7 @@ fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = shellOf(ctx); const config_dir = s.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { s.core.reportError(pane, "dump themes", err); return; }; @@ -3979,21 +3860,12 @@ fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } -/// Put `text` on THIS display's clipboard. The one place that copy happens: -/// SDL wants a sentinel-terminated string and a run of core cells is not one. -/// Shared, because the in-process host and an attached window answering a -/// `set_clipboard` off the wire are the same desktop action. fn putClipboard(gpa: std.mem.Allocator, text: []const u8) void { const z = gpa.dupeZ(u8, text) catch return; defer gpa.free(z); _ = c.SDL_SetClipboardText(z.ptr); } -/// THIS display's clipboard, or null when it holds nothing. SDL3 hands over an -/// OWNED copy that is the caller's to `SDL_free`, and reports "no text" as an -/// EMPTY string rather than null — so the length check is what actually -/// rejects a miss. Shared with the attached loop for the `putClipboard` -/// reason, turned round. fn takeClipboard() ?[:0]u8 { const raw = c.SDL_GetClipboardText() orelse return null; const text = std.mem.span(raw); @@ -4004,38 +3876,22 @@ fn takeClipboard() ?[:0]u8 { return text; } -/// Both of these are only ever reached through `Shell.vtable`, which -/// `Shell.host` installs only when there IS a window: the headless grid -/// harness nulls them and keeps the core's own clipboard. So neither needs a -/// `s.gui == null` guard, and neither may have one — a method that returns -/// without answering is exactly what left `SPC p` unanswered in grid mode. fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { putClipboard(shellOf(ctx).gpa, text); } fn readClipboard(ctx: ?*anyopaque) void { - // SDL answers synchronously, so the paste the core is waiting on lands - // inside this same drain — nothing to remember, no reply path to plumb. const text = takeClipboard() orelse return; defer c.SDL_free(text.ptr); shellOf(ctx).core.update(.{ .paste = text }); } test "the headless grid host round-trips a yank back as a paste" { - // The GUI shell's testable mode, driven through the SAME host the grid - // harness installs — `Shell.host()` picks `grid_vtable` off `gui == null`, - // so this is the real seam and not a hand-built one. Before the two - // clipboard methods were nulled, `SPC y` reached a function that returned - // on `gui == null` and `SPC p` was answered by nobody: the content below - // never changed, in the one mode of this file a test can run. const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer core.deinit(); core.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); - // Everything a Shell needs that this path never touches, at its zero - // value; `io` alone is undefined, because a clipboard is not IO the - // std.Io interface knows about and no arm reached here reads it. var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var gens: [pardes.MAX_PANES]u32 = @splat(0); var lsp_workers: LspWorkers = .{}; @@ -4045,10 +3901,10 @@ test "the headless grid host round-trips a yank back as a paste" { .lsp_workers = &lsp_workers, .sdl_wake = false, }; + defer queue.deinit(); var pipe_tasks: PipeTasks = .{}; var watches: file_watch.Table = @splat(null); - shell_bin.adoptSystemPath(); - var prompt_rcs = shell_bin.PromptRcs.init(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var shell: Shell = .{ .core = core, @@ -4067,8 +3923,6 @@ test "the headless grid host round-trips a yank back as a paste" { core.host = shell.host(); try std.testing.expect(core.host.vtable == &Shell.grid_vtable); - // `SPC y`: the selection to the system clipboard. Headless, "the system" - // is the core's in-process one. const pane = core.panes[0].?; core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'y' } }); @@ -4076,8 +3930,6 @@ test "the headless grid host round-trips a yank back as a paste" { const yanked = core.yank orelse return error.MissingYank; try std.testing.expect(yanked.len > 0); - // ...and `SPC p` gets it back, as an ordinary paste event, inside the - // drain. A host that cannot answer leaves the file exactly as it was. const before = pane.file.?.content.len; core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'p' } }); @@ -4092,55 +3944,57 @@ fn openLink(ctx: ?*anyopaque, url: []const u8) void { look.openLink(url); // desktop browser } -fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void { +fn lsp(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const s = shellOf(ctx); - if (s.threads_ok) spawnLsp(s.core, s.queue, req); + if (s.threads_ok) return spawnLsp(s.core, s.queue, req); + s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + s.core.reportError(req.pane, "lsp", error.WorkersUnavailable); } fn pipe(ctx: ?*anyopaque, id: u32) void { const s = shellOf(ctx); - if (s.threads_ok) spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id); + if (s.threads_ok) return spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id); + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + s.core.reportError(s.core.active, "pipe", error.WorkersUnavailable); } -/// Live cwd for tags/look: a cheap per-pane process lookup, polled every frame -/// because a tagline draws it. Whether a pane's tty still belongs to the prompt -/// pardes forked is deliberately NOT polled with it — see `ttyTaken`. fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { for (ptys, 0..) |slot, id| if (slot) |pt| { - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); + if (pt.fd < 0) continue; + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; } -// ===================================================================== -// fractional scroll: whole rows for the core, sub-row offsets for the picture -// ===================================================================== +fn shellClock() i64 { + var ts: libc.timespec = undefined; + if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; + return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); +} + +fn reapShell(pid: *libc.pid_t, kill_at: *i64, now: i64) void { + if (pid.* <= 0) return; + const result = libc.waitpid(pid.*, null, libc.W.NOHANG); + if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) { + pid.* = 0; + kill_at.* = 0; + } else if (kill_at.* != 0 and now >= kill_at.*) { + _ = libc.kill(pid.*, libc.SIG.KILL); + kill_at.* = 0; + } +} -/// Add one raw SDL vertical-wheel value to this render batch. SDL calls up -/// positive; the picture coordinate below calls down positive. Non-finite -/// input, including an addition that overflows, cannot enter persistent state. fn accumulateWheelDelta(pending: f32, raw_y: f32) f32 { if (!std.math.isFinite(raw_y)) return pending; const next = pending - raw_y; return if (std.math.isFinite(next)) next else pending; } -/// The old surface can supply at most one body-height of historical rows. -/// Clamp only pathological per-frame batches to that renderable range; normal -/// SDL deltas pass through unchanged. Including lag in the bound guarantees -/// applyScrollDelta cannot cross more than `body_rows` core boundaries. fn boundScrollDelta(lag: f32, delta: f32, body_rows: u16) f32 { const limit: f32 = @floatFromInt(body_rows); return std.math.clamp(lag + delta, -limit, limit) - lag; } -/// Apply an exact picture displacement and return the whole core rows it -/// crosses (positive = down) plus the retained sub-row picture/core offset. -/// -/// Crossing in the direction of travel rounds the core one row ahead of the -/// picture and leaves lag pointing back at it. Therefore the exposed strip is -/// always the one historical edge row which the previous surface still owns; -/// the renderer never needs a row from the future. fn applyScrollDelta(lag: f32, delta: f32) struct { lag: f32, rows: i32 } { var l = lag + delta; var rows: i32 = 0; @@ -4187,8 +4041,6 @@ test "wheel magnitude is preserved without quantization" { try std.testing.expectEqual(@as(f32, -0.25), s.lag); try std.testing.expectEqual(@as(f32, 0), accumulateWheelDelta(0, std.math.inf(f32))); - // A finite but nonsensical device value is bounded before the reducer, - // avoiding an unbounded loop while keeping the largest renderable move. const bounded = boundScrollDelta(0, 3.0e38, 24); const safe = applyScrollDelta(0, bounded); try std.testing.expectEqual(@as(f32, 24), bounded); @@ -4215,10 +4067,6 @@ fn resetScroll(g: *Gui) void { g.scroll_edge_len = 0; } -/// Apply this frame's exact wheel batch, hand the core every whole row it -/// crossed, and retain the one historical row exposed by the residual offset. -/// Called immediately before core.render(), while core.surface still holds -/// what the previous frame drew. fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const id = g.scroll_pane orelse return; const pane = core.panes[id] orelse { @@ -4231,8 +4079,6 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { if (g.scroll_lag == 0) resetScroll(g); return; } - // the rect the last frame was painted through — what the snapshot below - // indexes. Nothing between here and render() moves it. const r = core.rects[id]; if (r.h <= pardes.BOX_H) { resetScroll(g); @@ -4251,9 +4097,6 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { .col = g.scroll_col, .row = g.scroll_row, } }); - // the document ran out under us (top of a file, bottom of a live - // terminal): there is no travel left to draw, so stop dead rather - // than slide the pane against a view that is not moving if (pane.scroll() == was) { resetScroll(g); return; @@ -4261,16 +4104,9 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { } g.scroll_lag = st.lag; g.scroll_rect = r; - // the body origin travels with the rect: the two draw sites below have no - // core to ask, and with Tagbottom the body starts at r.y, not r.y + BOX_H const body_y = if (core.settings.tag_bottom) r.y else r.y + pardes.BOX_H; g.scroll_body_y = body_y; if (st.rows != 0) { - // The offset opens a gap at the trailing edge of the travel, and what - // belongs in it is the row that just left the pane: already gone from - // the surface the core is about to paint, still in the one it painted - // last frame. k rows in, that row is the k-1'th body row from the top - // going down, the k'th from the bottom going up. const s = &core.surface; g.scroll_edge_len = 0; if (r.w > config.GUTTER and r.h > pardes.BOX_H and r.x + r.w <= s.cols) { @@ -4293,12 +4129,6 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { if (g.scroll_lag == 0) resetScroll(g); } -/// The fractional pane body, emitted a SECOND time at its sub-row offset, -/// plus the one row of history that fills the gap the offset opens. Writes -/// instances at `base` and returns how many; the caller draws them scissored -/// to the body, which is the whole of the clipping — the shell draws the grid -/// in one flat pass, so without it the overhanging rows would land on the -/// pane's own tag and on whatever is below it. fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { const scroll_pane = g.scroll_pane orelse return 0; if (g.scroll_lag == 0) return 0; @@ -4311,7 +4141,6 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard const bw = r.w - config.GUTTER; const bh = r.h - pardes.BOX_H; if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us - // the same layout, one sub-row up: emitInstance needs to know nothing var shifted = layout; shifted.y_off -= g.scroll_lag * layout.h; const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32); @@ -4326,8 +4155,6 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard n += 1; } } - // ...and the row that just left, one row outside the body on the side the - // travel came from. The scissor keeps all of it but the exposed strip. if (g.scroll_edge_len >= bw) { const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1; var i: u16 = 0; @@ -4339,8 +4166,6 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard return n; } -/// That body rect in target pixels, clamped to the target — the scissor both -/// shells set around the draw above. fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect { const r = g.scroll_rect; const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w; @@ -4354,10 +4179,6 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 }; } -// ===================================================================== -// render: Surface → instanced quads → SDL GPU -// ===================================================================== - fn releaseNativeImage(g: *Gui, key: pardes.ImageCacheKey) void { if (g.native_images.fetchRemove(key)) |removed| c.SDL_ReleaseGPUTexture(g.device, removed.value); @@ -4414,7 +4235,7 @@ fn snapshotContainsPlacement(g: *const Gui, current: pardes.ImagePlace) bool { return false; } -fn placeIntersectsBox(place: SavedImagePlace, box: pardes.panel_animation.Box) bool { +fn placeIntersectsBox(place: SavedImagePlace, box: pardes.layout.Box) bool { const x0: f32 = @floatFromInt(place.x); const y0: f32 = @floatFromInt(place.y); const x1: f32 = @floatFromInt(@as(u32, place.x) + place.w); @@ -4550,21 +4371,15 @@ fn uploadNativeTexture( }; c.SDL_UploadToGPUTexture(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); - // SDL defers destruction until the submitted copy is done; the staging - // allocation is never needed again, so do not retain a second full image - // beside the texture for the life of the pane. try g.native_images.put(gpa, key, texture); } -/// Upload new pixel generations and the small per-frame placement buffer. -/// Returns the number of image instances drawNativeImagesGpu will consume. -// EFFECT_CODE_NATIVE_PANEL_BEGIN fn appendPreparedImage( g: *Gui, gpa: std.mem.Allocator, place: SavedImagePlace, - track: ?pardes.panel_animation.Track, - clip: ?pardes.panel_animation.Box, + track: ?pardes.layout.Track, + clip: ?pardes.layout.Box, old_layer: bool, ) void { const texture = g.native_images.get(place.key) orelse return; @@ -4676,8 +4491,6 @@ fn prepareNativeImages( appendPreparedImage(g, gpa, saved, active, null, false); } } else if (active.effect == .dissolve) { - // Old layer first. Its shader half disappears at the same - // per-cell threshold at which the current half appears. for (g.presented_images.items) |saved| { if (!placeIntersectsBox(saved, active.contentBox())) continue; appendPreparedImage(g, gpa, saved, active, active.contentBox(), true); @@ -4692,18 +4505,12 @@ fn prepareNativeImages( paintBatchForSerial(plan, current.serial) != batch_index) continue; const saved = SavedImagePlace.from(current); const current_track = if (track) |active| switch (active.effect) { - // An identical cached placement is semantically unchanged - // and must bypass a data effect exactly like an unchanged - // cell in Surface.cell_diffs. .dissolve => if (snapshotContainsPlacement(g, current)) null else active, else => active, } else null; appendPreparedImage(g, gpa, saved, current_track, null, false); } } - // Frozen visual under an incoming lifecycle pane. Append it after - // canonical static placements so it restores the old target pixels; - // the translated opening batch is painted later over this fixed clip. if (batch_index == 0) for (plan.batches[1..plan.len]) |tracked| { const active = tracked.track.?; if (active.effect != .vertical or active.phase != .opening) continue; @@ -4731,8 +4538,6 @@ fn prepareNativeImages( idx += 1; } c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer); - // The planning pass already rejected undrawable placements, so a mismatch - // here would desynchronize batch offsets from texture bindings. std.debug.assert(idx == capacity); const copy = c.SDL_BeginGPUCopyPass(cmd); @@ -4751,8 +4556,6 @@ fn imageScissor( ) c.SDL_Rect { if (prepared.clip) |box| return panelBoxScissor(g, box, max_w, max_h); if (prepared.track) |active| { - // Vertical travel is visible only through the pane's fixed lifecycle - // box. Other effects retain their historical visual-box clipping. const box = if (active.effect == .vertical) active.contentBox() else active.visualBox(); return panelBoxScissor(g, box, max_w, max_h); } @@ -4765,7 +4568,7 @@ fn imageScissor( }; } -fn panelBoxScissor(g: *const Gui, box: pardes.panel_animation.Box, max_w: u32, max_h: u32) c.SDL_Rect { +fn panelBoxScissor(g: *const Gui, box: pardes.layout.Box, max_w: u32, max_h: u32) c.SDL_Rect { const x0: i32 = @intFromFloat(@floor(box.x * @as(f32, @floatFromInt(g.cell_w)))); const y0: i32 = @intFromFloat(@floor(box.y * @as(f32, @floatFromInt(g.cell_h)))); const x1: i32 = @intFromFloat(@ceil((box.x + box.w) * @as(f32, @floatFromInt(g.cell_w)))); @@ -4817,9 +4620,7 @@ fn drawNativeImagesGpu( }; c.SDL_SetGPUScissor(pass, &whole); } -// EFFECT_CODE_NATIVE_PANEL_END -// EFFECT_CODE_FRAME_SUBMISSION_BEGIN fn renderFrame( g: *Gui, gpa: std.mem.Allocator, @@ -4828,27 +4629,19 @@ fn renderFrame( theme_bg: ?[3]u8, topbar_pane_border_rgb: [3]u8, tagline_rgb: [3]u8, - scene_effects: pardes.panel_animation.SceneEffect, + scene_effects: pardes.layout.SceneEffect, debug_on: bool, ) !bool { - // Per-attempt, not per-last-success: an early swapchain/capture return - // after one real target failure must not count as another failed retry. g.scene_target_failed = false; const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return false; var command_consumed = false; defer if (!command_consumed) { - // CPU caches become authoritative as uploads are enqueued (glyphs lose - // atlas_dirty; native textures enter the map). Submit every abandoned - // buffer so those transactions remain true. Submission is valid with - // or without a swapchain texture; cancellation is not valid after one - // is acquired and would discard capture-mode uploads before it. _ = c.SDL_SubmitGPUCommandBuffer(cmd); }; var sw: u32 = 0; var sh: u32 = 0; var target: *c.SDL_GPUTexture = undefined; if (g.capture) { - // capture: render offscreen (the window may never present), dump PPM sw = @as(u32, surface.cols) * g.cell_w; sh = @as(u32, surface.rows) * g.cell_h; if (sw == 0 or sh == 0) { @@ -4859,8 +4652,6 @@ fn renderFrame( try ensureCaptureTexture(g, sw, sh); target = g.capture_tex.?; } else if (g.soft_present) { - // No swapchain to acquire: render the window-sized frame offscreen and - // blit it in softPresentFrame below. var pw: c_int = 0; var ph: c_int = 0; if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) { @@ -4888,13 +4679,8 @@ fn renderFrame( const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); - // All full-window effects share one offscreen scene and one composable - // shader pass. With no bits set the ordinary render remains direct. var scene_on = scene_effects.crt or scene_effects.ripple or scene_effects.glitch; if (scene_on) ensureSceneTexture(g, sw, sh) catch { - // The optional postprocess target does not own canonical rendering. - // Use this already-acquired command/swapchain directly for the frame; - // the loop bounds retries and clears the public bits after three. g.scene_target_failed = true; scene_on = false; }; @@ -4902,11 +4688,6 @@ fn renderFrame( const layout = fixedCellLayout(g); var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff()); - // Cursors/debug overlays do not carry pane ownership. Hide those for the - // short interval in which panel geometry differs from logical geometry; - // otherwise a cursor could remain pinned at the final cell, or paint over - // a later opening pane, while its own pane moves underneath it. The - // topbar/pane rule is anchored window chrome and remains present. var overlay_count = buildOverlay( g, core, @@ -4922,8 +4703,6 @@ fn renderFrame( if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count)) overlay_count = 0; if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) { - // Planning wrote offsets before buffer growth/map could fail. Never - // let those counts index the previous frame's smaller/stale buffer. g.prepared_images.clearRetainingCapacity(); for (paint_plan.batches[0..paint_plan.len]) |*batch| { batch.image_start = 0; @@ -4940,11 +4719,6 @@ fn renderFrame( }; var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; - // Premultiplied, because that is what both a wl_surface and an X11 ARGB - // visual are composited as, and the glyph pass writes premultiplied for - // the same reason. A see-through ground is therefore all four channels - // zero and not `page.rgb` at alpha zero — the leftover colour would tint - // every glyph edge that blends against it. color_target.clear_color = if (page.clear) .{ .r = 0, .g = 0, .b = 0, .a = 0 } else .{ .r = @as(f32, @floatFromInt(page.rgb[0])) / 255.0, .g = @as(f32, @floatFromInt(page.rgb[1])) / 255.0, @@ -4978,9 +4752,6 @@ fn renderFrame( ) catch return error.GpuCapacity; } } - // Vertical opening retains the frozen grid below the incoming pane; - // closing is a presentation-only copy above the new canonical grid. Only - // those pane boxes are duplicated, so surviving panes never get tracks. for (paint_plan.batches[1..paint_plan.len]) |*batch| { const track = batch.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; @@ -5008,14 +4779,9 @@ fn renderFrame( cell_total = std.math.add(u32, cell_total, batch.cell_count) catch return error.GpuCapacity; } if (cell_total != 0) { - // Reserve one further grid for the fractional-scroll duplicate. Its - // path suppresses itself for an animated pane, but another static pane - // may still be scrolling while a closing tombstone is visible. const capacity = std.math.add(u32, cell_total, cells) catch return error.GpuCapacity; try ensureVbuf(g, capacity); - // ponytail: full re-upload every frame; the prototype's dirty-range - // diffing (cell_keys + coalesced ranges) is skipped for now. const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); @@ -5110,8 +4876,6 @@ fn renderFrame( shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page); c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); - // emitInstance may have rasterized new glyphs into the staging atlas; - // upload after vertex generation so this frame has what it references if (g.atlas_dirty) uploadAtlas(g, cmd); const copy = c.SDL_BeginGPUCopyPass(cmd); @@ -5121,10 +4885,6 @@ fn renderFrame( c.SDL_EndGPUCopyPass(copy); } - // Paint one complete panel before the next: its opaque cells followed by - // its native attachments. A phase-wide image tail would let an earlier - // pane's PDF/image cover a later pane's cells when their moving boxes - // overlap. PaintPlan is static, then moving slots, then opening slots. const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| { @@ -5148,8 +4908,6 @@ fn renderFrame( if (panel_clipped) c.SDL_SetGPUScissor(rp, &whole); } if (has_shifted) { - // The fractional duplicate is static content and remains - // below the static pane's native attachments. const clip = scrollScissor(g, layout, sw, sh); c.SDL_SetGPUScissor(rp, &clip); const binding = c.SDL_GPUBufferBinding{ @@ -5205,9 +4963,6 @@ fn renderFrame( c.SDL_EndGPURenderPass(crt_pass); } if (g.capture) { - // captureFrame consumes `cmd` on every success and error path: its - // allocation failures submit directly, and the ordinary path submits - // while acquiring the readback fence. command_consumed = true; try captureFrame(g, gpa, cmd, target, sw, sh); g.presented_scene = rendered_scene; @@ -5215,7 +4970,6 @@ fn renderFrame( return true; } if (g.soft_present) { - // Same contract as captureFrame: the readback submits `cmd` itself. command_consumed = true; try softPresentFrame(g, cmd, target, sw, sh); g.presented_scene = rendered_scene; @@ -5230,21 +4984,15 @@ fn renderFrame( } return submitted; } -// EFFECT_CODE_FRAME_SUBMISSION_END -// EFFECT_CODE_CELL_INSTANCE_BEGIN const ResolvedCell = struct { slot: Slot, fg: [3]u8, bg: [3]u8, role: pardes.FontRole, - /// `bg` is the see-through ground rather than a colour: paint the glyph - /// and leave the rest of the cell to the compositor. clear_bg: bool = false, }; -// Both moved to the core so the AppKit shell can call the SAME rule over the C -// ABI instead of keeping a second copy of it — see pardes.taglineBandOffset. const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; const taglineBandOffset = pardes.taglineBandOffset; @@ -5281,14 +5029,8 @@ test "tagline bands face the topbar rule and Tagbottom faces the window edge" { fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell { var fg = fg_default; var bg = page.rgb; - // Only an UNREVERSED default background is the ground; every branch below - // that names a real colour clears this, and the reverse at the end clears - // it because a reverse puts the TEXT colour there. var clear_bg = page.clear; var reverse = is_cursor; - // An invisible cell over a clear ground has nothing left to draw: `fg = bg` - // hides a glyph by painting it in the background, and a background that is - // not painted at all would let the ink through as a coloured silhouette. var blank = false; if (!cell.default) { const st = cell.style; @@ -5332,35 +5074,10 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs }; } -/// What the CORE said this cell's face is. fn cellFontRole(cell: *const pardes.Cell) pardes.FontRole { return if (cell.default) .body else cell.style.font_role; } -/// ...and the face it is actually DRAWN in, which differs in exactly one case -/// and that case is the whole of what an attached window renders differently. -/// -/// A compact tagline band is anchored at its pane's LEFT EDGE — that is what -/// `compactTaglineLayout`'s `origin_col` is — and a pane's left edge is a pane -/// RECT, which this wire does not carry (it carries cells, not the layout that -/// placed them). Anchoring per cell instead is not a near-miss, it is a picket -/// fence: `x_off = col * (body_w - tag_w)` puts every cell back on BODY pitch -/// while the quad stays `tag_w` wide, so the chrome band shows through between -/// every pair of cells and the text tracks visibly loose. Widening the quad -/// does not close it either — `emitInstance` samples exactly `tagline_width` -/// atlas texels for a tagline cell, so a wider quad stretches the glyph. -/// -/// So a pane tag row with no core to ask goes on the BODY grid, face and all: -/// one quad per cell at body pitch and body size, tiling exactly and tracking -/// exactly. The visible difference from a local window is that those rows wear -/// the body face rather than the 82% one, and that is the price of the pane -/// rects not being on the wire. It is also the grid `gridCellAtDimensions` -/// already hit-tests an attached tag row against, so a click still lands on the -/// glyph it was aimed at. -/// -/// ROW ZERO is exempt, and that exemption is why the topbar was never striped: -/// its origin is not a pane rect but column zero, always, so its compact band -/// is right with or without a core. fn drawnFontRole(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) pardes.FontRole { const role = cellFontRole(cell); if (role != .tagline or core != null or row < pardes.TOPBAR_H) return role; @@ -5371,10 +5088,6 @@ fn cellInstanceCount(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: return if (drawnFontRole(core, cell, row) == .tagline) 2 else 1; } -/// A tagline cell has two quads. The first preserves the pane-wide chrome -/// band on the body grid; the second draws the real cell on the smaller text -/// grid. Emitting them together in increasing column order is sufficient: -/// the compact cell never reaches the next body's cell origin. fn emitSurfaceCell( g: *Gui, core: ?*const pardes.Pardes, @@ -5385,7 +5098,7 @@ fn emitSurfaceCell( body_layout: CellLayout, win_w: f32, win_h: f32, - track: ?pardes.panel_animation.Track, + track: ?pardes.layout.Track, cell: *const pardes.Cell, tagline_base: *const pardes.Cell, old_layer: bool, @@ -5414,25 +5127,15 @@ fn emitInstance( layout: CellLayout, win_w: f32, win_h: f32, - track: ?pardes.panel_animation.Track, - /// The face this quad draws in, decided once per cell by `drawnFontRole` - /// rather than re-derived here: an attached window demotes a pane tag row - /// to the body face, and the quad geometry, the atlas slot and the uv span - /// all have to agree about that in one place. + track: ?pardes.layout.Track, role: pardes.FontRole, cell: *const pardes.Cell, - /// Old and new data layers carry their own quad geometry. The shader - /// discards exactly one at every reveal state, so a body/tagline role - /// change retains the correct band height on both sides of the diff. old_layer: bool, is_cursor: bool, - /// the ground this frame: what a default background resolves to, and - /// whether that is a colour at all page: Ground, ) void { const resolved = resolveCell(g, cell, role, is_cursor, page); - // Cell pixel rect (top-left origin) → NDC (y up). const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; const visual_h: f32 = if (resolved.role == .tagline) @floatFromInt(g.tagline_height) @@ -5491,7 +5194,6 @@ fn emitInstance( if (old_layer) instances[idx].effect |= old_layer_bit; if (resolved.clear_bg) instances[idx].effect |= clear_bg_bit; } -// EFFECT_CODE_CELL_INSTANCE_END fn cellCodepoint(cell: *const pardes.Cell) u32 { const grapheme = cell.grapheme(); @@ -5519,8 +5221,6 @@ test "insert cursor overlays without replacing the character beneath it" { .h = 20, }); try std.testing.expectEqual(@as(usize, 6), builder.len); - // A tagline caret uses the same centered visual band as its glyph and - // background, while its logical row remains the body-sized grid row. try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001); } @@ -5530,7 +5230,6 @@ fn palColor(idx: u8) [3]u8 { return .{ p.r, p.g, p.b }; } -// first codepoint of a UTF-8 grapheme; space on failure/empty fn firstCp(s: []const u8) u32 { if (s.len == 0) return ' '; const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' '; @@ -5538,24 +5237,6 @@ fn firstCp(s: []const u8) u32 { return std.unicode.utf8Decode(s[0..n]) catch ' '; } -/// Re-measure the cell, throw the glyph atlas away, and re-fit the grid to the -/// window. THE path for any change to what a cell LOOKS like: point g.font at -/// a different face (the Font builtin, above) or write a different g.px (the -/// Ctrl+/Ctrl- in dispatch) and call this — those are one line each, and -/// everything that has to follow from them is here. -/// -/// The atlas is the part that must not be skipped, and the reason the whole -/// thing is a function rather than three lines at a call site. It is keyed by -/// codepoint and font role — two raster sizes sharing a pen of cell_w×cell_h -/// slots — so after a change every slot in it holds the wrong picture at the -/// wrong metrics, and every key already in the map would keep being -/// drawn from that slot forever, because ensureGlyph's first line is a cache -/// hit. Clearing the map, zeroing the staging bitmap and rewinding the pen put -/// it back to exactly what init built, and ensureGlyph refills it as the next -/// frame draws. The zeroing is not tidiness: the upload is the WHOLE texture, -/// the new cell size is a different grid over the same 2048², and a leftover -/// bitmap no slot points at any more would still be sampled by whatever new -/// slot overlaps it. fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { g.scale = c.ui_font_scale_for_height(g.font, g.px); var cw: c_int = 10; @@ -5573,38 +5254,17 @@ fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { resetGlyphAtlas(g); - // ...and the grid: the same window is a different number of cells now. The - // shells re-derive this every frame anyway, so this is only the frame the - // change happens on — but it is the frame the surface is about to be - // rendered for, and a stale screen_w here is a row of cells drawn off the - // right edge of the window. An attached window has no core to tell: its - // loop compares `windowCells` against the last geometry it sent and puts a - // resize on the wire from there. if (core) |p| { const geom = windowCells(g); _ = updateCoreResize(p, geom.cols, geom.rows, g.cell_w, g.cell_h); } - // ...and a fractional scroll is measured in the OLD grid: scroll_rect - // is a rect of the pane the last frame drew, and scroll_edge is a saved row - // of exactly that rect's body WIDTH. The resize above moves both under it, - // and emitScrollRows only checks that the old rect still FITS inside the new - // surface — which it does whenever the font got smaller — so the next frame - // would paint last frame's strip over cells that are no longer the same - // text. Retire the offset instead; the next wheel event starts in the new - // grid. resetScroll(g); } -/// Rewind the shared atlas without touching body metrics. TaglineSize uses -/// this path: body glyphs are lazily reinserted at the same scale, tagline -/// glyphs at their new scale and band height. Repacking everything avoids an -/// ever-growing graveyard of old tagline slots when a config file experiments -/// with several sizes in one session. fn resetGlyphAtlas(g: *Gui) void { g.glyphs.clearRetainingCapacity(); @memset(g.atlas_stage, 0); - // slot (0,0) is the space glyph, exactly as init lays it out _ = c.ui_font_raster(g.font, g.scale, ' ', g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); g.space_slot = .{ .u = 0, .v = 0 }; g.pen_x = g.cell_w; @@ -5676,9 +5336,6 @@ fn ensureVbuf(g: *Gui, cells: u32) !void { var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 }; const next_vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; - // Allocate the pair before retiring either old half. A failed transfer - // allocation must not leave a new vertex buffer paired with null (or a - // stale vbuf_cells value that makes the next call accept that pair). if (g.vbuf) |buffer| c.SDL_ReleaseGPUBuffer(g.device, buffer); if (g.vxfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); g.vbuf = next_vbuf; @@ -5813,8 +5470,6 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } -// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex -// buffers at all (positions from gl_VertexIndex) fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); @@ -5845,10 +5500,6 @@ fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShader return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; } -// ===================================================================== -// PARDES_TEST frame capture: download the render target, write latest.ppm -// ===================================================================== - fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void { if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return; var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); @@ -5885,13 +5536,6 @@ fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { g.capture_tex_h = height; } -// ===================================================================== -// Software present: readback + SDL_Renderer blit, for compositors that -// cannot back a Vulkan swapchain (no linux-dmabuf; p9wl, remote stacks) -// ===================================================================== - -/// A colour-target format the device supports and writeCapturePpm/softPresentFrame -/// can both interpret. BGRA first because it is the usual swapchain layout. fn softTargetFormat(device: *c.SDL_GPUDevice) c.SDL_GPUTextureFormat { const candidates = [_]c.SDL_GPUTextureFormat{ c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, @@ -5908,7 +5552,6 @@ fn softTargetFormat(device: *c.SDL_GPUDevice) c.SDL_GPUTextureFormat { return c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; } -/// SDL pixel format matching the byte order of a 32-bit GPU format. fn softPixelFormat(format: c.SDL_GPUTextureFormat) ?c.SDL_PixelFormat { return switch (format) { c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, @@ -5937,10 +5580,6 @@ fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { @intCast(height), ) orelse return error.GpuCreate; _ = c.SDL_SetTextureScaleMode(next, c.SDL_SCALEMODE_NEAREST); - // The readback IS the frame, alpha included and already premultiplied. - // SDL's default for an alpha format is BLENDMODE_BLEND, which would blend - // it a second time against the cleared window and darken every glyph edge - // over a see-through ground. _ = c.SDL_SetTextureBlendMode(next, c.SDL_BLENDMODE_NONE); g.soft_texture = next; g.soft_tex_w = width; @@ -5948,8 +5587,6 @@ fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { return next; } -/// Download the finished frame and blit it with SDL_Renderer. Consumes `cmd` -/// on every path, exactly like captureFrame. fn softPresentFrame(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); @@ -5985,9 +5622,6 @@ fn softPresentFrame(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTex const texture = try ensureSoftTexture(g, sw, sh); const renderer = g.soft_renderer orelse return error.GpuCreate; if (!c.SDL_UpdateTexture(texture, null, mapped, @intCast(sw * bpp))) return error.GpuMap; - // Clear to nothing rather than to opaque black: on a transparent window - // this is the pixel the compositor keeps wherever the frame does not - // cover, and BLENDMODE_NONE below writes the frame over it verbatim. _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_NONE); _ = c.SDL_SetRenderDrawColor(renderer, 0, 0, 0, if (g.transparent) 0 else 255); _ = c.SDL_RenderClear(renderer); @@ -6068,11 +5702,6 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } -// ===================================================================== -// touch debug overlay: per-finger colored circles + trails + a click-action -// flash HUD, alpha-blended over the grid only while the Debug builtin is on. -// ===================================================================== - fn addCursorBar( builder: *OverlayBuilder, x: u16, @@ -6126,9 +5755,6 @@ fn buildOverlay( .a = 1.0, }); } - // The core grid contains only complete cells. Extend a bottommost - // Tagbottom band through the swapchain remainder so an arbitrary window - // height cannot reintroduce a page-colored strip below the final row. const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; if (grid_bottom < win_h and bottomTaglinePresent(surface)) { const rgb = tagline_rgb; @@ -6395,12 +6021,6 @@ fn miniGlyph(ch: u8) [5]u8 { }; } -// ===================================================================== -// shared plumbing (same shapes as tty.zig) -// ===================================================================== - -/// The effective codepoint the way vaxis Key.matches sees it: a single-char -/// text wins (shift resolved by the terminal), else the shifted codepoint. fn effCp(key: vaxis.Key) u21 { if (key.text) |t| { const view = std.unicode.Utf8View.init(t) catch return key.codepoint; @@ -6412,8 +6032,6 @@ fn effCp(key: vaxis.Key) u21 { return key.shifted_codepoint orelse key.codepoint; } -/// vaxis functional-key codepoints -> core Key constants (ASCII ones already -/// coincide: enter/tab/escape/backspace pass through). fn mapKey(cp: u21) u21 { return switch (cp) { vaxis.Key.up => pardes.Key.up, diff --git a/src/host.zig b/src/host.zig deleted file mode 100644 index 2c6bd208..00000000 --- a/src/host.zig +++ /dev/null @@ -1,345 +0,0 @@ -//! THE HOST SEAM: everything the core cannot do itself, as one struct of -//! OPTIONAL function pointers — `std.mem.Allocator`/`std.Io` shape, and the -//! generalization of two vtables this codebase already grew on its own -//! (`pardes.TtyQuery`, and the macOS shell's `Runtime`). -//! -//! Every method is optional, and a null method is not an error: the core -//! substitutes a default backed by ordinary data structures in this process -//! (`Fallback` below). So a host implements only what it actually has, and the -//! core cannot tell the difference — a `Save` lands in a real file under the -//! tty host and in `Fallback.files` under a host that never wrote a filesystem -//! method, and every path above that behaves identically. -//! -//! Two consequences worth having on purpose: -//! * The zero-method host IS the test harness. A `Host{}` is a complete, -//! deterministic, in-process pardes with a virtual filesystem, a virtual -//! clipboard and silent ptys. -//! * `Fallback` lives on the Pardes instance, not here, so N cores driven by -//! one fan-out host each keep their own state and can run in parallel. -//! -//! WHAT IS NOT HERE, and why: whether a capability EXISTS in this build stays -//! comptime and stays next to the code it shapes (`pardes.platform`, -//! `pardes.pdf_enabled`, `builtins.capabilities`, `PdfSlot`/`HapticSlot`). -//! A vtable cannot make a field zero-sized or a builtin absent from an enum. -//! The rule is: comptime decides what a BUILD has, this vtable decides who -//! SERVES it at runtime. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const source_manifest = @import("source_manifest.zig"); - -pub const LspRequest = struct { - id: u32, - kind: pardes.lsp.Kind, - pane: u8, - offset: u32, - arg: []const u8, -}; - -pub const Host = struct { - ctx: ?*anyopaque = null, - vtable: *const VTable = &.{}, - - /// One optional method per thing a host can do. Adding a method here is - /// additive for every existing host: they keep it null and get the default. - /// - /// EVERY name says how a fan-out must route it, and the compiler enforces - /// that it does (see Fanout.isPull): - /// `push_` every wrapped host gets it, and it returns nothing — a push - /// with an answer would have N answers and no way to pick one. - /// `pull_` exactly ONE host serves it, because there is one of whatever - /// comes back: one value, one sleep that ends, one `Event.paste` - /// for one Ctrl-V, one `lsp_resp` per request id. - pub const VTable = struct { - // ---- the loop's own three seams ---- - /// Block until there is input or `timeout_ms` elapses, translating - /// whatever arrives into `Pardes.update`/`postEvent` calls. This is the - /// ONLY place the process is allowed to sleep: the core never spins. - /// A pull because one host does the sleeping — fanned out, the second - /// host would not be serviced until the first happened to wake. - pull_wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null, - push_present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null, - /// After the frame is on screen (panel-presentation acknowledgement, - /// pointer refresh); split from `push_present` because it must observe - /// a frame the user has actually seen. - push_post_present: ?*const fn (ctx: ?*anyopaque) void = null, - /// Per-frame host bookkeeping with no event of its own: cwd polling, a - /// capability handshake landing, gamepad state. - push_poll_frame: ?*const fn (ctx: ?*anyopaque) void = null, - - // ---- this frontend's own membership ---- - /// `Detach` — leave the session, which carries on for everybody else. - /// Only a DETACHED core's host implements it, and the null case is the - /// point rather than an oversight: a local tty or SDL shell has no - /// session to leave, so the core reports that on the pane's row (see - /// `perform`) instead of quietly quitting something. Argumentless like - /// the two frame pushes above, because the host serving it already - /// knows whose keystroke arrived — it is the one that delivered it. - push_detach: ?*const fn (ctx: ?*anyopaque) void = null, - - // ---- pseudo-terminals ---- - push_spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null, - push_pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null, - push_pty_resize: ?*const fn (ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void = null, - /// Deliver a signal to whatever is on this pane's tty — `pty/ctl`'s - /// `sig INT`. A PUSH because there is no answer to have: `kill(2)` - /// either reaches a process that is already gone or reaches one whose - /// disposition the sender cannot see, and a script that wants to know - /// whether the program died reads the pane. NULL means this host owns - /// no pane shells and therefore has no child to signal — the browser - /// and the board, where the same null already makes `push_spawn` and - /// `push_pty_write` silent — and the effect is dropped exactly as a - /// write to a pane with no pty is. - push_pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void = null, - /// Is this pane's terminal still the prompt the host forked, or has a - /// program (vim, a pager, an agent) taken its tty? An effect cannot - /// answer it — the `execute` that asks must choose a destination inside - /// its own update, and effects drain after. A pushed fact would mean - /// every host probing every pane's processes every frame to answer a - /// question asked when a human middle-clicks a word. So the host leaves - /// a way to be asked and the core asks where it decides. The answer - /// must not re-enter the core. - pull_tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null, - - // ---- the board's own pads ---- - /// Flip one GPIO and report the level it held and the level it now holds. False means the - /// host would not do it: a pin number outside the part, or no pads at all. - /// - /// A pull, because there is one answer. The HOST answers it rather than the core reaching - /// for the registers itself - which `Peek` and `Poke` do two functions away - because - /// driving a pad correctly is not one register. It is the IO MUX function select, the GPIO - /// matrix output route, the pad's drive and input-buffer bits, and the output enable, keyed - /// by a per-pin table. The firmware already owns that code and checks it against ESP-IDF's - /// own headers on the die; a second copy in here would be a second copy nobody tests. - pull_gpio_toggle: ?*const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool = null, - - // ---- the filesystem ---- - /// `pane` travels with the bytes only so a host that posts a "saved" - /// message row can name the right pane; the core already resolved the - /// path and the content, so save_file and save_text both land here. - /// - /// A HOST THAT COULD NOT WRITE MUST CALL `Pardes.saveFailed`, and the - /// reason it is a call rather than a return value is the rule twenty - /// lines below: a `push_` reaches every host in a fan-out, so there is - /// no single answer to give back. The core marks the pane saved - /// optimistically around this call and `saveFailed` takes it back, so a - /// write that could not happen — a read-only file, a directory removed - /// under the pane, a full disk — leaves the ` *` in the tag where it - /// was. Until that existed the pane came clean on a save that never - /// happened, and `Del` makes no dirty check: the edits were one click - /// from gone with the screen saying they were safe. - push_write_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void = null, - /// The session dump. Separate because the host also chooses WHERE it - /// goes (dump.outPath is libc-bound; the freestanding core cannot). - push_write_dump: ?*const fn (ctx: ?*anyopaque, bytes: []const u8) void = null, - push_watch_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void = null, - push_watch_theme: ?*const fn (ctx: ?*anyopaque, generation: u32, on: bool) void = null, - push_dump_themes: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null, - - // ---- the desktop ---- - push_set_clipboard: ?*const fn (ctx: ?*anyopaque, text: []const u8) void = null, - /// Ask; the answer arrives later as an ordinary `Event.paste`, which is - /// why this returns nothing and is still a pull: two hosts answering - /// would paste the clipboard twice. Null answers immediately from the - /// in-process clipboard instead, so a request never goes unanswered. - pull_read_clipboard: ?*const fn (ctx: ?*anyopaque) void = null, - push_open_link: ?*const fn (ctx: ?*anyopaque, url: []const u8) void = null, - - // ---- work that must leave the loop ---- - /// Both answer exactly once, keyed by the id they carry, so both are - /// pulls: a second host's reply would arrive for a request already - /// completed and the core would apply it to whatever holds that id now. - pull_lsp: ?*const fn (ctx: ?*anyopaque, req: LspRequest) void = null, - pull_pipe: ?*const fn (ctx: ?*anyopaque, id: u32) void = null, - /// Hand one filesystem answer back to whoever asked for it (a FUSE - /// `write(2)` to /dev/fuse). `bytes` is the payload the core resolved - /// for this reply and is borrowed for the length of this call — it may - /// point straight into a pane's text, so a host that needs it later - /// copies it. A push and not a pull: the answer is already computed, - /// and a second host serving the same mount is not a thing that - /// happens (the transport that asked is the one holding the request). - push_fs_reply: ?*const fn (ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void = null, - }; -}; - -/// Where a host with no `write_dump` puts a session dump. Named here so the -/// core writes it and reports it as one path. -pub const fallback_dump_path = "pardes.dump.zon"; - -/// The in-process implementations behind every null method: a virtual -/// filesystem, a virtual clipboard, and a record of what was asked of ptys and -/// the desktop. Ordinary data structures, one set per Pardes instance. -/// -/// The filesystem is not empty. It is pardes's own source, embedded — see -/// source_manifest.zig — with `files` holding only what this session WROTE, so -/// a Save shadows the built-in copy and reading it back returns the edit. That -/// is what makes a host with no file methods a usable pardes rather than one -/// staring at an empty buffer. -/// -/// Only `files` grows, and it grows by REPLACING a path's content, so no -/// session accumulates. A pane whose child does not exist is SILENT: its bytes -/// are dropped rather than transcribed, because nothing reads a transcript back -/// and a browser session would then carry every keystroke forever. -pub const Fallback = struct { - gpa: std.mem.Allocator, - files: std.StringHashMapUnmanaged([]u8) = .empty, - clipboard: std.ArrayListUnmanaged(u8) = .empty, - /// Last link a host with no browser was asked to open. - link: std.ArrayListUnmanaged(u8) = .empty, - spawned: [pardes.MAX_PANES]bool = @splat(false), - watched: [pardes.MAX_PANES]bool = @splat(false), - - pub fn deinit(f: *Fallback) void { - var it = f.files.iterator(); - while (it.next()) |e| { - f.gpa.free(e.key_ptr.*); - f.gpa.free(e.value_ptr.*); - } - f.files.deinit(f.gpa); - f.clipboard.deinit(f.gpa); - f.link.deinit(f.gpa); - } - - /// True when the bytes are in the map. The core turns a false into the same - /// `saveFailed` a real host reports, so a virtual filesystem that could not - /// allocate does not leave a pane looking saved either. - pub fn writeFile(f: *Fallback, path: []const u8, bytes: []const u8) bool { - const copy = f.gpa.dupe(u8, bytes) catch return false; - if (f.files.getEntry(path)) |e| { - f.gpa.free(e.value_ptr.*); - e.value_ptr.* = copy; - return true; - } - const key = f.gpa.dupe(u8, path) catch { - f.gpa.free(copy); - return false; - }; - f.files.put(f.gpa, key, copy) catch { - f.gpa.free(key); - f.gpa.free(copy); - return false; - }; - return true; - } - - /// What this path holds now: the session's own write, else the embedded - /// source. Borrowed — the bytes live in the map or in the binary. - pub fn get(f: *const Fallback, path: []const u8) ?[]const u8 { - if (f.files.get(path)) |written| return written; - return source_manifest.find(path); - } - - pub fn setClipboard(f: *Fallback, text: []const u8) void { - f.clipboard.clearRetainingCapacity(); - f.clipboard.appendSlice(f.gpa, text) catch {}; - } - - pub fn setLink(f: *Fallback, url: []const u8) void { - f.link.clearRetainingCapacity(); - f.link.appendSlice(f.gpa, url) catch {}; - } -}; - -/// Fan out one core's host calls to several real hosts at once — the debugging -/// arrangement: every input reaches every host, and each host answers into its -/// own state. -/// -/// It advertises a method only when some wrapped host actually implements it, -/// so wrapping does NOT mask the core's per-method fallback: fan out two hosts -/// that never opened a link and the link still lands in `Fallback`. -pub const Fanout = struct { - hosts: []const Host, - vt: Host.VTable = .{}, - - pub fn init(hosts: []const Host) Fanout { - var f: Fanout = .{ .hosts = hosts }; - inline for (@typeInfo(Host.VTable).@"struct".fields) |field| { - for (hosts) |h| if (@field(h.vtable, field.name) != null) { - @field(f.vt, field.name) = @field(all, field.name); - break; - }; - } - return f; - } - - pub fn host(f: *const Fanout) Host { - return .{ .ctx = @ptrCast(@constCast(f)), .vtable = &f.vt }; - } - - fn self(ctx: ?*anyopaque) *const Fanout { - return @ptrCast(@alignCast(ctx.?)); - } - - /// A wrapper for every method, whether or not this fan-out advertises it. - /// Synthesized, so adding a method to `Host.VTable` needs no code here. - const all: Host.VTable = blk: { - var t: Host.VTable = .{}; - for (@typeInfo(Host.VTable).@"struct".fields) |field| { - @field(t, field.name) = fan(field.name); - } - break :blk t; - }; - - fn Method(comptime name: []const u8) std.builtin.Type.Fn { - const ptr = @typeInfo(@FieldType(Host.VTable, name)).optional.child; - return @typeInfo(@typeInfo(ptr).pointer.child).@"fn"; - } - - /// How to route a method, read off its own name. A method that is neither - /// is a COMPILE ERROR rather than a silent push, because the failure of a - /// forgotten pull is invisible in every unit test and obvious only to the - /// user: one Ctrl-V pasting twice. - fn isPull(comptime name: []const u8) bool { - if (std.mem.startsWith(u8, name, "pull_")) return true; - if (std.mem.startsWith(u8, name, "push_")) { - if (Method(name).return_type.? != void) @compileError("Host.VTable." ++ - name ++ " reaches every host, so it cannot return a value: whose answer would it be?"); - return false; - } - @compileError("Host.VTable." ++ name ++ " must be named push_… (every host gets it) " ++ - "or pull_… (exactly one host serves it, because there is one of whatever comes back)"); - } - - /// The walk, written once: `args` is everything after `ctx`. - fn dispatch(comptime name: []const u8, ctx: ?*anyopaque, args: anytype) Method(name).return_type.? { - for (self(ctx).hosts) |h| if (@field(h.vtable, name)) |fp| { - const answer = @call(.auto, fp, .{h.ctx} ++ args); - if (comptime isPull(name)) return answer; - }; - // `init` installs a wrapper only when some host has the method, so a - // pull always found one; a zero is the honest answer if that changes. - const R = Method(name).return_type.?; - if (comptime R != void) return std.mem.zeroes(R); - } - - /// One wrapper, built from the method's own signature: the parameter list - /// is the only part that cannot be derived, so there is one shape per - /// arity rather than one per method. - fn fan(comptime name: []const u8) @FieldType(Host.VTable, name) { - const m = Method(name); - const R = m.return_type.?; - const P = m.params; - return switch (P.len) { - 1 => struct { - fn w(c: ?*anyopaque) R { - return dispatch(name, c, .{}); - } - }.w, - 2 => struct { - fn w(c: ?*anyopaque, a: P[1].type.?) R { - return dispatch(name, c, .{a}); - } - }.w, - 3 => struct { - fn w(c: ?*anyopaque, a: P[1].type.?, b: P[2].type.?) R { - return dispatch(name, c, .{ a, b }); - } - }.w, - 4 => struct { - fn w(c: ?*anyopaque, a: P[1].type.?, b: P[2].type.?, d: P[3].type.?) R { - return dispatch(name, c, .{ a, b, d }); - } - }.w, - else => @compileError("Fanout has no wrapper shape for " ++ name ++ "'s arity"), - }; - } -}; diff --git a/src/host_io.zig b/src/host_io.zig index 6ffc890e..000d1a88 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1,200 +1,939 @@ -//! THE MACHINE-LOCAL HALF OF A HOST: fork a pane's shell, put bytes on a disk. -//! -//! `host.zig` is the seam — the struct of function pointers the core asks -//! through. This file is the part of the answer that is the same on every host -//! that has an operating system under it, and it is now the ONLY copy of it: -//! tty.zig, detached/server.zig, gui/gui.zig and macos.zig all fork and write -//! through here. They did not always. Each of the four grew its own `forkShell` -//! and its own `writeFd`, and what those four copies were for is best said by -//! what they had in common: ALL FOUR were missing FD_CLOEXEC on the pty master, -//! so in every shell pardes has ever shipped a program in one pane could read -//! and write another pane's terminal, and closing a master did not reliably hang -//! its shell up. One line below fixes that for all four at once (see `forkShell`) -//! — which is a better argument for this file existing than "it is shared" is. -//! -//! Why the daemon and not the frontend does this work: a unix socket means the -//! core and its frontends are on the SAME machine, so there is no question of -//! whose disk or whose process table is meant. Given that, the pane shells -//! belong to the long-lived process, because the whole promise of a detached -//! session is that it outlives the frontend attached to it — a shell forked by -//! a frontend dies with that frontend, and then the session has a pane with no -//! shell in it. The frontend keeps exactly what needs the human's screen: the -//! grid, the keyboard, the clipboard and a link to open. -//! -//! So `forkShell` takes the core it is forking on behalf of and nothing about -//! terminals: no vaxis, no `Loop`, no reader thread. Who drains the master fd -//! is the caller's business, and the callers answer differently on purpose. The -//! tty, gui and macOS shells hand it to a worker that posts into their event -//! loop; the daemon adds it to the one `poll(2)` it already runs over its -//! clients, and makes its own copy non-blocking in order to. That last is why -//! `Child.file.flags` is left saying what it says: the flag describes the -//! descriptor `forkpty` handed back, for the three callers that stream it, and -//! the one that polls it keeps only the handle. +const builtin = @import("builtin"); const std = @import("std"); const posix = std.posix; const libc = std.c; const pardes = @import("pardes.zig"); -const shell_bin = @import("shell_bin.zig"); -const fs_service = @import("fs_service.zig"); -const fuse = @import("fuse.zig"); - -/// `setCloexec` and nothing else. Imported rather than copied a fourth time — -/// fuse.zig and nested.zig each grew a private two-line version of it — because -/// the descriptor this file has to protect is the one every OTHER file in the -/// tree already protects, and one predicate is how the reasoning stays in one -/// place. nested.zig is a leaf (std, builtin, libc), so this costs no -/// dependency worth the name. -const nested = @import("nested.zig"); +const ninep_io = @import("9p_io.zig"); +const filesystem = @import("fs.zig"); + +pub const Host = struct { + ctx: ?*anyopaque = null, + vtable: *const VTable = &.{}, + + pub const VTable = struct { + wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null, + present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null, + post_present: ?*const fn (ctx: ?*anyopaque) void = null, + poll_frame: ?*const fn (ctx: ?*anyopaque) void = null, + detach: ?*const fn (ctx: ?*anyopaque) void = null, + spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null, + pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null, + pty_resize: ?*const fn (ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void = null, + pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void = null, + tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null, + gpio_toggle: ?*const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool = null, + write_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void = null, + write_dump: ?*const fn (ctx: ?*anyopaque, bytes: []const u8) void = null, + watch_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool, mode: pardes.WatchMode) void = null, + watch_theme: ?*const fn (ctx: ?*anyopaque, generation: u32, on: bool) void = null, + dump_themes: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null, + set_clipboard: ?*const fn (ctx: ?*anyopaque, text: []const u8) void = null, + read_clipboard: ?*const fn (ctx: ?*anyopaque) void = null, + open_link: ?*const fn (ctx: ?*anyopaque, url: []const u8) void = null, + lsp: ?*const fn (ctx: ?*anyopaque, req: Lsp.Request) void = null, + pipe: ?*const fn (ctx: ?*anyopaque, id: u32) void = null, + }; +}; + +pub const Fallback = struct { + pub const dump_path = "pardes.dump.zon"; + + gpa: std.mem.Allocator, + files: std.StringHashMapUnmanaged([]u8) = .empty, + clipboard: std.ArrayListUnmanaged(u8) = .empty, + link: std.ArrayListUnmanaged(u8) = .empty, + spawned: [pardes.MAX_PANES]bool = @splat(false), + watched: [pardes.MAX_PANES]bool = @splat(false), + + pub fn deinit(f: *Fallback) void { + var it = f.files.iterator(); + while (it.next()) |entry| { + f.gpa.free(entry.key_ptr.*); + f.gpa.free(entry.value_ptr.*); + } + f.files.deinit(f.gpa); + f.clipboard.deinit(f.gpa); + f.link.deinit(f.gpa); + } + + pub fn writeFile(f: *Fallback, path: []const u8, bytes: []const u8) bool { + const copy = f.gpa.dupe(u8, bytes) catch return false; + if (f.files.getEntry(path)) |entry| { + f.gpa.free(entry.value_ptr.*); + entry.value_ptr.* = copy; + return true; + } + const key = f.gpa.dupe(u8, path) catch { + f.gpa.free(copy); + return false; + }; + f.files.put(f.gpa, key, copy) catch { + f.gpa.free(key); + f.gpa.free(copy); + return false; + }; + return true; + } + + pub fn get(f: *const Fallback, path: []const u8) ?[]const u8 { + if (f.files.get(path)) |written| return written; + return filesystem.sourceBytes(path); + } + + pub fn setClipboard(f: *Fallback, text: []const u8) void { + f.clipboard.clearRetainingCapacity(); + f.clipboard.appendSlice(f.gpa, text) catch {}; + } + + pub fn setLink(f: *Fallback, url: []const u8) void { + f.link.clearRetainingCapacity(); + f.link.appendSlice(f.gpa, url) catch {}; + } +}; + +pub const Lsp = struct { + pub const Request = struct { + id: u32, + kind: pardes.lsp.Kind, + pane: u8, + offset: u32, + arg: []const u8, + }; + + pub const Task = struct { + id: u32, + future: std.Io.Future(anyerror!void), + }; + + /// One language query, owned by the worker that runs it. + pub const Job = struct { + id: u32, + kind: pardes.lsp.Kind, + offset: u32, + path: []u8, + source: [:0]u8, + arg: []u8, + root: []u8, + + pub fn free(job: *Job, gpa: std.mem.Allocator) void { + gpa.free(job.path); + gpa.free(job.source); + gpa.free(job.arg); + gpa.free(job.root); + gpa.destroy(job); + } + }; + + // Copy before starting a worker; the editor may replace any source slice afterward. + pub fn snapshot(gpa: std.mem.Allocator, core: *const pardes.Pardes, req: Request) !*Job { + if (req.pane >= core.panes.len) return error.NoPane; + const pane = core.panes[req.pane] orelse return error.NoPane; + const file = pane.file; + const job = try gpa.create(Job); + errdefer gpa.destroy(job); + const declared_path = if (file) |f| f.path else ""; + const path = try gpa.dupe(u8, filesystem.localPath(declared_path) orelse declared_path); + errdefer gpa.free(path); + const source = try gpa.dupeZ(u8, if (file) |f| f.content else ""); + errdefer gpa.free(source); + const arg = try gpa.dupe(u8, req.arg); + errdefer gpa.free(arg); + const declared_root = if (file) |f| std.fs.path.dirname(f.path) orelse "/" else pane.cwdSlice(); + const root = try gpa.dupe(u8, filesystem.localPath(declared_root) orelse declared_root); + job.* = .{ + .id = req.id, + .kind = req.kind, + .offset = req.offset, + .path = path, + .source = source, + .arg = arg, + .root = root, + }; + return job; + } + + // Null is failure; the receiver frees non-null rows with the job's allocator. + pub const Deliver = *const fn (ctx: ?*anyopaque, id: u32, rows: ?[]u8) void; + + pub fn work(gpa: std.mem.Allocator, job: *Job, ctx: ?*anyopaque, deliver: Deliver) void { + defer job.free(gpa); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + pardes.lsp.query(gpa, arena.allocator(), .{ + .kind = job.kind, + .path = job.path, + .source = job.source, + .offset = job.offset, + .arg = job.arg, + .root = job.root, + }, &out.writer) catch { + deliver(ctx, job.id, null); + return; + }; + const rows = out.toOwnedSlice() catch { + deliver(ctx, job.id, null); + return; + }; + deliver(ctx, job.id, rows); + } + + test "a snapshot owns every byte the backend will read" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + var needle: [6]u8 = "needle".*; + const job = try snapshot(gpa, core, .{ + .id = 7, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = &needle, + }); + defer job.free(gpa); + + try std.testing.expectEqual(@as(u32, 7), job.id); + try std.testing.expectEqual(pardes.lsp.Kind.status, job.kind); + try std.testing.expectEqualStrings("needle", job.arg); + try std.testing.expect(job.arg.ptr != &needle); + try std.testing.expectEqualStrings("", job.path); + try std.testing.expectEqual(@as(usize, 0), job.source.len); + try std.testing.expectEqual(@as(u8, 0), job.source[0]); + const pane = core.panes[core.active].?; + try std.testing.expectEqualStrings(pane.cwdSlice(), job.root); + if (job.root.len > 0) try std.testing.expect(job.root.ptr != pane.cwdSlice().ptr); + } + + test "LSP snapshot frees every partially copied field on allocation failure" { + const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer core.deinit(); + _ = try core.setTestFile("const copied = true;\n"); + const Snapshot = struct { + fn check(gpa: std.mem.Allocator, p: *const pardes.Pardes) !void { + const job = try snapshot(gpa, p, .{ + .id = 7, + .kind = .hover, + .pane = @intCast(p.active), + .offset = 6, + .arg = "query", + }); + defer job.free(gpa); + try std.testing.expectEqualStrings("const copied = true;\n", job.source); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Snapshot.check, .{core}); + } + + test "LSP snapshots translate explicit OS paths once and retain virtual names" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("const value = true;\n"); + for ([_]struct { path: []const u8, native: []const u8 }{ + .{ .path = "/n/os/project/file.zig", .native = "/project/file.zig" }, + .{ .path = "/n/os/n/os/project/file.zig", .native = "/n/os/project/file.zig" }, + .{ .path = "/virtual/src/file.zig", .native = "/virtual/src/file.zig" }, + }) |case| { + const replacement = try gpa.dupe(u8, case.path); + gpa.free(pane.file.?.path); + pane.file.?.path = replacement; + const job = try snapshot(gpa, core, .{ .id = 1, .kind = .hover, .pane = @intCast(core.active), .offset = 0, .arg = "" }); + defer job.free(gpa); + try std.testing.expectEqualStrings(case.native, job.path); + try std.testing.expectEqualStrings(std.fs.path.dirname(case.native).?, job.root); + try std.testing.expectEqualStrings(case.path, pane.file.?.path); + } + } + + test "a pane that is gone yields no job rather than a null deref" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + // The effect is drained after the core has moved on, so the pane it names + // may already have been deleted. Every shell open-coded this check. + const empty = for (core.panes, 0..) |slot, id| { + if (slot == null) break @as(u8, @intCast(id)); + } else return error.NoEmptyPane; + try std.testing.expectError(error.NoPane, snapshot(gpa, core, .{ + .id = 1, + .kind = .definition, + .pane = empty, + .offset = 0, + .arg = "", + })); + } + + test "work consumes the job and hands its rows to the sink" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + const Sink = struct { + var seen_id: u32 = 0; + var seen_rows: ?[]u8 = null; + fn take(_: ?*anyopaque, id: u32, rows: ?[]u8) void { + seen_id = id; + seen_rows = rows; + } + }; + Sink.seen_id = 0; + Sink.seen_rows = null; + + const job = try snapshot(gpa, core, .{ + .id = 42, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }); + work(gpa, job, null, Sink.take); + + // `status` is the one kind that answers with no file and no cursor, which + // is what makes it assertable here without a language server on the box. + try std.testing.expectEqual(@as(u32, 42), Sink.seen_id); + const rows = Sink.seen_rows orelse return error.SinkNeverCalled; + defer gpa.free(rows); + } + + test "LSP edit query failure leaves text and undo untouched before a successful retry" { + if (!pardes.lsp.supports.contains(.format)) return; + const Sink = struct { + core: *pardes.Pardes, + gpa: std.mem.Allocator, + calls: usize = 0, + failed: bool = false, + + fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + defer if (rows) |text| self.gpa.free(text); + self.calls += 1; + self.failed = rows == null; + self.core.update(.{ .lsp_resp = .{ .id = id, .rows = rows } }); + } + }; + const gpa = std.testing.allocator; + for ([_]pardes.lsp.Kind{ .format, .rename }) |kind| { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("const value=1;\n"); + const path = try gpa.dupe(u8, "/file.zig"); + gpa.free(pane.file.?.path); + pane.file.?.path = path; + pane.cur_col = 6; + const revision = pane.file.?.revision; + const undo_len = pane.file.?.history.undo_len; + var failing = std.testing.FailingAllocator.init(gpa, .{}); + var sink: Sink = .{ .core = core, .gpa = failing.allocator() }; + for ([_]bool{ true, false }) |fail| { + failing.fail_index = std.math.maxInt(usize); + core.lspRequest(core.active, kind, "renamed"); + const job = try snapshot(failing.allocator(), core, .{ + .id = core.lsp_wait.?.id, + .kind = kind, + .pane = @intCast(core.active), + .offset = 6, + .arg = "renamed", + }); + if (fail) failing.fail_index = failing.alloc_index; + work(failing.allocator(), job, &sink, Sink.take); + try std.testing.expectEqual(fail, sink.failed); + try std.testing.expect(core.lsp_wait == null); + if (fail) { + try std.testing.expectEqualStrings("const value=1;\n", pane.file.?.content); + try std.testing.expectEqual(revision, pane.file.?.revision); + try std.testing.expectEqual(undo_len, pane.file.?.history.undo_len); + } else { + try std.testing.expectEqualStrings(if (kind == .format) "const value = 1;\n" else "const renamed=1;\n", pane.file.?.content); + try std.testing.expectEqual(undo_len + 1, pane.file.?.history.undo_len); + } + } + try std.testing.expectEqual(@as(usize, 2), sink.calls); + } + } + + test "LSP work delivers failure when transferring result ownership cannot allocate" { + if (!pardes.lsp.supports.contains(.status)) return; + const TransferAllocator = struct { + failed: bool = false, + fail_copy: bool = false, + fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ra: usize) ?[*]u8 { + const self: *@This() = @ptrCast(@alignCast(ctx)); + if (self.fail_copy) { + self.failed = true; + return null; + } + return std.testing.allocator.rawAlloc(len, alignment, ra); + } + fn resize(_: *anyopaque, bytes: []u8, alignment: std.mem.Alignment, len: usize, ra: usize) bool { + return std.testing.allocator.rawResize(bytes, alignment, len, ra); + } + fn remap(ctx: *anyopaque, bytes: []u8, alignment: std.mem.Alignment, len: usize, ra: usize) ?[*]u8 { + const self: *@This() = @ptrCast(@alignCast(ctx)); + if (len < bytes.len) { + self.fail_copy = true; + return null; + } + return std.testing.allocator.rawRemap(bytes, alignment, len, ra); + } + fn free(_: *anyopaque, bytes: []u8, alignment: std.mem.Alignment, ra: usize) void { + std.testing.allocator.rawFree(bytes, alignment, ra); + } + }; + const Sink = struct { + calls: usize = 0, + id: u32 = 0, + rows: ?[]u8 = null, + fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + self.calls += 1; + self.id = id; + self.rows = rows; + } + }; + var allocator: TransferAllocator = .{}; + const gpa: std.mem.Allocator = .{ .ptr = &allocator, .vtable = &.{ + .alloc = TransferAllocator.alloc, + .resize = TransferAllocator.resize, + .remap = TransferAllocator.remap, + .free = TransferAllocator.free, + } }; + const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer core.deinit(); + const job = try snapshot(gpa, core, .{ .id = 37, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = "" }); + var sink: Sink = .{}; + work(gpa, job, &sink, Sink.take); + if (sink.rows) |rows| gpa.free(rows); + try std.testing.expect(allocator.failed); + try std.testing.expectEqual(@as(usize, 1), sink.calls); + try std.testing.expectEqual(@as(u32, 37), sink.id); + try std.testing.expect(sink.rows == null); + } +}; + +test { + _ = Lsp; +} + +pub const Shell = struct { + const X_OK: c_int = 1; + + extern "c" fn mkstemp(template: [*:0]u8) c_int; + extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; + + const path_capacity = 4096; + const max_path_files = 64; + + // Repair only the system-only PATH inherited from a macOS GUI launch. + fn adoptSystemPath() void { + if (comptime builtin.os.tag != .macos) return; + var buf: [path_capacity]u8 = undefined; + var len: usize = 0; + collectSystemPath("/etc/paths", "/etc/paths.d", &buf, &len); + if (len == 0) return; + const system = buf[0..len]; + + const current: []const u8 = if (libc.getenv("PATH")) |p| std.mem.span(p) else ""; + if (!allEntriesWithin(current, system)) return; + if (std.mem.eql(u8, current, system)) return; + + var out: [path_capacity:0]u8 = undefined; + if (len >= out.len) return; + @memcpy(out[0..len], system); + out[len] = 0; + _ = setenv("PATH", out[0..len :0].ptr, 1); + } + + // Run in the parent before forking; children borrow the completed prompt files. + pub fn prepare() PromptFiles { + adoptSystemPath(); + if (comptime builtin.os.tag.isDarwin()) + _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); + return PromptFiles.init(); + } + + fn collectSystemPath(paths_file: [:0]const u8, paths_dir: []const u8, buf: []u8, len: *usize) void { + var file_buf: [path_capacity]u8 = undefined; + if (readSmall(paths_file, &file_buf)) |body| appendLines(buf, len, body); + + const io = std.Io.Threaded.global_single_threaded.io(); + var dir = std.Io.Dir.cwd().openDir(io, paths_dir, .{ .iterate = true }) catch return; + defer dir.close(io); + + var names: [max_path_files][256]u8 = undefined; + var name_lens: [max_path_files]usize = undefined; + var count: usize = 0; + var it = dir.iterate(); + while (count < names.len) { + const entry = (it.next(io) catch break) orelse break; + if (entry.kind == .directory) continue; + if (entry.name.len == 0 or entry.name.len > names[count].len) continue; + @memcpy(names[count][0..entry.name.len], entry.name); + name_lens[count] = entry.name.len; + count += 1; + } + var order: [max_path_files]usize = undefined; + for (0..count) |i| order[i] = i; + std.mem.sort(usize, order[0..count], Names{ .names = &names, .lens = &name_lens }, Names.lessThan); + + var path_buf: [512]u8 = undefined; + for (order[0..count]) |i| { + const name = names[i][0..name_lens[i]]; + const path = std.fmt.bufPrintSentinel(&path_buf, "{s}/{s}", .{ paths_dir, name }, 0) catch continue; + if (readSmall(path, &file_buf)) |body| appendLines(buf, len, body); + } + } + + const Names = struct { + names: *const [max_path_files][256]u8, + lens: *const [max_path_files]usize, + + fn lessThan(self: Names, a: usize, b: usize) bool { + return std.mem.order(u8, self.names[a][0..self.lens[a]], self.names[b][0..self.lens[b]]) == .lt; + } + }; + + fn appendLines(buf: []u8, len: *usize, body: []const u8) void { + var lines = std.mem.splitScalar(u8, body, '\n'); + while (lines.next()) |raw| appendEntry(buf, len, std.mem.trim(u8, raw, " \t\r")); + } + + fn appendEntry(buf: []u8, len: *usize, entry: []const u8) void { + if (entry.len == 0) return; + if (hasEntry(buf[0..len.*], entry)) return; + const separator: usize = if (len.* == 0) 0 else 1; + if (len.* + separator + entry.len > buf.len) return; + if (separator == 1) { + buf[len.*] = ':'; + len.* += 1; + } + @memcpy(buf[len.*..][0..entry.len], entry); + len.* += entry.len; + } + + fn hasEntry(list: []const u8, entry: []const u8) bool { + var it = std.mem.tokenizeScalar(u8, list, ':'); + while (it.next()) |have| if (std.mem.eql(u8, have, entry)) return true; + return false; + } + + fn allEntriesWithin(candidate: []const u8, list: []const u8) bool { + var it = std.mem.tokenizeScalar(u8, candidate, ':'); + while (it.next()) |entry| if (!hasEntry(list, entry)) return false; + return true; + } + + fn readSmall(path: [:0]const u8, buf: []u8) ?[]const u8 { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); + if (fd < 0) return null; + defer _ = libc.close(fd); + var off: usize = 0; + while (off < buf.len) { + const n = libc.read(fd, buf[off..].ptr, buf.len - off); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return null; + } + if (n == 0) break; + off += @intCast(n); + } + return buf[0..off]; + } + + test "the launchd PATH is replaced and a configured one is left alone" { + var buf: [256]u8 = undefined; + var len: usize = 0; + appendEntry(&buf, &len, "/usr/bin"); + appendEntry(&buf, &len, "/bin"); + appendEntry(&buf, &len, "/usr/bin"); // already there: dedup keeps the first + appendEntry(&buf, &len, ""); + try std.testing.expectEqualStrings("/usr/bin:/bin", buf[0..len]); + + try std.testing.expect(allEntriesWithin("/usr/bin:/bin", "/usr/bin:/bin:/sbin")); + try std.testing.expect(allEntriesWithin("", "/usr/bin")); + try std.testing.expect(!allEntriesWithin("/Users/x/.cargo/bin:/usr/bin", "/usr/bin:/bin")); + try std.testing.expect(!allEntriesWithin("/opt/homebrew/bin", "/usr/bin:/bin")); + } + + test "system path files are sorted and duplicate directories keep their first position" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "paths", .data = " /usr/bin \n/bin\n\n/usr/bin\n" }); + try tmp.dir.createDirPath(std.testing.io, "paths.d"); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "paths.d/20-last", .data = "/opt/local/bin\n/usr/bin\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "paths.d/10-first", .data = "/opt/homebrew/bin\n/bin\n" }); + var root_buf: [std.fs.max_path_bytes]u8 = undefined; + const root = root_buf[0..try tmp.dir.realPath(std.testing.io, &root_buf)]; + var file_buf: [std.fs.max_path_bytes]u8 = undefined; + const paths_file = try std.fmt.bufPrintSentinel(&file_buf, "{s}/paths", .{root}, 0); + var dir_buf: [std.fs.max_path_bytes]u8 = undefined; + const paths_dir = try std.fmt.bufPrint(&dir_buf, "{s}/paths.d", .{root}); + var buf: [path_capacity]u8 = undefined; + var len: usize = 0; + collectSystemPath(paths_file, paths_dir, &buf, &len); + try std.testing.expectEqualStrings("/usr/bin:/bin:/opt/homebrew/bin:/opt/local/bin", buf[0..len]); + } + + const Family = enum { bash, fish, none }; + + fn family(bin: []const u8) Family { + const slash = std.mem.lastIndexOfScalar(u8, bin, '/'); + const base = if (slash) |s| bin[s + 1 ..] else bin; + if (std.mem.startsWith(u8, base, "bash")) return .bash; + if (std.mem.startsWith(u8, base, "fish")) return .fish; + return .none; + } + + const bash_rc = + \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" + \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' + \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + \\trap 'printf "\e]133;C\a"' DEBUG + \\ + ; + + // fish -C runs after config.fish; bash --rcfile must source .bashrc itself. + const fish_rc = + \\functions -c fish_prompt __pardes_user_prompt + \\function fish_prompt + \\ printf '\e]133;A;cl=line\a' + \\ __pardes_user_prompt + \\ printf '\e]133;B\a' + \\end + \\function __pardes_preexec --on-event fish_preexec + \\ printf '\e]133;C\a' + \\end + \\function __pardes_postexec --on-event fish_postexec + \\ printf '\e]133;D\a' + \\end + \\ + ; + + const rc_path_capacity = 64; + + // Private files live until host teardown. Lengths keep this value movable. + pub const PromptFiles = struct { + bash_path: [rc_path_capacity:0]u8 = @splat(0), + bash_len: u8 = 0, + fish_path: [rc_path_capacity:0]u8 = @splat(0), + fish_len: u8 = 0, + fish_command: [rc_path_capacity + "source ".len:0]u8 = @splat(0), + fish_command_len: u8 = 0, + + pub fn init() PromptFiles { + var rcs: PromptFiles = .{}; + rcs.bash_len = stage(&rcs.bash_path, "/tmp/pardes-osc133-bash-XXXXXX", bash_rc); + rcs.fish_len = stage(&rcs.fish_path, "/tmp/pardes-osc133-fish-XXXXXX", fish_rc); + if (rcs.fishPath()) |path| { + const command = std.fmt.bufPrintSentinel(&rcs.fish_command, "source {s}", .{path}, 0) catch { + _ = libc.unlink(path.ptr); + rcs.fish_len = 0; + return rcs; + }; + rcs.fish_command_len = @intCast(command.len); + } + return rcs; + } + + pub fn deinit(rcs: *PromptFiles) void { + if (rcs.bashPath()) |path| _ = libc.unlink(path.ptr); + if (rcs.fishPath()) |path| _ = libc.unlink(path.ptr); + rcs.bash_len = 0; + rcs.fish_len = 0; + rcs.fish_command_len = 0; + } + + fn bashPath(rcs: *const PromptFiles) ?[:0]const u8 { + if (rcs.bash_len == 0) return null; + return rcs.bash_path[0..rcs.bash_len :0]; + } + + fn fishPath(rcs: *const PromptFiles) ?[:0]const u8 { + if (rcs.fish_len == 0) return null; + return rcs.fish_path[0..rcs.fish_len :0]; + } + + fn fishCommand(rcs: *const PromptFiles) ?[:0]const u8 { + if (rcs.fish_command_len == 0) return null; + return rcs.fish_command[0..rcs.fish_command_len :0]; + } + }; + + // Publish a path only after its private 0600 file is fully written and closed. + fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u8 { + const path = std.fmt.bufPrintSentinel(path_buf, "{s}", .{template}, 0) catch return 0; + const fd = mkstemp(path.ptr); + if (fd < 0) return 0; + var off: usize = 0; + while (off < contents.len) { + const n = libc.write(fd, contents[off..].ptr, contents.len - off); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + _ = libc.close(fd); + _ = libc.unlink(path.ptr); + return 0; + } + if (n == 0) { + _ = libc.close(fd); + _ = libc.unlink(path.ptr); + return 0; + } + off += @intCast(n); + } + if (libc.close(fd) != 0) { + _ = libc.unlink(path.ptr); + return 0; + } + return @intCast(path.len); + } + + test "shell family is the basename's prefix, and anything else runs unadorned" { + try std.testing.expectEqual(Family.fish, family("fish")); + try std.testing.expectEqual(Family.fish, family("/usr/bin/fish")); + try std.testing.expectEqual(Family.fish, family("/opt/homebrew/bin/fish")); + try std.testing.expectEqual(Family.bash, family("bash")); + try std.testing.expectEqual(Family.bash, family("/bin/bash")); + try std.testing.expectEqual(Family.bash, family("/usr/bin/bash-5.2")); + try std.testing.expectEqual(Family.fish, family("/usr/local/bin/fish-3.7")); + try std.testing.expectEqual(Family.none, family("/opt/fish/bin/nu")); + try std.testing.expectEqual(Family.none, family("/usr/bin/zsh")); + try std.testing.expectEqual(Family.none, family("/bin/sh")); + try std.testing.expectEqual(Family.none, family("nu")); + try std.testing.expectEqual(Family.none, family("")); + } + + const bin_dirs = [_][]const u8{ + "/usr/bin/", + "/bin/", + "/usr/local/bin/", + "/opt/homebrew/bin/", + "/opt/local/bin/", + "/usr/sbin/", + }; + + const fallbacks = [_][]const u8{ + if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash", + "/bin/sh", + }; + + pub const Spawn = struct { + path: [*:0]const u8, + argv: [4:null]?[*:0]const u8, + }; + + // Resolve in the parent. The path buffer and prompt files must survive through exec. + pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8, prompt_rcs: *const PromptFiles) Spawn { + const path = find(bin, buf) orelse fallback(buf); + const marks: [2]?[*:0]const u8 = switch (family(std.mem.span(path))) { + .bash => if (prompt_rcs.bashPath()) |rc| .{ "--rcfile", rc.ptr } else .{ null, null }, + .fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null }, + .none => .{ null, null }, + }; + return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } }; + } + + fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 { + if (bin.len == 0 or bin.len + 1 > buf.len) return null; + if (std.mem.indexOfScalar(u8, bin, '/') != null) { + @memcpy(buf[0..bin.len], bin); + buf[bin.len] = 0; + const p: [*:0]const u8 = @ptrCast(buf); + return if (libc.access(p, X_OK) == 0) p else null; + } + for (bin_dirs) |dir| { + if (dir.len + bin.len + 1 > buf.len) continue; + @memcpy(buf[0..dir.len], dir); + @memcpy(buf[dir.len..][0..bin.len], bin); + buf[dir.len + bin.len] = 0; + const p: [*:0]const u8 = @ptrCast(buf); + if (libc.access(p, X_OK) == 0) return p; + } + return null; + } + + fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 { + for (fallbacks) |f| { + @memcpy(buf[0..f.len], f); + buf[f.len] = 0; + const p: [*:0]const u8 = @ptrCast(buf); + if (libc.access(p, X_OK) == 0) return p; + } + return @ptrCast(buf); + } + + test "a path is taken at its word, a name is looked up, and both pick their own marks" { + if (builtin.os.tag == .windows) return; + var buf: [std.fs.max_path_bytes]u8 = undefined; + var prompt_rcs = PromptFiles.init(); + defer prompt_rcs.deinit(); + + const sh = resolve("/bin/sh", &buf, &prompt_rcs); + try std.testing.expectEqualStrings("/bin/sh", std.mem.span(sh.path)); + try std.testing.expect(sh.argv[1] == null); + + const bash = resolve("bash", &buf, &prompt_rcs); + try std.testing.expect(family(std.mem.span(bash.path)) == .bash); + try std.testing.expectEqualStrings("--rcfile", std.mem.span(bash.argv[1].?)); + try std.testing.expectEqualStrings(prompt_rcs.bashPath().?, std.mem.span(bash.argv[2].?)); + + const missing = resolve("zznosuchshell", &buf, &prompt_rcs); + try std.testing.expect(!std.mem.eql(u8, "zznosuchshell", std.mem.span(missing.path))); + try std.testing.expect(libc.access(missing.path, X_OK) == 0); + + const gone = resolve("/zz/no/such/shell", &buf, &prompt_rcs); + try std.testing.expect(libc.access(gone.path, X_OK) == 0); + } + + test "prompt rc owners have private complete files and clean them up" { + if (builtin.os.tag == .windows) return; + var original = PromptFiles.init(); + var a = original; + original = .{}; + original.deinit(); + defer a.deinit(); + var b = PromptFiles.init(); + defer b.deinit(); + const a_bash = a.bashPath() orelse return error.TempCreateFailed; + const b_bash = b.bashPath() orelse return error.TempCreateFailed; + const a_fish = a.fishPath() orelse return error.TempCreateFailed; + try std.testing.expect(!std.mem.eql(u8, a_bash, b_bash)); + const fish_command = a.fishCommand() orelse return error.MissingFishCommand; + try std.testing.expectEqualStrings("source ", fish_command[0.."source ".len]); + try std.testing.expectEqualStrings(a_fish, fish_command["source ".len..]); + for ([_][]const u8{ a_bash, b_bash, a_fish }) |path| { + const stat = try std.Io.Dir.cwd().statFile(std.testing.io, path, .{}); + try std.testing.expectEqual(std.Io.File.Kind.file, stat.kind); + try std.testing.expectEqual(0, stat.permissions.toMode() & 0o077); + } + var fish_buf: [fish_rc.len]u8 = undefined; + try std.testing.expectEqualStrings(fish_rc, readSmall(a_fish, &fish_buf) orelse return error.ReadFailed); + + var buf: [bash_rc.len]u8 = undefined; + const fd = libc.open(a_bash.ptr, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return error.OpenFailed; + defer _ = libc.close(fd); + var len: usize = 0; + while (len < buf.len) { + const n = libc.read(fd, buf[len..].ptr, buf.len - len); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return error.ReadFailed; + } + if (n == 0) break; + len += @intCast(n); + } + try std.testing.expectEqualStrings(bash_rc, buf[0..len]); + + var removed: [rc_path_capacity:0]u8 = @splat(0); + @memcpy(removed[0..a_bash.len], a_bash); + removed[a_bash.len] = 0; + a.deinit(); + try std.testing.expect(libc.access(&removed, 0) < 0); + try std.testing.expectEqualStrings(bash_rc, readSmall(b_bash, &buf) orelse return error.ReadFailed); + } +}; extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; -/// A forked pane shell: the pty master to read and write, and the pid to reap. -/// Named rather than anonymous because four files now hold one of these. pub const Child = struct { file: std.Io.File, pid: posix.pid_t, }; -/// Fork a shell onto a fresh pty for `pane`, sized `rows`x`cols`. -/// -/// `core` is optional because a host may fork before it has one, and a core -/// that is absent simply does not name its shell. pub fn forkShell( core: ?*pardes.Pardes, pane: usize, - prompt_rcs: *const shell_bin.PromptRcs, + prompt_rcs: *const Shell.PromptFiles, bin: []const u8, - cwd: ?[*:0]const u8, + cwd: []const u8, rows: u16, cols: u16, - fs: ?*const fuse.Fs, -) Child { - var master: c_int = undefined; - // resolved BEFORE the fork, into this frame, which the child inherits: - // nothing between fork and exec may allocate, and a PATH search would + fs: ?*const ninep_io.Listener, +) !Child { + const native_cwd = filesystem.localPath(cwd) orelse cwd; + if (std.mem.indexOfScalar(u8, native_cwd, 0) != null) return error.InvalidPath; + var cwd_buf: [4096]u8 = undefined; + const cwd_z: ?[:0]const u8 = if (native_cwd.len == 0) null else dir: { + const path = std.fmt.bufPrintSentinel(&cwd_buf, "{s}", .{native_cwd}, 0) catch return error.NameTooLong; + const stat = try std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), path, .{}); + if (stat.kind != .directory) return error.NotDir; + break :dir path; + }; + var master: c_int = -1; var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs); - // ...and so is the pane's own address on the control filesystem, for a - // second reason on top of that one: acme puts `winid` in the child, which - // is safe there only because rfork(RFENVG) has just given it a private - // environment group. See fs_service.exportPaneEnv. - fs_service.exportPaneEnv(fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0); + const spawn = Shell.resolve(bin, &path_buf, prompt_rcs); + ninep_io.exportPaneEnv( + fs, + if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0, + if (core) |c| !c.opts.nested else false, + ); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); + if (pid < 0) return error.ForkFailed; if (pid == 0) { - // the blocked-SIGWINCH mask survives fork AND exec — unblock it or - // bash/vim in the pane would never see resizes (sigprocmask is - // async-signal-safe) var set = posix.sigemptyset(); posix.sigaddset(&set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.UNBLOCK, &set, null); - if (cwd) |c| _ = chdir(c); + if (cwd_z) |path| if (chdir(path.ptr) != 0) _exit(126); _ = execv(spawn.path, &spawn.argv); _exit(127); } - if (pid > 0) { - // CLOEXEC ON THE MASTER, and it belongs here rather than at either - // caller because `forkpty` is what opens it: /dev/ptmx is opened with no - // O_CLOEXEC and there is no flag argument to ask for one. Without this, - // every pane shell forked AFTER this one inherits this master and keeps - // it across `execv`, which is two bugs at once. - // - // The loud one: a program running in pane 3 can read pane 0's output and - // write bytes into pane 0's screen. - // - // The silent one, and the reason it compounds: closing a master is the - // only thing that hangs its shell up, and a master a later shell still - // holds open is not closed. detached/server.zig `closePty` and tty.zig - // `spawn` both depend on that hangup, so a pane delete or a respawn left - // an orphaned shell that never exits — never reaped, eventually blocked - // writing into a pty nobody reads — and each orphan pinned every earlier - // pane's master in turn. The startup drain forks pane 0 and then pane 1, - // so the arrangement existed from boot, and it existed in all four - // copies of this function before they became this one. nested.zig and - // fuse.zig say the same thing about their own descriptors ("pane shells - // are forked with forkpty and inherit everything open"); the master was - // the one descriptor in the tree that nobody had said it to. - // - // THE WINDOW THIS LEAVES, stated rather than papered over: fcntl after - // fork is not atomic, so a thread that forks and execs between these two - // syscalls inherits the master anyway. In the detached daemon there is no - // such thread — it is single-threaded by construction, which is what - // putting the pty masters in its own `poll(2)` bought. The shells with - // worker threads that can exec — tty.zig's pipe tasks above all — have a - // window two syscalls wide, and closing it means replacing `forkpty` with - // our own `posix_openpt(O_CLOEXEC)` / `grantpt` / `unlockpt` / fork / - // `setsid`, which is a different change to a different file. - nested.setCloexec(master); - if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null); - } + ninep_io.setCloexec(master); + if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null); return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; } -/// Truncate-or-create `path` and put `bytes` there. False on any failure, and -/// the caller reports it: a save that did not happen must not be announced as -/// one. -/// WHY it failed, and not merely that it did. A save is the one operation in -/// this program whose failure a user must not be able to miss, and until this -/// returned an error there was nothing for a host to put on the message row: -/// the bool said "no" and every caller answered it with a bare `return`. -/// `NoSpaceLeft` is the one that most needs saying — the file has already been -/// truncated by the time it happens, so a save that reports nothing has -/// destroyed the file it was asked to preserve. -pub const WriteError = error{ - PathTooLong, - PermissionDenied, - IsDirectory, - ReadOnlyFilesystem, - NoSpaceLeft, - OpenFailed, - WriteFailed, -}; +test "shell spawn rejects invalid directories before creating a child" { + const rcs: Shell.PromptFiles = .{}; + try std.testing.expectError(error.InvalidPath, forkShell(null, 0, &rcs, "/bin/sh", "/tmp\x00/ignored", 24, 80, null)); + const too_long = [_]u8{'x'} ** 4096; + try std.testing.expectError(error.NameTooLong, forkShell(null, 0, &rcs, "/bin/sh", &too_long, 24, 80, null)); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "file", .data = "not a directory\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(std.testing.io, &directory_buf)]; + var path_buf: [4096]u8 = undefined; + const file = try std.fmt.bufPrint(&path_buf, "{s}/file", .{directory}); + try std.testing.expectError(error.NotDir, forkShell(null, 0, &rcs, "/bin/sh", file, 24, 80, null)); + const explicit_file = try std.fmt.bufPrint(&path_buf, "/n/os{s}/file", .{directory}); + try std.testing.expectError(error.NotDir, forkShell(null, 0, &rcs, "/bin/sh", explicit_file, 24, 80, null)); + const missing = try std.fmt.bufPrint(&path_buf, "{s}/missing/" ++ ("child/" ** 50), .{directory}); + try std.testing.expect(missing.len > 256); + try std.testing.expectError(error.FileNotFound, forkShell(null, 0, &rcs, "/bin/sh", missing, 24, 80, null)); +} + +test "shell spawn uses an explicit OS directory longer than 256 bytes" { + if (!haveFile("/bin/sh")) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const sub_path = "nested-directory-with-more-than-forty-characters/" ** 7; + try tmp.dir.createDirPath(std.testing.io, sub_path); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPathFile(std.testing.io, sub_path, &directory_buf)]; + try std.testing.expect(directory.len > 256); + var explicit_buf: [4096]u8 = undefined; + const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{directory}); + const rcs: Shell.PromptFiles = .{}; + const child = try forkShell(null, 0, &rcs, "/bin/sh", explicit, 24, 80, null); + defer { + _ = libc.kill(child.pid, libc.SIG.KILL); + _ = libc.waitpid(child.pid, null, 0); + _ = libc.close(child.file.handle); + } + var sh: TestShell = .{ .master = child.file.handle, .pid = child.pid }; + try std.testing.expect(writeFd(child.file.handle, "printf '\\nPARDES-CWD:'; pwd; exit\n")); + var expected_buf: [4096]u8 = undefined; + const expected = try std.fmt.bufPrint(&expected_buf, "PARDES-CWD:{s}", .{directory}); + try std.testing.expect(sh.waitText(expected, 5_000)); +} -pub fn writeFileBytes(path: []const u8, bytes: []const u8) WriteError!void { - var pathbuf: [4096:0]u8 = undefined; - if (path.len >= pathbuf.len) return error.PathTooLong; - @memcpy(pathbuf[0..path.len], path); - pathbuf[path.len] = 0; - const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) return switch (libc.errno(fd)) { - .ACCES, .PERM => error.PermissionDenied, - .ISDIR => error.IsDirectory, - .ROFS => error.ReadOnlyFilesystem, - .NOSPC, .DQUOT => error.NoSpaceLeft, - .NAMETOOLONG => error.PathTooLong, - else => error.OpenFailed, - }; - const wrote = writeFd(fd, bytes); - // The close is part of the write. NFS and every write-back filesystem - // report a deferred error here and nowhere else, so a close that fails on a - // file we believe we wrote is a file we did not write. - const closed = libc.close(fd) == 0; - if (!wrote or !closed) return error.WriteFailed; -} - -/// A whole-buffer write that finishes short writes, retries EINTR, and refuses -/// to loop on no progress. -/// -/// The zero guard is not bookkeeping: without it a `write(2)` that returns 0 for -/// a nonzero count is an infinite SPIN, because 0 is neither an error nor -/// progress and `off` never moves. macos.zig's copy carried the guard and its -/// reason all along — "a zero-byte write makes no progress; looping on it would -/// spin the main thread forever" — and the tty copy this file was extracted -/// from did not, so the extraction briefly promoted the weakest of the three to -/// being the shared one. All three are now this one: gui.zig and macos.zig were -/// migrated onto it, so the guard is no longer missing anywhere. -/// -/// A spin is strictly worse than the block it replaces, which is why this -/// matters more now that detached/server.zig reaches this file from a -/// single-threaded poll loop: a blocked `write` is one syscall a signal can -/// interrupt, and a spin is 100% of a core with the whole session behind it. -/// True when every byte went. The answer is new: this used to return `void`, so -/// a full disk and a completed write were the same event to every caller — and -/// the one caller that matters had already truncated the file. A pty write -/// ignores it, which is what `_ =` at those call sites means. pub fn writeFd(fd: c_int, data: []const u8) bool { var off: usize = 0; while (off < data.len) { @@ -208,3 +947,512 @@ pub fn writeFd(fd: c_int, data: []const u8) bool { } return true; } + +const vnode_info_path = extern struct { + vi: [152]u8 align(8), // struct vnode_info: vinfo_stat + type + pad + fsid + path: [1024]u8, // MAXPATHLEN +}; +const proc_vnodepathinfo = extern struct { + cdir: vnode_info_path, + rdir: vnode_info_path, +}; +const PROC_PIDVNODEPATHINFO: c_int = 9; +extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; + +pub fn shellCwd(pid: libc.pid_t, buf: []u8) ?[]const u8 { + switch (builtin.os.tag) { + .linux => { + var pbuf: [64]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null; + const n = libc.readlink(path, buf.ptr, buf.len); + if (n <= 0 or n >= buf.len) return null; + return buf[0..@intCast(n)]; + }, + .macos, .ios, .tvos, .watchos, .visionos => { + var info: proc_vnodepathinfo = undefined; + const n = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, @sizeOf(proc_vnodepathinfo)); + if (n < @as(c_int, @sizeOf(proc_vnodepathinfo))) return null; + const path = std.mem.sliceTo(&info.cdir.path, 0); + if (path.len == 0 or path.len == info.cdir.path.len or path.len > buf.len) return null; + @memcpy(buf[0..path.len], path); + return buf[0..path.len]; + }, + else => return null, + } +} + +test "shell cwd rejects truncation and preserves an owned child path longer than 1024 bytes" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + if (!haveFile("/bin/sh")) return error.SkipZigTest; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const nested = "nested-directory-with-more-than-forty-characters/" ** 24; + try tmp.dir.createDirPath(io, nested); + var path_buf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + const directory = path_buf[0..try tmp.dir.realPathFile(io, nested, &path_buf)]; + try std.testing.expect(directory.len > 1024); + const rcs: Shell.PromptFiles = .{}; + const child = try forkShell(null, 0, &rcs, "/bin/sh", directory, 24, 80, null); + defer { + _ = libc.kill(child.pid, libc.SIG.KILL); + _ = libc.waitpid(child.pid, null, 0); + _ = libc.close(child.file.handle); + } + var sh: TestShell = .{ .master = child.file.handle, .pid = child.pid }; + try std.testing.expect(writeFd(child.file.handle, "printf '\\160ardes-cwd-ready\\n'\n")); + try std.testing.expect(sh.waitText("pardes-cwd-ready", 5_000)); + var result: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + try std.testing.expect(shellCwd(child.pid, result[0..0]) == null); + try std.testing.expect(shellCwd(child.pid, result[0..1024]) == null); + try std.testing.expect(shellCwd(child.pid, result[0..directory.len]) == null); + try std.testing.expectEqualStrings(directory, shellCwd(child.pid, result[0 .. directory.len + 1]) orelse return error.MissingCwd); + try std.testing.expectEqualStrings(directory, shellCwd(child.pid, &result) orelse return error.MissingCwd); +} + +extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t; + +const occ_max_depth: u8 = 8; +const occ_max_visited: usize = 32; + +const TtyProbe = struct { + self_exe: [std.fs.max_path_bytes]u8 = undefined, + exe: [std.fs.max_path_bytes]u8 = undefined, + blob: [4096]u8 = undefined, + pending: [occ_max_visited]Node = undefined, + + const Node = struct { pid: libc.pid_t, depth: u8 }; +}; + +pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool { + switch (builtin.os.tag) { + .linux => { + var probe: TtyProbe = undefined; + const fg = tcgetpgrp(master_fd); + if (fg < 0) return false; + const self_exe = procExe(shell_pid, &probe.self_exe) orelse return false; + + var saw_fg = fg == shell_pid; + var pending: usize = 0; + var visited: usize = 0; + switch (pushChildren(&probe, &pending, shell_pid, 1)) { + .pushed => {}, + .unreadable => return false, + .full => return true, + } + + while (pending > 0) { + pending -= 1; + const node = probe.pending[pending]; + visited += 1; + if (visited > occ_max_visited) return true; + + const pgrp = procPgrp(node.pid, &probe.blob); + if (pgrp) |g| { + if (g == fg) saw_fg = true; + } + + const exe = procExe(node.pid, &probe.exe) orelse { + if (offTty(node.pid, &probe.blob)) continue; + return true; + }; + if (!std.mem.eql(u8, exe, self_exe)) { + if (pgrp) |g| if (g == fg) return true; + continue; + } + if (node.depth >= occ_max_depth) return true; + switch (pushChildren(&probe, &pending, node.pid, node.depth + 1)) { + .pushed => {}, + .unreadable => {}, + .full => return true, + } + } + return !saw_fg; + }, + else => return false, + } +} + +pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void { + const sig = switch (which) { + .int => libc.SIG.INT, + .term => libc.SIG.TERM, + .hup => libc.SIG.HUP, + .quit => libc.SIG.QUIT, + .kill => libc.SIG.KILL, + }; + const fg = tcgetpgrp(master_fd); + if (fg > 0) { + _ = libc.kill(-fg, sig); + return; + } + if (shell_pid > 0) _ = libc.kill(shell_pid, sig); +} + +fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return null; + defer _ = libc.close(fd); + const got = libc.read(fd, buf.ptr, buf.len); + if (got <= 0) return null; + return buf[0..@intCast(got)]; +} + +fn procExe(pid: libc.pid_t, buf: *[std.fs.max_path_bytes]u8) ?[]const u8 { + var name: [64:0]u8 = undefined; + const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; + const n = libc.readlink(link, buf, buf.len); + if (n <= 0) return null; + return buf[0..@intCast(n)]; +} + +fn procPgrp(pid: libc.pid_t, buf: *[4096]u8) ?libc.pid_t { + var name: [64:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/stat", .{@as(u32, @intCast(pid))}, 0) catch return null; + return parsePgrp(readProc(path, buf) orelse return null); +} + +fn parsePgrp(stat: []const u8) ?libc.pid_t { + const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null; + var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n"); + _ = fields.next() orelse return null; // 3: state + _ = fields.next() orelse return null; // 4: ppid + const pgrp = fields.next() orelse return null; // 5: pgrp + return std.fmt.parseInt(libc.pid_t, pgrp, 10) catch null; +} + +fn offTty(pid: libc.pid_t, buf: *[4096]u8) bool { + var name: [64:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return false; + const status = readProc(path, buf) orelse return true; + return parseZombie(status); +} + +fn parseZombie(status: []const u8) bool { + var lines = std.mem.splitScalar(u8, status, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "State:")) continue; + const state = std.mem.trim(u8, line["State:".len..], " \t\r"); + return state.len > 0 and state[0] == 'Z'; + } + return false; +} + +const Pushed = enum { pushed, unreadable, full }; + +fn pushChildren(probe: *TtyProbe, pending: *usize, pid: libc.pid_t, depth: u8) Pushed { + var name: [96:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/task/{d}/children", .{ + @as(u32, @intCast(pid)), @as(u32, @intCast(pid)), + }, 0) catch return .unreadable; + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return .unreadable; + defer _ = libc.close(fd); + const got = libc.read(fd, &probe.blob, probe.blob.len); + if (got < 0) return .unreadable; + + var kids: [occ_max_visited]libc.pid_t = undefined; + const total = parseChildren(probe.blob[0..@intCast(got)], &kids); + if (total > kids.len or pending.* + total > probe.pending.len) return .full; + for (kids[0..total]) |kid| { + probe.pending[pending.*] = .{ .pid = kid, .depth = depth }; + pending.* += 1; + } + return .pushed; +} + +fn parseChildren(text: []const u8, out: []libc.pid_t) usize { + var total: usize = 0; + var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); + while (it.next()) |tok| { + if (std.mem.indexOfNone(u8, tok, "0123456789") != null) continue; + const kid = std.fmt.parseInt(libc.pid_t, tok, 10) catch continue; + if (total < out.len) out[total] = kid; + total += 1; + } + return total; +} + +test "the children blob parses to pids, and a garbage token never becomes one" { + var out: [8]libc.pid_t = undefined; + try std.testing.expectEqual(@as(usize, 0), parseChildren("", &out)); + try std.testing.expectEqual(@as(usize, 0), parseChildren(" ", &out)); + try std.testing.expectEqual(@as(usize, 1), parseChildren("991 ", &out)); + try std.testing.expectEqual(@as(libc.pid_t, 991), out[0]); + try std.testing.expectEqual(@as(usize, 3), parseChildren("7 8 9 ", &out)); + try std.testing.expectEqualSlices(libc.pid_t, &.{ 7, 8, 9 }, out[0..3]); + try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12", &out)); + try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12\n", &out)); + try std.testing.expectEqual(@as(usize, 2), parseChildren("5 -1 +7 0x3 abc 6 ", &out)); + try std.testing.expectEqualSlices(libc.pid_t, &.{ 5, 6 }, out[0..2]); + var two: [2]libc.pid_t = undefined; + try std.testing.expectEqual(@as(usize, 4), parseChildren("1 2 3 4 ", &two)); + try std.testing.expectEqualSlices(libc.pid_t, &.{ 1, 2 }, two[0..2]); +} + +test "the process group comes off the last ')', not a comm-shifted stat field" { + const shifted = "1234 (sh (a b)) S 991 992 993 34816 992 4194560 " ++ + "1729 0 0 0 1 0 0 0 20 0 1 0 8244630 9887744 1131"; + try std.testing.expectEqual(@as(libc.pid_t, 992), parsePgrp(shifted).?); + try std.testing.expectEqual(@as(libc.pid_t, 7), parsePgrp("42 (bash) S 1 7 7 34816 7 4194304").?); + try std.testing.expectEqual(@as(libc.pid_t, 42), parsePgrp("42 (sleep) S 7 42 7 0 -1").?); + try std.testing.expect(parsePgrp("") == null); + try std.testing.expect(parsePgrp("1234 (bash) S 991") == null); + try std.testing.expect(parsePgrp("1234 (bash) S 991 notanumber") == null); + try std.testing.expect(parsePgrp("no parens here at all") == null); +} + +test "the zombie state comes off its own status line" { + try std.testing.expect(parseZombie("Name:\tsh (a b)\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t1234\n")); + try std.testing.expect(parseZombie("State:\tZ (zombie)\n")); + try std.testing.expect(!parseZombie("Name:\tsh\nState:\tS (sleeping)\n")); + try std.testing.expect(!parseZombie("Name:\tvim\nState:\tR (running)\n")); + try std.testing.expect(!parseZombie("Name:\tvim\nState:\tT (stopped)\n")); + try std.testing.expect(!parseZombie("Name:\tsh (State: Z)\nState:\tS (sleeping)\n")); + try std.testing.expect(!parseZombie("Name:\tsh\nSta")); + try std.testing.expect(!parseZombie("State:\t")); +} + +const test_shell = "/bin/bash"; +const test_prompt = "PZX> "; + +const TestShell = struct { + master: c_int, + pid: libc.pid_t, + tail: [8192]u8 = undefined, + tail_len: usize = 0, + + fn start() ?TestShell { + if (!haveFile(test_shell)) return null; + var master: c_int = undefined; + const ws = std.posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 }; + const pid = forkpty(&master, null, null, &ws); + if (pid < 0) return null; + if (pid == 0) { + const argv: [3:null]?[*:0]const u8 = .{ test_shell, "--norc", "-i" }; + _ = execv(test_shell, &argv); + _exit(127); + } + var sh: TestShell = .{ .master = master, .pid = pid }; + sh.send("export PS1='PZ''X> '\n"); + if (!sh.waitText(test_prompt, 10_000)) { + sh.stop(); + return null; + } + sh.forget(); + return sh; + } + + fn send(sh: *TestShell, bytes: []const u8) void { + _ = libc.write(sh.master, bytes.ptr, bytes.len); + } + + fn forget(sh: *TestShell) void { + sh.tail_len = 0; + } + + fn drain(sh: *TestShell) void { + while (true) { + var fds = [1]libc.pollfd{.{ .fd = sh.master, .events = libc.POLL.IN, .revents = 0 }}; + if (libc.poll(&fds, 1, 0) <= 0) return; + if (fds[0].revents & libc.POLL.IN == 0) return; + var chunk: [4096]u8 = undefined; + const n = libc.read(sh.master, &chunk, chunk.len); + if (n <= 0) return; + sh.append(chunk[0..@intCast(n)]); + } + } + + fn append(sh: *TestShell, bytes: []const u8) void { + if (bytes.len >= sh.tail.len) { + @memcpy(&sh.tail, bytes[bytes.len - sh.tail.len ..]); + sh.tail_len = sh.tail.len; + return; + } + const room = sh.tail.len - sh.tail_len; + if (bytes.len > room) { + const drop = bytes.len - room; + std.mem.copyForwards(u8, sh.tail[0 .. sh.tail_len - drop], sh.tail[drop..sh.tail_len]); + sh.tail_len -= drop; + } + @memcpy(sh.tail[sh.tail_len..][0..bytes.len], bytes); + sh.tail_len += bytes.len; + } + + fn waitText(sh: *TestShell, needle: []const u8, ms: i64) bool { + const deadline = nowMs() + ms; + while (true) { + sh.drain(); + if (std.mem.indexOf(u8, sh.tail[0..sh.tail_len], needle) != null) return true; + if (nowMs() >= deadline) return false; + sleepMs(5); + } + } + + fn taken(sh: *TestShell) bool { + sh.drain(); + return ttyTaken(sh.pid, sh.master); + } + + fn waitTaken(sh: *TestShell, want: bool, ms: i64) bool { + const deadline = nowMs() + ms; + while (true) { + if (sh.taken() == want) return true; + if (nowMs() >= deadline) return false; + sleepMs(5); + } + } + + fn holdsTaken(sh: *TestShell, want: bool, ms: i64) bool { + const deadline = nowMs() + ms; + while (nowMs() < deadline) { + if (sh.taken() != want) return false; + sleepMs(5); + } + return true; + } + + fn stop(sh: *TestShell) void { + var probe: TtyProbe = undefined; + var pending: usize = 0; + var doomed: [occ_max_visited]libc.pid_t = undefined; + var n: usize = 0; + _ = pushChildren(&probe, &pending, sh.pid, 1); + while (pending > 0) { + pending -= 1; + const node = probe.pending[pending]; + if (n == doomed.len) break; + doomed[n] = node.pid; + n += 1; + if (node.depth < occ_max_depth) _ = pushChildren(&probe, &pending, node.pid, node.depth + 1); + } + _ = libc.kill(sh.pid, libc.SIG.KILL); + for (doomed[0..n]) |kid| { + _ = libc.kill(kid, libc.SIG.KILL); + _ = libc.kill(-kid, libc.SIG.KILL); + } + _ = libc.waitpid(sh.pid, null, 0); + _ = libc.close(sh.master); + } +}; + +fn haveFile(path: [*:0]const u8) bool { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return false; + _ = libc.close(fd); + return true; +} + +fn nowMs() i64 { + var ts: libc.timespec = undefined; + _ = libc.clock_gettime(.MONOTONIC, &ts); + return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); +} + +fn sleepMs(ms: i64) void { + const ts = libc.timespec{ + .sec = @intCast(@divFloor(ms, 1000)), + .nsec = @intCast(@mod(ms, 1000) * 1_000_000), + }; + _ = libc.nanosleep(&ts, null); +} + +test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands it back" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + try std.testing.expect(sh.holdsTaken(false, 200)); + + sh.send("sleep 30\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + + sh.forget(); + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); +} + +test "a background job is not the tty's owner" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + sh.send("sleep 30 &\n"); + try std.testing.expect(sh.waitText("[1]", 10_000)); + try std.testing.expect(sh.holdsTaken(false, 300)); + + sh.send("kill %1\n"); + try std.testing.expect(sh.holdsTaken(false, 300)); +} + +test "a nested interactive shell is still a prompt" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + sh.forget(); + sh.send("bash --norc -i\n"); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + try std.testing.expect(sh.holdsTaken(false, 300)); + + sh.forget(); + sh.send("bash --norc -i\n"); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + try std.testing.expect(sh.holdsTaken(false, 300)); + + sh.send("sleep 30\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); +} + +test "the walk reaches the leaf: bash -c 'sleep 30' takes the tty" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + sh.send("bash --norc -c 'sleep 30'\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); + + sh.send("bash --norc -c 'sleep 30; :'\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + + var probe: TtyProbe = undefined; + var pending: usize = 0; + try std.testing.expectEqual(Pushed.pushed, pushChildren(&probe, &pending, sh.pid, 1)); + try std.testing.expectEqual(@as(usize, 1), pending); + var wrapper_buf: [std.fs.max_path_bytes]u8 = undefined; + var shell_buf: [std.fs.max_path_bytes]u8 = undefined; + try std.testing.expectEqualStrings( + procExe(sh.pid, &shell_buf).?, + procExe(probe.pending[0].pid, &wrapper_buf).?, + ); + + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); +} + +test "a full-screen program takes the tty until it quits" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + const cases = [_]struct { bin: [*:0]const u8, run: []const u8, quit: []const u8 }{ + .{ .bin = "/usr/bin/vim", .run = "vim -u NONE -i NONE\n", .quit = "\x1b:q!\r" }, + .{ .bin = "/usr/bin/less", .run = "env LESS= less /etc/hosts\n", .quit = "q" }, + }; + var ran: usize = 0; + for (cases) |c| { + if (!haveFile(c.bin)) continue; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + sh.send(c.run); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.forget(); + sh.send(c.quit); + try std.testing.expect(sh.waitTaken(false, 10_000)); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + ran += 1; + } + if (ran == 0) return error.SkipZigTest; +} diff --git a/src/image.zig b/src/image.zig index 50e9649c..592230c8 100644 --- a/src/image.zig +++ b/src/image.zig @@ -1,7 +1,4 @@ -//! Image panes. Decoding is zstbi (C stb_image) to RGBA, downscaled; the kept -//! pixels render as PETSCII glyph art directly into the Surface — or, when the -//! host supports native images (and PETSCII isn't toggled), ride the Surface as -//! a pixel attachment the shell transmits/places (TTY: Kitty; SDL: GPU texture). +//! Image decoding, pixel placement, and glyph approximation. const std = @import("std"); const zstbi = @import("zstbi"); /// The 16-colour ANSI table below is the only thing this file ever wanted from @@ -10,21 +7,434 @@ const zstbi = @import("zstbi"); const terminal_panes = @import("pardes.zig").terminal_panes; const ghostty_vt = if (terminal_panes) @import("ghostty-vt") else struct {}; const pdf_enabled = @import("pardes_config").mupdf; -pub const petscii = @import("petscii.zig"); - -/// the terminal's own 16 ANSI colors — what the `terminal` palette mode -/// scores against; cells then paint as indexed colors so the real terminal -/// resolves the RGB -/// -/// These are GHOSTTY's sixteen, transcribed, because on a platform that has an -/// emulator the scoring used to read them straight out of it and a build -/// without one has to score against the identical table or render different -/// glyph art for the same photo. Note they are the base16 Tomorrow Night set, -/// NOT the xterm defaults: zig-pkg/ghostty-1.3.2-dev-5UdBCzeJ.../src/terminal/ -/// color.zig:389-405 (`Name.default`), which color.zig:8-15 copies into the -/// first sixteen entries of `color.default`. The comptime block below makes -/// the emulator prove that, so the transcription cannot rot in silence. -const ansi_default = [16][3]u8{ +// Glyph matching adapted from caioluders/petsciinator. +pub const GlyphArt = struct { + pub const commodore = [16][3]u8{ + .{ 0x00, 0x00, 0x00 }, // 0 black + .{ 0xff, 0xff, 0xff }, // 1 white + .{ 0x68, 0x37, 0x2b }, // 2 red + .{ 0x70, 0xa4, 0xb2 }, // 3 cyan + .{ 0x6f, 0x3d, 0x86 }, // 4 purple + .{ 0x58, 0x8d, 0x43 }, // 5 green + .{ 0x35, 0x28, 0x79 }, // 6 blue + .{ 0xb8, 0xc7, 0x6f }, // 7 yellow + .{ 0x6f, 0x4f, 0x25 }, // 8 orange + .{ 0x43, 0x39, 0x00 }, // 9 brown + .{ 0x9a, 0x67, 0x59 }, // 10 light red + .{ 0x44, 0x44, 0x44 }, // 11 dark grey + .{ 0x6c, 0x6c, 0x6c }, // 12 grey + .{ 0x9a, 0xd2, 0x84 }, // 13 light green + .{ 0x6c, 0x5e, 0xb5 }, // 14 light blue + .{ 0x95, 0x95, 0x95 }, // 15 light grey + }; + + const Palette = [16][3]u8; + + pub const Cell = struct { + glyph: [4]u8 = .{ ' ', 0, 0, 0 }, + glyph_len: u3 = 1, + fg: u4 = 1, + bg: u4 = 0, + }; + + pub const Grid = struct { cells: []Cell, cols: usize, rows: usize }; + + fn dist2(a: [3]u8, b: [3]u8) u32 { + const dr = @as(i32, a[0]) - b[0]; + const dg = @as(i32, a[1]) - b[1]; + const db = @as(i32, a[2]) - b[2]; + return @intCast(dr * dr + dg * dg + db * db); + } + + fn nearest(px: [3]u8, pal: Palette) u4 { + var best: u4 = 0; + var bestd: u32 = std.math.maxInt(u32); + for (pal, 0..) |c, i| { + const d = dist2(px, c); + if (d < bestd) { + bestd = d; + best = @intCast(i); + } + } + return best; + } + + // ---- glyph set: each is a codepoint + an 8x8 ink bitmap (bit y*8+x set = fg) ---- + const Glyph = struct { cp: u21, bits: u64 }; + + fn sextantCp(p: u6) u21 { + return switch (p) { + 0 => ' ', + 21 => 0x258C, // left half ▌ + 42 => 0x2590, // right half ▐ + 63 => 0x2588, // full block █ + else => blk: { + var off: u21 = @as(u21, p) - 1; + if (p > 21) off -= 1; + if (p > 42) off -= 1; + break :blk 0x1FB00 + off; // Symbols for Legacy Computing sextants + }, + }; + } + + // the 8x8 ink bitmap for a sextant pattern (2 cols x 3 rows of subcells). + fn sextantBits(p: u6) u64 { + var bits: u64 = 0; + var y: usize = 0; + while (y < 8) : (y += 1) { + const band = (y * 3) / 8; // 0,0,0,1,1,1,2,2 + var x: usize = 0; + while (x < 8) : (x += 1) { + const col: usize = if (x < 4) 0 else 1; + const sub: u6 = @intCast(band * 2 + col); + if ((p >> sub) & 1 != 0) bits |= @as(u64, 1) << @intCast(y * 8 + x); + } + } + return bits; + } + + // pack 8 row-bytes (bit x set, x=0 leftmost) into the 8x8 bitmap. + fn glyphMask(r: [8]u8) u64 { + var b: u64 = 0; + for (r, 0..) |row, y| b |= @as(u64, row) << @intCast(y * 8); + return b; + } + + // the horizontal half blocks and the ten 2x2 quadrants — sextants are 2x3, so they + // can't express an exact 4-row half or a quarter; these fill that gap. + const block_glyphs = [_]Glyph{ + .{ .cp = 0x2580, .bits = glyphMask(.{ 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0 }) }, // ▀ top half + .{ .cp = 0x2584, .bits = glyphMask(.{ 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff }) }, // ▄ bottom half + .{ .cp = 0x2598, .bits = glyphMask(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0, 0, 0, 0 }) }, // ▘ TL + .{ .cp = 0x259d, .bits = glyphMask(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0, 0, 0, 0 }) }, // ▝ TR + .{ .cp = 0x2596, .bits = glyphMask(.{ 0, 0, 0, 0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▖ BL + .{ .cp = 0x2597, .bits = glyphMask(.{ 0, 0, 0, 0, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▗ BR + .{ .cp = 0x259a, .bits = glyphMask(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▚ TL+BR + .{ .cp = 0x259e, .bits = glyphMask(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▞ TR+BL + .{ .cp = 0x259b, .bits = glyphMask(.{ 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▛ ¬BR + .{ .cp = 0x259c, .bits = glyphMask(.{ 0xff, 0xff, 0xff, 0xff, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▜ ¬BL + .{ .cp = 0x2599, .bits = glyphMask(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xff, 0xff, 0xff, 0xff }) }, // ▙ ¬TR + .{ .cp = 0x259f, .bits = glyphMask(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0xff, 0xff, 0xff, 0xff }) }, // ▟ ¬TL + }; + + // line/diagonal glyphs add the characteristic PETSCII "drawn" look on edges. + const line_glyphs = [_]Glyph{ + .{ .cp = 0x2500, .bits = glyphMask(.{ 0, 0, 0, 0xff, 0xff, 0, 0, 0 }) }, // ─ + .{ .cp = 0x2502, .bits = glyphMask(.{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18 }) }, // │ + .{ .cp = 0x253c, .bits = glyphMask(.{ 0x18, 0x18, 0x18, 0xff, 0xff, 0x18, 0x18, 0x18 }) }, // ┼ + .{ .cp = 0x2572, .bits = glyphMask(.{ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 }) }, // ╲ + .{ .cp = 0x2571, .bits = glyphMask(.{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }) }, // ╱ + .{ .cp = 0x2573, .bits = glyphMask(.{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }) }, // ╳ + }; + + const ascii_glyphs = [_]Glyph{ + .{ .cp = 0x21, .bits = 0x00180018183c3c18 }, // ! + .{ .cp = 0x22, .bits = 0x0000000000246666 }, // " + .{ .cp = 0x23, .bits = 0x0036367f367f3636 }, // # + .{ .cp = 0x24, .bits = 0x00183e603c067c18 }, // $ + .{ .cp = 0x25, .bits = 0x0063660c18336300 }, // % + .{ .cp = 0x26, .bits = 0x006e333b6e1c361c }, // & + .{ .cp = 0x27, .bits = 0x00000000000c1818 }, // ' + .{ .cp = 0x28, .bits = 0x0030180c0c0c1830 }, // ( + .{ .cp = 0x29, .bits = 0x000c18303030180c }, // ) + .{ .cp = 0x2a, .bits = 0x0000663cff3c6600 }, // * + .{ .cp = 0x2b, .bits = 0x000018187e181800 }, // + + .{ .cp = 0x2c, .bits = 0x0c18180000000000 }, // , + .{ .cp = 0x2d, .bits = 0x000000007e000000 }, // - + .{ .cp = 0x2e, .bits = 0x0018180000000000 }, // . + .{ .cp = 0x2f, .bits = 0x000103060c183060 }, // / + .{ .cp = 0x30, .bits = 0x001c36636b63361c }, // 0 + .{ .cp = 0x31, .bits = 0x007e181818181c18 }, // 1 + .{ .cp = 0x32, .bits = 0x007f660c3860633e }, // 2 + .{ .cp = 0x33, .bits = 0x003e63603c60633e }, // 3 + .{ .cp = 0x34, .bits = 0x0078307f33363c38 }, // 4 + .{ .cp = 0x35, .bits = 0x003e63603f03037f }, // 5 + .{ .cp = 0x36, .bits = 0x003e63633f03061c }, // 6 + .{ .cp = 0x37, .bits = 0x000c0c0c1830637f }, // 7 + .{ .cp = 0x38, .bits = 0x003e63633e63633e }, // 8 + .{ .cp = 0x39, .bits = 0x001e30607e63633e }, // 9 + .{ .cp = 0x3a, .bits = 0x0018180000181800 }, // : + .{ .cp = 0x3b, .bits = 0x0c18180000181800 }, // ; + .{ .cp = 0x3c, .bits = 0x006030180c183060 }, // < + .{ .cp = 0x3d, .bits = 0x00007e00007e0000 }, // = + .{ .cp = 0x3e, .bits = 0x00060c1830180c06 }, // > + .{ .cp = 0x3f, .bits = 0x001800181830633e }, // ? + .{ .cp = 0x40, .bits = 0x001e037b7b7b633e }, // @ + .{ .cp = 0x41, .bits = 0x006363637f63361c }, // A + .{ .cp = 0x42, .bits = 0x003f66663e66663f }, // B + .{ .cp = 0x43, .bits = 0x003c66030303663c }, // C + .{ .cp = 0x44, .bits = 0x001f36666666361f }, // D + .{ .cp = 0x45, .bits = 0x007f46161e16467f }, // E + .{ .cp = 0x46, .bits = 0x000f06161e16467f }, // F + .{ .cp = 0x47, .bits = 0x005c66730303663c }, // G + .{ .cp = 0x48, .bits = 0x006363637f636363 }, // H + .{ .cp = 0x49, .bits = 0x003c18181818183c }, // I + .{ .cp = 0x4a, .bits = 0x001e333330303078 }, // J + .{ .cp = 0x4b, .bits = 0x006766361e366667 }, // K + .{ .cp = 0x4c, .bits = 0x007f66460606060f }, // L + .{ .cp = 0x4d, .bits = 0x0063636b7f7f7763 }, // M + .{ .cp = 0x4e, .bits = 0x006363737b6f6763 }, // N + .{ .cp = 0x4f, .bits = 0x003e63636363633e }, // O + .{ .cp = 0x50, .bits = 0x000f06063e66663f }, // P + .{ .cp = 0x51, .bits = 0x703e73636363633e }, // Q + .{ .cp = 0x52, .bits = 0x006766363e66663f }, // R + .{ .cp = 0x53, .bits = 0x003c6630180c663c }, // S + .{ .cp = 0x54, .bits = 0x003c1818185a7e7e }, // T + .{ .cp = 0x55, .bits = 0x003e636363636363 }, // U + .{ .cp = 0x56, .bits = 0x001c366363636363 }, // V + .{ .cp = 0x57, .bits = 0x00367f6b6b636363 }, // W + .{ .cp = 0x58, .bits = 0x006363361c366363 }, // X + .{ .cp = 0x59, .bits = 0x003c18183c666666 }, // Y + .{ .cp = 0x5a, .bits = 0x007f664c1831637f }, // Z + .{ .cp = 0x5b, .bits = 0x003c0c0c0c0c0c3c }, // [ + .{ .cp = 0x5c, .bits = 0x00406030180c0603 }, // \\ + .{ .cp = 0x5d, .bits = 0x003c30303030303c }, // ] + .{ .cp = 0x5e, .bits = 0x0000000063361c08 }, // ^ + .{ .cp = 0x5f, .bits = 0xff00000000000000 }, // _ + .{ .cp = 0x60, .bits = 0x000000000030180c }, // ` + .{ .cp = 0x61, .bits = 0x006e333e301e0000 }, // a + .{ .cp = 0x62, .bits = 0x003b6666663e0607 }, // b + .{ .cp = 0x63, .bits = 0x003e6303633e0000 }, // c + .{ .cp = 0x64, .bits = 0x006e3333333e3038 }, // d + .{ .cp = 0x65, .bits = 0x003e037f633e0000 }, // e + .{ .cp = 0x66, .bits = 0x000f06061f06663c }, // f + .{ .cp = 0x67, .bits = 0x1f303e33336e0000 }, // g + .{ .cp = 0x68, .bits = 0x006766666e360607 }, // h + .{ .cp = 0x69, .bits = 0x003c1818181c0018 }, // i + .{ .cp = 0x6a, .bits = 0x3c66666060600060 }, // j + .{ .cp = 0x6b, .bits = 0x0067361e36660607 }, // k + .{ .cp = 0x6c, .bits = 0x003c18181818181c }, // l + .{ .cp = 0x6d, .bits = 0x006b6b6b7f370000 }, // m + .{ .cp = 0x6e, .bits = 0x00666666663b0000 }, // n + .{ .cp = 0x6f, .bits = 0x003e6363633e0000 }, // o + .{ .cp = 0x70, .bits = 0x0f063e66663b0000 }, // p + .{ .cp = 0x71, .bits = 0x78303e33336e0000 }, // q + .{ .cp = 0x72, .bits = 0x000f06066e3b0000 }, // r + .{ .cp = 0x73, .bits = 0x003f603e037e0000 }, // s + .{ .cp = 0x74, .bits = 0x00386c0c0c3f0c0c }, // t + .{ .cp = 0x75, .bits = 0x006e333333330000 }, // u + .{ .cp = 0x76, .bits = 0x001c366363630000 }, // v + .{ .cp = 0x77, .bits = 0x00367f6b6b630000 }, // w + .{ .cp = 0x78, .bits = 0x0063361c36630000 }, // x + .{ .cp = 0x79, .bits = 0x3f607e6363630000 }, // y + .{ .cp = 0x7a, .bits = 0x007e4c18327e0000 }, // z + .{ .cp = 0x7b, .bits = 0x007018180e181870 }, // { + .{ .cp = 0x7c, .bits = 0x0018181818181818 }, // | + .{ .cp = 0x7d, .bits = 0x000e18187018180e }, // } + .{ .cp = 0x7e, .bits = 0x0000000000003b6e }, // ~ + }; + + // the block glyph table (sextants + half/quadrant blocks + line glyphs), built at + // comptime, and the same set extended with the ASCII glyphs. `render(ascii=…)` picks. + const glyphs = blk: { + @setEvalBranchQuota(100000); + var list: [64 + block_glyphs.len + line_glyphs.len]Glyph = undefined; + var p: usize = 0; + while (p < 64) : (p += 1) list[p] = .{ .cp = sextantCp(@intCast(p)), .bits = sextantBits(@intCast(p)) }; + for (block_glyphs, 0..) |bg, i| list[64 + i] = bg; + for (line_glyphs, 0..) |lg, i| list[64 + block_glyphs.len + i] = lg; + break :blk list; + }; + const glyphs_ascii = glyphs ++ ascii_glyphs; + + fn matchCell(cell: *const [64][3]u8, pal: Palette, gset: []const Glyph) Cell { + var counts = [_]u16{0} ** 16; + for (cell) |px| counts[nearest(px, pal)] += 1; + var cand: [4]u4 = undefined; + var ncand: usize = 0; + var used = [_]bool{false} ** 16; + while (ncand < 4) : (ncand += 1) { + var best: ?usize = null; + for (counts, 0..) |c, i| { + if (used[i] or c == 0) continue; + if (best == null or c > counts[best.?]) best = i; + } + if (best) |bi| { + cand[ncand] = @intCast(bi); + used[bi] = true; + } else break; + } + if (ncand == 0) return .{}; // can't happen (64 pixels), but keep it total + if (ncand == 1) return encode(' ', cand[0], cand[0]); // solid color + + var best_cost: i64 = std.math.maxInt(i64); + var best = encode(' ', cand[0], cand[0]); + var fi: usize = 0; + while (fi < ncand) : (fi += 1) { + var bi: usize = 0; + while (bi < ncand) : (bi += 1) { + if (fi == bi) continue; + const fg = cand[fi]; + const bg = cand[bi]; + var dfg: [64]u32 = undefined; + var dbg: [64]u32 = undefined; + var base: i64 = 0; + for (cell, 0..) |px, p| { + dfg[p] = dist2(px, pal[fg]); + dbg[p] = dist2(px, pal[bg]); + base += dbg[p]; + } + for (gset) |g| { + var delta: i64 = 0; + var bits = g.bits; + while (bits != 0) : (bits &= bits - 1) { + const p: usize = @ctz(bits); + delta += @as(i64, dfg[p]) - @as(i64, dbg[p]); + } + const cost = base + delta; + if (cost < best_cost) { + best_cost = cost; + best = encode(g.cp, fg, bg); + } + } + } + } + return best; + } + + fn encode(cp: u21, fg: u4, bg: u4) Cell { + var c = Cell{ .fg = fg, .bg = bg }; + const n = std.unicode.utf8Encode(cp, &c.glyph) catch 1; + c.glyph_len = @intCast(n); + return c; + } + + pub fn render(gpa: std.mem.Allocator, rgba: []const u8, iw: usize, ih: usize, max_cols: usize, max_rows: usize, pal: Palette, ascii: bool) !Grid { + if (iw == 0 or ih == 0 or max_cols == 0 or max_rows == 0) return .{ .cells = try gpa.alloc(Cell, 0), .cols = 0, .rows = 0 }; + const gset: []const Glyph = if (ascii) &glyphs_ascii else &glyphs; + // contain-fit; cells are ~twice as tall as wide, so a row spans 2 width-units. + var cols = max_cols; + var rows = (max_cols * ih) / (2 * iw); + if (rows > max_rows) { + rows = max_rows; + cols = (max_rows * 2 * iw) / ih; + } + cols = std.math.clamp(cols, 1, max_cols); + rows = std.math.clamp(rows, 1, max_rows); + + const cells = try gpa.alloc(Cell, cols * rows); + var cy: usize = 0; + while (cy < rows) : (cy += 1) { + const ry0 = cy * ih / rows; + const ry1 = @max(ry0 + 1, (cy + 1) * ih / rows); + var cx: usize = 0; + while (cx < cols) : (cx += 1) { + const rx0 = cx * iw / cols; + const rx1 = @max(rx0 + 1, (cx + 1) * iw / cols); + var cell: [64][3]u8 = undefined; + var sy: usize = 0; + while (sy < 8) : (sy += 1) { + const py0 = ry0 + sy * (ry1 - ry0) / 8; + const py1 = @max(py0 + 1, ry0 + (sy + 1) * (ry1 - ry0) / 8); + var sx: usize = 0; + while (sx < 8) : (sx += 1) { + const px0 = rx0 + sx * (rx1 - rx0) / 8; + const px1 = @max(px0 + 1, rx0 + (sx + 1) * (rx1 - rx0) / 8); + var rs: usize = 0; + var gs: usize = 0; + var bs: usize = 0; + var n: usize = 0; + var yy = py0; + while (yy < py1 and yy < ih) : (yy += 1) { + var xx = px0; + while (xx < px1 and xx < iw) : (xx += 1) { + const i = (yy * iw + xx) * 4; + rs += rgba[i]; + gs += rgba[i + 1]; + bs += rgba[i + 2]; + n += 1; + } + } + if (n == 0) n = 1; + cell[sy * 8 + sx] = .{ @intCast(rs / n), @intCast(gs / n), @intCast(bs / n) }; + } + } + cells[cy * cols + cx] = matchCell(&cell, pal, gset); + } + } + return .{ .cells = cells, .cols = cols, .rows = rows }; + } + + test "sextant codepoints: blocks + endpoints" { + try std.testing.expectEqual(@as(u21, ' '), sextantCp(0)); + try std.testing.expectEqual(@as(u21, 0x2588), sextantCp(63)); + try std.testing.expectEqual(@as(u21, 0x258C), sextantCp(21)); + try std.testing.expectEqual(@as(u21, 0x2590), sextantCp(42)); + try std.testing.expectEqual(@as(u21, 0x1FB00), sextantCp(1)); // first sextant + try std.testing.expectEqual(@as(u21, 0x1FB3B), sextantCp(62)); // last sextant + } + + test "matchCell: solid color -> space on that bg" { + var cell: [64][3]u8 = undefined; + for (&cell) |*p| p.* = commodore[5]; // all green + const m = matchCell(&cell, commodore, &glyphs); + try std.testing.expectEqual(@as(u4, 5), m.bg); + try std.testing.expectEqual(@as(u8, ' '), m.glyph[0]); + } + + test "matchCell: clean top/bottom split picks the two colors" { + var cell: [64][3]u8 = undefined; + for (0..64) |p| cell[p] = if (p < 32) commodore[1] else commodore[6]; // white over blue + const m = matchCell(&cell, commodore, &glyphs); + // both palette colors must be chosen (in some fg/bg order) + const a = @as(u4, @min(m.fg, m.bg)); + const b = @as(u4, @max(m.fg, m.bg)); + try std.testing.expectEqual(@as(u4, 1), a); + try std.testing.expectEqual(@as(u4, 6), b); + // a clean top/bottom split must resolve to a real block glyph (the top-4-rows + // half block ▀), never a blank cell. + const cp = std.unicode.utf8Decode(m.glyph[0..m.glyph_len]) catch 0; + try std.testing.expectEqual(@as(u21, 0x2580), cp); + } + + test "ascii option only enables the ascii glyphs" { + // the ascii glyph codepoints must be reachable exactly when ascii is on. + var seen_block = false; + var seen_ascii = false; + for (glyphs) |g| if (g.cp == '#') { + seen_block = true; + }; + for (glyphs_ascii) |g| if (g.cp == '#') { + seen_ascii = true; + }; + try std.testing.expect(!seen_block); // '#' is an ascii-only glyph + try std.testing.expect(seen_ascii); + try std.testing.expectEqual(glyphs.len + ascii_glyphs.len, glyphs_ascii.len); + } + + test "ascii glyph is chosen when a cell has its exact shape" { + // a cell shaped exactly like the font 'S' (ink=white on black) must match 'S' + // with ascii on (cost 0), and fall back to some block glyph with ascii off. + const s_bits: u64 = 0x003c6630180c663c; + var cell: [64][3]u8 = undefined; + for (0..64) |p| cell[p] = if ((s_bits >> @intCast(p)) & 1 != 0) commodore[1] else commodore[0]; + const on = matchCell(&cell, commodore, &glyphs_ascii); + try std.testing.expectEqual(@as(u21, 'S'), std.unicode.utf8Decode(on.glyph[0..on.glyph_len]) catch 0); + const off = matchCell(&cell, commodore, &glyphs); + try std.testing.expect((std.unicode.utf8Decode(off.glyph[0..off.glyph_len]) catch 0) != 'S'); + } + + test "render: tiny image produces a grid within bounds" { + const a = std.testing.allocator; + // 2x2 checker, RGBA + var img = [_]u8{0} ** (2 * 2 * 4); + img[0] = 255; + img[1] = 255; + img[2] = 255; + img[3] = 255; // (0,0) white + img[(3) * 4 + 0] = 255; + img[(3) * 4 + 1] = 255; + img[(3) * 4 + 2] = 255; + img[(3) * 4 + 3] = 255; // (1,1) white + const g = try render(a, &img, 2, 2, 10, 10, commodore, true); + defer a.free(g.cells); + try std.testing.expect(g.cols >= 1 and g.cols <= 10); + try std.testing.expect(g.rows >= 1 and g.rows <= 10); + try std.testing.expectEqual(g.cols * g.rows, g.cells.len); + } +}; + +pub const terminal_palette = [16][3]u8{ .{ 0x1D, 0x1F, 0x21 }, // black .{ 0xCC, 0x66, 0x66 }, // red .{ 0xB5, 0xBD, 0x68 }, // green @@ -44,18 +454,14 @@ const ansi_default = [16][3]u8{ }; comptime { - if (terminal_panes) for (ansi_default, 0..) |rgb, i| { + if (terminal_panes) for (terminal_palette, 0..) |rgb, i| { const c = ghostty_vt.color.default[i]; if (rgb[0] != c.r or rgb[1] != c.g or rgb[2] != c.b) @compileError( - "src/image.zig ansi_default has drifted from ghostty's color.default", + "terminal palette differs from the emulator", ); }; } -pub fn ansiPalette() [16][3]u8 { - return ansi_default; -} - /// cap the longest side before keeping/transmitting: a pane is at most a /// screenful of cells, multi-thousand-pixel photos waste decode/transmit time const MAX_DIM: u32 = 1280; diff --git a/src/image_pane.zig b/src/image_pane.zig deleted file mode 100644 index 71ef2228..00000000 --- a/src/image_pane.zig +++ /dev/null @@ -1,262 +0,0 @@ -//! Image panes: their owned pixels, PETSCII cache, lifecycle, and two render -//! paths. Core layout supplies the body rectangle; this module fills it or -//! attaches one native image to it. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const image = @import("image.zig"); -const look = @import("look.zig"); -const dump = @import("dump.zig"); -const config = @import("config.zig"); - -const GridKey = struct { - cols: u16 = 0, - rows: u16 = 0, - palette: image.PaletteMode = .commodore, - ascii: bool = true, -}; - -/// The decoded, downscaled RGBA is retained so changing the pane size or one -/// of the glyph-renderer toggles can rebuild the PETSCII projection without -/// decoding the source again. All slices are owned by Pardes.image_gpa. -pub const State = struct { - path: []u8, - petscii: bool = false, - pmode: image.PaletteMode = .commodore, - ascii: bool = true, - tried: bool = false, - rgba: []u8 = &.{}, - iw: usize = 0, - ih: usize = 0, - /// Dump-loaded bytes, decoded lazily by the same path as a disk image. - raw: []u8 = &.{}, - grid: image.petscii.Grid = .{ .cells = &.{}, .gw = 0, .gh = 0 }, - grid_key: GridKey = .{}, - - pub fn deinit(state: *State, gpa: std.mem.Allocator) void { - gpa.free(state.path); - if (state.rgba.len > 0) gpa.free(state.rgba); - if (state.raw.len > 0) gpa.free(state.raw); - if (state.grid.cells.len > 0) gpa.free(state.grid.cells); - state.* = undefined; - } -}; - -/// Construct an image pane from a path and optionally transferred dump bytes. -/// `raw` must be image_gpa-owned and ownership transfers only on success. -pub fn create(p: *pardes.Pardes, id: usize, path: []const u8, raw: []u8) !*pardes.Pane { - const path_copy = try p.image_gpa.dupe(u8, path); - errdefer p.image_gpa.free(path_copy); - const pane = try p.newDocPane(id); - pane.image = .{ .path = path_copy, .raw = raw }; - return pane; -} - -/// Serialize the binary image record used by images and, for dump-schema -/// compatibility, PDFs. Common pane metadata is supplied by the core. -pub fn dumpPane( - arena: std.mem.Allocator, - pane: *const pardes.Pane, - tag: []const u8, - body: []const u8, - scroll: usize, - path: []const u8, - raw: []const u8, -) !dump.Pane { - const bytes = if (raw.len > 0) raw else look.readFile(arena, path) catch ""; - return .{ - .kind = .image, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .image = .{ - .path = path, - .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", - .petscii = if (pane.image) |state| state.petscii else false, - .palette = if (pane.image) |state| switch (state.pmode) { - .commodore => .commodore, - .terminal => .terminal, - } else .commodore, - .ascii = if (pane.image) |state| state.ascii else true, - }, - }; -} - -/// Rebuild an ordinary raster image from its dump record. PDF records share -/// the schema for compatibility and are deliberately dispatched by the core -/// before reaching this function. -pub fn restore(p: *pardes.Pardes, id: usize, src: dump.Pane) !*pardes.Pane { - const saved = src.image.?; - var raw: []u8 = if (saved.bytes_b64.len > 0) - try dump.decodeBytes(p.image_gpa, saved.bytes_b64) - else - &.{}; - errdefer if (raw.len > 0) p.image_gpa.free(raw); - const pane = try create(p, id, saved.path, raw); - raw = &.{}; - pane.image.?.petscii = saved.petscii; - pane.image.?.pmode = switch (saved.palette) { - .commodore => .commodore, - .terminal => .terminal, - }; - pane.image.?.ascii = saved.ascii; - pane.cols = @max(1, src.cols); - pane.rows = @max(1, src.rows); - return pane; -} - -pub fn togglePetscii(state: *State) void { - state.petscii = !state.petscii; -} - -pub fn togglePalette(state: *State) void { - state.pmode = if (state.pmode == .commodore) .terminal else .commodore; -} - -pub fn toggleAscii(state: *State) void { - state.ascii = !state.ascii; -} - -/// Image renderer choices are pane-local, not global Config values. Keep them -/// queryable where they apply: the live tag beside the image's path. -pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 { - return std.fmt.allocPrint( - arena, - "{s} petscii:{s} palette:{s} ascii:{s} {s}", - .{ - config.tag_image, - if (state.petscii) "on" else "off", - @tagName(state.pmode), - if (state.ascii) "on" else "off", - state.path, - }, - ); -} - -/// Prefix written by version-1 dumps before renderer choices became visible -/// in the live tag. Returned as a borrowed slice of saved_tag for one-time -/// custom-tail migration. -pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 { - const lead = config.tag_image ++ " "; - if (!std.mem.startsWith(u8, saved_tag, lead)) return null; - const path_at = lead.len; - if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null; - return saved_tag[0 .. path_at + state.path.len]; -} - -fn ensureDecoded(p: *pardes.Pardes, state: *State) void { - if (state.tried) return; - state.tried = true; - const bytes: []const u8 = if (state.raw.len > 0) - state.raw - else - look.readFile(p.scratch.allocator(), state.path) catch ""; - if (image.decode(p.image_gpa, bytes)) |decoded| { - state.rgba = decoded.rgba; - state.iw = decoded.w; - state.ih = decoded.h; - } -} - -fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void { - const wanted = GridKey{ .cols = cols, .rows = rows, .palette = state.pmode, .ascii = state.ascii }; - if (state.grid.cells.len > 0 and std.meta.eql(state.grid_key, wanted)) return; - if (state.grid.cells.len > 0) p.image_gpa.free(state.grid.cells); - const palette = switch (state.pmode) { - .commodore => image.petscii.commodore, - .terminal => image.ansiPalette(), - }; - state.grid = image.petscii.render( - p.image_gpa, - state.rgba, - state.iw, - state.ih, - cols, - rows, - palette, - state.ascii, - ) catch .{ .cells = &.{}, .gw = 0, .gh = 0 }; - state.grid_key = wanted; -} - -/// Render the image body supplied by core layout. Native-capable hosts receive -/// a pixel attachment; other hosts and explicit PETSCII mode receive cells. -pub fn draw( - p: *pardes.Pardes, - state: *State, - pane_id: u8, - serial: u32, - x: u16, - y: u16, - cols: u16, - rows: u16, -) void { - ensureDecoded(p, state); - if (state.rgba.len == 0 or cols == 0 or rows == 0) return; - if (!state.petscii and p.native_images) { - _ = p.appendImagePlace(.{ - .pane = pane_id, - .serial = serial, - .x = x, - .y = y, - .w = cols, - .h = rows, - .rgba = state.rgba, - .iw = state.iw, - .ih = state.ih, - }); - return; - } - - ensureGrid(p, state, cols, rows); - if (state.grid.cells.len == 0) return; - const offx = if (cols > state.grid.gw) (@as(usize, cols) - state.grid.gw) / 2 else 0; - const offy = if (rows > state.grid.gh) (@as(usize, rows) - state.grid.gh) / 2 else 0; - for (0..state.grid.gh) |cy| for (0..state.grid.gw) |cx| { - const cell = &state.grid.cells[cy * state.grid.gw + cx]; - const fg: pardes.Color = switch (state.pmode) { - .commodore => .{ .rgb = image.petscii.commodore[cell.fg] }, - .terminal => .{ .index = cell.fg }, - }; - const bg: pardes.Color = switch (state.pmode) { - .commodore => .{ .rgb = image.petscii.commodore[cell.bg] }, - .terminal => .{ .index = cell.bg }, - }; - const sx = x + @as(u16, @intCast(offx + cx)); - const sy = y + @as(u16, @intCast(offy + cy)); - if (sx < p.surface.cols and sy < p.surface.rows) - p.surface.set(sx, sy, cell.glyph[0..cell.glen], .{ .fg = fg, .bg = bg }); - }; -} - -test "pane-local renderer choices are visible in the image tag" { - var state: State = .{ .path = @constCast("/tmp/picture.ppm") }; - togglePetscii(&state); - togglePalette(&state); - toggleAscii(&state); - const tag = try tagPrefix(std.testing.allocator, &state); - defer std.testing.allocator.free(tag); - try std.testing.expectEqualStrings( - config.tag_image ++ " petscii:on palette:terminal ascii:off /tmp/picture.ppm", - tag, - ); - try std.testing.expectEqualStrings( - "img /tmp/picture.ppm", - legacySavedPrefix(&state, "img /tmp/picture.ppm Keep Del").?, - ); -} - -test "dump restore propagates malformed embedded image bytes" { - const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - try std.testing.expect(p.panes[1] == null); - try std.testing.expectError(error.InvalidCharacter, restore(p, 1, .{ - .kind = .image, - .tag = "img /tmp/bad.ppm", - .body = "", - .image = .{ .path = "/tmp/bad.ppm", .bytes_b64 = "A..A" }, - })); - try std.testing.expect(p.panes[1] == null); -} diff --git a/src/layout.zig b/src/layout.zig new file mode 100644 index 00000000..51944856 --- /dev/null +++ b/src/layout.zig @@ -0,0 +1,1706 @@ +const std = @import("std"); +const pardes = @import("pardes.zig"); +const config = @import("config.zig"); +const panes = @import("panes.zig"); +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +pub const Rect = struct { x: u16 = 0, y: u16 = 0, w: u16 = 0, h: u16 = 0 }; +pub const Snapshot = struct { serial: u32, box: Box }; +const MAX_COLS = pardes.MAX_COLS; +const MAX_PANES = pardes.MAX_PANES; +const TOPBAR_H = pardes.TOPBAR_H; +const BOX_H = pardes.BOX_H; + +pub const Presentation = struct { + // Only acknowledged draws advance shown geometry; committed and submitted may be ahead. + committed: [MAX_PANES]?Snapshot = @splat(null), + initialized: bool = false, + enabled: bool = false, + snap_once: bool = false, + tracks: [MAX_PANES]?Track = @splat(null), + closing: [MAX_PANES]Track = undefined, + closing_len: usize = 0, + submitted: [MAX_PANES]?Snapshot = @splat(null), + submitted_ready: bool = false, + shown: [MAX_PANES]?Snapshot = @splat(null), + shown_tracks: [MAX_PANES]?Track = @splat(null), + shown_closing: [MAX_PANES]Track = undefined, + shown_closing_len: usize = 0, + acknowledged: bool = false, + pending: bool = false, + previous_cells: []pardes.Cell = &.{}, + previous_cols: u16 = 0, + previous_rows: u16 = 0, + previous_valid: bool = false, + previous_layout: [MAX_PANES]?Snapshot = @splat(null), + diffs: []pardes.PanelCellDiff = &.{}, + diff_state: enum { none, requested, ready } = .none, + + pub const CellPosition = struct { col: u16, row: u16 }; + + pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void { + if (self.previous_cells.len > 0) gpa.free(self.previous_cells); + if (self.diffs.len > 0) gpa.free(self.diffs); + } + + pub fn sync(self: *Presentation, p: *Pardes) void { + const initializing = !self.initialized or !self.enabled; + if (initializing or self.snap_once) { + const had_layout = self.initialized; + self.committed = @splat(null); + self.tracks = @splat(null); + self.closing_len = 0; + self.diff_state = .none; + if (initializing) { + self.shown_tracks = @splat(null); + self.shown_closing_len = 0; + } + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; + } + self.initialized = true; + self.snap_once = false; + if (had_layout and self.acknowledged) + self.pending = true; + return; + } + + const effect = p.settings.panel_transition; + if (effect.needsPreviousGrid() and self.diff_state != .none) { + var second_change = false; + for (p.panes, self.committed, 0..) |slot, snapshot, id| { + const pane = slot orelse { + second_change = second_change or snapshot != null; + continue; + }; + const target = panelBox(p.rects[id]); + second_change = second_change or snapshot == null or + snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target); + } + if (second_change) { + p.abandonPanelAnimations(); + self.committed = @splat(null); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; + } + return; + } + } + var changed = false; + var animated_change = false; + for (p.panes, 0..) |slot, id| { + const pane = slot orelse { + if (self.committed[id]) |old| { + changed = true; + if (effect.lifecycleOnly()) { + if (self.appendClosing(.{ + .serial = old.serial, + .pane = @intCast(id), + .phase = .closing, + .effect = effect, + .from = old.box, + .to = closingBox(effect, old.box), + })) animated_change = true; + } + } + self.committed[id] = null; + self.tracks[id] = null; + // Never let pixels from a dead pane address a reused slot. + self.shown_tracks[id] = null; + continue; + }; + const target = panelBox(p.rects[id]); + const previous = self.committed[id]; + self.committed[id] = .{ .serial = pane.serial, .box = target }; + + if (effect == .off) { + changed = changed or previous == null or previous.?.serial != pane.serial or + !previous.?.box.eql(target); + self.tracks[id] = null; + continue; + } + if (previous) |old| { + if (old.serial == pane.serial and old.box.eql(target)) continue; + const same_lifetime = old.serial == pane.serial; + const prior = if (self.tracks[id]) |track| + if (track.serial == pane.serial and track.active()) track else null + else + null; + if (effect.lifecycleOnly() and same_lifetime) { + if (prior) |active| if (active.phase == .opening) { + var next = active; + next.from = openingBox(effect, target, p.screen_w); + next.to = target; + self.tracks[id] = next; + changed = true; + animated_change = true; + continue; + }; + self.tracks[id] = null; + changed = true; + continue; + } + if (effect.lifecycleOnly() and !same_lifetime) { + _ = self.appendClosing(.{ + .serial = old.serial, + .pane = @intCast(id), + .phase = .closing, + .effect = effect, + .from = old.box, + .to = closingBox(effect, old.box), + }); + } + const shown = if (self.shown[id]) |snapshot| + if (snapshot.serial == pane.serial) snapshot.box else null + else + null; + const from = if (!same_lifetime) + openingBox(effect, target, p.screen_w) + else if (shown) |box| + box + else if (self.acknowledged and prior != null) + prior.?.from + else + old.box; + const next: Track = .{ + .serial = pane.serial, + .pane = @intCast(id), + .phase = if (!same_lifetime or + (prior != null and prior.?.phase == .opening)) .opening else .moving, + .effect = effect, + .from = from, + .to = target, + }; + self.tracks[id] = next; + changed = true; + animated_change = true; + } else { + const next: Track = .{ + .serial = pane.serial, + .pane = @intCast(id), + .phase = .opening, + .effect = effect, + .from = openingBox(effect, target, p.screen_w), + .to = target, + }; + self.tracks[id] = next; + changed = true; + animated_change = true; + } + } + if (animated_change and effect.needsPreviousGrid()) { + self.diff_state = .requested; + } + if (changed and self.acknowledged) self.pending = true; + } + + fn appendClosing(self: *Presentation, track: Track) bool { + std.debug.assert(track.phase == .closing); + const baseline = self.previous_layout[track.pane] orelse return false; + if (!self.previous_valid or baseline.serial != track.serial or + !baseline.box.eql(track.from)) return false; + if (self.closing_len == self.closing.len) { + std.mem.copyForwards( + Track, + self.closing[0 .. self.closing.len - 1], + self.closing[1..], + ); + self.closing_len -= 1; + } + self.closing[self.closing_len] = track; + self.closing_len += 1; + return true; + } + + pub fn advance(self: *Presentation) void { + for (&self.tracks) |*slot| { + const track = if (slot.*) |*track| track else continue; + track.frame +|= 1; + if (!track.active()) slot.* = null; + } + var out: usize = 0; + for (self.closing[0..self.closing_len]) |value| { + var track = value; + track.frame +|= 1; + if (!track.active()) continue; + self.closing[out] = track; + out += 1; + } + self.closing_len = out; + } + + pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void { + var presented: [MAX_PANES]?Track = @splat(null); + var presented_closing: [MAX_PANES]Track = undefined; + var nclosing: usize = 0; + var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready) + self.submitted + else + @splat(null); + if (!self.submitted_ready) for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; + }; + for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| { + const pane = p.panes[id] orelse { + snapshot.* = null; + continue; + }; + if (pane.serial != saved.serial) snapshot.* = null; + }; + for (tracks) |track| { + if (track.phase == .closing) { + const current = for (self.closing[0..self.closing_len]) |candidate| { + if (candidate.serial == track.serial and candidate.pane == track.pane and + candidate.effect == track.effect) break true; + } else false; + if (!current or !track.active() or nclosing == presented_closing.len) continue; + presented_closing[nclosing] = track; + nclosing += 1; + continue; + } + const id: usize = track.pane; + if (id >= p.panes.len or !track.active()) continue; + const pane = p.panes[id] orelse continue; + if (pane.serial != track.serial) continue; + presented[id] = track; + presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() }; + } + self.shown_tracks = presented; + self.shown_closing = presented_closing; + self.shown_closing_len = nclosing; + self.shown = presented_layout; + self.acknowledged = true; + self.pending = false; + if (tracks.len == 0) { + self.tracks = @splat(null); + self.closing_len = 0; + self.diff_state = .none; + self.capturePrevious(p.gpa, &p.surface); + } + } + + fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { + self.diff_state = .none; + const cells = surface.cells; + if (cells.len == 0) { + self.previous_valid = false; + self.previous_layout = @splat(null); + return; + } + if (self.previous_cells.len != cells.len) { + const next = gpa.alloc(pardes.Cell, cells.len) catch { + self.previous_valid = false; + self.previous_layout = @splat(null); + return; + }; + if (self.previous_cells.len > 0) gpa.free(self.previous_cells); + self.previous_cells = next; + } + @memcpy(self.previous_cells, cells); + self.previous_cols = surface.cols; + self.previous_rows = surface.rows; + self.previous_valid = true; + self.previous_layout = if (self.submitted_ready) + self.submitted + else + @splat(null); + } + + pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff { + if (self.diff_state != .ready or col >= cols or row >= rows or + self.diffs.len != @as(usize, cols) * rows) return .unchanged; + return self.diffs[@as(usize, row) * cols + col]; + } + + pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition { + if (self.pending) return null; + var closing = self.shown_closing_len; + while (closing > 0) { + closing -= 1; + const track = self.shown_closing[closing]; + if (!track.active()) continue; + if (boxContainsCell(track.contentBox(), col, row) and + boxContainsCell(track.presented(), col, row)) return null; + } + const phases = [_]Phase{ .opening, .moving }; + for (phases) |phase| { + // Backends paint pane slots forward within a phase. Probe them in + // reverse so overlapping transition quads address the top pixel. + var index = self.shown_tracks.len; + while (index > 0) { + index -= 1; + const track = self.shown_tracks[index] orelse continue; + if (!track.active() or track.phase != phase) continue; + switch (track.effect) { + .slide, .zoom => { + const shown = track.presented(); + if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; + const x = @as(f32, @floatFromInt(col)) + 0.5; + const y = @as(f32, @floatFromInt(row)) + 0.5; + const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); + const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); + const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w)); + const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h)); + return .{ + .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))), + .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))), + }; + }, + .vertical => { + const clip = track.contentBox(); + if (!boxContainsCell(clip, col, row)) continue; + const shown = track.presented(); + if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) + return null; + const x = @as(f32, @floatFromInt(col)) + 0.5; + const y = @as(f32, @floatFromInt(row)) + 0.5; + const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); + const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); + return .{ + .col = @intFromFloat(@floor(clip.x + u * clip.w)), + .row = @intFromFloat(@floor(clip.y + v * clip.h)), + }; + }, + .dissolve, .ascii => { + if (!boxContainsCell(track.to, col, row)) continue; + const cell_diff = self.cellDiff(cols, rows, col, row); + if (!cell_diff.changed()) + return .{ .col = col, .row = row }; + const relative_col: u16 = @intFromFloat(@floor( + @as(f32, @floatFromInt(col)) + 0.5 - track.to.x, + )); + const relative_row: u16 = @intFromFloat(@floor( + @as(f32, @floatFromInt(row)) + 0.5 - track.to.y, + )); + const visible = switch (track.effect) { + .dissolve => dissolveRevealed( + track.serial, + relative_col, + relative_row, + track.amount(), + ), + .ascii => switch (cell_diff) { + .ascii => |diff| diff.complete(track.frame), + .unchanged, .visual => true, + }, + else => unreachable, + }; + return if (visible) .{ .col = col, .row = row } else null; + }, + .edges, .fall, .wave, .curtain, .scramble, .typewriter => { + if (!boxContainsCell(track.to, col, row)) continue; + const area = CellArea.of(track.to); + const settled = std.meta.eql( + charSource( + track, + col -| area.x0, + row -| area.y0, + area, + ), + CharSource.settled, + ); + return if (settled) .{ .col = col, .row = row } else null; + }, + .off => {}, + } + } + } + for (self.shown_tracks) |maybe| { + const track = maybe orelse continue; + if (!track.active() or (track.effect != .slide and track.effect != .zoom and + track.effect != .vertical)) continue; + if (boxContainsCell(track.to, col, row)) return null; + } + return .{ .col = col, .row = row }; + } + + fn boxContainsCell(box: Box, col: u16, row: u16) bool { + const x = @as(f32, @floatFromInt(col)) + 0.5; + const y = @as(f32, @floatFromInt(row)) + 0.5; + return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; + } + + pub fn cancel(self: *Presentation) void { + self.tracks = @splat(null); + self.closing_len = 0; + self.shown_tracks = @splat(null); + self.shown_closing_len = 0; + self.diff_state = .none; + self.pending = self.acknowledged; + } + + fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool { + const count = surface.cells.len; + if (!self.previous_valid or + self.previous_cols != surface.cols or + self.previous_rows != surface.rows or + self.previous_cells.len != count) return false; + if (self.diffs.len != count) { + const next = try gpa.alloc(pardes.PanelCellDiff, count); + if (self.diffs.len > 0) gpa.free(self.diffs); + self.diffs = next; + } + for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new| + diff.* = pardes.PanelCellDiff.between(old, new); + for (&self.tracks) |*slot| { + const track = if (slot.*) |*track| track else continue; + if (track.effect != .ascii) continue; + var longest: u16 = 1; + var row: u16 = 0; + while (row < surface.rows) : (row += 1) { + var col: u16 = 0; + while (col < surface.cols) : (col += 1) { + if (!boxContainsCell(track.to, col, row)) continue; + const index = @as(usize, row) * surface.cols + col; + switch (self.diffs[index]) { + .ascii => |diff| longest = @max(longest, diff.frameCount()), + .unchanged, .visual => {}, + } + } + } + track.frame_count = @max(track.frame_count, longest); + } + self.diff_state = .ready; + return true; + } + + pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void { + self.submitted = @splat(null); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + self.submitted[id] = .{ + .serial = pane.serial, + .box = panelBox(p.rects[id]), + }; + } + self.submitted_ready = true; + if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) { + for (&self.tracks) |*slot| { + const track = slot.* orelse continue; + if (track.effect.needsPreviousGrid()) slot.* = null; + } + self.closing_len = 0; + self.diff_state = .none; + } + if (self.diff_state == .ready) { + s.previous_cells = self.previous_cells; + s.cell_diffs = self.diffs; + } + s.npanel_tracks = paintOrder( + &self.tracks, + self.closing[0..self.closing_len], + &s.panel_tracks, + ); + } + + pub fn animating(self: *const Presentation) bool { + for (self.tracks) |track| if (track != null and track.?.active()) return true; + for (self.closing[0..self.closing_len]) |track| + if (track.active()) return true; + return false; + } + + test "presentation cache allocation failures retain ownership and allow retry" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const gpa = allocator.allocator(); + var state: Presentation = .{}; + defer state.deinit(gpa); + var cells: [3]pardes.Cell = @splat(.{}); + cells[0].default = false; + cells[0].text[0] = 'a'; + var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] }; + state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }; + state.submitted_ready = true; + state.capturePrevious(gpa, &surface); + try std.testing.expect(state.previous_valid); + try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial); + const previous = state.previous_cells.ptr; + const allocated = allocator.allocations; + allocator.fail_index = allocator.alloc_index; + state.capturePrevious(gpa, &surface); + try std.testing.expect(state.previous_valid); + try std.testing.expectEqual(allocated, allocator.allocations); + try std.testing.expect(!allocator.has_induced_failure); + + surface.cols = 3; + surface.cells = &cells; + state.submitted[0].?.box.w = 3; + state.diff_state = .ready; + state.capturePrevious(gpa, &surface); + try std.testing.expect(allocator.has_induced_failure); + try std.testing.expect(!state.previous_valid); + try std.testing.expectEqual(previous, state.previous_cells.ptr); + try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len); + try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]); + try std.testing.expect(state.previous_layout[0] == null); + try std.testing.expectEqual(.none, state.diff_state); + + allocator.fail_index = std.math.maxInt(usize); + state.capturePrevious(gpa, &surface); + try std.testing.expect(state.previous_valid); + try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len); + try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w); + state.diff_state = .requested; + allocator.fail_index = allocator.alloc_index; + try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface)); + try std.testing.expectEqual(.requested, state.diff_state); + try std.testing.expectEqual(@as(usize, 0), state.diffs.len); + allocator.fail_index = std.math.maxInt(usize); + try std.testing.expect(try state.prepareDiff(gpa, &surface)); + try std.testing.expectEqual(.ready, state.diff_state); + try std.testing.expectEqual(@as(usize, 3), state.diffs.len); + for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff); + } + + test "presentation submission failure preserves shown geometry until retry is acknowledged" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + p.acknowledgePanelPresentation(&.{}); + const shown = p.presentation.shown; + const previous = p.presentation.previous_cells.ptr; + p.presentation.diff_state = .requested; + p.presentation.pending = true; + allocator.fail_index = allocator.alloc_index; + try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface)); + try std.testing.expectEqualDeep(shown, p.presentation.shown); + try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr); + try std.testing.expectEqual(.requested, p.presentation.diff_state); + try std.testing.expect(p.presentation.pending); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null); + + allocator.fail_index = std.math.maxInt(usize); + try p.presentation.submit(p, &p.surface); + try std.testing.expectEqualDeep(shown, p.presentation.shown); + try std.testing.expect(p.presentation.pending); + p.acknowledgePanelPresentation(&.{}); + try std.testing.expect(!p.presentation.pending); + try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?); + } +}; + +pub const column_weight_unit: u64 = 1 << 32; +pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; + +pub const MovePlacement = struct { + preview_col: usize, + above_id: usize, + row: u16, + above_y: u16, + above_h: u16, +}; + +pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { + const a = p.rects[from]; + var best: ?usize = null; + var best_d: i32 = 0; + for (p.panes, 0..) |slot, i| { + if (slot == null or i == from) continue; + const r = p.rects[i]; + const vov = a.y < r.y + r.h and r.y < a.y + a.h; + const hov = a.x < r.x + r.w and r.x < a.x + a.w; + const ok = switch (dir) { + .left => r.x + r.w <= a.x and vov, + .right => r.x >= a.x + a.w and vov, + .up => r.y + r.h <= a.y and hov, + .down => r.y >= a.y + a.h and hov, + }; + if (!ok) continue; + const d: i32 = switch (dir) { + .left => @as(i32, a.x) - @as(i32, r.x + r.w), + .right => @as(i32, r.x) - @as(i32, a.x + a.w), + .up => @as(i32, a.y) - @as(i32, r.y + r.h), + .down => @as(i32, r.y) - @as(i32, a.y + a.h), + }; + if (best == null or d < best_d) { + best = i; + best_d = d; + } + } + if (best) |b| { + p.active = b; + // a count typed before the hop was meant for the pane you left + p.panes[b].?.normal.clear(); + } +} + +pub fn targetColumn(p: *Pardes, cur_x: u16) usize { + var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0; + for (0..p.ncol) |c| { + if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) { + tc = c; + break; + } + } + return tc; +} + +pub fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 { + if (h < 2) return null; + const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1; + const lo = y +| min_each; + const hi = y + h - min_each; + if (lo > hi) return y + h / 2; + return std.math.clamp(cur_y, lo, hi); +} + +pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement { + const src = findPane(p, id) orelse return null; + const tc = targetColumn(p, cur_x); + if (tc == src.col and p.col_n[src.col] == 1) return null; + if (tc == src.col) { + const sr = p.rects[id]; + if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; + } + var heights: [MAX_PANES]u16 = @splat(0); + for (0..p.ncol) |c| { + for (0..p.col_n[c]) |k| { + const pid = p.col_panes[c][k]; + heights[pid] = p.rects[pid].h; + } + } + if (p.col_n[src.col] > 1) { + const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx + 1]; + heights[sib] +|= p.rects[id].h; + } + var y: u16 = TOPBAR_H; + var last: ?MovePlacement = null; + for (0..p.col_n[tc]) |k| { + const pid = p.col_panes[tc][k]; + if (pid == id) continue; + const h = heights[pid]; + const row = splitRowForExtent(y, h, cur_y) orelse { + y +|= h; + continue; + }; + const placement: MovePlacement = .{ + .preview_col = tc, + .above_id = pid, + .row = row, + .above_y = y, + .above_h = h, + }; + last = placement; + if (cur_y < y + h) return placement; + y +|= h; + } + return last; +} + +pub fn movePane(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { + const placement = movePlacement(p, id, cur_x, cur_y) orelse return; + const src = findPane(p, id) orelse return; + const source_multi = p.col_n[src.col] > 1; + var heights: [MAX_PANES]u16 = @splat(0); + for (0..p.ncol) |c| { + for (0..p.col_n[c]) |k| { + const pid = p.col_panes[c][k]; + heights[pid] = p.rects[pid].h; + } + } + removePane(p, id); + if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { + const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx]; + heights[sib] +|= p.rects[id].h; + } + const af = findPane(p, placement.above_id) orelse return; + const upper_h = @max(1, placement.row -| placement.above_y); + const lower_h = @max(1, placement.above_h -| upper_h); + heights[placement.above_id] = upper_h; + heights[id] = lower_h; + insert(p, af.col, af.idx + 1, id); + setColumnWeights(p, af.col, &heights); + if (source_multi and src.col < p.ncol and src.col != af.col) setColumnWeights(p, src.col, &heights); +} + +pub fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { + if (col >= p.ncol) return; + for (0..p.col_n[col]) |k| { + const pid = p.col_panes[col][k]; + if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid])); + } +} + +pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void { + if (k + 1 >= p.col_n[cc]) return; + const a = p.panes[p.col_panes[cc][k]] orelse return; + const b = p.panes[p.col_panes[cc][k + 1]] orelse return; + const ra = p.rects[p.col_panes[cc][k]]; + const rb = p.rects[p.col_panes[cc][k + 1]]; + const combined: f32 = @floatFromInt(ra.h + rb.h); + var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y); + nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H)); + const pair = a.vweight + b.vweight; + a.vweight = pair * (nt / combined); + b.vweight = pair - a.vweight; +} + +pub fn findPane(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { + for (0..p.ncol) |c| { + for (0..p.col_n[c]) |k| { + if (p.col_panes[c][k] == id) return .{ .col = c, .idx = k }; + } + } + return null; +} + +pub fn insert(p: *Pardes, c: usize, idx: usize, id: usize) void { + var k = p.col_n[c]; + while (k > idx) : (k -= 1) p.col_panes[c][k] = p.col_panes[c][k - 1]; + p.col_panes[c][idx] = id; + p.col_n[c] += 1; +} + +pub fn removePane(p: *Pardes, id: usize) void { + const f = findPane(p, id) orelse return; + const c = f.col; + var k = f.idx; + while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1]; + p.col_n[c] -= 1; + if (p.col_n[c] == 0) { + if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; + var j = c; + while (j + 1 < p.ncol) : (j += 1) { + p.col_panes[j] = p.col_panes[j + 1]; + p.col_n[j] = p.col_n[j + 1]; + p.col_weight[j] = p.col_weight[j + 1]; + } + p.ncol -= 1; + } +} + +pub fn joinCol(p: *Pardes) void { + const f = findPane(p, p.active) orelse return; + const c = f.col; + if (c + 1 >= p.ncol) return; + const dst = c + 1; + p.col_weight[dst] +|= p.col_weight[c]; + for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k]; + p.col_n[dst] += p.col_n[c]; + var j = c; + while (j + 1 < p.ncol) : (j += 1) { + p.col_panes[j] = p.col_panes[j + 1]; + p.col_n[j] = p.col_n[j + 1]; + p.col_weight[j] = p.col_weight[j + 1]; + } + p.ncol -= 1; +} + +pub fn canSplitColumn(p: *Pardes, source_id: usize) bool { + if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false; + const source = findPane(p, source_id) orelse return false; + // Refresh derived widths: public layout surgery may be chained between + // syncs, and a cached width must never admit a now-too-narrow split. + compute(p); + if (p.col_w[source.col] < config.MINW * 2) return false; + + const weight = p.col_weight[source.col]; + if (weight >= 2 and weight % 2 == 0) return true; + for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2) + return false; + return weight > 0; +} + +pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { + if (id >= MAX_PANES or p.panes[id] == null) return false; + if (!canSplitColumn(p, source_id)) return false; + const source = findPane(p, source_id) orelse return false; + const source_col = source.col; + var old_weight = p.col_weight[source_col]; + const needs_rebase = old_weight < 2 or old_weight % 2 != 0; + if (needs_rebase) old_weight *= 2; + if (id == source_id) { + if (p.col_n[source_col] <= 1) return false; + absorbVWeight(p, id); + removePane(p, id); + } else if (findPane(p, id) != null) return false; + + if (needs_rebase) { + for (0..p.ncol) |column| p.col_weight[column] *= 2; + } + const source_weight = old_weight / 2; + const new_weight = old_weight - source_weight; + p.col_weight[source_col] = source_weight; + const c = source_col + @intFromBool(!before); + var j = p.ncol; + while (j > c) : (j -= 1) { + p.col_panes[j] = p.col_panes[j - 1]; + p.col_n[j] = p.col_n[j - 1]; + p.col_weight[j] = p.col_weight[j - 1]; + } + p.col_weight[c] = new_weight; + p.col_panes[c][0] = id; + p.col_n[c] = 1; + p.ncol += 1; + return true; +} + +pub fn snapColWeights(p: *Pardes, c: usize) void { + for (0..p.col_n[c]) |k| { + const pid = p.col_panes[c][k]; + if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + } +} + +pub fn absorbVWeight(p: *Pardes, id: usize) void { + const f = findPane(p, id) orelse return; + if (p.col_n[f.col] <= 1) return; + snapColWeights(p, f.col); + var sib = if (f.idx > 0) p.col_panes[f.col][f.idx - 1] else p.col_panes[f.col][f.idx + 1]; + var k = f.idx; + while (k > 0) : (k -= 1) { + sib = p.col_panes[f.col][k - 1]; + if (p.panes[sib]) |pp| if (if (pp.file) |ff| panes.Output.fileTraits(ff.output).doc else true) break; + } + if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); +} + +pub fn splitParent(p: *Pardes, want: usize) usize { + const need = 2 * BOX_H + 3; + if (p.rects[want].h >= need) return want; + if (findPane(p, want)) |f| for (0..p.col_n[f.col]) |k| { + if (p.rects[p.col_panes[f.col][k]].h >= need) return p.col_panes[f.col][k]; + }; + var tallest = want; + for (0..p.ncol) |c| for (0..p.col_n[c]) |k| { + const pid = p.col_panes[c][k]; + if (p.rects[pid].h >= need) return pid; + if (p.rects[pid].h > p.rects[tallest].h) tallest = pid; + }; + return tallest; +} + +pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { + const src = p.panes[src_id] orelse return; + const src_h = p.rects[src_id].h; + const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; + const cur: u16 = if (!src.isTerminal()) body / 2 else panes.Terminal.gridCursor(src).y + 1; + // cap keep so a content-full source still leaves the new pane a tag + + // a few body rows (an Alt-n from a full shell was born 0 rows tall) + const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); + if (findPane(p, src_id)) |f| for (0..p.col_n[f.col]) |k| { + const pid = p.col_panes[f.col][k]; + if (p.panes[pid]) |pp| if (pp != nw) { + pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + }; + }; + src.vweight = @floatFromInt(BOX_H + keep); + nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); + if (nw.file) |f| if (!panes.Output.fileTraits(f.output).doc) { + // trimmed: every row ends in a newline, and the empty line after + // the last one is not a result + const want: f32 = @floatFromInt(BOX_H + panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n"))); + if (want < nw.vweight) { + src.vweight += nw.vweight - want; + nw.vweight = want; + } + }; +} + +pub fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool { + const f = findPane(p, source_id) orelse return false; + compute(p); + return p.col_w[f.col] >= min_cells * 2; +} + +pub fn panelBox(rect: Rect) Box { + return .{ + .x = @floatFromInt(rect.x), + .y = @floatFromInt(rect.y), + .w = @floatFromInt(rect.w), + .h = @floatFromInt(rect.h), + }; +} + +pub fn columnBoundary(width: u16, prefix: u128, total: u128) u16 { + if (total == 0) return 0; + const pixels = (@as(u128, width) * prefix + total / 2) / total; + return @intCast(@min(@as(u128, width), pixels)); +} + +pub fn compute(p: *Pardes) void { + p.rects = @splat(.{}); + if (p.ncol == 0) return; + var wsum: u128 = 0; + for (0..p.ncol) |c| wsum += p.col_weight[c]; + if (wsum == 0) wsum = 1; + + // Round cumulative boundaries so widths still sum to the available screen. + var prefix: u128 = 0; + for (0..p.ncol) |c| { + const last = c + 1 == p.ncol; + const x = columnBoundary(p.screen_w, prefix, wsum); + prefix += p.col_weight[c]; + const end: u16 = if (last) + p.screen_w + else + columnBoundary(p.screen_w, prefix, wsum); + const cw = end -| x; + p.col_x[c] = x; + p.col_w[c] = cw; + + var vsum: f32 = 0; + for (0..p.col_n[c]) |k| { + if (p.panes[p.col_panes[c][k]]) |pane| vsum += pane.vweight; + } + if (vsum <= 0) vsum = 1; + + var y: u16 = TOPBAR_H; + const avail_h = p.screen_h -| TOPBAR_H; + for (0..p.col_n[c]) |k| { + const id = p.col_panes[c][k]; + const pane = p.panes[id] orelse continue; + const lastk = k + 1 == p.col_n[c]; + const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum; + const room = p.screen_h -| y; + const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh))))); + p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; + y +|= ch; + } + } +} + +pub const ascii_max_movement_frames: u16 = 12; + +pub const Easing = enum(u8) { + linear, + smooth, + /// Quintic ease-in-out. It creeps at both ends and crosses the middle of + /// the distance fast, inside the same frame count a linear walk would use. + smoother, + in_cubic, + out_cubic, + out_back, +}; + +pub const Transition = enum(u8) { + // Numeric values are shared with the GUI shader ABI. + off = 0, + slide = 1, + zoom = 2, + dissolve = 3, + ascii = 4, + vertical = 5, + edges = 6, + fall = 7, + wave = 8, + curtain = 9, + scramble = 10, + typewriter = 11, + + pub fn easing(effect: Transition) Easing { + return switch (effect) { + .off, .dissolve, .wave => .smooth, + .slide, .vertical, .edges => .out_cubic, + .zoom => .out_back, + // Character walks and per-cell locks read best with a slow start, + // a fast middle, and a slow settle over their fixed frame count. + .ascii, .fall, .scramble => .smoother, + // A sweep and a typewriter are constant-rate by definition: easing + // their head would make the pass visibly hesitate mid-pane. + .curtain, .typewriter => .linear, + }; + } + + pub fn frames(effect: Transition) u16 { + return switch (effect) { + .off => 0, + .slide => 12, + .zoom => 14, + .dissolve => 10, + .ascii => ascii_max_movement_frames + 1, + .vertical => 12, + .edges, .curtain, .scramble => 12, + // Travelling motion needs a couple more samples than a lock or a + // rigid slide before it stops reading as a jump. + .fall, .wave, .typewriter => 14, + }; + } + + pub fn composedByCore(effect: Transition) bool { + return switch (effect) { + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true, + .off, .slide, .zoom, .dissolve, .vertical => false, + }; + } + + pub fn needsPreviousGrid(effect: Transition) bool { + return effect == .dissolve or effect == .vertical or effect.composedByCore(); + } + + pub fn lifecycleOnly(effect: Transition) bool { + return effect == .vertical; + } +}; + +pub const SceneEffect = struct { + crt: bool = false, + ripple: bool = false, + glitch: bool = false, +}; + +pub const Phase = enum(u8) { + opening = 0, + moving = 1, + /// Presentation-only content whose pane lifetime has already ended. + /// It is never a valid input target. + closing = 2, +}; + +pub const Box = extern struct { + x: f32 = 0, + y: f32 = 0, + w: f32 = 0, + h: f32 = 0, + + pub fn eql(a: Box, b: Box) bool { + return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h; + } + + // A cell belongs to a fractional box when its center lies inside. + pub fn contains(box: Box, col: u16, row: u16) bool { + const x: f32 = @floatFromInt(col); + const y: f32 = @floatFromInt(row); + return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; + } +}; + +// Drawing and hit testing share this moving/opening/closing order. +pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize { + var len: usize = 0; + for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| { + const track = maybe orelse continue; + if (!track.active() or track.phase != phase) continue; + if (len == out.len) return len; + out[len] = track; + len += 1; + }; + for (closing) |track| { + if (!track.active()) continue; + if (len == out.len) return len; + out[len] = track; + len += 1; + } + return len; +} + +/// One POD record is enough for every backend. `from` and `to` are logical +/// cell boxes; frontends convert them to pixels only at their render edge. +pub const Track = extern struct { + serial: u32 = 0, + pane: u8 = 0, + phase: Phase = .moving, + effect: Transition = .off, + _padding: u8 = 0, + frame: u16 = 0, + frame_count: u16 = 0, + from: Box = .{}, + to: Box = .{}, + + pub fn active(track: Track) bool { + return track.effect != .off and track.frame < track.frames(); + } + + pub fn frames(track: Track) u16 { + return if (track.frame_count != 0) track.frame_count else track.effect.frames(); + } + + pub fn amount(track: Track) f32 { + // Opening rises quickly and settles; closing reverses that motion and + // accelerates down out of the fixed clip. + if (track.phase == .closing and track.effect == .vertical) + return progressEased(.in_cubic, track.frames(), track.frame); + return progressEased(track.effect.easing(), track.frames(), track.frame); + } + + pub fn presented(track: Track) Box { + return lerpBox(track.from, track.to, track.amount()); + } + + pub fn visualBox(track: Track) Box { + return switch (track.effect) { + .slide, .zoom, .vertical => track.presented(), + .off, .dissolve => track.to, + // Every character effect stays inside the pane's final rectangle. + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to, + }; + } + + pub fn contentBox(track: Track) Box { + return if (track.phase == .closing) track.from else track.to; + } +}; + +pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { + return switch (effect) { + .slide => blk: { + var from = target; + const middle = target.x + target.w * 0.5; + from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5) + -target.w + else + @floatFromInt(screen_width); + break :blk from; + }, + .zoom => .{ + .x = target.x + target.w * 0.5, + .y = target.y + target.h * 0.5, + .w = 0, + .h = 0, + }, + .vertical => blk: { + var from = target; + from.y += target.h; + break :blk from; + }, + .off, .dissolve => target, + // Character effects own the glyphs inside a fixed rectangle, so their + // panel opens at exactly its final geometry. + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target, + }; +} + +pub fn closingBox(effect: Transition, source: Box) Box { + return switch (effect) { + .vertical => blk: { + var to = source; + to.y += source.h; + break :blk to; + }, + else => source, + }; +} + +pub fn sample(easing: Easing, raw: f32) f32 { + const t = std.math.clamp(raw, 0.0, 1.0); + return switch (easing) { + .linear => t, + .smooth => t * t * (3.0 - 2.0 * t), + .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0), + .in_cubic => t * t * t, + .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), + // Robert Penner's ease-out-back polynomial. It intentionally travels + // a little past one before settling exactly on the endpoint. + .out_back => blk: { + const c1: f32 = 1.70158; + const c3 = c1 + 1.0; + const u = t - 1.0; + break :blk 1.0 + c3 * u * u * u + c1 * u * u; + }, + }; +} + +pub fn progress(effect: Transition, frame: u16) f32 { + return progressEased(effect.easing(), effect.frames(), frame); +} + +fn progressEased(easing: Easing, frames: u16, frame: u16) f32 { + if (frames <= 1 or frame >= frames - 1) return 1.0; + return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); +} + +pub fn lerpBox(from: Box, to: Box, t: f32) Box { + const u = @max(0.0, t); + return .{ + .x = from.x + (to.x - from.x) * u, + .y = from.y + (to.y - from.y) * u, + .w = @max(0.0, from.w + (to.w - from.w) * u), + .h = @max(0.0, from.h + (to.h - from.h) * u), + }; +} + +/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only +/// hashing means resizing or repainting a frame does not make cells flicker. +pub fn cellNoise(serial: u32, col: u16, row: u16) f32 { + var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b); + x ^= x >> 16; + x *%= 0x7feb_352d; + x ^= x >> 15; + x *%= 0x846c_a68b; + x ^= x >> 16; + return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0; +} + +/// Whether a changed dissolve cell has crossed from the frozen old grid to +/// the new one. Exact endpoints are part of the presentation contract. +pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool { + const t = std.math.clamp(raw_progress, 0.0, 1.0); + if (t <= 0) return false; + if (t >= 1) return true; + return cellNoise(serial, col, row) < t; +} + +pub const CellArea = struct { + x0: u16 = 0, + y0: u16 = 0, + cols: u16 = 1, + rows: u16 = 1, + + pub fn of(box: Box) CellArea { + return .{ + .x0 = floorCell(box.x), + .y0 = floorCell(box.y), + .cols = ceilCell(box.w), + .rows = ceilCell(box.h), + }; + } +}; + +fn floorCell(value: f32) u16 { + return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16)))); +} + +fn ceilCell(value: f32) u16 { + return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16)))); +} + +pub const CharSource = union(enum) { + /// Nothing has arrived here yet: keep the frozen old cell. + old, + at: Offset, + /// Paint this printable byte in the destination cell's own style, whatever + /// that cell holds — a caret marching over empty space is still a caret. + byte: u8, + churn: u8, + + pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 }; + + pub const settled: CharSource = .{ .at = .{} }; +}; + +pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource { + const t = track.amount(); + if (t >= 1.0) return .settled; + const w: f32 = @floatFromInt(area.cols); + const h: f32 = @floatFromInt(area.rows); + const c: f32 = @floatFromInt(col); + const r: f32 = @floatFromInt(row); + const remaining = 1.0 - t; + return switch (track.effect) { + .edges => blk: { + const travel = cellsOf(remaining * (w + 1.0)); + break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } }; + }, + // Columns rain down, each with its own stable head start, so the pane + // fills from the top and the last glyphs land at the bottom. + .fall => blk: { + const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4); + if (local <= 0.0) break :blk .old; + break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } }; + }, + // A vertical ripple travels left to right and its amplitude decays, so + // the pane settles out of a wave instead of a fade. + .wave => .{ .at = .{ + .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)), + } }, + // A curtain of glyphs marches in from the right, column by column, left + // to right; each column still has a short slide of its own. + .curtain => blk: { + const lead = t * (w + 1.0) - c; + if (lead <= 0.0) break :blk .old; + break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } }; + }, + // Every cell churns through printable ASCII and locks onto its final + // glyph at its own stable threshold: the pane resolves out of noise. + .scramble => blk: { + if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled; + const churn = cellNoise( + track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f), + col, + row, + ); + break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) }; + }, + // Reading-order reveal with a caret sitting on the write head. + .typewriter => blk: { + const head = t * w * h; + const index = r * w + c; + if (index + 1.0 <= head) break :blk .settled; + if (index <= head) break :blk .{ .byte = '_' }; + break :blk .old; + }, + // PanelAscii walks its own byte distance per cell, and the geometry + // effects never reach this path at all. + .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled, + }; +} + +fn cellsOf(distance: f32) i32 { + return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0))); +} + +/// Remap track progress into one cell's own window. A stagger delays a glyph +/// without making the effect as a whole end after its last frame. +fn staggered(t: f32, delay: f32) f32 { + if (delay >= 1.0) return t; + return (t - delay) / (1.0 - delay); +} + +test "easing presets have exact endpoints and intended shapes" { + inline for (std.enums.values(Easing)) |easing| { + try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); + try std.testing.expectEqual(@as(f32, 1), sample(easing, 1)); + } + try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5)); + try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); + try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); + try std.testing.expect(sample(.out_back, 0.8) > 1.0); + // Slow at both ends, fast through the middle, and symmetric about the + // halfway point: the same curve the integer byte walk reproduces. + try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5)); + try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15)); + try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85)); + try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) > + sample(.linear, 0.6) - sample(.linear, 0.4)); +} + +test "transition progress completes exactly" { + inline for (std.enums.values(Transition)) |effect| { + try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames())); + if (effect.frames() > 0) + try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1)); + try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16))); + } + try std.testing.expectEqual(@as(f32, 1), progress(.off, 0)); + + const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8)); + try std.testing.expectEqual(@as(f32, 0), shrinking.w); + try std.testing.expectEqual(@as(f32, 0), shrinking.h); + const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8)); + try std.testing.expect(opening.w > 100); + try std.testing.expect(opening.h > 40); +} + +test "character effects are core-composed and settle on the canonical glyph" { + const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 }; + const area: CellArea = .of(box); + try std.testing.expectEqual(@as(u16, 4), area.x0); + try std.testing.expectEqual(@as(u16, 2), area.y0); + try std.testing.expectEqual(@as(u16, 20), area.cols); + try std.testing.expectEqual(@as(u16, 6), area.rows); + + inline for (std.enums.values(Transition)) |effect| { + if (comptime !effect.composedByCore()) continue; + // Core composition needs the frozen old grid for every glyph which has + // not arrived, so no character effect may animate without it. + try std.testing.expect(effect.needsPreviousGrid()); + if (comptime effect == .ascii) continue; // owns its own per-cell byte walk + + const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box }; + const first: Track = .{ .effect = effect, .frame = 0, .to = box }; + var moving = false; + var row: u16 = 0; + while (row < area.rows) : (row += 1) { + var col: u16 = 0; + while (col < area.cols) : (col += 1) { + // The last active sample is the exact canonical grid: no cell + // is displaced, churning, or still frozen. + try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area)); + if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area))) + moving = true; + } + } + try std.testing.expect(moving); + } +} + +test "each character effect moves glyphs along its own axis" { + const box: Box = .{ .w = 30, .h = 8 }; + const area: CellArea = .of(box); + + // Rows alternate which screen edge they come from, and every glyph in a row + // travels as one rigid slide: one offset, no vertical component. + var edges: Track = .{ .effect = .edges, .frame = 2, .to = box }; + const even = charSource(edges, 5, 0, area).at; + const odd = charSource(edges, 5, 1, area).at; + try std.testing.expect(even.cols > 0); + try std.testing.expectEqual(-even.cols, odd.cols); + try std.testing.expectEqual(@as(i32, 0), even.rows); + try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at); + edges.frame = 5; + try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols); + + // Falling columns are vertical only, staggered, and sample from below the + // destination because the new text is still above the pane. + const fall: Track = .{ .effect = .fall, .frame = 4, .to = box }; + var falling = false; + var col: u16 = 0; + while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) { + .old => {}, + .byte, .churn => return error.FallShouldNotChurn, + .at => |offset| { + try std.testing.expectEqual(@as(i32, 0), offset.cols); + try std.testing.expect(offset.rows >= 0); + if (offset.rows > 0) falling = true; + }, + }; + try std.testing.expect(falling); + + // The wave displaces rows both ways as it travels, and only rows. + const wave: Track = .{ .effect = .wave, .frame = 1, .to = box }; + var above = false; + var below = false; + col = 0; + while (col < area.cols) : (col += 1) { + const offset = charSource(wave, col, 3, area).at; + try std.testing.expectEqual(@as(i32, 0), offset.cols); + if (offset.rows < 0) above = true; + if (offset.rows > 0) below = true; + } + try std.testing.expect(above and below); + + // The curtain has a head: columns behind it hold the old grid, columns the + // head has passed are settled, and the head itself is still sliding. + const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box }; + try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area)); + try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area)); + var sliding = false; + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) { + .at => |offset| if (offset.cols < 0) { + sliding = true; + }, + .old, .byte, .churn => {}, + }; + try std.testing.expect(sliding); + + var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box }; + var churning: usize = 0; + var locked: usize = 0; + var changed = false; + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) { + .churn => |byte| { + try std.testing.expect(byte >= ' ' and byte <= '~'); + churning += 1; + scramble.frame = 4; + switch (charSource(scramble, col, 0, area)) { + .churn => |next| changed = changed or next != byte, + .old, .at, .byte => {}, + } + scramble.frame = 3; + }, + .at => locked += 1, + .old, .byte => return error.ScrambleShouldNotFreeze, + }; + try std.testing.expect(churning > 0 and locked > 0 and changed); + + // The typewriter writes in reading order with a caret on its head. + const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box }; + try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area)); + try std.testing.expectEqual( + CharSource{ .old = {} }, + charSource(typewriter, area.cols - 1, area.rows - 1, area), + ); + var carets: usize = 0; + var row: u16 = 0; + while (row < area.rows) : (row += 1) { + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) { + .byte => |byte| { + try std.testing.expectEqual(@as(u8, '_'), byte); + carets += 1; + }, + .old, .at, .churn => {}, + }; + } + try std.testing.expectEqual(@as(usize, 1), carets); +} + +test "opening presets separate geometry and content transitions" { + const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 }; + try std.testing.expectEqual(target, openingBox(.ascii, target, 80)); + try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w); + try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x); + try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y); + try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y); + + var track: Track = .{ .effect = .slide, .from = target, .to = target }; + try std.testing.expect(track.active()); + track.frame = track.effect.frames(); + try std.testing.expect(!track.active()); + + track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; + try std.testing.expectEqual(target, track.visualBox()); + + var closing: Track = .{ + .phase = .closing, + .effect = .vertical, + .from = target, + .to = closingBox(.vertical, target), + }; + try std.testing.expectEqual(target, closing.contentBox()); + closing.frame = 2; + try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); +} + +test "dissolve has exact stable endpoints" { + for (0..64) |col| { + const x: u16 = @intCast(col); + try std.testing.expect(!dissolveRevealed(42, x, 7, 0)); + try std.testing.expect(dissolveRevealed(42, x, 7, 1)); + if (dissolveRevealed(42, x, 7, 0.25)) + try std.testing.expect(dissolveRevealed(42, x, 7, 0.75)); + } +} + +pub const Animation = struct { + pub const frame_ms: u32 = 16; + pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; + pub const transition_steps: u16 = 10; + + pub fn Transition(comptime Value: type) type { + return struct { + const Self = @This(); + + from: Value, + to: Value, + displayed: Value, + step: u16 = transition_steps, + + pub fn init(value: Value) Self { + return .{ .from = value, .to = value, .displayed = value }; + } + + pub fn isActive(a: *const Self) bool { + return a.step < transition_steps; + } + + pub fn retarget(a: *Self, target: Value) void { + a.from = a.displayed; + a.to = target; + a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; + if (a.step == transition_steps) a.displayed = target; + } + + pub fn advance(a: *Self) void { + if (!a.isActive()) return; + a.step += 1; + a.displayed = if (a.step == transition_steps) + a.to + else + Value.interpolate(a.from, a.to, a.step, transition_steps); + } + + pub fn snap(a: *Self, value: Value) void { + a.* = init(value); + } + }; + } + + pub fn Immediate(comptime Value: type) type { + return struct { + const Self = @This(); + + displayed: Value, + + pub fn init(value: Value) Self { + return .{ .displayed = value }; + } + + pub fn isActive(_: *const Self) bool { + return false; + } + + pub fn retarget(a: *Self, target: Value) void { + a.displayed = target; + } + + pub fn advance(_: *Self) void {} + + pub fn snap(a: *Self, value: Value) void { + a.displayed = value; + } + }; + } + + pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { + if (step == 0) return from; + if (step >= steps) return to; + var out: [3]u8 = undefined; + for (&out, from, to) |*dst, a, b| { + const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; + dst.* = @intCast((numerator + steps / 2) / steps); + } + return out; + } + + const TestColor = struct { + rgb: [3]u8, + + pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { + return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; + } + }; + + test "Immediate lands where a completed Transition lands" { + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; + + var faded = Animation.Transition(TestColor).init(from); + faded.retarget(to); + for (0..transition_steps) |_| faded.advance(); + + var instant = Immediate(TestColor).init(from); + try std.testing.expect(!instant.isActive()); + instant.retarget(to); + try std.testing.expectEqual(faded.displayed, instant.displayed); + + // Never active, so a frontend that renders only while something is animating stops immediately + // rather than spending ten frames discovering there is nothing to draw. + try std.testing.expect(!instant.isActive()); + instant.advance(); + try std.testing.expectEqual(to, instant.displayed); + + instant.snap(from); + try std.testing.expectEqual(from, instant.displayed); + } + + test "fixed-step interpolation has exact monotonic endpoints" { + const Tween = Animation.Transition(TestColor); + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; + var tween = Tween.init(from); + tween.retarget(to); + try std.testing.expectEqual(from, tween.displayed); + + var previous = tween.displayed; + for (0..transition_steps) |_| { + tween.advance(); + try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); + try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); + try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); + previous = tween.displayed; + } + try std.testing.expect(!tween.isActive()); + try std.testing.expectEqual(to, tween.displayed); + tween.advance(); + try std.testing.expectEqual(to, tween.displayed); + } + + test "retarget starts at the currently displayed value" { + const Tween = Animation.Transition(TestColor); + const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; + const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; + const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; + var tween = Tween.init(first); + tween.retarget(second); + tween.advance(); + tween.advance(); + tween.advance(); + const on_screen = tween.displayed; + + tween.retarget(third); + try std.testing.expectEqual(on_screen, tween.from); + try std.testing.expectEqual(on_screen, tween.displayed); + try std.testing.expect(tween.isActive()); + for (0..transition_steps) |_| tween.advance(); + try std.testing.expectEqual(third, tween.displayed); + } +}; diff --git a/src/limits.zig b/src/limits.zig deleted file mode 100644 index ee94a4f6..00000000 --- a/src/limits.zig +++ /dev/null @@ -1,237 +0,0 @@ -//! Every board-shaped capacity in one table. -//! -//! These numbers used to be nine `platform == .esp32p4` tests scattered across -//! nine files, each one a separate place to forget. They are not nine -//! decisions: they are ONE decision — how much memory this build is allowed to -//! spend — taken nine times, in nine files, where no reader could see the -//! total. Here the whole budget is on one screen and every cap says what it is -//! measured against. -//! -//! Two booleans derive all of it, and nothing outside this file tests the -//! platform for a capacity again. -//! -//! WHAT DOES NOT BELONG HERE: capability switches. `terminal_panes`, -//! `board_memory.enabled`, `hosted`, `font_picker` and the rest answer "does -//! this build have the thing at all", which is a question about the platform -//! and not about a budget — they stay next to the thing they gate. That -//! division is also why a build option selecting the board's budget on a -//! desktop does not work; the note on `board` below records the attempt. -const std = @import("std"); -const builtin = @import("builtin"); -const config = @import("pardes_config"); - -/// `board` is the ESP32-P4 firmware's budget: a 384 KiB heap and a 240 KiB -/// chunk of L2MEM shared between `.bss`, `.data` and the stack. `reduced` is -/// any freestanding target with no OS under it — the browser's wasm linear -/// memory grown on demand, megabytes rather than tens, but not a desktop's -/// address space. -/// -/// TWO BOOLEANS AND NOT A PROFILE ENUM, and a build option was tried and -/// removed. `-Dmem-profile=board` was meant to let a native test runner -/// compile the board's capacities and boot the core under them; it does not -/// work, and cannot. The dominant term in a boot is `@sizeOf(Pane)`, which -/// carries the ghostty-vt Terminal — 1.1 MiB of it — and what removes that is -/// `pardes.terminal_panes`, a CAPABILITY keyed on the platform rather than a -/// capacity in this table. So the option shrank the rings and left the boot -/// six times over budget, producing a configuration nothing was designed for: -/// `zig build unit-test -Dmem-profile=board` deadlocked in a futex rather than -/// failing, because a hosted build with the board's effect ring silently drops -/// effects a hosted test is waiting on. -/// -/// What DOES test the board's memory pressure natively is in pardes.zig: the -/// grid-scaled cost and the allocation-failure sweep, both of which are -/// platform-independent and run on the ordinary build. See the comment block -/// above `board_heap_bytes` there. -const board = config.platform == .esp32p4; -/// No OS means no address space to reserve megabytes out of, whatever the -/// platform is called. `.web` is wasm32-freestanding and `.esp32p4` is -/// riscv32-freestanding, so the target answers this for both. -const reduced_target = builtin.os.tag == .freestanding; -const KiB = 1024; -const MiB = 1024 * KiB; - -/// THE NUMBER EVERY OTHER NUMBER HERE IS MEASURED AGAINST: the board's whole -/// heap, the 384 KiB chunk of L2MEM at 0x4FF40000 (`05-zig-p4`'s linker script -/// owns the split; the 128 KiB above it measured as L2 cache rather than -/// memory). Unconditional and not profile-derived, because it is a fact about -/// the silicon rather than a budget this build chose — a desktop build that -/// wants to know what the board affords is asking exactly this question, which -/// is what the memory tests in pardes.zig do with it. -pub const board_heap_bytes = 384 * KiB; - -/// How many effects the ring holds. SHRUNK, not moved to the heap, on the -/// board: `pump` drains this to empty on every iteration with an -/// unconditional `while (nextEffect())` — including effects `perform` itself -/// queues — so no capacity can deadlock the drain, and the only question a -/// capacity answers is how big a single-pump BURST may be before `emit` -/// refuses the overflow. The one producer that can burst is `emitWrite`, -/// which chunks arbitrary bytes into 64-byte `.write` effects for a pty, and -/// a build with `terminal_panes == false` has no pty to write to. Everything -/// else queues O(1) effects per event, and `in_q` holds at most 64 events per -/// pump, so 128 leaves two effects per queued event. -/// -/// A 1.0625 MiB inline ring cannot live in the board's 384 KiB heap at all; -/// 128 entries is 34 KiB. NOTE THE BEHAVIOUR CHANGE: `emit` has always -/// refused (not evicted) once full, so on the board a burst larger than 128 effects -/// now drops its tail where 4096 would have held it — reachable only through -/// `emitWrite`, i.e. only if a pty ever appears on this platform. -pub const effect_cap = if (board) 128 else 4096; - -/// Bytes of a pane's pty write that may WAIT in the core when `effect_cap` -/// chunks are already queued. `emitWrite` splits a burst into fixed 64-byte -/// effects, so without this a paste larger than `effect_cap * 64` (256 KiB on -/// a desktop) lost its tail silently — the ring refuses rather than evicts, -/// which keeps queued bytes in order but cut the new ones off. The remainder -/// parks here instead and `nextEffect` refills the ring as the host drains it, -/// so a large paste is DELAYED rather than truncated. -/// -/// 4 MiB matches `tty.max_paste_bytes`, the largest burst a host can hand the -/// core in one event, so the bound is the one the producer already enforces. -/// Zero on the board: no ptys means no `emitWrite`, and the allocation this -/// would justify cannot live in 384 KiB anyway. A zero cap parks nothing and -/// restores the old refusal exactly. -pub const pending_write_cap: usize = if (board) 0 else 4 << 20; - -/// Rows the per-pane soft-wrap map covers. `wrapWidth` refuses to wrap a pane -/// taller than this (it reads the array's own length), so shrinking it cannot -/// truncate a map — a taller pane renders unwrapped, exactly as documented on -/// `Pane.wrap_line`. A serial console is not 128 rows tall. -pub const wrap_rows = if (board) 128 else 256; - -/// A shell's reported working directory, owned inline by the pane. Zero-sized -/// where there are no processes to report one: the `PdfSlot` rule, applied to -/// a capacity whose sole producer (`Pardes.setCwd`, fed by a pty's prompt -/// report) does not exist without terminal panes. `setOwnedCwd` clamps, so a -/// zero cap reads as "no directory known" — which is the truth here. -/// -/// Keyed on the profile rather than on `pardes.terminal_panes`, which this -/// file must not import (the core imports the table, not the other way round). -/// The two agree by construction: the board is the only build with no ptys. -pub const cwd_buf_cap = if (board) 0 else 1024; - -/// EDIT BOUNDARIES REMEMBERED PER FILE PANE. Every entry owns a gpa copy of -/// the WHOLE file, so this number multiplies heap, not just the pane: 256 of -/// them is not a bound a 384 KiB board could ever reach anyway. `pushHistory` -/// evicts and frees the oldest once full, so the smaller ring loses the -/// deepest undo steps and nothing else — no truncation, no dropped edit. -pub const undo_max = if (board) 16 else 256; - -/// How many message-row lines the session keeps for `Messages`, and one of the -/// bigger fixed costs on `Pardes`: an entry is 262 bytes, so 128 of them is -/// 32.75 KiB that is allocated whether or not anybody ever reads it. That is -/// 8.5% of the board's whole 384 KiB heap and about the size of its effect -/// ring, so the board takes sixteen — enough that a failure you looked away -/// from is still there, which is the whole point, and not enough to matter -/// beside the panes. This belongs here rather than in config.zig for exactly -/// the reason the file's header gives: it is a board-shaped capacity. -pub const message_log = if (board) 16 else 128; - -/// Bounds the only user-editable, schema-owned tag fragment. It IS the storage -/// bound: `Pane.tag_tail` is `[max_tag_tail]u8`, and every writer (appendTag, -/// tagInsert, restoreDumpTail, the acmefs `tag` file) refuses input that does -/// not fit rather than truncating it, so the schema limit and the buffer can -/// never disagree — a dump reader can reject data before copying it into a -/// pane. -/// -/// 512 on the P4 firmware. A tag is ONE line — a pane's path plus its command -/// words — and 4 KiB of it is 4 KiB per pane out of a 384 KiB heap. A serial -/// console is 80 columns; 512 is six of those. -pub const max_tag_tail: usize = if (board) 512 else 4096; - -/// HOW LONG A HOST-SUPPLIED ABSOLUTE PATH MAY BE, and the only reason that -/// record was ever kilobytes: the shell a native host resolved, the font file -/// a native picker returned, and (in pardes.zig) the one watched theme file. -/// All three name something on a FILESYSTEM, and all three are retained -/// inline because the core has no allocator at the point they arrive. -/// -/// Fixed at 4095 wherever a filesystem exists — deliberately NOT derived from -/// std.fs PATH_MAX, which web has no answer for, and 4095 rather than 4096 so -/// the macOS C bridge's NUL fits without a second, subtly different limit at -/// that boundary. Zero on the P4 firmware, which has no filesystem, no -/// processes to spawn a shell for and no font picker: `Text(0)` is a -/// zero-sized field whose `set` refuses every non-empty path, so the three -/// producers report failure instead of storing 12 KiB nothing can fill. -pub const host_path_cap: usize = if (board) 0 else 4095; - -/// WHETHER PARDES'S OWN SOURCE IS EMBEDDED — the source_manifest allowlist, -/// which is a capacity spelled as rodata rather than as a number. -/// -/// ON THE P4 the allowlist is EMPTY, and that is the whole difference: the -/// table is ~0.95 MiB of rodata against a 1.5 MiB flash partition, and the -/// firmware's filesystem is the serial host's, reached through the Host -/// vtable. The API is unchanged — `all` is a zero-length array and `find` -/// answers null — so every caller compiles identically and simply finds -/// nothing embedded. -pub const embedded_sources = !board; - -/// Bytes per dumped row, and it is a different number on the board. -/// -/// `hexdump -C`'s sixteen is the layout everyone can already read, and it needs 79 columns: ten for -/// the address, forty-eight for the hex, a gap, and the eighteen-column ASCII gutter. The P4 drives -/// a 56-column grid of which seven go to the line-number gutter, so a sixteen-byte row wraps onto a -/// second display line and the columns stop lining up - which is the entire value of the layout. -/// -/// Eight fits in 46 and keeps every property that matters: address on the left, fixed-width hex -/// columns, ASCII on the right, and a gap at the halfway mark because the eye counts in fours and -/// eights rather than in sixteens. -/// -/// NO `0x` ON WHAT THESE WORDS PRINT, which is where two of those columns came from. It reads no -/// worse - every number here is hex, there is no other kind, and the words refuse a decimal one - and -/// it buys something better than the width: an address in a dump can now be typed straight back into -/// a `Peek` without editing it, because bare hex is exactly what the parser wants. Output that is -/// valid input is worth more than a prefix restating what the whole file already says. -pub const hexdump_row_bytes: u32 = if (board) 8 else 16; - -/// Three tiers, because the address space differs by four orders of magnitude. -/// `reduced_target` is the browser: a wasm linear memory it grows on demand, so -/// the static reservations are megabytes rather than tens. -/// -/// `board` is ESP32-P4 firmware, and its tier is deliberately ALL FALLBACK. Every -/// capacity here is a `StackFallbackAllocator`'s buffer, which is a static and -/// therefore lands in `.bss` — and on the P4 `.bss`, `.data` and the stack all -/// share ONE 240 KiB chunk of L2MEM at 0x4FF03000, while the heap the fallback -/// allocator hands out is the separate 384 KiB chunk at 0x4FF40000 - the 128 KiB -/// above that measured as L2 cache rather than memory. A -/// megabyte-shaped reservation here would not fit, and every byte that did fit -/// would be taken from the stack's neighbourhood to duplicate memory the heap -/// already has. So the buffers exist only because the type requires one: 4 KiB -/// absorbs the small churn, and everything else spills to the real heap on the -/// first allocation. -pub const arena = struct { - pub const pardes = if (board) 4 * KiB else if (reduced_target) 8 * MiB else 32 * MiB; - pub const frame = if (board) 4 * KiB else if (reduced_target) 4 * MiB else 16 * MiB; - // Zero is legal and always spills, which is exactly what an arena for a - // compiled-out subsystem should do. `StackFallbackAllocator(0).buffer` is - // `[0]u8`; `get()` inits the FixedBufferAllocator over an empty slice, so - // `FixedBufferAllocator.alloc` fails every nonzero request and `alloc` - // falls through to `self.fallback_allocator.rawAlloc`, while `ownsPtr` over - // an empty range is false for every pointer so `resize`/`remap`/`free` - // route to the fallback too. See lib/std/heap.zig, StackFallbackAllocator. - pub const tree_sitter = if (board) 0 else if (reduced_target) 4 * MiB else 16 * MiB; - pub const image = if (board) 0 else if (reduced_target) 64 * KiB else 32 * MiB; - pub const pdf = if (board) 0 else if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB; -}; - -// THE REGRESSION GUARD for the refactor that created this file: nine caps -// moved out of nine files, and the one thing that must not have changed is -// what a tty/gui/macos build gets. Spelling the historical desktop numbers -// here as literals is the point — a derivation would agree with itself. -test "board limits: a desktop build keeps exactly its historical capacities" { - if (board or reduced_target) return error.SkipZigTest; - try std.testing.expectEqual(4096, effect_cap); - try std.testing.expectEqual(256, wrap_rows); - try std.testing.expectEqual(1024, cwd_buf_cap); - try std.testing.expectEqual(256, undo_max); - try std.testing.expectEqual(@as(usize, 4096), max_tag_tail); - try std.testing.expectEqual(@as(usize, 4095), host_path_cap); - try std.testing.expect(embedded_sources); - try std.testing.expectEqual(@as(u32, 16), hexdump_row_bytes); - // The arena tier a desktop gets is the third one, so it is only the - // historical desktop tier when the target is not itself reduced. - if (reduced_target) return; - try std.testing.expectEqual(32 * MiB, arena.pardes); - try std.testing.expectEqual(16 * MiB, arena.frame); - try std.testing.expectEqual(16 * MiB, arena.tree_sitter); - try std.testing.expectEqual(32 * MiB, arena.image); - try std.testing.expectEqual(if (config.mupdf) 64 * MiB else 64 * KiB, arena.pdf); -} diff --git a/src/look.zig b/src/look.zig index 236b6950..daea5221 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1,45 +1,22 @@ -//! What a click on text MEANS. The acme "look" (right click / Enter): expand -//! the click to a file-ish word, then resolve it against the pane's directory. -//! How a word is SPELLED — the isfilec set, the `:LINE:COL` suffix, `@pN`, the -//! URL schemes, the image extensions — is config.zig; this file is only what -//! the spelling RESOLVES to. -//! -//! This is the one deliberately platform-divergent file — the divergence is a -//! comptime switch on pardes.platform, used the way the stdlib switches on -//! os.tag, so every platform's behavior sits in the same screenful: -//! tty/gui — the word resolves through the real filesystem (realpath, -//! open(O_DIRECTORY)); dirs open shells, files open file panes. -//! web — tracked Pardes .zig sources form a build-generated read-only -//! filesystem; URLs still open in a new tab. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const pardes = @import("pardes.zig"); -const lsp = @import("lsp/lsp.zig"); const config = @import("config.zig"); const pdf_enabled = @import("pardes_config").mupdf; -/// The virtual filesystem, on every platform: the browser has only this, and -/// `run-isolated` chooses it (see `isolated` below). -const embedded_sources = @import("source_manifest.zig"); +const fs = @import("fs.zig"); +const platform_has_fs = fs.platform_has_fs; -extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; extern "c" fn fork() c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; -// absolute opener path per OS: execv must not search PATH (no allocation -// between fork and exec), same rule as the shell spawn. -// ponytail: hardcoded path; a distro that puts xdg-open elsewhere (nix) needs -// a PATH search in the child, which is not fork-safe here. const opener_path: ?[*:0]const u8 = switch (builtin.os.tag) { .linux => "/usr/bin/xdg-open", .macos => "/usr/bin/open", else => null, }; -/// Hand a URL to the desktop — the native half of the web backend's -/// window.open. Double fork: the opener is reparented to init, so the one -/// child we DO wait for exits immediately and nothing is left to reap. pub fn openLink(url: []const u8) void { const opener = opener_path orelse return; var buf: [1024]u8 = undefined; @@ -56,59 +33,20 @@ pub fn openLink(url: []const u8) void { _ = libc.waitpid(pid, null, 0); } -/// WHERE in a pane a look word points. A spot (`:LINE:COL`) — or a SPAN, when -/// the word carries a range (config.range_sep), which a look SELECTS instead -/// of merely parking on. Everything is 1-based and 0 means absent, so a bare -/// path is the all-zero Spot and `end_line == 0` is the question "is this a -/// range". pub const Spot = struct { line: usize = 0, col: usize = 0, end_line: usize = 0, - /// 0 with a live `end_line` is the whole-lines form: through the END of - /// end_line, newline included, which is what helix's `x` selects. end_col: usize = 0, }; -/// digits at `i` and where they end; `end == i` means there were none. Four -/// numbers now come out of the same token, and spelling the scan four times -/// is how one of them ends up subtly different from the others. fn num(tok: []const u8, i: usize) struct { v: usize, end: usize } { var v: usize = 0; var j = i; - // SATURATING, and this is not defensive programming — it is the fix for a - // crash on an ordinary keystroke. The digits come off whatever word is - // under the pointer, so `*` and `+` here run on text the user never wrote - // and cannot control: a right-click, an Enter, or an `n` on anything shaped - // `foo:99999999999999999999` — a hash in a log, a column of a CSV, the - // output of any program — overflowed a `usize` and took the editor down - // with "integer overflow". A number too big to be a line is not a line, and - // `maxInt` is refused by every consumer for free: `file_pane.open` asks - // `line <= total` and `focusPaneLine` asks `id < MAX_PANES`. Same shape - // acmefs.zig's address parser already uses. while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) v = v *| 10 +| (tok[j] - '0'); return .{ .v = v, .end = j }; } -/// peel a trailing :LINE[:COL] spot, or one of the three range spellings, off -/// a look word (config.line_col_sep / config.range_sep own both characters): -/// main.zig:100 -> line 100 -/// main.zig:100:7 -> line 100, col 7 -/// main.zig:100: -> line 100 grep -n's trailing delimiter -/// main.zig:100-104 -> lines 100..104 whole -/// main.zig:100:7-21 -> line 100, cols 7..21 -/// main.zig:100:7-104:3 -> line 100 col 7 .. line 104 col 3 -/// -/// A tail that does not parse leaves the token a plain PATH, which is the rule -/// that keeps the dash safe: `a-b`, `build-2:3` and `x:1-y` are all paths (the -/// last one goes back to hunting for a later ':' and finds none), because a -/// range needs a number on both sides of its dash. -/// -/// `end` is how far into `tok` the form actually REACHED. The read is lenient -/// by design — `main.zig:100:7x` is the file at line 100 and the mangled `:7x` -/// is simply dropped — so `end == tok.len` is the separate question "is the -/// whole token this target and nothing else", which is what a row-grained step -/// must ask before it selects a run of a line (lookableLineSpan). pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: usize } { var sep: usize = 0; while (sep < tok.len) : (sep += 1) { @@ -117,7 +55,6 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: if (l.end == sep + 1) continue; // no digits after ':' const path = tok[0..sep]; var i = l.end; - // `:LINE-ENDLINE`: whole lines, no column anywhere in the form if (i < tok.len and tok[i] == config.range_sep) { const e = num(tok, i + 1); if (e.end == i + 1) continue; // a dash with no number is not a range @@ -127,10 +64,6 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: if (i < tok.len and tok[i] != config.line_col_sep) continue; // junk after the number var at: Spot = .{ .line = l.v }; if (i == tok.len) return .{ .path = path, .at = at, .end = i }; - // `:COL`. A column that does not parse is dropped and the LINE still - // stands, which is how this has always read a half-mangled suffix — and - // `end` stops at the last character that DID read, so the caller that - // cares can tell the two apart. const c = num(tok, i + 1); if (c.end == i + 1) return .{ .path = path, .at = at, .end = i }; if (c.end < tok.len and tok[c.end] != config.line_col_sep and tok[c.end] != config.range_sep) @@ -138,9 +71,6 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: at.col = c.v; i = c.end; if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at, .end = i }; - // `-ENDCOL` on this same line, unless a `:ENDCOL` follows — then that - // first number was the end LINE all along. One lookahead, and it is - // what lets the two-number and four-number forms share a spelling. const e = num(tok, i + 1); if (e.end == i + 1) return .{ .path = path, .at = at, .end = i }; at.end_line = at.line; @@ -168,13 +98,11 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" { .{ .tok = "main.zig:100-104", .path = "main.zig", .at = .{ .line = 100, .end_line = 104 } }, .{ .tok = "main.zig:100:7-21", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 100, .end_col = 21 } }, .{ .tok = "main.zig:100:7-104:3", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 104, .end_col = 3 } }, - // the dash cases that must stay ORDINARY PATHS .{ .tok = "my-file.zig", .path = "my-file.zig", .at = .{} }, .{ .tok = "my-file:10", .path = "my-file", .at = .{ .line = 10 } }, .{ .tok = "x:1-y", .path = "x:1-y", .at = .{} }, .{ .tok = "a-b-c", .path = "a-b-c", .at = .{} }, .{ .tok = "2026-07-30", .path = "2026-07-30", .at = .{} }, - // a mangled tail still yields what parsed (unchanged behaviour) .{ .tok = "main.zig:100x", .path = "main.zig:100x", .at = .{} }, .{ .tok = "main.zig:100:7x", .path = "main.zig", .at = .{ .line = 100 } }, }; @@ -186,11 +114,6 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" { } test "a number too big to be a line saturates instead of taking the editor down" { - // These are keystrokes, not arguments. `parsePathLine` runs on whatever - // word is under the pointer on a right-click, an Enter or an `n` — so the - // digits come out of a hash in a log, a CSV column, or any program's - // output, and an unchecked `v * 10` there is a panic on ordinary use. Every - // number in the token comes through the same scan, so all four are tried. const huge = "99999999999999999999999999"; const cases = [_][]const u8{ "f.zig:" ++ huge, @@ -201,31 +124,21 @@ test "a number too big to be a line saturates instead of taking the editor down" for (cases) |tok| { const got = parsePathLine(tok); try std.testing.expectEqualStrings("f.zig", got.path); - // Saturated rather than wrapped: a wrap would address a REAL line, and - // silently jumping somewhere is worse than not jumping. try std.testing.expect(got.at.line >= 1); } - // ...and the pane address, which has its own scan. `focusPaneLine` refuses - // anything past MAX_PANES, so this resolves to a pane that cannot exist. var realbuf: [4096]u8 = undefined; - const target = resolve("@p" ++ huge, "/tmp", &realbuf); + const target = resolve(null, "@p" ++ huge, "/tmp", &realbuf); try std.testing.expect(target == .pane); try std.testing.expect(target.pane.id >= 16); } test "parsePathLine: `end` separates a whole-token target from a lenient read" { - // the whole token IS the target: every spelling the doc above lists for ([_][]const u8{ "main.zig", "main.zig:100", "main.zig:100:7", "main.zig:100-104", "main.zig:100:7-21", "main.zig:100:7-104:3", "@p3:10:5", "x:1-y", }) |tok| try std.testing.expectEqual(tok.len, parsePathLine(tok).end); - // ...and the reads that DROP a tail: a result row with its matched text - // still attached, which is exactly what a row-grained step must not select - // whole (lookableLineSpan). Note where each one STOPS — a spot is only - // taken once its whole form has read, so the `:7` of a `:100:7 text` row - // is dropped along with the text and `end` says so. const partial = [_]struct { tok: []const u8, end: usize }{ .{ .tok = "main.zig:100:", .end = "main.zig:100".len }, // trailing ':' is peeled, not parsed .{ .tok = "main.zig:100:7x", .end = "main.zig:100".len }, @@ -234,17 +147,11 @@ test "parsePathLine: `end` separates a whole-token target from a lenient read" { .{ .tok = "@p3:10:5 /home/goblin", .end = "@p3:10".len }, }; for (partial) |c| try std.testing.expectEqual(c.end, parsePathLine(c.tok).end); - // A form that breaks off mid-range is not a lenient read at all: the scan - // goes back for a later ':', finds none, and the token is a plain PATH - // whole — which resolves or does not on its own merits. const whole = "main.zig:100-104 whole lines"; try std.testing.expectEqual(whole.len, parsePathLine(whole).end); try std.testing.expectEqualStrings(whole, parsePathLine(whole).path); } -/// A file-like Look target has a rendering kind only in MuPDF builds. The -/// feature-off enum has no `pdf` tag at all, so `.pdf` is indistinguishable -/// from any other ordinary file before it reaches the core. pub const FileKind = if (pdf_enabled) enum { text, pdf } else enum { text }; pub const FileTarget = struct { @@ -259,9 +166,6 @@ pub const Target = union(enum) { file: FileTarget, image: struct { path: []const u8 }, url: []const u8, - /// `@p7:10:5` — pane 7, line 10, column 5 (0 = unspecified). The one - /// target that names a live pane instead of a path, because terminals and - /// output buffers have no file for a location to point at. pane: struct { id: usize, at: Spot }, }; @@ -290,7 +194,7 @@ test "PDF file kinds exist only in MuPDF-enabled builds" { test ".pdf Look paths are ordinary files when MuPDF is disabled" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; - const target = resolve("docs/design.pdf", ".", &realbuf); + const target = resolve(null, "docs/design.pdf", ".", &realbuf); switch (target) { .file => |file| { if (comptime pdf_enabled) @@ -302,110 +206,48 @@ test ".pdf Look paths are ordinary files when MuPDF is disabled" { } } -/// Where a look-able word actually SITS inside a run of non-whitespace. pub const Span = struct { start: usize, end: usize }; -/// The punctuation a path wears in prose and never owns. Two sets, because -/// the two ends are not alike: a directory may legally END in `/`, and the -/// `:` that closes `grep -n`'s `main.zig:100:` is junk on the right and -/// meaningful nowhere on the left. const lead_trim = "([{<\"'`*"; const trail_trim = ")]}>\"'`*,;:.!?"; -/// The largest look-able span inside one whitespace-delimited `word`, or null -/// when nothing in it resolves. This is the WORD grain of n/N — split a row on -/// whitespace and take the biggest piece of each run Look can act on — which -/// is what a terminal, a file and a PDF step, because their lines are free -/// text and a line may hold several places (an `ls` row hops file to file). -/// A results buffer steps ROWS instead: lookableLineSpan. -/// -/// TWO resolve attempts at most, which is what keeps a motion across a -/// screenful of prose from being a hundred realpaths: the run with every -/// wrapper character peeled off BOTH ends at once, then — only if that found -/// nothing — the run exactly as written. -/// -/// PEELED FIRST, which is the ordering that matters. `resolve` is lenient -/// about a tail it cannot parse (`main.zig:12:3,` yields the FILE and drops -/// the position, by design), so asking it about the raw run first would -/// happily answer yes and swallow the comma along with the `:3`. Peeling -/// first hands it `main.zig:12:3` and the look lands on the column. The raw -/// run stays as the fallback for the file genuinely named `foo,` or `..`, -/// where the peel eats something real. -/// -/// Deliberately NOT a search for the longest resolving substring: that costs -/// a syscall per prefix to find a path hiding inside a word nobody typed as -/// one. A run needing a cleverer peel is still one Enter away with the cursor -/// parked on it. -/// -/// Direction-free on purpose: n and N ask this the same question about the -/// same run and get the same span back, which is what lets the two motions be -/// exact inverses of each other. -pub fn lookableSpan(word: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { +pub fn wordSpan(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { if (word.len == 0) return null; var lo: usize = 0; var hi: usize = word.len; while (lo < hi and std.mem.indexOfScalar(u8, lead_trim, word[lo]) != null) lo += 1; while (hi > lo and std.mem.indexOfScalar(u8, trail_trim, word[hi - 1]) != null) hi -= 1; - if (lo < hi and resolve(word[lo..hi], cwd, realbuf) != .none) return .{ .start = lo, .end = hi }; - // nothing came off, so the peeled attempt WAS the raw one + if (lo < hi and resolve(p, word[lo..hi], cwd, realbuf) != .none) return .{ .start = lo, .end = hi }; if (lo == 0 and hi == word.len) return null; - if (resolve(word, cwd, realbuf) == .none) return null; + if (resolve(p, word, cwd, realbuf) == .none) return null; return .{ .start = 0, .end = word.len }; } test "lookableSpan peels prose punctuation off a path, largest first" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; - // the bare run resolves whole, wrappers and all left alone try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig".len }), - lookableSpan("src/look.zig", ".", &realbuf), + wordSpan(null, "src/look.zig", ".", &realbuf), ); - // ...and a wrapped one gives back the span INSIDE the wrappers try std.testing.expectEqualDeep( @as(?Span, .{ .start = 1, .end = 1 + "src/look.zig".len }), - lookableSpan("(src/look.zig),", ".", &realbuf), + wordSpan(null, "(src/look.zig),", ".", &realbuf), ); - // the `:LINE:COL` tail is part of the span: it is what a look READS try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:3".len }), - lookableSpan("src/look.zig:12:3,", ".", &realbuf), + wordSpan(null, "src/look.zig:12:3,", ".", &realbuf), ); - // grep -n's trailing delimiter comes off, the line number stays try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12".len }), - lookableSpan("src/look.zig:12:", ".", &realbuf), + wordSpan(null, "src/look.zig:12:", ".", &realbuf), ); - try std.testing.expectEqual(@as(?Span, null), lookableSpan("nothing-here", ".", &realbuf)); - try std.testing.expectEqual(@as(?Span, null), lookableSpan("", ".", &realbuf)); - try std.testing.expectEqual(@as(?Span, null), lookableSpan("((()))", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "nothing-here", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "((()))", ".", &realbuf)); } -/// The largest look-able span ANCHORED at the start of `line`'s text, or null -/// when the row names no place at all. This is the ROW grain of n/N, and what -/// a results buffer steps: a row there IS one location — `path:LINE:COL text` -/// — and the words after the location are the MATCH, not a second place to -/// step to. One stop per row, always its head. -/// -/// LARGEST, so the candidates are the run from the first non-blank cell out to -/// each whitespace boundary, tried LONGEST first: a path with a blank in it -/// (`old notes/plan.txt`) beats the word hiding inside it, which is the case -/// the word grain cannot express at all. -/// -/// A candidate only counts when it is the target EXACTLY — parsePathLine -/// consuming every byte of it, after the same wrapper peel lookableSpan does. -/// That gate is what keeps longest-first from swallowing the whole row: -/// `resolve` is lenient by design and answers `src/x.zig:12:5 const y` with -/// the FILE, so without it every result row would select out to its right -/// margin and throw the `:5` away along with the text. A url is lenient the -/// same way in the other direction — it is recognised by its PREFIX, so a -/// longer run is not a longer link — and only the filesystem can vouch for a -/// span with a blank inside it, so only the filesystem is allowed to. -/// -/// Cost is the word grain's: the exactness gate is pure parsing, so a row -/// spends at most one resolve per whitespace boundary and the ordinary result -/// row — whose head is its whole location — spends two. -pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { +pub fn lineSpan(p: ?*pardes.Pardes, line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { var lo: usize = 0; while (lo < line.len and (line[lo] == ' ' or line[lo] == '\t')) lo += 1; var hi = std.mem.trimEnd(u8, line, " \t\r").len; @@ -415,13 +257,12 @@ pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ? while (a < b and std.mem.indexOfScalar(u8, lead_trim, line[a]) != null) a += 1; while (b > a and std.mem.indexOfScalar(u8, trail_trim, line[b - 1]) != null) b -= 1; const cand = line[a..b]; - if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(cand, cwd, realbuf)) { + if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(p, cand, cwd, realbuf)) { .dir, .file, .image => return .{ .start = a, .end = b }, .url, .pane => if (std.mem.indexOfAny(u8, cand, " \t") == null) return .{ .start = a, .end = b }, .none => {}, }; - // ...else the same run one word shorter while (hi > lo and line[hi - 1] != ' ' and line[hi - 1] != '\t') hi -= 1; while (hi > lo and (line[hi - 1] == ' ' or line[hi - 1] == '\t')) hi -= 1; } @@ -431,1302 +272,81 @@ pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ? test "lookableLineSpan takes the row's location and stops before its text" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; - // a grep row: the location, and NOT the matched code after it — which - // `resolve` would happily answer for, minus the column try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5-9".len }), - lookableLineSpan("src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf), + lineSpan(null, "src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf), ); - // an lsp/jumplist row, whose column is followed by a blank rather than a - // ':' — the form a lenient read drops on the floor try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5".len }), - lookableLineSpan("src/look.zig:12:5 pub fn resolve", ".", &realbuf), + lineSpan(null, "src/look.zig:12:5 pub fn resolve", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "@p3:10:5".len }), - lookableLineSpan("@p3:10:5 /home/goblin", ".", &realbuf), + lineSpan(null, "@p3:10:5 /home/goblin", ".", &realbuf), ); - // a bare path row, wrappers peeled and blank indent skipped like anywhere - // else — the anchor is the row's first non-blank cell, not column zero try std.testing.expectEqualDeep( @as(?Span, .{ .start = 3, .end = 3 + "src/look.zig".len }), - lookableLineSpan(" (src/look.zig)", ".", &realbuf), + lineSpan(null, " (src/look.zig)", ".", &realbuf), ); - // a link row keeps its link and leaves the title alone: a longer run is - // not a longer url try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "https://pardes.dev/a".len }), - lookableLineSpan("https://pardes.dev/a Chapter One", ".", &realbuf), + lineSpan(null, "https://pardes.dev/a Chapter One", ".", &realbuf), ); - // ANCHORED: a place mentioned mid-row is not a stop, and a row with no - // place at its head is no stop at all try std.testing.expectEqual( @as(?Span, null), - lookableLineSpan("see also src/look.zig", ".", &realbuf), + lineSpan(null, "see also src/look.zig", ".", &realbuf), ); - try std.testing.expectEqual(@as(?Span, null), lookableLineSpan(" ", ".", &realbuf)); - try std.testing.expectEqual(@as(?Span, null), lookableLineSpan("", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), lineSpan(null, " ", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), lineSpan(null, "", ".", &realbuf)); } test "lookableLineSpan prefers the longest run, so a blank inside a path is one span" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; - // A real path with a blank in it, under a directory whose own name is the - // first word of the row: the word grain can only ever see `tmp`, and the - // row grain sees the file, because it asks about the longest run first. - const io = std.Io.Threaded.global_single_threaded.io(); - var tmp = try std.Io.Dir.cwd().openDir(io, "/tmp", .{}); - defer tmp.close(io); + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); const name = "pardes look span.txt"; - try tmp.writeFile(io, .{ .sub_path = name, .data = "" }); - defer tmp.deleteFile(io, name) catch {}; + try tmp.dir.writeFile(io, .{ .sub_path = name, .data = "" }); + try tmp.dir.createDir(io, "subdir", .default_dir); + var path: [4096]u8 = undefined; + const len = try tmp.dir.realPathFile(io, name, &path); + const cwd = std.fs.path.dirname(path[0..len]).?; try std.testing.expectEqualDeep( - @as(?Span, .{ .start = 0, .end = ("tmp/" ++ name).len }), - lookableLineSpan("tmp/" ++ name, "/", &realbuf), + @as(?Span, .{ .start = 0, .end = name.len }), + lineSpan(null, name, cwd, &realbuf), ); - // ...and the shrink still finds the shorter run when the long one is - // prose. Candidates END at a blank, so the runs tried are whole words: - // there is no hunt for a path hiding inside one (lookableSpan's rule). try std.testing.expectEqualDeep( - @as(?Span, .{ .start = 0, .end = "tmp".len }), - lookableLineSpan("tmp holds pardes look span.txt", "/", &realbuf), + @as(?Span, .{ .start = 0, .end = "subdir".len }), + lineSpan(null, "subdir holds pardes look span.txt", cwd, &realbuf), ); } -/// Resolve a looked-at word against the pane's directory. `realbuf` must -/// outlive the returned Target (native paths point into it; web paths are -/// process-lifetime slices in the embedded source archive). -pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target { +pub fn resolve(p: ?*pardes.Pardes, word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target { const trimmed = std.mem.trim(u8, word_raw, " \t\r\n"); const pl = parsePathLine(trimmed); const word = pl.path; if (word.len == 0) return .none; - // `@pN` addresses a pane, not a path: every platform, before the fs. if (word.len > config.pane_addr.len and std.mem.startsWith(u8, word, config.pane_addr)) { var id: usize = 0; for (word[config.pane_addr.len..]) |c| { if (!std.ascii.isDigit(c)) break; - // Saturating for the same reason `num` above is: this scan also - // runs on a word somebody merely clicked. `focusPaneLine` refuses - // anything past `MAX_PANES`, so a saturated id addresses nothing. id = id *| 10 +| (c - '0'); } else return .{ .pane = .{ .id = id, .at = pl.at } }; } - // a URL is a URL everywhere: no filesystem can answer it, so it leaves the - // app (browser tab on web, xdg-open/open on the desktop). for (config.url_schemes) |scheme| { if (std.mem.startsWith(u8, trimmed, scheme)) return .{ .url = trimmed }; } - if (platform_has_fs) { - var joinbuf: [2048]u8 = undefined; - const joined: ?[:0]u8 = if (word[0] == '/') - (std.fmt.bufPrintSentinel(&joinbuf, "{s}", .{word}, 0) catch null) - else - (std.fmt.bufPrintSentinel(&joinbuf, "{s}/{s}", .{ cwd, word }, 0) catch null); - const jz = joined orelse return .none; - const rp = realpath(jz.ptr, realbuf) orelse return .none; - const resolved = std.mem.span(rp); - if (isDir(rp)) return .{ .dir = resolved }; - if (comptime pdf_enabled) if (isPdfPath(resolved)) return .{ .file = .{ - .path = resolved, - .at = pl.at, - .kind = .pdf, - } }; - if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } }; - return .{ .file = .{ .path = resolved, .at = pl.at } }; - } else { - // web: tracked Zig sources resolve inside the build-generated, - // read-only source filesystem. - if (resolveEmbedded(word, cwd, realbuf)) |source| - return .{ .file = .{ .path = source.path, .at = pl.at } }; - return .none; - } -} - -/// Resolve a source path without teaching the core about a browser filesystem. -/// Cwd-relative and absolute dump paths are normalized, with printed archive -/// paths also accepted root-relative. The suffix match lets a dump made in -/// `/host/repo` address names that deliberately remain relative to the root. -fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?embedded_sources.Source { - var wordbuf: [4096]u8 = undefined; - const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null; - if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true); - - var joined: [4096]u8 = undefined; - if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate| - if (normalizeVirtualPath(candidate, scratch)) |normalized| - if (findEmbeddedSource(normalized, true)) |source| return source; - // A printed archive path is root-relative even when its surrounding dump - // pane came from some unrelated cwd. - return findEmbeddedSource(normalized_word, false); -} - -fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { - var len: usize = 0; - var parts = std.mem.tokenizeAny(u8, path, "/\\"); - while (parts.next()) |part| { - if (std.mem.eql(u8, part, ".")) continue; - if (std.mem.eql(u8, part, "..")) { - while (len > 0 and out[len - 1] != '/') len -= 1; - if (len > 0) len -= 1; - continue; - } - const extra = part.len + @intFromBool(len != 0); - if (len + extra > out.len) return null; - if (len != 0) { - out[len] = '/'; - len += 1; - } - @memcpy(out[len..][0..part.len], part); - len += part.len; - } - if (len == 0) return null; - return out[0..len]; -} - -fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?embedded_sources.Source { - for (embedded_sources.all) |source| - if (std.mem.eql(u8, source.path, path)) return source; - if (!allow_root_suffix) return null; - for (embedded_sources.all) |source| { - if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue; - if (std.mem.endsWith(u8, path, source.path)) return source; - } - return null; -} - -/// Whether there is a real filesystem to reach at all. An ISOLATED build has -/// none by construction — the option is comptime, so every libc path below is -/// dead code the compiler removes rather than a branch that could be taken by -/// accident. The browser has never had one either, and both then read the same -/// embedded source. -const platform_has_fs = !pardes.isolated and switch (pardes.platform) { - .tty, .gui, .macos => true, - // The browser's filesystem is the embedded source archive; the P4 - // firmware's is whatever the serial host answers for, through the Host - // vtable — never a path this process opens. - .web, .esp32p4 => false, -}; - -// Find's safety rails. The core is SYNCHRONOUS — a Find at `/` runs inside the -// keystroke that asked for it — so the walk must end whatever it is pointed at. -// Three caps, because each alone leaks: hits bound the results buffer, depth -// bounds a deep tree, and steps bound a wide shallow one (a pattern that never -// matches would otherwise walk the whole disk without ever filling `hits`). -const find_max_hits = 512; -const find_max_depth = 16; -const find_max_steps = 100_000; -/// One search result buffer. A grep can visit one root per pane, each root can -/// contribute `find_max_hits`, and native paths are capped at 4096 bytes below. -/// Callers allocate this conservative ceiling once; a full buffer truncates at -/// the last complete row. -pub const search_max_output_bytes = pardes.MAX_PANES * find_max_hits * (4096 + 320); - -/// Directories a source tree has no answers in, skipped whole. fd reads -/// .gitignore for this; pardes has no ignore parser, and every one of these -/// costs a real search: agave's `target/` alone is 456_000 of its 460_000 -/// entries and holds 1_200 of the 1_242 paths matching "bank", so a Find for -/// `bank` burned the whole 512-hit budget on build artifacts and never -/// reached `runtime/src/bank.rs`. That looked like a broken matcher. -const find_skip = [_][]const u8{ - ".git", ".jj", "target", "node_modules", - ".venv", "__pycache__", ".zig-cache", "zig-out", -}; - -/// `fd`, in-core: every path under `dir` whose NAME contains `pat` (plain -/// case-insensitive substring — fd's default is a regex and pardes has no -/// regex engine to spend on one), one path per line into `out`, RELATIVE to -/// `dir` — the results buffer is itself named `dir/+Search`, so every row -/// resolves against the same directory the walk started in and reads as the -/// short name the searcher was looking for. Only real directories are -/// entered, so a symlink can never close a cycle. -/// Filesystem setup and traversal errors are returned to the UI boundary. -pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8) !usize { - var hits: [find_max_hits][]const u8 = undefined; - var hits_len: usize = 0; - if (platform_has_fs) { - // Zig 0.16 moved the filesystem behind std.Io; the blocking - // single-threaded implementation (the one std.debug itself holds) IS - // the synchronous walk the core uses — no pool, no cancelation. - const io = std.Io.Threaded.global_single_threaded.io(); - var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); - defer root.close(io); - // walkSelectively, not walk: descending is opt-in, which is the only - // way to express the depth cap and find_skip at all. - var w = try root.walkSelectively(arena); - defer w.deinit(); - var steps: usize = 0; - walk: while (steps < find_max_steps and hits_len < hits.len) { - steps += 1; // an unreadable dir burns a step too, so it cannot spin - const e = (try w.next(io)) orelse break; - if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null) { - // e.path points into the walker's own buffer, dead at next() - hits[hits_len] = try arena.dupe(u8, e.path); - hits_len += 1; - } - if (e.kind != .directory or e.depth() >= find_max_depth) continue; - for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; - try w.enter(io, e); - } - } else { - // web: the build-generated source archive IS the filesystem, and it is - // already a flat list of paths — the whole walk is the match. - for (embedded_sources.all) |s| { - if (hits_len >= hits.len) break; - if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null) { - hits[hits_len] = s.path; - hits_len += 1; - } - } - } - // readdir order is undefined; sort so the same tree gives the same buffer - // twice running and n/N walks it in a sane order. - std.mem.sort([]const u8, hits[0..hits_len], {}, struct { - fn lt(_: void, a: []const u8, b: []const u8) bool { - return std.mem.lessThan(u8, a, b); - } - }.lt); - var written: usize = 0; - for (hits[0..hits_len]) |h| { - if (h.len + 1 > out.len - written) break; - @memcpy(out[written..][0..h.len], h); - written += h.len; - out[written] = '\n'; - written += 1; - } - return written; -} - -/// how much of one file Grep reads. The core is synchronous, so a tree with a -/// core dump in it must not stall the keystroke: past this the tail of the file -/// is simply not searched (`grep -R` would read it all). -const grep_max_bytes = 256 * 1024; -const grep_max_files = 20_000; - -/// every line of `text` holding `pat`, as `path:LINE:COL-ENDCOL text` rows — -/// the shared half of grep(), and the shape every result row in pardes has: -/// the leading word is a look target, so n/N walk the hits. The row names the -/// MATCH's span and not just its first cell, so stepping onto one selects the -/// text that matched (config.range_sep). Returns the rows written, at most -/// `budget`. -const GrepResult = struct { bytes: usize, hits: usize }; - -fn grepText(path: []const u8, text: []const u8, pat: []const u8, out: []u8, budget: usize) GrepResult { - var result: GrepResult = .{ .bytes = 0, .hits = 0 }; - var line: usize = 0; - var it = std.mem.splitScalar(u8, text, '\n'); - while (it.next()) |raw| { - line += 1; - if (result.hits >= budget) break; - const at = std.ascii.indexOfIgnoreCase(raw, pat) orelse continue; - // one minified line can be the whole file: cut it, but never mid - // codepoint — a partial UTF-8 sequence reaches the renderer as a hit - // row and there is nothing sane for it to draw. - const ln = std.mem.trimEnd(u8, raw, " \t\r"); - var cut = @min(ln.len, 200); - while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1; - const row = std.fmt.bufPrint(out[result.bytes..], "{s}:{d}:{d}{c}{d} {s}\n", .{ - path, line, at + 1, config.range_sep, at + pat.len, ln[0..cut], - }) catch break; - result.bytes += row.len; - result.hits += 1; - } - return result; -} - -/// `grep -R`, in-core: every LINE of every file under `dir` containing `pat` -/// (plain case-insensitive substring, like every other search here), one row -/// per hit into `out`. A row's path is RELATIVE to `base` — the directory of -/// the pane that asked, which is also the one its results buffer is named in, -/// so a row reads as the short name that pane would have typed and still looks -/// up. A hit `base` does not contain (another pane's tree) keeps its absolute -/// path, which resolves from anywhere. Same walk, same skip list and same three -/// caps as find(), plus grep_max_bytes and a NUL sniff so a binary never lands -/// in the results. -/// Filesystem setup, traversal, and read errors are returned to the UI boundary. -pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8) !usize { - var hits: usize = 0; - var written: usize = 0; - if (!platform_has_fs) { - // web: the build-generated source archive IS the filesystem - for (embedded_sources.all) |s| { - if (hits >= find_max_hits or written == out.len) break; - const result = grepText(s.path, s.contents, pat, out[written..], find_max_hits - hits); - hits += result.hits; - written += result.bytes; - } - return written; - } - const root_path = std.mem.trimEnd(u8, dir, "/"); - const home = std.mem.trimEnd(u8, base, "/"); - // The walk collects into one bounded allocation, then the read scans in - // sorted order. e.path dies at the next next(), so these are copies. - const files = try arena.alloc([]const u8, grep_max_files); - var files_len: usize = 0; - { - const io = std.Io.Threaded.global_single_threaded.io(); - var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); - defer root.close(io); - var w = try root.walkSelectively(arena); - defer w.deinit(); - var steps: usize = 0; - walk: while (steps < find_max_steps and files_len < files.len) { - steps += 1; - const e = (try w.next(io)) orelse break; - if (e.kind == .directory) { - if (e.depth() >= find_max_depth) continue; - for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; - try w.enter(io, e); - continue; - } - if (e.kind != .file) continue; - files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }); - files_len += 1; - } - } - std.mem.sort([]const u8, files[0..files_len], {}, struct { - fn lt(_: void, a: []const u8, b: []const u8) bool { - return std.mem.lessThan(u8, a, b); - } - }.lt); - // ONE bounded buffer reused for every file: a synchronous search must not - // swallow a file it cannot afford to hold. - const buf = try gpa.alloc(u8, grep_max_bytes); - defer gpa.free(buf); - for (files[0..files_len]) |path| { - if (hits >= find_max_hits or written == out.len) break; - var pathbuf: [4096]u8 = undefined; - const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; - // A FILE THIS WALK CANNOT READ IS A FILE THIS WALK SKIPS. It used to - // abort the whole grep and report `OpenFailed`, so ONE root-owned 0600 - // file — or one deleted between the walk and the read, which is routine - // in a build tree — turned a search of ten thousand files into zero - // results and a word that explains nothing. A grep is a question about - // the files you can read; the ones you cannot are not an answer to it. - // NONBLOCK for the reason `readFile` has it: a FIFO in the tree would - // otherwise stop the search until somebody wrote to it. - const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .NONBLOCK = true }); - if (fd < 0) continue; - var len: usize = 0; - var readable = true; - while (len < buf.len) { - const n = libc.read(fd, buf[len..].ptr, buf.len - len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - // Skipped, not fatal, for the same reason: whatever this is, it - // is not text this search can answer with. - readable = false; - break; - } - if (n == 0) break; - len += @intCast(n); - } - _ = libc.close(fd); - if (!readable) continue; - const text = buf[0..len]; - if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; // binary - // per PATH, not per root: one root can straddle the asking pane's - // directory (a shell at `/a` searching for a file pane at `/a/b`), and - // the rows inside it are the ones worth shortening. The rule is - // lsp.rel's — under `base` means relative, anywhere else stays - // absolute — and it is THE one spelling now; this used to be an - // inline twin that the seam's own comment complained about. - const shown = lsp.rel(home, path); - const result = grepText(shown, text, pat, out[written..], find_max_hits - hits); - hits += result.hits; - written += result.bytes; - } - return written; -} - -test "grep skips a file it cannot read instead of abandoning the search" { - if (!platform_has_fs) return; - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var base_buf: [std.fs.max_path_bytes]u8 = undefined; - const dir = base_buf[0..try tmp.dir.realPath(std.testing.io, &base_buf)]; - - // Two files, and the unreadable one sorts FIRST — the walk reads in sorted - // order, so `a-` is the one that used to abort the search before `b-` was - // ever opened. - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a-locked.txt", .data = "needle here\n" }); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "b-open.txt", .data = "needle here\n" }); - var locked_buf: [std.fs.max_path_bytes]u8 = undefined; - const locked = try std.fmt.bufPrintSentinel(&locked_buf, "{s}/a-locked.txt", .{dir}, 0); - if (libc.chmod(locked, 0) != 0) return; - // Running as root reads it anyway, and then this test is testing nothing: - // say so by not pretending to have run. - const probe = libc.open(locked, .{ .ACCMODE = .RDONLY }); - if (probe >= 0) { - _ = libc.close(probe); - _ = libc.chmod(locked, 0o644); - return error.SkipZigTest; - } - - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - const out = try gpa.alloc(u8, 64 * 1024); - defer gpa.free(out); - const n = try grep(arena.allocator(), gpa, dir, dir, "needle", out); - _ = libc.chmod(locked, 0o644); // so `tmp.cleanup` can remove it - - // The readable file's hit came back. Before this, the whole call returned - // `error.OpenFailed` and the +Grep buffer was empty. - try std.testing.expect(std.mem.indexOf(u8, out[0..n], "b-open.txt") != null); - try std.testing.expect(std.mem.indexOf(u8, out[0..n], "a-locked.txt") == null); -} - - -/// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed) -fn isDir(path: [*:0]const u8) bool { - const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); - if (fd < 0) return false; - _ = libc.close(fd); - return true; -} - -/// Read a whole file (gpa-owned) — the look side of opening a file pane. Web -/// reads from the generated source archive; native shells read the real fs. -const read_file_max_bytes = 256 * 1024 * 1024; -const read_stream_max_bytes = 4 * 1024 * 1024; - -/// Read a whole file with one size-bounded allocation. A file that grows after -/// fstat is read as the snapshot size; zero-size virtual files get a separate -/// bounded stream read. Files over either applicable cap are rejected. -pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { - if (!platform_has_fs) { - var normalized_buf: [4096]u8 = undefined; - const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return error.OpenFailed; - const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed; - if (source.contents.len > read_file_max_bytes) return error.FileTooLarge; - return gpa.dupe(u8, source.contents); - } - var pathbuf: [4096]u8 = undefined; - const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; - // NONBLOCK, and it is the difference between an error and a dead editor. - // A plain blocking `open` of a FIFO waits for a writer that may never come, - // and this call runs INSIDE the keystroke that asked for it — no frame, no - // message row, and in the tty shell no Ctrl-C either, because the terminal - // is in raw mode. `Look` on a named pipe (or on a device that blocks until - // carrier) froze the whole program with nothing on screen to say why. The - // flag is cleared again below for the file kinds that are worth reading; - // the ones that are not are refused by name. - const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .NONBLOCK = true }); - // WHY it would not open, not just that it would not. Every one of these is - // an ordinary thing to do by accident — `pardes /root`, a file left at mode - // 000, a name that was deleted between resolving and reading — and a caller - // that can only say "OpenFailed" has to show the human a word that means - // nothing to them. `errno` is libc's here, which is the only reason it can - // be read off a `-1`: see the raw-syscall note in `termCwd`. - if (fd < 0) return switch (libc.errno(fd)) { - .ACCES, .PERM => error.PermissionDenied, - .NOENT => error.FileNotFound, - .ISDIR => error.IsDirectory, - .NAMETOOLONG => error.PathTooLong, - else => error.OpenFailed, - }; - defer _ = libc.close(fd); - - // The flag STAYS SET, and the read loops below answer `EAGAIN` with - // `NotAFile`. A regular file ignores `O_NONBLOCK` entirely — the kernel - // never short-reads one for it — so this costs ordinary opens nothing and - // turns the one case that would have hung into an error with a name. - const end = libc.lseek(fd, 0, libc.SEEK.END); - // NOT SEEKABLE IS NOT A DOCUMENT. `lseek` answers `ESPIPE` for a pipe, a - // socket and a terminal, and those are exactly the things whose "contents" - // are a future rather than a file — with `O_NONBLOCK` above they no longer - // hang the editor, but an unwritten FIFO then reads as EOF and opened as a - // silent empty pane, which says even less than the hang did. The zero-size - // files that ARE worth streaming — procfs and its kin — seek fine and - // report 0, so they take the branch below untouched. - if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile; - const size: usize = if (end < 0) 0 else @intCast(end); - if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; - if (size == 0) { - // procfs and similar virtual files report zero size. Probe once so a - // genuinely empty file remains an exact zero-byte allocation, then use - // one conservative bounded allocation for a non-empty stream. - var first: [16 * 1024]u8 = undefined; - var first_len: usize = 0; - while (true) { - const n = libc.read(fd, &first, first.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - // Nothing to read AND nothing that will end: an empty pipe with - // no writer. This is the hang, reported instead of waited on. - if (libc.errno(n) == .AGAIN) return error.NotAFile; - return error.ReadFailed; - } - first_len = @intCast(n); - break; - } - if (first_len == 0) return gpa.alloc(u8, 0); - var stream = try gpa.alloc(u8, read_stream_max_bytes); - errdefer gpa.free(stream); - @memcpy(stream[0..first_len], first[0..first_len]); - var stream_len = first_len; - while (stream_len < stream.len) { - const n = libc.read(fd, stream[stream_len..].ptr, stream.len - stream_len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - // A stream that has paused is a stream that has ended, as far - // as one keystroke is concerned: keep what came. - if (libc.errno(n) == .AGAIN) break; - return error.ReadFailed; - } - if (n == 0) break; - stream_len += @intCast(n); - } - if (stream_len == stream.len) { - var extra: [1]u8 = undefined; - while (true) { - const n = libc.read(fd, &extra, 1); - if (n < 0 and libc.errno(n) == .INTR) continue; - if (n < 0 and libc.errno(n) == .AGAIN) break; - if (n < 0) return error.ReadFailed; - if (n > 0) return error.FileTooLarge; - break; - } - } - if (stream_len != stream.len) stream = try gpa.realloc(stream, stream_len); - return stream; - } - if (size > read_file_max_bytes) return error.FileTooLarge; - var buf = try gpa.alloc(u8, size); - errdefer gpa.free(buf); - var len: usize = 0; - while (len < buf.len) { - const n = libc.read(fd, buf[len..].ptr, buf.len - len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; - } - if (n == 0) break; - len += @intCast(n); - } - if (len != buf.len) buf = try gpa.realloc(buf, len); - return buf; -} - -// ---- shell cwd: what directory a pane's looks resolve against ---- - -// macOS has no /proc; libproc's proc_pidinfo(PROC_PIDVNODEPATHINFO) yields the -// cwd vnode path. Not in std.c — layout from xnu's sys/proc_info.h. -const vnode_info_path = extern struct { - vi: [152]u8 align(8), // struct vnode_info: vinfo_stat + type + pad + fsid - path: [1024]u8, // MAXPATHLEN -}; -const proc_vnodepathinfo = extern struct { - cdir: vnode_info_path, - rdir: vnode_info_path, -}; -const PROC_PIDVNODEPATHINFO: c_int = 9; -extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; - -/// Live cwd of a shell process (pane tags, look resolution). linux reads -/// /proc//cwd, darwin asks libproc; other POSIX systems have no cheap -/// answer — return null and panes keep their spawn-time cwd (callers already -/// tolerate failure: dead shells have no cwd either). -pub fn shellCwd(pid: libc.pid_t, buf: *[1024]u8) ?[]const u8 { - switch (builtin.os.tag) { - .linux => { - var pbuf: [64]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null; - const n = libc.readlink(path, buf, buf.len); - if (n <= 0) return null; - return buf[0..@intCast(n)]; - }, - .macos, .ios, .tvos, .watchos, .visionos => { - var info: proc_vnodepathinfo = undefined; - const n = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, @sizeOf(proc_vnodepathinfo)); - if (n < @as(c_int, @sizeOf(proc_vnodepathinfo))) return null; - const path = std.mem.sliceTo(&info.cdir.path, 0); - if (path.len == 0) return null; - @memcpy(buf[0..path.len], path); - return buf[0..path.len]; - }, - else => return null, - } -} - -// ---- tty occupancy: is a pane's terminal still the prompt pardes forked? ---- - -// Linux answers TIOCGPGRP asked of the pty MASTER with the SLAVE side's -// foreground process group — the number the kernel would deliver ^C to. Not in -// std.c, and the master is the only end pardes holds. -extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t; - -/// How far the descendant walk goes before it stops trusting itself. A shell -/// sitting at its prompt has no descendants at all and a foreground job is one -/// hop, so these are not a budget, they are a fuse: the walk is driven by -/// numbers read out of the kernel and must not be able to spin on a surprising -/// one (the same reason nested.outer() caps its hops). Hitting either bound -/// answers OCCUPIED — a tree we did not finish reading may hide the foreground -/// job, and typing a command line into vim is worse than declining to type it -/// into a shell that really was idle under 32 background jobs. -const occ_max_depth: u8 = 8; -const occ_max_visited: usize = 32; - -/// Scratch for one `ttyTaken` answer: the walk's helpers share it rather than -/// each declaring its own copy of a path buffer. Lives in the probe's own -/// frame — there is no polling loop to hoist it out of any more, because the -/// core asks this question only where it is about to type a command line. -const TtyProbe = struct { - /// the forked shell's own executable, read once per probe - self_exe: [std.fs.max_path_bytes]u8 = undefined, - /// ...and one descendant's, to compare against it - exe: [std.fs.max_path_bytes]u8 = undefined, - /// one small /proc text at a time: a children list, a stat line, a status - /// blob. Each is consumed (parsed to numbers) before the next read. - blob: [4096]u8 = undefined, - /// the DFS worklist, bounded by the same fuse as the visit count - pending: [occ_max_visited]Node = undefined, - - const Node = struct { pid: libc.pid_t, depth: u8 }; -}; - -/// Is something OTHER than the shell prompt pardes forked sitting on this -/// pane's tty — vim, less, an agent, a build? An Exec must never type a command -/// line into such a program (it would land as vim keystrokes), so a taken -/// terminal is treated exactly like no terminal at all: the core routes the -/// command to another shell. -/// -/// The predicate, and the false answer each clause exists to prevent: -/// -/// fg = tcgetpgrp(master) the tty's foreground pgrp, from the kernel -/// fg < 0 -> free no answer at all (not a tty, a host that -/// does not allow the ioctl): behave as before -/// self = exe(shell_pid) the binary of the terminal we spawned, -/// straight out of /proc, so no spawn path has -/// to be plumbed through three frontends' Pty -/// structs and kept in step with shell_bin -/// self == null -> free the shell is gone; the pane's EOF is about -/// to remove it anyway -/// walk descendants of shell_pid: -/// exe unreadable -> occupied, unless the child is a zombie (or has -/// already vanished), which is provably not on -/// the tty. Unreadable-but-alive is a setuid -/// program — `sudo` waiting for a password is -/// the case that must NOT be typed into. -/// exe != self -> occupied iff its pgrp is fg. The pgrp filter is -/// what keeps `sleep 30 &` from looking -/// occupied: a background job is a child of an -/// idle prompt, and its pgrp is not the tty's. -/// exe == self -> recurse. A nested shell prompt is still a usable -/// prompt, so `bash` inside `bash` stays -/// Exec-able; and the leaf is the answer, which -/// is what catches `bash -c 'sleep 30'` — there -/// the foreground pgrp LEADER's exe is our own -/// shell binary while the tty really belongs to -/// `sleep`. -/// no visited process in pgrp fg, and fg != shell_pid -/// -> occupied the tty belongs to a group we could not -/// attribute to anything we forked (a -/// foreground leader that died or re-parented); -/// never type into it. -/// otherwise -> free -pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool { - switch (builtin.os.tag) { - .linux => { - var probe: TtyProbe = undefined; - const fg = tcgetpgrp(master_fd); - if (fg < 0) return false; - const self_exe = procExe(shell_pid, &probe.self_exe) orelse return false; - - // The shell's own pgrp is normally the tty's when it is at its - // prompt (forkpty made it the session and group leader), so the - // idle answer is reached without reading its stat at all — the - // whole fast path is tcgetpgrp, one readlink, and an empty - // children file. - var saw_fg = fg == shell_pid; - var pending: usize = 0; - var visited: usize = 0; - switch (pushChildren(&probe, &pending, shell_pid, 1)) { - .pushed => {}, - // No children file: a kernel without CONFIG_PROC_CHILDREN - // cannot answer this question at all, so answer free and leave - // behaviour exactly as it was before this probe existed. - .unreadable => return false, - .full => return true, - } - - while (pending > 0) { - pending -= 1; - const node = probe.pending[pending]; - visited += 1; - if (visited > occ_max_visited) return true; - - // One stat read carries the group; note it before anything can - // return, because the final clause is about every process we - // looked at, not only the ones that decided the answer. - const pgrp = procPgrp(node.pid, &probe.blob); - if (pgrp) |g| { - if (g == fg) saw_fg = true; - } - - const exe = procExe(node.pid, &probe.exe) orelse { - if (offTty(node.pid, &probe.blob)) continue; - return true; - }; - if (!std.mem.eql(u8, exe, self_exe)) { - if (pgrp) |g| if (g == fg) return true; - continue; - } - if (node.depth >= occ_max_depth) return true; - switch (pushChildren(&probe, &pending, node.pid, node.depth + 1)) { - .pushed => {}, - // This one exited while we walked (or the kernel stopped - // answering for it); its own pgrp was already counted and - // there is nothing below it to learn. - .unreadable => {}, - .full => return true, - } - } - return !saw_fg; - }, - // A darwin implementation is tcgetpgrp (which xnu also allows on the - // master) plus a descendant walk built from proc_listchildpids, with - // proc_pidpath for the exe and proc_bsdinfo's pbi_pgid for the group — - // there is no /proc to read. Until then macOS behaves as it did before - // this probe existed: every terminal is a prompt. - else => return false, - } -} - -/// DELIVER A SIGNAL TO WHATEVER IS ON THIS PANE'S TTY — the host half of -/// `pty/ctl`'s `sig` verb, shared by every frontend that owns pane shells so -/// that the target is decided once instead of three times. -/// -/// THE TARGET IS THE FOREGROUND PROCESS GROUP, not the shell's pid, and the -/// difference is the whole usefulness of the verb. `tcgetpgrp` on the master -/// answers with the number the kernel would deliver a ^C to (see the comment -/// on the declaration above), which is the running build, the pager, the -/// agent — the thing a script means when it says `sig INT`. Aimed at the pid -/// instead, `sig INT` would reach an interactive shell, which ignores SIGINT -/// while it waits for a job: the verb would appear to work and do nothing on -/// the one case anybody wants it for. At an idle prompt the two are the same -/// number, because forkpty made the shell its own group leader. -/// -/// The pid is the FALLBACK, for an OS or a host whose master end will not -/// answer the ioctl. There `sig KILL` still ends the shell, which is the case -/// where being ignored is not an acceptable outcome. -pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void { - // Whatever `std.c.SIG` spells these as on this platform, unconverted: the - // one call below wants exactly that type (see the `kill` beside `harvest`). - const sig = switch (which) { - .int => libc.SIG.INT, - .term => libc.SIG.TERM, - .hup => libc.SIG.HUP, - .quit => libc.SIG.QUIT, - .kill => libc.SIG.KILL, - }; - const fg = tcgetpgrp(master_fd); - // A process GROUP is addressed as its negated leader; `fg` is already a - // group id, so this is `kill(-fg)` and not `kill(-leader_of(fg))`. - if (fg > 0) { - _ = libc.kill(-fg, sig); - return; - } - if (shell_pid > 0) _ = libc.kill(shell_pid, sig); -} - -/// Read a small /proc text in one go. These files are generated on read and -/// answer completely in a single call at these sizes; a short read would only -/// truncate a field, which every parser below treats as "no answer". -fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 { - const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return null; - defer _ = libc.close(fd); - const got = libc.read(fd, buf.ptr, buf.len); - if (got <= 0) return null; - return buf[0..@intCast(got)]; -} - -/// The binary behind a pid, as the kernel spells it. Fails for a zombie (no mm -/// to point at) and for a process we may not inspect — the two cases `ttyTaken` -/// has to tell apart. -fn procExe(pid: libc.pid_t, buf: *[std.fs.max_path_bytes]u8) ?[]const u8 { - var name: [64:0]u8 = undefined; - const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; - const n = libc.readlink(link, buf, buf.len); - if (n <= 0) return null; - return buf[0..@intCast(n)]; -} - -/// A pid's process group. -fn procPgrp(pid: libc.pid_t, buf: *[4096]u8) ?libc.pid_t { - var name: [64:0]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/stat", .{@as(u32, @intCast(pid))}, 0) catch return null; - return parsePgrp(readProc(path, buf) orelse return null); -} - -/// Field 5 of /proc//stat, found by scanning back from the LAST ')' -/// rather than counting fields from the start: field 2 is `comm` in -/// parentheses, and a comm may contain spaces AND parentheses, so a process -/// named `sh (a b)` shifts everything after it and a positional parse silently -/// reads some other number as the group. Same trap nested.parsePPid documents; -/// the kernel puts comm's closing paren last precisely so this scan works. -fn parsePgrp(stat: []const u8) ?libc.pid_t { - const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null; - var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n"); - _ = fields.next() orelse return null; // 3: state - _ = fields.next() orelse return null; // 4: ppid - const pgrp = fields.next() orelse return null; // 5: pgrp - return std.fmt.parseInt(libc.pid_t, pgrp, 10) catch null; -} - -/// Is this pid provably NOT holding the tty even though its exe is unreadable: -/// a zombie (dead, waiting to be reaped) or already gone. Everything else that -/// hides its exe — a setuid program — is alive and on the terminal. -fn offTty(pid: libc.pid_t, buf: *[4096]u8) bool { - var name: [64:0]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return false; - // No status at all: the pid died between the children read and here. A - // process that no longer exists cannot be typed into. - const status = readProc(path, buf) orelse return true; - return parseZombie(status); -} - -/// The `State:` field of a /proc//status blob, and only Z. Line-anchored, -/// so a comm that spells `State: Z` inside the `Name:` line cannot answer. -fn parseZombie(status: []const u8) bool { - var lines = std.mem.splitScalar(u8, status, '\n'); - while (lines.next()) |line| { - if (!std.mem.startsWith(u8, line, "State:")) continue; - const state = std.mem.trim(u8, line["State:".len..], " \t\r"); - return state.len > 0 and state[0] == 'Z'; - } - return false; -} - -const Pushed = enum { pushed, unreadable, full }; - -/// Put a pid's direct children on the worklist. The children file is the whole -/// reason this walk is cheap: an idle shell's is empty, so the fast path reads -/// one empty file instead of scanning /proc. -/// -/// Spelled out rather than routed through `readProc` precisely because of that -/// empty file: readProc treats a zero-byte answer as no answer, which is right -/// for a stat line and exactly wrong here — "this process has no children" is -/// the most informative reply the walk ever gets, and calling it unreadable -/// would make the whole probe give up on every idle shell. -fn pushChildren(probe: *TtyProbe, pending: *usize, pid: libc.pid_t, depth: u8) Pushed { - var name: [96:0]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/task/{d}/children", .{ - @as(u32, @intCast(pid)), @as(u32, @intCast(pid)), - }, 0) catch return .unreadable; - const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return .unreadable; - defer _ = libc.close(fd); - const got = libc.read(fd, &probe.blob, probe.blob.len); - if (got < 0) return .unreadable; - - var kids: [occ_max_visited]libc.pid_t = undefined; - const total = parseChildren(probe.blob[0..@intCast(got)], &kids); - if (total > kids.len or pending.* + total > probe.pending.len) return .full; - for (kids[0..total]) |kid| { - probe.pending[pending.*] = .{ .pid = kid, .depth = depth }; - pending.* += 1; - } - return .pushed; -} - -/// The pids in a /proc//task//children blob: space separated, with a -/// trailing space, and empty for the overwhelmingly common idle shell. Returns -/// how many valid pids the blob HAS, having written the first `out.len` of them -/// — a total past `out.len` is the caller's overflow signal. A token that is -/// not strictly digits is skipped rather than answered wrong: this drives who -/// gets walked, and parseInt alone would take `-1` and `+7`. -fn parseChildren(text: []const u8, out: []libc.pid_t) usize { - var total: usize = 0; - var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); - while (it.next()) |tok| { - if (std.mem.indexOfNone(u8, tok, "0123456789") != null) continue; - const kid = std.fmt.parseInt(libc.pid_t, tok, 10) catch continue; - if (total < out.len) out[total] = kid; - total += 1; - } - return total; -} - -test "the children blob parses to pids, and a garbage token never becomes one" { - var out: [8]libc.pid_t = undefined; - // the idle shell, which is the case the whole fast path is shaped around - try std.testing.expectEqual(@as(usize, 0), parseChildren("", &out)); - try std.testing.expectEqual(@as(usize, 0), parseChildren(" ", &out)); - // one child — the kernel writes a TRAILING space and no newline - try std.testing.expectEqual(@as(usize, 1), parseChildren("991 ", &out)); - try std.testing.expectEqual(@as(libc.pid_t, 991), out[0]); - // several, with and without the trailing separator - try std.testing.expectEqual(@as(usize, 3), parseChildren("7 8 9 ", &out)); - try std.testing.expectEqualSlices(libc.pid_t, &.{ 7, 8, 9 }, out[0..3]); - try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12", &out)); - try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12\n", &out)); - // garbage: this list decides whose /proc entries get read, and parseInt - // alone would take every one of these. The pids AROUND the junk still - // answer — dropping the tree because one token was odd would silently turn - // a busy terminal into a free one. - try std.testing.expectEqual(@as(usize, 2), parseChildren("5 -1 +7 0x3 abc 6 ", &out)); - try std.testing.expectEqualSlices(libc.pid_t, &.{ 5, 6 }, out[0..2]); - // overflow is REPORTED, not silently truncated: the total is what the blob - // HAS, so the caller can answer "occupied" instead of walking a tree it - // only partly read - var two: [2]libc.pid_t = undefined; - try std.testing.expectEqual(@as(usize, 4), parseChildren("1 2 3 4 ", &two)); - try std.testing.expectEqualSlices(libc.pid_t, &.{ 1, 2 }, two[0..2]); -} - -test "the process group comes off the last ')', not a comm-shifted stat field" { - // the comm here contains a space AND parentheses — the exact shape that - // breaks `field 5 of /proc//stat` (see nested.parsePPid). Counting - // from the left answers `b))` for the state and `S` for the group. - const shifted = "1234 (sh (a b)) S 991 992 993 34816 992 4194560 " ++ - "1729 0 0 0 1 0 0 0 20 0 1 0 8244630 9887744 1131"; - try std.testing.expectEqual(@as(libc.pid_t, 992), parsePgrp(shifted).?); - // ...and the ordinary shape still reads the same field - try std.testing.expectEqual(@as(libc.pid_t, 7), parsePgrp("42 (bash) S 1 7 7 34816 7 4194304").?); - // a group of its own, which is what a background job has - try std.testing.expectEqual(@as(libc.pid_t, 42), parsePgrp("42 (sleep) S 7 42 7 0 -1").?); - // a truncated read must not answer from a half line, and a blob that is - // not a stat line at all must not answer at all - try std.testing.expect(parsePgrp("") == null); - try std.testing.expect(parsePgrp("1234 (bash) S 991") == null); - try std.testing.expect(parsePgrp("1234 (bash) S 991 notanumber") == null); - try std.testing.expect(parsePgrp("no parens here at all") == null); -} - -test "the zombie state comes off its own status line" { - // a reaped-but-not-yet-collected child: no exe to read, and provably not - // holding the tty, so the walk must skip it instead of answering occupied - try std.testing.expect(parseZombie("Name:\tsh (a b)\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t1234\n")); - try std.testing.expect(parseZombie("State:\tZ (zombie)\n")); - // every other state is a live process, and an unreadable exe then means - // setuid (sudo asking for a password) — the one thing never to type into - try std.testing.expect(!parseZombie("Name:\tsh\nState:\tS (sleeping)\n")); - try std.testing.expect(!parseZombie("Name:\tvim\nState:\tR (running)\n")); - try std.testing.expect(!parseZombie("Name:\tvim\nState:\tT (stopped)\n")); - // a comm that spells the field cannot answer for it: the scan is anchored - // to the start of a line, and `Name:` is where a comm lives - try std.testing.expect(!parseZombie("Name:\tsh (State: Z)\nState:\tS (sleeping)\n")); - // a truncated read is not a zombie (and so stays conservative) - try std.testing.expect(!parseZombie("Name:\tsh\nSta")); - try std.testing.expect(!parseZombie("State:\t")); -} - -// ---- tests: the predicate against real processes on a real pty ---- -// -// The parsers above cannot see any of what follows: whether Linux answers -// TIOCGPGRP on the MASTER at all, whether bash really puts a background job in -// its own group, and whether `bash -c` leaves our own binary as the foreground -// leader are all facts about the system, and every one of them decides an -// answer. So these fork a real bash on a real pty — the way -// test/e2e_harness.zig forks the whole app — and drive it. -extern "c" fn forkpty( - amaster: *c_int, - name: ?[*:0]u8, - termp: ?*const anyopaque, - winp: ?*const std.posix.winsize, -) c_int; - -const test_shell = "/bin/bash"; -const test_prompt = "PZX> "; - -/// A real interactive bash on a pty of our own, plus the polling the cases need. -/// Nothing here sleeps for a fixed time waiting for the shell: every step polls -/// to a deadline, and every poll DRAINS the master — a shell whose output is -/// never read blocks on a full pty buffer and then nothing else happens either. -const TestShell = struct { - master: c_int, - pid: libc.pid_t, - /// a rolling window of what the shell has written, so a case can wait for - /// the prompt (or a job-control notice) instead of guessing a duration - tail: [8192]u8 = undefined, - tail_len: usize = 0, - - fn start() ?TestShell { - if (!haveFile(test_shell)) return null; - var master: c_int = undefined; - const ws = std.posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 }; - const pid = forkpty(&master, null, null, &ws); - if (pid < 0) return null; - if (pid == 0) { - // --norc: the developer's own bashrc must not decide what these - // tests see. -i: job control, which is what puts a background job - // in a group of its own and is half of what is under test. - const argv: [3:null]?[*:0]const u8 = .{ test_shell, "--norc", "-i" }; - _ = execv(test_shell, &argv); - _exit(127); - } - var sh: TestShell = .{ .master = master, .pid = pid }; - // A prompt of our own — EXPORTED, so a nested bash shows the same one — - // spelled with a '' seam, so the echo of the command that sets it - // cannot be mistaken for the prompt it produces. - sh.send("export PS1='PZ''X> '\n"); - if (!sh.waitText(test_prompt, 10_000)) { - sh.stop(); - return null; - } - sh.forget(); - return sh; - } - - fn send(sh: *TestShell, bytes: []const u8) void { - _ = libc.write(sh.master, bytes.ptr, bytes.len); - } - - fn forget(sh: *TestShell) void { - sh.tail_len = 0; - } - - /// Read everything the shell has produced so far, without blocking. - fn drain(sh: *TestShell) void { - while (true) { - var fds = [1]libc.pollfd{.{ .fd = sh.master, .events = libc.POLL.IN, .revents = 0 }}; - if (libc.poll(&fds, 1, 0) <= 0) return; - if (fds[0].revents & libc.POLL.IN == 0) return; - var chunk: [4096]u8 = undefined; - const n = libc.read(sh.master, &chunk, chunk.len); - if (n <= 0) return; - sh.append(chunk[0..@intCast(n)]); - } - } - - fn append(sh: *TestShell, bytes: []const u8) void { - if (bytes.len >= sh.tail.len) { - @memcpy(&sh.tail, bytes[bytes.len - sh.tail.len ..]); - sh.tail_len = sh.tail.len; - return; - } - const room = sh.tail.len - sh.tail_len; - if (bytes.len > room) { - const drop = bytes.len - room; - std.mem.copyForwards(u8, sh.tail[0 .. sh.tail_len - drop], sh.tail[drop..sh.tail_len]); - sh.tail_len -= drop; - } - @memcpy(sh.tail[sh.tail_len..][0..bytes.len], bytes); - sh.tail_len += bytes.len; - } - - fn waitText(sh: *TestShell, needle: []const u8, ms: i64) bool { - const deadline = nowMs() + ms; - while (true) { - sh.drain(); - if (std.mem.indexOf(u8, sh.tail[0..sh.tail_len], needle) != null) return true; - if (nowMs() >= deadline) return false; - sleepMs(5); - } - } - - fn taken(sh: *TestShell) bool { - sh.drain(); - return ttyTaken(sh.pid, sh.master); - } - - /// Poll until the verdict is `want` — the answer changes when the SHELL - /// gets around to forking or reaping, not when we sent the line. - fn waitTaken(sh: *TestShell, want: bool, ms: i64) bool { - const deadline = nowMs() + ms; - while (true) { - if (sh.taken() == want) return true; - if (nowMs() >= deadline) return false; - sleepMs(5); - } - } - - /// ...and the other direction: the verdict STAYS `want` for a window. What - /// a false positive looks like is a probe that flickers to occupied while - /// the shell sits at its prompt with a background job, and a single sample - /// can miss it. - fn holdsTaken(sh: *TestShell, want: bool, ms: i64) bool { - const deadline = nowMs() + ms; - while (nowMs() < deadline) { - if (sh.taken() != want) return false; - sleepMs(5); - } - return true; - } - - /// Kill the shell AND everything under it, then reap and close. The tree - /// has to be collected BEFORE the shell dies: a foreground job lives in its - /// own process group, so killing bash alone leaves `sleep 30` running, - /// re-parented to init — a stray that outlives the test binary. - fn stop(sh: *TestShell) void { - var probe: TtyProbe = undefined; - var pending: usize = 0; - var doomed: [occ_max_visited]libc.pid_t = undefined; - var n: usize = 0; - _ = pushChildren(&probe, &pending, sh.pid, 1); - while (pending > 0) { - pending -= 1; - const node = probe.pending[pending]; - if (n == doomed.len) break; - doomed[n] = node.pid; - n += 1; - if (node.depth < occ_max_depth) _ = pushChildren(&probe, &pending, node.pid, node.depth + 1); - } - _ = libc.kill(sh.pid, libc.SIG.KILL); - for (doomed[0..n]) |kid| { - _ = libc.kill(kid, libc.SIG.KILL); - // ...and its group, for a program that forked helpers of its own - _ = libc.kill(-kid, libc.SIG.KILL); - } - _ = libc.waitpid(sh.pid, null, 0); - _ = libc.close(sh.master); - } -}; - -fn haveFile(path: [*:0]const u8) bool { - const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return false; - _ = libc.close(fd); - return true; -} - -fn nowMs() i64 { - var ts: libc.timespec = undefined; - _ = libc.clock_gettime(.MONOTONIC, &ts); - return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); -} - -fn sleepMs(ms: i64) void { - const ts = libc.timespec{ - .sec = @intCast(@divFloor(ms, 1000)), - .nsec = @intCast(@mod(ms, 1000) * 1_000_000), - }; - _ = libc.nanosleep(&ts, null); -} - -test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands it back" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - - // The whole point of the default: a shell sitting at its prompt is usable, - // and stays usable across samples. - try std.testing.expect(sh.holdsTaken(false, 200)); - - // A foreground job IS the terminal now — this is the answer an Exec needs, - // and typing a command line here would be typing it at `sleep`. - sh.send("sleep 30\n"); - try std.testing.expect(sh.waitTaken(true, 10_000)); - - // ^C, and the tty is the prompt's again. Nothing is cached: the next poll - // simply finds no children, which is why recovery needs no event. - sh.forget(); - sh.send("\x03"); - try std.testing.expect(sh.waitTaken(false, 10_000)); - try std.testing.expect(sh.waitText(test_prompt, 10_000)); -} - -test "a background job is not the tty's owner" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - - // The false positive the pgrp filter exists for. Waiting for the job - // notice first matters: the verdict has to be taken while the child is - // genuinely alive, or this test would pass with no probe at all. - sh.send("sleep 30 &\n"); - try std.testing.expect(sh.waitText("[1]", 10_000)); - try std.testing.expect(sh.holdsTaken(false, 300)); - - // ...and it is still free once the job is gone, which also means the - // zombie between `kill` and bash's reap is not read as an occupant. - sh.send("kill %1\n"); - try std.testing.expect(sh.holdsTaken(false, 300)); -} - -test "a nested interactive shell is still a prompt" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - - // `bash` inside `bash`: the leaf matches the binary we spawned, so it is a - // prompt like any other and Exec must keep working. This is the case the - // recursion is FOR, and the reason "any child at all" would be wrong. - sh.forget(); - sh.send("bash --norc -i\n"); - try std.testing.expect(sh.waitText(test_prompt, 10_000)); - try std.testing.expect(sh.holdsTaken(false, 300)); - - // ...and one level deeper still - sh.forget(); - sh.send("bash --norc -i\n"); - try std.testing.expect(sh.waitText(test_prompt, 10_000)); - try std.testing.expect(sh.holdsTaken(false, 300)); - - // a job inside the INNER shell is still the tty's owner - sh.send("sleep 30\n"); - try std.testing.expect(sh.waitTaken(true, 10_000)); - sh.send("\x03"); - try std.testing.expect(sh.waitTaken(false, 10_000)); -} - -test "the walk reaches the leaf: bash -c 'sleep 30' takes the tty" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - - sh.send("bash --norc -c 'sleep 30'\n"); - try std.testing.expect(sh.waitTaken(true, 10_000)); - sh.send("\x03"); - try std.testing.expect(sh.waitTaken(false, 10_000)); - - // The same shape where bash provably CANNOT exec the command in place (two - // commands, so the wrapper has to stay around and fork): the foreground - // group's leader is then our own shell binary while the tty really belongs - // to `sleep`. A predicate that stopped at the leader would call this free. - sh.send("bash --norc -c 'sleep 30; :'\n"); - try std.testing.expect(sh.waitTaken(true, 10_000)); - - // ...and that is the shape asserted, not assumed: the shell's only child - // runs the same binary the shell does. - var probe: TtyProbe = undefined; - var pending: usize = 0; - try std.testing.expectEqual(Pushed.pushed, pushChildren(&probe, &pending, sh.pid, 1)); - try std.testing.expectEqual(@as(usize, 1), pending); - var wrapper_buf: [std.fs.max_path_bytes]u8 = undefined; - var shell_buf: [std.fs.max_path_bytes]u8 = undefined; - try std.testing.expectEqualStrings( - procExe(sh.pid, &shell_buf).?, - procExe(probe.pending[0].pid, &wrapper_buf).?, - ); - - sh.send("\x03"); - try std.testing.expect(sh.waitTaken(false, 10_000)); -} - -test "a full-screen program takes the tty until it quits" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - // The two shapes a human actually loses a terminal to: an editor that takes - // the alternate screen, and a pager that does not. Both are skipped rather - // than failed where they are not installed. - const cases = [_]struct { bin: [*:0]const u8, run: []const u8, quit: []const u8 }{ - // -u NONE -i NONE: no vimrc, no viminfo — this must not touch the - // developer's own files, and an rc that starts a plugin would change - // the process tree under test. - .{ .bin = "/usr/bin/vim", .run = "vim -u NONE -i NONE\n", .quit = "\x1b:q!\r" }, - // LESS= so a developer's own -F (quit if one screen) cannot make the - // pager exit before it is asked to - .{ .bin = "/usr/bin/less", .run = "env LESS= less /etc/hosts\n", .quit = "q" }, - }; - var ran: usize = 0; - for (cases) |c| { - if (!haveFile(c.bin)) continue; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - sh.send(c.run); - try std.testing.expect(sh.waitTaken(true, 10_000)); - sh.forget(); - sh.send(c.quit); - try std.testing.expect(sh.waitTaken(false, 10_000)); - try std.testing.expect(sh.waitText(test_prompt, 10_000)); - ran += 1; - } - if (ran == 0) return error.SkipZigTest; + const found = fs.resolve(p, word, cwd, realbuf) orelse return .none; + if (found.dir) return .{ .dir = found.path }; + if (comptime pdf_enabled) if (isPdfPath(found.path)) return .{ .file = .{ + .path = found.path, + .at = pl.at, + .kind = .pdf, + } }; + if (isImagePath(found.path)) return .{ .image = .{ .path = found.path } }; + return .{ .file = .{ .path = found.path, .at = pl.at } }; } diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig index 0b4f2ba1..ca19009a 100644 --- a/src/lsp/lsp.zig +++ b/src/lsp/lsp.zig @@ -1,216 +1,111 @@ -//! The language-intelligence seam. -//! -//! The core never speaks a protocol and never blocks. It emits an `lsp` Effect -//! naming a Kind, a file and a byte offset; a shell runs `query` on a worker -//! and posts the answer back as an `lsp_resp` Event. That is the whole async -//! execution model — the same shape the pty readers already use, because a -//! language query is just another thing that answers later. -//! -//! Location answers render as `+Search` rows. A location is -//! `path:LINE:COL text` — or `path:LINE:COL-ENDCOL text` where the protocol -//! answered with a real range, which a look then SELECTS — and that is what -//! look.zig already resolves and what n/N already steps, so a multi-result -//! answer IS helix's picker and a single result IS a jump, with no picker UI -//! written for it. Free text (hover, formatting) rides the same buffer. Rename -//! is the one mutating answer: it emits byte ranges through `edit`, and the core -//! applies them atomically only while the source revision is still current. -//! -//! `query` is the ONLY thing an implementation supplies. Swapping backends is -//! swapping this one function, which is also how the three competing -//! implementations are measured against each other: same core, same harness, -//! same rows, different `query`. const std = @import("std"); -/// What the caller wants to know. The helix command each one backs is named -/// alongside, because the keymap is helix's and these are its verbs — helix's -/// bare `X` spelled `SPC l X` here, because `d`, `k`, `s` and `h` were -/// already pardes's own most-pressed leader keys and the rest follow them into -/// the group rather than splitting the menu (see config.leader_path). pub const Kind = enum { - /// gd definition, - /// gD declaration, - /// gy type_definition, - /// gi implementation, - /// gr references, - /// SPC l k hover, - /// SPC l s document_symbols, - /// SPC l S (arg = the query) workspace_symbols, - /// SPC l d, and the list that ]d / [d step diagnostics, - /// SPC l D workspace_diagnostics, - /// SPC l r (arg = the new name) rename, - /// SPC l a code_action, - /// = format, - /// SPC l h select_refs, - /// Tab in insert mode, with a `.` immediately before the cursor. NOT an - /// autocomplete popup — the seam returns locations, so this answers "what - /// could go here, and where is each of those DEFINED": one row per - /// candidate, pointing at its declaration, in the same `+Search` buffer - /// `gr` fills. Nothing is inserted. completion, - - // The two-step hierarchy kinds, LSP 3.16/3.17: prepare at the cursor, - // then walk the item the server handed back. helix has none of these - // four (checked against helix-term/src/keymap/default.rs, which stops at - // the gotos), so they are pardes exceeding parity rather than matching - // it — possible here because the answers are LOCATIONS, and locations - // are the one thing this seam renders for free. - /// SPC l c — who calls the function under the cursor incoming_calls, - /// SPC l C — everything the function under the cursor calls outgoing_calls, - /// SPC l t — the types this one extends/implements supertypes, - /// SPC l T — the types that extend/implement this one subtypes, - - // The two introspection kinds. A backend that answers nothing is - // indistinguishable from a backend that is broken, so these exist to tell - // those apart — they are the only Kinds whose answer is ABOUT the backend - // rather than about the code. - /// SPC l i — configuration, capabilities and the recent-query log status, - /// SPC l w — why the query at the cursor answers what it does. Narrates - /// the REAL resolution path rather than re-deriving it, so it cannot drift - /// away from what `gd` actually did. explain, - - // What an ANSWER becomes — which buffer it opens, whether a single row - // jumps instead, whether n/N walk it — is not here: it is one row per Kind - // in output_pane.traits, beside the same questions asked of `/`, Find, - // Grep and Help. A Kind added above will not compile until it has one. }; -/// One question. `source` is a snapshot of the buffer taken by the shell -/// before the worker starts — the core keeps editing while this is in flight, -/// so a backend must never reach back into core memory. pub const Req = struct { kind: Kind, - /// absolute path of the file the offset is in path: []const u8, - /// the buffer's bytes, NUL-terminated (std.zig.Ast and zls both want a - /// sentinel, and every backend has to parse this same text) source: [:0]const u8, - /// cursor position, a byte offset into `source` offset: u32, - /// kind-specific argument: the new name for a rename, the query for - /// workspace symbols. Empty otherwise. arg: []const u8 = "", - /// where the project starts — the directory of the pane that asked. A - /// backend that indexes more than one file walks from here. root: []const u8 = "", }; -/// How a row SPELLS a path: relative to `base` if it lives UNDER it, its full -/// absolute self otherwise. -/// -/// `base` is `Req.root` — the directory of the file the query was asked about -/// — which is also the directory the results buffer is opened in, so a row -/// shortened here reads as the name that window would have typed and still -/// resolves when looked. `gr` over one file was otherwise the same -/// forty-character absolute prefix repeated down the whole pane, with the part -/// you came to read pushed off the right edge. -/// -/// UNDER, not "shorter": a path outside that tree is left absolute rather than -/// walked up to with `../`. An absolute path resolves from anywhere and says -/// where it is; `../../..` says neither, and the moment the row is read -/// somewhere other than beside its own buffer it is wrong. -/// -/// This is also `look.grep`'s `shown` rule — it calls this function, so the -/// two spellings the docs used to complain about are one. +const backends = if (@import("pardes_config").zls_backend) + .{ @import("lsp_zls.zig"), @import("lsp_client.zig") } +else + .{}; + +pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc"; + +pub const supports: std.EnumSet(Kind) = blk: { + var s: std.EnumSet(Kind) = .initEmpty(); + for (0..backends.len) |i| s.setUnion(backends[i].supports); + break :blk s; +}; + +pub fn speaks(path: []const u8) bool { + inline for (backends) |b| if (b.speaks(path)) return true; + return false; +} + +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) !void { + if (req.kind == .status) { + inline for (backends) |b| try b.query(gpa, arena, req, out); + return; + } + inline for (backends) |b| { + if (b.speaks(req.path) and b.supports.contains(req.kind)) + return b.query(gpa, arena, req, out); + } + if (req.kind == .explain and backends.len > 0) + try backends[0].query(gpa, arena, req, out); +} + +// Called on server reader threads; the sink must copy text before returning. +pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void { + if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb); +} + pub fn rel(base: []const u8, path: []const u8) []const u8 { if (base.len == 0) return path; - const home = std.mem.trimEnd(u8, base, "/"); - if (path.len > home.len and std.mem.startsWith(u8, path, home) and path[home.len] == '/') - return path[home.len + 1 ..]; + const prefix = std.mem.trimEnd(u8, base, "/"); + if (path.len > prefix.len and std.mem.startsWith(u8, path, prefix) and path[prefix.len] == '/') + return path[prefix.len + 1 ..]; return path; } -/// Emit one `path:LINE:COL text` row. Line and column are 1-based, the way -/// every other row in a `+Search` buffer is (and the way look.zig parses one). -/// `path` has already been through `rel`: the caller holds the base. -pub fn row( - out: *std.Io.Writer, - path: []const u8, - line: usize, - col: usize, - text: []const u8, -) void { - out.print("{s}:{d}:{d} {s}\n", .{ +pub fn row(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, text: []const u8) std.Io.Writer.Error!void { + try out.print("{s}:{d}:{d} {s}\n", .{ path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"), - }) catch {}; + }); } -/// The same row for a protocol RANGE: `path:LINE:COL-ENDCOL`, which a look -/// SELECTS rather than parking on its first cell — so `gd` lands on the whole -/// name and a references list steps symbol by symbol with each one highlighted -/// (config.range_sep spells the dash; `-` is written out here for the same -/// reason `:` is). -/// -/// `end_col` is the protocol's own EXCLUSIVE end character, which is already -/// the 1-based inclusive column pardes wants, so the conversion is the absence -/// of one. A span that is empty or crosses lines falls back to the point row: -/// the only multi-line ranges here are whole declarations, and a goto onto one -/// wants the cursor at its name, not its body painted. -pub fn spanRow( - out: *std.Io.Writer, - path: []const u8, - line: usize, - col: usize, - end_line: usize, - end_col: usize, - text: []const u8, -) void { +// Input positions are zero-based and end-exclusive; displayed spans are one-based and inclusive. +pub fn spanRow(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, end_line: usize, end_col: usize, text: []const u8) std.Io.Writer.Error!void { if (end_line != line or end_col <= col) return row(out, path, line, col, text); - out.print("{s}:{d}:{d}-{d} {s}\n", .{ + try out.print("{s}:{d}:{d}-{d} {s}\n", .{ path, line + 1, col + 1, end_col, std.mem.trim(u8, text, " \t\r\n"), - }) catch {}; + }); } -/// Emit one half-open byte range for a mutating response. Rename is the only -/// current user: every other answer remains human-readable rows. Byte offsets -/// avoid converting the displayed 1-based locations back into source offsets -/// in the core, and the prefix makes malformed or mixed responses fail closed. -pub fn edit(out: *std.Io.Writer, start: usize, end: usize) void { - out.print("@edit {d} {d}\n", .{ start, end }) catch {}; +pub fn edit(out: *std.Io.Writer, start: usize, end: usize) std.Io.Writer.Error!void { + try out.print("@edit {d} {d}\n", .{ start, end }); } -/// The general mutating record: a half-open byte range REPLACED BY `text`, -/// which `@edit` cannot say (its replacement is the request's own arg, the -/// same for every range). Rename through a protocol server and `=` both need -/// per-range text, so this carries it — percent-encoded onto the one line a -/// record is allowed to be, because a TextEdit's newText is full of newlines -/// and the record stream is parsed line by line. The core decodes with -/// `parseLspEdits` and applies all records in one undo transaction; malformed, -/// overlapping or out-of-bounds records change nothing, exactly as for @edit. -pub fn put(out: *std.Io.Writer, start: usize, end: usize, text: []const u8) void { - out.print("@put {d} {d} ", .{ start, end }) catch {}; +pub fn put(out: *std.Io.Writer, start: usize, end: usize, text: []const u8) std.Io.Writer.Error!void { + try out.print("@put {d} {d} ", .{ start, end }); for (text) |c| { - // '%' so the encoding round-trips; control bytes so the record stays - // one line; ' ' so the text is one token. Everything else is itself. - if (c == '%' or c == ' ' or c < 0x21) - out.print("%{X:0>2}", .{c}) catch {} + if (c == '%' or c < 0x21) + try out.print("%{X:0>2}", .{c}) else - out.writeByte(c) catch {}; + try out.writeByte(c); } - out.writeByte('\n') catch {}; + try out.writeByte('\n'); } -/// Byte offset -> (line, column), both 0-based. Every backend needs it to turn -/// an AST token into a row, so it lives here rather than three times over. pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } { const upto = source[0..@min(offset, source.len)]; const line = std.mem.count(u8, upto, "\n"); @@ -218,82 +113,29 @@ pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usi return .{ .line = line, .col = upto.len - bol }; } -/// Answer `req`, writing rows to `out`. Runs on a worker thread with no -/// access to the core: everything it may read is in `req`. -/// -/// `out` is a plain `std.Io.Writer` — the shell owns the buffer behind it (an -/// `Io.Writer.Allocating`), so a backend never allocates the result, never -/// frees it, and cannot get the allocator wrong. Write failures are the -/// writer's problem; a backend may ignore them. -/// -/// `arena` is freed wholesale when the query returns; `gpa` is for a backend's -/// own longer-lived scratch. Errors are not reported — a backend that cannot -/// answer writes nothing, and the core treats "no rows" as "no result", which -/// is also what a language server still starting up looks like. -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) void { - // `status` is about the BACKENDS, plural: every one reports, in seam - // order, so `SPC l i` shows the analyser and the protocol client side by - // side and a machine with neither prints nothing at all. - if (req.kind == .status) { - inline for (backends) |b| b.query(gpa, arena, req, out); - return; +test "LSP encoders report every insufficient output capacity" { + const cases = [_]struct { kind: enum { row, span, edit, put }, expected: []const u8 }{ + .{ .kind = .row, .expected = "file:1:3 hi\n" }, + .{ .kind = .span, .expected = "file:1:3-5 hi\n" }, + .{ .kind = .edit, .expected = "@edit 1 3\n" }, + .{ .kind = .put, .expected = "@put 1 3 hé%20%25%0A\n" }, + }; + for (cases) |case| { + var buf: [128]u8 = undefined; + for (0..case.expected.len + 1) |capacity| { + var out: std.Io.Writer = .fixed(buf[0..capacity]); + const result = switch (case.kind) { + .row => row(&out, "file", 0, 2, " hi \n"), + .span => spanRow(&out, "file", 0, 2, 0, 5, " hi \n"), + .edit => edit(&out, 1, 3), + .put => put(&out, 1, 3, "hé %\n"), + }; + if (capacity < case.expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(case.expected, out.buffered()); + } + } } - inline for (backends) |b| { - if (b.speaks(req.path) and b.supports.contains(req.kind)) - return b.query(gpa, arena, req, out); - } - // Nobody spoke the file. `explain` exists precisely to narrate a refusal, - // so it still goes to the first backend, whose trace says WHY it stopped - // ("not a .zig file", "no server for .md") instead of silently no-rowing. - if (req.kind == .explain and backends.len > 0) - backends[0].query(gpa, arena, req, out); } - -/// The compiled-in backends, asked in order; the first one that speaks the -/// file's language AND claims the kind answers. Two on a native build — ZLS -/// linked as a module for Zig (no process, cold is warm), and a real LSP -/// client (lsp_client.zig) speaking JSON-RPC to child servers for everything -/// else: rust-analyzer, clangd, gopls, whatever the spec table names. A -/// FREESTANDING core (web, esp32) compiles in neither: `supports` is then -/// empty, `lspRequest` returns before it emits, and the effect never exists. -const backends = if (@import("pardes_config").zls_backend) - .{ @import("lsp_zls.zig"), @import("lsp_client.zig") } -else - .{}; - -/// What this backend can actually answer, for the evaluation harness and for -/// the core (a Kind that is not supported never leaves the keymap). An -/// implementation narrows this to what it really does — claiming a feature it -/// does not have shows up immediately in the harness's matrix. -pub const supports: std.EnumSet(Kind) = blk: { - var s: std.EnumSet(Kind) = .initEmpty(); - for (0..backends.len) |i| s.setUnion(backends[i].supports); - break :blk s; -}; - -/// Does the backend read this file's LANGUAGE at all? `supports` answers what -/// a backend can do; this answers what it can do it TO, and it exists for the -/// one key that must not be eaten when the answer is no: insert-mode Tab -/// 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 { - inline for (backends) |b| if (b.speaks(path)) return true; - return false; -} - -/// Where a shell registers the one function unsolicited SERVER STATE goes -/// through: "rust-analyzer indexing 3/120", "gopls exited". Called from the -/// client's reader threads, so a sink must be thread-safe and must copy -/// `text` before returning; both native shells post it to their event queue -/// and let the loop hand it to `Pardes.setMessage` — the same transient row a -/// save narrates into, because a server starting up is exactly that kind of -/// news. A build with no client accepts and ignores the registration. -pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void { - if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb); -} - -/// Name shown by the harness and in `SPC ?`. This is the SEAM's, not the -/// backend's: a backend does not declare it, so renaming a backend means -/// editing this line. -pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc"; diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index b1be590b..8c6a0da9 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -189,7 +189,7 @@ const wedged_strikes = 3; // consecutive timeouts before a restart const max_rows = 2000; const max_doc_bytes = 8 << 20; -const Err = error{ Dead, Timeout, Protocol, OutOfMemory, NoServer }; +const Err = error{ Dead, Timeout, Protocol, OutOfMemory, NoServer, WriteFailed }; /// Long-lived state outlives every query arena and cannot borrow the caller's /// gpa (a different one shows up in the harness than in the shell), so @@ -399,11 +399,9 @@ const Trace = struct { // ------------------------------------------------------------------- query -/// The seam entry point. Never fails, never panics; no rows is the only error -/// rendering there is (`SPC l i` shows what was swallowed). -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) void { +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { _ = gpa; - if (req.kind == .status) return status(req, out) catch {}; + if (req.kind == .status) return status(req, out); var tr: Trace = .{ .on = req.kind == .explain }; const si = specFor(req.path) orelse { @@ -414,33 +412,36 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out }; tr.note("file {s} -> {s} (languageId {s})", .{ std.fs.path.basename(req.path), specs[si].name, specs[si].lang }); - const scratch_buf = arena.alloc(u8, max_rows * 512) catch return; + const scratch_buf = try arena.alloc(u8, max_rows * 512); var scratch: std.Io.Writer = .fixed(scratch_buf); const t0 = nowUs(); var err_name: []const u8 = ""; + var failure: ?anyerror = null; answer(arena, si, req, &scratch, &tr) catch |e| { + failure = e; err_name = @errorName(e); - tr.note("ERROR: {s} — the editor shows this as 'no result'", .{err_name}); + tr.note("ERROR: {s}", .{err_name}); }; const us = nowUs() -| t0; const rows = std.mem.count(u8, scratch.buffered(), "\n"); record(si, req, us, rows, err_name); if (req.kind == .explain) return traceOut(&tr, req, out, rows, us); - out.writeAll(scratch.buffered()) catch {}; + if (failure) |err| return err; + try out.writeAll(scratch.buffered()); } -fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) void { +fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) std.Io.Writer.Error!void { if (req.kind != .explain) return; - out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ + try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), - }) catch {}; - out.writeAll(tr.buf[0..tr.len]) catch {}; + }); + try out.writeAll(tr.buf[0..tr.len]); if (hideTime()) - out.print("\n{d} row(s)\n", .{rows}) catch {} + try out.print("\n{d} row(s)\n", .{rows}) else - out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {}; + try out.print("\n{d} row(s) in {d}us\n", .{ rows, us }); } fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) Err!void { @@ -470,7 +471,7 @@ fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer } } }, - error.OutOfMemory, error.NoServer => {}, + error.OutOfMemory, error.NoServer, error.WriteFailed => {}, } return e; }; @@ -519,7 +520,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io .select_refs => { const b = try atPos(arena, uri.items, pos); const result = (try call(c, arena, "textDocument/documentHighlight", b.items, deadline)) orelse return; - for (items(result)) |h| emitRange(&cx, c.caps.enc, uri.items, get(h, "range"), ""); + for (items(result)) |h| try emitRange(&cx, c.caps.enc, uri.items, get(h, "range"), ""); }, .hover => { const b = try atPos(arena, uri.items, pos); @@ -530,8 +531,8 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io break :blk ""; }; if (text.len == 0) return; - out.writeAll(std.mem.trim(u8, text, " \t\r\n")) catch {}; - out.writeByte('\n') catch {}; + try out.writeAll(std.mem.trim(u8, text, " \t\r\n")); + try out.writeByte('\n'); }, .document_symbols => { var b: std.ArrayList(u8) = .empty; @@ -539,7 +540,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io try jstr(&b, arena, uri.items); try app(&b, arena, "}"); const result = (try call(c, arena, "textDocument/documentSymbol", b.items, deadline)) orelse return; - walkSymbols(&cx, c.caps.enc, uri.items, result, 0); + try walkSymbols(&cx, c.caps.enc, uri.items, result, 0); }, .workspace_symbols => { var b: std.ArrayList(u8) = .empty; @@ -548,7 +549,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io const result = (try call(c, arena, "workspace/symbol", b.items, deadline)) orelse return; for (items(result)) |sym| { const loc = get(sym, "location") orelse continue; - emitRange(&cx, c.caps.enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), str(get(sym, "name")) orelse ""); + try emitRange(&cx, c.caps.enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), str(get(sym, "name")) orelse ""); } }, .diagnostics => try diagnostics(c, arena, &cx, uri.items, deadline), @@ -576,7 +577,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io const result = (try call(c, arena, "textDocument/codeAction", b.items, deadline)) orelse return; for (items(result)) |ca| { const title = str(get(ca, "title")) orelse continue; - lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), pos.line, 0, flat(arena, title)); + try lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), pos.line, 0, flat(arena, title)); cx.rows += 1; } }, @@ -601,7 +602,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io // no location of its own (unlike the ZLS backend, which points // at declarations), so the honest place is where it would be // inserted. n/N still step the list; Enter goes nowhere new. - lsp.row(cx.out, here, pos.line, byteCol(req.source, pos.line, pos.ch, c.caps.enc), text.items); + try lsp.row(cx.out, here, pos.line, byteCol(req.source, pos.line, pos.ch, c.caps.enc), text.items); n += 1; } }, @@ -618,30 +619,30 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io /// Goto/references result shapes: bare Location, Location[], LocationLink[]. fn locations(cx: *Cx, enc: Enc, result: std.json.Value) Err!void { if (result == .object) { - emitRange(cx, enc, str(get(result, "uri")) orelse return, get(result, "range"), ""); + try emitRange(cx, enc, str(get(result, "uri")) orelse return, get(result, "range"), ""); return; } for (items(result)) |loc| { if (get(loc, "targetUri")) |tu| { const r = get(loc, "targetSelectionRange") orelse get(loc, "targetRange"); - emitRange(cx, enc, str(tu) orelse continue, r, ""); + try emitRange(cx, enc, str(tu) orelse continue, r, ""); } else { - emitRange(cx, enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), ""); + try emitRange(cx, enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), ""); } } } /// DocumentSymbol[] nests (`children`), SymbolInformation[] is flat. -fn walkSymbols(cx: *Cx, enc: Enc, uri: []const u8, node: std.json.Value, depth: u8) void { +fn walkSymbols(cx: *Cx, enc: Enc, uri: []const u8, node: std.json.Value, depth: u8) Err!void { if (depth > 8) return; for (items(node)) |sym| { const name = str(get(sym, "name")) orelse continue; if (get(get(sym, "location"), "range")) |r| { - emitRange(cx, enc, str(get(get(sym, "location"), "uri")) orelse uri, r, name); + try emitRange(cx, enc, str(get(get(sym, "location"), "uri")) orelse uri, r, name); } else { - emitRange(cx, enc, uri, get(sym, "selectionRange") orelse get(sym, "range"), name); + try emitRange(cx, enc, uri, get(sym, "selectionRange") orelse get(sym, "range"), name); } - if (get(sym, "children")) |kids| walkSymbols(cx, enc, uri, kids, depth + 1); + if (get(sym, "children")) |kids| try walkSymbols(cx, enc, uri, kids, depth + 1); } } @@ -655,7 +656,7 @@ fn diagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, dea try jstr(&b, arena, uri); try app(&b, arena, "}"); const result = (try call(c, arena, "textDocument/diagnostic", b.items, deadline)) orelse return; - for (items(get(result, "items"))) |dg| emitDiag(cx, c.caps.enc, uri, dg, arena); + for (items(get(result, "items"))) |dg| try emitDiag(cx, c.caps.enc, uri, dg, arena); return; } var stale = true; @@ -664,7 +665,7 @@ fn diagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, dea break; }; if (stale) waitFresh(c, uri, nowMs() + diag_ms); - renderStore(c, arena, cx, uri); + try renderStore(c, arena, cx, uri); } fn workspaceDiagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, deadline: i64) Err!void { @@ -672,15 +673,15 @@ fn workspaceDiagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, deadline: i const result = (try call(c, arena, "workspace/diagnostic", "\"previousResultIds\":[]", deadline)) orelse return; for (items(get(result, "items"))) |per| { const uri = str(get(per, "uri")) orelse continue; - for (items(get(per, "items"))) |dg| emitDiag(cx, c.caps.enc, uri, dg, arena); + for (items(get(per, "items"))) |dg| try emitDiag(cx, c.caps.enc, uri, dg, arena); } return; } - renderStore(c, arena, cx, null); + try renderStore(c, arena, cx, null); } /// One diagnostic row: `severity: message`, at the diagnostic's own range. -fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.mem.Allocator) void { +fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.mem.Allocator) Err!void { const sev = num(get(dg, "severity")) orelse 1; const label: []const u8 = switch (sev) { 1 => "error", @@ -688,15 +689,15 @@ fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.m 3 => "info", else => "hint", }; - const msg = std.fmt.allocPrint(arena, "{s}: {s}", .{ label, flat(arena, str(get(dg, "message")) orelse "") }) catch return; - emitRange(cx, enc, uri, get(dg, "range"), msg); + const msg = try std.fmt.allocPrint(arena, "{s}: {s}", .{ label, flat(arena, str(get(dg, "message")) orelse "") }); + try emitRange(cx, enc, uri, get(dg, "range"), msg); } -fn renderStore(c: *Conn, arena: std.mem.Allocator, cx: *Cx, only_uri: ?[]const u8) void { +fn renderStore(c: *Conn, arena: std.mem.Allocator, cx: *Cx, only_uri: ?[]const u8) Err!void { for (c.diags.items) |d| { if (only_uri) |u| if (!std.mem.eql(u8, d.uri, u)) continue; const v = std.json.parseFromSliceLeaky(std.json.Value, arena, d.body, .{}) catch continue; - for (items(get(get(v, "params"), "diagnostics"))) |dg| emitDiag(cx, c.caps.enc, d.uri, dg, arena); + for (items(get(get(v, "params"), "diagnostics"))) |dg| try emitDiag(cx, c.caps.enc, d.uri, dg, arena); } } @@ -740,7 +741,7 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, const span = byteSpan(src, get(ed, "range"), enc) orelse return; const text = str(get(ed, "newText")) orelse return; edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; - } else emitRange(cx, enc, u, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); + } else try emitRange(cx, enc, u, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); } } } else if (get(result, "changes")) |ch| if (ch == .object) { @@ -753,7 +754,7 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, const span = byteSpan(src, get(ed, "range"), enc) orelse return; const text = str(get(ed, "newText")) orelse return; edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; - } else emitRange(cx, enc, e.key_ptr.*, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); + } else try emitRange(cx, enc, e.key_ptr.*, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); } } }; @@ -762,13 +763,13 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, // the preview needs the self-file rows too — the point is the full map for (edits.items) |ed| { const lc = lsp.lineCol(src, ed.start); - lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), lc.line, lc.col, flat(cx.arena, ed.text)); + try lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), lc.line, lc.col, flat(cx.arena, ed.text)); cx.rows += 1; } return; } sortEdits(edits.items); - for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text); + for (edits.items) |ed| try lsp.put(cx.out, ed.start, ed.end, ed.text); } /// TextEdit[] from formatting is by definition about the current document: @@ -783,7 +784,7 @@ fn formatEdits(cx: *Cx, enc: Enc, result: std.json.Value, src: []const u8) Err!v edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; } sortEdits(edits.items); - for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text); + for (edits.items) |ed| try lsp.put(cx.out, ed.start, ed.end, ed.text); } /// One would-be buffer mutation, on its way to an `@put` record. @@ -842,14 +843,14 @@ fn hierarchy(c: *Conn, si: usize, arena: std.mem.Allocator, cx: *Cx, uri: []cons const name = str(get(from, "name")) orelse ""; const ranges = items(get(entry, "fromRanges")); if (ranges.len == 0) { - emitRange(cx, c.caps.enc, fu, get(from, "selectionRange"), name); - } else for (ranges) |r| emitRange(cx, c.caps.enc, fu, r, name); + try emitRange(cx, c.caps.enc, fu, get(from, "selectionRange"), name); + } else for (ranges) |r| try emitRange(cx, c.caps.enc, fu, r, name); }, .outgoing => { const to = get(entry, "to") orelse continue; - emitRange(cx, c.caps.enc, str(get(to, "uri")) orelse continue, get(to, "selectionRange") orelse get(to, "range"), hierText(cx.arena, to)); + try emitRange(cx, c.caps.enc, str(get(to, "uri")) orelse continue, get(to, "selectionRange") orelse get(to, "range"), hierText(cx.arena, to)); }, - .supers, .subs => emitRange(cx, c.caps.enc, str(get(entry, "uri")) orelse continue, get(entry, "selectionRange") orelse get(entry, "range"), hierText(cx.arena, entry)), + .supers, .subs => try emitRange(cx, c.caps.enc, str(get(entry, "uri")) orelse continue, get(entry, "selectionRange") orelse get(entry, "range"), hierText(cx.arena, entry)), }; } } @@ -924,7 +925,7 @@ const Cx = struct { /// decoded, `rel`'d against the asking window), utf-16 columns become byte /// columns, and a single-line range becomes the `path:LINE:COL-ENDCOL` form a /// look SELECTS. `note` overrides the source line as the row's text. -fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: []const u8) void { +fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: []const u8) Err!void { if (cx.rows >= max_rows) return; const path = pathOf(cx.arena, uri) orelse return; if (path.len == 0 or path[0] != '/') return; // rows promise absolute-or-rel-from-base @@ -953,9 +954,9 @@ fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: [ // byte column; converting the exclusive utf-16 end unit yields the // exclusive byte column, which is the same number. const end_col = colBytes(lntext, r.ec, enc); - lsp.spanRow(cx.out, shown, r.sl, col, r.el, end_col, rowtext); + try lsp.spanRow(cx.out, shown, r.sl, col, r.el, end_col, rowtext); } else { - lsp.row(cx.out, shown, r.sl, col, rowtext); + try lsp.row(cx.out, shown, r.sl, col, rowtext); } cx.rows += 1; } @@ -1050,36 +1051,12 @@ fn flat(arena: std.mem.Allocator, s: []const u8) []const u8 { return std.mem.trim(u8, buf.items, " "); } -/// Close-on-exec by fcntl, the darwin route. Same three lines as fuse.zig's -/// and nested.zig's, and here for the same reason they have their own: this -/// file imports neither. fn setCloexec(fd: c_int) void { const FD_CLOEXEC: c_int = 1; _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); } -/// The client's transport: an AF_UNIX stream pair with both ends close-on-exec -/// and, on darwin, the parent end opted out of SIGPIPE. False if the host -/// refused, which is a dead server and not a dead editor. -/// -/// A named function rather than nine lines inside `ensure` because the one -/// thing it encodes is a PLATFORM LIE, and a test has to be able to call -/// exactly what the spawn calls. SOCK_CLOEXEC is a LINUX flag; zig spells -/// `SOCK.CLOEXEC` for darwin too — as 0x10000000, with "does not exist on -/// darwin but is used in std.net" in the comment beside it — and darwin's -/// socketpair(2) validates `type` strictly, so asking for it there returns -/// EPROTONOSUPPORT. Every server spawn on macOS failed on that line, before -/// the fork: no binary probe, no handshake, no message row, just `NoServer` in -/// 100µs from a client that had never once run on the platform it was written -/// on. The end-to-end suite that would have caught it (test/snapshots/ -/// lsp-client.snap) only ever runs against the linux target, where the flag is -/// real. fuse.zig and nested.zig already took the plain-socket-plus-fcntl -/// route; this was the one caller that did not. -/// -/// THE WINDOW THIS LEAVES, the same one host_io.zig states for the pty master: -/// fcntl after socketpair is not atomic, so another thread that forks and -/// execs in between inherits both ends. Linux closes it with the flag; darwin -/// has no socketpair that takes one. +// Darwin needs fcntl after socketpair; another concurrent fork can inherit the pair in that window. fn transportPair(sv: *[2]libc.fd_t) bool { const sock_type = if (comptime builtin.os.tag.isDarwin()) libc.SOCK.STREAM @@ -2065,6 +2042,35 @@ fn coord(v: ?std.json.Value) ?u32 { // ----------------------------------------------------------------- tests +test "LSP client rename and format propagate incomplete edit encoding" { + const gpa = std.testing.allocator; + const uri = "file:///file.c"; + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, + \\{"changes":{"file:///file.c":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}},"newText":"A%\n"},{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"B"}]}} + , .{}); + defer parsed.deinit(); + const expected = "@put 0 3 A%25%0A\n@put 4 7 B\n"; + for ([_]bool{ true, false }) |rename| { + var buffer: [128]u8 = undefined; + for (0..expected.len + 1) |capacity| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer = .fixed(buffer[0..capacity]); + var cx: Cx = .{ .arena = arena.allocator(), .base = "/", .cur_path = "/file.c", .cur_src = "abc xyz", .out = &out }; + const result = if (rename) + renameEdits(&cx, .utf8, uri, parsed.value, cx.cur_src) + else + formatEdits(&cx, .utf8, get(get(parsed.value, "changes"), uri).?, cx.cur_src); + if (capacity < expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(expected, out.buffered()); + } + } + } +} + test "frameNext distinguishes incomplete, valid and poison frames" { try std.testing.expectEqual(FrameStep.incomplete, frameNext("Content-Length: 5\r\n")); try std.testing.expectEqual(FrameStep.incomplete, frameNext("Content-Length: 5\r\n\r\nhel")); diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig index fb9804e6..47e6a165 100644 --- a/src/lsp/lsp_zls.zig +++ b/src/lsp/lsp_zls.zig @@ -189,47 +189,41 @@ const Trace = struct { } }; -/// The seam's contract: never fail, never panic, no rows is a legal answer. -/// Every error path in here — OOM, a cancelled io, a file that vanished — -/// collapses to "appended nothing", which the core already treats as "no -/// result". There is deliberately no error rendering. -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) void { +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { // status reads the log; recording it would push a real query out of a // 24-entry ring every time you looked at it. - if (req.kind == .status) return status(arena, req, out) catch {}; + if (req.kind == .status) return status(arena, req, out); - // Rows land in bounded scratch storage first, for two reasons a plain - // Writer cannot serve: the log wants an exact row count, and `explain` - // throws the rows away and prints the narration in their place. A query - // that exceeds the row budget's byte allowance keeps its ordered prefix. - const scratch_buf = gpa.alloc(u8, max_output_bytes) catch return; + const scratch_buf = try gpa.alloc(u8, max_output_bytes); defer gpa.free(scratch_buf); var scratch: std.Io.Writer = .fixed(scratch_buf); var tr: Trace = .{ .on = req.kind == .explain }; const t0 = nowUs(); var err_name: []const u8 = ""; + var failure: ?anyerror = null; run(gpa, arena, req, &scratch, &tr) catch |e| { + failure = e; err_name = @errorName(e); - tr.note("ERROR: {s} — the query threw; the editor shows this as 'no result'", .{err_name}); + tr.note("ERROR: {s}", .{err_name}); }; const us = nowUs() -| t0; const rows = std.mem.count(u8, scratch.buffered(), "\n"); record(req, us, rows, err_name); if (req.kind != .explain) { - out.writeAll(scratch.buffered()) catch {}; + if (failure) |err| return err; + try out.writeAll(scratch.buffered()); return; } - // the question was never "where is it", it was "what did you do" - out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ + try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), - }) catch {}; - out.writeAll(tr.written()) catch {}; + }); + try out.writeAll(tr.written()); if (hideTime()) - out.print("\n{d} row(s)\n", .{rows}) catch {} + try out.print("\n{d} row(s)\n", .{rows}) else - out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {}; + try out.print("\n{d} row(s) in {d}us\n", .{ rows, us }); } fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) !void { @@ -518,7 +512,7 @@ fn rowForToken(arena: std.mem.Allocator, base: []const u8, th: Analyser.TokenWit if (th.token >= tree.tokens.len) return; const r = offsets.tokenToRange(tree, th.token, enc); const path = lsp.rel(base, th.handle.uri.toFsPath(arena) catch return); - lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(tree.source, r.start.line)); + try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(tree.source, r.start.line)); } // ---------------------------------------------------------------- goto @@ -627,7 +621,7 @@ fn goto( // knows the graph and baked it in; consult that. if (moduleRoot(str)) |path| { tr.note("`{s}` is a build.zig dependency; resolved from the compiled-in module map", .{str}); - lsp.row(out, lsp.rel(base, path), 0, 0, str); + try lsp.row(out, lsp.rel(base, path), 0, 0, str); return; } tr.note("STOP: `{s}` does not resolve to a file. Relative paths, `std` and this", .{str}); @@ -635,8 +629,8 @@ fn goto( tr.note(" OWN internal module names need the build graph we do not run.", .{}); return; }, - .one => |u| lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch return), 0, 0, str), - .many => |us| for (us) |u| lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch continue), 0, 0, str), + .one => |u| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch return), 0, 0, str), + .many => |us| for (us) |u| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch continue), 0, 0, str), } return; }, @@ -673,7 +667,7 @@ fn goto( const t2 = &nd.handle.tree; const rr = offsets.nodeToRange(t2, nd.node, enc); const path = lsp.rel(base, nd.handle.uri.toFsPath(arena) catch continue); - lsp.spanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line)); + try lsp.spanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line)); }, } } @@ -973,7 +967,7 @@ fn completion( r.start.character -= @intCast(pad); if (r.end.line == r.start.line) r.end.character -= @intCast(pad); } - lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, text); + try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, text); n += 1; } } @@ -999,7 +993,7 @@ fn hover( var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |ln| { if (std.mem.startsWith(u8, ln, "```")) continue; - out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}) catch return; + try out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}); } } @@ -1043,7 +1037,7 @@ fn emitSymbols( try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d }) else name; - lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, text); + try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, text); if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out); } } @@ -1091,7 +1085,7 @@ fn filterSymbols( try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); if (containsIgnoreCase(s.name, needle)) { n.* += 1; - lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, name); + try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, name); } if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out); } @@ -1155,19 +1149,23 @@ fn references( const path = if (new_name == null) lsp.rel(base, handle.uri.toFsPath(arena) catch return) else ""; var n: usize = 0; for (0..tree.tokens.len) |i| { - if (n >= max_rows) return; + if (new_name == null and n >= max_rows) return; const tok: Ast.TokenIndex = @intCast(i); if (tree.tokenTag(tok) != .identifier) continue; if (!std.mem.eql(u8, offsets.identifierTokenToNameSlice(tree, tok), want)) continue; const at = tree.tokenStart(tok); - const d = (declAt(arena, analyser, handle, at) catch continue) orelse continue; + const d = (declAt(arena, analyser, handle, at) catch |err| { + if (new_name != null) return err; + continue; + }) orelse continue; if (!d.eql(target)) continue; + if (n >= max_rows) return error.TooManyEdits; n += 1; if (new_name != null) { - lsp.edit(out, at, at + want.len); + try lsp.edit(out, at, at + want.len); } else { const r = offsets.tokenToRange(tree, tok, enc); - lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lines.?.line(r.start.line)); + try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lines.?.line(r.start.line)); } } } @@ -1216,7 +1214,7 @@ fn diagnostics( out: *std.Io.Writer, ) !void { const n = try treeDiagnostics(gpa, arena, path, tree, out); - if (n == 0) lsp.row(out, path, 0, 0, "no diagnostics"); + if (n == 0) try lsp.row(out, path, 0, 0, "no diagnostics"); } /// `zig ast-check`, in this process. ZLS spawns the compiler for this when it @@ -1258,7 +1256,7 @@ fn treeDiagnostics( const at = tree.tokenStart(e.token); const lc = lsp.lineCol(tree.source, at); n += 1; - lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ + try lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ w.buffered(), std.mem.trim(u8, lines.line(lc.line), " \t"), })); } @@ -1274,7 +1272,7 @@ fn treeDiagnostics( if (em.src_loc == .none) continue; const sl = bundle.getSourceLocation(em.src_loc); n += 1; - lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ + try lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ bundle.nullTerminatedString(em.msg), })); } @@ -1298,10 +1296,10 @@ fn workspaceDiagnostics( const src = readFileZ(arena, io, path) catch continue; var tree: Ast = Ast.parse(arena, src, .zig) catch continue; defer tree.deinit(arena); - total += treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out) catch continue; + total += try treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out); if (total >= max_rows) return; } - if (total == 0) lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.len})); + if (total == 0) try lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.len})); } // ----------------------------------------------------------- code actions @@ -1323,7 +1321,7 @@ fn codeActions( ) !void { const tree = &handle.tree; if (tree.errors.len != 0) { - out.print("no code actions: file does not parse\n", .{}) catch {}; + try out.print("no code actions: file does not parse\n", .{}); return; } var bundle = try astCheck(gpa, "", tree); @@ -1345,9 +1343,9 @@ fn codeActions( builder.generateCodeActionsInRange(at) catch {}; for (builder.actions.items[0..@min(builder.actions.items.len, max_rows)]) |a| { - out.print("{s}\n", .{a.title}) catch return; + try out.print("{s}\n", .{a.title}); } - if (builder.actions.items.len == 0) out.print("no code actions\n", .{}) catch {}; + if (builder.actions.items.len == 0) try out.print("no code actions\n", .{}); } // ---------------------------------------------------------------- format @@ -1362,19 +1360,19 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi var tree: Ast = try .parse(arena, req.source, .zig); defer tree.deinit(arena); if (tree.errors.len != 0) { - lsp.row(out, path, 0, 0, "cannot format: file does not parse"); + try lsp.row(out, path, 0, 0, "cannot format: file does not parse"); return; } var count_buf: [4096]u8 = undefined; var counting: std.Io.Writer.Discarding = .init(&count_buf); - tree.render(arena, &counting.writer, .{}) catch return; + try tree.render(arena, &counting.writer, .{}); const size = std.math.cast(usize, counting.fullCount()) orelse return; const render_buf = try arena.alloc(u8, size); var w: std.Io.Writer = .fixed(render_buf); - tree.render(arena, &w, .{}) catch return; + try tree.render(arena, &w, .{}); const formatted = w.buffered(); if (std.mem.eql(u8, formatted, req.source)) { - lsp.row(out, path, 0, 0, "already formatted"); + try lsp.row(out, path, 0, 0, "already formatted"); return; } // one record, spanning only what changed: the common prefix and suffix @@ -1388,7 +1386,7 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi src_hi -= 1; fmt_hi -= 1; } - lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]); + try lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]); } // ------------------------------------------------------------------ files @@ -1426,3 +1424,53 @@ fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8) ![]co fn readFileZ(arena: std.mem.Allocator, io: std.Io, path: []const u8) ![:0]u8 { return std.Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(4 * 1024 * 1024), .of(u8), 0); } + +test "LSP ZLS format propagates output failure and preserves successful encoding" { + const gpa = std.testing.allocator; + const req: lsp.Req = .{ .kind = .format, .path = "/file.zig", .source = "const value=1;\n", .offset = 6 }; + const expected = "@put 11 12 %20=%20\n"; + var buffer: [128]u8 = undefined; + for ([_]usize{ 0, expected.len - 1, expected.len }) |capacity| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer = .fixed(buffer[0..capacity]); + const result = query(gpa, arena.allocator(), req, &out); + if (capacity < expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(expected, out.buffered()); + } + } +} + +test "LSP ZLS rename refuses a partial edit set beyond its row budget" { + const gpa = std.testing.allocator; + for ([_]usize{ max_rows - 1, max_rows }) |references_count| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var source: std.Io.Writer.Allocating = .init(gpa); + defer source.deinit(); + try source.writer.writeAll("const value: u32 = 1;\nfn use() void {\n"); + for (0..references_count) |_| try source.writer.writeAll(" _ = value;\n"); + try source.writer.writeAll("}\n"); + const text = try gpa.dupeZ(u8, source.written()); + defer gpa.free(text); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + const result = query(gpa, arena.allocator(), .{ + .kind = .rename, + .path = "/rename.zig", + .source = text, + .offset = 6, + .arg = "renamed", + }, &out.writer); + if (references_count < max_rows) { + try result; + try std.testing.expectEqual(max_rows, std.mem.count(u8, out.written(), "@edit ")); + } else { + try std.testing.expectError(error.TooManyEdits, result); + try std.testing.expectEqual(@as(usize, 0), out.written().len); + } + } +} diff --git a/src/lsp_host.zig b/src/lsp_host.zig deleted file mode 100644 index 803beb31..00000000 --- a/src/lsp_host.zig +++ /dev/null @@ -1,206 +0,0 @@ -//! Turning the core's `lsp` effect into work on a thread, and its rows back -//! into something a loop can deliver. Native-shell side, like host_io.zig and -//! shell_bin.zig, and here for the reason those are: all three native shells -//! need it and none of them needs a different one. -//! -//! It was two copies before it was this. tty.zig had it inline and gui.zig had -//! it again with `tty.zig's LspJob, and copied for the same reason` written -//! over the top — identical down to the comment about a pane with no file. The -//! AppKit shell had NEITHER, so its vtable left `pull_lsp` null, the core -//! answered its own requests with no rows, and every language query did nothing -//! at all in the shell most people run. None of that looked like a missing -//! feature from the outside: `gd` just moved no cursor. A third copy is what -//! this module exists instead of. -//! -//! What is genuinely per-host stays per-host, and it is small: which allocator, -//! how a finished job reaches the loop (a mutex queue, a vaxis event, an inbox -//! plus a wakeup), and what bounds the in-flight set (a refcount to join at -//! teardown, or one future to cancel). What is NOT per-host is everything -//! below: the core goes on editing the moment the effect is drained, so every -//! byte the backend may read has to be COPIED first, and getting that ladder -//! subtly different in three places is how one shell reads freed text one -//! keystroke later. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const host_api = @import("host.zig"); - -/// One language query, owned by the worker that runs it. -pub const Job = struct { - id: u32, - kind: pardes.lsp.Kind, - offset: u32, - path: []u8, - source: [:0]u8, - arg: []u8, - root: []u8, - - pub fn free(job: *Job, gpa: std.mem.Allocator) void { - gpa.free(job.path); - gpa.free(job.source); - gpa.free(job.arg); - gpa.free(job.root); - gpa.destroy(job); - } -}; - -/// Copy the query out of the core. Null when the pane is gone or an allocation -/// failed, and nothing leaks on either path — the ladder frees exactly what it -/// had managed to take. -/// -/// A pane with no file still asks `status`: that query is about the BACKEND, -/// not the buffer. Empty path and source then, and the root comes off the -/// pane's cwd so a bare terminal still reports which servers it would reach. -pub fn snapshot(gpa: std.mem.Allocator, core: *const pardes.Pardes, req: host_api.LspRequest) ?*Job { - const pane = core.panes[req.pane] orelse return null; - const file = pane.file; - const job = gpa.create(Job) catch return null; - job.* = .{ - .id = req.id, - .kind = req.kind, - .offset = req.offset, - .path = gpa.dupe(u8, if (file) |f| f.path else "") catch { - gpa.destroy(job); - return null; - }, - .source = gpa.dupeZ(u8, if (file) |f| f.content else "") catch { - gpa.free(job.path); - gpa.destroy(job); - return null; - }, - .arg = gpa.dupe(u8, req.arg) catch { - gpa.free(job.path); - gpa.free(job.source); - gpa.destroy(job); - return null; - }, - .root = gpa.dupe(u8, if (file) |f| - (std.fs.path.dirname(f.path) orelse "/") - else - pane.cwdSlice()) catch { - gpa.free(job.path); - gpa.free(job.source); - gpa.free(job.arg); - gpa.destroy(job); - return null; - }, - }; - return job; -} - -/// What a host does with finished rows. It TAKES OWNERSHIP of `rows`, which -/// were allocated with the same allocator the job was. -pub const Deliver = *const fn (ctx: ?*anyopaque, id: u32, rows: []u8) void; - -/// Run `job` to completion and hand its rows to `deliver`. Consumes the job -/// either way. -/// -/// This is the whole async execution model, and it is the one every shell -/// already uses for its pty reader: do the slow thing off the loop, hand the -/// result over as an event, let the core stay a state machine that never -/// blocks. The shell owns the result buffer; the backend only writes into it. -pub fn work(gpa: std.mem.Allocator, job: *Job, ctx: ?*anyopaque, deliver: Deliver) void { - defer job.free(gpa); - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - pardes.lsp.query(gpa, arena.allocator(), .{ - .kind = job.kind, - .path = job.path, - .source = job.source, - .offset = job.offset, - .arg = job.arg, - .root = job.root, - }, &out.writer); - // Duped out of the writer: `deliver` outlives this frame and the writer - // does not. A failed dupe drops the answer, which the core survives — the - // request times out into no rows, exactly as an empty answer would. - const rows = gpa.dupe(u8, out.written()) catch return; - deliver(ctx, job.id, rows); -} - -test "a snapshot owns every byte the backend will read" { - const gpa = std.testing.allocator; - const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); - defer core.deinit(); - - var needle: [6]u8 = "needle".*; - const job = snapshot(gpa, core, .{ - .id = 7, - .kind = .status, - .pane = @intCast(core.active), - .offset = 0, - .arg = &needle, - }) orelse return error.SnapshotFailed; - defer job.free(gpa); - - try std.testing.expectEqual(@as(u32, 7), job.id); - try std.testing.expectEqual(pardes.lsp.Kind.status, job.kind); - // `arg` is the caller's buffer on the way in and the job's own bytes on the - // way out. THIS is the property the whole ladder exists for: the core reuses - // that buffer for the next builtin's argument the moment the effect drains. - try std.testing.expectEqualStrings("needle", job.arg); - try std.testing.expect(job.arg.ptr != &needle); - // A terminal pane has no file and the query still has to be answerable: - // empty path, a NUL-terminated empty source, and the pane's own cwd as the - // root so a bare terminal still reports which servers it would reach. The - // cwd may legitimately be empty in a core that has never spawned a shell; - // what matters is that the job OWNS it rather than borrowing it. - try std.testing.expectEqualStrings("", job.path); - try std.testing.expectEqual(@as(usize, 0), job.source.len); - try std.testing.expectEqual(@as(u8, 0), job.source[0]); - const pane = core.panes[core.active].?; - try std.testing.expectEqualStrings(pane.cwdSlice(), job.root); - if (job.root.len > 0) try std.testing.expect(job.root.ptr != pane.cwdSlice().ptr); -} - -test "a pane that is gone yields no job rather than a null deref" { - const gpa = std.testing.allocator; - const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); - defer core.deinit(); - - // The effect is drained after the core has moved on, so the pane it names - // may already have been deleted. Every shell open-coded this check. - const empty = for (core.panes, 0..) |slot, id| { - if (slot == null) break @as(u8, @intCast(id)); - } else return error.NoEmptyPane; - try std.testing.expect(snapshot(gpa, core, .{ - .id = 1, - .kind = .definition, - .pane = empty, - .offset = 0, - .arg = "", - }) == null); -} - -test "work consumes the job and hands its rows to the sink" { - const gpa = std.testing.allocator; - const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); - defer core.deinit(); - - const Sink = struct { - var seen_id: u32 = 0; - var seen_rows: ?[]u8 = null; - fn take(_: ?*anyopaque, id: u32, rows: []u8) void { - seen_id = id; - seen_rows = rows; - } - }; - Sink.seen_id = 0; - Sink.seen_rows = null; - - const job = snapshot(gpa, core, .{ - .id = 42, - .kind = .status, - .pane = @intCast(core.active), - .offset = 0, - .arg = "", - }) orelse return error.SnapshotFailed; - work(gpa, job, null, Sink.take); - - // `status` is the one kind that answers with no file and no cursor, which - // is what makes it assertable here without a language server on the box. - try std.testing.expectEqual(@as(u32, 42), Sink.seen_id); - const rows = Sink.seen_rows orelse return error.SinkNeverCalled; - defer gpa.free(rows); -} diff --git a/src/macos.zig b/src/macos.zig index ddab78e9..36c70f86 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1,23 +1,5 @@ -//! libpardes — the static library the native macOS app links against. -//! -//! The split, which is the whole design: Zig keeps the core, the ptys, every -//! effect and the worker threads; Swift owns NSApplication, the window, input -//! translation and drawing. src/macos/pardes.h is the contract between them and -//! docs/macos.md argues for the shape. -//! -//! This is deliberately src/web.zig's boundary with the wasm removed. Both -//! hosts are the same animal — someone else owns the clock, feeds events in -//! through flat functions and reads one packed cell buffer out — and the -//! browser already proved the shape works. The one real divergence is that the -//! browser has no processes, so it forwards every effect to JavaScript, whereas -//! forkpty is right here and this file performs them. -//! -//! Everything below is main-thread only. The single exception is the `wakeup` -//! callback, which a pty reader task calls; the host's job is to hop to the -//! main thread and call pardes_tick. -//! -//! The Zig half is ordinary POSIX and builds/tests on Linux — see the dev-loop -//! section of docs/macos.md. Only the Swift app needs a Mac. +const filesystem = @import("fs.zig"); +const ninep_io = @import("9p_io.zig"); const std = @import("std"); const builtin = @import("builtin"); @@ -25,29 +7,16 @@ const posix = std.posix; const libc = std.c; const pardes = @import("pardes.zig"); const look = @import("look.zig"); -const shell_bin = @import("shell_bin.zig"); -const message = @import("message.zig"); -const nested = @import("nested.zig"); -const panel_animation = @import("panel_animation.zig"); +const message = pardes.Pardes.Message; +const layout = @import("layout.zig"); const file_watch = @import("file_watch.zig"); -/// The geometry types the pixel-attachment ABI carries. Behind the same -/// comptime gate the placements themselves are: a build without MuPDF emits no -/// attachments, so nothing here is analysed. const image = if (pardes.pdf_enabled) @import("image.zig") else struct {}; -const user_config = @import("user_config.zig"); const host_io = @import("host_io.zig"); const fonts = @import("fonts.zig"); // the shared fallback preference order -const lsp_host = @import("lsp_host.zig"); // the shared snapshot + worker body -const host_api = @import("host.zig"); // LspRequest and the vtable's own types const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout const selection_pipe = @import("selection_pipe.zig"); // Job, runJob and Tasks const crash = @import("crash.zig"); -/// This file is the ROOT of the macOS build (build.zig: the AppKit shell is a -/// library whose host owns main()), so `std.builtin.panic` resolves here and -/// not in src/main.zig — a handler written only there would never run in the -/// app, which is the shell with the least useful stderr of the four. No -/// terminal to restore either, which is the rest of what main.zig's does. pub const panic = std.debug.FullPanic(struct { fn call(msg: []const u8, ret_addr: ?usize) noreturn { crash.record(msg); @@ -57,9 +26,6 @@ pub const panic = std.debug.FullPanic(struct { extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -/// Implemented by FileWatcher.swift in the app and e2e host. Zig-only unit -/// tests have no AppKit runloop and compile this call away; the shipped static -/// library leaves the symbol for its Swift executable to satisfy directly. extern "c" fn pardes_host_watch_file( pane: u8, generation: u32, @@ -72,14 +38,8 @@ fn hostWatchFile(pane: u8, generation: u32, path: ?[*]const u8, path_len: usize) pardes_host_watch_file(pane, generation, path, path_len); } -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). Same -// constant the tty and gui shells spell for the same reason. const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); -// A library linked into an AppKit process has no terminal to garble, but it -// does share the app's stderr with Console.app. Same filter as src/main.zig: -// ghostty-vt narrates every unimplemented escape a child writes, and nobody -// wants that in a crash report. PARDES_LOG=1 gets the real logger back. pub const std_options: std.Options = .{ .logFn = logFn }; fn logFn( @@ -94,15 +54,6 @@ fn logFn( const log = std.log.scoped(.macos); -// ---------------------------------------------------------------- boundary - -/// Sync with: pardes_cell_s. The identical encoding is spelled a second time -/// for the browser as WebCell in src/web.zig. -/// -/// ponytail: two copies of a fifteen-line pure encoder, not a shared module. -/// The web ABI is snapshot-tested through a headless Chrome that does not run -/// here, so extracting it would refactor a backend I cannot exercise to save -/// thirty lines. Merge them the day a third host wants the same bytes. pub const Cell = extern struct { text: [8]u8, fg: u32, @@ -112,40 +63,21 @@ pub const Cell = extern struct { flags: u8, }; -/// Sync with: pardes_scene_s. Persistent full-window effects share one host -/// postprocess, so one plain snapshot carries both its switches and clock. pub const Scene = extern struct { flags: u32 = 0, time_seconds: f32 = 0, frame: u32 = 0, }; -/// Sync with pardes_panel_{box,track}_s. The core's backend-neutral Track is -/// already an extern POD record, so the native boundary can publish it without -/// translating the easing vocabulary into a second representation. -pub const PanelBox = panel_animation.Box; -pub const PanelTrack = panel_animation.Track; - -/// Sync with: pardes_image_s. One rasterized attachment — a PDF page, or an -/// image pane's pixels — and where on the grid it goes. -/// -/// Geometry travels in PHYSICAL PIXELS, because that is the space the core -/// already computed it in (pardes_resize hands it the physical cell). `cell_x` -/// and `cell_y` are the pane BODY's origin in cells and the only thing the -/// host has to multiply out; `dst` is relative to that origin, and `src` is -/// the crop of the raster to take. The core has already clipped both to the -/// viewport, which is what lets a host draw a continuous-scroll page without -/// inventing an overflow clip of its own. +pub const PanelBox = layout.Box; +pub const PanelTrack = layout.Track; + pub const Image = extern struct { - /// pane lifetime, page and raster generation: together the cache key. A - /// host keeps its decoded texture while all three hold still, and `fit`, - /// panning and scrolling deliberately do not move them. serial: u32, page: u32, revision: u32, cell_x: u16, cell_y: u16, - /// the body this attachment may not paint outside of, in cells cell_w: u16, cell_h: u16, dst_x: u32, @@ -156,18 +88,12 @@ pub const Image = extern struct { src_y: u32, src_w: u32, src_h: u32, - /// subpixel vertical displacement a proportional wheel kept offset_y: f32, iw: u32, ih: u32, - /// iw * ih * 4 bytes, RGBA8. Borrowed until the next pardes_frame. rgba: [*]const u8, }; -/// Sync with: pardes_runtime_s. Three callbacks, because everything else the -/// core asks for it already does itself — it owns the ptys, and look.openLink -/// hands URLs to /usr/bin/open. All optional at the ABI level: a host that -/// passes null simply does without, rather than trapping inside the library. pub const Runtime = extern struct { userdata: ?*anyopaque = null, wakeup: ?*const fn (?*anyopaque) callconv(.c) void = null, @@ -182,30 +108,14 @@ const cell_flag_tagline: u8 = 2; const scene_flag_crt: u32 = 1 << 0; const scene_flag_ripple: u32 = 1 << 1; const scene_flag_glitch: u32 = 1 << 2; -/// The nominal display cadence the SHADER's `frame` field is expressed in. It -/// is a unit of that field and nothing else now: the animation clock below is -/// driven by measured elapsed time, not by counting callbacks. const scene_frame_hz: u32 = 60; -/// The scene clock wraps here so `time_seconds` never grows large enough for an -/// f32 to lose sub-millisecond resolution. 4096 seconds, the same span the old -/// 4096-frames-per-hz counter covered. const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s; -/// The most elapsed time one tick may cash in. A window that was occluded, a -/// laptop that slept or a debugger breakpoint all produce an enormous dt, and -/// spending it would fast-forward an animation instead of resuming it. -const max_tick_catch_up_ns: u64 = 4 * pardes.animation.frame_ns; -/// FileWatcher.swift keys sources by an opaque u8. Pane ids occupy 0..15; -/// the next value is the one process-global ThemeFile source. +const max_tick_catch_up_ns: u64 = 4 * pardes.layout.Animation.frame_ns; const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES); const watch_slot_count = pardes.MAX_PANES + 1; -// ---------------------------------------------------------------- state - -/// One pty, and the task draining it. `gen` is the per-slot spawn generation: -/// the core reuses pane ids and has no close effect, so a respawned slot must -/// ignore the previous shell's late bytes rather than feed them to the new one. const Pty = struct { file: std.Io.File, pid: posix.pid_t, @@ -213,18 +123,11 @@ const Pty = struct { reader: std.Io.Future(anyerror!void), }; -/// Main-thread ownership for the host's per-pane vnode sources. A path is -/// copied rather than borrowed from Pane: a queued callback may outlive the -/// effect which replaced that pane slot, and exact path equality is the final -/// guard before any bytes reach the core. const WatchedFile = struct { path: []u8, serial: u32, generation_on_disk: file_watch.Generation, generation: u32, - /// One self-scheduled reconciliation after a transient read/reopen race. - /// A real host edge replenishes it; a malformed stable file therefore - /// tries twice and then sleeps rather than becoming an idle busy loop. retries_left: u8 = 1, }; @@ -247,9 +150,6 @@ const FileWatches = struct { serial: u32, generation_on_disk: file_watch.Generation, ) !u32 { - // Allocate first. If memory is tight, the caller can explicitly stop - // the old source; silently retaining a watch for a reused pane would be - // worse than having no watch at all. const owned = try gpa.dupe(u8, path); if (watches.entries[pane]) |old| gpa.free(old.path); const generation = watches.nextGeneration(pane); @@ -270,10 +170,6 @@ const FileWatches = struct { return watches.nextGeneration(pane); } - /// Coalesce any number of vnode events into one main-thread re-read. - /// The Swift side already debounces a burst; this bit is the second, cheap - /// edge which prevents two queued callbacks from applying one snapshot - /// twice. A stale generation can never dirty a reused pane slot. fn notify(watches: *FileWatches, pane: u8, generation: u32) bool { const watched = if (watches.entries[pane]) |*entry| entry else return false; if (watched.generation != generation) return false; @@ -350,8 +246,6 @@ test "mac file watch generations own paths, coalesce, and reject stale callbacks try std.testing.expect(watches.retry(3, second)); try std.testing.expect(!watches.retry(3, second)); try std.testing.expect(watches.takeDirty(3)); - // A stable malformed file cannot self-wake forever, but a later real vnode - // edge replenishes exactly one retry for the new external transaction. try std.testing.expect(watches.notify(3, second)); try std.testing.expectEqual(@as(u8, 1), watches.entries[3].?.retries_left); try std.testing.expect(watches.takeDirty(3)); @@ -373,10 +267,87 @@ test "mac file watch generations own paths, coalesce, and reject stale callbacks const file_watcher_swift = @embedFile("macos/Sources/FileWatcher.swift"); +test "mac queued results never cancel a newer LSP request" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var st: State = .{ + .gpa = gpa, + .threaded = undefined, + .io = std.testing.io, + .core = core, + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + }; + defer st.config_arena.deinit(); + defer st.inbox.close(gpa, std.testing.io); + core.lspRequest(0, .status, ""); + const old_id = core.lsp_wait.?.id; + core.lspRequest(0, .status, ""); + const current_id = core.lsp_wait.?.id; + st.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } }; + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = old_id, .rows = try gpa.dupe(u8, "stale result\n") } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.lsp_task != null); + try std.testing.expectEqual(current_id, st.lsp_task.?.id); + try std.testing.expectEqual(current_id, core.lsp_wait.?.id); + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = current_id, .rows = try gpa.dupe(u8, "") } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.lsp_task == null); + try std.testing.expect(core.lsp_wait == null); +} + +test "mac worker setup failures finish matching LSP and pipe requests" { + const gpa = std.testing.allocator; + var failing_vtable = std.testing.io.vtable.*; + failing_vtable.concurrent = std.Io.failingConcurrent; + const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; + for (0..2) |failure| { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + var failing = std.testing.FailingAllocator.init(gpa, .{ + .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), + }); + var st: State = .{ + .gpa = failing.allocator(), + .threaded = undefined, + .io = failing_io, + .core = core, + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + }; + defer st.config_arena.deinit(); + defer st.inbox.close(gpa, std.testing.io); + core.lspRequest(core.active, .status, ""); + const req: host_io.Lsp.Request = .{ + .id = core.lsp_wait.?.id, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }; + lspRequest(&st, req); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + core.update(.{ .key = .{ .cp = '|' } }); + core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = core.pipe_wait.?.id; + pipeRequest(&st, pipe_id); + try std.testing.expect(core.pipe_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expect(st.lsp_task == null); + try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); + } +} + test "mac host watcher covers file and directory vnode events, debounce, and generation callback" { - // Linux cannot compile AppKit/Dispatch Swift. Keep the critical architecture - // check reachable there: atomic saves need the parent, in-place writes need - // a rearmed file source, and all mutation returns through the generation ABI. try std.testing.expect(std.mem.indexOf( u8, file_watcher_swift, @@ -399,33 +370,18 @@ test "mac host watcher covers file and directory vnode events, debounce, and gen ) != null); } -/// What a reader task hands the main thread. `gen` travels with the message so -/// a shell that was replaced while its read was in flight cannot have its -/// stragglers parsed into the pty that took its slot. const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, eof: struct { pane: u8, gen: u32 }, - /// One `Look ` line from a pardes launched inside this one. Arrives - /// on the listener thread; runs, like everything else, on the main one. - command: []u8, - /// A language query finished on a worker; `rows` are gpa-owned. NOT lossy: - /// the core is holding a request id open for exactly this, and dropping it - /// leaves `lsp_wait` armed and every later query dead. - lsp_done: struct { id: u32, rows: []u8 }, - /// Unsolicited server state — "rust-analyzer indexing 45%" — for the - /// transient message row. Periodic news, so it IS lossy: a dropped line is - /// repriced by the next one. + lsp_done: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, - /// A `|` filter finished on a worker. NOT lossy for the same reason - /// `lsp_done` is not: the core is holding a request id open for it. pipe: selection_pipe.Response, fn free(m: Msg, gpa: std.mem.Allocator) void { switch (m) { .output => |o| gpa.free(o.bytes), .eof => {}, - .command => |c| gpa.free(c), - .lsp_done => |d| gpa.free(d.rows), + .lsp_done => |d| if (d.rows) |rows| gpa.free(rows), .lsp_status => |t| gpa.free(t), .pipe => |r| { var response = r; @@ -435,8 +391,8 @@ const Msg = union(enum) { } }; - const inbox_capacity = 512; +const inbox_output_limit = inbox_capacity - pardes.MAX_PANES - selection_pipe.Tasks.capacity - 2; const MessageBatch = struct { items: [inbox_capacity]Msg = undefined, @@ -448,202 +404,272 @@ const MessageBatch = struct { }; const Inbox = struct { - mutex: std.atomic.Mutex = .unlocked, + mutex: std.Io.Mutex = .init, + space: std.Io.Condition = .init, items: [inbox_capacity]Msg = undefined, - head: usize = 0, len: usize = 0, closed: bool = false, - /// Set when a wakeup has been delivered and not yet answered by a tick. wake_pending: std.atomic.Value(bool) = .init(false), - fn lock(q: *Inbox) void { - // AppKit's main thread runs at a higher QoS than reader tasks, so yield - // periodically rather than donating a full core to a preempted reader. - var spins: u8 = 0; - while (!q.mutex.tryLock()) { - spins +%= 1; - if (spins == 0) std.Thread.yield() catch {} else std.atomic.spinLoopHint(); - } - } - fn removeAt(q: *Inbox, offset: usize) Msg { - const removed = q.items[(q.head + offset) % q.items.len]; - var i = offset; - while (i + 1 < q.len) : (i += 1) - q.items[(q.head + i) % q.items.len] = q.items[(q.head + i + 1) % q.items.len]; + const removed = q.items[offset]; + std.mem.copyForwards(Msg, q.items[offset .. q.len - 1], q.items[offset + 1 .. q.len]); q.len -= 1; return removed; } - /// Pty output is lossy under sustained backpressure. EOF is structural, and - /// so is a nested `Look`: one is a reader that must be reaped, the other is - /// a launch that already exited believing it was delivered. Admit both by - /// evicting queued output. Every switch below is exhaustive on purpose — a - /// new message kind has to say which of the two it is. - fn push(q: *Inbox, gpa: std.mem.Allocator, m: Msg) void { - q.lock(); - defer q.mutex.unlock(); + fn pushOutput(q: *Inbox, gpa: std.mem.Allocator, io: std.Io, output: @FieldType(Msg, "output")) std.Io.Cancelable!void { + errdefer gpa.free(output.bytes); + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); + while (q.len >= inbox_output_limit and !q.closed) try q.space.wait(io, &q.mutex); + if (q.closed) return error.Canceled; + q.items[q.len] = .{ .output = output }; + q.len += 1; + } + + fn push(q: *Inbox, gpa: std.mem.Allocator, io: std.Io, m: Msg) void { + std.debug.assert(m != .output); + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); if (q.closed) { m.free(gpa); return; } - if (q.len == q.items.len) { - const lossy = switch (m) { - .output, .lsp_status => true, - .eof, .command, .lsp_done, .pipe => false, - }; - if (lossy) { - m.free(gpa); - return; - } + if (m != .pipe) { var offset: usize = 0; - while (offset < q.len) : (offset += 1) - if (switch (q.items[(q.head + offset) % q.items.len]) { - .output, .lsp_status => true, - .eof, .command, .lsp_done, .pipe => false, - }) break; - if (offset == q.len) return; - q.removeAt(offset).free(gpa); + while (offset < q.len) : (offset += 1) { + const old = q.items[offset]; + const replaced = switch (m) { + .eof => |end| old == .eof and old.eof.pane == end.pane, + .lsp_done => old == .lsp_done, + .lsp_status => old == .lsp_status, + .output, .pipe => unreachable, + }; + if (replaced) { + q.removeAt(offset).free(gpa); + break; + } + } } - q.items[(q.head + q.len) % q.items.len] = m; + std.debug.assert(q.len < q.items.len); + q.items[q.len] = m; q.len += 1; } - fn take(q: *Inbox) MessageBatch { - q.lock(); - defer q.mutex.unlock(); - var batch: MessageBatch = .{}; - while (q.len > 0) { - batch.items[batch.len] = q.items[q.head]; - batch.len += 1; - q.head = (q.head + 1) % q.items.len; - q.len -= 1; - } - q.head = 0; + fn take(q: *Inbox, io: std.Io) MessageBatch { + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); + var batch: MessageBatch = .{ .len = q.len }; + @memcpy(batch.items[0..q.len], q.items[0..q.len]); + q.len = 0; + q.space.broadcast(io); return batch; } - fn close(q: *Inbox, gpa: std.mem.Allocator) void { - q.lock(); - defer q.mutex.unlock(); + fn close(q: *Inbox, gpa: std.mem.Allocator, io: std.Io) void { + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); q.closed = true; - while (q.len > 0) { - q.items[q.head].free(gpa); - q.head = (q.head + 1) % q.items.len; - q.len -= 1; - } - q.head = 0; + for (q.items[0..q.len]) |msg| msg.free(gpa); + q.len = 0; + q.space.broadcast(io); } }; +test "mac inbox coalesces completions at the tail without reordering terminal output" { + const gpa = std.testing.allocator; + var inbox: Inbox = .{}; + defer inbox.close(gpa, std.testing.io); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = std.math.maxInt(u32), .rows = try gpa.dupe(u8, "old result") } }); + inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "old status") }); + try inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = std.math.maxInt(u32), .bytes = try gpa.dupe(u8, "old output") }); + inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = 0, .gen = std.math.maxInt(u32) } }); + try inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = 0, .bytes = try gpa.dupe(u8, "new output") }); + inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = 0, .gen = 0 } }); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 0, .rows = try gpa.dupe(u8, "new result") } }); + inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "new status") }); + var batch = inbox.take(std.testing.io); + defer for (batch.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(@as(usize, 5), batch.len); + try std.testing.expectEqualStrings("old output", batch.items[0].output.bytes); + try std.testing.expectEqualStrings("new output", batch.items[1].output.bytes); + try std.testing.expectEqual(@as(u32, 0), batch.items[2].eof.gen); + try std.testing.expectEqual(@as(u32, 0), batch.items[3].lsp_done.id); + try std.testing.expectEqualStrings("new result", batch.items[3].lsp_done.rows.?); + try std.testing.expectEqualStrings("new status", batch.items[4].lsp_status); + inbox.close(gpa, std.testing.io); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 1, .rows = try gpa.dupe(u8, "closed") } }); + try std.testing.expectEqual(@as(usize, 0), inbox.len); +} + +test "mac inbox admits every retained task completion when output fills the queue" { + const gpa = std.testing.allocator; + var inbox: Inbox = .{}; + defer inbox.close(gpa, std.testing.io); + var tasks: selection_pipe.Tasks = .{}; + defer tasks.cancelAll(std.testing.io); + for (0..inbox_output_limit) |_| try inbox.pushOutput(gpa, std.testing.io, .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "output"), + }); + for (0..pardes.MAX_PANES) |pane| inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = @intCast(pane), .gen = 1 } }); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 1, .rows = try gpa.dupe(u8, "result") } }); + for (0..selection_pipe.Tasks.capacity) |index| { + const id: u32 = @intCast(index); + try std.testing.expect(tasks.add(.{ .id = id, .future = .{ .any_future = null, .result = {} } })); + if (index == 0) { + const output = try gpa.dupe(u8, "filtered"); + const outputs = gpa.dupe([]u8, &.{output}) catch |err| { + gpa.free(output); + return err; + }; + inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = id, .success = true, .outputs = outputs } }); + } else inbox.push(gpa, std.testing.io, .{ .pipe = .{ + .id = id, + .success = false, + .outputs = &.{}, + .failure = .{ .kind = .exit, .code = 1, .stderr = try gpa.dupe(u8, "no match") }, + } }); + } + inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "current status") }); + try std.testing.expectEqual(inbox_capacity, inbox.len); + var batch = inbox.take(std.testing.io); + defer for (batch.slice()) |msg| msg.free(gpa); + var eof_count: usize = 0; + var lsp_count: usize = 0; + var pipe_count: usize = 0; + var output_count: usize = 0; + for (batch.slice()) |msg| switch (msg) { + .eof => eof_count += 1, + .lsp_done => lsp_count += 1, + .pipe => |result| { + pipe_count += 1; + tasks.finish(std.testing.io, result.id); + }, + .output => |output| { + output_count += 1; + try std.testing.expectEqualStrings("output", output.bytes); + }, + .lsp_status => |status| try std.testing.expectEqualStrings("current status", status), + }; + try std.testing.expectEqual(pardes.MAX_PANES, eof_count); + try std.testing.expectEqual(@as(usize, 1), lsp_count); + try std.testing.expectEqual(selection_pipe.Tasks.capacity, pipe_count); + try std.testing.expectEqual(inbox_output_limit, output_count); + try std.testing.expectEqual(@as(usize, 0), tasks.len); +} + +test "mac inbox preserves output under backpressure and wakes on take cancel and close" { + const gpa = std.testing.allocator; + const io = std.testing.io; + const Producer = struct { + fn run(q: *Inbox, done: *std.Io.Event) !void { + defer done.set(std.testing.io); + defer q.push(std.testing.allocator, std.testing.io, .{ .eof = .{ .pane = 0, .gen = 1 } }); + try q.pushOutput(std.testing.allocator, std.testing.io, .{ + .pane = 0, + .gen = 1, + .bytes = try std.testing.allocator.dupe(u8, "tail\x1b[0m"), + }); + try q.pushOutput(std.testing.allocator, std.testing.io, .{ + .pane = 0, + .gen = 1, + .bytes = try std.testing.allocator.dupe(u8, "é😀"), + }); + } + }; + for ([_]enum { take, cancel, close }{ .take, .cancel, .close }) |action| { + var inbox: Inbox = .{}; + defer inbox.close(gpa, io); + for (0..inbox_output_limit) |_| try inbox.pushOutput(gpa, io, .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "before"), + }); + var done: std.Io.Event = .unset; + var future = try io.concurrent(Producer.run, .{ &inbox, &done }); + defer future.cancel(io) catch {}; + for (0..1000) |_| { + if (inbox.space.state.load(.acquire).waiters != 0) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expectEqual(@as(u16, 1), inbox.space.state.load(.acquire).waiters); + switch (action) { + .take => { + var before = inbox.take(io); + defer for (before.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(inbox_output_limit, before.len); + for (before.slice()) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + try done.waitTimeout(io, .{ .duration = .{ .raw = .fromSeconds(2), .clock = .awake } }); + try future.await(io); + var after = inbox.take(io); + defer for (after.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(@as(usize, 3), after.len); + try std.testing.expectEqualStrings("tail\x1b[0m", after.items[0].output.bytes); + try std.testing.expectEqualStrings("é😀", after.items[1].output.bytes); + try std.testing.expect(after.items[2] == .eof); + }, + .cancel => { + try std.testing.expectError(error.Canceled, future.cancel(io)); + var batch = inbox.take(io); + defer for (batch.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(inbox_output_limit + 1, batch.len); + for (batch.slice()[0..inbox_output_limit]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + try std.testing.expect(batch.items[inbox_output_limit] == .eof); + }, + .close => { + inbox.close(gpa, io); + try done.waitTimeout(io, .{ .duration = .{ .raw = .fromSeconds(2), .clock = .awake } }); + try std.testing.expectError(error.Canceled, future.await(io)); + try std.testing.expectEqual(@as(usize, 0), inbox.len); + }, + } + } +} + const State = struct { + ninep: ?*ninep_io.Listener = null, gpa: std.mem.Allocator, threaded: *std.Io.Threaded, io: std.Io, core: *pardes.Pardes, - /// False for the one effect drain inside pardes_init and nothing else: no - /// reader task exists yet, and the first theme file must land without a fade. started: bool = false, runtime: Runtime, cells: []Cell = &.{}, - /// Frozen canonical grid paired with an encoded change mask while a content or - /// lifecycle transition is active. Both are encoded at frame time so the - /// native renderer never borrows core-owned Cell layout across the ABI. previous_cells: []Cell = &.{}, changed_cells: []u8 = &.{}, panel_diff_len: usize = 0, frame_len: usize = 0, - /// The grid `cells` actually holds. Not read back off the core: a render - /// can move screen_w/screen_h and then fail, and a host that sized its - /// loops from those would walk off the buffer. frame_cols: u16 = 0, frame_rows: u16 = 0, - /// This frame's pixel attachments, flattened out of Surface.images. Grown - /// and reused like `cells`, and emptied by the same failure path — the - /// accessors must never describe a different frame than the cell count. images: []Image = &.{}, images_len: usize = 0, - /// This frame's panel transitions, copied out of Surface in deterministic - /// paint order: moving, opening, then frozen closing tombstones. panel_tracks: [pardes.MAX_PANES * 2]PanelTrack = undefined, panel_tracks_len: usize = 0, ptys: [pardes.MAX_PANES]?Pty = @splat(null), inbox: Inbox = .{}, - /// The single in-flight language query. ONE slot, like the tty shell's: - /// replacing it cancels the previous worker, which is right because the - /// only query anyone is waiting for is the one they just asked for. - lsp_task: ?std.Io.Future(anyerror!void) = null, - /// Filters running off the main thread. Bounded by the shared table; a full - /// one answers the request as failed rather than queueing it. + lsp_task: ?host_io.Lsp.Task = null, pipe_tasks: selection_pipe.Tasks = .{}, file_watches: FileWatches = .{}, - /// Per-slot spawn generation, owned by the main thread. A reader carries a - /// copy in every message it posts; anything that no longer matches belongs - /// to a shell this slot has already replaced. gens: [pardes.MAX_PANES]u32 = @splat(0), - /// Sub-cell wheel distance the core has not been told about yet, one - /// accumulator per axis. The core moves a whole row or column at a time, - /// so fractional trackpad travel banks here and is spent as wheel presses - /// — see pardes_scroll. Separate axes because a diagonal drift must not - /// let one direction's residue push the other over a notch. scroll_lag: f32 = 0, scroll_lag_x: f32 = 0, - /// Degrees of trackpad rotation not yet spent as a search step — the same - /// accumulate-and-keep-the-remainder shape as scroll_lag, see pardes_rotate. rotate_lag: f32 = 0, - /// The dial's angular velocity, in degrees per second. While fingers are - /// down this is a running estimate off the event stream; when they lift it - /// becomes the fling that `coasting` spends. Zero is a dial at rest. rotate_velocity: f32 = 0, - /// When the last rotation event arrived, so the estimate above has a dt. rotate_last_ns: i128 = 0, - /// Fingers are off and the dial is still turning. Separate from a nonzero - /// velocity because during the gesture that velocity is a MEASUREMENT — - /// spending it then would double every twist under the hand making it. rotate_coasting: bool = false, - /// Real elapsed time for the persistent Core Image scene pass, in - /// nanoseconds. Input and pty pumps never spend it; pardes_animation_tick - /// is the only writer. - /// - /// TIME, not a callback count. It used to be a frame counter divided by an - /// assumed 60 Hz, and the callbacks do not arrive at 60 Hz — the pump - /// re-arms `asyncAfter(0.016)` only after the previous frame's work, so the - /// real period is 16 ms PLUS a tick, a drain and a draw. Shader time - /// therefore advanced at roughly three quarters of wall clock, unevenly, - /// which is what a scene effect looks like when it stutters. scene_ns: u64 = 0, - /// Monotonic stamp of the previous tick, and the leftover time that was not - /// yet worth a whole fixed animation step. The core's transitions count - /// FRAMES, so real elapsed time is banked here and spent in whole - /// `animation.frame_ns` steps: a late callback advances two frames instead - /// of stretching one, which is what keeps a transition's duration the same - /// on a busy machine as on an idle one. last_tick_ns: u64 = 0, tick_bank_ns: u64 = 0, - /// Panes whose shell has produced output since we last read its cwd. - /// - /// The cwd is wanted for pane tags and for resolving a relative Look, and - /// asking libproc costs a syscall per pane. Polling it on a clock spends - /// that forever to notice something that only ever changes when the shell - /// runs a command — and a shell that ran a command always writes at least - /// its next prompt. So the read is owed to output, not to time: mark here - /// on the way past and settle it once at the end of the drain, however - /// many chunks that burst arrived in. cwd_stale: [pardes.MAX_PANES]bool = @splat(false), - /// The socket a pardes launched inside this app connects to (nested.zig), - /// or -1 when it could not be bound and nested launches open their own - /// window as they always did. - sock_fd: c_int = -1, - /// Owns the bytes of the user config, which Options only borrows. config_arena: std.heap.ArenaAllocator, - /// Private prompt snippets borrowed by every child argv until exec. - prompt_rcs: shell_bin.PromptRcs, + prompt_rcs: host_io.Shell.PromptFiles, }; var state: ?State = null; -// ---------------------------------------------------------------- lifecycle - export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_int { if (state != null) return 1; // already up; deinit first initCore(runtime, cols_arg, rows_arg) catch |err| { @@ -653,17 +679,11 @@ export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_ return 0; } -/// The body is split out purely so the cleanup below is real: `errdefer` fires -/// on an error return and nothing else, so writing this inside an export that -/// returns c_int would leave every one of these as dead code — and a half-built -/// init leaks an arena, leaves zstbi pointing at a dead allocator, and (because -/// Io.Threaded installs process-wide SIGIO/SIGPIPE handlers that only its -/// deinit restores) hands those handlers permanently to the host app. fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { const gpa = std.heap.smp_allocator; - const allocs = pardes.allocators.init(gpa); - errdefer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + errdefer pardes.memory.deinit(); const threaded = try gpa.create(std.Io.Threaded); errdefer gpa.destroy(threaded); @@ -676,25 +696,16 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { var opts: pardes.Options = .{ .tty_only = true, - // The purpose-built 16 MiB stack-fallback buffer this host has always - // rendered out of; the core builds its per-frame Surface arena on it. .frame_allocator = allocs.frame, .image_allocator = allocs.image, .pdf_allocator = allocs.pdf, .tree_sitter_allocator = allocs.tree_sitter, }; - // Native shells opt into the user config, and every builtin in it must have - // run before the host can render a frame — so it is read here, before - // Pardes.init, exactly as src/main.zig does it. The env map is rebuilt from - // libc's environ because a library has no std.process.Init to inherit one. if (captureEnv(config_arena.allocator())) |*env| { - const found = user_config.load(io, config_arena.allocator(), env); + const found = pardes.config.User.load(io, config_arena.allocator(), env); opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; - // This host has no terminal at all, so the panic trace stderr gets goes - // to a Console.app nobody has open. `panic` above writes it beside the - // init file too, and this is where it learns the directory. if (found.dir) |d| crash.setDir(d); } @@ -707,17 +718,9 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { const core = try pardes.Pardes.init(allocs.pardes, opts); errdefer core.deinit(); - // This host draws pixels. Without it the core assumes a terminal that - // cannot, and a PDF pane degrades to counted page turns with nothing on - // screen at all — which is exactly what it did. The SDL shell sets the - // same flag; the tty one sets it from the terminal's kitty-graphics - // capability, because there it is a question rather than a fact. core.native_images = true; - // PATH, the bash banner and the prompt rc files, in the one order that - // works. State retains the path buffers for every later spawn and removes - // the files at app teardown. - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); errdefer prompt_rcs.deinit(); state = .{ @@ -730,96 +733,38 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { .runtime = if (runtime) |r| r.* else .{}, }; const st = &state.?; - // Every capability this host has, including the tty pull the core makes at - // the Exec that cares rather than at the cwd read above. Assigned here and - // not left to `pump`, because the spawns below happen outside one. + st.ninep = ninep_io.start(gpa, core); core.host = hostFor(st); - // The real grid, delivered as an EVENT and not as Options.cols/rows: the - // core defers an integrated shell's greeting until this resize and OSC - // 133 B; the first forkpty below takes its winsize straight off the core. const cols = @max(1, cols_arg); const rows = @max(1, rows_arg); core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - // The initial spawns happen before any reader task exists, mirroring the - // tty shell. Note the difference in what that buys: tty.zig runs from - // main() and really is single-threaded there, whereas this is called from - // applicationDidFinishLaunching, by which point AppKit and libdispatch - // have long since spawned threads. What keeps the fork safe is the child - // itself — chdir and execv, raw syscalls with nothing allocated between - // fork and exec — not the thread count. Ordering it this way anyway keeps - // the two backends readable side by side. while (core.nextEffect()) |effect| core.perform(effect); st.started = true; + if (st.ninep) |listener| listener.wakeThread(st, wakeNinep) catch |err| core.reportError(0, "9p wake", err); for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id)); - // Server-state narration onto the transient message row. Registered HERE - // and not at the `state = .{...}` assignment because the sink is called - // from the protocol client's reader threads and must not fire before the - // inbox is reachable. Without this the sink existed and nothing ever called - // it, so "rust-analyzer: indexing 45%" never appeared in this shell. pardes.lsp.setStatusSink(st, lspStatusSink); - - // Last, because it is the one thing here that publishes this process to - // the outside: nothing may connect before the core can answer. The shells - // above are already forked, which is why the listener's fd is CLOEXEC — - // an orphaned bash holding it would keep the socket bound after we quit. - st.sock_fd = nested.listen(); - if (st.sock_fd >= 0) { - const thread = std.Thread.spawn(.{}, lookServer, .{st}) catch |err| { - // Bound but unattended would be worse than never bound: every - // nested launch would connect, be believed, and vanish. - log.warn("nested Look server did not start ({t})", .{err}); - nested.unlisten(st.sock_fd); - st.sock_fd = -1; - return; - }; - thread.detach(); - } } -/// Accept `Look ` lines from pardes instances launched inside this app -/// and post them where the main thread will run them. -/// -/// A detached thread around a call that never returns, exactly like the tty -/// backend's: close(2) does not release a thread parked in accept(2), so this -/// dies with the process rather than with the socket. The window that leaves -/// is one connection accepted between the last tick and process exit posting -/// into an inbox nobody drains — the same bound the pty readers have, and a -/// self-pipe to close it would be more machinery than the window is worth. -fn lookServer(st: *State) void { - var buf: [nested.max_line]u8 = undefined; - while (nested.acceptLine(st.sock_fd, &buf)) |line| { - const owned = st.gpa.dupe(u8, line) catch continue; - st.inbox.push(st.gpa, .{ .command = owned }); - wake(st); - } +fn wakeNinep(ctx: ?*anyopaque) void { + const st: *State = @ptrCast(@alignCast(ctx orelse return)); + wake(st); } export fn pardes_deinit() void { const st = &(state orelse return); - // Before anything else: it is the only fd another process can reach us - // through, and unlinking the file is what stops the next launch from - // connecting to a session that is halfway through tearing itself down. - // The thread parked in accept(2) is not released by this and dies with - // the process, which is what its detach() already said. - nested.unlisten(st.sock_fd); - st.sock_fd = -1; - // The protocol client's reader threads call the sink, and the State it is - // handed is about to become null: unregister before the inbox goes away, - // and cancel the one query that may still be running against it. + if (st.ninep) |listener| { + listener.deinit(st.gpa); + st.ninep = null; + } pardes.lsp.setStatusSink(null, null); if (st.lsp_task) |*t| { - t.cancel(st.io) catch {}; + t.future.cancel(st.io) catch {}; st.lsp_task = null; } - // ...and every filter still running against it. A future nobody cancels is - // a thread writing into a State that is about to be null. st.pipe_tasks.cancelAll(st.io); - // Cancel host directory sources while their generation table still exists. - // A debounce block already queued on the main runloop may call back later; - // state=null below and the bumped generation each make that callback inert. for (0..pardes.MAX_PANES) |pane| if (st.file_watches.entries[pane] != null) { const id: u8 = @intCast(pane); const generation = st.file_watches.stop(st.gpa, id); @@ -829,13 +774,8 @@ export fn pardes_deinit() void { const generation = st.file_watches.stop(st.gpa, theme_watch_pane); hostWatchFile(theme_watch_pane, generation, null, 0); } - // Every reader is joined here, before anything it touches is freed. The - // runtime joins its tasks on exit, so a reader left parked in read(2) would - // hang the process instead of the app quitting. for (0..pardes.MAX_PANES) |pane| reap(st, @intCast(pane)); - // Only now is the inbox quiet. Anything still queued owns gpa bytes and - // would show up as a leak rather than as the shutdown it actually is. - st.inbox.close(st.gpa); + st.inbox.close(st.gpa, st.io); st.file_watches.deinit(st.gpa); if (st.cells.len > 0) st.gpa.free(st.cells); if (st.previous_cells.len > 0) st.gpa.free(st.previous_cells); @@ -849,7 +789,7 @@ export fn pardes_deinit() void { st.prompt_rcs.deinit(); st.threaded.deinit(); st.gpa.destroy(st.threaded); - pardes.allocators.deinit(); + pardes.memory.deinit(); state = null; } @@ -858,7 +798,7 @@ export fn pardes_should_quit() bool { return st.core.quit; } -fn encodeSceneEffects(effects: panel_animation.SceneEffect) u32 { +fn encodeSceneEffects(effects: layout.SceneEffect) u32 { var flags: u32 = 0; if (effects.crt) flags |= scene_flag_crt; if (effects.ripple) flags |= scene_flag_ripple; @@ -870,56 +810,39 @@ fn currentSceneFlags(st: *const State) u32 { return encodeSceneEffects(st.core.settings.scene_effects); } -/// Advance the scene clock by real elapsed time, wrapping so an f32 -/// `time_seconds` keeps sub-millisecond resolution forever. fn advanceSceneClock(st: *State, elapsed_ns: u64) void { st.scene_ns = (st.scene_ns +| elapsed_ns) % scene_wrap_ns; } -/// How much real time this tick may spend, and how many whole fixed steps that -/// buys. Pure arithmetic, split out of `pardes_animation_tick` so the clock the -/// whole feel of the app rides on can be asserted without a display attached. -/// -/// `previous` of zero means "no sample yet" — the first tick of a run, or a -/// monotonic clock that refused to answer — and spends exactly one step rather -/// than the entire uptime. const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 }; fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend { const measured = if (previous_ns == 0 or now_ns <= previous_ns) - pardes.animation.frame_ns + pardes.layout.Animation.frame_ns else now_ns - previous_ns; const elapsed = @min(measured, max_tick_catch_up_ns); var bank = bank_ns +| elapsed; var steps: u32 = 0; - while (bank >= pardes.animation.frame_ns) : (steps += 1) bank -= pardes.animation.frame_ns; + while (bank >= pardes.layout.Animation.frame_ns) : (steps += 1) bank -= pardes.layout.Animation.frame_ns; return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank }; } test "the animation clock spends real time, not callbacks" { - const frame = pardes.animation.frame_ns; + const frame = pardes.layout.Animation.frame_ns; const expectEqual = std.testing.expectEqual; - // First tick of a run has nothing to measure from and spends exactly one - // step — never the whole uptime. const first = spendTickTime(0, 999 * std.time.ns_per_s, 0); try expectEqual(@as(u32, 1), first.steps); try expectEqual(frame, first.elapsed_ns); - // A callback that lands ON time buys one step and banks nothing. const on_time = spendTickTime(1_000, 1_000 + frame, 0); try expectEqual(@as(u32, 1), on_time.steps); try expectEqual(@as(u64, 0), on_time.bank_ns); - // THE BUG THIS FIXES. A callback that lands late used to still count as one - // frame, so an animation stretched and ran slow. Two frames' worth of real - // time now buys two steps. const late = spendTickTime(1_000, 1_000 + 2 * frame, 0); try expectEqual(@as(u32, 2), late.steps); - // ...and time too short for a step is BANKED, not discarded: three 6 ms - // callbacks are worth one 16 ms frame, not zero and not three. var bank: u64 = 0; var steps: u32 = 0; for (0..3) |_| { @@ -930,44 +853,19 @@ test "the animation clock spends real time, not callbacks" { try expectEqual(@as(u32, 1), steps); try expectEqual(@as(u64, 2 * std.time.ns_per_ms), bank); - // A stall — occluded window, sleep, breakpoint — is CLAMPED. Resuming an - // animation must not fast-forward it by however long nobody was looking. const stall = spendTickTime(1_000, 1_000 + 10 * std.time.ns_per_s, 0); try expectEqual(max_tick_catch_up_ns, stall.elapsed_ns); try expectEqual(@as(u32, @intCast(max_tick_catch_up_ns / frame)), stall.steps); - // A monotonic clock that refuses to answer, or that goes backwards, spends - // one step rather than a garbage dt. try expectEqual(@as(u32, 1), spendTickTime(5_000, 4_000, 0).steps); } -/// Something on screen moves on its own and wants ~60 Hz ticks: a finite core -/// transition, a persistent scene shader, or the rotation dial coasting after -/// a flick. All are spent only by pardes_animation_tick, so input and pty pumps -/// cannot make frame-count animation run faster than the display clock. export fn pardes_animating() bool { const st = &(state orelse return false); return st.core.animationActive() or st.rotate_coasting; } -/// The colour the host should paint everything the grid does not: the window -/// background behind the titlebar, and behind every pixel of a live resize the -/// view has not caught up with yet. -/// -/// The theme's OWN background, not the chrome's, and so not animated — the -/// same split every other shell draws. Chrome (taglines, the move box, the -/// scrollbar) fades between themes over a handful of frames; document -/// backgrounds switch the instant the theme does, and this is one of those. -/// -/// PARDES_COLOR_DEFAULT means the active theme declares NO background of its -/// own (`bg = null`: the curated `dark`, and every vendored `*_transparent`). -/// In a terminal that means "wear whatever the terminal is wearing"; a window -/// has nothing to wear, so the host lets its own backdrop through — see the -/// NSVisualEffectView in AppDelegate. export fn pardes_theme_bg() u32 { - // Before pardes_init there is no session, but there IS a theme: the ring's - // first entry is what the core boots wearing, so answering with it keeps - // the window from opening one colour and flipping to another a frame later. const th = if (state) |*st| st.core.theme() else &pardes.themes[0]; const bg = th.bg orelse return color_default; return @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2]; @@ -977,23 +875,10 @@ fn taglineFontPercent(core: ?*const pardes.Pardes) u8 { return if (core) |p| p.settings.font.tagline_percent else pardes.config.gui_tagline_font_percent; } -/// The smaller face used for pane taglines, as a percentage of the body face. -/// Grid geometry always comes from the body face. Before init the compiled -/// default lets the host construct its metrics; afterwards it pulls the live -/// core value so a TaglineSize command is visible on the next host read. export fn pardes_gui_tagline_font_percent() u8 { return taglineFontPercent(if (state) |*st| st.core else null); } -/// Where that smaller band sits inside its body-sized row, and the rule between -/// the topbar band and the first pane-tag band. Both answers come from the core -/// rather than being reimplemented here, because a second copy of this geometry -/// is exactly what left the native shell centring every band while the SDL -/// shell joined them (`pardes.taglineBandOffset`). -/// -/// PHYSICAL PIXELS, like the SDL shell's: a host working in points multiplies -/// by its backing scale on the way in and divides on the way out, which is the -/// same snapping it already does for the cell itself. export fn pardes_tagline_band_offset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h); } @@ -1002,34 +887,16 @@ export fn pardes_topbar_pane_border_px(cell_h: u32, tagline_h: u32) u32 { return pardes.topbarPaneBorderPixels(cell_h, tagline_h); } -/// ...and the HORIZONTAL half of the same story: the column a compact tagline -/// band anchors at, so a tag row advances on the tagline face's own pitch -/// instead of dropping a smaller glyph into the middle of every body cell. -/// Without it this shell tracked its tags visibly looser than the SDL window -/// beside it at the same percentage. -/// -/// CELLS, not pixels: the caller already knows both cell widths, and an -/// animating panel's origin is fractional. export fn pardes_tagline_origin_col(col: u16, row: u16) f32 { const st = &(state orelse return @floatFromInt(col)); return pardes.taglineOriginColForFrame(st.core, col, row); } -/// ...and its inverse, for the pointer. A tag row whose glyphs were compacted -/// but whose clicks were not is a click that drifts one word further right for -/// every word along the row, so the layout and the hit test are one feature. -/// -/// `x` and both widths in the SAME unit — this shell measures in POINTS and -/// passes points; only their ratio is read. export fn pardes_grid_col_at(x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { const st = &(state orelse return pardes.gridColAt(null, x, row, body_w, tagline_w)); return pardes.gridColAt(st.core, x, row, body_w, tagline_w); } -/// Colour of that rule: the compiled override when a build pins one, otherwise -/// the active theme's scrollbar track — the same resolution the SDL shell does -/// at `src/gui/gui.zig:3813`. PARDES_COLOR_DEFAULT before there is a session to -/// ask, which the host reads as "do not draw the rule yet". export fn pardes_topbar_pane_border_rgb() u32 { const rgb = pardes.config.gui_topbar_pane_border_rgb orelse fromTheme: { const st = state orelse return color_default; @@ -1038,33 +905,12 @@ export fn pardes_topbar_pane_border_rgb() u32 { return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; } -/// The tag band's own background — `chromeTheme().tag_bg`, the same value the -/// SDL shell builds its `tagline_base` cell from. -/// -/// A host needs it because a compact tag row is painted in two passes: the -/// pane-wide band in THIS colour on the body grid, then each cell's own -/// background on the narrower grid the glyphs use. Without the split, a -/// highlighted word's box lands on body pitch while its letters sit on tagline -/// pitch, and the box drifts further from the word the further along the row -/// it is. PARDES_COLOR_DEFAULT before there is a session to ask. export fn pardes_tagline_bg() u32 { const st = state orelse return color_default; const rgb = st.core.chromeTheme().tag_bg; return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; } -/// The shared fallback PREFERENCE ORDER — `fonts.fallback_names`, the same list -/// the SDL shell walks. Only the order is shared; resolving a name is each -/// host's own business, and has to be: SDL matches file stems while walking the -/// font directories itself, and CoreText matches PostScript and family names, -/// which for the same face are routinely different strings. "Mononoki Nerd -/// Font Mono" ships as `MononokiNerdFontMono-Regular.ttf` and answers to -/// `MononokiNFM-Regular`, and a by-stem lookup on this platform silently -/// resolves to Helvetica rather than failing. -/// -/// Returned as pointer + length rather than NUL-terminated because these are -/// Zig string literals and a sentinel copy of each would exist only to be -/// dropped again by the caller. export fn pardes_fallback_font_count() u32 { return fonts.fallback_names.len; } @@ -1093,45 +939,27 @@ test "the fallback preference order crosses the ABI intact and ends at the bound try std.testing.expectEqual(@as(u32, fonts.fallback_names.len), pardes_fallback_font_count()); try std.testing.expect(pardes_fallback_font_count() > 0); - // Every name arrives byte for byte and in the SAME ORDER, which is the - // whole of what is shared: the AppKit shell seeds its CoreText cascade from - // this list and the SDL shell walks the font directories for it, and a - // reordering here would silently give one window a different fallback than - // the other at the same codepoint. for (fonts.fallback_names, 0..) |want, i| { var len: u32 = 0; const got = pardes_fallback_font_name(@intCast(i), &len) orelse return error.MissingFallbackName; try std.testing.expectEqualStrings(want, got[0..len]); } - // Past the end is null AND a zero length: a host that ignores the count and - // walks until null must not read a stale length and copy from a null - // pointer. var len: u32 = 12345; try std.testing.expect(pardes_fallback_font_name(pardes_fallback_font_count(), &len) == null); try std.testing.expectEqual(@as(u32, 0), len); } -/// One coherent snapshot for the host's single scene postprocess. The clock is -/// REAL ELAPSED TIME, advanced only on the scheduled display callback and never -/// on an input or pty drain — so a burst of typing cannot fast-forward a scene -/// effect, and a slow callback no longer slows one down either. export fn pardes_scene() Scene { const st = &(state orelse return .{}); const seconds = @as(f64, @floatFromInt(st.scene_ns)) / @as(f64, std.time.ns_per_s); return .{ .flags = currentSceneFlags(st), .time_seconds = @floatCast(seconds), - // The shader's frame counter is that time expressed in nominal display - // frames; it is a UNIT of the clock now, not the clock itself. .frame = @intFromFloat(seconds * @as(f64, @floatFromInt(scene_frame_hz))), }; } -/// The host could not construct or repeatedly submit the shared Metal/Core -/// Image pass. Stop claiming effects are enabled when only the canonical grid -/// can be presented, stop its otherwise-unbounded display-clock wakeups, and -/// snap any current panels before the direct canonical fallback is drawn. export fn pardes_postprocessor_unavailable() void { const st = &(state orelse return); st.core.disableSceneEffects(); @@ -1140,27 +968,11 @@ export fn pardes_postprocessor_unavailable() void { st.scene_ns = 0; } -/// One transient postprocess submission failed and the host will draw the -/// canonical grid for this frame. A later retry may keep scene effects, but it -/// must not resume a half-finished panel transition after that canonical frame. export fn pardes_panel_animation_failed() void { const st = &(state orelse return); st.core.abandonPanelAnimations(); } -/// The core's per-frame poll: re-read the cwd of every shell that just spoke, -/// and only those. -/// -/// A pane's tag shows this and a relative `Look` resolves against it, so it has -/// to follow the shell around rather than stay at the directory the pane was -/// spawned in. The tty and SDL hosts poll all of them every frame; here the -/// drain has just said exactly which shells produced bytes, and nothing else -/// can have changed one — a `cd` is a command, and a shell that ran a command -/// writes at least its next prompt. So an idle session costs nothing at all, -/// and a busy one costs one libproc call per pane per burst. -/// -/// Whether a shell's tty is still that shell is NOT refreshed here: nothing -/// draws it, so the core pulls it instead (see `ttyTaken`). fn refreshCwds(ctx: ?*anyopaque) void { const st = hostState(ctx); for (&st.cwd_stale, 0..) |*stale, id| { @@ -1168,7 +980,7 @@ fn refreshCwds(ctx: ?*anyopaque) void { stale.* = false; const pt = st.ptys[id] orelse continue; var buf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &buf)) |wd| st.core.setCwd(id, wd); + if (host_io.shellCwd(pt.pid, &buf)) |wd| st.core.setCwd(id, wd); } } @@ -1188,9 +1000,8 @@ fn watchInitialGeneration(st: *State, pane: u8, path: []const u8) ?file_watch.Ge return null; } -/// Start watching the path the core resolved for this pane. Turning a watch off -/// is the caller's business (`watchFile`); everything here is the start. -fn setFileWatch(st: *State, pane: u8, path: []const u8) void { +fn setFileWatch(st: *State, pane: u8, path: []const u8, mode: pardes.WatchMode) void { + const native = filesystem.localPath(path) orelse return; const value = st.core.panes[pane] orelse return; const generation_on_disk = watchInitialGeneration(st, pane, path) orelse return; const generation = st.file_watches.replace( @@ -1204,12 +1015,13 @@ fn setFileWatch(st: *State, pane: u8, path: []const u8) void { hostWatchFile(pane, stopped, null, 0); return; }; - const watched = st.file_watches.entries[pane].?; - hostWatchFile(pane, generation, watched.path.ptr, watched.path.len); - // The document was opened before this source existed. Reconcile once only - // AFTER source.activate() so a replacement in that gap is either observed - // here or produces a later directory edge; there is no open-before-watch - // window in which both mechanisms can miss it. + hostWatchFile(pane, generation, native.ptr, native.len); + if (mode == .baseline_disk and value.file != null) { + const bytes = filesystem.read(st.core, path) catch return; + defer st.core.gpa.free(bytes); + st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes)); + return; + } _ = reloadWatchedFile(st, pane, false); } @@ -1218,7 +1030,7 @@ fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: boo hostWatchFile(theme_watch_pane, stopped, null, 0); if (!on) return; const request = st.core.themeFileRequest(request_generation) orelse return; - const bytes = look.readFile(st.gpa, request.path) catch |err| { + const bytes = filesystem.readFile(st.gpa, request.path) catch |err| { st.core.failThemeFile(request_generation, err); return; }; @@ -1233,8 +1045,6 @@ fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: boo ) catch return; const watched = st.file_watches.entries[theme_watch_pane].?; hostWatchFile(theme_watch_pane, callback_generation, watched.path.ptr, watched.path.len); - // Read-before-watch has the same rename-over gap as document panes. One - // immediate reconciliation after Swift activates the source closes it. _ = reloadWatchedTheme(st, false); } @@ -1242,7 +1052,7 @@ fn reloadWatchedTheme(st: *State, announce: bool) bool { const watched = if (st.file_watches.entries[theme_watch_pane]) |*entry| entry else return false; const request = st.core.themeFileRequest(watched.serial) orelse return false; if (!std.mem.eql(u8, watched.path, request.path)) return false; - const bytes = look.readFile(st.gpa, watched.path) catch |err| { + const bytes = filesystem.readFile(st.gpa, watched.path) catch |err| { st.core.failThemeFile(watched.serial, err); return false; }; @@ -1264,11 +1074,6 @@ fn reloadWatchedTheme(st: *State, announce: bool) bool { return true; } -/// Read and apply on the main thread. Swift only says that this path or its -/// parent changed. Text hashes an exact bounded snapshot; PDFs may be much -/// larger than that bound and MuPDF reopens the path itself, so they compare a -/// cheap inode/size/time identity instead. Both transactions enter through the -/// same success-reporting core seam and only then advance their baseline. const WatchReload = enum { no_change, committed, changed_uncommitted }; fn retryWatchedFile(st: *State, pane: u8, generation: u32) void { @@ -1286,11 +1091,11 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { const result: WatchReload = switch (watched.generation_on_disk) { .text => |old_hash| text: { if (current.file == null) break :text .no_change; - const bytes = look.readFile(st.gpa, watched.path) catch { + const bytes = filesystem.read(st.core, watched.path) catch { retryWatchedFile(st, pane, generation); break :text .no_change; }; - defer st.gpa.free(bytes); + defer st.core.gpa.free(bytes); const hash = std.hash.Wyhash.hash(0, bytes); if (hash == old_hash) break :text .no_change; if (!st.core.reloadWatchedFile(pane, bytes)) { @@ -1298,9 +1103,6 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { break :text .no_change; } - // The call is synchronous, but retain the same lifetime guards as - // the async edge: future refactors cannot bless a reused slot just - // because it happens to carry the same pathname. const after = st.core.panes[pane] orelse break :text .no_change; const active = if (st.file_watches.entries[pane]) |*entry| entry else break :text .no_change; if (active.generation != generation or after.serial != active.serial) @@ -1331,10 +1133,6 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { retryWatchedFile(st, pane, generation); break :pdf .changed_uncommitted; }; - // The identity must bracket the complete synchronous MuPDF - // transaction. If the path moved during it, leave the old baseline - // in place and spend one bounded retry from the already-armed - // source; correctness does not depend on receiving a second edge. if (!before.eql(after_identity)) { retryWatchedFile(st, pane, generation); break :pdf .changed_uncommitted; @@ -1354,21 +1152,14 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { return result != .no_change; } -/// FileWatcher.swift calls this from DispatchQueue.main after its short quiet -/// period. Do not touch the core here: schedule the ordinary pump so all file -/// IO and state mutation stay in pardes_tick with pty/nested messages. export fn pardes_watch_changed(pane: u8, generation: u32) void { const st = &(state orelse return); if (pane >= watch_slot_count) return; if (st.file_watches.notify(pane, generation)) wake(st); } -/// What arrived off the loop thread since the last tick: pty output, a reaped -/// shell, a nested `Look`, and the file-watch edges Swift debounced. Every one -/// of them carries borrowed bytes, so they go straight into `update` rather -/// than through the core's event queue. fn drainInbox(st: *State) bool { - var batch = st.inbox.take(); + var batch = st.inbox.take(st.io); var did = batch.len > 0; for (batch.slice()) |msg| { defer msg.free(st.gpa); @@ -1380,39 +1171,20 @@ fn drainInbox(st: *State) bool { }, .eof => |e| { if (st.gens[e.pane] != e.gen) continue; - // The shell is gone: join its reader (a completed future that - // is never awaited leaks its allocation), close the master and - // free the slot. reap(st, e.pane); st.core.update(.{ .eof = .{ .pane = e.pane } }); }, - // Already filtered down to `Look ` by the accept side — this - // socket may open things and that is all it may do. - .command => |c| st.core.update(.{ .command = c }), - // The rows the worker produced, back into the request the core is - // still holding open. Joining the future here is what keeps a - // completed task from leaking its allocation. .lsp_done => |d| { st.core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); - if (st.lsp_task) |*t| { - t.cancel(st.io) catch {}; + if (st.lsp_task) |*t| if (t.id == d.id) { + t.future.await(st.io) catch {}; st.lsp_task = null; - } + }; }, - // "rust-analyzer: cargo check 88%" onto the transient message row, - // on the ACTIVE pane: server state is session news, not a fact - // about whichever pane happened to ask. .lsp_status => |text| { var mbuf: [256]u8 = undefined; st.core.setStatus(st.core.active, message.stamp(&mbuf, "lsp", text)); }, - // The filter's answer, then join the worker that produced it. - // - // NO deinit here: this loop's `defer msg.free(st.gpa)` owns the - // response, and `Msg.free` deinits it. The SDL shell frees inside - // its arm because its queue has no blanket free — copying that arm - // across without the surrounding contract is a double free, which - // is exactly what it was until the first `|` crashed the app. .pipe => |value| { st.core.update(.{ .pipe_resp = .{ .id = value.id, @@ -1435,51 +1207,225 @@ fn drainInbox(st: *State) bool { return did; } -/// Hand the core what arrived off-thread, then perform whatever it queued in -/// response. Returns whether this tick had IO to do, which is what bounds the -/// app's "pump until quiet" drain loop. -/// -/// It deliberately does NOT render. AppKit wants to be TOLD the view is dirty -/// and to draw once per display refresh: a pty burst is a dozen wakeups and a -/// dozen ticks, and rendering inside each of them would encode eleven grids -/// nobody ever sees. The render is `pardes_frame`, which the draw callback -/// calls at display cadence — the coalescing this whole boundary is shaped -/// around, and what src/macos/pardes.h has always said pardes_frame is. -/// -/// NOT a repaint signal, however tempting: the core changes the grid on its own -/// for a cursor move, a selection, a mode change and a scroll, none of which -/// queue an effect or read a pty, so all four return false here. The macOS host -/// learned that the expensive way — see the comment on pump() in -/// src/macos/Sources/AppDelegate.swift. export fn pardes_tick() bool { const st = &(state orelse return false); - // Cleared before the drain: a reader that pushes during this tick must be - // able to schedule the next one. st.inbox.wake_pending.store(false, .release); var did = drainInbox(st); - // Straight to `perform`, not through `pump`: the effects are the IO half of - // a tick and the render is not. `core.host` was seated once at init and is - // this host for the life of the session, so both this loop and the - // `tty_taken` pull the next keystroke makes land here. + if (st.ninep) |listener| { + const drained = listener.tick(st.core); + did = did or drained.count != 0; + if (drained.pending) wake(st); + } while (st.core.nextEffect()) |effect| { did = true; st.core.perform(effect); } + if (restoreCore(st)) did = true; return did; } -/// Spend the real time elapsed since the previous tick. Event pumps deliberately -/// never call this: a burst of key, mouse, or pty notifications is work to -/// drain, not elapsed animation time. +fn restoreCore(st: *State) bool { + if (st.core.quit) return false; + const path = st.core.takeRestore() orelse return false; + const bytes = filesystem.readFile(st.gpa, path) catch |err| { + st.core.reportError(st.core.active, "Restore", err); + return false; + }; + defer st.gpa.free(bytes); + const replacement = st.core.restore(bytes) catch |err| { + st.core.reportError(st.core.active, "Restore", err); + return false; + }; + if (st.lsp_task) |*task| { + task.future.cancel(st.io) catch {}; + st.lsp_task = null; + } + st.pipe_tasks.cancelAll(st.io); + for (0..pardes.MAX_PANES) |pane| { + reap(st, @intCast(pane)); + st.gens[pane] +%= 1; + } + var stale = st.inbox.take(st.io); + for (stale.slice()) |msg| msg.free(st.gpa); + for (0..watch_slot_count) |pane| if (st.file_watches.entries[pane] != null) { + const id: u8 = @intCast(pane); + const generation = st.file_watches.stop(st.gpa, id); + hostWatchFile(id, generation, null, 0); + }; + if (st.ninep) |listener| listener.reset(st.core); + replacement.host = hostFor(st); + st.core.deinit(); + st.core = replacement; + clearFrame(st); + st.cwd_stale = @splat(false); + st.scroll_lag = 0; + st.scroll_lag_x = 0; + st.rotate_lag = 0; + st.rotate_velocity = 0; + st.rotate_last_ns = 0; + st.rotate_coasting = false; + st.scene_ns = 0; + st.last_tick_ns = 0; + st.tick_bank_ns = 0; + return true; +} + +test "mac Restore keeps host state and rejects callbacks from the old core" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }); + var st: State = .{ + .gpa = gpa, + .threaded = undefined, + .io = std.testing.io, + .core = core, + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + .frame_len = 7, + .rotate_coasting = true, + }; + defer st.core.deinit(); + defer st.config_arena.deinit(); + defer st.file_watches.deinit(gpa); + defer st.inbox.close(gpa, std.testing.io); + const marker = try st.config_arena.allocator().dupe(u8, "host configuration"); + _ = try core.setTestFile("saved body\n"); + const old_serial = core.panes[0].?.serial; + st.gens[0] = 23; + const old_watch = try st.file_watches.replace(gpa, 0, "/test.txt", old_serial, .{ .text = 0 }); + try core.dumpState(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "restore.zon", .data = core.dump_out.? }); + while (core.nextEffect()) |_| {} + + var command: [512]u8 = undefined; + core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/missing.zon", .{tmp.sub_path}) }); + try std.testing.expect(!restoreCore(&st)); + try std.testing.expectEqual(core, st.core); + try std.testing.expectEqual(old_watch, st.file_watches.entries[0].?.generation); + try std.testing.expect(st.rotate_coasting); + + core.lspRequest(0, .status, ""); + const old_request = core.lsp_wait.?.id; + st.lsp_task = .{ .id = old_request, .future = .{ .any_future = null, .result = {} } }; + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = old_request, .rows = try gpa.dupe(u8, "old result") } }); + for (0..selection_pipe.Tasks.capacity) |index| { + const id: u32 = @intCast(index); + try std.testing.expect(st.pipe_tasks.add(.{ .id = id, .future = .{ .any_future = null, .result = {} } })); + st.inbox.push(gpa, std.testing.io, .{ .pipe = .{ + .id = id, + .success = false, + .outputs = &.{}, + .failure = .{ .kind = .exit, .code = 1, .stderr = try gpa.dupe(u8, "old filter failure") }, + } }); + } + try st.inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = 23, .bytes = try gpa.dupe(u8, "old PTY output") }); + core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/restore.zon", .{tmp.sub_path}) }); + try std.testing.expect(restoreCore(&st)); + try std.testing.expect(st.core != core); + try std.testing.expect(st.core.panes[0].?.serial > old_serial); + try std.testing.expectEqualStrings("saved body\n", st.core.panes[0].?.file.?.content); + try std.testing.expectEqualStrings("host configuration", marker); + try std.testing.expectEqual(@as(usize, 0), st.frame_len); + try std.testing.expect(!st.rotate_coasting); + try std.testing.expectEqual(@as(u32, 24), st.gens[0]); + try std.testing.expect(!st.file_watches.notify(0, old_watch)); + try std.testing.expect(st.file_watches.generations[0] > old_watch); + try std.testing.expectEqual(@as(usize, 0), st.inbox.len); + try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); + try std.testing.expect(st.lsp_task == null); + try std.testing.expect(!drainInbox(&st)); + try std.testing.expect(!st.cwd_stale[0]); + + st.core.lspRequest(0, .status, ""); + const new_request = st.core.lsp_wait.?.id; + try std.testing.expect(new_request > old_request); + st.lsp_task = .{ .id = new_request, .future = .{ .any_future = null, .result = {} } }; + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = new_request, .rows = &.{} } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.lsp_task == null); + try std.testing.expect(st.core.lsp_wait == null); + + const pane = st.core.panes[0].?; + pane.cur_col = 4; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + st.core.update(.{ .key = .{ .cp = '|' } }); + st.core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + st.core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = st.core.pipe_wait.?.id; + try std.testing.expect(st.pipe_tasks.add(.{ .id = pipe_id, .future = .{ .any_future = null, .result = {} } })); + const output = try gpa.dupe(u8, "FRESH"); + const outputs = gpa.dupe([]u8, &.{output}) catch |err| { + gpa.free(output); + return err; + }; + st.inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = pipe_id, .success = true, .outputs = outputs } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.core.pipe_wait == null); + try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); + try std.testing.expectEqualStrings("FRESH body\n", pane.file.?.content); +} + +test "mac Restore cancels a PTY reader waiting for output capacity" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var st: State = .{ + .gpa = gpa, + .threaded = undefined, + .io = io, + .core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }), + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + }; + defer st.core.deinit(); + defer st.config_arena.deinit(); + defer st.file_watches.deinit(gpa); + defer st.inbox.close(gpa, io); + try st.core.dumpState(); + try tmp.dir.writeFile(io, .{ .sub_path = "restore.zon", .data = st.core.dump_out.? }); + while (st.core.nextEffect()) |_| {} + for (0..inbox_output_limit) |_| try st.inbox.pushOutput(gpa, io, .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "retained output"), + }); + const child = try host_io.forkShell(null, 0, &st.prompt_rcs, "/bin/sh", "", 12, 40, null); + st.gens[0] = 1; + st.ptys[0] = .{ .file = child.file, .pid = child.pid, .gen = 1, .reader = .{ .any_future = null, .result = {} } }; + defer { + if (st.ptys[0]) |pt| if (pt.pid == child.pid) reap(&st, 0); + if (libc.waitpid(child.pid, null, posix.W.NOHANG) == 0) { + _ = libc.kill(child.pid, libc.SIG.KILL); + _ = libc.waitpid(child.pid, null, 0); + } + } + startReader(&st, &st.ptys[0].?, 0); + try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); + for (0..1000) |_| { + if (st.inbox.space.state.load(.acquire).waiters != 0) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expectEqual(@as(u16, 1), st.inbox.space.state.load(.acquire).waiters); + var command: [512]u8 = undefined; + st.core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/restore.zon", .{tmp.sub_path}) }); + try std.testing.expect(restoreCore(&st)); + try std.testing.expect(st.ptys[0] == null); + try std.testing.expectEqual(@as(usize, 0), st.inbox.len); + try std.testing.expectEqual(@as(u16, 0), st.inbox.space.state.load(.acquire).waiters); + try st.inbox.pushOutput(gpa, io, .{ .pane = 0, .gen = st.gens[0], .bytes = try gpa.dupe(u8, "fresh output") }); + var batch = st.inbox.take(io); + defer for (batch.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqualStrings("fresh output", batch.items[0].output.bytes); +} + export fn pardes_animation_tick() bool { const st = &(state orelse return false); - // MEASURED elapsed time, not one assumed frame. The scheduler re-arms only - // after the previous frame's tick, drain and draw have finished, so on the - // fallback clock the callbacks land slower than 60 Hz and unevenly. - // Counting each as one frame made every animation run slow AND stutter; - // spending real time makes cadence a question of smoothness only, and no - // longer a question of speed. const now: u64 = @intCast(@max(0, monotonicNs())); const spend = spendTickTime(st.last_tick_ns, now, st.tick_bank_ns); st.last_tick_ns = now; @@ -1487,15 +1433,10 @@ export fn pardes_animation_tick() bool { var changed = false; if (currentSceneFlags(st) != 0) { - // Shader time is wall-clock seconds, so a scene effect runs at the same - // rate whatever the callback cadence turns out to be. advanceSceneClock(st, spend.elapsed_ns); changed = true; } - // The core's transitions and the dial's coast are FIXED-STEP: they count - // frames. The banked time is spent in whole steps, so a late callback - // advances two frames rather than stretching one over 32 ms. for (0..spend.steps) |_| { if (st.core.animationActive()) { st.core.update(.tick); @@ -1507,17 +1448,11 @@ export fn pardes_animation_tick() bool { if (@abs(st.rotate_velocity) < rotation_fling_stop) { st.rotate_velocity = 0; st.rotate_coasting = false; - // The remainder dies with the gesture: a banked half-notch - // surviving into the next twist is the hysteresis `rotate 0` - // exists to clear. st.rotate_lag = 0; } changed = true; } } - // Nothing is animating any more: drop the banked remainder so the next run - // starts on a whole step instead of jumping however far this one stopped - // short, and forget the stamp so its first dt is not the idle gap. if (!changed) { st.tick_bank_ns = 0; st.last_tick_ns = 0; @@ -1525,8 +1460,6 @@ export fn pardes_animation_tick() bool { return changed; } -// ---------------------------------------------------------------- events in - export fn pardes_key(cp_arg: u32, text_ptr: ?[*]const u8, len: usize, mods: u32) void { const st = &(state orelse return); if (cp_arg > std.math.maxInt(u21)) return; @@ -1546,9 +1479,6 @@ export fn pardes_paste(text_ptr: ?[*]const u8, len: usize) void { st.core.update(.{ .paste = text }); } -/// Button and kind arrive as their boundary ordinals. An out-of-range value is -/// dropped rather than reaching an unchecked enum cast — same rule the browser -/// ABI keeps, for the same reason: the host is not part of this build. export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32) void { const st = &(state orelse return); const button: pardes.Mouse.Button = switch (button_arg) { @@ -1596,10 +1526,6 @@ export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) vo .row = row, } }); } - // Horizontal after vertical, and through the same quantizer: the core's - // own drift guard (config.wheelTick) is what decides whether a sideways - // wobble during a vertical flick counts, so the shell must not second-guess - // it by filtering here. var right_left = takeScrollTicks(&st.scroll_lag_x, delta_cols); while (right_left != 0) { const right = right_left > 0; @@ -1613,16 +1539,8 @@ export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) vo } } -/// Spend a trackpad rotation as search steps. AppKit reports degrees since the -/// last event, counterclockwise positive; the core has no rotation, so the -/// dial is quantized into the keys a hand would otherwise press — clockwise is -/// `n` (forward through the matches), counterclockwise `N`. export fn pardes_rotate(degrees: f32) void { const st = &(state orelse return); - // A gesture beginning re-zeros the dial: leftover travel from the last - // twist must not make the first degree of this one jump a match — and it - // catches a fling still coasting, because a finger back down is how a hand - // catches a dial. if (degrees == 0) { st.rotate_lag = 0; st.rotate_velocity = 0; @@ -1634,19 +1552,11 @@ export fn pardes_rotate(degrees: f32) void { spendRotation(st, degrees); } -/// The fingers lifted. What happens next is decided entirely by how fast they -/// were moving when they did: `rotationFling` subtracts the floor, so a slow -/// twist stops dead where it was put and a flick keeps going in proportion to -/// how hard it was thrown. export fn pardes_rotate_end() void { const st = &(state orelse return); const last = st.rotate_last_ns; st.rotate_last_ns = 0; st.rotate_coasting = false; - // A hand that turned the dial, STOPPED, and then lifted has released at - // rest however fast it was moving before — and the last sample is still - // sitting there saying otherwise. Without this the most deliberate twist - // of all (turn, look at it, let go) is the one that flings. if (last == 0 or monotonicNs() - last > 90 * std.time.ns_per_ms) { st.rotate_velocity = 0; return; @@ -1655,23 +1565,12 @@ export fn pardes_rotate_end() void { st.rotate_coasting = st.rotate_velocity != 0; } -/// Monotonic nanoseconds, the clock lsp_zls.zig already times with. Monotonic -/// and not REALTIME on purpose: a dial that flung because NTP stepped the wall -/// clock backwards would be a bug nobody ever reproduces. -/// -/// Zero on failure, which is also the "no sample yet" sentinel — so a clock -/// that will not answer makes the dial refuse to fling rather than fling on a -/// garbage dt. fn monotonicNs() i128 { var ts: libc.timespec = undefined; if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec; } -/// One event's contribution to the velocity estimate, in degrees per second. -/// Smoothed, because a single 120 Hz sample of a human wrist is mostly noise -/// and the fling would otherwise be decided by whichever one happened to land -/// last. fn noteRotationVelocity(st: *State, degrees: f32) void { const now = monotonicNs(); const last = st.rotate_last_ns; @@ -1679,8 +1578,6 @@ fn noteRotationVelocity(st: *State, degrees: f32) void { st.rotate_coasting = false; if (last == 0 or now == 0) return; const dt_ns = now - last; - // A gap this long is a gesture nobody announced the start of, not a slow - // one: dividing by it would report a crawl and eat a real fling. if (dt_ns <= 0 or dt_ns > 200 * std.time.ns_per_ms) return; const seconds: f32 = @floatCast(@as(f64, @floatFromInt(dt_ns)) / @as(f64, std.time.ns_per_s)); const sample = degrees / seconds; @@ -1688,9 +1585,6 @@ fn noteRotationVelocity(st: *State, degrees: f32) void { st.rotate_velocity = st.rotate_velocity * 0.35 + sample * 0.65; } -/// Turn degrees into whole search steps, keeping the remainder. The one place -/// the dial reaches the core, so a hand-turned notch and a coasted one are the -/// same keystroke by construction. fn spendRotation(st: *State, degrees: f32) void { var left = takeRotationNotches(&st.rotate_lag, degrees); while (left != 0) { @@ -1721,36 +1615,27 @@ export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16) } }); } -// ---------------------------------------------------------------- frame out - -/// Render one frame, and the only place this host renders: AppKit's draw -/// callback, which is the one call it coalesces. A burst of input or pty output -/// marks the view dirty many times and is drawn once, so however much work the -/// ticks above drained, the grid is encoded once per display refresh. -/// -/// It is the core's whole loop iteration — drain, perform, poll, render, -/// present — and it cannot block: `wait_input` is null, because AppKit -/// delivered the events before it called us and sleeping inside a run-loop -/// callback is a beachball. `present` copies the result into the flat buffers -/// the accessors below describe (presentFrame); returns their cell count, or 0 -/// if the render failed. export fn pardes_frame() u32 { const st = &(state orelse return 0); - // The macOS host had NO zones at all, so every capture attributed its whole - // frame to the core. This is the boundary the AppKit `draw(_:)` calls into. const tz = tracy.zone(@src(), "pardes_frame"); defer tz.end(); + _ = restoreCore(st); st.core.pump(hostFor(st)) catch |err| { log.err("render failed: {t}", .{err}); clearFrame(st); return 0; }; + if (restoreCore(st)) { + st.core.pump(hostFor(st)) catch |err| { + log.err("render failed: {t}", .{err}); + clearFrame(st); + return 0; + }; + } tracy.frameMark(); return @intCast(st.frame_len); } -/// Everything the accessors below describe is emptied together, so a failure -/// can never leave last frame's buffer behind a fresh cols/rows. fn clearFrame(st: *State) void { st.frame_len = 0; st.frame_cols = 0; @@ -1760,9 +1645,6 @@ fn clearFrame(st: *State) void { st.panel_diff_len = 0; } -/// Copy one rendered frame into the flat buffers the native renderer reads. -/// Core-owned Cell layout is never borrowed across the ABI, so the grid, the -/// panel diff, the attachments and the tracks are all encoded here. fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const st = hostState(ctx); const tz = tracy.zone(@src(), "presentFrame"); @@ -1785,9 +1667,6 @@ fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { st.frame_cols = surface.cols; st.frame_rows = surface.rows; { - // One encode per cell, every frame, whether or not the cell changed. - // If this is the hot zone the answer is a dirty-range copy, not a - // faster encodeCell. const tz_cells = tracy.zone(@src(), "encodeCells"); defer tz_cells.end(); for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell); @@ -1797,15 +1676,6 @@ fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { collectPanelTracks(st, surface); } -/// Flatten tracks into the C-visible array the shader composites from. -/// -/// A plain copy, and that is the point. This used to re-sort by phase into -/// moving/opening/closing — which is EXACTLY the order `Pardes.render` already -/// publishes them in ("Moving panes first, then new panes, then inert closing -/// tombstones on top", src/pardes.zig), and it re-filtered `active()` the core -/// had already filtered. A second ordering rule that happens to agree is not -/// free: it is the thing that silently stops agreeing. The core's order is the -/// contract; every host receives the same dense record set. fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void { const source = surface.panelTracks(); const len = @min(source.len, st.panel_tracks.len); @@ -1813,9 +1683,6 @@ fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void { st.panel_tracks_len = len; } -/// Copy the old/new semantic transition data as one all-or-nothing snapshot. -/// A missing allocation disables the optional diff for this frame; it never -/// leaves a previous grid paired with a mask from another render. fn collectPanelDiff(st: *State, surface: *const pardes.Surface, count: usize) void { if (!surface.hasPanelDiff() or count == 0) return; if (st.previous_cells.len != count) { @@ -1843,11 +1710,6 @@ fn encodeChanged(diff: pardes.PanelCellDiff) u8 { return if (diff.changed()) 255 else 0; } -/// Flatten Surface.images into the flat C array the host walks. -/// -/// A dropped attachment is a page that does not draw, never a wrong one, so -/// every failure here just stops collecting: the frame is still valid, it -/// simply has fewer pictures in it than the core offered. fn collectImages(st: *State, surface: *const pardes.Surface) void { if (comptime !pardes.pdf_enabled) return; if (surface.nimages == 0) return; @@ -1861,10 +1723,6 @@ fn collectImages(st: *State, surface: *const pardes.Surface) void { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (place.iw == 0 or place.ih == 0 or place.rgba.len == 0) continue; - // Continuous documents hand over geometry the core already clipped to - // the viewport. Anything else (a static image pane) is the whole - // raster scaled into the whole body, which is the same two rectangles - // spelled without a crop. const geometry = place.native.geometry orelse image.NativeGeometry{ .src = .{ .x = 0, .y = 0, .w = @intCast(place.iw), .h = @intCast(place.ih) }, .dst = .{ @@ -1921,10 +1779,6 @@ export fn pardes_frame_panel_track_list() ?[*]const PanelTrack { return if (st.panel_tracks_len == 0) null else st.panel_tracks[0..].ptr; } -/// AppKit calls this only after its destination context has accepted the -/// frame. The boolean keeps the ABI POD-only: animated presentation uses the -/// borrowed records from `pardes_frame`, while a direct fallback commits the -/// canonical grid with an empty snapshot. export fn pardes_frame_presented(animated_panels: bool) bool { const st = &(state orelse return false); const was_animating = st.core.animationActive(); @@ -1981,9 +1835,6 @@ export fn pardes_cursor_bar() bool { return if (st.core.surface.cursor) |c| c.bar else false; } -/// The acme verb the core last performed, and clears it. Ordinals, not the -/// enum: the host is not part of this build, so the boundary speaks integers -/// and the ABI guard asserts they are the ones the header names. export fn pardes_take_haptic() c_int { const st = &(state orelse return 0); return switch (st.core.takeHaptic()) { @@ -1993,13 +1844,6 @@ export fn pardes_take_haptic() c_int { }; } -/// The file the `Font` builtin asked for, and clears it — the same take-once -/// shape as the haptic above, and the same one the SDL shell uses on this -/// exact variable. -/// -/// A copy rather than the borrowed State slice: C wants a terminator. One -/// static buffer because there is one core and the header promises the value -/// only until the next call. var font_path_z: [4096:0]u8 = undefined; export fn pardes_font_take() ?[*:0]const u8 { @@ -2012,8 +1856,6 @@ export fn pardes_font_take() ?[*:0]const u8 { return &font_path_z; } -/// Observe the face already on screen without resolving an unrelated Font -/// request. Initial state, host-only zoom and display-scale changes use this. export fn pardes_font_observe( effective_name: ?[*]const u8, len: usize, @@ -2025,7 +1867,6 @@ export fn pardes_font_observe( return st.core.observeFont(ptr[0..len], point_hundredths, .points); } -/// Commit what CoreText accepted for the request returned by font_take. export fn pardes_font_ack( effective_name: ?[*]const u8, len: usize, @@ -2037,21 +1878,11 @@ export fn pardes_font_ack( return st.core.acknowledgeFont(ptr[0..len], point_hundredths, .points); } -/// Resolve a taken request which CoreText could not load without claiming the -/// fallback/previous face was the requested one. export fn pardes_font_reject() void { const st = &(state orelse return); st.core.rejectFont(); } -/// The FILE behind the focused pane, or null when there is none — a terminal, -/// an output buffer (`+Search` names a directory, not a document), or nothing -/// focused at all. A PDF and an image both count: they are real paths on disk, -/// and the titlebar's proxy icon is about the file, not about who can edit it. -/// -/// A copy into a static buffer for the reason pardes_font_take keeps one: the -/// core owns a length and no terminator, C wants a string, and there is one -/// core. Valid until the next call. var active_path_z: [4096:0]u8 = undefined; export fn pardes_active_path() ?[*:0]const u8 { @@ -2063,10 +1894,6 @@ export fn pardes_active_path() ?[*:0]const u8 { return &active_path_z; } -/// Does the focused pane hold edits that are not on disk? False for everything -/// that cannot be saved in the first place, which is the same set -/// pardes_active_path answers null for minus the PDFs and images — those have -/// a path but no buffer, so they are never dirty. export fn pardes_active_dirty() bool { const st = &(state orelse return false); const pane = st.core.panes[st.core.active] orelse return false; @@ -2083,78 +1910,48 @@ fn activeFilePath(st: *State) ?[]const u8 { return null; } -// ---------------------------------------------------------------- host seam - -/// What this host can do, for the core's own loop to call. What it deliberately -/// cannot: -/// * `wait_input` — AppKit delivered the events before it called us and owns -/// the sleep; blocking inside a run-loop callback is a beachball. -/// * `post_present` — presentation is acknowledged when the destination -/// context has accepted the frame (pardes_frame_presented), which is a -/// later callback, not the moment the cells were encoded. -/// * `pipe` — no worker to hand a job to yet, so a `|` filter does nothing -/// in this shell. Teardown is not a method at all: pardes_deinit is the -/// app's own call, made after AppKit's loop rather than from inside one. -/// -/// `lsp` USED to be on that list, and the entry claimed the core's empty answer -/// was "exactly what this host replied". It was not a considered trade: it -/// meant every language query in the shipped Mac app did nothing, silently, and -/// looked from the outside like a backend with no answer rather than a host -/// with no method. It is now `lspRequest` over the shared `lsp_host` worker. -/// -/// Watch is deliberately different again: FileWatcher.swift owns its -/// per-directory DispatchSource and only returns a debounced hint; these -/// main-thread methods own the bytes, hash and shared text/PDF core event. const vtable: pardes.Host.VTable = .{ - .push_present = presentFrame, - .push_poll_frame = refreshCwds, - .push_spawn = spawnShell, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .pull_lsp = lspRequest, - .pull_pipe = pipeRequest, + .present = presentFrame, + .poll_frame = refreshCwds, + .spawn = spawnShell, + .pty_write = ptyWrite, + .pty_resize = ptyResize, + .pty_signal = ptySignal, + .tty_taken = ttyTaken, + .write_file = writeFile, + .write_dump = writeDump, + .watch_file = watchFile, + .watch_theme = watchTheme, + .dump_themes = dumpThemes, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, + .lsp = lspRequest, + .pipe = pipeRequest, }; fn hostFor(st: *State) pardes.Host { return .{ .ctx = st, .vtable = &vtable }; } -/// Answer a language query off the main thread and post the rows back. The -/// snapshot and the worker body are `lsp_host`'s, shared with the tty and SDL -/// shells; what is left here is the only part that is actually this host's — -/// which allocator, and how a finished job reaches the main thread. -fn lspRequest(ctx: ?*anyopaque, req: host_api.LspRequest) void { +fn lspRequest(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const st = hostState(ctx); - const job = lsp_host.snapshot(st.gpa, st.core, req) orelse return; - // One in flight. Replacing it cancels the previous worker, which is right: - // the only answer anyone is waiting for is the one just asked for. + const job = host_io.Lsp.snapshot(st.gpa, st.core, req) catch |err| { + st.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return st.core.reportError(req.pane, "lsp", err); + }; if (st.lsp_task) |*old| { - old.cancel(st.io) catch {}; + old.future.cancel(st.io) catch {}; st.lsp_task = null; } - st.lsp_task = st.io.concurrent(lspWorker, .{ st, job }) catch { + const future = st.io.concurrent(lspWorker, .{ st, job }) catch |err| { job.free(st.gpa); - return; + st.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return st.core.reportError(req.pane, "lsp", err); }; + st.lsp_task = .{ .id = req.id, .future = future }; } -/// Run a `|` filter off the main thread. The job copy, the subprocess and the -/// response all belong to `selection_pipe`; what is here is this host's inbox -/// and its bounded in-flight table. -/// -/// This shell had no `pull_pipe` at all, so `pardes.zig` self-answered every -/// filter as failed — a `|` in the Mac app silently did nothing, the same shape -/// of gap `pull_lsp` was. fn pipeRequest(ctx: ?*anyopaque, id: u32) void { const st = hostState(ctx); if (st.pipe_tasks.full()) { @@ -2162,10 +1959,14 @@ fn pipeRequest(ctx: ?*anyopaque, id: u32) void { return; } const view = st.core.pipeRequest(id) orelse return; - const job = selection_pipe.Job.copy(st.gpa, view) catch return; - const future = st.io.concurrent(pipeWorker, .{ st, job }) catch { + const job = selection_pipe.Job.copy(st.gpa, view) catch |err| { + st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return st.core.reportError(st.core.active, "pipe", err); + }; + const future = st.io.concurrent(pipeWorker, .{ st, job }) catch |err| { job.deinit(st.gpa); - return; + st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return st.core.reportError(st.core.active, "pipe", err); }; std.debug.assert(st.pipe_tasks.add(.{ .id = id, .future = future })); } @@ -2173,28 +1974,24 @@ fn pipeRequest(ctx: ?*anyopaque, id: u32) void { fn pipeWorker(st: *State, job: *selection_pipe.Job) anyerror!void { defer job.deinit(st.gpa); const response = selection_pipe.runJob(st.gpa, st.io, job); - st.inbox.push(st.gpa, .{ .pipe = response }); + st.inbox.push(st.gpa, st.io, .{ .pipe = response }); wake(st); } -fn lspWorker(st: *State, job: *lsp_host.Job) anyerror!void { - lsp_host.work(st.gpa, job, st, deliverLspRows); +fn lspWorker(st: *State, job: *host_io.Lsp.Job) anyerror!void { + host_io.Lsp.work(st.gpa, job, st, deliverLspRows); } -fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: []u8) void { +fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const st: *State = @ptrCast(@alignCast(ctx orelse return)); - st.inbox.push(st.gpa, .{ .lsp_done = .{ .id = id, .rows = rows } }); + st.inbox.push(st.gpa, st.io, .{ .lsp_done = .{ .id = id, .rows = rows } }); wake(st); } -/// The registered `lsp.setStatusSink` target, called from the protocol client's -/// READER threads. Thread-safe and non-blocking only: a dupe and an inbox push, -/// which is lossy for this message kind by design — the sink's lock is held -/// around this call and server state is periodic news. fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const st: *State = @ptrCast(@alignCast(ctx orelse return)); const copy = st.gpa.dupe(u8, text) catch return; - st.inbox.push(st.gpa, .{ .lsp_status = copy }); + st.inbox.push(st.gpa, st.io, .{ .lsp_status = copy }); wake(st); } @@ -2205,35 +2002,19 @@ fn hostState(ctx: ?*anyopaque) *State { fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const st = hostState(ctx); const core = st.core; - // The core reuses pane ids and has no close effect, so a deleted pane's - // shell lives in its slot until a respawn lands here. Reap it: cancel joins - // the reader, and the generation bump makes its late bytes and eof - // unreadable. reap(st, pane); st.gens[pane] +%= 1; const gen = st.gens[pane]; - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - // <= because writing the sentinel slot of a [N:0]u8 is legal, and Effect's - // cwd buffer is exactly 256: `<` would silently drop a maximal path and - // start the shell wherever the app bundle was launched from instead. - if (cwd.len > 0 and cwd.len <= cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w, null); + const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd, core.screen_h, core.screen_w, st.ninep) catch |err| return core.reportError(pane, "shell", err); st.ptys[pane] = .{ .file = child.file, .pid = child.pid, .gen = gen, .reader = .{ .any_future = null, .result = {} }, }; - // Report the pane's starting directory back to the core (tags); the slot - // needs no occupancy reset, nothing is remembered. var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd); + if (host_io.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd); if (st.started) if (st.ptys[pane]) |*pt| startReader(st, pt, pane); } @@ -2249,37 +2030,22 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } -/// `pty/ctl`'s `sig`. Unlike `ttyTaken` above this is NOT degraded on darwin: -/// `tcgetpgrp` on the master and `kill` are both POSIX, and neither needs the -/// libproc descendant walk `look.ttyTaken` is still waiting for. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const st = hostState(ctx); - if (st.ptys[pane]) |pt| look.signalTty(pt.pid, pt.file.handle, sig); + if (st.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig); } -/// Asked only where a command line is about to be typed: is a program holding -/// this pane's tty instead of the prompt we forked? `look.ttyTaken` answers -/// `false` on darwin until it grows a libproc implementation, so this host -/// behaves exactly as it did — the wiring is here so it cannot rot, and it -/// costs nothing until then. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const st = hostState(ctx); const pt = st.ptys[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.file.handle); + return host_io.ttyTaken(pt.pid, pt.file.handle); } -/// A file pane's save and a scrollback's both land here; the core has already -/// resolved which path and which bytes. fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const st = hostState(ctx); - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(st.core, path, bytes) catch |err| return st.core.saveFailed(pane, "save", err); - // The directory source will observe our own close. Move its baseline first - // so that notification is a hash no-op instead of manufacturing an external - // reload and undo boundary. st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes)); - // After the write, not beside it: every early return above is a save that - // did not happen and must not be reported as one. var mbuf: [256]u8 = undefined; st.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -2288,20 +2054,18 @@ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const st = hostState(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return st.core.reportError(0, "dump", err); + filesystem.write(st.core, path, bytes) catch |err| return st.core.reportError(0, "dump", err); st.core.setLastDump(path); } -fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void { +fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool, mode: pardes.WatchMode) void { const st = hostState(ctx); - // No path is a pane with nothing on disk to watch (an output buffer, an - // image), which is the same answer as being turned off. if (!on or path.len == 0) { const generation = st.file_watches.stop(st.gpa, pane); hostWatchFile(pane, generation, null, 0); return; } - setFileWatch(st, pane, path); + setFileWatch(st, pane, path, mode); } fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { @@ -2312,7 +2076,7 @@ fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const st = hostState(ctx); const config_dir = st.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = pardes.config.User.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| { st.core.reportError(pane, "dump themes", err); return; }; @@ -2327,11 +2091,6 @@ fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { cb(st.runtime.userdata, text.ptr, text.len); } -/// The host answers with pardes_paste, which the AppDelegate calls straight -/// back inside this call: NSPasteboard reads are synchronous, so the paste -/// event lands mid-pump. That is safe and deliberate — pardes_paste only feeds -/// core.update, and whatever that queues is picked up by the same effect loop -/// rather than waiting a tick. A host with a null callback simply never pastes. fn readClipboard(ctx: ?*anyopaque) void { const st = hostState(ctx); const cb = st.runtime.read_clipboard orelse return; @@ -2342,39 +2101,21 @@ fn openLink(_: ?*anyopaque, url: []const u8) void { look.openLink(url); } -// ---------------------------------------------------------------- workers - fn startReader(st: *State, pt: *Pty, id: u8) void { pt.reader = st.io.concurrent(readPty, .{ st, st.io, pt.file, id, pt.gen }) catch |err| { - // No reader means the shell fills its pty buffer, blocks in write(2) - // and the pane silently freezes. Nothing recovers it, so at least say - // so — this is what PARDES_LOG exists for. log.err("pane {d} has no reader ({t}); it will not show output", .{ id, err }); return; }; } -/// Release one pane's shell: join the reader, close the master, reap the child. -/// Order matters — cancel is what unblocks a task parked in read(2), and the fd -/// must not be closed under a live reader. Called on eof and again on a spawn -/// into the same slot, so it has to tolerate an empty slot. fn reap(st: *State, pane: u8) void { var pt = st.ptys[pane] orelse return; st.ptys[pane] = null; pt.reader.cancel(st.io) catch {}; _ = libc.close(pt.file.handle); - // A library inside an app that runs for hours cannot leave these: the tty - // shell gets away with never reaping because the process exits seconds - // later, but here it would be one zombie per shell ever opened. NOHANG - // because the child may still be dying and the UI thread must not wait for - // it; the next reap or process exit collects whatever is left. _ = libc.waitpid(pt.pid, null, posix.W.NOHANG); } -/// Drain one pty into its inbox and wake the host. The same shape as the tty -/// shell's reader, with the vaxis event queue replaced by a mutex and one -/// callback: do the blocking thing away from the loop, hand the bytes over, -/// leave the core a state machine that never waits. fn readPty(st: *State, io: std.Io, pty: std.Io.File, id: u8, gen: u32) anyerror!void { var read_buf: [0x10000]u8 = undefined; var reader = pty.readerStreaming(io, &read_buf); @@ -2383,29 +2124,20 @@ fn readPty(st: *State, io: std.Io, pty: std.Io.File, id: u8, gen: u32) anyerror! var vec = [_][]u8{&buf}; const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; - // Duped outside the lock on purpose — see Inbox. const bytes = st.gpa.dupe(u8, buf[0..n]) catch break; - st.inbox.push(st.gpa, .{ .output = .{ .pane = id, .gen = gen, .bytes = bytes } }); + st.inbox.pushOutput(st.gpa, io, .{ .pane = id, .gen = gen, .bytes = bytes }) catch break; wake(st); } - st.inbox.push(st.gpa, .{ .eof = .{ .pane = id, .gen = gen } }); + st.inbox.push(st.gpa, st.io, .{ .eof = .{ .pane = id, .gen = gen } }); wake(st); } -/// Ask the host for a tick, at most once per tick. `pardes_tick` clears the -/// flag before it drains, so a push that lands mid-drain still wakes and no -/// message can be left sitting in the inbox with nobody scheduled to read it. fn wake(st: *State) void { const cb = st.runtime.wakeup orelse return; if (st.inbox.wake_pending.swap(true, .acq_rel)) return; cb(st.runtime.userdata); } -// ---------------------------------------------------------------- helpers - -/// Rebuild the process environment as a Map, because a library never sees the -/// std.process.Init that main() gets one from. Only the config-path lookup -/// reads it, and the arena owns the copies for the life of the process. fn captureEnv(arena: std.mem.Allocator) ?std.process.Environ.Map { var map: std.process.Environ.Map = .init(arena); const environ = std.c.environ; @@ -2460,10 +2192,6 @@ fn encodeCellFlags(default: bool, role: pardes.FontRole) u8 { @as(u8, @intFromBool(role == .tagline)) * cell_flag_tagline; } -/// Spend accumulated sub-row travel as whole wheel notches, keeping the -/// remainder. The core has no fractional scroll — both other shells do this -/// too — and the clamp is so that an absurd delta (a momentum-phase kinetic -/// fling reported in points, a NaN) cannot spin the emit loop. fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 { if (!std.math.isFinite(delta_rows)) return 0; const next = std.math.clamp(lag.* + delta_rows, -256, 256); @@ -2473,55 +2201,20 @@ fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 { return whole; } -/// One search step per this many degrees of twist. Every notch is a jump to -/// another match, so it stays coarse enough that a thumb resettling cannot -/// walk the cursor across the file — but 20 degrees was more than a wrist -/// gives without thinking about it, and the dial felt stuck. Ten is still a -/// deliberate twist, and 36 steps to a full turn. const rotation_notch_degrees: f32 = 10; -/// Where momentum STARTS, in degrees per second — and it starts at zero. -/// -/// The fling is the release speed MINUS this, so a slow twist coasts not a -/// little but not at all, and the faster the flick the more there is. A plain -/// threshold would hand out two free notches the instant it was crossed, which -/// is the one thing a dial must not do: the same gesture, a hair quicker, -/// jumping twice as far is how a control stops feeling like a control. const rotation_fling_floor: f32 = 70; -/// ...and the ceiling on what is left after that subtraction. AppKit reports a -/// thousand degrees a second for one frame of a twitch, and this cap is what -/// decides how far the hardest possible flick throws the list: 400 deg/s is -/// about 111 degrees of coast, so eleven matches. Twenty read as the list -/// getting away from you. const rotation_fling_max: f32 = 400; -/// One pump of coasting. Fixed rather than measured: the host re-pumps at -/// ~60 Hz for exactly as long as pardes_animating says to, and a fixed step -/// makes one fling spend the same travel every time — which is what lets a -/// golden assert it instead of asserting the machine's timer jitter. const rotation_fling_step: f32 = 1.0 / 60.0; -/// Per-step decay. 0.94 at 60 Hz is a little over half a second of coast, the -/// same order as the trackpad's own inertial scrolling. const rotation_fling_decay: f32 = 0.94; -/// Below this the dial is at rest: one notch a second is not momentum, it is a -/// list still stepping long after the hand has moved on. const rotation_fling_stop: f32 = 18; -/// The velocity a release at `speed` degrees/second actually coasts at, after -/// the floor is subtracted and the remainder capped. Zero means the twist was -/// a placement, not a throw — which is most of them. -/// -/// Total travel follows from it and the decay as a geometric series: -/// `v * step / (1 - decay)`, i.e. about 0.28 degrees per degree/second. A -/// 200 deg/s release therefore coasts ~36 degrees, three or four notches. fn rotationFling(speed: f32) f32 { const excess = @min(@abs(speed) - rotation_fling_floor, rotation_fling_max); if (excess < rotation_fling_stop) return 0; return std.math.copysign(excess, speed); } -/// Spend accumulated rotation as whole search steps, keeping the remainder. -/// Same contract as takeScrollTicks, including the clamp: an absurd delta -/// spends a bounded number of notches instead of spinning the emit loop. fn takeRotationNotches(lag: *f32, degrees: f32) i32 { if (!std.math.isFinite(degrees)) return 0; const limit = rotation_notch_degrees * 64; @@ -2532,17 +2225,6 @@ fn takeRotationNotches(lag: *f32, degrees: f32) i32 { return whole; } -// ---------------------------------------------------------------- ABI guard - -// The header is hand-written, so nothing but a test keeps it honest. build.zig -// translate-C's src/macos/pardes.h into this test build and every constant and -// layout below is asserted against the Zig side — ghostty's trick, and the -// cheapest possible insurance against a silent ABI skew. -/// Compare one declaration's arity and scalar widths against the header's. -/// Not a type equality — translate-C spells pointers `[*c]` and mints its own -/// struct types, so nothing here would ever match exactly. Arity and width are -/// what actually break: a parameter added on one side only (which is how the -/// Swift host first got pardes_scroll wrong), or a u16 that became a u32. fn expectSameAbi(comptime C: type, comptime Z: type) !void { const c_fn = @typeInfo(C).@"fn"; const z_fn = @typeInfo(Z).@"fn"; @@ -2631,9 +2313,6 @@ test "pardes.h matches the Zig boundary" { field.name; try expectEqual(@offsetOf(c.pardes_panel_track_s, c_name), @offsetOf(PanelTrack, field.name)); } - // The attachment struct is a wide one and every field is read by hand on - // the Swift side, so its layout is checked at both ends rather than at the - // two that happen to be easy. try expectEqual(@sizeOf(c.pardes_image_s), @sizeOf(Image)); inline for (@typeInfo(Image).@"struct".fields) |field| try expectEqual(@offsetOf(c.pardes_image_s, field.name), @offsetOf(Image, field.name)); @@ -2647,23 +2326,22 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt); try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple); try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch); - try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(panel_animation.Phase.opening)); - try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(panel_animation.Phase.moving)); - try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(panel_animation.Phase.closing)); - try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(panel_animation.Transition.off)); - try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(panel_animation.Transition.slide)); - try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(panel_animation.Transition.zoom)); - try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(panel_animation.Transition.dissolve)); - try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(panel_animation.Transition.ascii)); - try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(panel_animation.Transition.vertical)); - try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(panel_animation.Transition.edges)); - try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(panel_animation.Transition.fall)); - try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(panel_animation.Transition.wave)); - try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(panel_animation.Transition.curtain)); - try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(panel_animation.Transition.scramble)); - try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(panel_animation.Transition.typewriter)); - - // Every key the host has a name for must be the codepoint the core reads. + try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(layout.Phase.opening)); + try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(layout.Phase.moving)); + try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(layout.Phase.closing)); + try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(layout.Transition.off)); + try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(layout.Transition.slide)); + try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(layout.Transition.zoom)); + try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(layout.Transition.dissolve)); + try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(layout.Transition.ascii)); + try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(layout.Transition.vertical)); + try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(layout.Transition.edges)); + try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(layout.Transition.fall)); + try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(layout.Transition.wave)); + try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(layout.Transition.curtain)); + try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(layout.Transition.scramble)); + try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(layout.Transition.typewriter)); + try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter); try expectEqual(@as(u21, c.PARDES_KEY_ESCAPE), pardes.Key.escape); try expectEqual(@as(u21, c.PARDES_KEY_TAB), pardes.Key.tab); @@ -2678,8 +2356,6 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u21, c.PARDES_KEY_PAGE_DOWN), pardes.Key.page_down); try expectEqual(@as(u21, c.PARDES_KEY_DELETE), pardes.Key.delete); - // The mouse ordinals the switch in pardes_mouse decodes are the enum's own - // declaration order; a reorder there is a silent remap of acme's buttons. try expectEqual(c.PARDES_MOUSE_LEFT, @intFromEnum(pardes.Mouse.Button.left)); try expectEqual(c.PARDES_MOUSE_MIDDLE, @intFromEnum(pardes.Mouse.Button.middle)); try expectEqual(c.PARDES_MOUSE_RIGHT, @intFromEnum(pardes.Mouse.Button.right)); @@ -2693,13 +2369,10 @@ test "pardes.h matches the Zig boundary" { try expectEqual(c.PARDES_MOUSE_MOTION, @intFromEnum(pardes.Mouse.Kind.motion)); try expectEqual(c.PARDES_MOUSE_DRAG, @intFromEnum(pardes.Mouse.Kind.drag)); - // The haptic ordinals pardes_take_haptic returns, against the header's - // names and the core's enum. Three places, checked as one. try expectEqual(c.PARDES_HAPTIC_NONE, @intFromEnum(pardes.Haptic.none)); try expectEqual(c.PARDES_HAPTIC_EXEC, @intFromEnum(pardes.Haptic.exec)); try expectEqual(c.PARDES_HAPTIC_LOOK, @intFromEnum(pardes.Haptic.look)); - // The attribute bits the host decodes, against the encoder that writes them. try expectEqual(@as(u16, c.PARDES_ATTR_BOLD), encodeAttrs(.{ .bold = true })); try expectEqual(@as(u16, c.PARDES_ATTR_DIM), encodeAttrs(.{ .dim = true })); try expectEqual(@as(u16, c.PARDES_ATTR_ITALIC), encodeAttrs(.{ .italic = true })); @@ -2712,8 +2385,6 @@ test "pardes.h matches the Zig boundary" { encodeAttrs(.{ .ul = .curly }), ); - // Font role is explicit ABI data, not something the host reconstructs - // from tag colours. Default and role occupy independent bits. try expectEqual(@as(u8, 0), encodeCellFlags(false, .body)); try expectEqual(cell_flag_tagline, encodeCellFlags(false, .tagline)); try expectEqual(cell_flag_default | cell_flag_tagline, encodeCellFlags(true, .tagline)); @@ -2731,9 +2402,6 @@ test "scene effect flags and display clock are compact and independent" { encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }), ); - // The clock is TIME now, so the wrap is a duration and the assertion is - // that it wraps without losing the remainder — an f32 `time_seconds` that - // grew without bound would lose sub-millisecond resolution within a day. var st: State = undefined; st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5); advanceSceneClock(&st, std.time.ns_per_ms * 5); @@ -2743,10 +2411,6 @@ test "scene effect flags and display clock are compact and independent" { } test "the mac panel ABI hands the shader the core's order verbatim" { - // The host used to re-sort by phase here. It does not any more: the order - // is `panel_animation.paintOrder`, applied once in `Pardes.render`, and - // asserted where it lives (src/pardes.zig). What this host still owes is - // that it copies FAITHFULLY and cannot overrun its fixed ABI array. const source = [_]PanelTrack{ .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom }, .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve }, @@ -2796,19 +2460,15 @@ test "colors encode to the three tags the host decodes" { test "sub-row scroll spends whole notches and keeps the remainder" { const expectEqual = std.testing.expectEqual; var lag: f32 = 0; - // Four quarter-row flicks are one row, and not before the fourth. try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(i32, 1), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(f32, 0), lag); - // Direction reverses without the accumulated travel leaking across it. try expectEqual(@as(i32, -2), takeScrollTicks(&lag, -2.5)); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); - // Garbage moves nothing and leaves the accumulator usable; a fling far - // past the clamp spends at most one screen and does not spin the caller. lag = 0; try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.nan(f32))); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.inf(f32))); @@ -2819,23 +2479,16 @@ test "sub-row scroll spends whole notches and keeps the remainder" { test "trackpad rotation spends whole search steps and keeps the remainder" { const expectEqual = std.testing.expectEqual; var lag: f32 = 0; - // A twist under one notch moves nothing; crossing it moves exactly one, - // and the overshoot is credited to the next. try expectEqual(@as(i32, 0), takeRotationNotches(&lag, 7)); try expectEqual(@as(i32, 1), takeRotationNotches(&lag, 5)); try expectEqual(@as(f32, 2), lag); - // Reversing spends the residue first, so a twist back is not amplified by - // travel the other direction already banked. try expectEqual(@as(i32, -1), takeRotationNotches(&lag, -12)); try expectEqual(@as(f32, 0), lag); - // One deliberate half-turn is several matches, not a hundred. lag = 0; try expectEqual(@as(i32, 18), takeRotationNotches(&lag, 180)); - // Garbage moves nothing and leaves the dial usable; an absurd delta is - // clamped rather than spinning the emit loop. lag = 0; try expectEqual(@as(i32, 0), takeRotationNotches(&lag, std.math.nan(f32))); try expectEqual(@as(i32, 0), takeRotationNotches(&lag, -std.math.inf(f32))); @@ -2844,27 +2497,17 @@ test "trackpad rotation spends whole search steps and keeps the remainder" { } test "the dial flings in proportion to the release, and not at all when placed" { - // The whole point of the curve: momentum ramps UP FROM ZERO at the floor - // rather than switching on at it, so no release speed exists where the - // same gesture a hair quicker suddenly jumps several matches further. try std.testing.expectEqual(@as(f32, 0), rotationFling(0)); try std.testing.expectEqual(@as(f32, 0), rotationFling(40)); try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor)); - // Just over the floor is still nothing: what is left has to beat the - // at-rest threshold before it is worth waking the pump for. try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor + 5)); - // ...and past that it is linear in the release speed, both ways. try std.testing.expectEqual(@as(f32, 130), rotationFling(200)); try std.testing.expectEqual(@as(f32, -130), rotationFling(-200)); - // A twitch is capped rather than emptying the list. try std.testing.expectEqual(rotation_fling_max, rotationFling(100_000)); try std.testing.expectEqual(-rotation_fling_max, rotationFling(-100_000)); - // What that buys, in the units a hand feels: total coast is the geometric - // series v*step/(1-decay), so a brisk 200 deg/s release is a few matches - // and the hardest flick the cap allows is bounded well short of a hundred. const travel = struct { fn of(speed: f32) f32 { return @abs(rotationFling(speed)) * rotation_fling_step / (1 - rotation_fling_decay); @@ -2872,45 +2515,26 @@ test "the dial flings in proportion to the release, and not at all when placed" }.of; try std.testing.expect(travel(200) / rotation_notch_degrees < 5); try std.testing.expect(travel(200) / rotation_notch_degrees >= 3); - // ...and the hardest flick a trackpad can report is bounded at about a - // dozen matches. This is the number to change if the dial ever feels like - // it is getting away from the hand. try std.testing.expect(travel(100_000) / rotation_notch_degrees < 12); try std.testing.expect(travel(100_000) / rotation_notch_degrees > 8); } -// The loop, end to end, on the one machine that can run it: the core owns the -// iteration now, so the two things this file used to spell out by hand are -// exactly what a live session has to keep proving. A frame exists because the -// DRAW rendered one — ticks drain work and never render, which is what lets -// AppKit coalesce a burst into a single encoded grid — and elapsed animation -// time is spent only by the display clock, however many times the tick runs. -// -// It really boots: a shell is forked, an inbox drains, effects are performed -// through the vtable. Everything above it is the Swift app, which needs a Mac. test "a live session renders on the draw and animates only on the display clock" { try std.testing.expectEqual(@as(c_int, 0), pardes_init(null, 80, 24)); defer pardes_deinit(); const st = &state.?; - // The spawn effect reached forkpty rather than the core's silent fallback: - // init performs its own drain, before any reader task exists. try std.testing.expect(st.ptys[0] != null); - // A tick drains and performs. It publishes no frame, so ten of them in a - // pty burst cost one render and not ten. _ = pardes_tick(); _ = pardes_tick(); try std.testing.expectEqual(@as(u16, 0), pardes_frame_cols()); try std.testing.expect(pardes_frame_cells() == null); - // The draw is what renders and presents. try std.testing.expectEqual(@as(u32, 80 * 24), pardes_frame()); try std.testing.expectEqual(@as(u16, 80), pardes_frame_cols()); try std.testing.expectEqual(@as(u16, 24), pardes_frame_rows()); try std.testing.expect(pardes_frame_cells() != null); - // Two themes, so the second retarget is a real transition whatever the - // developer's config booted this session wearing. for ([_][]const u8{ "Theme dark", "Theme acme" }) |command| { pardes_command(command.ptr, command.len); _ = pardes_tick(); @@ -2918,16 +2542,12 @@ test "a live session renders on the draw and animates only on the display clock" } try std.testing.expect(pardes_animating()); const step = st.core.chrome_animation.step; - // Input and pty pumps drain work and draws encode it; neither spends a - // frame, which is what keeps a burst of keys from collapsing a ten-frame - // fade into one. _ = pardes_tick(); _ = pardes_frame(); _ = pardes_tick(); _ = pardes_frame(); try std.testing.expectEqual(step, st.core.chrome_animation.step); try std.testing.expectEqual(@as(usize, 0), st.core.in_len); - // Only the display clock spends it, and exactly one frame per call. try std.testing.expect(pardes_animation_tick()); try std.testing.expectEqual(step + 1, st.core.chrome_animation.step); _ = pardes_tick(); diff --git a/src/macos/build-e2e.sh b/src/macos/build-e2e.sh deleted file mode 100755 index e9ccebcd..00000000 --- a/src/macos/build-e2e.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/sh -# Link the offscreen end-to-end harness: the app's own Swift shell, plus -# test/macos_e2e.swift as the entry point, against the same libpardes.a. Run it -# through `zig build macos-e2e -Dplatform=macos`, or by hand with the install -# prefix as $1 and the deployment target as $2. -# -# A SECOND BINARY rather than a `--e2e` flag on the app, for two reasons. -# -# Test scaffolding does not ship inside the product. A flag would put the script -# interpreter, the /tmp world-builder and the golden differ into the thing a -# user launches, and would give the app a mode in which it rewrites files under -# /tmp and calls exit() — none of which anyone should be one argv typo away from. -# -# And src/macos/Sources/main.swift holds top-level code, which IS an entry -# point: a module cannot contain both top-level statements and a @main type, so -# the harness could not join that link even if the first reason went away. Every -# other Swift file the app builds from is compiled here, so this link is also -# what proves the shell still compiles as a library rather than as an app. -set -eu - -root=$(cd "$(dirname "$0")/../.." && pwd) -out=${1:-"$root/zig-out"} -# Keep in step with macos_min_version in build.zig, which passes it in. The -# default is only for a by-hand run. Same string the app's own link uses, and -# for the same reason: -target is what decides LC_BUILD_VERSION and turns on -# the availability diagnostics. -minver=${2:-13.0} -lib="$out/lib/libpardes.a" -bin="$out/bin/pardes-macos-e2e" - -[ -f "$lib" ] || { echo "missing $lib — run: zig build -Dplatform=macos" >&2; exit 1; } -command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; } - -mkdir -p "$out/bin" -# Bundle.main.resourceURL is the executable directory for this standalone -# harness. Put the same committed source there that the real app installs into -# Contents/Resources, so the shader path is exercised rather than bypassed. -cp "$root/shaders/crt.ci.metal" "$out/bin/crt.ci.metal" - -# -import-objc-header and -lc++ are the app link's, unchanged, and have to stay -# that way: this link exists to exercise the app's link, so anything that -# differs here is something the harness cannot vouch for. -# -# -O for the same reason. A debug build of the CoreText pass and the effect -# drain settles on different timings than a user sees, and `stable` waits on -# exactly those timings. -swiftc -O -target "$(uname -m)-apple-macos$minver" \ - -import-objc-header "$root/src/macos/pardes.h" \ - -o "$bin" \ - "$root/src/macos/Sources/PardesView.swift" \ - "$root/src/macos/Sources/ScenePostprocessor.swift" \ - "$root/src/macos/Sources/FileWatcher.swift" \ - "$root/src/macos/Sources/AppDelegate.swift" \ - "$root/test/macos_e2e.swift" \ - "$lib" -lc++ \ - -framework AppKit -framework CoreText -framework CoreGraphics \ - -framework CoreImage -framework Metal - -echo "built $bin" diff --git a/src/main.zig b/src/main.zig index b08a64da..e4aef61e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,15 +1,12 @@ const std = @import("std"); const builtin = @import("builtin"); const pardes = @import("pardes.zig"); -const nested = @import("nested.zig"); +const ninep_io = @import("9p_io.zig"); const is_emscripten = builtin.os.tag == .emscripten; extern "c" fn emscripten_console_error(utf8: [*:0]const u8) void; -// emscripten: std.debug's default threaded-io singleton doesn't run on wasm; -// fail it (init errors surface via emscripten_console_error below). Native -// values match the std defaults. Same shim as the prototype's replay.zig. pub const std_options_debug_threaded_io: ?*std.Io.Threaded = if (is_emscripten) null else @@ -19,18 +16,6 @@ pub const std_options_debug_io: std.Io = if (is_emscripten) else std_options_debug_threaded_io.?.io(); -// Every std.log call in the process — ours and every dependency's — funnels -// through this one function. ghostty-vt narrates whatever it does not -// implement in the bytes a child writes to its pty (`debug(stream)`, -// `warning(stream): ignoring unimplemented CSI p`, `debug(kitty_gfx)`; opening -// yazi is worth several lines before it has drawn anything), and in the tty -// shell stderr IS the screen — those land on top of the rendered grid, and in -// the gui/web shells on the console. So drop the libraries at every level: an -// `err` painted over the UI is no better than a debug one. Only pardes' own -// scopes get through, because their messages carry detail the error returns -// don't (gui's SDL_GetError strings, dump's zon parse diagnostic) — .default -// is NOT one of them, ghostty and uucode both log unscoped. Set PARDES_LOG to -// get the real logger back: `PARDES_LOG=1 pardes 2>/tmp/pardes.log`. pub const std_options: std.Options = .{ .logFn = logFn }; fn logFn( @@ -43,40 +28,16 @@ fn logFn( std.log.defaultLog(level, scope, format, args); } -// A panic must restore the terminal (cooked mode, main screen, mouse off) -// before the trace prints, or it lands garbled in a raw alt screen. recover() -// no-ops unless the vaxis tty is live, so gui/tty share the handler. -// -// Then the same message and trace are appended to `/crashes` -// BEFORE stderr gets them, because stderr is the one place this program cannot -// keep them: see crash.zig. Silent on every failure, so the fallback is -// exactly the behaviour that was here before. pub const panic = if (is_emscripten) std.debug.FullPanic(std.debug.defaultPanic) else std.debug.FullPanic(struct { fn call(msg: []const u8, ret_addr: ?usize) noreturn { - // ONCE. This handler is re-entered whenever something panics while it - // runs, and std's own trace printer does exactly that — `defaultPanic` - // survives its own recursion through a private `panic_stage`, and - // everything up here is in front of that guard. `recover()` is not - // idempotent: it closes the vaxis tty and never clears the global that - // says there is one, so a second call double-closes, which std answers - // with `recoverableOsBugDetected` and an `unreachable` in a Debug - // build. Measured with the crash file in place: one panic left TWO - // records, the real message and then "reached unreachable code" from - // this line under it. if (!recovering.swap(true, .seq_cst)) @import("vaxis").recover(); @import("crash.zig").record(msg); std.debug.defaultPanic(msg, ret_addr); } }.call); -/// Whether this process has already restored its terminal — see `panic` above, -/// and note that `debug.handleSegfault` below shares it: a SIGSEGV raised while -/// the panic handler runs must not double-close either. var recovering: std.atomic.Value(bool) = .init(false); -// Fatal signals (SIGSEGV/SIGILL/SIGBUS/SIGFPE) bypass the panic handler and -// no defer/errdefer ever runs — hook std.debug's segfault path the same way -// so the terminal is restored before the trace prints. pub const debug = if (is_emscripten) struct {} else struct { pub fn handleSegfault(addr: ?usize, name: []const u8, opt_ctx: anytype) noreturn { if (!recovering.swap(true, .seq_cst)) @import("vaxis").recover(); @@ -101,23 +62,18 @@ const help_text = \\ Without it, a pardes started inside a pardes \\ hands its FILE argument to the outer one. This \\ session will not serve its own children either. - \\ --fs serve acme's control filesystem for this session - \\ under $XDG_RUNTIME_DIR/pardes/, and export - \\ PARDES_FS and PARDES_PANE into every pane shell - \\ --fs= ...at instead. Must be absolute; pardes - \\ unmounts it on exit but leaves the directory - \\ --fs9 serve that same tree over 9P2000 on a unix socket - \\ at $XDG_RUNTIME_DIR/pardes-9p-.sock, where - \\ is the session name or this pid. Dial it - \\ with `9p -a ` or mount with `9pfuse`. - \\ Independent of --fs: either, both or neither - \\ --fs9= ...named rather than derived. One path - \\ component: no '/' and nothing empty. Served by - \\ --detach sessions today; the tty and GUI shells - \\ still take --fs only + \\ Explicit session configuration starts a new one. + \\ --9p= name the default 9P socket. Without this flag, + \\ the name is the detached session name or pid. + \\ The socket is under $XDG_RUNTIME_DIR, falling + \\ back to ~/.local/state/pardes. + \\ --9p-tcp= also serve 9P at tcp!!. + \\ --9p-quic= also serve at quic!! (-Dquic=true). + \\ --mount== mount a 9P session or Unix/TCP/QUIC endpoint under /n/. + \\ Repeat for more mounts; os and self are reserved. \\ --detach run this session with NO terminal of its own, - \\ serving frontends over a unix socket beside the - \\ nested-instance one. The core, the panes and the + \\ serving frontends over a unix socket beside its + \\ 9P socket. The core, the panes and the \\ undo history outlive every frontend that attaches \\ --detach= ...named rather than this process's pid, so \\ a frontend can say which session it wants. One @@ -136,18 +92,6 @@ const help_text = \\ ; -/// A MISTAKE AT THE SHELL PROMPT, said in words and nothing else. -/// -/// Every one of these used to be `return error.BadArgs` out of `main`, which -/// std prints as `error: BadArgs` with a RETURN TRACE under it. That reads as a -/// crash — it is the same shape a real panic has — for the most ordinary thing -/// a person can do, which is mistype a flag. It also said `BadArgs` and nothing -/// about WHICH argument, when the site that returned it knew exactly. -/// -/// stderr and not an `+Errors` pane, which is the answer one line down in -/// `Pardes.init`: argv is read before any core exists, and a person who typed -/// a bad flag is looking at the prompt they typed it into rather than at an -/// editor. `--attach`'s refusal three functions down already answers this way. fn badArgs(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { var buf: [1024]u8 = undefined; const line = std.fmt.bufPrint(&buf, "pardes: " ++ fmt ++ "\nTry 'pardes --help'.\n", args) catch @@ -156,12 +100,6 @@ fn badArgs(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { std.process.exit(1); } -/// Built at COMPTIME, because both halves are: `version` comes out of -/// `build.zig.zon` through the options module and `commit` out of `git` at -/// configure time, so there is nothing here to format at runtime and no buffer -/// to size. The commit is in parentheses when there is one and absent -/// otherwise — a build from a tarball says `pardes 0.0.1` and is not lying -/// about a revision it never had. See `pardes.version`/`pardes.commit`. const version_text = if (pardes.commit) |c| "pardes " ++ pardes.version ++ " (" ++ c ++ ")\n" else @@ -201,116 +139,108 @@ fn webMain() !void { } fn nativeMain(init: std.process.Init) !void { - // FIRST, before anything can log or panic. std's start code hands the - // debug/log stderr writer the process environ exactly as the kernel laid - // it out beside argv, and `std.debug.lockStderr` scans that block once, - // lazily, for NO_COLOR and CLICOLOR_FORCE. Lazily is the problem: libc - // may have rewritten the block by then. SDL_CreateWindow does, every - // time — `Wayland_ShowWindow` unsets XDG_ACTIVATION_TOKEN, and glibc's - // unsetenv compacts `environ` in place and leaves a null in the slot the - // captured length still counts. `Environ.scan` unwraps that null, so the - // FIRST log line or panic in the gui shell panicked inside the scan, and - // then the panic handler took the same path and deadlocked on the stderr - // lock it was already holding: "attempt to use null value" and a hang, - // with no trace and no message of its own. Every SDL_GetError report in - // gui.zig was that, which is to say unreachable. - // - // Locking and immediately unlocking here does the scan while the block is - // still the one std was given, and memoizes it. Nothing is written. var stderr_probe: [64]u8 = undefined; _ = std.debug.lockStderr(&stderr_probe); std.debug.unlockStderr(); var opts: pardes.Options = .{}; + var mounts: [pardes.filesystem.max_mounts]pardes.filesystem.Mount = undefined; + var mounts_len: usize = 0; const arena = init.arena.allocator(); const args = try init.minimal.args.toSlice(arena); - // bare `pardes` boots straight into tty mode — and so does a `pardes` - // carrying nothing but flags that say something about the SESSION rather - // than about its layout: --nested is about this session's relationship to - // its parent, --fs is about who may script it, --detach is about who may - // WATCH it, --attach is about whose screen this one is showing, and none of - // the four says anything about what should be on screen. Anything else (a - // FILE, -n, --tty) is layout, and answers this question itself further - // down. - opts.tty_only = for (args[1..]) |a| { - if (!std.mem.eql(u8, a, "--nested") and - !std.mem.eql(u8, a, "--fs") and - !std.mem.startsWith(u8, a, "--fs=") and - !std.mem.eql(u8, a, "--fs9") and - !std.mem.startsWith(u8, a, "--fs9=") and - !std.mem.eql(u8, a, "--detach") and - !std.mem.startsWith(u8, a, "--detach=") and - !std.mem.eql(u8, a, "--attach") and - !std.mem.startsWith(u8, a, "--attach=")) break false; - } else true; - // `--detach[=]`: null when it was not given, so the empty string is - // free to mean "the default name" the way opts.fs uses it for a directory. + var session_only = true; + var new_session = false; + var explicit_tty = false; var detach: ?[]const u8 = null; - // ...and `--attach[=]`, the same shape: null when it was not given, - // and the empty string means "the one session there is" - // (detached_client.resolve) rather than a session with no name. var attach: ?[]const u8 = null; - // Kept RAW until every flag is parsed: classifying it means chdir'ing into - // a directory and recording nothing, and the nested client below still - // needs the word itself to resolve. var positional: ?[:0]const u8 = null; var i: usize = 1; while (i < args.len) : (i += 1) { const a = args[i]; if (std.mem.eql(u8, a, "--tty")) { - opts.tty_only = true; + explicit_tty = true; } else if (std.mem.startsWith(u8, a, "--tty-toggle=")) { + session_only = false; + new_session = true; opts.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse badArgs(init.io, "--tty-toggle wants one letter, not '{s}'", .{a["--tty-toggle=".len..]}); } else if (std.mem.eql(u8, a, "--tty-toggle")) { + session_only = false; + new_session = true; i += 1; if (i >= args.len) badArgs(init.io, "--tty-toggle needs a letter after it", .{}); opts.tty_toggle = parseCtrlKey(args[i]) orelse badArgs(init.io, "--tty-toggle wants one letter, not '{s}'", .{args[i]}); } else if (std.mem.eql(u8, a, "-n")) { + session_only = false; + new_session = true; i += 1; if (i >= args.len) badArgs(init.io, "-n needs a count after it: 1 or 3", .{}); opts.shells = std.fmt.parseInt(u8, args[i], 10) catch badArgs(init.io, "-n takes 1 or 3, not '{s}'", .{args[i]}); } else if (std.mem.eql(u8, a, "-l")) { + session_only = false; + new_session = true; i += 1; if (i >= args.len) badArgs(init.io, "-l needs the path of a dump to load", .{}); opts.load_path = args[i]; - } else if (std.mem.eql(u8, a, "--fs")) { - opts.fs = ""; - } else if (std.mem.startsWith(u8, a, "--fs=")) { - // `--fs=` and NEVER `--fs `, which is the one place this - // parser cannot follow --tty-toggle: --tty-toggle's argument is - // mandatory, so consuming the next word is unambiguous. `--fs` is - // useful bare, so a two-word form would make `pardes --fs README` - // mount at ./README and open no file — the flag would silently eat - // the FILE argument. One spelling, and it carries its own value. - opts.fs = a["--fs=".len..]; - } else if (std.mem.eql(u8, a, "--fs9")) { - opts.fs9 = ""; - } else if (std.mem.startsWith(u8, a, "--fs9=")) { - // One spelling that carries its own value, for the reason `--fs` - // gives directly above: the flag is useful bare, so a two-word - // form would make `pardes --fs9 README` a socket called README - // that opens no file. - opts.fs9 = a["--fs9=".len..]; + } else if (std.mem.eql(u8, a, "--9p")) { + badArgs(init.io, "--9p needs a socket name: --9p=", .{}); + } else if (std.mem.startsWith(u8, a, "--9p=")) { + new_session = true; + const name = a["--9p=".len..]; + if (name.len == 0 or std.mem.indexOfAny(u8, name, "/\x00") != null) + badArgs(init.io, "invalid 9P socket name: '{s}'", .{name}); + opts.ninep_name = name; + } else if (std.mem.startsWith(u8, a, "--9p-tcp=")) { + new_session = true; + const dial = a["--9p-tcp=".len..]; + if (!std.mem.startsWith(u8, dial, "tcp!")) + badArgs(init.io, "--9p-tcp needs tcp!!", .{}); + _ = @import("9p_io.zig").networkAddress(dial, true) catch + badArgs(init.io, "--9p-tcp needs tcp!!", .{}); + if (opts.ninep_tcp != null) badArgs(init.io, "--9p-tcp was specified twice", .{}); + opts.ninep_tcp = dial; + } else if (std.mem.eql(u8, a, "--9p-tcp")) { + badArgs(init.io, "--9p-tcp needs tcp!!", .{}); + } else if (std.mem.startsWith(u8, a, "--9p-quic=")) { + new_session = true; + const dial = a["--9p-quic=".len..]; + if (!std.mem.startsWith(u8, dial, "quic!")) + badArgs(init.io, "--9p-quic needs quic!!", .{}); + _ = @import("9p_io.zig").networkAddress(dial, true) catch + badArgs(init.io, "--9p-quic needs quic!!", .{}); + if (opts.ninep_quic != null) badArgs(init.io, "--9p-quic was specified twice", .{}); + if (!@import("9p_io.zig").quic_enabled) + badArgs(init.io, "QUIC is not included; rebuild with -Dquic=true", .{}); + opts.ninep_quic = dial; + } else if (std.mem.eql(u8, a, "--9p-quic")) { + badArgs(init.io, "--9p-quic needs quic!!", .{}); + } else if (std.mem.startsWith(u8, a, "--mount=")) { + new_session = true; + const mount = a["--mount=".len..]; + const split = std.mem.indexOfScalar(u8, mount, '=') orelse + badArgs(init.io, "--mount needs name=dial", .{}); + const name = mount[0..split]; + const dial = mount[split + 1 ..]; + if (dial.len == 0 or !@import("fs.zig").validMountName(name)) + badArgs(init.io, "invalid mount name or dial: '{s}'", .{mount}); + @import("9p_io.zig").Client.validateDial(dial) catch + badArgs(init.io, "invalid mount dial: '{s}'", .{dial}); + for (mounts[0..mounts_len]) |existing| if (std.mem.eql(u8, existing.name, name)) + badArgs(init.io, "duplicate mount name: '{s}'", .{name}); + if (mounts_len == mounts.len) badArgs(init.io, "too many mounts (maximum {d})", .{mounts.len}); + mounts[mounts_len] = .{ .name = name, .dial = dial }; + mounts_len += 1; } else if (std.mem.eql(u8, a, "--nested")) { opts.nested = true; } else if (std.mem.eql(u8, a, "--detach")) { detach = ""; } else if (std.mem.startsWith(u8, a, "--detach=")) { - // `--detach=` and never `--detach `, for exactly the - // reason --fs gives above: the flag is useful bare, so a two-word - // form would make `pardes --detach README` a session called README - // that opens no file. detach = a["--detach=".len..]; } else if (std.mem.eql(u8, a, "--attach")) { attach = ""; } else if (std.mem.startsWith(u8, a, "--attach=")) { - // `--attach=` and never `--attach `, for the reason - // --fs states above and --detach repeats: the flag is useful bare, - // so a two-word form would make `pardes --attach README` an attach - // to a session called README that opens no file. attach = a["--attach=".len..]; } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { try std.Io.File.stdout().writeStreamingAll(init.io, help_text); @@ -319,6 +249,7 @@ fn nativeMain(init: std.process.Init) !void { try std.Io.File.stdout().writeStreamingAll(init.io, version_text); return; } else if (a.len > 0 and a[0] != '-' and positional == null) { + session_only = false; positional = a; } else if (a.len == 0) { badArgs(init.io, "an empty argument names nothing", .{}); @@ -328,170 +259,86 @@ fn nativeMain(init: std.process.Init) !void { badArgs(init.io, "one file or directory at a time, and '{s}' is the second", .{a}); } } - // Started INSIDE another pardes: hand it the file and get out of the way - // rather than stacking a second full-screen UI inside one of its panes. - // The word is resolved here rather than sent raw because the outer - // instance resolves against ITS panes' directories, which are not ours. - // A word naming nothing on disk is REFUSED HERE, in this shell, and does - // not fall through: the classification below used to refuse it too, and - // once it started booting an `+Errors` pane instead, a typo became the one - // input that stacked the second full-screen UI this whole block exists to - // prevent — and one with no shell pane in it, so the only way out is `Del`. - // The outer instance is not told either: `Look` on a word naming nothing - // is not something to do to somebody else's session. - // - // `--detach` is exempt for the same reason `--nested` is, arrived at from - // the other side: it stacks no UI at all. A detached session started from a - // pane is a session, not a request that the outer instance open something, - // and handing it our positional would leave the caller with no session. - // - // `--attach` is exempt for the mirror of that: it stacks a UI, but the UI - // is a session that already exists somewhere else, and handing our word to - // the outer instance would open the file in the WRONG session and leave - // the caller with no frontend. - if (!opts.nested and detach == null and attach == null) if (nested.outer()) |outer_pid| { + opts.tty_only = explicit_tty or session_only; + opts.mounts = mounts[0..mounts_len]; + if (detach != null and attach != null) + badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); + if (opts.ninep_name.len != 0 and attach != null) + badArgs(init.io, "--9p names a session's own socket, and --attach has none of its own", .{}); + if (opts.ninep_tcp != null and attach != null) + badArgs(init.io, "--9p-tcp opens a session's own listener, and --attach has none of its own", .{}); + if (opts.ninep_quic != null and attach != null) + badArgs(init.io, "--9p-quic opens a session's own listener, and --attach has none of its own", .{}); + if (opts.mounts.len != 0 and attach != null) + badArgs(init.io, "--mount configures a session's own core, and --attach has none of its own", .{}); + if (!new_session and !opts.nested and detach == null and attach == null) forwarding: { + const enabled = std.c.getenv("PARDES_FORWARD_LOOK") orelse break :forwarding; + if (!std.mem.eql(u8, std.mem.span(enabled), "1")) break :forwarding; + const dial = std.mem.span(std.c.getenv("PARDES_9P") orelse break :forwarding); + ninep_io.Client.validateDial(dial) catch break :forwarding; + const pane_text = std.mem.span(std.c.getenv("PARDES_PANE") orelse break :forwarding); + for (pane_text) |byte| if (!std.ascii.isDigit(byte)) break :forwarding; + const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :forwarding; + if (serial == 0) break :forwarding; + var ctl_buf: [64]u8 = undefined; + const ctl = try std.fmt.bufPrint(&ctl_buf, "/self/pane/{d}/ctl", .{serial}); const word = positional orelse { + var tag_buf: [64]u8 = undefined; + const tag = try std.fmt.bufPrint(&tag_buf, "/self/pane/{d}/tag", .{serial}); + const contents = ninep_io.Client.read(arena, dial, tag, tag) catch break :forwarding; + arena.free(contents); try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); std.process.exit(1); }; - var cwdbuf: [4096]u8 = undefined; - const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse - badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{}); + if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding; + const target = @import("look.zig").parsePathLine(word); var realbuf: [4096]u8 = undefined; - const sent = switch (@import("look.zig").resolve(word, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { - .dir => |d| nested.sendLook(outer_pid, d, 0), - .file => |t| nested.sendLook(outer_pid, t.path, t.at.line), - .image => |t| nested.sendLook(outer_pid, t.path, 0), - // Nothing of that name. One line on this shell's stderr and out, - // which is what the paragraph above promises: the outer session is - // not disturbed and no UI is stacked. Said in words rather than - // returned as an error, because an error out of `main` is the - // stack trace this release stopped showing people for a typo. - .none => { - var buf: [4096]u8 = undefined; - const line = std.fmt.bufPrint(&buf, "pardes: file or directory not found: {s}\n", .{word}) catch "pardes: file or directory not found\n"; - try std.Io.File.stderr().writeStreamingAll(init.io, line); - std.process.exit(1); - }, - // an unreachable outer instance (an older build, a stale socket - // path) is not worth failing a launch over: run normally instead - else => false, - }; - if (sent) return; - }; + const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path; + var command_buf: [8192]u8 = undefined; + const command = std.fmt.bufPrint(&command_buf, "look {s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; + ninep_io.Client.write(arena, dial, ctl, command) catch break :forwarding; + return; + } if (positional) |a| { - // a directory becomes the cwd shells spawn in (chdir succeeds only on - // dirs); anything else resolves as a file - if (std.c.chdir(a.ptr) != 0) { + const target = @import("look.zig").parsePathLine(a); + if (std.mem.eql(u8, target.path, "/n") or std.mem.startsWith(u8, target.path, "/n/") or + std.mem.eql(u8, target.path, "/virtual") or std.mem.startsWith(u8, target.path, "/virtual/")) + { + opts.file = try arena.dupe(u8, target.path); + opts.file_line = target.at.line; + } else if (std.c.chdir(a.ptr) != 0) { var cwdbuf: [4096]u8 = undefined; const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse - badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{}); + badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{}); var realbuf: [4096]u8 = undefined; - switch (@import("look.zig").resolve(a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { + switch (@import("look.zig").resolve(null, a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { .file => |t| { opts.file = try arena.dupe(u8, t.path); opts.file_line = t.at.line; }, .image => |t| opts.file = try arena.dupe(u8, t.path), - // Nothing of that name is there. A typo is not a reason to - // refuse to start: the session boots with one `+Errors` pane - // naming what was asked for (pardes.zig `missing`). - // - // The LAUNCH DIRECTORY goes with it, and it is not decoration: - // an output pane's directory is where a `Grep` from it walks, - // where a `Newtty` spawns its shell and what a `Save` prefills. - // The first draft passed "" — copied from the board's boot - // buffer, which can afford it because that platform has no - // filesystem — and the pane came out at `/+Errors`, so `Grep` - // on the boot screen walked from the root of the filesystem. - // - // The other arms stay `BadArgs`. `.url` and `.pane` are targets - // no LAUNCH can act on, and `.dir` here is a directory that - // resolves but `chdir` refused, which is a permission problem - // rather than a typo. NOTE that the commonest permission case - // does not arrive here at all: `look.isDir` probes with - // `O_DIRECTORY|O_RDONLY`, so a directory you cannot read (say - // `/root`) fails that probe, resolves as `.file`, and dies in - // `file_pane.open` with `error.OpenFailed` out of `main` — - // still a stack trace at a human. Left as it was, because it is - // a different fault than the one this arm fixes. .none => opts.missing = .{ .word = try arena.dupe(u8, a), .dir = try arena.dupe(u8, std.mem.span(@as([*:0]u8, @ptrCast(cwd)))), }, - // A URL, an `@pN` pane address, or a directory that resolves - // and `chdir` refused. None is a typo, and none is something a - // LAUNCH can act on — the first two are words to click once - // pardes is open, and the third is a permission problem. .url => badArgs(init.io, "a URL is not something a launch can open; start pardes and click it", .{}), .pane => badArgs(init.io, "@pN addresses a pane of a running pardes, so there is none yet", .{}), else => badArgs(init.io, "cannot enter that directory: {s}", .{a}), } } } - // Native shells opt into the user config; direct core callers and web keep - // Options' null default. Read it before entering either frontend so every - // builtin has run before that frontend can render its first frame. - const found = @import("user_config.zig").load(init.io, arena, init.environ_map); + const found = pardes.config.User.load(init.io, arena, init.environ_map); opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; - // The panic handler above writes beside that init file, and this is the - // only place it can learn where that is — it runs with no `Options` in - // reach. Set for every native entry through this file, including the - // `--detach` daemon below, whose stderr nobody is reading. if (found.dir) |d| @import("crash.zig").setDir(d); - // `--detach` is the core with no terminal and `--attach` is a terminal - // with no core, so the two together are a contradiction with no useful - // reading. Refused rather than resolved by declaration order, which would - // silently drop whichever flag lost. - if (detach != null and attach != null) - badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); - // `--fs` mounts the acme control filesystem, and it needs a CORE to serve. - // `--attach` has none — it is a terminal whose state lives in another - // process — so the flag there would be parsed, stored, and served by - // nobody. Refused rather than dropped, and it is the stronger case of the - // line above: a contradiction is at least visible, whereas a silently - // dropped mount is invisible until someone waits for a directory that will - // never appear. - // - // `--detach` used to be refused here too, and is not any more. The reason - // given was that `push_fs_reply` was one of the host methods the detached - // core deliberately left null; it no longer is. A daemon mounts its own - // /dev/fuse and polls it in the same `poll(2)` as its frontends and its - // pane shells, which costs it one descriptor and no thread — strictly less - // than the desktop shells pay. `--detach --fs` is now the configuration - // that most wants a control filesystem, because it is the one whose panes - // outlive every terminal that could otherwise have scripted them. - if (opts.fs != null and attach != null) - badArgs(init.io, "--fs serves a session's own core, and --attach has none of its own", .{}); - // ...and `--fs9` for exactly that reason and no other: it is the same tree - // over a different transport, and an `--attach` has no core to serve it - // from either. Checked separately rather than folded into the line above - // so that neither flag's refusal is a side effect of the other's — they - // are independent everywhere else. - if (opts.fs9 != null and attach != null) - badArgs(init.io, "--fs9 serves a session's own core, and --attach has none of its own", .{}); - // `--detach` replaces the frontend rather than choosing among them: the - // core runs here, with no terminal, and the frontends are elsewhere on a - // socket (src/detached/). It is checked before `platform` because it is not - // a shell — the tty and gui builds can both be asked for one. if (detach) |name| { - // Bare `--detach` is named by this process's pid, which is the one name - // nobody has to be told and no two sessions can share. Unsigned: `{d}` - // prints a leading '+' for a positive SIGNED int, which is nested.zig's - // note about the same cast. const named = if (name.len != 0) name else try std.fmt.allocPrint(arena, "{d}", .{@as(u32, @intCast(std.c.getpid()))}); return @import("detached/server.zig").run(init, opts, named); } - // A frontend is a SHELL, and the two native ones — a terminal and an SDL - // window — both know how to be one. The browser has no unix socket to - // reach a session over and the AppKit shell is entered by its own host - // rather than through this file, so neither is wired for it; the check is - // comptime-folded, so a tty or gui build carries none of it. if (attach != null and pardes.platform != .tty and pardes.platform != .gui) { try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty or gui shell\n"); std.process.exit(1); @@ -499,11 +346,6 @@ fn nativeMain(init: std.process.Init) !void { switch (pardes.platform) { .tty => try @import("tty/tty.zig").run(init, opts, attach), .gui => try @import("gui/gui.zig").run(init, opts, attach), - // Every other shell is entered by its host and never links this file - // at all: the browser through src/web.zig, the macOS app through - // src/macos.zig, and the ESP32-P4 firmware through src/esp32p4/app.zig, - // which is a root of its own in this repository and links the - // `pardes-esp32p4` object over the C ABI in src/esp32p4.zig. .web, .macos, .esp32p4 => unreachable, } } @@ -523,65 +365,22 @@ fn parseCtrlKey(raw: []const u8) ?u21 { return c; } -// The shells are imported inside main(), which a test build never analyses — -// so their inline tests need naming here to exist at all. Each shell only -// compiles when selected (GUI @cImports SDL; TTY imports vaxis), hence the -// comptime gates. Naming a file gets THAT file's tests and no further: -// fonts.zig is imported by gui.zig, builtins.zig and the macOS host, and still -// needs its own line here. test { - _ = @import("user_config.zig"); - // Reached only from the panic handler and from `nativeMain`, neither of - // which a test build analyses — so without this line the crash file has no - // test at all. + _ = pardes.config.User; _ = @import("crash.zig"); - _ = @import("allocators.zig"); - _ = @import("fs_service.zig"); - // acme's control filesystem, both halves, and NOT their own b.addTest - // modules in build.zig the way temp_file/nested/fonts are: both reach - // src/pardes.zig (acmefs takes a *Pardes, fuse.zig speaks its Req/Reply), - // so a standalone module would have to re-wire ghostty-vt, tree-sitter, the - // themes and every option the core imports. This module already has them. - // - // fuse.zig genuinely needs its name here (nothing the core analyses reaches - // it — only the shells import it). acmefs.zig does not today, because - // pardes.zig re-exports it unconditionally; it is named anyway, because the - // day that re-export grows a comptime gate is the day 23 tests disappear in - // silence. That is the fonts.zig story above, told once already. - _ = @import("acmefs.zig"); - _ = @import("fuse.zig"); - // The detached-session transport (src/detached/), same story as fuse.zig - // above: it speaks the core's Event/Surface, so it belongs in THIS module - // rather than a standalone b.addTest, and nothing the core analyses reaches - // it. A TTY build does — tty.zig imports client.zig for `--attach` — but - // these names are what makes the transport's tests exist in every other - // build too, and `--detach` is not a tty-only feature. - // client.zig's own tests drive a real `Session` over a real socket, so - // naming it reaches server.zig too — but server.zig is named anyway, for - // the acmefs.zig reason: the day client.zig stops importing it is the day - // those tests vanish in silence. + _ = @import("memory.zig"); + _ = @import("fs.zig"); _ = @import("detached/wire.zig"); _ = @import("detached/server.zig"); _ = @import("detached/client.zig"); - // The 9P listener, and it needs its name here for the reason the - // panel_compositor line below states rather than the fuse.zig one above: - // detached/server.zig imports it, but naming a file does not make Zig - // analyse the tests of what IT imports — measured, by three tests that - // compiled and never ran. A standalone b.addTest is not an option either, - // because this file reaches pardes.zig (`Server(acmefs)`); src/9p.zig, the - // half that does NOT, has one in build.zig. - _ = @import("fs9_service.zig"); - // Its client twin, and it needs its name here for the same reason with the - // same measurement behind it: builtins.zig imports it for the `9p` word, - // and naming a file does not make Zig analyse the tests of what IT - // imports. The protocol half's tests are in src/9p.zig's own b.addTest; - // these are the host's — the argument split, the two dial spellings, and - // the immediate refusal when nothing is listening. - _ = @import("fs9_client.zig"); + _ = @import("9p_io.zig"); + _ = @import("9p_io.zig").Client; + _ = @import("host_io.zig"); + _ = @import("host_io.zig").Shell; + _ = @import("host_io.zig").Lsp; + _ = @import("lsp/lsp_client.zig"); if (comptime pardes.platform == .tty) { _ = @import("tty/tty.zig"); - // tty.zig calls the compositor only from its runtime loop, so merely - // naming the shell does not make Zig analyse the compositor's tests. _ = @import("tty/panel_compositor.zig"); } if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig"); diff --git a/src/memory.zig b/src/memory.zig new file mode 100644 index 00000000..60ec1bb6 --- /dev/null +++ b/src/memory.zig @@ -0,0 +1,148 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const config = @import("pardes_config"); + +const board = config.platform == .esp32p4; +const reduced_target = builtin.os.tag == .freestanding; +const KiB = 1024; +const MiB = 1024 * KiB; + +pub const limits = struct { + pub const max_file_bytes = 256 * MiB; + pub const max_stream_bytes = 4 * MiB; + // P4: 384 KiB heap; static buffers and stack share a separate 240 KiB region. + pub const board_heap_bytes = 384 * KiB; + pub const effect_cap = if (board) 128 else 4096; + pub const pending_write_cap: usize = if (board) 0 else 4 * MiB; + pub const wrap_rows = if (board) 128 else 256; + pub const undo_max = if (board) 16 else 256; + pub const message_log = if (board) 16 else 128; + pub const max_tag_tail: usize = if (board) 512 else 4096; + pub const host_path_cap: usize = if (board) 0 else 4095; + pub const embedded_sources = !board; + pub const hexdump_row_bytes: u32 = if (board) 8 else 16; + + pub const arena = struct { + pub const pardes = if (board) 4 * KiB else if (reduced_target) 8 * MiB else 32 * MiB; + pub const frame = if (board) 4 * KiB else if (reduced_target) 4 * MiB else 16 * MiB; + pub const tree_sitter = if (board) 0 else if (reduced_target) 4 * MiB else 16 * MiB; + pub const image = if (board) 0 else if (reduced_target) 64 * KiB else 32 * MiB; + pub const pdf = if (board) 0 else if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB; + }; +}; + +const Allocator = std.mem.Allocator; +const debug_enabled = builtin.mode == .Debug; + +pub const Allocators = struct { + pardes: Allocator, + frame: Allocator, + lsp: Allocator, + tree_sitter: Allocator, + image: Allocator, + pdf: Allocator, +}; + +var pardes_fallback: std.heap.StackFallbackAllocator(limits.arena.pardes) = undefined; +var frame_fallback: std.heap.StackFallbackAllocator(limits.arena.frame) = undefined; +var tree_sitter_fallback: std.heap.StackFallbackAllocator(limits.arena.tree_sitter) = undefined; +var image_fallback: std.heap.StackFallbackAllocator(limits.arena.image) = undefined; +var pdf_fallback: std.heap.StackFallbackAllocator(limits.arena.pdf) = undefined; + +const Debug = std.heap.DebugAllocator(.{}); +var pardes_debug: Debug = .init; +var frame_debug: Debug = .init; +var lsp_debug: Debug = .init; +var tree_sitter_debug: Debug = .init; +var image_debug: Debug = .init; +var pdf_debug: Debug = .init; + +// One session at a time; free its allocations before deinit. Concurrent LSP workers use the caller's allocator. +pub fn init(fallback: Allocator) Allocators { + pardes_fallback.fallback_allocator = fallback; + pardes_fallback.get_called = if (std.debug.runtime_safety) false else {}; + frame_fallback.fallback_allocator = fallback; + frame_fallback.get_called = if (std.debug.runtime_safety) false else {}; + tree_sitter_fallback.fallback_allocator = fallback; + tree_sitter_fallback.get_called = if (std.debug.runtime_safety) false else {}; + image_fallback.fallback_allocator = fallback; + image_fallback.get_called = if (std.debug.runtime_safety) false else {}; + pdf_fallback.fallback_allocator = fallback; + pdf_fallback.get_called = if (std.debug.runtime_safety) false else {}; + + const raw: Allocators = .{ + .pardes = pardes_fallback.get(), + .frame = frame_fallback.get(), + .lsp = fallback, + .tree_sitter = tree_sitter_fallback.get(), + .image = image_fallback.get(), + .pdf = pdf_fallback.get(), + }; + if (!debug_enabled) return raw; + + pardes_debug = .{ .backing_allocator = raw.pardes }; + frame_debug = .{ .backing_allocator = raw.frame }; + lsp_debug = .{ .backing_allocator = raw.lsp }; + tree_sitter_debug = .{ .backing_allocator = raw.tree_sitter }; + image_debug = .{ .backing_allocator = raw.image }; + pdf_debug = .{ .backing_allocator = raw.pdf }; + return .{ + .pardes = pardes_debug.allocator(), + .frame = frame_debug.allocator(), + .lsp = lsp_debug.allocator(), + .tree_sitter = tree_sitter_debug.allocator(), + .image = image_debug.allocator(), + .pdf = pdf_debug.allocator(), + }; +} + +pub fn deinit() void { + if (!debug_enabled) return; + var leaked = pardes_debug.deinit() == .leak; + leaked = (frame_debug.deinit() == .leak) or leaked; + leaked = (lsp_debug.deinit() == .leak) or leaked; + leaked = (tree_sitter_debug.deinit() == .leak) or leaked; + leaked = (image_debug.deinit() == .leak) or leaked; + leaked = (pdf_debug.deinit() == .leak) or leaked; + if (leaked) @panic("allocator leaks detected"); +} + +test "fixed allocators are separate, spill, and restart" { + var allocs = init(std.testing.allocator); + const core = try allocs.pardes.alloc(u8, 32); + const frame = try allocs.frame.alloc(u8, 32); + try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(core.ptr)); + try std.testing.expect(frame_fallback.fixed_buffer_allocator.ownsPtr(frame.ptr)); + try std.testing.expect(core.ptr != frame.ptr); + + const spill = try allocs.pardes.alloc(u8, limits.arena.pardes + 1); + try std.testing.expect(!pardes_fallback.fixed_buffer_allocator.ownsPtr(spill.ptr)); + allocs.pardes.free(spill); + allocs.frame.free(frame); + allocs.pardes.free(core); + deinit(); + + allocs = init(std.testing.allocator); + defer deinit(); + const restarted = try allocs.pardes.alloc(u8, 32); + defer allocs.pardes.free(restarted); + try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(restarted.ptr)); +} + +test "memory limits preserve desktop capacities" { + if (board or reduced_target) return error.SkipZigTest; + try std.testing.expectEqual(4096, limits.effect_cap); + try std.testing.expectEqual(@as(usize, 4 * MiB), limits.pending_write_cap); + try std.testing.expectEqual(256, limits.wrap_rows); + try std.testing.expectEqual(256, limits.undo_max); + try std.testing.expectEqual(128, limits.message_log); + try std.testing.expectEqual(@as(usize, 4096), limits.max_tag_tail); + try std.testing.expectEqual(@as(usize, 4095), limits.host_path_cap); + try std.testing.expect(limits.embedded_sources); + try std.testing.expectEqual(@as(u32, 16), limits.hexdump_row_bytes); + try std.testing.expectEqual(32 * MiB, limits.arena.pardes); + try std.testing.expectEqual(16 * MiB, limits.arena.frame); + try std.testing.expectEqual(16 * MiB, limits.arena.tree_sitter); + try std.testing.expectEqual(32 * MiB, limits.arena.image); + try std.testing.expectEqual(if (config.mupdf) 64 * MiB else 64 * KiB, limits.arena.pdf); +} diff --git a/src/message.zig b/src/message.zig deleted file mode 100644 index 0855c448..00000000 --- a/src/message.zig +++ /dev/null @@ -1,88 +0,0 @@ -//! Native-shell ownership of the transient message row's one clock read. -//! -//! The core owns the row, the buffer and when it goes away (Pardes.setMessage); -//! what it does not own is a clock, and its header says so. So what happened is -//! narrated HERE — at the moment the IO it narrates actually finished, which is -//! also the only moment it is true: a save that failed says nothing — and handed -//! over as finished text. Both native shells post the same two events (a save, -//! an external reload), so the wording sits in one place instead of once per -//! shell, where the two copies would drift the first time either was reworded. -//! Same split, and the same reason, as temp_file.zig owning `New`'s mkstemp. -//! -//! LOCAL time, not UTC: this row is read by a person sitting in front of the -//! terminal. dump.zig stamps filenames in UTC because those are sorted, not -//! read. -const std = @import("std"); -const libc = std.c; - -/// glibc/musl/macOS `struct tm`. Only the first three fields are ever read; the -/// rest are declared because localtime_r writes the whole struct. -const Tm = extern struct { - sec: c_int, - min: c_int, - hour: c_int, - mday: c_int, - mon: c_int, - year: c_int, - wday: c_int, - yday: c_int, - isdst: c_int, - gmtoff: c_long, - zone: ?[*:0]const u8, -}; -extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm; - -/// `HH:MM:SS ` into `buf`, e.g. `14:32:07 saved /etc/hosts`. -/// Written into a caller buffer rather than allocated, because the core's own -/// message buffer is fixed too and a message wider than a pane is one nobody -/// reads. A subject too long for the room left is cut from the FRONT: the tail -/// of a path is the part that identifies it. -/// -/// $PARDES_NOTIME blanks the DIGITS and nothing else. The snapshot harness sets -/// it (the same flag hides the language backend's durations) because a wall -/// clock cannot live in a golden — but a message that vanished under the -/// harness could not be pinned at all, so the row still renders, still says -/// what happened, and still occupies the same columns. -/// The stamped text WITHOUT its clock — what two of these rows have in common -/// when they say the same thing at different times. -/// -/// The message log de-duplicates on this rather than on the whole row, because -/// the clock makes every host message unique by construction: `saved x` at -/// 14:32:07 and at 14:32:09 are different strings, so a watched file rebuilt -/// in a loop filled the ring with identical-looking rows, each counted once. -/// That is exactly the case the de-duplication exists for. -pub fn body(text: []const u8) []const u8 { - // `HH:MM:SS ` — ten bytes, digits or the `--:--:--` the harness blanks - // them to. Anything else is a message that was never stamped. - if (text.len < 10) return text; - if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text; - for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| { - if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text; - } - return text[10..]; -} - -pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 { - var clock: [8]u8 = "--:--:--".*; - const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false; - if (!notime) { - var ts: libc.timespec = undefined; - _ = libc.clock_gettime(.REALTIME, &ts); - const secs: libc.time_t = ts.sec; - var tm: Tm = undefined; - if (localtime_r(&secs, &tm) != null) { - // unsigned on purpose: Zig 0.16 prints a `+` for a positive SIGNED - // int, and `{d:0>2}` on a c_int would spell 14:32:07 as +1:+3:+7 - _ = std.fmt.bufPrint(&clock, "{d:0>2}:{d:0>2}:{d:0>2}", .{ - @as(u32, @intCast(tm.hour)), - @as(u32, @intCast(tm.min)), - @as(u32, @intCast(tm.sec)), - }) catch {}; - } - } - const head = std.fmt.bufPrint(buf, "{s} {s} ", .{ &clock, verb }) catch return buf[0..0]; - const room = buf.len - head.len; - const tail = if (subject.len <= room) subject else subject[subject.len - room ..]; - @memcpy(buf[head.len..][0..tail.len], tail); - return buf[0 .. head.len + tail.len]; -} diff --git a/src/modal.zig b/src/modal.zig index 11eae743..3827d11f 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -1,14 +1,654 @@ const std = @import("std"); const uucode = @import("uucode"); -// Modal-editing text math, kept free of vaxis/ghostty so it can be unit-tested -// in isolation (see the `unit-test` build step). main.zig wires this onto the -// pane's cursor + (for file panes) its content. -// -// The cursor sits ON a grapheme: col is its UTF-8 byte offset in -// [0, line.len]; col == line.len means "on the line terminator / after the -// last grapheme". Motions never leave a cursor in the middle of UTF-8 or an -// extended grapheme cluster. +pub const Normal = struct { + pub const Role = enum { + escape, + + prefix_goto, + prefix_view, + prefix_match, + prefix_find_fwd, + prefix_find_back, + prefix_till_fwd, + prefix_till_back, + prefix_replace, + prefix_next, + prefix_prev, + + goto_file_start, + goto_last_line, + goto_line_start, + goto_line_end, + goto_first_nonws, + goto_line_down, + goto_line_up, + goto_column, + goto_view_top, + goto_view_center, + goto_view_bottom, + goto_definition, + goto_declaration, + goto_type_definition, + goto_implementation, + goto_references, + + view_top, + view_center, + view_bottom, + view_scroll_down, + view_scroll_up, + + match_inside, + match_around, + surround_add, + surround_replace, + surround_delete, + + goto_paragraph, + add_newline, + goto_diagnostic, + goto_diagnostic_end, + + move_left, + move_right, + move_down, + move_up, + next_word_start, + prev_word_start, + next_word_end, + next_long_word_start, + prev_long_word_start, + next_long_word_end, + repeat_find, + line_start, + line_end, + line_first_nonws, + goto_line, + half_page_down, + half_page_up, + page_down, + page_up, + + insert, + append, + insert_line_start, + insert_line_end, + open_below, + open_above, + + select_mode, + select_line, + select_line_bounds, + shrink_to_line_bounds, + collapse_selection, + flip_selection, + select_all, + copy_sel_below, + copy_sel_above, + keep_primary_sel, + remove_primary_sel, + rotate_sel_fwd, + rotate_sel_back, + split_sel_newline, + merge_sels, + merge_consecutive_sels, + trim_sels, + select_regex, + split_regex, + + delete, + delete_noyank, + change, + yank, + replace_with_yank, + paste_after, + paste_before, + switch_case, + to_lowercase, + to_uppercase, + join_lines, + indent, + unindent, + format, + increment, + decrement, + comment_toggle, + undo, + redo, + + leader, + command_line, + pipe_selection, + pipe_selection_to, + insert_output, + append_output, + search, + search_next, + search_prev, + }; + + pub const Input = struct { + roles: std.EnumSet(Role) = .initEmpty(), + cp: u21, + ctrl: bool = false, + alt: bool = false, + + pub fn has(value: Input, role: Role) bool { + return value.roles.contains(role); + } + + fn literal(value: Input) ?u21 { + if (value.ctrl or value.alt or value.cp >= 0xF0000) return null; + return value.cp; + } + }; + + pub const Prefix = enum(u8) { + none, + goto, + view, + match, + find_fwd, + find_back, + till_fwd, + till_back, + replace, + next, + prev, + }; + + pub const MatchSub = enum(u8) { + none, + inside, + around, + surround_add, + surround_replace, + surround_delete, + }; + + pub const State = struct { + count: u32 = 0, + prefix: Prefix = .none, + match_sub: MatchSub = .none, + held_char: u21 = 0, + + pub fn clear(state: *State) void { + state.* = .{}; + } + }; + + pub const PipeBehavior = enum { + /// `|` — stdin is the selection, and the output REPLACES it. + replace, + /// `A-|` — stdin is the selection, and the output is discarded. The text + /// is not touched at all; the point is the command's side effect. + ignore, + /// `!` — no stdin, and the output is inserted BEFORE each selection. + insert, + /// `A-!` — no stdin, and the output is appended AFTER each selection. + append, + + /// Do the selections become stdin? helix's `pipe` flag. + pub fn pipes(b: PipeBehavior) bool { + return b == .replace or b == .ignore; + } + }; + + pub const Scope = enum { once, per_selection }; + pub const Direction = enum { backward, forward }; + pub const Motion = enum { + left, + right, + down, + up, + next_word_start, + prev_word_start, + next_word_end, + next_long_word_start, + prev_long_word_start, + next_long_word_end, + }; + pub const Goto = enum { + file_start, + last_line, + line_start, + line_end, + first_nonws, + line_down, + line_up, + column, + view_top, + view_center, + view_bottom, + }; + pub const View = enum { top, center, bottom, scroll_down, scroll_up }; + pub const Find = enum { forward, backward, till_forward, till_backward }; + pub const Line = enum { start, end, first_nonws }; + pub const Page = enum { half_down, half_up, down, up }; + pub const Insert = enum { at, append, line_start, line_end, open_below, open_above }; + pub const Select = enum { + mode, + line, + line_bounds, + shrink_to_line_bounds, + collapse, + flip, + all, + }; + pub const Multi = enum { + copy_below, + copy_above, + keep_primary, + remove_primary, + rotate_forward, + rotate_backward, + split_newline, + merge, + merge_consecutive, + trim, + }; + pub const Edit = enum { + delete, + delete_noyank, + change, + yank, + replace_with_yank, + paste_after, + paste_before, + switch_case, + lowercase, + uppercase, + join_lines, + indent, + unindent, + comment_toggle, + undo, + redo, + }; + pub const Lsp = enum { definition, declaration, type_definition, implementation, references, format }; + + pub const Counted = struct { + count: u32, + explicit: bool, + }; + + pub const Action = union(enum) { + escape, + goto: struct { target: Goto, count: u32, explicit_count: bool }, + view: View, + find: struct { kind: Find, char: u21, count: u32 }, + replace_char: u21, + match_bracket, + textobject: struct { char: u21, around: bool }, + surround_add: u21, + surround_delete: u21, + surround_replace: struct { from: u21, to: u21 }, + paragraph: struct { direction: Direction, count: u32 }, + add_newline: struct { direction: Direction, count: u32 }, + diagnostic: struct { direction: Direction, endpoint: bool }, + move: struct { motion: Motion, count: u32 }, + repeat_find: u32, + line: Line, + goto_line: Counted, + page: struct { kind: Page, count: u32 }, + insert: struct { kind: Insert, count: u32 }, + select: struct { kind: Select, count: u32 }, + multi: struct { kind: Multi, count: u32 }, + select_regex: bool, // false = select, true = split + edit: struct { kind: Edit, count: u32 }, + lsp: Lsp, + adjust_number: i64, + leader, + command_line, + pipe_selection: PipeBehavior, + search, + search_step: Direction, + + pub fn scope(value: Action) Scope { + return switch (value) { + .escape, + .multi, + .select_regex, + .leader, + .command_line, + .pipe_selection, + .search, + .search_step, + => .once, + .edit => |edit| switch (edit.kind) { + .comment_toggle, .undo, .redo => .once, + else => .per_selection, + }, + else => .per_selection, + }; + } + }; + + pub const Result = union(enum) { + pending, + ignored, + unbound, + action: Action, + }; + + fn resultAction(value: Action) Result { + return .{ .action = value }; + } + + fn consumeCount(state: *State) Counted { + const count = state.count; + state.count = 0; + return .{ .count = @max(1, count), .explicit = count != 0 }; + } + + fn armPrefix(state: *State, prefix: Prefix, saved_count: u32) Result { + state.prefix = prefix; + state.count = saved_count; + if (prefix == .match) { + state.match_sub = .none; + state.held_char = 0; + } + return .pending; + } + + pub fn parse(state: *State, key: Input) Result { + if (key.has(.escape)) { + state.clear(); + return resultAction(.escape); + } + + // A digit is a count only before a command/prefix. A leading zero keeps + // its configured line-start role; after another digit it extends count. + if (state.prefix == .none and !key.ctrl and !key.alt and + key.cp >= '0' and key.cp <= '9' and + !(key.cp == '0' and state.count == 0)) + { + if (state.count < 0xffff) + state.count = state.count * 10 + key.cp - '0'; + return .pending; + } + + const counted = consumeCount(state); + const count = counted.count; + + switch (state.prefix) { + .goto => { + state.prefix = .none; + if (key.has(.goto_file_start)) return resultAction(.{ .goto = .{ .target = .file_start, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_last_line)) return resultAction(.{ .goto = .{ .target = .last_line, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_start)) return resultAction(.{ .goto = .{ .target = .line_start, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_end)) return resultAction(.{ .goto = .{ .target = .line_end, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_first_nonws)) return resultAction(.{ .goto = .{ .target = .first_nonws, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_down)) return resultAction(.{ .goto = .{ .target = .line_down, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_up)) return resultAction(.{ .goto = .{ .target = .line_up, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_column)) return resultAction(.{ .goto = .{ .target = .column, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); + if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); + if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); + if (key.has(.goto_implementation)) return resultAction(.{ .lsp = .implementation }); + if (key.has(.goto_references)) return resultAction(.{ .lsp = .references }); + return .ignored; + }, + .view => { + state.prefix = .none; + if (key.has(.view_top)) return resultAction(.{ .view = .top }); + if (key.has(.view_center)) return resultAction(.{ .view = .center }); + if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom }); + if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down }); + if (key.has(.view_scroll_up)) return resultAction(.{ .view = .scroll_up }); + if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); + if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); + if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); + if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); + return .ignored; + }, + .find_fwd, .find_back, .till_fwd, .till_back => |prefix| { + state.prefix = .none; + const char = key.literal() orelse return .ignored; + const kind: Find = switch (prefix) { + .find_fwd => .forward, + .find_back => .backward, + .till_fwd => .till_forward, + .till_back => .till_backward, + else => unreachable, + }; + return resultAction(.{ .find = .{ .kind = kind, .char = char, .count = count } }); + }, + .replace => { + state.prefix = .none; + const char = key.literal() orelse return .ignored; + return resultAction(.{ .replace_char = char }); + }, + .match => { + if (state.match_sub == .none) { + if (key.has(.prefix_match)) { + state.prefix = .none; + return resultAction(.match_bracket); + } + const sub: MatchSub = if (key.has(.match_inside)) + .inside + else if (key.has(.match_around)) + .around + else if (key.has(.surround_add)) + .surround_add + else if (key.has(.surround_replace)) + .surround_replace + else if (key.has(.surround_delete)) + .surround_delete + else { + state.prefix = .none; + return .ignored; + }; + state.match_sub = sub; + return .pending; + } + const char = key.literal() orelse { + state.clear(); + return .ignored; + }; + if (state.match_sub == .surround_replace and state.held_char == 0) { + state.held_char = char; + return .pending; + } + const sub = state.match_sub; + const from = state.held_char; + state.clear(); + return switch (sub) { + .inside => resultAction(.{ .textobject = .{ .char = char, .around = false } }), + .around => resultAction(.{ .textobject = .{ .char = char, .around = true } }), + .surround_add => resultAction(.{ .surround_add = char }), + .surround_delete => resultAction(.{ .surround_delete = char }), + .surround_replace => resultAction(.{ .surround_replace = .{ .from = from, .to = char } }), + .none => unreachable, + }; + }, + .next, .prev => |prefix| { + state.prefix = .none; + const direction: Direction = if (prefix == .next) .forward else .backward; + if (key.has(.goto_paragraph)) return resultAction(.{ .paragraph = .{ .direction = direction, .count = count } }); + if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } }); + if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } }); + if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); + return .ignored; + }, + .none => {}, + } + + // Prefix setters retain the count for their continuation. + if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0); + if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0); + if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0); + if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0); + if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0); + if (key.has(.prefix_till_back)) return armPrefix(state, .till_back, if (counted.explicit) count else 0); + if (key.has(.prefix_replace)) return armPrefix(state, .replace, if (counted.explicit) count else 0); + if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0); + if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0); + if (key.has(.prefix_match)) return armPrefix(state, .match, 0); + + if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } }); + if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } }); + if (key.has(.move_down)) return resultAction(.{ .move = .{ .motion = .down, .count = count } }); + if (key.has(.move_up)) return resultAction(.{ .move = .{ .motion = .up, .count = count } }); + if (key.has(.next_word_start)) return resultAction(.{ .move = .{ .motion = .next_word_start, .count = count } }); + if (key.has(.prev_word_start)) return resultAction(.{ .move = .{ .motion = .prev_word_start, .count = count } }); + if (key.has(.next_word_end)) return resultAction(.{ .move = .{ .motion = .next_word_end, .count = count } }); + if (key.has(.next_long_word_start)) return resultAction(.{ .move = .{ .motion = .next_long_word_start, .count = count } }); + if (key.has(.prev_long_word_start)) return resultAction(.{ .move = .{ .motion = .prev_long_word_start, .count = count } }); + if (key.has(.next_long_word_end)) return resultAction(.{ .move = .{ .motion = .next_long_word_end, .count = count } }); + if (key.has(.repeat_find)) return resultAction(.{ .repeat_find = count }); + if (key.has(.line_start)) return resultAction(.{ .line = .start }); + if (key.has(.line_end)) return resultAction(.{ .line = .end }); + if (key.has(.line_first_nonws)) return resultAction(.{ .line = .first_nonws }); + if (key.has(.goto_line)) return resultAction(.{ .goto_line = counted }); + if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); + if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); + if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); + if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); + + if (key.has(.insert)) return resultAction(.{ .insert = .{ .kind = .at, .count = count } }); + if (key.has(.append)) return resultAction(.{ .insert = .{ .kind = .append, .count = count } }); + if (key.has(.insert_line_start)) return resultAction(.{ .insert = .{ .kind = .line_start, .count = count } }); + if (key.has(.insert_line_end)) return resultAction(.{ .insert = .{ .kind = .line_end, .count = count } }); + if (key.has(.open_below)) return resultAction(.{ .insert = .{ .kind = .open_below, .count = count } }); + if (key.has(.open_above)) return resultAction(.{ .insert = .{ .kind = .open_above, .count = count } }); + + if (key.has(.select_mode)) return resultAction(.{ .select = .{ .kind = .mode, .count = count } }); + if (key.has(.select_line)) return resultAction(.{ .select = .{ .kind = .line, .count = count } }); + if (key.has(.select_line_bounds)) return resultAction(.{ .select = .{ .kind = .line_bounds, .count = count } }); + if (key.has(.shrink_to_line_bounds)) return resultAction(.{ .select = .{ .kind = .shrink_to_line_bounds, .count = count } }); + if (key.has(.collapse_selection)) return resultAction(.{ .select = .{ .kind = .collapse, .count = count } }); + if (key.has(.flip_selection)) return resultAction(.{ .select = .{ .kind = .flip, .count = count } }); + if (key.has(.select_all)) return resultAction(.{ .select = .{ .kind = .all, .count = count } }); + + if (key.has(.copy_sel_below)) return resultAction(.{ .multi = .{ .kind = .copy_below, .count = count } }); + if (key.has(.copy_sel_above)) return resultAction(.{ .multi = .{ .kind = .copy_above, .count = count } }); + if (key.has(.keep_primary_sel)) return resultAction(.{ .multi = .{ .kind = .keep_primary, .count = count } }); + if (key.has(.remove_primary_sel)) return resultAction(.{ .multi = .{ .kind = .remove_primary, .count = count } }); + if (key.has(.rotate_sel_fwd)) return resultAction(.{ .multi = .{ .kind = .rotate_forward, .count = count } }); + if (key.has(.rotate_sel_back)) return resultAction(.{ .multi = .{ .kind = .rotate_backward, .count = count } }); + if (key.has(.split_sel_newline)) return resultAction(.{ .multi = .{ .kind = .split_newline, .count = count } }); + if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } }); + if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } }); + if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } }); + if (key.has(.select_regex)) return resultAction(.{ .select_regex = false }); + if (key.has(.split_regex)) return resultAction(.{ .select_regex = true }); + + if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); + if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); + if (key.has(.change)) return resultAction(.{ .edit = .{ .kind = .change, .count = count } }); + if (key.has(.yank)) return resultAction(.{ .edit = .{ .kind = .yank, .count = count } }); + if (key.has(.replace_with_yank)) return resultAction(.{ .edit = .{ .kind = .replace_with_yank, .count = count } }); + if (key.has(.paste_after)) return resultAction(.{ .edit = .{ .kind = .paste_after, .count = count } }); + if (key.has(.paste_before)) return resultAction(.{ .edit = .{ .kind = .paste_before, .count = count } }); + if (key.has(.switch_case)) return resultAction(.{ .edit = .{ .kind = .switch_case, .count = count } }); + if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } }); + if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } }); + if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } }); + if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); + if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); + if (key.has(.format)) return resultAction(.{ .lsp = .format }); + if (key.has(.increment)) return resultAction(.{ .adjust_number = @intCast(count) }); + if (key.has(.decrement)) return resultAction(.{ .adjust_number = -@as(i64, @intCast(count)) }); + if (key.has(.comment_toggle)) return resultAction(.{ .edit = .{ .kind = .comment_toggle, .count = count } }); + if (key.has(.undo)) return resultAction(.{ .edit = .{ .kind = .undo, .count = count } }); + if (key.has(.redo)) return resultAction(.{ .edit = .{ .kind = .redo, .count = count } }); + + if (key.has(.leader)) return resultAction(.leader); + if (key.has(.command_line)) return resultAction(.command_line); + if (key.has(.pipe_selection)) return resultAction(.{ .pipe_selection = .replace }); + if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore }); + if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); + if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); + if (key.has(.search)) return resultAction(.search); + if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); + if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); + return .unbound; + } + + fn input(cp: u21, roles: []const Role) Input { + return .{ .cp = cp, .roles = .initMany(roles) }; + } + + test "counts survive prefixes and identical parser actions can feed both adapters" { + var text: State = .{}; + var pdf: State = .{}; + const sequence = [_]Input{ + input('1', &.{}), + input('2', &.{}), + input('g', &.{ .prefix_goto, .goto_file_start }), + input('j', &.{ .move_down, .goto_line_down, .view_scroll_down }), + }; + for (sequence[0 .. sequence.len - 1]) |key| { + try std.testing.expectEqualDeep(parse(&text, key), parse(&pdf, key)); + } + const ta = parse(&text, sequence[sequence.len - 1]); + const pa = parse(&pdf, sequence[sequence.len - 1]); + try std.testing.expectEqualDeep(ta, pa); + try std.testing.expectEqualDeep(Result{ .action = .{ .goto = .{ + .target = .line_down, + .count = 12, + .explicit_count = true, + } } }, ta); + try std.testing.expectEqual(State{}, text); + try std.testing.expectEqual(State{}, pdf); + } + + test "invalid continuations are ignored and clear prefix plus count" { + var state: State = .{}; + try std.testing.expectEqual(Result.pending, parse(&state, input('4', &.{}))); + try std.testing.expectEqual(Result.pending, parse(&state, input('g', &.{.prefix_goto}))); + try std.testing.expectEqual(Result.ignored, parse(&state, input('?', &.{}))); + try std.testing.expectEqual(State{}, state); + try std.testing.expectEqualDeep(Result{ .action = .{ .move = .{ .motion = .down, .count = 1 } } }, parse(&state, input('j', &.{.move_down}))); + } + + test "literal arguments retain conflicting command characters" { + var state: State = .{}; + try std.testing.expectEqual(Result.pending, parse(&state, input('f', &.{.prefix_find_fwd}))); + try std.testing.expectEqualDeep(Result{ .action = .{ .find = .{ .kind = .forward, .char = 'p', .count = 1 } } }, parse(&state, input('p', &.{.paste_after}))); + + try std.testing.expectEqual(Result.pending, parse(&state, input('m', &.{.prefix_match}))); + try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.surround_replace}))); + try std.testing.expectEqual(Result.pending, parse(&state, input('[', &.{.prefix_prev}))); + try std.testing.expectEqualDeep(Result{ .action = .{ .surround_replace = .{ .from = '[', .to = ']' } } }, parse(&state, input(']', &.{.prefix_next}))); + try std.testing.expectEqual(State{}, state); + } + + test "modified and special keys cannot satisfy literal continuations" { + var state: State = .{}; + _ = parse(&state, input('r', &.{.prefix_replace})); + try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 'x', .ctrl = true })); + try std.testing.expectEqual(State{}, state); + _ = parse(&state, input('f', &.{.prefix_find_fwd})); + try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 0xF0001 })); + try std.testing.expectEqual(State{}, state); + } + + test "replace accepts a Unicode literal" { + var state: State = .{}; + try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.prefix_replace}))); + try std.testing.expectEqualDeep(Result{ .action = .{ .replace_char = '界' } }, parse(&state, input('界', &.{}))); + try std.testing.expectEqual(State{}, state); + } + + test "once versus per-selection is semantic action metadata" { + try std.testing.expectEqual(Scope.once, (@as(Action, .search)).scope()); + try std.testing.expectEqual(Scope.once, (Action{ .edit = .{ .kind = .undo, .count = 1 } }).scope()); + try std.testing.expectEqual(Scope.per_selection, (Action{ .edit = .{ .kind = .delete, .count = 1 } }).scope()); + try std.testing.expectEqual(Scope.per_selection, (Action{ .move = .{ .motion = .down, .count = 3 } }).scope()); + } +}; + +test { + _ = Normal; +} + +// Cursor columns are UTF-8 byte offsets at grapheme boundaries; line.len is the terminator. pub const Cursor = struct { row: usize = 0, @@ -19,8 +659,7 @@ pub const Cursor = struct { } }; -// word char classes (matches ad/vim/kakoune: word = alnum + _, punct = other -// non-ws, ws = space/tab/newline). +// Unicode word characters, punctuation, and whitespace. pub const Kind = enum { word, punct, ws }; fn codepointAt(text: []const u8, off: usize) u21 { @@ -59,23 +698,6 @@ fn kindOfCodepoint(cp: u21) Kind { }; } -pub fn kindOf(c: u8) Kind { - return kindOfCodepoint(c); -} - -// "long word" (W/B/E): only whitespace separates; punct is part of a word. -fn kindOfLong(cp: u21) Kind { - return if (isUnicodeWhitespace(cp)) .ws else .word; -} - -fn kindAt(lines: []const []const u8, c: Cursor, long: bool) Kind { - if (c.row >= lines.len) return .ws; - const line = lines[c.row]; - if (c.col >= line.len) return .ws; // line terminator / EOF = whitespace - const cp = codepointAt(line, graphemeStart(line, c.col)); - return if (long) kindOfLong(cp) else kindOfCodepoint(cp); -} - fn lineLenOf(lines: []const []const u8, row: usize) usize { if (row >= lines.len) return 0; return lines[row].len; @@ -110,30 +732,12 @@ fn stepBwd(lines: []const []const u8, c: *Cursor) bool { return true; } -// at EOF? (past the last line's last char) -fn atEof(lines: []const []const u8, c: Cursor) bool { - if (c.row >= lines.len) return true; - if (c.row + 1 < lines.len) return false; - return c.col >= lines[c.row].len; -} - pub fn firstNonWs(line: []const u8) usize { var i: usize = 0; while (i < line.len and isUnicodeWhitespace(codepointAt(line, i))) i = nextGrapheme(line, i); return i; } -// ---- per-line motions ---- - -pub fn lineStart(c: Cursor) Cursor { - return .{ .row = c.row, .col = 0 }; -} - -pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor { - const llen = lineLenOf(lines, c.row); - return .{ .row = c.row, .col = if (llen == 0) 0 else prevGrapheme(lines[c.row], llen) }; -} - pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor { // the row can sit past the content (mouse click below a short pane's // last line) — out of range reads as an empty line, like lineLenOf @@ -141,8 +745,6 @@ pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor { return .{ .row = c.row, .col = firstNonWs(lines[c.row]) }; } -// ---- char/line motions ---- - pub fn charLeft(lines: []const []const u8, c: Cursor) Cursor { if (c.row >= lines.len) return .{ .row = c.row, .col = 0 }; return .{ .row = c.row, .col = prevGrapheme(lines[c.row], @min(c.col, lines[c.row].len)) }; @@ -160,91 +762,6 @@ fn clampLineCol(line: []const u8, col: usize) usize { return graphemeStart(line, @min(col, last)); } -pub fn lineDown(lines: []const []const u8, c: Cursor) Cursor { - const nr = if (c.row + 1 < lines.len) c.row + 1 else c.row; - const llen = lineLenOf(lines, nr); - return .{ .row = nr, .col = if (llen == 0) 0 else clampLineCol(lines[nr], c.col) }; -} - -pub fn lineUp(lines: []const []const u8, c: Cursor) Cursor { - const nr = if (c.row > 0) c.row - 1 else c.row; - const llen = lineLenOf(lines, nr); - return .{ .row = nr, .col = if (llen == 0) 0 else clampLineCol(lines[nr], c.col) }; -} - -// ---- word motions ---- - -// `w`/`W`: to the start of the next word. -pub fn nextWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { - var p = c; - const start_kind = kindAt(lines, p, long); - if (start_kind != .ws) { - // skip the rest of the current word-class run - while (!atEof(lines, p) and kindAt(lines, p, long) == start_kind) { - if (!stepFwd(lines, &p)) break; - } - } - // skip whitespace (incl. newlines) to the next word start - while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { - if (!stepFwd(lines, &p)) break; - } - // p now sits on the next word's first char (or EOF -> last valid pos) - return clampToChar(lines, p); -} - -// `b`/`B`: to the start of the previous word. -pub fn prevWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { - var p = c; - if (!stepBwd(lines, &p)) return c; // at buffer start - // skip whitespace backward - while (kindAt(lines, p, long) == .ws) { - if (!stepBwd(lines, &p)) return .{ .row = 0, .col = 0 }; - } - // now on the end of the previous word; walk back to its start - const k = kindAt(lines, p, long); - while (true) { - var q = p; - if (!stepBwd(lines, &q)) { - p.col = 0; - break; - } - if (kindAt(lines, q, long) != k) break; // crossed into prior class - p = q; - } - return clampToChar(lines, p); -} - -// `e`/`E`: to the end of the current/next word. -pub fn nextWordEnd(lines: []const []const u8, c: Cursor, long: bool) Cursor { - var p = c; - if (!stepFwd(lines, &p)) return clampToChar(lines, c); - // skip whitespace forward - while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { - if (!stepFwd(lines, &p)) break; - } - if (atEof(lines, p)) return clampToChar(lines, p); - // now on a word's first char; advance to the last char of this run - const k = kindAt(lines, p, long); - while (!atEof(lines, p)) { - var q = p; - if (!stepFwd(lines, &q)) break; - if (kindAt(lines, q, long) != k) break; - p = q; - } - return clampToChar(lines, p); -} - -// ---- goto ---- - -pub fn gotoFirst() Cursor { - return .{ .row = 0, .col = 0 }; -} - -pub fn gotoLast(lines: []const []const u8) Cursor { - const r = if (lines.len == 0) 0 else lines.len - 1; - return .{ .row = r, .col = 0 }; -} - // the character the cursor sits on; line terminators / EOF read as '\n'. fn codepointAtCursor(lines: []const []const u8, c: Cursor) u21 { if (c.row >= lines.len) return '\n'; @@ -258,26 +775,6 @@ fn charAt(lines: []const []const u8, c: Cursor) u8 { return if (cp <= 0x7f) @intCast(cp) else 0; } -// `f`/`F`/`t`/`T`: the nth occurrence of `ch` after/before the cursor, across -// line boundaries (helix: not confined to the line). `till` stops one position -// short of the hit. Returns null (no move) when there aren't n occurrences. -pub fn findChar(lines: []const []const u8, c: Cursor, ch: u21, fwd: bool, till: bool, n: usize) ?Cursor { - var p = clampToChar(lines, c); - var left = if (n == 0) 1 else n; - while (left > 0) { - if (fwd) { - if (!stepFwd(lines, &p)) return null; - } else { - if (!stepBwd(lines, &p)) return null; - } - if (codepointAtCursor(lines, p) == ch) left -= 1; - } - if (till) { - if (fwd) _ = stepBwd(lines, &p) else _ = stepFwd(lines, &p); - } - return clampToChar(lines, p); -} - // `mm`: the bracket matching the one under the cursor (dumb text scan with // nesting; no tree-sitter). Null when the cursor is not on a bracket. pub fn matchBracket(lines: []const []const u8, c: Cursor) ?Cursor { @@ -314,31 +811,6 @@ pub fn matchBracket(lines: []const []const u8, c: Cursor) ?Cursor { return null; } -fn isBlank(line: []const u8) bool { - return firstNonWs(line) == line.len; -} - -// `]p`: the start of the next blank-line-delimited block (or the last line). -pub fn paragraphFwd(lines: []const []const u8, c: Cursor) Cursor { - var r = c.row; - while (r < lines.len and !isBlank(lines[r])) r += 1; - while (r < lines.len and isBlank(lines[r])) r += 1; - if (r >= lines.len) return gotoLast(lines); - return .{ .row = r, .col = 0 }; -} - -// `[p`: the start of the current block, or of the previous one when already -// on a block start / a blank line. -pub fn paragraphBwd(lines: []const []const u8, c: Cursor) Cursor { - if (c.row == 0 or lines.len == 0) return .{ .row = 0, .col = 0 }; - var r = @min(c.row, lines.len) - 1; - while (r > 0 and isBlank(lines[r])) r -= 1; - while (r > 0 and !isBlank(lines[r - 1])) r -= 1; - return .{ .row = r, .col = 0 }; -} - -// ---- textobject / surround range math (mi/ma/ms/mr/md) ---- - // an inclusive char range [a, b] in document order pub const Range = struct { a: Cursor, b: Cursor }; @@ -380,94 +852,6 @@ pub fn enclosingQuote(lines: []const []const u8, c0: Cursor, q: u8) ?Range { return null; } -// mi/ma over a bracket pair: `around` keeps the brackets, inside shrinks them -// off (null when nothing is left between them). -pub fn pairRange(lines: []const []const u8, c: Cursor, open: u8, close: u8, around: bool) ?Range { - const r = enclosingPair(lines, c, open, close) orelse return null; - if (around) return r; - return shrinkOffDelims(lines, r); -} - -pub fn quoteRange(lines: []const []const u8, c: Cursor, q: u8, around: bool) ?Range { - const r = enclosingQuote(lines, c, q) orelse return null; - if (around) return r; - return shrinkOffDelims(lines, r); -} - -fn shrinkOffDelims(lines: []const []const u8, r: Range) ?Range { - var a = r.a; - var b = r.b; - if (!stepFwd(lines, &a)) return null; - if (!stepBwd(lines, &b)) return null; - if (b.row < a.row or (b.row == a.row and b.col < a.col)) return null; // empty inside - return .{ .a = a, .b = b }; -} - -// miw/maw (and W): the word run under the cursor; `around` adds the trailing -// whitespace on the line (or the leading run when there is none). -pub fn wordRange(lines: []const []const u8, c0: Cursor, long: bool, around: bool) ?Range { - const c = clampToChar(lines, c0); - if (c.row >= lines.len) return null; - const line = lines[c.row]; - if (line.len == 0 or c.col >= line.len) return null; - const k = kindAt(lines, c, long); - if (k == .ws) return null; - var lo = c.col; - while (lo > 0) { - const prev = prevGrapheme(line, lo); - if (kindAt(lines, .{ .row = c.row, .col = prev }, long) != k) break; - lo = prev; - } - var hi = c.col; - while (true) { - const next = nextGrapheme(line, hi); - if (next >= line.len or kindAt(lines, .{ .row = c.row, .col = next }, long) != k) break; - hi = next; - } - if (around) { - var h2 = hi; - while (true) { - const next = nextGrapheme(line, h2); - if (next >= line.len or kindAt(lines, .{ .row = c.row, .col = next }, long) != .ws) break; - h2 = next; - } - if (h2 != hi) { - hi = h2; - } else { - while (lo > 0) { - const prev = prevGrapheme(line, lo); - if (kindAt(lines, .{ .row = c.row, .col = prev }, long) != .ws) break; - lo = prev; - } - } - } - return .{ .a = .{ .row = c.row, .col = lo }, .b = .{ .row = c.row, .col = hi } }; -} - -// mip/map: the blank-line-delimited block around the cursor; `around` adds the -// trailing blank lines (or the leading ones when there are none). -pub fn paragraphRange(lines: []const []const u8, c0: Cursor, around: bool) ?Range { - const c = clampToChar(lines, c0); - if (c.row >= lines.len or isBlank(lines[c.row])) return null; - var r0 = c.row; - while (r0 > 0 and !isBlank(lines[r0 - 1])) r0 -= 1; - var r1 = c.row; - while (r1 + 1 < lines.len and !isBlank(lines[r1 + 1])) r1 += 1; - if (around) { - var r2 = r1; - while (r2 + 1 < lines.len and isBlank(lines[r2 + 1])) r2 += 1; - if (r2 != r1) { - r1 = r2; - } else { - while (r0 > 0 and isBlank(lines[r0 - 1])) r0 -= 1; - } - } - const llen = lineLenOf(lines, r1); - return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = if (llen == 0) 0 else prevGrapheme(lines[r1], llen) } }; -} - -// ---- helpers used by motions + main.zig ---- - // clamp a (possibly terminator/EOF) position onto a real character. pub fn clampToChar(lines: []const []const u8, c: Cursor) Cursor { if (c.row >= lines.len) { @@ -505,21 +889,11 @@ pub fn lineSlice(content: []const u8, row: usize) []const u8 { return content[start..nl]; } -/// The byte span of line `row`, in ONE scan that stops at that row. -/// -/// This exists because the obvious spelling costs a scan of the WHOLE document per call and the -/// obvious USE of it costs several. `insertAt` below read `lineCount` twice merely to clamp a row, -/// and `lineCount` is `std.mem.count` over every byte; on a 19 MB fixture that was two full passes -/// before a single character could be inserted. Measured with `zig build perf`: `edit-char` on the -/// 300 000-line fixture cost 15.0 ms, against 1.5 ms to render the frame that shows it. -/// -/// Returns null when the row does not exist, so a caller that must clamp pays for the count only on -/// that path - which is the rare one, since a cursor is normally inside its document. +/// A bounded scan of one line; null if the row does not exist. pub const LineSpan = struct { start: usize, end: usize }; pub fn lineSpan(content: []const u8, row: usize) ?LineSpan { - // An empty document has no lines at all, which is what `lineCount` says about it - not one - // empty line. Agreeing with that here is what lets `insertAt` fall through to offset 0. + // Match lineCount: empty content has no lines; a trailing newline adds one. if (content.len == 0) return null; var start: usize = 0; var r: usize = 0; @@ -527,15 +901,12 @@ pub fn lineSpan(content: []const u8, row: usize) ?LineSpan { const nl = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse return null; start = nl + 1; } - // Row `row` exists if it begins inside the content, OR it is the empty last line after a - // trailing newline - which `lineCount` also counts, so the two agree. if (start > content.len) return null; if (start == content.len and !(row == 0 or content.len == 0 or content[content.len - 1] == '\n')) return null; const end = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse content.len; return .{ .start = start, .end = end }; } -// ---- file content mutations. caller frees the returned slice + the old one. ---- fn spliceAlloc(alloc: std.mem.Allocator, content: []const u8, start: usize, end: usize, replacement: []const u8) ![]u8 { const out = try alloc.alloc(u8, content.len - (end - start) + replacement.len); @memcpy(out[0..start], content[0..start]); @@ -546,8 +917,7 @@ fn spliceAlloc(alloc: std.mem.Allocator, content: []const u8, start: usize, end: /// insert `text` at (row, col). col is clamped to the line length. pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 { - // One bounded scan on the common path. The fallback keeps the old clamping exactly - a row past - // the end lands on the last line - and only it pays for a full count. + // Only an out-of-range row requires counting the whole document. const span = lineSpan(content, c.row) orelse blk: { const last = lineCount(content) -| 1; break :blk lineSpan(content, last) orelse LineSpan{ .start = content.len, .end = content.len }; @@ -728,106 +1098,10 @@ pub fn changeCase(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: C return out; } -// `J`: join line `row` with the next — the newline and the next line's leading -// whitespace become one space (helix join). `col` is the space's column. -// Null when `row` is the last line. -pub fn joinLine(alloc: std.mem.Allocator, content: []const u8, row: usize) !?struct { content: []u8, col: usize } { - if (row + 1 >= lineCount(content)) return null; - const a = lineSlice(content, row); - const next = lineSlice(content, row + 1); - const b = std.mem.trimStart(u8, next, " \t"); - const start = lineStartOffset(content, row); - const rest = lineStartOffset(content, row + 1) + (next.len - b.len); - const prefix_end = start + a.len; - const out = try alloc.alloc(u8, prefix_end + 1 + content.len - rest); - @memcpy(out[0..prefix_end], content[0..prefix_end]); - out[prefix_end] = ' '; - @memcpy(out[prefix_end + 1 ..], content[rest..]); - return .{ .content = out, .col = a.len }; -} - // `>` / `<`: indent/unindent lines [r0, r1]. Fixed width — pardes has no // per-language indent config; 4 spaces, one tab counts as one level out. pub const INDENT_W = 4; -pub fn indentLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize, add: bool) ![]u8 { - const lo = @min(r0, r1); - const hi = @max(r0, r1); - var out_len = content.len; - var it = std.mem.splitScalar(u8, content, '\n'); - var row: usize = 0; - while (it.next()) |line| : (row += 1) { - if (row < lo or row > hi) continue; - if (add) { - if (line.len != 0) out_len += INDENT_W; - } else { - var cut: usize = 0; - if (line.len > 0 and line[0] == '\t') { - cut = 1; - } else while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1; - out_len -= cut; - } - } - - const out = try alloc.alloc(u8, out_len); - it = std.mem.splitScalar(u8, content, '\n'); - row = 0; - var write: usize = 0; - while (it.next()) |line| : (row += 1) { - if (row > 0) { - out[write] = '\n'; - write += 1; - } - var selected = line; - if (row >= lo and row <= hi) { - if (add) { - if (line.len != 0) { - @memset(out[write..][0..INDENT_W], ' '); - write += INDENT_W; - } - } else if (line.len > 0 and line[0] == '\t') { - selected = line[1..]; - } else { - var cut: usize = 0; - while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1; - selected = line[cut..]; - } - } - @memcpy(out[write..][0..selected.len], selected); - write += selected.len; - } - return out; -} - -// `Ctrl-a`/`Ctrl-x`: add `delta` to the decimal integer under the cursor -// (helix: under the cursor only, no forward scan). Null when the cursor is -// not on a number. The new cursor sits on the number's last digit. -pub fn adjustNumber(alloc: std.mem.Allocator, content: []const u8, c: Cursor, delta: i64) !?struct { content: []u8, cur: Cursor } { - const line = lineSlice(content, c.row); - if (c.col >= line.len) return null; - var s = c.col; - var e = c.col; - if (!std.ascii.isDigit(line[s])) { - // sitting on the '-' of a negative number counts - if (!(line[s] == '-' and s + 1 < line.len and std.ascii.isDigit(line[s + 1]))) return null; - e = s + 1; - } - while (s > 0 and std.ascii.isDigit(line[s - 1])) s -= 1; - if (s > 0 and line[s - 1] == '-') s -= 1; - while (e < line.len and std.ascii.isDigit(line[e])) e += 1; - const val = std.fmt.parseInt(i64, line[s..e], 10) catch return null; - const nv = val +| delta; - var buf: [24]u8 = undefined; - // "{d}" prints '+' for positive signed ints — format the magnitude unsigned - const numstr = if (nv < 0) - std.fmt.bufPrint(&buf, "-{d}", .{@abs(nv)}) catch return null - else - std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null; - const off = lineStartOffset(content, c.row); - const start = off + s; - return .{ .content = try spliceAlloc(alloc, content, start, off + e, numstr), .cur = .{ .row = c.row, .col = s + numstr.len - 1 } }; -} - // delete the EXCLUSIVE span [a, b) — insert-mode kills. col may equal the // line length (the newline); a kill crossing it passes b = (row+1, 0). pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 { @@ -837,35 +1111,14 @@ pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: C return spliceAlloc(alloc, content, s, e, ""); } -// ---- helix range engine (phase 5) ---- -// -// Gap-offset ranges over the FLAT buffer, ported faithfully from -// helix-core/src/movement.rs + selection.rs @ 278b24389 (the genizah -// checkout). Positions are UTF-8 gap offsets 0..=text.len. Stored columns -// remain byte offsets, but every range boundary is an extended-grapheme -// boundary. A range with -// head > anchor selects [anchor, head) with the block cursor ON head-1; -// head < anchor selects [head, anchor) with the cursor ON head. The -// differential suite (test/hxcases, `zig build hxdiff`) pins every behavior -// here key-for-key against a real helix. - -pub const HxRange = struct { anchor: usize, head: usize }; - -/// The first byte of the grapheme cluster containing `off`. -/// -/// The general answer needs UAX #29, which is why the slow path below iterates from the start of -/// `text` with the full break state machine - and that made this the single hottest function in a -/// keystroke: 21.5% of a profiled edit at the ESP32-P4's 40x12 geometry, because the render path -/// calls it once per visible row with a column offset, so the cost follows the cursor's distance -/// along its line. That is exactly the shape measured on the die, where inserting at column 320 of -/// a fixed line cost 7.8 ms more than inserting at column 0 of the same line. -/// -/// The fast path is sound rather than approximate. In UAX #29 every ASCII scalar is its own -/// grapheme cluster with ONE exception, GB3: CR is joined to a following LF. Every other rule that -/// could extend a cluster across `off` - Extend, ZWJ, SpacingMark, Prepend, Regional_Indicator - -/// is spelled with non-ASCII scalars. So if the byte at `off` and the byte before it are both -/// ASCII and are not that CR-LF pair, `off` already IS a cluster boundary and there is nothing to -/// search for. Text that is not all ASCII still takes the slow path, byte for byte as before. +// Ported from Helix movement.rs and selection.rs at 278b24389. +// Half-open selections use UTF-8 byte gaps at grapheme boundaries. +// A forward selection's cursor is the grapheme before head; a backward one's is at head. + +pub const Selection = struct { anchor: usize, head: usize }; + +/// The first byte of the grapheme containing off. Adjacent ASCII bytes are +/// boundaries except CR-LF (UAX #29 GB3); other cases need full segmentation. pub fn graphemeStart(text: []const u8, off: usize) usize { const bounded = @min(off, text.len); if (bounded == text.len) return text.len; @@ -884,15 +1137,7 @@ pub fn graphemeStart(text: []const u8, off: usize) usize { /// one extended grapheme forward, clamped at text.len pub fn nextGrapheme(text: []const u8, off: usize) usize { if (off >= text.len) return text.len; - // The editor's own offsets are already boundaries. Keep the overwhelmingly - // common ASCII path O(1); only repair a continuation-byte input here. - // - // GB3 is the one UAX #29 rule that joins two ASCII scalars: CR takes a - // following LF into the same cluster. `graphemeStart` spells that exclusion - // out (:875) and this did not, so the two disagreed about a CRLF file by - // exactly one byte — a head stepped onto the offset between CR and LF and - // `graphemeStart` then repaired it back onto the CR. Excluded here for the - // same reason and in the same words; everything else ASCII is still O(1). + // Inputs are grapheme boundaries; repair continuation bytes. CR-LF stays one cluster. if (text[off] < 0x80 and (off + 1 == text.len or text[off + 1] < 0x80) and !(text[off] == '\r' and off + 1 < text.len and text[off + 1] == '\n')) return off + 1; var start = off; @@ -911,13 +1156,10 @@ pub fn prevGrapheme(text: []const u8, off: usize) usize { bounded = repaired; } if (bounded == 0) return 0; - // ...and the same GB3 exclusion, from the other side: a CR before this LF - // means the cluster starts one byte earlier than the fast path would say. + // GB3 also excludes stepping backward into CR-LF. if (text[bounded - 1] < 0x80 and (bounded == 1 or text[bounded - 2] < 0x80) and !(bounded >= 2 and text[bounded - 2] == '\r' and text[bounded - 1] == '\n')) return bounded - 1; - // Graphemes cannot cross a line break. Restrict the forward segmentation - // needed for a reverse step to the current line instead of rescanning the - // complete buffer. + // No grapheme crosses a line break; reverse segmentation only needs this line. const line_start = if (std.mem.lastIndexOfScalar(u8, text[0 .. bounded - 1], '\n')) |nl| nl + 1 else 0; if (line_start == bounded) return bounded - 1; // the newline is its own editor cell var it = uucode.grapheme.utf8Iterator(text[line_start..bounded]); @@ -935,14 +1177,12 @@ pub fn graphemeAtColumn(text: []const u8, column: usize) usize { } /// the block cursor cell of a range (helix Range::cursor) -pub fn hxCursor(text: []const u8, r: HxRange) usize { +pub fn selectionCursor(text: []const u8, r: Selection) usize { return if (r.head > r.anchor) prevGrapheme(text, r.head) else r.head; } -/// helix Range::put_cursor: park the block cursor at cell `idx`, optionally -/// extending — the anchor shifts one grapheme when the head crosses it so the -/// anchor CELL stays fixed. -pub fn hxPutCursor(text: []const u8, r: HxRange, idx: usize, extend: bool) HxRange { +/// Crossing the anchor moves its byte gap one grapheme, preserving the anchor cell. +pub fn moveSelectionCursor(text: []const u8, r: Selection, idx: usize, extend: bool) Selection { if (!extend) return .{ .anchor = idx, .head = idx }; var anchor = r.anchor; if (r.head >= r.anchor and idx < r.anchor) { @@ -956,39 +1196,35 @@ pub fn hxPutCursor(text: []const u8, r: HxRange, idx: usize, extend: bool) HxRan // ropey-style line math: len_lines = count('\n') + 1 — the slot after a // trailing '\n' is a real, empty last line and the cursor can sit there. -pub fn hxLineCount(text: []const u8) usize { +pub fn cursorLineCount(text: []const u8) usize { return std.mem.count(u8, text, "\n") + 1; } -pub fn hxLineOf(text: []const u8, off: usize) usize { +pub fn lineAtOffset(text: []const u8, off: usize) usize { return std.mem.count(u8, text[0..@min(off, text.len)], "\n"); } /// offset of line's terminator ('\n'), or text.len on the last line -pub fn hxLineEndIdx(text: []const u8, line: usize) usize { +pub fn lineEndOffset(text: []const u8, line: usize) usize { const s = lineStartOffset(text, line); return if (std.mem.indexOfScalarPos(u8, text, s, '\n')) |nl| nl else text.len; } /// gap offset -> (row, col) cell -pub fn hxPos(text: []const u8, off: usize) Cursor { +pub fn positionAt(text: []const u8, off: usize) Cursor { const bounded = @min(off, text.len); - // The line start is the byte after the last '\n' BEFORE off, which is the - // same number lineStartOffset(text, row) walks the whole prefix to reach — - // one backward scan of a single line instead of a second pass over - // everything above the cursor. On a multi-MB buffer that second pass was - // most of what a keystroke cost. + // Find the line start backward without a second scan of the document prefix. const s = if (std.mem.lastIndexOfScalar(u8, text[0..bounded], '\n')) |nl| nl + 1 else 0; const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len; const col = graphemeStart(text[s..e], @min(bounded - s, e - s)); - return .{ .row = hxLineOf(text, s + col), .col = col }; + return .{ .row = lineAtOffset(text, s + col), .col = col }; } /// (row, col) -> clamped gap offset; col == line length lands ON the '\n' -pub fn hxOff(text: []const u8, c: Cursor) usize { - const row = @min(c.row, hxLineCount(text) - 1); +pub fn offsetAt(text: []const u8, c: Cursor) usize { + const row = @min(c.row, cursorLineCount(text) - 1); const s = lineStartOffset(text, row); - // hxLineEndIdx(text, row) inlined: it starts by walking to `row` again, + // lineEndOffset(text, row) inlined: it starts by walking to `row` again, // and we are already standing there const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len; const raw = @min(c.col, e - s); @@ -1008,9 +1244,9 @@ pub const WordTarget = enum { // helix categorize_char: Eol is its OWN category, distinct from Whitespace — // that distinction is load-bearing in reached_target. -const HxCat = enum { word, punct, ws, eol }; +const WordClass = enum { word, punct, ws, eol }; -fn hxCatAt(text: []const u8, off: usize) HxCat { +fn wordClassAt(text: []const u8, off: usize) WordClass { if (off >= text.len) return .eol; const cp = codepointAt(text, off); if (cp == '\n' or cp == '\r') return .eol; @@ -1021,25 +1257,25 @@ fn hxCatAt(text: []const u8, off: usize) HxCat { }; } -fn hxIsWs(c: HxCat) bool { // Rust char::is_whitespace (includes line endings) +fn isWordWhitespace(c: WordClass) bool { // Rust char::is_whitespace (includes line endings) return c == .ws or c == .eol; } -fn hxIsWordBoundary(a: HxCat, b: HxCat) bool { +fn isWordBoundary(a: WordClass, b: WordClass) bool { return a != b; } -fn hxIsLongBoundary(a: HxCat, b: HxCat) bool { +fn isLongWordBoundary(a: WordClass, b: WordClass) bool { if ((a == .word and b == .punct) or (a == .punct and b == .word)) return false; return a != b; } -fn hxReached(target: WordTarget, prev: HxCat, next: HxCat) bool { +fn reachedWordTarget(target: WordTarget, prev: WordClass, next: WordClass) bool { return switch (target) { - .next_word_start, .prev_word_end => hxIsWordBoundary(prev, next) and (next == .eol or !hxIsWs(next)), - .next_word_end, .prev_word_start => hxIsWordBoundary(prev, next) and (!hxIsWs(prev) or next == .eol), - .next_long_word_start, .prev_long_word_end => hxIsLongBoundary(prev, next) and (next == .eol or !hxIsWs(next)), - .next_long_word_end, .prev_long_word_start => hxIsLongBoundary(prev, next) and (!hxIsWs(prev) or next == .eol), + .next_word_start, .prev_word_end => isWordBoundary(prev, next) and (next == .eol or !isWordWhitespace(next)), + .next_word_end, .prev_word_start => isWordBoundary(prev, next) and (!isWordWhitespace(prev) or next == .eol), + .next_long_word_start, .prev_long_word_end => isLongWordBoundary(prev, next) and (next == .eol or !isWordWhitespace(next)), + .next_long_word_end, .prev_long_word_start => isLongWordBoundary(prev, next) and (!isWordWhitespace(prev) or next == .eol), }; } @@ -1051,12 +1287,12 @@ fn wmIsPrev(t: WordTarget) bool { } /// w/b/e/W/B/E: helix word_move — each step selects the traversed span. -pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarget) HxRange { +pub fn moveWord(text: []const u8, r0: Selection, count: usize, target: WordTarget) Selection { const is_prev = wmIsPrev(target); if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0; // block-cursor prep: collapse to the 1-wide cell at the head, pointing // in the motion direction (the anchor of the input is irrelevant) - var r: HxRange = if (is_prev) + var r: Selection = if (is_prev) (if (r0.anchor < r0.head) .{ .anchor = r0.head, .head = prevGrapheme(text, r0.head) } else @@ -1067,7 +1303,7 @@ pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarge else .{ .anchor = r0.head, .head = nextGrapheme(text, r0.head) }); for (0..@max(1, count)) |_| { - const next = hxRangeToTarget(text, target, r, is_prev); + const next = wordRangeToTarget(text, target, r, is_prev); if (next.anchor == r.anchor and next.head == r.head) break; r = next; } @@ -1076,20 +1312,20 @@ pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarge // port of CharHelpers::range_to_target — a char iterator walking away from // origin.head; when reversed, "next" reads the byte just behind the position. -fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_prev: bool) HxRange { +fn wordRangeToTarget(text: []const u8, target: WordTarget, origin: Selection, is_prev: bool) Selection { var anchor = origin.anchor; var head = origin.head; var it = origin.head; - var prev_cat: ?HxCat = if (is_prev) - (if (it < text.len) hxCatAt(text, it) else null) + var prev_cat: ?WordClass = if (is_prev) + (if (it < text.len) wordClassAt(text, it) else null) else - (if (it > 0) hxCatAt(text, prevGrapheme(text, it)) else null); + (if (it > 0) wordClassAt(text, prevGrapheme(text, it)) else null); // skip any initial newline characters while (true) { if ((is_prev and it == 0) or (!is_prev and it >= text.len)) break; const cell = if (is_prev) prevGrapheme(text, it) else it; - const cat = hxCatAt(text, cell); + const cat = wordClassAt(text, cell); if (cat != .eol) break; it = if (is_prev) cell else nextGrapheme(text, cell); prev_cat = cat; @@ -1102,8 +1338,8 @@ fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_pre while (true) { if ((is_prev and it == 0) or (!is_prev and it >= text.len)) break; const cell = if (is_prev) prevGrapheme(text, it) else it; - const next_cat = hxCatAt(text, cell); - if (prev_cat == null or hxReached(target, prev_cat.?, next_cat)) { + const next_cat = wordClassAt(text, cell); + if (prev_cat == null or reachedWordTarget(target, prev_cat.?, next_cat)) { if (head == head_start) anchor = head else break; } prev_cat = next_cat; @@ -1114,34 +1350,34 @@ fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_pre } /// a ropey "line is a line ending" — the line has no content of its own -fn hxLineIsEmpty(text: []const u8, line: usize) bool { - return lineStartOffset(text, line) == hxLineEndIdx(text, line); +fn lineIsEmpty(text: []const u8, line: usize) bool { + return lineStartOffset(text, line) == lineEndOffset(text, line); } /// ]p / [p: helix move_next_paragraph / move_prev_paragraph -pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend: bool) HxRange { - const nlines = hxLineCount(text); - const cursor = hxCursor(text, r); - var line = hxLineOf(text, cursor); +pub fn moveParagraph(text: []const u8, r: Selection, count: usize, fwd: bool, extend: bool) Selection { + const nlines = cursorLineCount(text); + const cursor = selectionCursor(text, r); + var line = lineAtOffset(text, cursor); if (fwd) { const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1); const last_char = prevGrapheme(text, nxt_start) == cursor; - const curr_empty = hxLineIsEmpty(text, line); - const next_empty = hxLineIsEmpty(text, @min(nlines - 1, line + 1)); + const curr_empty = lineIsEmpty(text, line); + const next_empty = lineIsEmpty(text, @min(nlines - 1, line + 1)); const curr_empty_to_line = curr_empty and !next_empty; // skip the character after the paragraph boundary if (curr_empty_to_line and last_char) line += 1; var l = line; var last_line = l; for (0..@max(1, count)) |_| { - while (l < nlines and !hxLineIsEmpty(text, l)) l += 1; - while (l < nlines and hxLineIsEmpty(text, l)) l += 1; + while (l < nlines and !lineIsEmpty(text, l)) l += 1; + while (l < nlines and lineIsEmpty(text, l)) l += 1; if (l == last_line) break; last_line = l; } const head = if (l >= nlines) text.len else lineStartOffset(text, l); const anchor = if (extend) - hxPutCursor(text, r, head, true).anchor + moveSelectionCursor(text, r, head, true).anchor else if (curr_empty_to_line and last_char) r.head else @@ -1149,22 +1385,22 @@ pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend: return .{ .anchor = anchor, .head = head }; } const first_char = lineStartOffset(text, line) == cursor; - const prev_empty = hxLineIsEmpty(text, line -| 1); - const curr_empty = hxLineIsEmpty(text, line); + const prev_empty = lineIsEmpty(text, line -| 1); + const curr_empty = lineIsEmpty(text, line); const prev_empty_to_line = prev_empty and !curr_empty; // skip the character before the paragraph boundary if (prev_empty_to_line and !first_char) line += 1; var l = line; var last_line = l; for (0..@max(1, count)) |_| { - while (l > 0 and hxLineIsEmpty(text, l - 1)) l -= 1; - while (l > 0 and !hxLineIsEmpty(text, l - 1)) l -= 1; + while (l > 0 and lineIsEmpty(text, l - 1)) l -= 1; + while (l > 0 and !lineIsEmpty(text, l - 1)) l -= 1; if (l == last_line) break; last_line = l; } const head = lineStartOffset(text, l); const anchor = if (extend) - hxPutCursor(text, r, head, true).anchor + moveSelectionCursor(text, r, head, true).anchor else if (prev_empty_to_line and first_char) cursor else @@ -1174,19 +1410,19 @@ pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend: /// j/k target: helix move_vertically — goal_col clamps to the line's content /// length, i.e. the cursor may land ON the '\n' of a shorter line. -pub fn hxVertTarget(text: []const u8, pos: usize, down: bool, count: usize, goal_col: usize) usize { - const nlines = hxLineCount(text); - const line = hxLineOf(text, pos); +pub fn verticalTarget(text: []const u8, pos: usize, down: bool, count: usize, goal_col: usize) usize { + const nlines = cursorLineCount(text); + const line = lineAtOffset(text, pos); const nline = if (down) @min(line + @max(1, count), nlines - 1) else line -| @max(1, count); const s = lineStartOffset(text, nline); - // hxLineEndIdx(text, nline) without its second walk to nline (see hxOff) + // lineEndOffset(text, nline) without its second walk to nline (see offsetAt) const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len; return s + graphemeStart(text[s..e], @min(goal_col, e - s)); } /// f/F/t/T target cell. helix find_char: the exclusive (till) search starts /// one further out so repeats make progress; not-found = null (no move). -pub fn hxFindTarget(text: []const u8, cursor: usize, ch: u21, fwd: bool, till: bool, count: usize) ?usize { +pub fn findTarget(text: []const u8, cursor: usize, ch: u21, fwd: bool, till: bool, count: usize) ?usize { var left = @max(1, count); if (fwd) { const head = nextGrapheme(text, cursor); @@ -1212,17 +1448,17 @@ pub fn hxFindTarget(text: []const u8, cursor: usize, ch: u21, fwd: bool, till: b } // helix textobject.rs find_word_boundary -fn hxFindWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usize { - var prev: HxCat = if (fwd) - (if (pos0 == 0) .ws else hxCatAt(text, prevGrapheme(text, pos0))) +fn findWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usize { + var prev: WordClass = if (fwd) + (if (pos0 == 0) .ws else wordClassAt(text, prevGrapheme(text, pos0))) else - (if (pos0 >= text.len) .ws else hxCatAt(text, pos0)); + (if (pos0 >= text.len) .ws else wordClassAt(text, pos0)); var pos = pos0; var it = pos0; while (true) { if ((fwd and it >= text.len) or (!fwd and it == 0)) break; const cell = if (fwd) it else prevGrapheme(text, it); - const cat = hxCatAt(text, cell); + const cat = wordClassAt(text, cell); if (cat == .eol or cat == .ws) return pos; if (!long and cat != prev and pos != 0 and pos != text.len) return pos; it = if (fwd) nextGrapheme(text, cell) else cell; @@ -1234,34 +1470,34 @@ fn hxFindWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usiz /// miw/maw (and W): helix textobject_word — on whitespace it selects the /// whitespace run's boundary (a 1-wide cursor there) -pub fn hxTextobjectWord(text: []const u8, r: HxRange, around: bool, long: bool) HxRange { - const pos = hxCursor(text, r); - const word_start = hxFindWordBoundary(text, pos, false, long); - const cat: HxCat = if (pos < text.len) hxCatAt(text, pos) else .ws; - const word_end = if (cat == .ws or cat == .eol) pos else hxFindWordBoundary(text, nextGrapheme(text, pos), true, long); +pub fn selectWord(text: []const u8, r: Selection, around: bool, long: bool) Selection { + const pos = selectionCursor(text, r); + const word_start = findWordBoundary(text, pos, false, long); + const cat: WordClass = if (pos < text.len) wordClassAt(text, pos) else .ws; + const word_end = if (cat == .ws or cat == .eol) pos else findWordBoundary(text, nextGrapheme(text, pos), true, long); if (word_start == word_end or !around) return .{ .anchor = word_start, .head = word_end }; var end = word_end; - while (end < text.len and hxIsWs(hxCatAt(text, end)) and hxCatAt(text, end) != .eol) + while (end < text.len and isWordWhitespace(wordClassAt(text, end)) and wordClassAt(text, end) != .eol) end = nextGrapheme(text, end); if (end > word_end) return .{ .anchor = word_start, .head = end }; var start = word_start; while (start > 0) { const before = prevGrapheme(text, start); - const before_cat = hxCatAt(text, before); - if (!hxIsWs(before_cat) or before_cat == .eol) break; + const before_cat = wordClassAt(text, before); + if (!isWordWhitespace(before_cat) or before_cat == .eol) break; start = before; } return .{ .anchor = start, .head = word_end }; } /// mip/map: helix textobject_paragraph -pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count: usize) HxRange { - const nlines = hxLineCount(text); - const cursor = hxCursor(text, r); - var line = hxLineOf(text, cursor); - const prev_empty = hxLineIsEmpty(text, line -| 1); - const curr_empty = hxLineIsEmpty(text, line); - const next_empty = line + 1 >= nlines or hxLineIsEmpty(text, line + 1); +pub fn selectParagraph(text: []const u8, r: Selection, around: bool, count: usize) Selection { + const nlines = cursorLineCount(text); + const cursor = selectionCursor(text, r); + var line = lineAtOffset(text, cursor); + const prev_empty = lineIsEmpty(text, line -| 1); + const curr_empty = lineIsEmpty(text, line); + const next_empty = line + 1 >= nlines or lineIsEmpty(text, line + 1); const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1); const last_char = prevGrapheme(text, nxt_start) == cursor; const prev_empty_to_line = prev_empty and !curr_empty; @@ -1271,29 +1507,29 @@ pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count: if (prev_empty_to_line or curr_empty_to_line) line_back += 1; // do not include the current paragraph on a paragraph end (include next) if (!(curr_empty_to_line and last_char)) { - while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1; - while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and lineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and !lineIsEmpty(text, line_back - 1)) line_back -= 1; } if (curr_empty_to_line and last_char) line += 1; const n = @max(1, count); var count_done: usize = 0; for (0..n) |_| { var done = false; - while (line < nlines and !hxLineIsEmpty(text, line)) { + while (line < nlines and !lineIsEmpty(text, line)) { line += 1; done = true; } - while (line < nlines and hxLineIsEmpty(text, line)) line += 1; + while (line < nlines and lineIsEmpty(text, line)) line += 1; if (done) count_done += 1; } // search one paragraph backwards when we ran off the end if (count_done != n and line >= nlines) { - while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1; - while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and lineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and !lineIsEmpty(text, line_back - 1)) line_back -= 1; } if (!around) { // inside: drop the trailing whitespace paragraph - while (line > 0 and hxLineIsEmpty(text, line - 1)) line -= 1; + while (line > 0 and lineIsEmpty(text, line - 1)) line -= 1; } return .{ .anchor = lineStartOffset(text, line_back), @@ -1301,25 +1537,25 @@ pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count: }; } -test "hx textobject word and paragraph" { +test "selection textobjects distinguish words and paragraph boundaries" { const t = "alpha beta gamma\n"; // miw mid-word - var r = hxTextobjectWord(t, .{ .anchor = 8, .head = 9 }, false, false); + var r = selectWord(t, .{ .anchor = 8, .head = 9 }, false, false); try std.testing.expectEqual(@as(usize, 6), r.anchor); try std.testing.expectEqual(@as(usize, 10), r.head); // maw on the space after "beta": collapses to the boundary - r = hxTextobjectWord(t, .{ .anchor = 10, .head = 11 }, true, false); + r = selectWord(t, .{ .anchor = 10, .head = 11 }, true, false); try std.testing.expectEqual(@as(usize, 10), r.anchor); try std.testing.expectEqual(@as(usize, 10), r.head); const t2 = "aa\n\ncc\n"; // mip from the blank line selects the NEXT paragraph - r = hxTextobjectParagraph(t2, .{ .anchor = 3, .head = 4 }, false, 1); + r = selectParagraph(t2, .{ .anchor = 3, .head = 4 }, false, 1); try std.testing.expectEqual(@as(usize, 4), r.anchor); try std.testing.expectEqual(@as(usize, 7), r.head); } /// leading-whitespace visual width (tab -> next multiple of INDENT_W) -pub fn hxIndentWidth(line: []const u8) usize { +pub fn indentWidth(line: []const u8) usize { var w: usize = 0; for (line) |ch| { if (ch == ' ') w += 1 else if (ch == '\t') w = (w / INDENT_W + 1) * INDENT_W else break; @@ -1329,37 +1565,32 @@ pub fn hxIndentWidth(line: []const u8) usize { /// full indent LEVELS of a line as spaces (helix indent_level_for_line: /// partial levels round down) — what o/O/insert-newline copy. -pub fn hxIndentString(line: []const u8) []const u8 { - const level = hxIndentWidth(line) / INDENT_W; +pub fn indentText(line: []const u8) []const u8 { + const level = indentWidth(line) / INDENT_W; const max = " "; // 8 levels is plenty (ponytail) return max[0..@min(level * INDENT_W, max.len)]; } -/// Indent width for an inserted newline. Keep the current full indent levels, -/// then add one logical tab after a simple delimiter-shaped line ending. -/// `)` intentionally includes both ordinary calls and the requested `})` -/// continuation shape; this is syntax-agnostic and does not try to parse. -pub fn hxNewlineIndentWidth(line: []const u8, col: usize) usize { +/// Copy full indent levels and add one after (, [, {, or ), without parsing. +pub fn newlineIndentWidth(line: []const u8, col: usize) usize { const prefix = std.mem.trimEnd(u8, line[0..@min(col, line.len)], " \t"); const extra = if (prefix.len == 0) false else switch (prefix[prefix.len - 1]) { '(', '[', '{', ')' => true, else => false, }; - return hxIndentString(line).len + @as(usize, if (extra) INDENT_W else 0); + return indentText(line).len + @as(usize, if (extra) INDENT_W else 0); } test "newline indent keeps levels and adds one after delimiters" { - try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth(" value", 9)); - try std.testing.expectEqual(@as(usize, 8), hxNewlineIndentWidth(" call()", 10)); - try std.testing.expectEqual(@as(usize, 8), hxNewlineIndentWidth(" callback({}) ", 16)); - try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth("work(", 5)); - try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth("list[tail", 5)); + try std.testing.expectEqual(@as(usize, 4), newlineIndentWidth(" value", 9)); + try std.testing.expectEqual(@as(usize, 8), newlineIndentWidth(" call()", 10)); + try std.testing.expectEqual(@as(usize, 8), newlineIndentWidth(" callback({}) ", 16)); + try std.testing.expectEqual(@as(usize, 4), newlineIndentWidth("work(", 5)); + try std.testing.expectEqual(@as(usize, 4), newlineIndentWidth("list[tail", 5)); } -/// helix Ctrl-a / Ctrl-x: increment the SELECTED text as a decimal integer. -/// Zero-padding is preserved (width follows sign flips, helix-style). -/// Ponytail: no 0x/0o/0b bases, no '_' separators — decimal only. -pub fn hxIncrement(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]u8 { +/// Increment a selected decimal integer, preserving zero-padding across sign changes. +pub fn incrementDecimal(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]u8 { if (frag.len == 0) return null; const neg = frag[0] == '-'; const digits = if (neg) frag[1..] else frag; @@ -1391,80 +1622,80 @@ pub fn hxIncrement(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[] return out; } -test "hx word moves match helix" { +test "word selections match Helix motions" { const t = "alpha beta\n"; // w from a fresh 1-wide cursor selects "alpha " (cursor on the space) - var r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_start); + var r = moveWord(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_start); try std.testing.expectEqual(@as(usize, 0), r.anchor); try std.testing.expectEqual(@as(usize, 6), r.head); // e from the same start ends on 'a' of alpha - r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_end); + r = moveWord(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_end); try std.testing.expectEqual(@as(usize, 5), r.head); try std.testing.expectEqual(@as(usize, 0), r.anchor); // b from the w result selects "alpha" backward - r = hxWordMove(t, .{ .anchor = 6, .head = 10 }, 1, .prev_word_start); + r = moveWord(t, .{ .anchor = 6, .head = 10 }, 1, .prev_word_start); try std.testing.expectEqual(@as(usize, 10), r.anchor); try std.testing.expectEqual(@as(usize, 6), r.head); // 2w on "one two three": anchor comes from the last hop only const t2 = "one two three\n"; - r = hxWordMove(t2, .{ .anchor = 0, .head = 1 }, 2, .next_word_start); + r = moveWord(t2, .{ .anchor = 0, .head = 1 }, 2, .next_word_start); try std.testing.expectEqual(@as(usize, 4), r.anchor); try std.testing.expectEqual(@as(usize, 8), r.head); // w at EOF collapses to a zero-width range at len const t3 = "alpha\n"; - r = hxWordMove(t3, .{ .anchor = 0, .head = 5 }, 1, .next_word_start); + r = moveWord(t3, .{ .anchor = 0, .head = 5 }, 1, .next_word_start); try std.testing.expectEqual(@as(usize, 6), r.head); try std.testing.expectEqual(@as(usize, 6), r.anchor); // W treats punct runs as word chars const t4 = "foo.bar baz\n"; - r = hxWordMove(t4, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); + r = moveWord(t4, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); try std.testing.expectEqual(@as(usize, 0), r.anchor); try std.testing.expectEqual(@as(usize, 8), r.head); } -test "hx put cursor keeps the anchor cell across crossings" { +test "selection cursor keeps the anchor cell across crossings" { const t = "abcdef\n"; // forward range [2,3) extended left of the anchor: anchor cell stays 2 - var r = hxPutCursor(t, .{ .anchor = 2, .head = 3 }, 0, true); + var r = moveSelectionCursor(t, .{ .anchor = 2, .head = 3 }, 0, true); try std.testing.expectEqual(@as(usize, 3), r.anchor); try std.testing.expectEqual(@as(usize, 0), r.head); - try std.testing.expectEqual(@as(usize, 0), hxCursor(t, r)); + try std.testing.expectEqual(@as(usize, 0), selectionCursor(t, r)); // and back: cursor to 4 -> forward again, anchor gap back to 2 - r = hxPutCursor(t, r, 4, true); + r = moveSelectionCursor(t, r, 4, true); try std.testing.expectEqual(@as(usize, 2), r.anchor); try std.testing.expectEqual(@as(usize, 5), r.head); } -test "hx paragraph moves" { +test "paragraph selections cross blank lines" { const t = "aa\nbb\n\ncc\ndd\n\nee\n"; // ]p from the top selects through the blank line to the next block - var r = hxParaMove(t, .{ .anchor = 0, .head = 1 }, 1, true, false); + var r = moveParagraph(t, .{ .anchor = 0, .head = 1 }, 1, true, false); try std.testing.expectEqual(@as(usize, 0), r.anchor); try std.testing.expectEqual(@as(usize, 7), r.head); // [p from "ee" (line 6, offset 14) goes back to "cc" block start - r = hxParaMove(t, .{ .anchor = 14, .head = 15 }, 1, false, false); + r = moveParagraph(t, .{ .anchor = 14, .head = 15 }, 1, false, false); try std.testing.expectEqual(@as(usize, 14), r.anchor); try std.testing.expectEqual(@as(usize, 7), r.head); } -test "hx vertical: goal col clamps onto the newline cell" { +test "vertical target clamps the goal column onto the newline cell" { const t = "abcdef\nab\nabcdef\n"; // from (0,5) down: line "ab" clamps to its '\n' at offset 9 - try std.testing.expectEqual(@as(usize, 9), hxVertTarget(t, 5, true, 1, 5)); + try std.testing.expectEqual(@as(usize, 9), verticalTarget(t, 5, true, 1, 5)); // two down with the same goal restores col 5 - try std.testing.expectEqual(@as(usize, 15), hxVertTarget(t, 9, true, 1, 5)); + try std.testing.expectEqual(@as(usize, 15), verticalTarget(t, 9, true, 1, 5)); } -test "hx find targets" { +test "find targets count matches and skip adjacent till targets" { const t = "abcabc\n"; - try std.testing.expectEqual(@as(usize, 2), hxFindTarget(t, 0, 'c', true, false, 1).?); - try std.testing.expectEqual(@as(usize, 5), hxFindTarget(t, 0, 'c', true, false, 2).?); - try std.testing.expectEqual(@as(usize, 1), hxFindTarget(t, 0, 'c', true, true, 1).?); + try std.testing.expectEqual(@as(usize, 2), findTarget(t, 0, 'c', true, false, 1).?); + try std.testing.expectEqual(@as(usize, 5), findTarget(t, 0, 'c', true, false, 2).?); + try std.testing.expectEqual(@as(usize, 1), findTarget(t, 0, 'c', true, true, 1).?); // till repeat skips the adjacent target: from cell 1, next tc reaches 4 - try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 1, 'c', true, true, 1).?); - try std.testing.expectEqual(@as(usize, 3), hxFindTarget(t, 5, 'a', false, false, 1).?); - try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 5, 'a', false, true, 1).?); - try std.testing.expectEqual(@as(?usize, null), hxFindTarget(t, 0, 'z', true, false, 1)); + try std.testing.expectEqual(@as(usize, 4), findTarget(t, 1, 'c', true, true, 1).?); + try std.testing.expectEqual(@as(usize, 3), findTarget(t, 5, 'a', false, false, 1).?); + try std.testing.expectEqual(@as(usize, 4), findTarget(t, 5, 'a', false, true, 1).?); + try std.testing.expectEqual(@as(?usize, null), findTarget(t, 0, 'z', true, false, 1)); } test "extended grapheme boundaries cover combining emoji flag and CJK text" { @@ -1482,10 +1713,7 @@ test "extended grapheme boundaries cover combining emoji flag and CJK text" { } test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 walk" { - // Both functions answer ASCII from arithmetic and hand everything else to the segmenter. The - // guard is a claim about UAX #29 (an ASCII scalar is its own cluster unless the next scalar - // extends it, and every extender is non-ASCII), so pin it against the walk it skips rather - // than against transcribed offsets: same text, both routes, every offset including past the end. + // Compare every offset against segmentation with the ASCII fast paths removed. const H = struct { // `graphemeStart` with the ASCII arm deleted — nothing else changed. fn start(text: []const u8, off: usize) usize { @@ -1522,19 +1750,14 @@ test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 wa } }; - // Scalars that extend a preceding ASCII base into ONE cluster, which is the whole reason the - // fast path inspects its neighbour: a combining mark, a ZWJ sequence, a spacing mark - // (Devanagari visarga), a variation selector. Plus wide glyphs, a regional-indicator pair, - // and three shapes of invalid UTF-8 the segmenter must still be trusted with: a bad start - // byte, a truncated tail, a bad continuation. + // Combining marks, ZWJ, spacing marks, selectors, wide glyphs, flags, and invalid UTF-8. const neighbours = [_][]const u8{ "", "a", "\u{301}", "\u{200d}\u{1f680}", "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}\u{754c}", "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", }; - // Every byte the range test can see, ASCII and not: 0x20..0x7e take the fast path, and \t, \r, - // the rest of the C0 controls and DEL are excluded by it and must still reach the same answer. + // Every ASCII byte paired with each Unicode or invalid neighbor, in both orders. var buf: [16]u8 = undefined; var b: u8 = 0; while (b < 0x80) : (b += 1) { @@ -1549,26 +1772,13 @@ test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 wa } } - // Text that has no CR-LF pair in it: GB3 is the one ASCII-only rule that joins two clusters, - // and it gets its own test below because it is the single exclusion every fast path has to - // carry by hand. for ([_][]const u8{ "a\r", "\ra", "\n\r", "a\rb\nc" }) |text| try H.check(text); // Mixed text long enough that a fast-path run starts, ends and restarts inside one string. try H.check("plain ascii then \u{4e16}\u{754c} then e\u{301} then more ascii"); } -// GB3 is the one UAX #29 rule that joins two ASCII scalars: CR takes a following LF into the same -// cluster. Each of the three steppers carries that exclusion separately - `graphemeStart` at :875, -// `nextGrapheme`'s ASCII arm at :896, `prevGrapheme`'s at :916 - so nothing but a test keeps them -// agreeing. The invariant is that all three answer the same CRLF boundary: for every cluster the -// segmenter reports, `graphemeStart` maps its start to itself, `nextGrapheme` maps that start to -// its end, and `prevGrapheme` maps its end back to the start. -// -// This was a live bug: `nextGrapheme` and `prevGrapheme` stepped exactly one byte whenever the -// byte at the offset and its neighbour were ASCII, so on a CRLF file the flat-buffer range engine -// could step a head to offset 1 and `graphemeStart` would repair that same offset back to 0. Both -// arms now spell the exclusion out, and this test is what holds them there. +// CR-LF must have identical boundaries in forward, backward, and containing-cluster queries. test "GB3 keeps CR-LF one cluster for every grapheme step" { const text = "a\r\nb"; // The reference: the same segmentation the slow arms of these functions run. @@ -1588,112 +1798,69 @@ test "GB3 keeps CR-LF one cluster for every grapheme step" { } test "Unicode find and word motion stay on grapheme boundaries" { - const lines = [_][]const u8{"\u{e9}x\u{e9}"}; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, findChar(&lines, .{ .row = 0, .col = 0 }, 'é', true, false, 1).?); + try std.testing.expectEqual(@as(usize, 3), findTarget("\u{e9}x\u{e9}", 0, 'é', true, false, 1).?); const text = "café 世界 ok\n"; - const first = hxWordMove(text, .{ .anchor = 0, .head = 1 }, 1, .next_word_start); + const first = moveWord(text, .{ .anchor = 0, .head = 1 }, 1, .next_word_start); try std.testing.expectEqual(@as(usize, 0), first.anchor); try std.testing.expectEqual(@as(usize, 6), first.head); - const second = hxWordMove(text, .{ .anchor = 0, .head = 1 }, 2, .next_word_start); + const second = moveWord(text, .{ .anchor = 0, .head = 1 }, 2, .next_word_start); try std.testing.expectEqual(@as(usize, 6), second.anchor); try std.testing.expectEqual(@as(usize, 13), second.head); // Long-word motions split on Unicode whitespace, not only ASCII spaces. const nbsp = "alpha\u{a0}beta\n"; - const long = hxWordMove(nbsp, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); + const long = moveWord(nbsp, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); try std.testing.expectEqual(@as(usize, 7), long.head); } -test "hx increment" { +test "decimal increment preserves padding and handles sign changes" { const a = std.testing.allocator; { - const r = (try hxIncrement(a, "15", 1)).?; + const r = (try incrementDecimal(a, "15", 1)).?; defer a.free(r); try std.testing.expectEqualStrings("16", r); } { - const r = (try hxIncrement(a, "007", 1)).?; + const r = (try incrementDecimal(a, "007", 1)).?; defer a.free(r); try std.testing.expectEqualStrings("008", r); } { - const r = (try hxIncrement(a, "-3", 1)).?; + const r = (try incrementDecimal(a, "-3", 1)).?; defer a.free(r); try std.testing.expectEqualStrings("-2", r); } { - const r = (try hxIncrement(a, "9", -10)).?; + const r = (try incrementDecimal(a, "9", -10)).?; defer a.free(r); try std.testing.expectEqualStrings("-1", r); } - try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "a 1", 1)); - try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "", 1)); + try std.testing.expectEqual(@as(?[]u8, null), try incrementDecimal(a, "a 1", 1)); + try std.testing.expectEqual(@as(?[]u8, null), try incrementDecimal(a, "", 1)); } -// ---- tests ---- - -test "kindOf" { - try std.testing.expectEqual(Kind.word, kindOf('a')); - try std.testing.expectEqual(Kind.word, kindOf('_')); - try std.testing.expectEqual(Kind.word, kindOf('9')); - try std.testing.expectEqual(Kind.punct, kindOf('.')); - try std.testing.expectEqual(Kind.punct, kindOf('(')); - try std.testing.expectEqual(Kind.ws, kindOf(' ')); - try std.testing.expectEqual(Kind.ws, kindOf('\n')); +test "codepoint classes distinguish words punctuation and whitespace" { + try std.testing.expectEqual(Kind.word, kindOfCodepoint('a')); + try std.testing.expectEqual(Kind.word, kindOfCodepoint('_')); + try std.testing.expectEqual(Kind.word, kindOfCodepoint('9')); + try std.testing.expectEqual(Kind.punct, kindOfCodepoint('.')); + try std.testing.expectEqual(Kind.punct, kindOfCodepoint('(')); + try std.testing.expectEqual(Kind.ws, kindOfCodepoint(' ')); + try std.testing.expectEqual(Kind.ws, kindOfCodepoint('\n')); } -test "char/line motions" { +test "insert cursor steps characters and skips indentation" { const lines = [_][]const u8{ "alpha beta", " two words", "x" }; const c = Cursor{ .row = 0, .col = 5 }; try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, charLeft(&.{"hello"}, c)); try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, charRight(&lines, c)); - try std.testing.expectEqual(Cursor{ .row = 1, .col = 5 }, lineDown(&lines, c)); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, lineUp(&lines, Cursor{ .row = 1, .col = 5 })); - // line ends - try std.testing.expectEqual(Cursor{ .row = 0, .col = 9 }, lineEnd(&lines, c)); - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 })); - // first non-ws try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 })); // cursor row past the content (mouse click below a short pane): no panic try std.testing.expectEqual(Cursor{ .row = 24, .col = 0 }, firstNonWsOf(&lines, Cursor{ .row = 24, .col = 3 })); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&lines, Cursor{ .row = 24, .col = 0 })); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&[_][]const u8{}, Cursor{ .row = 5, .col = 0 })); -} - -test "word motions w/b/e" { - const lines = [_][]const u8{"this is a test"}; - const w = &lines; - // "this is a test", indices 0..13 - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); // t->next word "is" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "a" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "test" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 9 }, false)); // from ws - // b - try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, prevWordStart(w, Cursor{ .row = 0, .col = 10 }, false)); // test -> "a" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, prevWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "is" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, prevWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "this" - // e - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); // this[3] - try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nextWordEnd(w, Cursor{ .row = 0, .col = 3 }, false)); // -> "is"[6] - try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, nextWordEnd(w, Cursor{ .row = 0, .col = 10 }, false)); // -> "test"[13] -} - -test "word motions cross line" { - const lines = [_][]const u8{ "foo bar", "", "baz" }; - const w = &lines; - // from end of "foo bar" (row0 col6) w crosses the blank line to "baz" - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, nextWordStart(w, Cursor{ .row = 0, .col = 6 }, false)); - // b from "baz" crosses back to "bar" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, prevWordStart(w, Cursor{ .row = 2, .col = 0 }, false)); - // e from row0 col0 -> "foo" end (col2) - try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); } test "lineSpan agrees with the whole-document scans it replaces" { - // The bounded scan is only worth having if it is indistinguishable from the pair it replaced, - // including at the edges that make line counting awkward: an empty document, a trailing - // newline (which is its own empty last line), and a row past the end. for ([_][]const u8{ "", "a", "a\n", "a\nbb\n", "a\nbb\nccc", "\n", "\n\n" }) |content| { const n = lineCount(content); var row: usize = 0; @@ -1717,27 +1884,11 @@ test "insertAt still clamps a row past the end onto the last line" { defer gpa.free(out); try std.testing.expectEqualStrings("a\nbb\ncccX", out); - // And an in-range insert lands where the old spelling put it. const mid = try insertAt(gpa, content, .{ .row = 1, .col = 1 }, "X"); defer gpa.free(mid); try std.testing.expectEqualStrings("a\nbXb\nccc", mid); } -test "long word W treats punct as word" { - // "foo.bar baz" : W from 0 -> "baz" at 8 (foo.bar is one long word) - const lines = [_][]const u8{"foo.bar baz"}; - const w = &lines; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, true)); - // w (non-long) from 0 -> '.' at 3 (punct is its own word, like vim/helix) - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); -} - -test "goto" { - const lines = [_][]const u8{ "a", "b", "c" }; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, gotoFirst()); - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, gotoLast(&lines)); -} - test "lineStartOffset + lineSlice" { const content = "alpha\nbeta\n\ngamma"; try std.testing.expectEqual(@as(usize, 0), lineStartOffset(content, 0)); @@ -1858,23 +2009,6 @@ test "clearLine" { try std.testing.expectEqualStrings("keep\n\nkeep2", r); } -test "findChar f/F/t/T across lines and counts" { - const lines = [_][]const u8{ "abcabc", "xa" }; - const w = &lines; - // f: next occurrence, on it - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 1).?); - // count: 2fa crosses into the next line - try std.testing.expectEqual(Cursor{ .row = 1, .col = 1 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 2).?); - // t stops one short - try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, true, 1).?); - // F backward, on it - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, false, 1).?); - // T backward stops one after - try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, true, 1).?); - // not found: null, no move - try std.testing.expectEqual(@as(?Cursor, null), findChar(w, .{ .row = 0, .col = 0 }, 'z', true, false, 1)); -} - test "matchBracket nesting both directions" { const lines = [_][]const u8{"a (b (c) d) e"}; const w = &lines; @@ -1891,78 +2025,30 @@ test "matchBracket across lines" { try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, matchBracket(w, .{ .row = 2, .col = 0 }).?); } -test "paragraph motions" { - const lines = [_][]const u8{ "one", "two", "", "", "three", "four", "", "five" }; - const w = &lines; - try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphFwd(w, .{ .row = 0, .col = 1 })); - try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 4, .col = 0 })); - // no next block: the last line - try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 7, .col = 0 })); - // from mid-block up to its start; from a start up to the previous block's - try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphBwd(w, .{ .row = 5, .col = 1 })); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 4, .col = 0 })); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 0, .col = 0 })); -} - -test "pairRange inside/around, cursor on and between brackets" { +test "enclosing pair includes delimiters and chooses the nearest nested pair" { const lines = [_][]const u8{"f(a, (b))"}; const w = &lines; - const around = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', true).?; + const around = enclosingPair(w, .{ .row = 0, .col = 3 }, '(', ')').?; try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, around.a); try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, around.b); - const inside = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, inside.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, inside.b); - // cursor on the nested open picks the nested pair - const nested = pairRange(w, .{ .row = 0, .col = 5 }, '(', ')', false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.b); - // empty pair: no inside + const nested = enclosingPair(w, .{ .row = 0, .col = 5 }, '(', ')').?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nested.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, nested.b); const empty = [_][]const u8{"()"}; - try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 0 }, '(', ')', false)); - // not enclosed - try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 1 }, '[', ']', false)); + const pair = enclosingPair(&empty, .{ .row = 0, .col = 0 }, '(', ')').?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, pair.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, pair.b); + try std.testing.expectEqual(@as(?Range, null), enclosingPair(&empty, .{ .row = 0, .col = 1 }, '[', ']')); } -test "quoteRange line-scoped" { +test "enclosing quote stays on its line" { const lines = [_][]const u8{"say 'hi there' end"}; const w = &lines; - const r = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', true).?; + const r = enclosingQuote(w, .{ .row = 0, .col = 7 }, '\'').?; try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, r.a); try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, r.b); - const ri = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, ri.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 12 }, ri.b); - // cursor after the pair: not enclosed - try std.testing.expectEqual(@as(?Range, null), quoteRange(w, .{ .row = 0, .col = 16 }, '\'', true)); -} - -test "wordRange inside/around" { - const lines = [_][]const u8{"one two.three"}; - const w = &lines; - const r = wordRange(w, .{ .row = 0, .col = 1 }, false, false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, r.b); - // around eats the trailing spaces - const ra = wordRange(w, .{ .row = 0, .col = 1 }, false, true).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, ra.b); - // long word spans the dot - const rl = wordRange(w, .{ .row = 0, .col = 6 }, true, false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, rl.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, rl.b); - // on whitespace: none - try std.testing.expectEqual(@as(?Range, null), wordRange(w, .{ .row = 0, .col = 3 }, false, false)); -} - -test "paragraphRange inside/around" { - const lines = [_][]const u8{ "a", "b", "", "c" }; - const w = &lines; - const r = paragraphRange(w, .{ .row = 1, .col = 0 }, false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a); - try std.testing.expectEqual(Cursor{ .row = 1, .col = 0 }, r.b); - const ra = paragraphRange(w, .{ .row = 1, .col = 0 }, true).?; - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, ra.b); - try std.testing.expectEqual(@as(?Range, null), paragraphRange(w, .{ .row = 2, .col = 0 }, false)); + try std.testing.expectEqual(@as(?Range, null), enclosingQuote(w, .{ .row = 0, .col = 16 }, '\'')); + try std.testing.expectEqual(@as(?Range, null), enclosingQuote(&.{ "'open", "close'" }, .{}, '\'')); } test "advanceBy" { @@ -1997,46 +2083,6 @@ test "changeCase" { try std.testing.expectEqualStrings("ab CD", up); } -test "joinLine" { - const a = std.testing.allocator; - const r = (try joinLine(a, "one\n two\nthree", 0)).?; - defer a.free(r.content); - try std.testing.expectEqualStrings("one two\nthree", r.content); - try std.testing.expectEqual(@as(usize, 3), r.col); - // last line: nothing to join - try std.testing.expectEqual(@as(?@TypeOf(r), null), try joinLine(a, "one", 0)); -} - -test "indentLines add and remove" { - const a = std.testing.allocator; - const r = try indentLines(a, "one\n\ntwo", 0, 2, true); - defer a.free(r); - try std.testing.expectEqualStrings(" one\n\n two", r); - const u = try indentLines(a, " one\n\ttwo\n three\nx", 0, 2, false); - defer a.free(u); - try std.testing.expectEqualStrings("one\ntwo\nthree\nx", u); -} - -test "adjustNumber" { - const a = std.testing.allocator; - const r = (try adjustNumber(a, "x 41 y", .{ .row = 0, .col = 3 }, 1)).?; - defer a.free(r.content); - try std.testing.expectEqualStrings("x 42 y", r.content); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, r.cur); - // negative, cursor on the '-' - const n = (try adjustNumber(a, "v=-1;", .{ .row = 0, .col = 2 }, -1)).?; - defer a.free(n.content); - try std.testing.expectEqualStrings("v=-2;", n.content); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, n.cur); - // width change moves the last-digit column - const g = (try adjustNumber(a, "9", .{ .row = 0, .col = 0 }, 1)).?; - defer a.free(g.content); - try std.testing.expectEqualStrings("10", g.content); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, g.cur); - // not on a number - try std.testing.expectEqual(@as(?@TypeOf(r), null), try adjustNumber(a, "abc", .{ .row = 0, .col = 0 }, 1)); -} - test "deleteSpan including the newline" { const a = std.testing.allocator; const r = try deleteSpan(a, "hello world", .{ .row = 0, .col = 2 }, .{ .row = 0, .col = 5 }); diff --git a/src/nested.zig b/src/nested.zig deleted file mode 100644 index 1997d8e4..00000000 --- a/src/nested.zig +++ /dev/null @@ -1,743 +0,0 @@ -//! A pardes launched inside a pardes hands its file to the outer one. -//! -//! Every top-level instance listens on `/pardes-.sock`, where `` -//! is `$XDG_RUNTIME_DIR` or, when the session has none, `~/.local/state/pardes` -//! created 0700. NOT /tmp: this socket takes a command line and runs it, and a -//! world-writable directory means both that somebody else can plant a listener -//! at a pid we are about to guess and that a file they planted under the sticky -//! bit cannot be unlinked, so bind fails and the feature goes quietly off. -//! -//! An instance that finds an ancestor process running the same executable -//! resolves its positional argument, writes ONE line — `Look /abs/path` — to -//! that ancestor's socket and exits silently; the outer pardes runs the line -//! through executeBuiltinLine and opens a pane for it. The wire format is a -//! builtin command line because that is a language pardes already speaks: no -//! serialization, nothing to version. The receive side still filters it down -//! to `Look `, because executeBuiltinLine dispatches ANY builtin and this -//! socket sits at a path anyone can derive from a pid — `Exec …` arriving here -//! is not something this protocol is allowed to say. -//! -//! Linux and darwin. The two differ in every primitive this needs and in none -//! of the design: /proc against libproc for the ancestor walk, SOCK_CLOEXEC -//! and accept4 against a plain socket plus an fcntl, and a `sun_path` of 108 -//! bytes against one of 104 — which is why no buffer below spells a number, -//! they are all sized from the field itself. Anywhere else the walk returns -//! null and a pardes inside a pardes opens a second session, as before. -//! -//! macOS also has a third executable in the family: the app bundle. Its binary -//! is named `pardes`, like the tty frontend, wherever the bundle is installed. -//! Executable identity therefore comes from the family name rather than its -//! path — see samePardesExecutable. -const std = @import("std"); -const builtin = @import("builtin"); -const libc = std.c; - -// std.c has getenv but neither setter; the tests below need both -extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -extern "c" fn unsetenv(name: [*:0]const u8) c_int; - -/// THE SOCKET CONVENTIONS BELOW ARE SHARED, and the ones marked `pub` are -/// shared with src/detached/server.zig — a second unix socket in the same -/// per-user directory, under a different name (`pardes-detached-.sock` -/// rather than `pardes-.sock`). They were copied into that file when it -/// landed; one directory vetted by two different predicates is exactly the -/// divergence the reasoning here is meant to prevent, so there is one of each. -pub const darwin = switch (builtin.os.tag) { - .macos, .ios, .tvos, .watchos, .visionos => true, - else => false, -}; - -/// This module is only as portable as its two ingredients: a way to name the -/// executable and parent of an arbitrary pid, and unix sockets. The detached -/// transport needs the second alone, and the same answer. -pub const supported = builtin.os.tag == .linux or darwin; - -/// `sun_path` is 108 bytes on linux and 104 on darwin, and it is the hard -/// limit on this whole feature: a path that does not fit is not a socket -/// address, it is a truncated one pointing somewhere else. Taken from the -/// struct so that the buffers, the fit checks and the memcpy below cannot -/// disagree with the kernel or with each other. -pub const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len; - -/// libproc, darwin's answer to /proc. `proc_pidpath` is readlink of -/// `/proc//exe`; `PROC_PIDTBSDINFO` carries the parent pid that linux -/// spells `PPid:`. Both are same-uid readable, which is the only permission -/// an ancestor walk through one's own processes needs. -const PROC_PIDTBSDINFO: c_int = 3; -const proc_bsdinfo = extern struct { - flags: u32, - status: u32, - xstatus: u32, - pid: u32, - ppid: u32, - /// uids, gids, comm, name, the tty and the start time: filled by the - /// kernel and unread here, but the call fails unless the buffer is the - /// whole 136-byte record. - rest: [116]u8, -}; -extern "c" fn proc_pidpath(pid: c_int, buffer: *anyopaque, buffersize: u32) c_int; -extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; - -/// Linux opens sockets CLOEXEC in one call; darwin has to set it afterwards. -/// The gap is a race only against a fork on another thread, and every caller -/// is past that: `listen` runs before the first pane exists, `acceptLine` runs -/// on a thread of its own long after spawning has settled, and the detached -/// session — which forks EVERY pane shell in the session, because the daemon -/// owns them now (`host_io.zig`) — has no other thread to race with, since it -/// services its pane ptys from the same `poll(2)` that accepts its frontends. -/// -/// CLOEXEC matters MORE for that last one than it did when a frontend forked -/// the shells: a pane shell is long-lived and arbitrary, and an inherited -/// listener would keep the session's socket bound long after the session -/// ended — the same shape as the inherited lock fd that once held a flock -/// forever. -pub fn setCloexec(fd: c_int) void { - const FD_CLOEXEC: c_int = 1; - _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); -} - -/// The longest command line this protocol carries or accepts. `Look ` plus a -/// PATH_MAX path fits with room over; anything longer cannot have come from -/// the client and is dropped rather than truncated into a different command. -pub const max_line = 4200; - -/// Where the sockets live. `$XDG_RUNTIME_DIR` first — a per-user 0700 tmpfs -/// the login session already cleans up — else `~/.local/state/pardes`, which -/// is per-user for the same reason a home directory is. NEVER /tmp: these -/// sockets take a command line, or keystrokes into a live editor. Asked by the -/// client (to derive the path), by the listener (to create and vet it), by the -/// sweeper (to scan it) and by the detached transport (all three, for its own -/// name), so it is written once. -pub fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 { - if (libc.getenv("XDG_RUNTIME_DIR")) |x| - return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(x)}, 0) catch null; - const home = libc.getenv("HOME") orelse return null; - return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{std.mem.span(home)}, 0) catch null; -} - -/// `/pardes-.sock`. `` is the LISTENING instance's own pid, so -/// two pardes never collide and a nested child derives the exact path from the -/// ancestor pid its tree walk found. The buffer is sun_path-sized: a longer -/// path is not a socket address at all. -pub fn socketPath(buf: *[sun_path_len]u8, pid: libc.pid_t) ?[:0]const u8 { - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = socketDir(&dir_buf) orelse return null; - // unsigned: {d} prints a leading '+' for a positive SIGNED int - return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d}.sock", .{ dir, @as(u32, @intCast(pid)) }, 0) catch null; -} - -/// Normalize the kernel suffix left on a running executable after its file is -/// replaced. `zig build` does this routinely while an outer session is live. -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; -} - -/// The tty, SDL and macOS builds are sibling frontends of the same program. -/// Their installed names differ only by `-gui` (and, for cross builds, share -/// the same `-os-arch` tail), or not at all when one of them is the app bundle -/// — so any of them must recognise any other as an outer pardes. Paths are -/// deliberately ignored: the GUI may be installed system-wide while the tty -/// frontend is installed in the user's bin directory. -fn samePardesExecutable(a_raw: []const u8, b_raw: []const u8) bool { - const a = stripDeleted(a_raw); - const b = stripDeleted(b_raw); - return sameFamily(std.fs.path.basename(a), std.fs.path.basename(b)); -} - -/// What is left of a family name after the frontend part: `` for `pardes` and -/// `pardes-gui`, `-linux-aarch64` for the cross-built spellings of both. Null -/// when the name is not in the family at all — `not-pardes`, `pardesfoo`, and -/// helper binaries such as `pardes-snap` are other programs. -fn familyTail(name: []const u8) ?[]const u8 { - const rest = if (std.mem.startsWith(u8, name, "pardes-gui")) - name["pardes-gui".len..] - else if (std.mem.startsWith(u8, name, "pardes")) - name["pardes".len..] - else - return null; - if (rest.len == 0) return rest; - // Build names have exactly `-os-arch` after the frontend. Validating both - // fields keeps sibling installs flexible without mistaking pardes-snap, - // pardes-perf, and the other helper executables for editor frontends. - if (rest[0] != '-') return null; - var fields = std.mem.splitScalar(u8, rest[1..], '-'); - const os = fields.next() orelse return null; - const arch = fields.next() orelse return null; - if (fields.next() != null) return null; - if (std.meta.stringToEnum(std.Target.Os.Tag, os) == null) return null; - if (std.meta.stringToEnum(std.Target.Cpu.Arch, arch) == null) return null; - return rest; -} - -/// Two executable names in the same family. The tails have to agree — a linux -/// binary and an x86_64 one are two builds — unless one of them has no tail at -/// all, which is the untagged name the default build and, unavoidably, the app -/// bundle both produce: CFBundleExecutable is a fixed string, so the bundled -/// copy of `pardes-macos-aarch64` is called `pardes` and nothing in the name -/// records what it was. A foreign-arch ancestor cannot be running here. -fn sameFamily(a: []const u8, b: []const u8) bool { - const a_tail = familyTail(a) orelse return false; - const b_tail = familyTail(b) orelse return false; - if (std.mem.eql(u8, a, b)) return true; - return a_tail.len == 0 or b_tail.len == 0 or std.mem.eql(u8, a_tail, b_tail); -} - -/// The `PPid:` field of a /proc//status blob. Deliberately NOT field 4 of -/// /proc//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. -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; - return std.fmt.parseInt(libc.pid_t, std.mem.trim(u8, line["PPid:".len..], " \t\r"), 10) catch null; - } - return null; -} - -/// The pid in a `pardes-.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. -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; - for (digits) |ch| if (!std.ascii.isDigit(ch)) return null; - return std.fmt.parseInt(libc.pid_t, digits, 10) catch null; -} - -/// Name the executable behind a pid, the way this OS spells it. -fn exeOf(pid: libc.pid_t, buf: *[4096]u8) ?[]const u8 { - switch (builtin.os.tag) { - .linux => { - var name: [64:0]u8 = undefined; - const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; - const n = libc.readlink(link, buf, buf.len); - if (n <= 0) return null; - return buf[0..@intCast(n)]; - }, - else => { - if (comptime !darwin) return null; - // Documented to want a PROC_PIDPATHINFO_MAXSIZE buffer, which is - // exactly this one, and to return the length it wrote. - const n = proc_pidpath(pid, buf, @intCast(buf.len)); - if (n <= 0) return null; - return buf[0..@intCast(n)]; - }, - } -} - -/// ...and its parent. -fn parentOf(pid: libc.pid_t) ?libc.pid_t { - switch (builtin.os.tag) { - .linux => { - var name: [64:0]u8 = undefined; - var buf: [4096]u8 = undefined; - const status = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return null; - const fd = libc.open(status, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return null; - const got = libc.read(fd, &buf, buf.len); - _ = libc.close(fd); - if (got <= 0) return null; - return parsePPid(buf[0..@intCast(got)]); - }, - else => { - if (comptime !darwin) return null; - var info: proc_bsdinfo = undefined; - const n = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, @sizeOf(proc_bsdinfo)); - // A short answer means the record this was compiled against is not - // the one the kernel filled, and `ppid` is then some other field. - if (n < @as(c_int, @sizeOf(proc_bsdinfo))) return null; - return @intCast(info.ppid); - }, - } -} - -/// The pid of the nearest ancestor running a pardes executable, or null. -/// Identity is that ancestor's executable path against our own; the tty, SDL -/// and app-bundle siblings also match when they were installed together. A -/// name alone would call every unrelated `pardes` ancestor an outer instance. -/// The hop cap is not for the process tree, which cannot loop, but because the -/// walk is driven by numbers read out of the kernel and should not be able to -/// spin on a surprising one. -pub fn outer() ?libc.pid_t { - if (comptime !supported) return null; - var self_buf: [4096]u8 = undefined; - const self_exe = exeOf(libc.getpid(), &self_buf) orelse return null; - // A process harness may deliberately launch a fresh top-level pardes from - // inside another one. Its pid is a process-tree boundary, not an opt-out - // for the new session itself: pane shells below the child still detect it. - // This is what lets the snapshot harness exercise nested launches while - // the harness happens to be running in a real pardes pane. - const boundary = if (libc.getenv("PARDES_NESTED_BOUNDARY_PID")) |raw| - std.fmt.parseInt(libc.pid_t, std.mem.span(raw), 10) catch 0 - else - 0; - var pid = libc.getppid(); - var hops: usize = 0; - while (pid > 1 and hops < 64) : (hops += 1) { - if (pid == boundary) return null; - var buf: [4096]u8 = undefined; - if (exeOf(pid, &buf)) |exe| if (samePardesExecutable(exe, self_exe)) return pid; - pid = parentOf(pid) orelse return null; - } - return null; -} - -/// Hand `Look [:]` to the pardes listening as `pid` and say -/// whether it landed. False for every failure — no socket file, nobody -/// accepting, a path that does not fit — because an outer instance that -/// cannot be reached (an older build, a stale path) must never cost the -/// caller its own launch. Writes and returns: the answer is a pane appearing -/// on someone else's screen, and there is nothing to wait for. -pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool { - if (comptime !supported) return false; - // The protocol is one line, so a path with a line break IN it says - // something else entirely: `we\nird.txt` arrived as `Look .../we` and the - // outer instance opened a different file that happened to exist. \r goes - // too — the receive side trims a trailing one. Unsendable, not escaped: - // the caller falls through and opens the file in its own session. - if (std.mem.indexOfAny(u8, path, "\r\n") != null) return false; - var cmd_buf: [max_line]u8 = undefined; - const cmd = (if (line > 0) - std.fmt.bufPrint(&cmd_buf, "Look {s}:{d}\n", .{ path, line }) - else - std.fmt.bufPrint(&cmd_buf, "Look {s}\n", .{path})) catch return false; - - // sun_path-sized by construction, so `sock` cannot be longer than the - // field it is about to be copied into — socketPath returns null instead. - var path_buf: [sun_path_len]u8 = undefined; - const sock = socketPath(&path_buf, pid) orelse return false; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return false; - setCloexec(fd); - defer _ = libc.close(fd); - if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false; - var off: usize = 0; - while (off < cmd.len) { - const n = libc.write(fd, cmd.ptr + off, cmd.len - off); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return false; - } - if (n == 0) return false; - off += @intCast(n); - } - return true; -} - -/// The two things `ensureSocketDir` has to know about a path, from whichever -/// call the platform actually offers. Darwin has fstatat and no statx; on -/// linux std.c.fstatat is `void` — glibc hides it behind a versioned symbol -/// std cannot name — so linux asks statx for the same fields. Both spellings -/// refuse to follow a symlink, which is the point of asking. -/// -/// `pub` for the detached transport, which vets the same directory and also -/// vets the SOCKET FILE with it (src/detached/server.zig `vetted`): `mode` -/// carries the type bits, so one call answers "is this a socket, ours, and -/// private" as well as it answers it for a directory. -pub const DirFacts = struct { mode: u32, uid: libc.uid_t }; - -pub fn statNoFollow(path: [:0]const u8) ?DirFacts { - if (comptime darwin) { - var st: libc.Stat = undefined; - if (libc.fstatat(libc.AT.FDCWD, path, &st, libc.AT.SYMLINK_NOFOLLOW) != 0) return null; - return .{ .mode = st.mode, .uid = st.uid }; - } else { - const linux = std.os.linux; - var stx: linux.Statx = undefined; - const want: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true }; - if (libc.statx(linux.AT.FDCWD, path, linux.AT.SYMLINK_NOFOLLOW, want, &stx) != 0) return null; - return .{ .mode = stx.mode, .uid = stx.uid }; - } -} - -/// Create the socket directory if it is missing and refuse it unless it is a -/// directory WE own with nothing granted to group or other. A planted path is -/// the whole attack on a socket that runs commands — or, for the detached -/// transport that shares this, on one that carries keystrokes into a live -/// editor — and $XDG_RUNTIME_DIR passes this untouched (the login session -/// already makes it 0700). -pub fn ensureSocketDir(dir: [:0]const u8) bool { - // mkdir -p, because the HOME branch is three levels deep and a machine - // without ~/.local/state would otherwise switch the feature off in - // silence. Under $XDG_RUNTIME_DIR every prefix already exists and simply - // EEXISTs, which is the ordinary case for the leaf too. - var partial: [sun_path_len:0]u8 = undefined; - @memcpy(partial[0 .. dir.len + 1], dir[0 .. dir.len + 1]); - for (1..dir.len) |i| { - if (dir[i] != '/') continue; - partial[i] = 0; - _ = libc.mkdir(partial[0..i :0], 0o700); - partial[i] = '/'; - } - _ = libc.mkdir(dir, 0o700); - // A symlink where the directory should be is exactly the plant this - // guards against, so the stat above it does not follow one. - const st = statNoFollow(dir) orelse return false; - const IFMT: u32 = 0o170000; - const IFDIR: u32 = 0o040000; - if (st.mode & IFMT != IFDIR) return false; - if (st.uid != libc.getuid()) return false; - return st.mode & 0o077 == 0; -} - -/// Unlink the socket files of pardes processes that are gone. A pardes killed -/// rather than quit runs no defer, so its file outlives it; harmless by -/// construction (bind unlinks first, a client's connect is refused) but it is -/// our own litter and the snapshot suite alone leaves ~90 behind per run. -/// Bounded: one readdir of a directory only we write to, one kill(0) each. -fn sweep(dir: [:0]const u8) void { - const d = libc.opendir(dir) orelse return; - defer _ = libc.closedir(d); - const me = libc.getpid(); - while (libc.readdir(d)) |ent| { - const pid = sweepPid(std.mem.sliceTo(&ent.name, 0)) orelse continue; - if (pid == me) continue; - // 0 = alive; EPERM = alive and someone else's. Only ESRCH is a corpse. - const rc = libc.kill(pid, @enumFromInt(0)); - if (rc == 0 or libc.errno(rc) != .SRCH) continue; - var pbuf: [sun_path_len]u8 = undefined; - _ = libc.unlink(socketPath(&pbuf, pid) orelse continue); - } -} - -/// 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 listen() c_int { - if (comptime !supported) return -1; - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = socketDir(&dir_buf) orelse return -1; - if (!ensureSocketDir(dir)) return -1; - sweep(dir); - // Fits by construction: socketPath writes into a sun_path-sized buffer and - // returns null rather than a truncated address. - var path_buf: [sun_path_len]u8 = undefined; - const path = socketPath(&path_buf, libc.getpid()) orelse return -1; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return -1; - setCloexec(fd); - _ = libc.unlink(path); // pid reuse: a dead pardes' file would EADDRINUSE forever - if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { - _ = libc.close(fd); - return -1; - } - // Owner-only, and BEFORE listen(2), which is the moment anyone could - // connect: the directory is already private, this is the second wall. - _ = libc.chmod(path, 0o600); - if (libc.listen(fd, 8) != 0) { - _ = libc.close(fd); - return -1; - } - 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 -/// unsupported build hand it. -pub fn unlisten(fd: c_int) void { - if (fd < 0) return; - _ = libc.close(fd); - var path_buf: [sun_path_len]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) -/// would otherwise kill the listener thread for the life of the process while -/// the socket stayed bound, and every later launch would exit 0 having done -/// nothing. Every accepted connection is CLOEXEC for the reason the listener -/// is. -pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 { - if (comptime !supported) return null; - while (true) { - const conn = libc.accept(fd, null, null); - if (conn < 0) { - switch (libc.errno(conn)) { - .INTR => continue, - // the fd went away or never was one: nothing will ever arrive - .BADF, .INVAL, .NOTSOCK => return null, - // transient. Sleep first: EMFILE persists until some other fd - // is freed, and a bare `continue` would spin a core on it. - else => { - var ts: libc.timespec = .{ .sec = 0, .nsec = 100 * std.time.ns_per_ms }; - _ = libc.nanosleep(&ts, null); - continue; - }, - } - } - defer _ = libc.close(conn); - setCloexec(conn); - // A peer that connects and says nothing must not hold the listener: - // this is a serial accept loop, and one silent connection used to - // block every later launch until it let go. The client writes its one - // short line immediately, so a second is already generous. - 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; - 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 - len += @intCast(n); - if (std.mem.indexOfScalar(u8, buf[0..len], '\n') != null) break; - } - const end = std.mem.indexOfScalar(u8, buf[0..len], '\n') orelse len; - // a full buffer with no newline is an overlong line: drop it whole - // rather than run its truncation as some other command - if (end == buf.len) continue; - const line = std.mem.trimEnd(u8, buf[0..end], "\r"); - // one verb (see the file header): this socket may open things, and - // that is all it may do - if (!std.mem.startsWith(u8, line, "Look ")) continue; - return line; - } -} - -test "socket path: XDG first, then a private dir under HOME, never /tmp" { - var buf: [sun_path_len]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"); - _ = setenv("HOME", "/home/who", 1); - try std.testing.expectEqualStrings("/home/who/.local/state/pardes/pardes-4242.sock", socketPath(&buf, 4242).?); - // sun_path holds the NUL, so a directory that fills it has no socket - // address at all — say so instead of binding a truncated one. Sized from - // the field: the limit is 108 on linux and 104 on darwin, and a literal - // here would test nothing on whichever platform it was not written for. - _ = setenv("XDG_RUNTIME_DIR", "/" ++ ("x" ** (sun_path_len - 8)), 1); - try std.testing.expect(socketPath(&buf, 4242) == null); - _ = unsetenv("XDG_RUNTIME_DIR"); - _ = unsetenv("HOME"); - try std.testing.expect(socketPath(&buf, 4242) == null); -} - -test "a rebuilt binary still matches its own running instance" { - // `zig build` under a live pardes: the outer's exe link gains the suffix, - // the new process's does not, and before this the two stopped comparing - // equal — every nested launch opened a second UI. - try std.testing.expectEqualStrings("/usr/bin/pardes", stripDeleted("/usr/bin/pardes (deleted)")); - try std.testing.expectEqualStrings("/usr/bin/pardes", stripDeleted("/usr/bin/pardes")); - try std.testing.expectEqualStrings("", stripDeleted(" (deleted)")); - // only a SUFFIX, and only the whole one - try std.testing.expectEqualStrings("/x (deleted) y", stripDeleted("/x (deleted) y")); - try std.testing.expectEqualStrings("/x (delete)", stripDeleted("/x (delete)")); -} - -test "tty and GUI sibling executables recognise each other" { - try std.testing.expect(samePardesExecutable( - "/work/zig-out/bin/pardes", - "/work/zig-out/bin/pardes-gui", - )); - try std.testing.expect(samePardesExecutable( - "/work/zig-out/bin/pardes-linux-aarch64", - "/work/zig-out/bin/pardes-gui-linux-aarch64 (deleted)", - )); - // Installation paths do not define the family. This is the ordinary - // system-GUI/user-TTY pairing and the reason this comparison uses names. - try std.testing.expect(samePardesExecutable( - "/home/who/.local/bin/pardes", - "/usr/bin/pardes-gui", - )); - try std.testing.expect(!samePardesExecutable( - "/work/zig-out/bin/pardes-linux-aarch64", - "/work/zig-out/bin/pardes-gui-linux-x86_64", - )); - try std.testing.expect(!samePardesExecutable( - "/work/zig-out/bin/not-pardes", - "/work/zig-out/bin/not-pardes-gui", - )); - try std.testing.expect(!samePardesExecutable( - "/one/bin/not-pardes", - "/two/bin/not-pardes", - )); - try std.testing.expect(!samePardesExecutable( - "/work/zig-out/bin/pardes-snap", - "/usr/bin/pardes", - )); -} - -test "the app bundle is in the same executable family" { - // What `pardes foo.zig` typed into the bundle's own shell has to resolve: - // the ancestor is zig-out/pardes.app/..., this process is zig-out/bin/..., - // and nothing below zig-out is shared. - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes.app/Contents/MacOS/pardes", - "/work/zig-out/bin/pardes", - )); - // ...and the SDL sibling, which reaches it by the name rule instead. - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes.app/Contents/MacOS/pardes", - "/work/zig-out/bin/pardes-gui", - )); - // The case this machine actually produces: `zig build` installs the tty - // binary under its os-arch tail, and the bundle carries the same build - // under the one name CFBundleExecutable can spell. - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes.app/Contents/MacOS/pardes", - "/work/zig-out/bin/pardes-macos-aarch64", - )); - // Installation location does not matter here either. - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes.app/Contents/MacOS/pardes", - "/opt/zig-out/bin/pardes", - )); - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes/Contents/MacOS/pardes", - "/work/zig-out/bin/pardes", - )); - // Nothing here may loosen the rule for two unrelated programs that merely - // sit in a bin and a bundle of the same tree. - try std.testing.expect(!samePardesExecutable( - "/work/zig-out/other.app/Contents/MacOS/other", - "/work/zig-out/bin/pardes", - )); -} - -test "the ancestor walk reads this process's own parent" { - // The one thing a hand-written `struct proc_bsdinfo` gets wrong silently: - // a field ordering that puts something else where ppid should be still - // returns a plausible number. getppid knows the answer, so compare. - // - // Also the only check that libproc answers us at all — every caller of - // outer() treats a failure as "no outer instance", which is exactly what a - // permission problem would look like. - if (comptime !supported) return error.SkipZigTest; - try std.testing.expectEqual(libc.getppid(), parentOf(libc.getpid()).?); - // ...and that the walk terminates rather than spinning on pid 1's parent. - try std.testing.expect(parentOf(1) == null or parentOf(1).? <= 1); - - var buf: [4096]u8 = undefined; - const exe = exeOf(libc.getpid(), &buf).?; - try std.testing.expect(exe.len > 0); - try std.testing.expect(exe[0] == '/'); - // The test binary is not a pardes, so the walk must come back empty rather - // than matching some ancestor by accident. - try std.testing.expect(outer() == null); -} - -extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; -extern "c" fn rmdir(path: [*:0]const u8) c_int; - -test "a Look line survives the socket round trip" { - // Everything the protocol actually does, against a real kernel: bind, - // chmod, connect, write, accept, read, and the one-verb filter. The pure - // functions above cannot see any of it, and every primitive here is - // spelled differently on the two platforms this now supports. - if (comptime !supported) return error.SkipZigTest; - - // A private directory of our own. Not the developer's real state dir: this - // binds a socket named after a pid that is the TEST's, and sweep() unlinks - // what it finds beside it. - var tmpl: [64:0]u8 = undefined; - _ = std.fmt.bufPrintSentinel(&tmpl, "/tmp/pardes-nested-XXXXXX", .{}, 0) catch unreachable; - if (mkdtemp(&tmpl) == null) return error.SkipZigTest; - const dir = std.mem.sliceTo(&tmpl, 0); - defer _ = rmdir(tmpl[0..dir.len :0]); - - var xdg_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; - defer { - if (xdg0) |v| { - _ = setenv("XDG_RUNTIME_DIR", v, 1); - } else _ = unsetenv("XDG_RUNTIME_DIR"); - } - _ = setenv("XDG_RUNTIME_DIR", tmpl[0..dir.len :0], 1); - - const fd = listen(); - try std.testing.expect(fd >= 0); - defer unlisten(fd); - - // Sent to our own pid, which is the pid listen() named the socket after. - // The client closes as it returns, and the line is already queued, so the - // single-threaded accept below finds a complete connection waiting — no - // thread and no timeout needed to prove the protocol. - try std.testing.expect(sendLook(libc.getpid(), "/etc/hosts", 42)); - var buf: [max_line]u8 = undefined; - try std.testing.expectEqualStrings("Look /etc/hosts:42", acceptLine(fd, &buf).?); - - // ...and without a line number, which is the directory and image case. - try std.testing.expect(sendLook(libc.getpid(), "/etc", 0)); - try std.testing.expectEqualStrings("Look /etc", acceptLine(fd, &buf).?); - - // The socket takes one verb. Anything else is dropped rather than run, so - // the next Look is what comes back — proving the filter skipped it without - // dropping the connection after it. - try std.testing.expect(writeLine(libc.getpid(), "Exec rm -rf /\n")); - try std.testing.expect(sendLook(libc.getpid(), "/etc/passwd", 0)); - try std.testing.expectEqualStrings("Look /etc/passwd", acceptLine(fd, &buf).?); - - // A path that cannot be one line is not escaped, it is refused. - try std.testing.expect(!sendLook(libc.getpid(), "/etc/ho\nsts", 0)); -} - -/// sendLook with the framing bypassed, so a test can put something on the wire -/// that the client would never send. -fn writeLine(pid: libc.pid_t, line: []const u8) bool { - var path_buf: [sun_path_len]u8 = undefined; - const sock = socketPath(&path_buf, pid) orelse return false; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return false; - defer _ = libc.close(fd); - if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false; - return libc.write(fd, line.ptr, line.len) == @as(isize, @intCast(line.len)); -} - -test "the sweep only recognises its own socket names" { - try std.testing.expectEqual(@as(libc.pid_t, 7), sweepPid("pardes-7.sock").?); - try std.testing.expectEqual(@as(libc.pid_t, 4194304), sweepPid("pardes-4194304.sock").?); - try std.testing.expect(sweepPid("pardes-.sock") == null); - try std.testing.expect(sweepPid("pardes-7.sockx") == null); - try std.testing.expect(sweepPid("pardes-7") == null); - try std.testing.expect(sweepPid("bus") == null); - try std.testing.expect(sweepPid("pardes-osc133.bash") == null); - // parseInt alone would take these, and the sweep UNLINKS what it answers - try std.testing.expect(sweepPid("pardes-+7.sock") == null); - try std.testing.expect(sweepPid("pardes--7.sock") == null); - try std.testing.expect(sweepPid("pardes- 7.sock") == null); -} - -test "PPid comes off the status field, not a comm-shifted stat line" { - // the comm here contains a space AND parentheses — the exact shape that - // breaks `field 4 of /proc//stat` - const status = "Name:\tsh (a b)\nUmask:\t0022\nState:\tS (sleeping)\n" ++ - "Tgid:\t1234\nNgid:\t0\nPid:\t1234\nPPid:\t991\nTracerPid:\t0\n"; - try std.testing.expectEqual(@as(libc.pid_t, 991), parsePPid(status).?); - try std.testing.expectEqual(@as(libc.pid_t, 0), parsePPid("PPid:\t0\n").?); - try std.testing.expect(parsePPid("Name:\tinit\nTracerPid:\t0\n") == null); - try std.testing.expect(parsePPid("PPid:\tnotanumber\n") == null); - // a truncated read must not answer from a half line - try std.testing.expect(parsePPid("Name:\tsh\nPPi") == null); -} diff --git a/src/normal_input.zig b/src/normal_input.zig deleted file mode 100644 index 5fa29bea..00000000 --- a/src/normal_input.zig +++ /dev/null @@ -1,659 +0,0 @@ -//! Pure BODY-NORMAL input recognition. -//! -//! The platform/core boundary first normalizes a physical key into every -//! configured `Role` it matches. This module then owns the state machine: -//! counts, prefixes and literal character arguments. It deliberately knows -//! nothing about panes or text, so the text and PDF adapters consume exactly -//! the same semantic `Action` values. -const std = @import("std"); - -pub const Role = enum { - escape, - - prefix_goto, - prefix_view, - prefix_match, - prefix_find_fwd, - prefix_find_back, - prefix_till_fwd, - prefix_till_back, - prefix_replace, - prefix_next, - prefix_prev, - - goto_file_start, - goto_last_line, - goto_line_start, - goto_line_end, - goto_first_nonws, - goto_line_down, - goto_line_up, - goto_column, - goto_view_top, - goto_view_center, - goto_view_bottom, - goto_definition, - goto_declaration, - goto_type_definition, - goto_implementation, - goto_references, - - view_top, - view_center, - view_bottom, - view_scroll_down, - view_scroll_up, - - match_inside, - match_around, - surround_add, - surround_replace, - surround_delete, - - goto_paragraph, - add_newline, - goto_diagnostic, - goto_diagnostic_end, - - move_left, - move_right, - move_down, - move_up, - next_word_start, - prev_word_start, - next_word_end, - next_long_word_start, - prev_long_word_start, - next_long_word_end, - repeat_find, - line_start, - line_end, - line_first_nonws, - goto_line, - half_page_down, - half_page_up, - page_down, - page_up, - - insert, - append, - insert_line_start, - insert_line_end, - open_below, - open_above, - - select_mode, - select_line, - select_line_bounds, - shrink_to_line_bounds, - collapse_selection, - flip_selection, - select_all, - copy_sel_below, - copy_sel_above, - keep_primary_sel, - remove_primary_sel, - rotate_sel_fwd, - rotate_sel_back, - split_sel_newline, - merge_sels, - merge_consecutive_sels, - trim_sels, - select_regex, - split_regex, - - delete, - delete_noyank, - change, - yank, - replace_with_yank, - paste_after, - paste_before, - switch_case, - to_lowercase, - to_uppercase, - join_lines, - indent, - unindent, - format, - increment, - decrement, - comment_toggle, - undo, - redo, - - leader, - command_line, - pipe_selection, - pipe_selection_to, - insert_output, - append_output, - search, - search_next, - search_prev, -}; - -pub const Input = struct { - roles: std.EnumSet(Role) = .initEmpty(), - cp: u21, - ctrl: bool = false, - alt: bool = false, - - pub fn has(value: Input, role: Role) bool { - return value.roles.contains(role); - } - - fn literal(value: Input) ?u21 { - if (value.ctrl or value.alt or value.cp >= 0xF0000) return null; - return value.cp; - } -}; - -pub const Prefix = enum(u8) { - none, - goto, - view, - match, - find_fwd, - find_back, - till_fwd, - till_back, - replace, - next, - prev, -}; - -pub const MatchSub = enum(u8) { - none, - inside, - around, - surround_add, - surround_replace, - surround_delete, -}; - -pub const State = struct { - count: u32 = 0, - prefix: Prefix = .none, - match_sub: MatchSub = .none, - held_char: u21 = 0, - - pub fn clear(state: *State) void { - state.* = .{}; - } -}; - -/// WHERE a filter's output goes, and whether the selection is its stdin. -/// helix's `|`, `A-|`, `!` and `A-!` in one word each (commands.rs -/// `ShellBehavior`); its `$` (keep selections by exit status) is not here yet -/// because it needs a per-selection verdict rather than one atomic answer. -pub const PipeBehavior = enum { - /// `|` — stdin is the selection, and the output REPLACES it. - replace, - /// `A-|` — stdin is the selection, and the output is discarded. The text - /// is not touched at all; the point is the command's side effect. - ignore, - /// `!` — no stdin, and the output is inserted BEFORE each selection. - insert, - /// `A-!` — no stdin, and the output is appended AFTER each selection. - append, - - /// Do the selections become stdin? helix's `pipe` flag. - pub fn pipes(b: PipeBehavior) bool { - return b == .replace or b == .ignore; - } -}; - -pub const Scope = enum { once, per_selection }; -pub const Direction = enum { backward, forward }; -pub const Motion = enum { - left, - right, - down, - up, - next_word_start, - prev_word_start, - next_word_end, - next_long_word_start, - prev_long_word_start, - next_long_word_end, -}; -pub const Goto = enum { - file_start, - last_line, - line_start, - line_end, - first_nonws, - line_down, - line_up, - column, - view_top, - view_center, - view_bottom, -}; -pub const View = enum { top, center, bottom, scroll_down, scroll_up }; -pub const Find = enum { forward, backward, till_forward, till_backward }; -pub const Line = enum { start, end, first_nonws }; -pub const Page = enum { half_down, half_up, down, up }; -pub const Insert = enum { at, append, line_start, line_end, open_below, open_above }; -pub const Select = enum { - mode, - line, - line_bounds, - shrink_to_line_bounds, - collapse, - flip, - all, -}; -pub const Multi = enum { - copy_below, - copy_above, - keep_primary, - remove_primary, - rotate_forward, - rotate_backward, - split_newline, - merge, - merge_consecutive, - trim, -}; -pub const Edit = enum { - delete, - delete_noyank, - change, - yank, - replace_with_yank, - paste_after, - paste_before, - switch_case, - lowercase, - uppercase, - join_lines, - indent, - unindent, - comment_toggle, - undo, - redo, -}; -pub const Lsp = enum { definition, declaration, type_definition, implementation, references, format }; - -pub const Counted = struct { - count: u32, - explicit: bool, -}; - -pub const Action = union(enum) { - escape, - goto: struct { target: Goto, count: u32, explicit_count: bool }, - view: View, - find: struct { kind: Find, char: u21, count: u32 }, - replace_char: u21, - match_bracket, - textobject: struct { char: u21, around: bool }, - surround_add: u21, - surround_delete: u21, - surround_replace: struct { from: u21, to: u21 }, - paragraph: struct { direction: Direction, count: u32 }, - add_newline: struct { direction: Direction, count: u32 }, - diagnostic: struct { direction: Direction, endpoint: bool }, - move: struct { motion: Motion, count: u32 }, - repeat_find: u32, - line: Line, - goto_line: Counted, - page: struct { kind: Page, count: u32 }, - insert: struct { kind: Insert, count: u32 }, - select: struct { kind: Select, count: u32 }, - multi: struct { kind: Multi, count: u32 }, - select_regex: bool, // false = select, true = split - edit: struct { kind: Edit, count: u32 }, - lsp: Lsp, - adjust_number: i64, - leader, - command_line, - /// helix's five shell commands are one action with a behaviour, because - /// they differ only in where the output lands and whether the selection is - /// stdin. See `PipeBehavior`. - pipe_selection: PipeBehavior, - search, - search_step: Direction, - - pub fn scope(value: Action) Scope { - return switch (value) { - .escape, - .multi, - .select_regex, - .leader, - .command_line, - .pipe_selection, - .search, - .search_step, - => .once, - .edit => |edit| switch (edit.kind) { - .comment_toggle, .undo, .redo => .once, - else => .per_selection, - }, - else => .per_selection, - }; - } -}; - -pub const Result = union(enum) { - pending, - ignored, - unbound, - action: Action, -}; - -fn resultAction(value: Action) Result { - return .{ .action = value }; -} - -fn consumeCount(state: *State) Counted { - const count = state.count; - state.count = 0; - return .{ .count = @max(1, count), .explicit = count != 0 }; -} - -fn armPrefix(state: *State, prefix: Prefix, saved_count: u32) Result { - state.prefix = prefix; - state.count = saved_count; - if (prefix == .match) { - state.match_sub = .none; - state.held_char = 0; - } - return .pending; -} - -/// Consume one normalized physical key. Invalid continuations are -/// distinguished from genuinely unbound top-level keys, and always clear the -/// prefix that owned them. -pub fn parse(state: *State, key: Input) Result { - if (key.has(.escape)) { - state.clear(); - return resultAction(.escape); - } - - // A digit is a count only before a command/prefix. A leading zero keeps - // its configured line-start role; after another digit it extends count. - if (state.prefix == .none and !key.ctrl and !key.alt and - key.cp >= '0' and key.cp <= '9' and - !(key.cp == '0' and state.count == 0)) - { - if (state.count < 0xffff) - state.count = state.count * 10 + key.cp - '0'; - return .pending; - } - - const counted = consumeCount(state); - const count = counted.count; - - switch (state.prefix) { - .goto => { - state.prefix = .none; - if (key.has(.goto_file_start)) return resultAction(.{ .goto = .{ .target = .file_start, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_last_line)) return resultAction(.{ .goto = .{ .target = .last_line, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_line_start)) return resultAction(.{ .goto = .{ .target = .line_start, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_line_end)) return resultAction(.{ .goto = .{ .target = .line_end, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_first_nonws)) return resultAction(.{ .goto = .{ .target = .first_nonws, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_line_down)) return resultAction(.{ .goto = .{ .target = .line_down, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_line_up)) return resultAction(.{ .goto = .{ .target = .line_up, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_column)) return resultAction(.{ .goto = .{ .target = .column, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); - if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); - if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); - if (key.has(.goto_implementation)) return resultAction(.{ .lsp = .implementation }); - if (key.has(.goto_references)) return resultAction(.{ .lsp = .references }); - return .ignored; - }, - .view => { - state.prefix = .none; - if (key.has(.view_top)) return resultAction(.{ .view = .top }); - if (key.has(.view_center)) return resultAction(.{ .view = .center }); - if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom }); - if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down }); - if (key.has(.view_scroll_up)) return resultAction(.{ .view = .scroll_up }); - if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); - if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); - if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); - if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); - return .ignored; - }, - .find_fwd, .find_back, .till_fwd, .till_back => |prefix| { - state.prefix = .none; - const char = key.literal() orelse return .ignored; - const kind: Find = switch (prefix) { - .find_fwd => .forward, - .find_back => .backward, - .till_fwd => .till_forward, - .till_back => .till_backward, - else => unreachable, - }; - return resultAction(.{ .find = .{ .kind = kind, .char = char, .count = count } }); - }, - .replace => { - state.prefix = .none; - const char = key.literal() orelse return .ignored; - return resultAction(.{ .replace_char = char }); - }, - .match => { - if (state.match_sub == .none) { - if (key.has(.prefix_match)) { - state.prefix = .none; - return resultAction(.match_bracket); - } - const sub: MatchSub = if (key.has(.match_inside)) - .inside - else if (key.has(.match_around)) - .around - else if (key.has(.surround_add)) - .surround_add - else if (key.has(.surround_replace)) - .surround_replace - else if (key.has(.surround_delete)) - .surround_delete - else { - state.prefix = .none; - return .ignored; - }; - state.match_sub = sub; - return .pending; - } - const char = key.literal() orelse { - state.clear(); - return .ignored; - }; - if (state.match_sub == .surround_replace and state.held_char == 0) { - state.held_char = char; - return .pending; - } - const sub = state.match_sub; - const from = state.held_char; - state.clear(); - return switch (sub) { - .inside => resultAction(.{ .textobject = .{ .char = char, .around = false } }), - .around => resultAction(.{ .textobject = .{ .char = char, .around = true } }), - .surround_add => resultAction(.{ .surround_add = char }), - .surround_delete => resultAction(.{ .surround_delete = char }), - .surround_replace => resultAction(.{ .surround_replace = .{ .from = from, .to = char } }), - .none => unreachable, - }; - }, - .next, .prev => |prefix| { - state.prefix = .none; - const direction: Direction = if (prefix == .next) .forward else .backward; - if (key.has(.goto_paragraph)) return resultAction(.{ .paragraph = .{ .direction = direction, .count = count } }); - if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } }); - if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } }); - if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); - return .ignored; - }, - .none => {}, - } - - // Prefix setters retain the count for their continuation. - if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0); - if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0); - if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0); - if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0); - if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0); - if (key.has(.prefix_till_back)) return armPrefix(state, .till_back, if (counted.explicit) count else 0); - if (key.has(.prefix_replace)) return armPrefix(state, .replace, if (counted.explicit) count else 0); - if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0); - if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0); - if (key.has(.prefix_match)) return armPrefix(state, .match, 0); - - if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } }); - if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } }); - if (key.has(.move_down)) return resultAction(.{ .move = .{ .motion = .down, .count = count } }); - if (key.has(.move_up)) return resultAction(.{ .move = .{ .motion = .up, .count = count } }); - if (key.has(.next_word_start)) return resultAction(.{ .move = .{ .motion = .next_word_start, .count = count } }); - if (key.has(.prev_word_start)) return resultAction(.{ .move = .{ .motion = .prev_word_start, .count = count } }); - if (key.has(.next_word_end)) return resultAction(.{ .move = .{ .motion = .next_word_end, .count = count } }); - if (key.has(.next_long_word_start)) return resultAction(.{ .move = .{ .motion = .next_long_word_start, .count = count } }); - if (key.has(.prev_long_word_start)) return resultAction(.{ .move = .{ .motion = .prev_long_word_start, .count = count } }); - if (key.has(.next_long_word_end)) return resultAction(.{ .move = .{ .motion = .next_long_word_end, .count = count } }); - if (key.has(.repeat_find)) return resultAction(.{ .repeat_find = count }); - if (key.has(.line_start)) return resultAction(.{ .line = .start }); - if (key.has(.line_end)) return resultAction(.{ .line = .end }); - if (key.has(.line_first_nonws)) return resultAction(.{ .line = .first_nonws }); - if (key.has(.goto_line)) return resultAction(.{ .goto_line = counted }); - if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); - if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); - if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); - if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); - - if (key.has(.insert)) return resultAction(.{ .insert = .{ .kind = .at, .count = count } }); - if (key.has(.append)) return resultAction(.{ .insert = .{ .kind = .append, .count = count } }); - if (key.has(.insert_line_start)) return resultAction(.{ .insert = .{ .kind = .line_start, .count = count } }); - if (key.has(.insert_line_end)) return resultAction(.{ .insert = .{ .kind = .line_end, .count = count } }); - if (key.has(.open_below)) return resultAction(.{ .insert = .{ .kind = .open_below, .count = count } }); - if (key.has(.open_above)) return resultAction(.{ .insert = .{ .kind = .open_above, .count = count } }); - - if (key.has(.select_mode)) return resultAction(.{ .select = .{ .kind = .mode, .count = count } }); - if (key.has(.select_line)) return resultAction(.{ .select = .{ .kind = .line, .count = count } }); - if (key.has(.select_line_bounds)) return resultAction(.{ .select = .{ .kind = .line_bounds, .count = count } }); - if (key.has(.shrink_to_line_bounds)) return resultAction(.{ .select = .{ .kind = .shrink_to_line_bounds, .count = count } }); - if (key.has(.collapse_selection)) return resultAction(.{ .select = .{ .kind = .collapse, .count = count } }); - if (key.has(.flip_selection)) return resultAction(.{ .select = .{ .kind = .flip, .count = count } }); - if (key.has(.select_all)) return resultAction(.{ .select = .{ .kind = .all, .count = count } }); - - if (key.has(.copy_sel_below)) return resultAction(.{ .multi = .{ .kind = .copy_below, .count = count } }); - if (key.has(.copy_sel_above)) return resultAction(.{ .multi = .{ .kind = .copy_above, .count = count } }); - if (key.has(.keep_primary_sel)) return resultAction(.{ .multi = .{ .kind = .keep_primary, .count = count } }); - if (key.has(.remove_primary_sel)) return resultAction(.{ .multi = .{ .kind = .remove_primary, .count = count } }); - if (key.has(.rotate_sel_fwd)) return resultAction(.{ .multi = .{ .kind = .rotate_forward, .count = count } }); - if (key.has(.rotate_sel_back)) return resultAction(.{ .multi = .{ .kind = .rotate_backward, .count = count } }); - if (key.has(.split_sel_newline)) return resultAction(.{ .multi = .{ .kind = .split_newline, .count = count } }); - if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } }); - if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } }); - if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } }); - if (key.has(.select_regex)) return resultAction(.{ .select_regex = false }); - if (key.has(.split_regex)) return resultAction(.{ .select_regex = true }); - - if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); - if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); - if (key.has(.change)) return resultAction(.{ .edit = .{ .kind = .change, .count = count } }); - if (key.has(.yank)) return resultAction(.{ .edit = .{ .kind = .yank, .count = count } }); - if (key.has(.replace_with_yank)) return resultAction(.{ .edit = .{ .kind = .replace_with_yank, .count = count } }); - if (key.has(.paste_after)) return resultAction(.{ .edit = .{ .kind = .paste_after, .count = count } }); - if (key.has(.paste_before)) return resultAction(.{ .edit = .{ .kind = .paste_before, .count = count } }); - if (key.has(.switch_case)) return resultAction(.{ .edit = .{ .kind = .switch_case, .count = count } }); - if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } }); - if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } }); - if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } }); - if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); - if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); - if (key.has(.format)) return resultAction(.{ .lsp = .format }); - if (key.has(.increment)) return resultAction(.{ .adjust_number = @intCast(count) }); - if (key.has(.decrement)) return resultAction(.{ .adjust_number = -@as(i64, @intCast(count)) }); - if (key.has(.comment_toggle)) return resultAction(.{ .edit = .{ .kind = .comment_toggle, .count = count } }); - if (key.has(.undo)) return resultAction(.{ .edit = .{ .kind = .undo, .count = count } }); - if (key.has(.redo)) return resultAction(.{ .edit = .{ .kind = .redo, .count = count } }); - - if (key.has(.leader)) return resultAction(.leader); - if (key.has(.command_line)) return resultAction(.command_line); - if (key.has(.pipe_selection)) return resultAction(.{ .pipe_selection = .replace }); - if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore }); - if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); - if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); - if (key.has(.search)) return resultAction(.search); - if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); - if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); - return .unbound; -} - -fn input(cp: u21, roles: []const Role) Input { - return .{ .cp = cp, .roles = .initMany(roles) }; -} - -test "counts survive prefixes and identical parser actions can feed both adapters" { - var text: State = .{}; - var pdf: State = .{}; - const sequence = [_]Input{ - input('1', &.{}), - input('2', &.{}), - input('g', &.{ .prefix_goto, .goto_file_start }), - input('j', &.{ .move_down, .goto_line_down, .view_scroll_down }), - }; - for (sequence[0 .. sequence.len - 1]) |key| { - try std.testing.expectEqualDeep(parse(&text, key), parse(&pdf, key)); - } - const ta = parse(&text, sequence[sequence.len - 1]); - const pa = parse(&pdf, sequence[sequence.len - 1]); - try std.testing.expectEqualDeep(ta, pa); - try std.testing.expectEqualDeep(Result{ .action = .{ .goto = .{ - .target = .line_down, - .count = 12, - .explicit_count = true, - } } }, ta); - try std.testing.expectEqual(State{}, text); - try std.testing.expectEqual(State{}, pdf); -} - -test "invalid continuations are ignored and clear prefix plus count" { - var state: State = .{}; - try std.testing.expectEqual(Result.pending, parse(&state, input('4', &.{}))); - try std.testing.expectEqual(Result.pending, parse(&state, input('g', &.{.prefix_goto}))); - try std.testing.expectEqual(Result.ignored, parse(&state, input('?', &.{}))); - try std.testing.expectEqual(State{}, state); - try std.testing.expectEqualDeep(Result{ .action = .{ .move = .{ .motion = .down, .count = 1 } } }, parse(&state, input('j', &.{.move_down}))); -} - -test "literal arguments retain conflicting command characters" { - var state: State = .{}; - try std.testing.expectEqual(Result.pending, parse(&state, input('f', &.{.prefix_find_fwd}))); - try std.testing.expectEqualDeep(Result{ .action = .{ .find = .{ .kind = .forward, .char = 'p', .count = 1 } } }, parse(&state, input('p', &.{.paste_after}))); - - try std.testing.expectEqual(Result.pending, parse(&state, input('m', &.{.prefix_match}))); - try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.surround_replace}))); - try std.testing.expectEqual(Result.pending, parse(&state, input('[', &.{.prefix_prev}))); - try std.testing.expectEqualDeep(Result{ .action = .{ .surround_replace = .{ .from = '[', .to = ']' } } }, parse(&state, input(']', &.{.prefix_next}))); - try std.testing.expectEqual(State{}, state); -} - -test "modified and special keys cannot satisfy literal continuations" { - var state: State = .{}; - _ = parse(&state, input('r', &.{.prefix_replace})); - try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 'x', .ctrl = true })); - try std.testing.expectEqual(State{}, state); - _ = parse(&state, input('f', &.{.prefix_find_fwd})); - try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 0xF0001 })); - try std.testing.expectEqual(State{}, state); -} - -test "replace accepts a Unicode literal" { - var state: State = .{}; - try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.prefix_replace}))); - try std.testing.expectEqualDeep(Result{ .action = .{ .replace_char = '界' } }, parse(&state, input('界', &.{}))); - try std.testing.expectEqual(State{}, state); -} - -test "once versus per-selection is semantic action metadata" { - try std.testing.expectEqual(Scope.once, (@as(Action, .search)).scope()); - try std.testing.expectEqual(Scope.once, (Action{ .edit = .{ .kind = .undo, .count = 1 } }).scope()); - try std.testing.expectEqual(Scope.per_selection, (Action{ .edit = .{ .kind = .delete, .count = 1 } }).scope()); - try std.testing.expectEqual(Scope.per_selection, (Action{ .move = .{ .motion = .down, .count = 3 } }).scope()); -} diff --git a/src/output_pane.zig b/src/output_pane.zig deleted file mode 100644 index f4721136..00000000 --- a/src/output_pane.zig +++ /dev/null @@ -1,695 +0,0 @@ -//! Output panes: acme's +Errors, a file pane with no file behind it, holding -//! text the core produced itself (+Search results, +Help, the LSP answer -//! buffers). It IS a file pane — every mode, motion, chord and look works for -//! free — and that reuse is the point. -//! -//! What it is NOT any more is a file pane with a bool on it. An output buffer -//! remembers THE COMMAND THAT OPENED IT (`Origin`), and every special case it -//! gets is one `traits` lookup on that field. Before this, a dozen places -//! re-derived what a pane was from the outside, each asking a different wrong -//! question: `endsWith(path, "+Search")` (the NAME decided what a pane WAS, -//! which is backwards — a name is a consequence), a `search_kind` field on the -//! pane that ran the search rather than on the buffer that answered it, and -//! `f.output` booleans sprinkled through kind-agnostic layout code. Now the -//! buffer knows, and the table below is the whole answer: one screen you read -//! top to bottom to see every way an output pane differs from a file, and one -//! row to add to introduce another kind. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const file_pane = @import("file_pane.zig"); -const modal = @import("modal.zig"); -const builtins = @import("builtins.zig"); -const runtime_config = @import("runtime_config.zig"); -const effect_sources = @import("effect_sources.zig"); -const Builtin = builtins.registry.Builtin(); -const config = @import("config.zig"); -const build_options = @import("pardes_config"); -const dump = @import("dump.zig"); -const lsp = @import("lsp/lsp.zig"); -const gui_shader_source_mode = effect_sources.guiShaderSourceMode(); - -/// the installed fonts, for openFonts. GUI only, behind the same comptime -/// branch builtins.zig imports it through — see the note there. -const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {}; - -/// What opened this buffer — THE field, and the only input to `traits`. -/// -/// Two vocabularies because the core has exactly two: a `Builtin` is a word -/// you can execute anywhere, and that covers Find, Grep, Help and every -/// language query that has a name (Hover, Diagnostics...). The rest are KEYS — -/// helix binds the five gotos and `=` as motions and `/` as a search input, -/// and a motion has no word to click. Recording the key's `lsp.Kind` (or -/// `.search` for the bare `/`) is not a parallel tag enum: both are the values -/// the caller already holds when it opens the buffer. -pub const Origin = union(enum) { - cmd: Builtin, - query: lsp.Kind, - /// the bare `/` — the pane's own text, searched in core - search, - /// acme's `+Errors`: whatever a script wrote to a pane's `errors` file or - /// to the top-level `cons`. Not a command at all — the third vocabulary - /// is "somebody else's output", and it has no word to click because the - /// writer is a process, not a keystroke. - errors, -}; - -/// The exact command identity. A search prompt is bounded by the same one-line -/// cap as a tag, so retaining that whole bound keeps refill and dump/restore -/// identity exact without adding a per-output allocation. -pub const max_arg = dump.max_origin_arg; - -/// An output buffer's own state, hung off `file_pane.State.output`. -pub const Output = struct { - from: Origin, - /// the command's ARGUMENT: the pattern a Grep matched, the new name a - /// Rename took, the SPC prefix a Help lists. Inline rather than allocated: - /// tag input already enforces this exact cap. - arg_buf: [max_arg]u8 = undefined, - arg_len: u16 = 0, - - pub fn arg(o: *const Output) []const u8 { - return o.arg_buf[0..o.arg_len]; - } -}; - -pub fn setArg(o: *Output, text: []const u8) error{ArgumentTooLong}!void { - if (text.len > max_arg) return error.ArgumentTooLong; - o.arg_len = @intCast(text.len); - @memcpy(o.arg_buf[0..o.arg_len], text); -} - -/// Every way an output pane differs from a file pane. Manual builtins already -/// declare this exact row beside their implementation; use that schema here -/// too instead of copying it into a parallel struct. -pub const Traits = builtins.OutputTraits; - -/// A REAL file pane, as a row of the same table — so kind-agnostic code asks -/// one question and gets one answer whichever it is holding. `name` is unused: -/// a file already has a path. -const file_row: Traits = .{ .name = "", .doc = true, .saves = true }; - -/// THE TABLE. Everything above, answered from the command that opened the -/// buffer. Exhaustive on purpose: a new `lsp.Kind` or a new output-opening -/// builtin should not compile until someone has said what its buffer does. -pub fn traits(o: Origin) Traits { - return switch (o) { - // rows are `location text`, so n/N walk them - .search => .{ .name = config.search_buffer, .steps = true }, - // A transcript, not a list: rows are whatever a program printed, so - // n/N walks its words like any prose buffer, and there is nothing to - // Save — acme's +Errors is not a file either. - .errors => .{ .name = config.errors_buffer, .doc = true }, - .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, - .query => |k| switch (k) { - .hover => .{ .name = config.hover_buffer }, - // prose: an action list, a diff, a report about the backend - .code_action, .format, .status, .explain => .{ .name = config.lsp_buffer }, - // Rename usually resolves to edit records the core consumes before - // any buffer opens; what RENDERS is the multi-file PREVIEW — one - // location row per would-be edit — which n/N step like any list. - .rename => .{ .name = config.search_buffer, .steps = true }, - .definition, .declaration, .type_definition, .implementation, .references => .{ - .name = config.search_buffer, - .steps = true, - .jumps = true, - }, - // The hierarchy kinds behave like references: a list of places, - // and a lone answer (one caller, one subtype) is a jump. - .incoming_calls, .outgoing_calls, .supertypes, .subtypes => .{ - .name = config.search_buffer, - .steps = true, - .jumps = true, - }, - // completion lists WHAT COULD GO HERE, one row per candidate's - // definition. It does not jump on a single row where the gotos do: - // a goto answers a question whose answer is a place, so landing - // there IS the answer, whereas the question here is "what can I - // write", and being teleported into the one candidate's - // declaration instead of being shown it is not that. - .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{ - .name = config.search_buffer, - .steps = true, - }, - }, - }; -} - -/// The same table asked of a file pane's `output` field, null (a real file) -/// included. This is what the kind-agnostic code in pardes.zig calls. -pub fn fileTraits(out: ?Output) Traits { - return traits((out orelse return file_row).from); -} - -/// How much of a row ONE n/N step selects (Pardes.lookSpanIn). Derived from -/// the two columns above rather than a third one, because it is not a fact -/// about a buffer — it is what those facts MEAN to the walk. -pub const Grain = enum { - /// every look-able word, several to a line, in document order. Free text: - /// a terminal's scrollback, a file, a PDF, and an output buffer of PROSE, - /// where the place you want may be mid-sentence. - word, - /// the location at the head of the row, and one stop per row. A results - /// buffer is a LIST: the words after a row's location are the matched - /// text, and stepping onto them was stepping onto the same hit twice. - line, - /// the whole row: a command list, where the line is the word. - whole, -}; - -/// The grain of a pane's rows, off its `output` field — null (a real file) -/// included, which is why a file pane is unaffected by any of this. -pub fn grain(out: ?Output) Grain { - const tr = fileTraits(out); - if (tr.commands) return .whole; - return if (tr.steps) .line else .word; -} - -/// How the dump spells an origin. A WORD, never an integer, for the reason the -/// dump already stores tag words: reordering builtins.zig stays free. Nothing -/// collides — a builtin is CamelCase, an lsp.Kind is snake_case, and `/` is -/// neither. -pub fn word(o: Origin) []const u8 { - return switch (o) { - .cmd => |b| @tagName(b), - .query => |k| @tagName(k), - .search => "/", - .errors => config.errors_buffer, - }; -} - -/// the inverse; null for "" (a real file) and for a word this build no longer -/// has, which is a dump from another version and not a reason to fail a load -pub fn fromWord(w: []const u8) ?Origin { - if (w.len == 0) return null; - if (std.mem.eql(u8, w, "/")) return .search; - if (std.mem.eql(u8, w, config.errors_buffer)) return .errors; - if (std.meta.stringToEnum(Builtin, w)) |b| - if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b }; - if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; - return null; -} - -/// Is the results buffer `pane`'s n/N is armed on the one `from` filled? -/// `]d`/`[d` are the only keys that care WHICH search is showing — they step -/// the diagnostics list when it is up and ask for one when it is not — and -/// this is how they ask now that the buffer remembers: `search_pane` is a -/// SLOT, so this doubles as the check that the slot is still ours. -pub fn resultsFrom(p: *Pardes, pane: *Pane, from: Origin) bool { - const rp = p.panes[pane.search_pane orelse return false] orelse return false; - const f = rp.file orelse return false; - const o = f.output orelse return false; - return std.meta.eql(o.from, from); -} - -/// Open one. `content` is gpa-owned and adopted; the NAME comes from the table -/// (the caller says what ran, not what to call it) and carries `dir` so looks -/// inside the buffer resolve like anywhere else. -pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8) !*Pane { - const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ - std.mem.trimEnd(u8, dir, "/"), traits(from).name, - }); - errdefer p.gpa.free(path); - var out: Output = .{ .from = from }; - try setArg(&out, arg); - const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content, .output = out }; - pane.cur_pinned = true; - 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 { - errdefer p.gpa.free(content); - const pane = p.panes[id] orelse return error.MissingPane; - 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; - try 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; - p.armLookWalk(i); - } - return; - } - const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, arg, content); - 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; - p.armLookWalk(free); - } -} - -/// 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 -/// for nothing: the leading word is an ordinary look target, and the ordinary -/// look path is what goes there. -/// -/// A RENDERING, never a second list. The rows are spelled from the stack at -/// the moment you ask and go stale the moment you jump, exactly like a search -/// result — which is also why this opens a fresh buffer per press instead of -/// refreshing one the way Help does: Help is a document, this is a snapshot. -/// -/// How a location is spelled is the rule runSearch already follows: a REAL -/// file names itself (its path is absolute, so the row resolves from any -/// pane's directory), and everything else — a terminal, an output buffer, an -/// image — has no file to point at and gets `@pN`. The trailing text is the -/// content line for anything holding text, else the pane's directory: enough -/// to recognise the place without opening it. -pub fn openJumps(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - for (p.jumps[0..p.njumps]) |j| { - const jp = p.panes[j.pane] orelse continue; - var idbuf: [16]u8 = undefined; - const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null; - const has_path = if (jp.file) |f| f.output == null else pdf_path != null; - const loc: []const u8 = if (has_path) - (if (jp.file) |f| f.path else pdf_path.?) - else - std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable; - const what: []const u8 = if (jp.file) |f| - std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") - else if (jp.image) |iv| - iv.path - else if (pdf_path) |path| - path - else - jp.cwdSlice(); - var cut = @min(what.len, 120); - while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; - if (j.line == 0) - try out.writer.print("{s} {s}\n", .{ loc, what[0..cut] }) - else - try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }); - } - const content = try out.toOwnedSlice(); - try openStepped(p, id, .{ .cmd = .Jumplist }, content); -} - -/// The ThemeSel builtin: the theme ring written out as one `Theme ` row -/// per theme — the ordinary builtin with its argument, exactly the line you -/// would type — into a buffer whose `commands` trait says the rows are words -/// and not places. n/N therefore select each row WHOLE and Tab runs it, so -/// walking the list is trying the themes on and stopping on one is choosing -/// it: no picker mode, no preview state, nothing to commit or cancel. -/// -/// The command's own name comes from the runtime setting descriptor rather -/// than a second literal. The descriptor generates the builtin too, so a -/// rename cannot leave picker rows naming a command that is gone. -pub fn openThemes(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - for (pardes.themes) |t| - try out.writer.print(comptime runtime_config.findAction(.theme).?.word ++ " {s}\n", .{t.name}); - const content = try out.toOwnedSlice(); - try openStepped(p, id, .{ .cmd = .ThemeSel }, content); -} - -/// The FontSel builtin: openThemes over the fonts installed on the machine -/// instead of the themes compiled into the binary — one `Font ` row -/// each, in a buffer whose rows n/N RUN, so walking it wears the fonts and -/// stopping picks one. Everything that makes that work is already above; this -/// is the same one-pass writer pointed at a different list. -/// -/// Only where the shell draws its own text. The same `font_picker` availability -/// bit that generates the Font/FontSel builtins keeps non-GUI builds from -/// analysing font discovery here. -pub fn openFonts(p: *Pardes, id: usize) !void { - if (builtins.capabilities.font_picker) { - const arena = p.scratch.allocator(); - const font_list = fonts.list(arena, null); - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - for (font_list) |f| - try out.writer.print(comptime runtime_config.findAction(.font).?.word ++ " {s}\n", .{f.name}); - const content = try out.toOwnedSlice(); - try openStepped(p, id, .{ .cmd = .FontSel }, content); - } -} - -/// Open a buffer n/N will walk, and arm them on it: the shared tail of every -/// builtin that answers with a list. `content` is gpa-owned and adopted by the -/// new pane, or freed here if opening it fails. -/// -/// Focus stays with the pane that ASKED, exactly as it does after a search: -/// n/N are read there, and they step the buffer they just armed. -fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void { - errdefer p.gpa.free(content); - const pane = p.panes[id] orelse return error.MissingPane; - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, "", content); - p.placeDoc(id, free, np); - p.active = id; - pane.search_pane = free; - pane.search_row = null; - p.armLookWalk(free); -} - -/// The Help builtin: THE INDEX of builtins — every one of them, and every way -/// to run it — filtered to what `prefix` can still reach, written into an -/// output buffer (acme's +Errors). Ordinary text, so the names in it are LIVE: -/// middle-click `Tutor` there and the tutor opens. Reuses the open +Help -/// buffer instead of piling panes up, and focus follows: you asked to read it. -/// -/// ONE builtin and not two. The complete index and the mid-chord "what can -/// `SPC h` still reach" are the same array (pardes.builtin_rows) read with a -/// different prefix — the empty one matches every row, including the builtins -/// SPC cannot reach at all, so the reference page IS the filter's degenerate -/// case. A second builtin would have been a second renderer over a superset of -/// these rows, and the two would have drifted the first time a column moved. -fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 { - const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n"; - const group_header = "pardes builtins under SPC"; - // The LANGUAGE KEYS are the one part of the keymap Help would otherwise - // never show: they are motions and modes, not words, so no builtin row - // carries them — yet they are the keys a reader comes looking for. Full - // listing only; a mid-chord `SPC l` view stays a pure filter. - const language_footer = - "\nlanguage keys (motions, not words):\n" ++ - "gd gD gy gi gr goto: definition,\n" ++ - " declaration, type-def,\n" ++ - " implementation, refs\n" ++ - "]d [d ]D [D diagnostics: next,\n" ++ - " prev, last, first\n" ++ - "= format (applies, one\n" ++ - " undo step)\n" ++ - "Tab after a . completion, in insert\n" ++ - "C-left-click definition, by mouse\n" ++ - "SPC l ... hover, rename, symbols,\n" ++ - " calls, types: above\n"; - var len: usize = if (prefix.len == 0) - full_header.len + language_footer.len - else - group_header.len + prefix.len * 2 + 2; - for (pardes.builtin_rows) |row| { - // a path-less builtin filters as the empty path: in the full listing - // (which starts with nothing) and out of every group - if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; - len += row.line.len + 1; - } - const content = try gpa.alloc(u8, len); - var at: usize = 0; - if (prefix.len == 0) { - @memcpy(content[0..full_header.len], full_header); - at = full_header.len; - } else { - @memcpy(content[0..group_header.len], group_header); - at = group_header.len; - for (prefix) |c| { - content[at] = ' '; - content[at + 1] = c; - at += 2; - } - content[at] = '\n'; - content[at + 1] = '\n'; - at += 2; - } - for (pardes.builtin_rows) |row| { - if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; - @memcpy(content[at..][0..row.line.len], row.line); - at += row.line.len; - content[at] = '\n'; - at += 1; - } - if (prefix.len == 0) { - @memcpy(content[at..][0..language_footer.len], language_footer); - at += language_footer.len; - } - std.debug.assert(at == content.len); - return content; -} - -pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void { - const content = try helpContent(p.gpa, prefix); - // content is handed off unfreed on purpose: openRead adopts it or frees - // it, and nothing between the alloc above and this line can fail. - return openRead(p, id, .{ .cmd = .Help }, prefix, content); -} - -test "full Help renders every builtin row, then the language keys" { - const content = try helpContent(std.testing.allocator, ""); - defer std.testing.allocator.free(content); - - // FIRST blank line: the end of the header (the footer opens with one too) - const body = content[(std.mem.indexOf(u8, content, "\n\n") orelse - return error.MissingHelpHeader) + 2 ..]; - var lines = std.mem.splitScalar(u8, body, '\n'); - for (pardes.builtin_rows) |row| - try std.testing.expectEqualStrings(row.line, lines.next() orelse - return error.MissingBuiltinHelpRow); - // ...and after the last row, the language-keys section: the one part of - // the keymap no builtin row can carry, closing the page. - try std.testing.expectEqualStrings("", lines.next() orelse - return error.MissingLanguageKeys); - try std.testing.expectEqualStrings("language keys (motions, not words):", lines.next() orelse - return error.MissingLanguageKeys); - try std.testing.expect(std.mem.indexOf(u8, body, "\ngd gD gy gi gr goto: definition,\n") != null); - // the group view stays a pure filter: no footer under a prefix - const group = try helpContent(std.testing.allocator, "l"); - defer std.testing.allocator.free(group); - try std.testing.expect(std.mem.indexOf(u8, group, "language keys") == null); -} - -/// The complete live Config report: the generated settings and the host facts -/// needed to interpret them. The startup path remains ordinary selectable text -/// in the report, so Look still opens the exact file the launcher consulted. -pub fn openConfig(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try runtime_config.writeReport(&out.writer, .{ - .startup_config_path = p.opts.startup_config_path, - .platform = @tagName(pardes.platform), - .theme_name = p.theme().name, - .compiled_default_shell = config.default_shell, - .gui_shader_source_mode = if (gui_shader_source_mode) |mode| - mode.label() - else - null, - .hover_delay_frames = config.look_preview_delay_frames, - .native_images = p.native_images, - .capabilities = builtins.capabilities, - .state = &p.settings, - }); - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .Config }, "", content); -} - -/// The message-row log, oldest first — the lines that were said in passing and -/// then cleared by the next keystroke. -pub fn openMessages(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - var i: usize = 0; - while (p.messageLog(i)) |m| : (i += 1) { - if (m.pane != 0xff) try out.writer.print("{d}: ", .{m.pane}); - try out.writer.writeAll(m.slice()); - if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats}); - try out.writer.writeByte('\n'); - } - if (i == 0) try out.writer.writeAll("nothing has been said yet\n"); - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .Messages }, "", content); -} - -/// The version banner plus the embedded CHANGELOG, so an installed binary can -/// say what it is and what changed without a repository beside it. -pub fn openChangelog(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try out.writer.print("pardes {s}\n\n", .{build_options.version}); - try out.writer.writeAll(@embedFile("CHANGELOG.md")); - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .Changelog }, "", content); -} - -/// Print the implementation that this build actually uses for one effect. -/// Sources are build inputs embedded as bytes, so this stays useful from an -/// installed binary with no repository beside it. -pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void { - const name = std.mem.trim(u8, argument, " \t\r\n"); - const setting = runtime_config.find(name) orelse return error.UnknownEffect; - switch (setting.action) { - .transition, .scene => {}, - else => return error.NotAnEffect, - } - if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable; - const segments = effect_sources.forSetting(setting) orelse - return error.EffectUnavailable; - - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) }); - if (gui_shader_source_mode) |mode| - try out.writer.print("GUI shader source: {s}\n", .{mode.label()}); - try out.writer.writeByte('\n'); - for (segments) |segment| { - try out.writer.print("--- {s} ---\n", .{segment.path}); - try out.writer.writeAll(segment.source); - if (!std.mem.endsWith(u8, segment.source, "\n")) try out.writer.writeByte('\n'); - try out.writer.writeByte('\n'); - } - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, content); -} - -/// Open a buffer you READ, and go there: the shared tail of every builtin -/// whose answer is a document rather than a list. Asking again REFRESHES the -/// one already open instead of stacking a twin beside it — found by its -/// ORIGIN, never by matching its name, for the reason the whole file exists. -/// -/// The mirror of `openStepped`, and the difference is the two lines at the -/// ends: focus comes HERE (you asked to read it) where a results buffer -/// leaves you in the pane that asked, and n/N are not armed, because prose -/// has nowhere to step to. -/// -/// `content` is gpa-owned: adopted by the buffer, or freed here when there is -/// nowhere to put it. -/// Put a report in this directory's `+Errors` buffer — acme's own name for -/// output that came from the PROGRAM rather than from a word somebody clicked. -/// Refills the one already open rather than stacking a twin, which is what a -/// second failed filter wants: the newest reason is the one being read. -pub fn openErrors(p: *Pardes, id: usize, content: []u8) !void { - return openRead(p, id, .errors, "", content); -} - -fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void { - errdefer p.gpa.free(content); - const pane = p.panes[id] orelse return error.MissingPane; - for (p.panes, 0..) |slot, i| { - const hp = slot orelse continue; - const hf = if (hp.file) |*f| f else continue; - const ho = if (hf.output) |*o| o else continue; - if (!std.meta.eql(ho.from, from)) continue; - try setArg(ho, arg); - file_pane.setContent(p, hf, content); - hf.scroll = 0; - hp.cur_row = 0; - hp.msel.active = false; - p.active = i; - return; - } - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, arg, content); - p.placeDoc(id, free, np); - p.active = free; -} - -test "dump origins accept only builtins that actually own output panes" { - try std.testing.expectEqual(Origin{ .cmd = .Help }, fromWord("Help").?); - try std.testing.expect(fromWord("Kill") == null); - try std.testing.expect(fromWord("Theme") == null); -} - -test "result refill identity retains the full bounded argument" { - const p = try Pardes.init(std.testing.allocator, .{ - .tty_only = true, - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - - var first: [max_arg]u8 = @splat('a'); - var second = first; - first[200] = 'x'; - second[200] = 'y'; - - try fillResults( - p, - 0, - "/tmp", - .search, - &first, - try p.gpa.dupe(u8, "first\n"), - null, - ); - const first_id = p.panes[0].?.search_pane orelse return error.MissingResults; - const next_slot = p.freeSlot(); - - try fillResults( - p, - 0, - "/tmp", - .search, - &first, - try p.gpa.dupe(u8, "refilled\n"), - null, - ); - try std.testing.expectEqual(first_id, p.panes[0].?.search_pane.?); - try std.testing.expectEqual(next_slot, p.freeSlot()); - try std.testing.expectEqualStrings("refilled\n", p.panes[first_id].?.file.?.content); - - try fillResults( - p, - 0, - "/tmp", - .search, - &second, - try p.gpa.dupe(u8, "second\n"), - null, - ); - try std.testing.expect(p.panes[0].?.search_pane.? != first_id); - - var output: Output = .{ .from = .search }; - var oversized: [max_arg + 1]u8 = @splat('z'); - try std.testing.expectError(error.ArgumentTooLong, setArg(&output, &oversized)); - const slot_before_error = p.freeSlot(); - try std.testing.expectError( - error.ArgumentTooLong, - fillResults( - p, - 0, - "/tmp", - .search, - &oversized, - try p.gpa.dupe(u8, "must be freed\n"), - null, - ), - ); - try std.testing.expectEqual(slot_before_error, p.freeSlot()); -} diff --git a/src/output_pane_integration_test.zig b/src/output_pane_integration_test.zig deleted file mode 100644 index e7a46a03..00000000 --- a/src/output_pane_integration_test.zig +++ /dev/null @@ -1,338 +0,0 @@ -//! End-to-end output-pane tests. Production output identity, rendering, and -//! builders stay in output_pane.zig; this module exercises their direct seam -//! with Pardes builtins and the generic n/N input walk. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const output_pane = @import("output_pane.zig"); -const runtime_config = @import("runtime_config.zig"); -const builtins = @import("builtins.zig"); -const config = @import("config.zig"); -const modal = @import("modal.zig"); - -const Pardes = pardes.Pardes; -const Key = pardes.Key; -const platform = pardes.platform; -const font_picker = pardes.font_picker; -const fonts = if (font_picker) @import("fonts.zig") else struct {}; - -test "Config prints the startup path and refreshes its one output" { - const path = "/home/pardes-test/.config/pardes/init"; - const p = try Pardes.init(std.testing.allocator, .{ .startup_config_path = path }); - defer p.deinit(); - - try std.testing.expect(p.executeBuiltinLine(0, "Config")); - const opened = p.active; - const out = p.panes[opened].?.file.?; - for ([_][]const u8{ - "Startup config: " ++ path ++ "\n", - "Theme: helix\n", - "Shell requested (new panes): " ++ config.default_shell ++ " (default)\n", - "Shell effective (last spawn): (none)\n", - "Shell pending: on\n", - }) |line| try std.testing.expect(std.mem.indexOf(u8, out.content, line) != null); - try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path)); - try std.testing.expectEqual(output_pane.Origin{ .cmd = .Config }, out.output.?.from); - - try std.testing.expect(p.executeBuiltinLine(0, "Config")); - try std.testing.expectEqual(opened, p.active); - var buffers: usize = 0; - for (p.panes) |slot| { - const f = (slot orelse continue).file orelse continue; - const origin = (f.output orelse continue).from; - buffers += @intFromBool(std.meta.eql(origin, output_pane.Origin{ .cmd = .Config })); - } - try std.testing.expectEqual(@as(usize, 1), buffers); -} - -test "Config reports the absence of a per-user config path" { - const p = try Pardes.init(std.testing.allocator, .{}); - defer p.deinit(); - try std.testing.expect(p.executeBuiltinLine(0, "Config")); - const report = p.panes[p.active].?.file.?.content; - try std.testing.expect(std.mem.indexOf(u8, report, "no per-user config path") != null); -} - -test "EffectCode opens the embedded implementation used by this backend" { - if (comptime platform == .web) return; - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - - try std.testing.expect(p.executeBuiltinLine(0, "EffectCode PanelSlide")); - const out = p.panes[p.active].?.file.?; - try std.testing.expectEqualStrings(config.effect_code_buffer, std.fs.path.basename(out.path)); - try std.testing.expectEqual(output_pane.Origin{ .cmd = .EffectCode }, out.output.?.from); - try std.testing.expectEqualStrings("PanelSlide", out.output.?.arg()); - try std.testing.expect(std.mem.indexOf(u8, out.content, "pub const Transition = enum") != null); - const backend_source = switch (platform) { - .tty => "src/tty/panel_compositor.zig", - .gui => if (@import("pardes_config").gui_shader_sources_prebuilt) - "shaders/prebuilt/ui.vert.glsl" - else - "shaders/ui.vert.glsl", - .macos => "src/macos/Sources/ScenePostprocessor.swift", - // Neither backend builds this native test binary: the browser shell is wasm and the P4 - // firmware is a freestanding object, so no `unit-test` run can ever land here. - .web, .esp32p4 => unreachable, - }; - try std.testing.expect(std.mem.indexOf(u8, out.content, backend_source) != null); -} - -test "every enabled setting builtin mutates the State Config reports" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - try std.testing.expectEqual(config.gui_tagline_font_percent, p.settings.font.tagline_percent); - - var font_arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer font_arena.deinit(); - var chosen_font: ?[]const u8 = null; - var buf: [512]u8 = undefined; - for (runtime_config.settings) |setting| { - if (!setting.enabled(builtins.capabilities)) continue; - const command: []const u8 = switch (setting.action) { - .theme => try std.fmt.bufPrint(&buf, "{s} acme", .{setting.word}), - .shell => try std.fmt.bufPrint(&buf, "{s} fish", .{setting.word}), - .tagline_size => try std.fmt.bufPrint(&buf, "{s} 67", .{setting.word}), - .font => continue, - else => setting.word, - }; - try std.testing.expect(p.executeBuiltinLine(p.active, command)); - } - if (comptime font_picker) { - const before_invalid = p.settings.font.tagline_percent; - try std.testing.expect(p.executeBuiltinLine(p.active, "TaglineSize 0")); - try std.testing.expectEqual(before_invalid, p.settings.font.tagline_percent); - const installed = fonts.list(font_arena.allocator(), null); - if (installed.len > 0) { - chosen_font = installed[0].name; - const command = try std.fmt.bufPrint(&buf, "Font {s}", .{installed[0].name}); - try std.testing.expect(p.executeBuiltinLine(p.active, command)); - } - } - - try std.testing.expect(p.executeBuiltinLine(p.active, "Config")); - const report = p.panes[p.active].?.file.?.content; - for ([_][]const u8{ - "Colors: off\n", - "Wrap: off\n", - "Tagbottom: on\n", - "Debug: on\n", - "Theme: acme\n", - "Shell requested (new panes): fish\n", - }) |line| try std.testing.expect(std.mem.indexOf(u8, report, line) != null); - const transition = if (builtins.capabilities.panel_transitions) - try std.fmt.bufPrint(&buf, "Panel transition: {s}\n", .{ - runtime_config.findAction(.{ .transition = p.settings.panel_transition }).?.word, - }) - else - "Panel transition: unsupported\n"; - try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); - const scene_status = if (builtins.capabilities.scene_shaders) "on" else "unsupported"; - for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { - const line = try std.fmt.bufPrint(&buf, "{s}: {s}\n", .{ name, scene_status }); - try std.testing.expect(std.mem.indexOf(u8, report, line) != null); - } - const tagline = if (!builtins.capabilities.tagline_font_size) - "TaglineSize: unsupported\n" - else - try std.fmt.bufPrint(&buf, "TaglineSize: {d}%{s}\n", .{ - p.settings.font.tagline_percent, - if (builtins.capabilities.font_picker) "" else " (build-time only)", - }); - try std.testing.expect(std.mem.indexOf(u8, report, tagline) != null); - if (chosen_font) |name| { - try std.testing.expect(std.mem.indexOf(u8, report, name) != null); - try std.testing.expect(std.mem.indexOf(u8, report, "Font pending: on\n") != null); - } -} - -fn walkFixture(p: *Pardes, id: usize, cwd: []const u8, pattern: []const u8) !usize { - const rows = try p.gpa.dupe(u8, - \\build.zig:1:1 first - \\(mise.toml) and build.zig.zon:3:2-9 two on one row - \\nothing look-able on this row at all - \\uucode_config.zig:7:1 last - \\ - ); - try output_pane.fillResults(p, id, cwd, .search, pattern, rows, null); - return p.panes[id].?.search_pane orelse error.MissingResults; -} - -const ProjectPaths = struct { cwd: []const u8, boot: []const u8 }; - -fn projectPaths(cwd_buf: *[4096]u8, path_buf: *[4096]u8) !ProjectPaths { - const raw = std.c.getcwd(cwd_buf, cwd_buf.len) orelse return error.GetCwdFailed; - const cwd = std.mem.span(@as([*:0]u8, @ptrCast(raw))); - return .{ .cwd = cwd, .boot = try std.fmt.bufPrint(path_buf, "{s}/mise.toml", .{cwd}) }; -} - -fn selectedOutputText(pane: *const pardes.Pane) ?[]const u8 { - const file = pane.file orelse return null; - if (!pane.vsel.active or pane.vsel.row != pane.cur_row or pane.cur_row < 0) return null; - const line = modal.lineSlice(file.content, @intCast(pane.cur_row)); - const lo: usize = @intCast(@max(0, @min(pane.vsel.col, pane.cur_col))); - const hi: usize = @intCast(@max(0, @max(pane.vsel.col, pane.cur_col))); - if (lo >= line.len) return ""; - return line[lo..@min(line.len, hi + 1)]; -} - -test "n/N selects one output location per row and opens nothing" { - if (platform == .web) return; - var cwd_buf: [4096]u8 = undefined; - var path_buf: [4096]u8 = undefined; - const paths = try projectPaths(&cwd_buf, &path_buf); - const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); - defer p.deinit(); - p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); - const rid = try walkFixture(p, p.active, paths.cwd, "one"); - const results = p.panes[rid].?; - const panes_before = p.freeSlot(); - - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(rid, p.active); - try std.testing.expectEqual(panes_before, p.freeSlot()); - try std.testing.expect(results.vsel.active and results.vsel.explicit); - try std.testing.expectEqualStrings("build.zig:1:1", selectedOutputText(results) orelse ""); - try std.testing.expectEqual(output_pane.Grain.line, output_pane.grain(results.file.?.output)); - - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(@as(i32, 1), results.cur_row); - try std.testing.expectEqualStrings("mise.toml", selectedOutputText(results) orelse ""); - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(@as(i32, 3), results.cur_row); // row 2 is not look-able - try std.testing.expectEqualStrings("uucode_config.zig:7:1", selectedOutputText(results) orelse ""); - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(@as(i32, 0), results.cur_row); // ring seam - - p.update(.{ .key = .{ .cp = Key.enter } }); - try std.testing.expect(p.freeSlot() != panes_before); - try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/build.zig")); - try std.testing.expectEqual(output_pane.Grain.word, output_pane.grain(p.panes[p.active].?.file.?.output)); -} - -test "n/N resumes the result output whose Look moved focus away" { - if (platform == .web) return; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(std.testing.io, .{ - .sub_path = "look-owner.txt", - .data = "alpha target\nbeta target\ngamma target\n", - }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/look-owner.txt", .{tmp.sub_path}); - const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 }); - defer p.deinit(); - - try std.testing.expect(p.executeBuiltinLine(0, "Look target")); - const rid = p.panes[0].?.search_pane orelse return error.MissingResults; - const results = p.panes[rid].?; - const first = results.cur_row; - try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expect(results.look_at != null); - try std.testing.expectEqual(results.serial, p.look_walk_owner orelse return error.MissingLookOwner); - - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(rid, p.active); - try std.testing.expectEqual(first + 1, results.cur_row); - p.update(.{ .key = .{ .cp = 'N' } }); - try std.testing.expectEqual(first, results.cur_row); - - // A later no-match answer has no position to resume and therefore cannot - // steal the provenance established by the Look above. - const owner = results.serial; - const dir = std.fs.path.dirname(path) orelse "."; - try output_pane.fillResults( - p, - 0, - dir, - .search, - "no-such-result", - try p.gpa.dupe(u8, ""), - null, - ); - try std.testing.expectEqual(owner, p.look_walk_owner.?); - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(rid, p.active); - try std.testing.expectEqual(first + 1, results.cur_row); - - // Emptying the owner itself keeps its identity as the ring's starting - // point. Deleting it then leaves a stale serial, never an id that can bind - // to the unrelated pane subsequently allocated in the same slot. - try output_pane.fillResults(p, 0, dir, .search, "target", try p.gpa.dupe(u8, ""), null); - try std.testing.expectEqual(owner, p.look_walk_owner.?); - try std.testing.expect(p.executeBuiltinLine(rid, "Del")); - try std.testing.expect(p.paneBySerial(owner) == null); - const replacement = try p.newShell(rid, ""); - try std.testing.expect(replacement.serial != owner); - try std.testing.expect(p.paneBySerial(owner) == null); -} - -test "N exactly reverses n across output panes and the ring seam" { - if (platform == .web) return; - var cwd_buf: [4096]u8 = undefined; - var path_buf: [4096]u8 = undefined; - const paths = try projectPaths(&cwd_buf, &path_buf); - const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); - defer p.deinit(); - p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); - const first = try walkFixture(p, p.active, paths.cwd, "one"); - p.update(.{ .key = .{ .cp = 'n', .alt = true } }); - const second = try walkFixture(p, p.active, paths.cwd, "two"); - try std.testing.expect(first != second); - - const Mark = struct { pane: usize, row: i32, col: i32 }; - const here = struct { - fn at(pp: *Pardes) Mark { - const pane = pp.panes[pp.active].?; - return .{ .pane = pp.active, .row = pane.cur_row, .col = pane.cur_col }; - } - }.at; - p.update(.{ .key = .{ .cp = 'n' } }); - const base = here(p); - var trail: [12]Mark = undefined; - for (&trail) |*mark| { - p.update(.{ .key = .{ .cp = 'n' } }); - mark.* = here(p); - } - var i = trail.len; - while (i > 0) { - i -= 1; - p.update(.{ .key = .{ .cp = 'N' } }); - const want = if (i == 0) base else trail[i - 1]; - try std.testing.expectEqual(want, here(p)); - } - var saw_first = false; - var saw_second = false; - for (trail) |mark| { - saw_first = saw_first or mark.pane == first; - saw_second = saw_second or mark.pane == second; - } - try std.testing.expect(saw_first and saw_second); -} - -test "n/N selects command outputs by whole row" { - if (platform == .web) return; - var cwd_buf: [4096]u8 = undefined; - var path_buf: [4096]u8 = undefined; - const paths = try projectPaths(&cwd_buf, &path_buf); - const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); - defer p.deinit(); - p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); - - try std.testing.expect(p.executeBuiltinLine(p.active, "ThemeSel")); - const tid = p.panes[p.active].?.search_pane orelse return error.MissingThemeList; - const themes = p.panes[tid].?; - try std.testing.expect(output_pane.fileTraits(themes.file.?.output).commands); - p.update(.{ .key = .{ .cp = 'n' } }); - const row0 = std.mem.trimEnd(u8, modal.lineSlice(themes.file.?.content, 0), " \t\r"); - try std.testing.expectEqualStrings(row0, selectedOutputText(themes) orelse ""); - const theme_before = p.settings.theme; - p.update(.{ .key = .{ .cp = 'n' } }); - p.update(.{ .key = .{ .cp = Key.tab } }); - try std.testing.expect(p.settings.theme != theme_before); - - try std.testing.expect(p.executeBuiltinLine(0, "Look build.zig")); - p.update(.tick); - try std.testing.expect(p.active != 0); - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(@as(usize, 0), p.active); -} diff --git a/src/panel_animation.zig b/src/panel_animation.zig deleted file mode 100644 index a77e5a34..00000000 --- a/src/panel_animation.zig +++ /dev/null @@ -1,662 +0,0 @@ -//! Backend-neutral vocabulary and math for pane transitions. -//! -//! The core publishes plain transition data and composes semantic PanelAscii -//! bytes itself. GUI shells evaluate geometry/dissolve data in shaders; the -//! TTY shell evaluates the same records over cells in its grid. There are -//! deliberately no callbacks or backend objects here. -const std = @import("std"); - -pub const ascii_max_movement_frames: u16 = 12; - -pub const Easing = enum(u8) { - linear, - smooth, - /// Quintic ease-in-out. It creeps at both ends and crosses the middle of - /// the distance fast, inside the same frame count a linear walk would use. - smoother, - in_cubic, - out_cubic, - out_back, -}; - -pub const Transition = enum(u8) { - // These values cross both GUI shader ABIs. GLSL receives the enum in the - // instance uvec4 and the Core Image kernel receives it as a float, so - // spelling the numbers here keeps a source reorder from changing pixels. - off = 0, - slide = 1, - zoom = 2, - dissolve = 3, - ascii = 4, - vertical = 5, - // Character effects the core composes into Surface cells (see - // `composedByCore`). A backend never evaluates them: it receives finished - // glyphs, so these ids reach a shader only as "draw this panel batch". - edges = 6, - fall = 7, - wave = 8, - curtain = 9, - scramble = 10, - typewriter = 11, - - pub fn easing(effect: Transition) Easing { - return switch (effect) { - .off, .dissolve, .wave => .smooth, - .slide, .vertical, .edges => .out_cubic, - .zoom => .out_back, - // Character walks and per-cell locks read best with a slow start, - // a fast middle, and a slow settle over their fixed frame count. - .ascii, .fall, .scramble => .smoother, - // A sweep and a typewriter are constant-rate by definition: easing - // their head would make the pass visibly hesitate mid-pane. - .curtain, .typewriter => .linear, - }; - } - - pub fn frames(effect: Transition) u16 { - return switch (effect) { - .off => 0, - .slide => 12, - .zoom => 14, - .dissolve => 10, - // Frame zero is the exact old byte. Core's AsciiDiff caps a long - // byte walk at twelve eased movement samples, including the exact - // destination; nearby bytes still move one value at a time. - .ascii => ascii_max_movement_frames + 1, - .vertical => 12, - .edges, .curtain, .scramble => 12, - // Travelling motion needs a couple more samples than a lock or a - // rigid slide before it stops reading as a jump. - .fall, .wave, .typewriter => 14, - }; - } - - /// Character effects whose glyphs the core writes into the published - /// Surface. Every backend rasterizes the same finished cells, which is why - /// none of them owns a byte walk, a stagger, or a noise threshold. - pub fn composedByCore(effect: Transition) bool { - return switch (effect) { - .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true, - .off, .slide, .zoom, .dissolve, .vertical => false, - }; - } - - pub fn needsPreviousGrid(effect: Transition) bool { - return effect == .dissolve or effect == .vertical or effect.composedByCore(); - } - - pub fn lifecycleOnly(effect: Transition) bool { - return effect == .vertical; - } -}; - -/// Full-scene shader effects. CRT is one effect in this vocabulary rather -/// than a separate renderer switch; only one scene pass is needed even when -/// more than one bit is enabled. -pub const SceneEffect = struct { - crt: bool = false, - ripple: bool = false, - glitch: bool = false, -}; - -pub const Phase = enum(u8) { - opening = 0, - moving = 1, - /// Presentation-only content whose pane lifetime has already ended. - /// It is never a valid input target. - closing = 2, -}; - -pub const Box = extern struct { - x: f32 = 0, - y: f32 = 0, - w: f32 = 0, - h: f32 = 0, - - pub fn eql(a: Box, b: Box) bool { - return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h; - } - - /// Whether a grid cell falls inside this box. Cells are whole, boxes are - /// fractional mid-animation, so the test is the cell's ORIGIN against a - /// half-open range: a box straddling a column owns it once its origin is - /// covered, and never owns it twice. - pub fn contains(box: Box, col: u16, row: u16) bool { - const x: f32 = @floatFromInt(col); - const y: f32 = @floatFromInt(row); - return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; - } -}; - -/// The order every backend composites tracks in: moving panes first, then new -/// panes, then inert closing tombstones on top. Returns how many were written. -/// -/// A function rather than a loop inside `Pardes.render` because it is a RULE -/// three hosts used to re-derive — macos.zig re-sorted the already-sorted list -/// and tty/panel_compositor.zig walked the phases again — and a second sort -/// that happens to agree is the one that silently stops agreeing. `render` -/// calls this and every host receives the result verbatim. -/// -/// Inactive tracks are dropped here, so a host never has to ask. -pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize { - var len: usize = 0; - for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| { - const track = maybe orelse continue; - if (!track.active() or track.phase != phase) continue; - if (len == out.len) return len; - out[len] = track; - len += 1; - }; - for (closing) |track| { - if (!track.active()) continue; - if (len == out.len) return len; - out[len] = track; - len += 1; - } - return len; -} - -/// One POD record is enough for every backend. `from` and `to` are logical -/// cell boxes; frontends convert them to pixels only at their render edge. -pub const Track = extern struct { - serial: u32 = 0, - pane: u8 = 0, - phase: Phase = .moving, - effect: Transition = .off, - _padding: u8 = 0, - frame: u16 = 0, - /// Core-computed duration for data-dependent effects. Zero selects the - /// effect preset; PanelAscii fills this from the longest eased byte walk - /// in the pane's semantic cell diff. - frame_count: u16 = 0, - from: Box = .{}, - to: Box = .{}, - - pub fn active(track: Track) bool { - return track.effect != .off and track.frame < track.frames(); - } - - pub fn frames(track: Track) u16 { - return if (track.frame_count != 0) track.frame_count else track.effect.frames(); - } - - pub fn amount(track: Track) f32 { - // Opening rises quickly and settles; closing reverses that motion and - // accelerates down out of the fixed clip. - if (track.phase == .closing and track.effect == .vertical) - return progressEased(.in_cubic, track.frames(), track.frame); - return progressEased(track.effect.easing(), track.frames(), track.frame); - } - - pub fn presented(track: Track) Box { - return lerpBox(track.from, track.to, track.amount()); - } - - /// Geometry actually painted by every backend. Content transitions reveal - /// or move cells inside the final rectangle; slide, zoom, and vertical - /// transform the panel rectangle itself. - pub fn visualBox(track: Track) Box { - return switch (track.effect) { - .slide, .zoom, .vertical => track.presented(), - .off, .dissolve => track.to, - // Every character effect stays inside the pane's final rectangle. - .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to, - }; - } - - /// The canonical box whose cells a backend samples. Opening and moving - /// tracks sample the new frame at `to`; a closing tombstone samples the - /// frozen old frame at `from` because its pane no longer exists. - pub fn contentBox(track: Track) Box { - return if (track.phase == .closing) track.from else track.to; - } -}; - -/// Preset starting geometry for a newly-visible panel. Dissolve and the -/// character effects animate content in place; slide, zoom, and vertical -/// animate its rectangle. -pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { - return switch (effect) { - .slide => blk: { - var from = target; - const middle = target.x + target.w * 0.5; - from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5) - -target.w - else - @floatFromInt(screen_width); - break :blk from; - }, - .zoom => .{ - .x = target.x + target.w * 0.5, - .y = target.y + target.h * 0.5, - .w = 0, - .h = 0, - }, - // Slide upward into a fixed clip equal to the new pane's own box. - // Starting one panel-height below that box keeps the translated - // content from travelling across any surviving pane. - .vertical => blk: { - var from = target; - from.y += target.h; - break :blk from; - }, - .off, .dissolve => target, - // Character effects own the glyphs inside a fixed rectangle, so their - // panel opens at exactly its final geometry. - .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target, - }; -} - -/// Destination for a lifecycle-only closing track. The old panel drops out -/// through its own fixed clip, reversing the opening path; surviving panes -/// are already at canonical geometry underneath it and never receive tracks -/// for this effect. -pub fn closingBox(effect: Transition, source: Box) Box { - return switch (effect) { - .vertical => blk: { - var to = source; - to.y += source.h; - break :blk to; - }, - else => source, - }; -} - -pub fn sample(easing: Easing, raw: f32) f32 { - const t = std.math.clamp(raw, 0.0, 1.0); - return switch (easing) { - .linear => t, - .smooth => t * t * (3.0 - 2.0 * t), - .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0), - .in_cubic => t * t * t, - .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), - // Robert Penner's ease-out-back polynomial. It intentionally travels - // a little past one before settling exactly on the endpoint. - .out_back => blk: { - const c1: f32 = 1.70158; - const c3 = c1 + 1.0; - const u = t - 1.0; - break :blk 1.0 + c3 * u * u * u + c1 * u * u; - }, - }; -} - -pub fn progress(effect: Transition, frame: u16) f32 { - return progressEased(effect.easing(), effect.frames(), frame); -} - -fn progressEased(easing: Easing, frames: u16, frame: u16) f32 { - if (frames <= 1 or frame >= frames - 1) return 1.0; - // `frames` is the number of presented samples, including both exact - // endpoints. This makes the last active frame the real final image rather - // than 15/16 followed by an unrendered snap to canonical content. - return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); -} - -pub fn lerpBox(from: Box, to: Box, t: f32) Box { - // Keep easing overshoot for opening/moving geometry—the whole visual - // distinction of out-back—while preventing any shrinking dimension from - // becoming negative and flipping its quad. - const u = @max(0.0, t); - return .{ - .x = from.x + (to.x - from.x) * u, - .y = from.y + (to.y - from.y) * u, - .w = @max(0.0, from.w + (to.w - from.w) * u), - .h = @max(0.0, from.h + (to.h - from.h) * u), - }; -} - -/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only -/// hashing means resizing or repainting a frame does not make cells flicker. -pub fn cellNoise(serial: u32, col: u16, row: u16) f32 { - var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b); - x ^= x >> 16; - x *%= 0x7feb_352d; - x ^= x >> 15; - x *%= 0x846c_a68b; - x ^= x >> 16; - return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0; -} - -/// Whether a changed dissolve cell has crossed from the frozen old grid to -/// the new one. Exact endpoints are part of the presentation contract. -pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool { - const t = std.math.clamp(raw_progress, 0.0, 1.0); - if (t <= 0) return false; - if (t >= 1) return true; - return cellNoise(serial, col, row) < t; -} - -/// The pane-local cell grid a core-composed character effect walks. Origin and -/// size use the same floor/ceil convention as the GUI cell-coordinate upload, -/// so the core's composition and any backend port index the same glyph. -pub const CellArea = struct { - x0: u16 = 0, - y0: u16 = 0, - cols: u16 = 1, - rows: u16 = 1, - - pub fn of(box: Box) CellArea { - return .{ - .x0 = floorCell(box.x), - .y0 = floorCell(box.y), - .cols = ceilCell(box.w), - .rows = ceilCell(box.h), - }; - } -}; - -fn floorCell(value: f32) u16 { - return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16)))); -} - -fn ceilCell(value: f32) u16 { - return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16)))); -} - -/// What one pane cell shows this frame under a core-composed character -/// effect. `at` offsets are in cells and relative to the destination cell, so -/// an all-zero offset is exactly the canonical glyph and every effect ends on -/// the untouched final frame. -pub const CharSource = union(enum) { - /// Nothing has arrived here yet: keep the frozen old cell. - old, - at: Offset, - /// Paint this printable byte in the destination cell's own style, whatever - /// that cell holds — a caret marching over empty space is still a caret. - byte: u8, - /// Paint this printable byte only where there is a glyph to churn. Noise - /// over blank cells would fill a pane with junk instead of letting its - /// text resolve out of noise. - churn: u8, - - pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 }; - - pub const settled: CharSource = .{ .at = .{} }; -}; - -/// One cell of one core-composed character effect. Offsets travel with the -/// glyph rather than blending it: a cell either holds a real glyph from the -/// new grid, the frozen old glyph, or a churning byte, never a mix. A source -/// outside the pane is the caller's cue to keep the old cell. -pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource { - const t = track.amount(); - if (t >= 1.0) return .settled; - const w: f32 = @floatFromInt(area.cols); - const h: f32 = @floatFromInt(area.rows); - const c: f32 = @floatFromInt(col); - const r: f32 = @floatFromInt(row); - const remaining = 1.0 - t; - return switch (track.effect) { - // Whole rows arrive from the left and right screen edges, alternating. - // Sliding rigid rows is what keeps the glyphs crisp: one row is one - // rigid translation, so no cell ever samples two source glyphs. - .edges => blk: { - const travel = cellsOf(remaining * (w + 1.0)); - break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } }; - }, - // Columns rain down, each with its own stable head start, so the pane - // fills from the top and the last glyphs land at the bottom. - .fall => blk: { - const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4); - if (local <= 0.0) break :blk .old; - break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } }; - }, - // A vertical ripple travels left to right and its amplitude decays, so - // the pane settles out of a wave instead of a fade. - .wave => .{ .at = .{ - .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)), - } }, - // A curtain of glyphs marches in from the right, column by column, left - // to right; each column still has a short slide of its own. - .curtain => blk: { - const lead = t * (w + 1.0) - c; - if (lead <= 0.0) break :blk .old; - break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } }; - }, - // Every cell churns through printable ASCII and locks onto its final - // glyph at its own stable threshold: the pane resolves out of noise. - .scramble => blk: { - if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled; - const churn = cellNoise( - track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f), - col, - row, - ); - break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) }; - }, - // Reading-order reveal with a caret sitting on the write head. - .typewriter => blk: { - const head = t * w * h; - const index = r * w + c; - if (index + 1.0 <= head) break :blk .settled; - if (index <= head) break :blk .{ .byte = '_' }; - break :blk .old; - }, - // PanelAscii walks its own byte distance per cell, and the geometry - // effects never reach this path at all. - .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled, - }; -} - -fn cellsOf(distance: f32) i32 { - return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0))); -} - -/// Remap track progress into one cell's own window. A stagger delays a glyph -/// without making the effect as a whole end after its last frame. -fn staggered(t: f32, delay: f32) f32 { - if (delay >= 1.0) return t; - return (t - delay) / (1.0 - delay); -} - -test "easing presets have exact endpoints and intended shapes" { - inline for (std.enums.values(Easing)) |easing| { - try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); - try std.testing.expectEqual(@as(f32, 1), sample(easing, 1)); - } - try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5)); - try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); - try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); - try std.testing.expect(sample(.out_back, 0.8) > 1.0); - // Slow at both ends, fast through the middle, and symmetric about the - // halfway point: the same curve the integer byte walk reproduces. - try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5)); - try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15)); - try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85)); - try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) > - sample(.linear, 0.6) - sample(.linear, 0.4)); -} - -test "transition progress completes exactly" { - inline for (std.enums.values(Transition)) |effect| { - try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames())); - if (effect.frames() > 0) - try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1)); - try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16))); - } - try std.testing.expectEqual(@as(f32, 1), progress(.off, 0)); - - const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8)); - try std.testing.expectEqual(@as(f32, 0), shrinking.w); - try std.testing.expectEqual(@as(f32, 0), shrinking.h); - const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8)); - try std.testing.expect(opening.w > 100); - try std.testing.expect(opening.h > 40); -} - -test "character effects are core-composed and settle on the canonical glyph" { - const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 }; - const area: CellArea = .of(box); - try std.testing.expectEqual(@as(u16, 4), area.x0); - try std.testing.expectEqual(@as(u16, 2), area.y0); - try std.testing.expectEqual(@as(u16, 20), area.cols); - try std.testing.expectEqual(@as(u16, 6), area.rows); - - inline for (std.enums.values(Transition)) |effect| { - if (comptime !effect.composedByCore()) continue; - // Core composition needs the frozen old grid for every glyph which has - // not arrived, so no character effect may animate without it. - try std.testing.expect(effect.needsPreviousGrid()); - if (comptime effect == .ascii) continue; // owns its own per-cell byte walk - - const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box }; - const first: Track = .{ .effect = effect, .frame = 0, .to = box }; - var moving = false; - var row: u16 = 0; - while (row < area.rows) : (row += 1) { - var col: u16 = 0; - while (col < area.cols) : (col += 1) { - // The last active sample is the exact canonical grid: no cell - // is displaced, churning, or still frozen. - try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area)); - if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area))) - moving = true; - } - } - try std.testing.expect(moving); - } -} - -test "each character effect moves glyphs along its own axis" { - const box: Box = .{ .w = 30, .h = 8 }; - const area: CellArea = .of(box); - - // Rows alternate which screen edge they come from, and every glyph in a row - // travels as one rigid slide: one offset, no vertical component. - var edges: Track = .{ .effect = .edges, .frame = 2, .to = box }; - const even = charSource(edges, 5, 0, area).at; - const odd = charSource(edges, 5, 1, area).at; - try std.testing.expect(even.cols > 0); - try std.testing.expectEqual(-even.cols, odd.cols); - try std.testing.expectEqual(@as(i32, 0), even.rows); - try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at); - edges.frame = 5; - try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols); - - // Falling columns are vertical only, staggered, and sample from below the - // destination because the new text is still above the pane. - const fall: Track = .{ .effect = .fall, .frame = 4, .to = box }; - var falling = false; - var col: u16 = 0; - while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) { - .old => {}, - .byte, .churn => return error.FallShouldNotChurn, - .at => |offset| { - try std.testing.expectEqual(@as(i32, 0), offset.cols); - try std.testing.expect(offset.rows >= 0); - if (offset.rows > 0) falling = true; - }, - }; - try std.testing.expect(falling); - - // The wave displaces rows both ways as it travels, and only rows. - const wave: Track = .{ .effect = .wave, .frame = 1, .to = box }; - var above = false; - var below = false; - col = 0; - while (col < area.cols) : (col += 1) { - const offset = charSource(wave, col, 3, area).at; - try std.testing.expectEqual(@as(i32, 0), offset.cols); - if (offset.rows < 0) above = true; - if (offset.rows > 0) below = true; - } - try std.testing.expect(above and below); - - // The curtain has a head: columns behind it hold the old grid, columns the - // head has passed are settled, and the head itself is still sliding. - const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box }; - try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area)); - try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area)); - var sliding = false; - col = 0; - while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) { - .at => |offset| if (offset.cols < 0) { - sliding = true; - }, - .old, .byte, .churn => {}, - }; - try std.testing.expect(sliding); - - // Scramble churns printable ASCII per cell and per frame, then locks. It - // asks for churn rather than an unconditional byte, so the compositor can - // keep the pane's blank space blank. - var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box }; - var churning: usize = 0; - var locked: usize = 0; - var changed = false; - col = 0; - while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) { - .churn => |byte| { - try std.testing.expect(byte >= ' ' and byte <= '~'); - churning += 1; - scramble.frame = 4; - switch (charSource(scramble, col, 0, area)) { - .churn => |next| changed = changed or next != byte, - .old, .at, .byte => {}, - } - scramble.frame = 3; - }, - .at => locked += 1, - .old, .byte => return error.ScrambleShouldNotFreeze, - }; - try std.testing.expect(churning > 0 and locked > 0 and changed); - - // The typewriter writes in reading order with a caret on its head. - const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box }; - try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area)); - try std.testing.expectEqual( - CharSource{ .old = {} }, - charSource(typewriter, area.cols - 1, area.rows - 1, area), - ); - var carets: usize = 0; - var row: u16 = 0; - while (row < area.rows) : (row += 1) { - col = 0; - while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) { - .byte => |byte| { - try std.testing.expectEqual(@as(u8, '_'), byte); - carets += 1; - }, - .old, .at, .churn => {}, - }; - } - try std.testing.expectEqual(@as(usize, 1), carets); -} - -test "opening presets separate geometry and content transitions" { - const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 }; - try std.testing.expectEqual(target, openingBox(.ascii, target, 80)); - try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w); - try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x); - try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y); - try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y); - - var track: Track = .{ .effect = .slide, .from = target, .to = target }; - try std.testing.expect(track.active()); - track.frame = track.effect.frames(); - try std.testing.expect(!track.active()); - - track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; - try std.testing.expectEqual(target, track.visualBox()); - - var closing: Track = .{ - .phase = .closing, - .effect = .vertical, - .from = target, - .to = closingBox(.vertical, target), - }; - try std.testing.expectEqual(target, closing.contentBox()); - closing.frame = 2; - try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); -} - -test "dissolve has exact stable endpoints" { - for (0..64) |col| { - const x: u16 = @intCast(col); - try std.testing.expect(!dissolveRevealed(42, x, 7, 0)); - try std.testing.expect(dissolveRevealed(42, x, 7, 1)); - if (dissolveRevealed(42, x, 7, 0.25)) - try std.testing.expect(dissolveRevealed(42, x, 7, 0.75)); - } -} diff --git a/src/panes.zig b/src/panes.zig new file mode 100644 index 00000000..6db7f53b --- /dev/null +++ b/src/panes.zig @@ -0,0 +1,7681 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const pardes = @import("pardes.zig"); +const layout = @import("layout.zig"); +const config = @import("config.zig"); +const Pardes = pardes.Pardes; +const modal = @import("modal.zig"); +const filesystem = @import("fs.zig"); +const syntax = @import("syntax.zig"); +const tracy = @import("tracy.zig"); +const dump = @import("dump.zig"); +const limits = @import("memory.zig").limits; +const builtins = @import("builtins.zig"); +const effect_sources = @import("effect_sources.zig"); +const build_options = @import("pardes_config"); +const lsp = @import("lsp/lsp.zig"); +const image = @import("image.zig"); +const look = @import("look.zig"); +const Key = pardes.Key; + +/// An owned editable buffer and the absolute surface row of its first line. +/// Files use row zero; terminal overlays may begin anywhere in scrollback. +pub const EditText = struct { text: []u8, row0: i32 }; + +pub const Pane = struct { + pub const Mode = enum { normal, insert, tty }; + + /// One mouse selection (block-shaped), per button. c/r are text-area relative; + /// r counts from the tag row (body starts at BOX_H). + pub const Sel = struct { + state: enum { none, dragging, done } = .none, + c0: i32 = 0, + c1: i32 = 0, + r0: i32 = 0, + r1: i32 = 0, + }; + + /// A modal line selection (helix `x`): whole rows [r0, r1], absolute. + pub const LineSel = struct { + active: bool = false, + r0: i32 = 0, + r1: i32 = 0, + }; + + pub const CharSel = struct { + active: bool = false, + row: i32 = 0, + col: i32 = 0, + explicit: bool = false, + }; + + pub const LookSpot = struct { + row: i32, + col0: i32, + col1: i32, + }; + + pub const max_selections = 64; + + pub const SelRange = struct { + row: i32, + col: i32, + arow: i32, + acol: i32, + /// this range's own j/k goal column (helix Range::old_visual_position); + /// the PRIMARY's copy is Pane.sticky_col + sticky: i32 = -1, + }; + + const PdfSlot = if (Pdf.enabled) ?Pdf.State else void; + + pub const Prompt = union(enum) { + none, + search: u16, + pipe: struct { at: u16, how: modal.Normal.PipeBehavior }, + /// Save on a scratch buffer or a terminal: the tail is a path to write to. + save: u16, + }; + + pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 }; + terminal: ?*Terminal.State = null, + gpa: std.mem.Allocator, + serial: u32 = 0, + mode: Mode = .normal, + vweight: f32 = 1, + cols: u16, + rows: u16, + greet: bool = false, + pending_command: Terminal.PendingCommand = .{}, + file: ?File.State = null, + image: ?Image.State = null, + pdf: PdfSlot = if (Pdf.enabled) null else {}, + msel: LineSel = .{}, + vsel: CharSel = .{}, + sels: [max_selections - 1]SelRange = undefined, + nsel: u8 = 0, + select: bool = false, + /// sticky goal column for j/k runs (helix old_visual_position): any + /// non-vertical range write resets it to -1. + sticky_col: i32 = -1, + append_at: ?struct { row: i32, col: i32 } = null, + normal: modal.Normal.State = .{}, + /// last f/F/t/T motion, for Alt-. repeat + find_op: u8 = 0, + find_ch: u21 = 0, + /// Tag-tail input state. The tag text is presentation; this tag carries + /// which operation owns it and the tail offset restored on submit/cancel. + prompt: Prompt = .none, + search_pane: ?usize = null, + search_row: ?usize = null, + look_at: ?LookSpot = null, + sel_snap: [max_selections]modal.Selection = undefined, + nsel_snap: u8 = 0, + sel_snap_pri: u8 = 0, + sel_snap_expl: bool = false, + /// the editable tag tail: a bounded one-line command buffer. Input that + /// does not fit is refused atomically. + tag_tail: [limits.max_tag_tail]u8 = undefined, + tag_tail_len: usize = 0, + tag_init: bool = false, + tag_edit: bool = false, + tag_sel: bool = false, + /// the body mode a tag edit hijacked (tags are always insert); terminals + /// restore it on exit so clicking the tag never changes the pane's mode + tag_mode: Mode = .normal, + tag_col: u16 = 0, + tag_anchor: u16 = 0, + ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined, + ed_undo_len: usize = 0, + ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined, + ed_redo_len: usize = 0, + cwd: Cwd = .none, + cur_pinned: bool = false, + cur_row: i32 = 0, + cur_col: i32 = 0, + hscroll: i32 = 0, + // Visible rows map to source lines and raw/display column origins. + wrap_line: [limits.wrap_rows]i32 = undefined, + wrap_col: [limits.wrap_rows]i32 = undefined, + wrap_n: u16 = 0, + sel: [3]Sel = @splat(.{}), + /// terminals only: the typed-text buffer standing in for shell rows + ovl: ?Terminal.EditBuffer = null, + tty_filter: bool = false, + msg: [256]u8 = undefined, + msg_len: u16 = 0, + + pub fn tagSlice(p: *const Pane) []const u8 { + return p.tag_tail[0..p.tag_tail_len]; + } + pub fn promptAt(p: *const Pane) ?u16 { + return switch (p.prompt) { + .none => null, + .search, .save => |at| at, + .pipe => |pipe| pipe.at, + }; + } + + pub fn appendTag(p: *Pane, text: []const u8) bool { + if (text.len > p.tag_tail.len - p.tag_tail_len) return false; + @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); + p.tag_tail_len += text.len; + return true; + } + + pub fn insertTagByte(p: *Pane, at: usize, byte: u8) bool { + if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false; + std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]); + p.tag_tail[at] = byte; + p.tag_tail_len += 1; + return true; + } + + pub fn removeTagByte(p: *Pane, at: usize) void { + if (at >= p.tag_tail_len) return; + std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]); + p.tag_tail_len -= 1; + } + + pub fn cwdSlice(p: *const Pane) []const u8 { + return switch (p.cwd) { + .none => "", + .owned => |dir| dir, + .inherited => |src| src.cwdSlice(), + }; + } + + pub fn clearCwd(pane: *Pane) void { + if (pane.cwd == .owned) pane.gpa.free(pane.cwd.owned); + pane.cwd = .none; + } + + pub fn setOwnedCwd(pane: *Pane, dir: []const u8) !void { + if (dir.len > limits.host_path_cap) return error.PathTooLong; + const copy = try pane.gpa.dupe(u8, dir); + pane.clearCwd(); + pane.cwd = .{ .owned = copy }; + } + + pub fn isTerminal(pane: *const Pane) bool { + const no_pdf = if (comptime Pdf.enabled) pane.pdf == null else true; + return pane.file == null and pane.image == null and no_pdf; + } + + /// The one coloring choice keyed on what a pane IS, so the highlight + /// producer (refreshHighlights) and the render pass agree on the algorithm. + pub const ColorAlgo = enum { none, tty, source, diff, locations }; + pub fn colorAlgo(pane: *const Pane) ColorAlgo { + if (pane.isTerminal()) return .tty; + if (pane.file) |f| { + if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; + if (f.output != null and !Output.fileTraits(f.output).saves) return .locations; + return .source; + } + return .none; + } + + pub fn pdfPath(pane: *const Pane) ?[]const u8 { + if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.path; + return null; + } + + pub fn pdfPage(pane: *const Pane) ?usize { + if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.page; + return null; + } + + pub fn surfRow(pane: *const Pane, g: i32) i32 { + const o = pane.ovl orelse return g; + if (g <= o.row) return g; + const lines: i32 = @intCast(modal.lineCount(o.text)); + if (g >= o.row + o.rows) return g + lines - o.rows; + return @min(g, o.row + lines - 1); // inside the buffer: its own rows + } + + /// the inverse; every surface row inside the edit buffer maps to its anchor + pub fn gridRow(pane: *const Pane, s: i32) i32 { + const o = pane.ovl orelse return s; + if (s <= o.row) return s; + const lines: i32 = @intCast(modal.lineCount(o.text)); + if (s < o.row + lines) return o.row; + return s - lines + o.rows; + } + + /// current scroll offset: file top line, or the scrollback offset + pub fn scroll(pane: *Pane) i32 { + if (pane.file) |f| return @intCast(f.scroll); + if (comptime Pdf.enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); + return pane.surfRow(Terminal.gridOffset(pane)); + } + + pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { + if (pane.wrap_n > 0 and vr >= 0 and vr < @as(i32, pane.wrap_n)) + return .{ .line = pane.wrap_line[@intCast(vr)], .at = pane.wrap_col[@intCast(vr)] }; + // unwrapped: rows ARE lines, and the byte column a row starts at is the + // horizontal scroll (always 0 on a terminal, which never has one) + return .{ .line = pane.scroll() + vr, .at = pane.hscroll }; + } + + pub fn wrapRow(pane: *Pane, line: i32, col: i32) struct { row: i32, at: i32 } { + if (pane.wrap_n == 0) return .{ .row = line - pane.scroll(), .at = pane.hscroll }; + var i: u16 = 0; + while (i < pane.wrap_n) : (i += 1) { + if (pane.wrap_line[i] != line) continue; + // the LAST row of a line owns every column past its start, so a + // cursor parked on the trailing newline still has somewhere to draw + if (i + 1 < pane.wrap_n and pane.wrap_line[i + 1] == line and col >= pane.wrap_col[i + 1]) continue; + return .{ .row = @intCast(i), .at = pane.wrap_col[i] }; + } + return .{ .row = -1, .at = 0 }; + } + + pub fn scrollBy(pane: *Pane, delta: i32) void { + if (pane.file) |*f| { + const max: i64 = @intCast(File.nlines(pane.gpa, f) -| 1); + const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); + const next: usize = @intCast(n); + if (next != f.scroll) { + f.scroll = next; + f.syntax_dirty = true; + } + } else if (hasPdf(pane)) { + if (comptime Pdf.enabled) { + const pv = &pane.pdf.?; + const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); + pv.text_scroll = @intCast(std.math.clamp( + @as(i64, @intCast(pv.text_scroll)) + delta, + 0, + max, + )); + } + } else { + // the vt scrolls in SHELL rows; convert through the edit buffer + const off = Terminal.gridOffset(pane); + Terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); + } + } + + pub fn ensureCursorVisible(pane: *Pane) void { + // scrolloff margin, shrunk on short panes so the band stays non-empty + var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); + const off = pane.scroll(); + var last = off + @as(i32, pane.rows) - 1; + if (pane.wrap_n > 0) { + const lines_shown = pane.wrap_line[pane.wrap_n - 1] - pane.wrap_line[0]; + last = off + lines_shown; + margin = @min(margin, @divTrunc(@max(0, lines_shown), 2)); + } + if (pane.cur_row < off + margin) { + pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 + } else if (pane.cur_row > last - margin) { + // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); + // terminals overshoot harmlessly — the vt clamps at the live bottom + var to = pane.cur_row + margin; + if (pane.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1))); + if (comptime Pdf.enabled) { + if (pane.pdf) |pv| + to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); + } + pane.scrollBy(@max(0, to - last)); + } + if (pane.file) |f| { + if (pane.wrap_n != 0) return; + const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, File.gutterWidth(pane))); + const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); + const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))); + const raw_cur: usize = @intCast(@max(0, pane.cur_col)); + const raw_scroll: usize = @intCast(@max(0, pane.hscroll)); + const cur = @as(i32, @intCast(File.rawDisplayCol(line, raw_cur))); + const visual_scroll = @as(i32, @intCast(File.rawDisplayCol(line, raw_scroll))); + var target = visual_scroll; + if (cur < visual_scroll + hmargin) + target = @max(0, cur - hmargin) + else if (cur > visual_scroll + w - 1 - hmargin) + target = cur - (w - 1 - hmargin); + if (target != visual_scroll) pane.hscroll = @intCast(File.rawAtDisplay(line, @intCast(target))); + } + } + + pub fn pinCursor(pane: *Pane) void { + if (pane.cur_pinned) return; + if (pane.file != null or hasPdf(pane)) { + pane.cur_row = pane.scroll(); + pane.cur_col = 0; + } else { + const cur = Terminal.gridCursor(pane); + pane.cur_row = pane.surfRow(@as(i32, cur.y) + Terminal.gridOffset(pane)); + pane.cur_col = @intCast(cur.x); + } + pane.cur_pinned = true; + } + + pub fn hasPdf(pane: *const Pane) bool { + return if (comptime Pdf.enabled) pane.pdf != null else false; + } + + pub fn hasPdfSelection(pane: *const Pane) bool { + return if (comptime Pdf.enabled) + if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false + else + false; + } + + pub fn toModalCursor(pane: *Pane) modal.Cursor { + return .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; + } + + pub fn fromModalCursor(pane: *Pane, c: modal.Cursor) void { + pane.cur_row = @as(i32, @intCast(c.row)); + pane.cur_col = @intCast(c.col); + pane.cur_pinned = true; + } + + pub fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor { + if (lines.len == 0) return c; + const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1; + const target = lines[row]; + if (target.len == 0) return .{ .row = row, .col = 0 }; + const source = if (c.row < lines.len) lines[c.row] else ""; + const goal = File.rawDisplayCol(source, c.col); + const mapped = File.rawAtDisplay(target, goal); + const last = modal.prevGrapheme(target, target.len); + return .{ .row = row, .col = modal.graphemeStart(target, @min(mapped, last)) }; + } + + pub fn primaryRange(pane: *Pane, text: []const u8, row0: i32) modal.Selection { + const c = File.textOffset(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); + if (pane.msel.active) { + // legacy line selection (file-search results highlight): linewise + const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); + const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); + const s = modal.lineStartOffset(text, r0); + const e = if (r1 + 1 >= modal.cursorLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); + return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; + } + if (pane.vsel.active) return cellRange(text, pane.vsel.row - row0, pane.vsel.col, pane.cur_row - row0, pane.cur_col); + return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; + } + + pub fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.Selection { + const a = modal.offsetAt(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) }); + const c = modal.offsetAt(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) }); + return cellOffRange(text, a, c); + } + + /// the same, from the two cells' gap offsets + pub fn cellOffRange(text: []const u8, a: usize, c: usize) modal.Selection { + if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; + return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; + } + + pub fn rangeCells(text: []const u8, r: modal.Selection) struct { cur: usize, anc: usize } { + if (r.head > r.anchor) return .{ .cur = modal.prevGrapheme(text, r.head), .anc = r.anchor }; + if (r.head < r.anchor) return .{ .cur = r.head, .anc = modal.prevGrapheme(text, r.anchor) }; + return .{ .cur = r.head, .anc = r.head }; + } + + pub fn setRange(pane: *Pane, text: []const u8, row0: i32, r0: modal.Selection, explicit: bool) void { + var r = r0; + if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 + const off = rangeCells(text, r); + const cc = File.textPosition(pane, text, off.cur); + // a bare block cursor has both cells on the same offset — the common + // case by far — and this conversion is not free even indexed + const ac = if (off.anc == off.cur) cc else File.textPosition(pane, text, off.anc); + pane.cur_row = @as(i32, @intCast(cc.row)) + row0; + pane.cur_col = @intCast(cc.col); + pane.vsel = .{ + .active = off.anc != off.cur or pane.select, + .row = @as(i32, @intCast(ac.row)) + row0, + .col = @intCast(ac.col), + .explicit = explicit or pane.select, + }; + pane.msel.active = false; + pane.nsel = 0; // writing ONE range means the selection IS that range + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.normal.clear(); + pane.ensureCursorVisible(); + } + + pub fn ranges(pane: *Pane, text: []const u8, row0: i32, out: *[max_selections]modal.Selection) struct { n: usize, pri: usize } { + const pr = primaryRange(pane, text, row0); + var n: usize = 0; + var pri: usize = 0; + var placed = false; + for (pane.sels[0..pane.nsel]) |s| { + const r = cellRange(text, s.arow - row0, s.acol, s.row - row0, s.col); + if (!placed and @min(pr.anchor, pr.head) <= @min(r.anchor, r.head)) { + pri = n; + out[n] = pr; + n += 1; + placed = true; + } + out[n] = r; + n += 1; + } + if (!placed) { + pri = n; + out[n] = pr; + n += 1; + } + return .{ .n = n, .pri = pri }; + } + + pub fn setRanges(pane: *Pane, text: []const u8, in: []const modal.Selection, sticky: []const i32, pri0: usize, explicit: bool) void { + if (in.len == 0) return; // helix asserts non-empty; here it just means "no change" + var r: [max_selections]modal.Selection = undefined; + var st: [max_selections]i32 = undefined; + var n: usize = @min(in.len, max_selections); + var pri: usize = @min(pri0, n - 1); + for (in[0..n], 0..) |x, i| { + r[i] = x; + if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head); + st[i] = if (i < sticky.len) sticky[i] else -1; + } + // insertion sort by start — n is tiny and usually already ordered + var i: usize = 1; + while (i < n) : (i += 1) { + var j = i; + while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) { + std.mem.swap(modal.Selection, &r[j], &r[j - 1]); + std.mem.swap(i32, &st[j], &st[j - 1]); + if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j; + } + } + var k: usize = 0; + i = 1; + while (i < n) : (i += 1) { + const a = r[k]; + const b = r[i]; + const af = @min(a.anchor, a.head); + const at = @max(a.anchor, a.head); + const bf = @min(b.anchor, b.head); + const bt = @max(b.anchor, b.head); + if (af == bf or (at > bf and bt > af)) { + r[k] = if (a.anchor > a.head and b.anchor > b.head) + .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) } + else + .{ .anchor = @min(af, bf), .head = @max(at, bt) }; + if (pri == i) pri = k; + if (st[k] < 0) st[k] = st[i]; + continue; + } + k += 1; + r[k] = b; + st[k] = st[i]; + if (pri == i) pri = k; + } + n = k + 1; + setRange(pane, text, 0, r[pri], explicit); + pane.sticky_col = st[pri]; + var w: usize = 0; + for (r[0..n], 0..) |x, idx| { + if (idx == pri) continue; + const c = rangeCells(text, x); + const cc = modal.positionAt(text, c.cur); + const ac = modal.positionAt(text, c.anc); + pane.sels[w] = .{ + .row = @as(i32, @intCast(cc.row)), + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)), + .acol = @intCast(ac.col), + .sticky = st[idx], + }; + w += 1; + } + pane.nsel = @intCast(w); + } + + pub fn multiSelAction(pane: *Pane, text: []const u8, kind: modal.Normal.Multi, cnt: usize) void { + var rs: [max_selections]modal.Selection = undefined; + const got = ranges(pane, text, 0, &rs); + const n = got.n; + const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + if (kind == .remove_primary) { + if (n < 2) return; // helix: "no selections remaining" + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + for (rs[0..n], 0..) |r, i| { + if (i == got.pri) continue; + out[m] = r; + m += 1; + } + // helix Selection::remove: the NEXT range takes over, or the + // previous one when the primary was last + return setRanges(pane, text, out[0..m], &.{}, @min(got.pri, m - 1), expl); + } + if (kind == .rotate_forward or kind == .rotate_backward) { + const step = cnt % n; + const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n; + return setRanges(pane, text, rs[0..n], &.{}, pri, expl); + } + if (kind == .merge) { + // helix merge_selections: first.merge(last) — the ranges are + // sorted, so that is simply the whole span + const lo = @min(rs[0].anchor, rs[0].head); + const hi = @max(rs[n - 1].anchor, rs[n - 1].head); + const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head; + const one: modal.Selection = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi }; + return setRanges(pane, text, &.{one}, &.{}, 0, expl); + } + if (kind == .merge_consecutive) { + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + var pri: usize = 0; + for (rs[0..n], 0..) |r, i| { + if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) { + const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head)); + const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head)); + out[m - 1] = .{ .anchor = lo, .head = hi }; + if (i == got.pri) pri = m - 1; + continue; + } + if (i == got.pri) pri = m; + out[m] = r; + m += 1; + } + return setRanges(pane, text, out[0..m], &.{}, pri, expl); + } + if (kind == .split_newline) { + // helix selection::split_on_newline — one range per line the + // selection covers, the newlines themselves left out + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + for (rs[0..n]) |r| { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + if (from == to) { + if (m < max_selections) { + out[m] = r; + m += 1; + } + continue; + } + var start = from; + while (start < to and m < max_selections) { + const eol = modal.lineEndOffset(text, modal.lineAtOffset(text, start)); + if (eol >= to) { + out[m] = .{ .anchor = start, .head = to }; + m += 1; + break; + } + out[m] = .{ .anchor = start, .head = eol }; + m += 1; + start = eol + 1; + } + } + if (m == 0) return; + return setRanges(pane, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 + } + if (kind == .trim) { + // helix trim_selections: whitespace off both ends; ranges that are + // empty or all whitespace are dropped entirely + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + for (rs[0..n]) |r| { + var from = @min(r.anchor, r.head); + var to = @max(r.anchor, r.head); + while (from < to and std.ascii.isWhitespace(text[from])) from += 1; + while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1; + if (from >= to) continue; + out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }; + m += 1; + } + if (m == 0) { // helix: collapse_selection + keep_primary_selection + const c = modal.selectionCursor(text, rs[got.pri]); + return setRange(pane, text, 0, .{ .anchor = c, .head = c }, false); + } + // helix: the first survivor that OVERLAPS the old primary, else the last + const pf = @min(rs[got.pri].anchor, rs[got.pri].head); + const pt = @max(rs[got.pri].anchor, rs[got.pri].head); + var pri = m - 1; + for (out[0..m], 0..) |r, i| { + const f = @min(r.anchor, r.head); + const t = @max(r.anchor, r.head); + if (f == pf or (t > pf and pt > f)) { + pri = i; + break; + } + } + return setRanges(pane, text, out[0..m], &.{}, pri, expl); + } + const below = kind == .copy_below; + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + var pri: usize = 0; + const nlines = modal.cursorLineCount(text); + for (rs[0..n], 0..) |r, ri| { + const is_pri = ri == got.pri; + // head-exclusive: back the leading end off onto its own cell + const hp = modal.positionAt(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head); + const ap = modal.positionAt(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor)); + const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1; + if (m >= max_selections) break; + if (is_pri) pri = m; + out[m] = r; + m += 1; + var made: usize = 0; + var k: usize = 0; + while (made < cnt and m < max_selections) : (k += 1) { + const d = (k + 1) * height; + const arow = if (below) ap.row + d else ap.row -| d; + const hrow = if (below) hp.row + d else hp.row -| d; + if (arow >= nlines or hrow >= nlines) break; + const a2 = modal.offsetAt(text, .{ .row = arow, .col = ap.col }); + const h2 = modal.offsetAt(text, .{ .row = hrow, .col = hp.col }); + // a line too short to reach the column is skipped, not clamped + if (modal.positionAt(text, a2).col == ap.col and modal.positionAt(text, h2).col == hp.col) { + if (is_pri) pri = m; + out[m] = modal.moveSelectionCursor(text, .{ .anchor = a2, .head = a2 }, h2, true); + m += 1; + made += 1; + } + if (arow == 0 and hrow == 0) break; + } + } + setRanges(pane, text, out[0..m], &.{}, pri, expl); + } + + /// a range's start CELL (document order key) — the smaller of its two ends + pub fn selStart(s: SelRange) struct { row: i32, col: i32 } { + if (s.arow < s.row or (s.arow == s.row and s.acol < s.col)) return .{ .row = s.arow, .col = s.acol }; + return .{ .row = s.row, .col = s.col }; + } + + pub fn maxLine(text: []const u8) usize { + const nl = modal.cursorLineCount(text); + return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1; + } + + /// point-target motion: collapse there (extend in select mode) + pub fn pointMove(pane: *Pane, text: []const u8, range: modal.Selection, target: usize) void { + setRange(pane, text, 0, modal.moveSelectionCursor(text, range, target, pane.select), false); + } + + /// word motions select their traversed span (extend mode: head only) + pub fn wordMove(pane: *Pane, text: []const u8, range: modal.Selection, cnt: usize, target: modal.WordTarget) void { + const wr = modal.moveWord(text, range, cnt, target); + const res = if (pane.select) modal.moveSelectionCursor(text, range, modal.selectionCursor(text, wr), true) else wr; + setRange(pane, text, 0, res, false); + } + + /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move) + pub fn findMove(pane: *Pane, text: []const u8, range: modal.Selection, ch: u21, fwd: bool, till: bool, cnt: usize) void { + const cur = modal.selectionCursor(text, range); + const t = modal.findTarget(text, cur, ch, fwd, till, cnt) orelse return; + const res = if (pane.select) + modal.moveSelectionCursor(text, range, t, true) + else + modal.moveSelectionCursor(text, .{ .anchor = cur, .head = cur }, t, true); + setRange(pane, text, 0, res, false); + } + + /// j/k and friends: sticky goal column, clamped onto short lines' newline + pub fn verticalMove(pane: *Pane, text: []const u8, range: modal.Selection, down: bool, cnt: usize) void { + const cur = modal.selectionCursor(text, range); + const pos = File.textPosition(pane, text, cur); + const goal: usize = if (pane.sticky_col >= 0) + @intCast(pane.sticky_col) + else + File.rawDisplayCol(File.textLine(pane, text, pos.row), pos.col); + const last_row = File.textLineCount(pane, text) - 1; + const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt); + const target_col = File.rawAtDisplay(File.textLine(pane, text, nline), goal); + const t = File.textOffset(pane, text, .{ .row = nline, .col = target_col }); + // extend mode never walks onto the empty trailing line (helix) + if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; + setRange(pane, text, 0, modal.moveSelectionCursor(text, range, t, pane.select), false); + pane.sticky_col = @intCast(goal); + } + + pub fn visualMove( + pane: *Pane, + text: []const u8, + range: modal.Selection, + down: bool, + cnt: usize, + width: usize, + ) void { + const cur = modal.selectionCursor(text, range); + const pos = File.textPosition(pane, text, cur); + const last_row = File.textLineCount(pane, text) - 1; + var row = pos.row; + var line = modal.lineSlice(text, row); + var vrow = File.visualRow(line, pos.col, width); + const goal: usize = if (pane.sticky_col >= 0) + @intCast(pane.sticky_col) + else + File.rawDisplayCol(line[vrow.start..vrow.end], pos.col -| vrow.start); + var steps = @max(1, cnt); + while (steps > 0) : (steps -= 1) { + if (down) { + if (vrow.end < line.len) { + vrow = File.visualRow(line, vrow.end, width); + continue; + } + if (row == last_row) break; + row += 1; + line = modal.lineSlice(text, row); + vrow = File.visualRow(line, 0, width); + } else { + if (vrow.start > 0) { + vrow = File.visualRow(line, vrow.start - 1, width); + continue; + } + if (row == 0) break; + row -= 1; + line = modal.lineSlice(text, row); + vrow = File.visualRow(line, line.len, width); + } + } + // The newline slot is a real cursor position, but the first byte of the + // NEXT visual row is not: landing there would read as two rows moved. + var target_col = vrow.start + File.rawAtDisplay(line[vrow.start..vrow.end], goal); + if (vrow.end < line.len and target_col >= vrow.end) + target_col = modal.graphemeStart(line, vrow.end - 1); + const t = File.textOffset(pane, text, .{ .row = row, .col = target_col }); + // extend mode never walks onto the empty trailing line (helix) + if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; + setRange(pane, text, 0, modal.moveSelectionCursor(text, range, t, pane.select), false); + pane.sticky_col = @intCast(goal); + } + + /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows + pub fn halfPageMove(pane: *Pane, text: []const u8, range: modal.Selection, down: bool) void { + const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2)); + pane.scrollBy(if (down) half else -half); + verticalMove(pane, text, range, down, @intCast(half)); + } + + /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift + /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0 + pub fn scrollViewMove(pane: *Pane, text: []const u8, range: modal.Selection, delta: i32) void { + const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); + pane.scrollBy(delta); + const top = pane.scroll(); + const last_row: i32 = @intCast(File.textLineCount(pane, text) - 1); + const cur = modal.selectionCursor(text, range); + if (delta > 0) { + const snap: i32 = @max(0, @min(top + margin, last_row)); + const head = File.textLineStart(pane, text, @intCast(snap)); + if (head <= cur) return; + const anchor = if (pane.select) range.anchor else head; + setRange(pane, text, 0, .{ .anchor = anchor, .head = head }, false); + } else { + const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); + const head = File.textLineStart(pane, text, @intCast(snap)); + if (head >= cur) return; + const anchor = if (pane.select) range.anchor else head; + setRange(pane, text, 0, .{ .anchor = anchor, .head = head }, false); + } + } + + /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) + pub fn gotoWindow(pane: *Pane, text: []const u8, range: modal.Selection, which: enum { top, center, bottom }, cnt: usize) void { + const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); + const top = pane.scroll(); + const last_row: i32 = @intCast(File.textLineCount(pane, text) - 1); + const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top); + const n: i32 = @intCast(cnt - 1); + var vline: i32 = switch (which) { + .top => top + margin + n, + .center => top + @divTrunc(last_vis, 2), + .bottom => top + last_vis - (margin + n), + }; + vline = @max(vline, top + margin); + vline = @min(vline, top + last_vis - margin); + const row: i32 = std.math.clamp(vline, 0, last_row); + pointMove(pane, text, range, File.textLineStart(pane, text, @intCast(row))); + } + + /// helix Range::line_range — the inclusive line span a range covers + pub fn rangeLineSpan(text: []const u8, r: modal.Selection) struct { start: usize, end: usize } { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from); + return .{ .start = modal.lineAtOffset(text, from), .end = modal.lineAtOffset(text, to_adj) }; + } + + fn lineStartOrEof(text: []const u8, line: usize) usize { + if (line >= modal.cursorLineCount(text)) return text.len; + return modal.lineStartOffset(text, line); + } + + /// helix `x` extend_line_below: full lines incl. the newline, cursor ON + /// the last one's '\n'; an already-line-bounded selection grows downward + pub fn lineSelect(pane: *Pane, text: []const u8, range: modal.Selection, cnt: usize) void { + const span = rangeLineSpan(text, range); + const start = modal.lineStartOffset(text, span.start); + const end = lineStartOrEof(text, span.end + 1); + const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end; + const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full)); + setRange(pane, text, 0, .{ .anchor = start, .head = head }, true); + } + + /// helix `X` extend_to_line_bounds (direction kept) + pub fn lineBoundsSelect(pane: *Pane, text: []const u8, range: modal.Selection) void { + const span = rangeLineSpan(text, range); + const start = modal.lineStartOffset(text, span.start); + const end = lineStartOrEof(text, span.end + 1); + const r: modal.Selection = if (range.head < range.anchor) + .{ .anchor = end, .head = start } + else + .{ .anchor = start, .head = end }; + setRange(pane, text, 0, r, true); + } + + /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) + pub fn shrinkSelToLineBounds(pane: *Pane, text: []const u8, range: modal.Selection) void { + const span = rangeLineSpan(text, range); + if (span.start == span.end) return; + const from = @min(range.anchor, range.head); + const to = @max(range.anchor, range.head); + var start = modal.lineStartOffset(text, span.start); + var end = lineStartOrEof(text, span.end + 1); + if (start != from) start = lineStartOrEof(text, span.start + 1); + if (end != to) end = modal.lineStartOffset(text, span.end); + const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + const r: modal.Selection = if (range.head < range.anchor) + .{ .anchor = end, .head = start } + else + .{ .anchor = start, .head = end }; + setRange(pane, text, 0, r, expl); + } + + /// pull the cursor back inside `text` after a rewrite; `row0` is the + /// absolute surface row of its first line (0 for a file) + pub fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { + const n = modal.lineCount(text); + const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); + const llen = modal.lineSlice(text, row).len; + pane.cur_row = @as(i32, @intCast(row)) + row0; + pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); + pane.cur_pinned = true; + pane.vsel.active = false; + pane.msel.active = false; + pane.ensureCursorVisible(); + } +}; + +pub const File = struct { + const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; + + /// Content and primary selection at one file edit boundary. Keeping only the + /// primary avoids putting Pane.max_selections ranges in every history entry. + pub const Snapshot = struct { + content: []u8, + cur_row: i32, + cur_col: i32, + vsel: Pane.CharSel, + }; + + pub const History = struct { + undo: [limits.undo_max]Snapshot = undefined, + undo_len: usize = 0, + redo: [limits.undo_max]Snapshot = undefined, + redo_len: usize = 0, + + pub fn create(gpa: std.mem.Allocator) !*History { + const history = try gpa.create(History); + history.undo_len = 0; + history.redo_len = 0; + return history; + } + }; + + /// A file pane's backing: owned content, its derived caches, and undo history. + pub const State = struct { + path: []u8, + content: []u8, + revision: u32 = 0, + /// Revision last known to match disk, after Save or an external reload. + /// Equal means the screen matches disk. + saved_revision: u32 = 0, + watch_after_save: bool = false, + /// Non-null for a generated output buffer rather than an on-disk file. + output: ?Output.State = null, + mini: ?Mini.State = null, + scroll: usize = 0, + /// `line_starts[i]` is line i's byte offset. Empty means not built yet. + line_starts: []usize = &.{}, + /// One tree_sitter_gpa-owned syntax.Syn byte per highlighted source byte. + highlights: []u8 = &.{}, + highlight_start: usize = 0, + syntax_dirty: bool = true, + history: *History, + }; + + pub fn dumpPane( + arena: std.mem.Allocator, + pane: *const Pane, + file: *const State, + tag: []const u8, + body: []const u8, + scroll: usize, + origin: []const u8, + origin_arg: []const u8, + ) !dump.Pane { + return .{ + .kind = .file, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .file = .{ + .path = file.path, + .content = file.content, + .content_b64 = try dump.encodeBytes(arena, file.content), + .dirty = file.revision != file.saved_revision, + .origin = origin, + .origin_arg = origin_arg, + .mini_source = if (file.mini) |mini| mini.source else "", + .mini_colors_b64 = if (file.mini) |mini| try dump.encodeBytes(arena, mini.colors) else "", + }, + }; + } + + pub fn graphemeDisplayWidth(grapheme: []const u8) usize { + if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; + if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1; + return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); + } + + pub fn byteDisplayWidth(byte: u8) usize { + return if (byte == '\t') config.tab_width else 1; + } + + pub fn displayWidth(text: []const u8) usize { + var width: usize = 0; + var at: usize = 0; + while (at < text.len) { + const end = modal.nextGrapheme(text, at); + width +|= graphemeDisplayWidth(text[at..end]); + at = end; + } + return width; + } + + /// Source byte at a zero-based display column. Every cell occupied by a tab + /// maps back to that one tab byte. + pub fn byteAtDisplay(text: []const u8, display_col: usize) usize { + var col: usize = 0; + var at: usize = 0; + while (at < text.len) { + const end = modal.nextGrapheme(text, at); + const next = col +| graphemeDisplayWidth(text[at..end]); + if (display_col < next) return at; + col = next; + at = end; + } + return text.len; + } + + /// File cursor columns may live past EOL. Tabs expand before that boundary; + /// every virtual column after it remains one screen cell. + pub fn rawDisplayCol(line_text: []const u8, raw_col: usize) usize { + const bounded = modal.graphemeStart(line_text, @min(raw_col, line_text.len)); + return displayWidth(line_text[0..bounded]) +| (raw_col -| line_text.len); + } + + pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize { + const width = displayWidth(line_text); + if (display_col > width) return line_text.len +| (display_col - width); + return byteAtDisplay(line_text, display_col); + } + + pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: usize) usize { + if (from_raw >= line_text.len) return from_raw +| display_col; + const from = modal.graphemeStart(line_text, from_raw); + return from +| rawAtDisplay(line_text[from..], display_col); + } + + pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 { + const from_display = rawDisplayCol(line_text, from_raw); + const to_display = rawDisplayCol(line_text, to_raw); + if (to_display >= from_display) return @intCast(to_display - from_display); + return -@as(i32, @intCast(from_display - to_display)); + } + + pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 { + const start = lineDisplayOffset(line_text, from_raw, at_raw); + if (at_raw >= line_text.len) return start; + const at = modal.graphemeStart(line_text, at_raw); + const end = modal.nextGrapheme(line_text, at); + return start + @as(i32, @intCast(graphemeDisplayWidth(line_text[at..end]))) - 1; + } + + pub fn sourceLine(pane: *const Pane, row: i32) []const u8 { + const f = if (pane.file) |*file| file else return ""; + if (row < 0) return ""; + const line: usize = @intCast(row); + if (f.line_starts.len == 0) return modal.lineSlice(f.content, line); + if (line >= f.line_starts.len) return ""; + const start = f.line_starts[line]; + const end = if (line + 1 < f.line_starts.len) f.line_starts[line + 1] - 1 else f.content.len; + return f.content[start..end]; + } + + test "indexed source rows match uncached scans across content changes" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile(""); + const file = &pane.file.?; + for ([_][]const u8{ "", "one", "one\n", "a\r\nλ界\nlast", "\n\n", "\tλ e\u{301}\n\r\n" }) |content| { + setContent(p, file, try p.gpa.dupe(u8, content)); + try std.testing.expectEqual(@as(usize, 0), file.line_starts.len); + const rows = lineCount(content) + 2; + for ([_]bool{ false, true }) |indexed| { + if (indexed) _ = try lineIndex(p.gpa, file); + try std.testing.expectEqualStrings("", sourceLine(pane, -1)); + for (0..rows) |row| try std.testing.expectEqualStrings( + modal.lineSlice(content, row), + sourceLine(pane, @intCast(row)), + ); + if (!indexed) try std.testing.expectEqual(@as(usize, 0), file.line_starts.len); + } + } + } + + pub fn displayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 { + const line_text = sourceLine(pane, row); + const from: usize = @intCast(@max(0, from_raw)); + const to: usize = @intCast(@max(0, to_raw)); + return lineDisplayOffset(line_text, from, to); + } + + pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 { + const line_text = sourceLine(pane, row); + return lineDisplayEndOffset(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, at_raw))); + } + + pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 { + const line_text = sourceLine(pane, row); + return @intCast(byteAtDisplayFrom(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, display_col)))); + } + + pub fn gutterWidth(pane: *const Pane) u16 { + if (pane.file == null) return 0; + const file = &pane.file.?; + var lines = if (file.line_starts.len > 0) file.line_starts.len else lineCount(file.content); + var digits: u16 = 1; + while (lines >= 10) : (lines /= 10) digits += 1; + return @max(config.PREFIX_W, digits + 1); + } + + /// Convert between rendered cells and UTF-8 byte columns. Tag rows always + /// need grapheme conversion; file body rows additionally skip the gutter. + pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize { + if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col); + if (pane.file == null) return rawAtDisplay(line_text, display_col); + const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len); + if (display_col <= prefix) return display_col; + return prefix +| rawAtDisplay(line_text[prefix..], display_col - prefix); + } + + pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize { + if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col); + if (pane.file == null) return rawDisplayCol(line_text, byte_col); + const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len); + if (byte_col <= prefix) return byte_col; + return prefix +| rawDisplayCol(line_text[prefix..], byte_col - prefix); + } + + test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" { + const ref = struct { + fn width(grapheme: []const u8) usize { + if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; + return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); + } + }.width; + + var one: [1]u8 = undefined; + var b: u8 = 0; + while (b < 0x80) : (b += 1) { + one[0] = b; + try std.testing.expectEqual(ref(one[0..1]), graphemeDisplayWidth(one[0..1])); + } + for ([_][]const u8{ + "e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}", + "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", + }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g)); + } + + test "the ASCII run in fitEnd survives an exhaustive byte sweep" { + const reference = struct { + fn fitEnd(text: []const u8, start: usize, width: usize) usize { + var end = start; + var used: usize = 0; + while (end < text.len) { + const next_end = modal.nextGrapheme(text, end); + const next_used = used +| graphemeDisplayWidth(text[end..next_end]); + if (next_used > width) return if (end == start) next_end else end; + used = next_used; + end = next_end; + } + return end; + } + }.fitEnd; + + const neighbours = [_][]const u8{ + "", "z", "\u{301}", "\u{200d}\u{1f680}", + "\u{903}", "\u{fe0f}", "\u{4e16}", "\u{1f642}", + "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", + }; + var buf: [16]u8 = undefined; + // The same pair again behind an ASCII prefix, so a break can land exactly at the run boundary + // as well as before it and inside the multi-byte cluster that follows it. + var prefixed: [18]u8 = undefined; + prefixed[0] = 'a'; + prefixed[1] = 'b'; + var b: u8 = 0; + while (b < 0x80) : (b += 1) { + buf[0] = b; + for (neighbours) |tail| { + @memcpy(buf[1..][0..tail.len], tail); + const pair = buf[0 .. 1 + tail.len]; + @memcpy(prefixed[2..][0..pair.len], pair); + for ([_][]const u8{ pair, prefixed[0 .. 2 + pair.len] }) |text| { + var width: usize = 0; + while (width <= text.len + 3) : (width += 1) { + var start: usize = 0; + while (start <= text.len) : (start += 1) { + std.testing.expectEqual( + reference(text, start, width), + fitEnd(text, start, width), + ) catch |e| { + std.debug.print("fitEnd({any}, {d}, {d})\n", .{ text, start, width }); + return e; + }; + } + } + } + } + } + } + + fn fitEnd(text: []const u8, start: usize, width: usize) usize { + var end = start; + var used: usize = 0; + while (used < width and end < text.len) { + const b = text[end]; + if (b < 0x20 or b >= 0x7f) break; + if (end + 1 < text.len and text[end + 1] >= 0x80) break; + used += 1; + end += 1; + } + while (end < text.len) { + const next_end = modal.nextGrapheme(text, end); + const next_used = used +| graphemeDisplayWidth(text[end..next_end]); + if (next_used > width) return if (end == start) next_end else end; + used = next_used; + end = next_end; + } + return end; + } + + test "the ASCII run in fitEnd cuts where the grapheme walk would" { + const reference = struct { + fn fitEnd(text: []const u8, start: usize, width: usize) usize { + var end = start; + var used: usize = 0; + while (end < text.len) { + const next_end = modal.nextGrapheme(text, end); + const next_used = used +| graphemeDisplayWidth(text[end..next_end]); + if (next_used > width) return if (end == start) next_end else end; + used = next_used; + end = next_end; + } + return end; + } + }.fitEnd; + + const cases = [_][]const u8{ + "", + "hello world", + // the fast path must hand over at the first non-ASCII byte, mid-run + "abc\u{00e9}def", + // a wide glyph is two columns, so a width boundary can land inside it + "ab\u{4e16}\u{754c}cd", + // a cluster the fast path must not split + "a\u{0301}bc", + // tabs and controls are excluded from the fast path by the range test + "ab\tcd", + "ab\rcd", + // an ASCII byte followed by a continuation byte is NOT its own cluster + "e\u{0301}x", + "\u{1f1e6}\u{1f1e7}ok", + }; + for (cases) |text| { + var width: usize = 0; + while (width <= text.len + 3) : (width += 1) { + var start: usize = 0; + while (start <= text.len) : (start += 1) { + try std.testing.expectEqual( + reference(text, start, width), + fitEnd(text, start, width), + ); + } + } + } + } + + pub fn lineCount(content: []const u8) usize { + return std.mem.count(u8, content, "\n") + 1; + } + + // Content changes invalidate this lazily rebuilt byte-offset index. + pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize { + if (f.line_starts.len > 0) return f.line_starts; + // Exact allocation: deinitPane frees `line_starts` itself, so the stored + // slice must span the complete allocation rather than spare capacity. + const starts = try gpa.alloc(usize, lineCount(f.content)); + starts[0] = 0; + var i: usize = 1; + var off: usize = 0; + while (std.mem.indexOfScalarPos(u8, f.content, off, '\n')) |nl| { + off = nl + 1; + starts[i] = off; + i += 1; + } + f.line_starts = starts; + return starts; + } + + /// line count, O(1) once the index is warm + pub fn nlines(gpa: std.mem.Allocator, f: *State) usize { + const idx = lineIndex(gpa, f) catch return lineCount(f.content); + return idx.len; + } + + /// byte offset of line `row`, or content.len past the end — modal + /// .lineStartOffset's contract exactly, without its walk + pub fn lineStart(gpa: std.mem.Allocator, f: *State, row: usize) usize { + const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row); + return if (row >= idx.len) f.content.len else idx[row]; + } + + pub fn cursorLines(arena: std.mem.Allocator, pane: *Pane, f: *State) ![]const []const u8 { + const index = try lineIndex(pane.gpa, f); + const lines = try arena.alloc([]const u8, index.len); + for (index, 0..) |start, i| { + const end = if (i + 1 < index.len) index[i + 1] - 1 else f.content.len; + lines[i] = f.content[start..end]; + } + return lines; + } + + /// Use the file's line index only when `text` is its complete live content. + /// Edit-buffer fragments and other temporary text retain modal's scan path. + fn contentIndex(pane: *Pane, text: []const u8) ?[]const usize { + const f = if (pane.file) |*file| file else return null; + if (text.ptr != f.content.ptr or text.len != f.content.len) return null; + return lineIndex(pane.gpa, f) catch null; + } + + pub fn textOffset(pane: *Pane, text: []const u8, cursor: modal.Cursor) usize { + const index = contentIndex(pane, text) orelse return modal.offsetAt(text, cursor); + const row = @min(cursor.row, index.len - 1); + const start = index[row]; + const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; + return start + modal.graphemeStart(text[start..end], @min(cursor.col, end - start)); + } + + pub fn textLineStart(pane: *Pane, text: []const u8, row: usize) usize { + const index = contentIndex(pane, text) orelse return modal.lineStartOffset(text, row); + return if (row >= index.len) text.len else index[row]; + } + + pub fn textLine(pane: *Pane, text: []const u8, row: usize) []const u8 { + const index = contentIndex(pane, text) orelse return modal.lineSlice(text, row); + if (row >= index.len) return ""; + const start = index[row]; + const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; + return text[start..end]; + } + + test "indexed text rows preserve fragment and allocation failure semantics" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("first\n\tλ界\n\nlast\n"); + const text = pane.file.?.content; + allocator.fail_index = allocator.alloc_index; + for (0..lineCount(text) + 2) |row| try std.testing.expectEqualStrings(modal.lineSlice(text, row), textLine(pane, text, row)); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.line_starts.len); + allocator.fail_index = std.math.maxInt(usize); + _ = try lineIndex(p.gpa, &pane.file.?); + allocator.fail_index = allocator.alloc_index; + const allocations = allocator.allocations; + for ([_][]const u8{ text, text[2..], text[0..8], "other\nrows\n\n", "" }) |fragment| { + for (0..lineCount(fragment) + 2) |row| try std.testing.expectEqualStrings( + modal.lineSlice(fragment, row), + textLine(pane, fragment, row), + ); + } + try std.testing.expectEqual(allocations, allocator.allocations); + } + + pub fn textLineCount(pane: *Pane, text: []const u8) usize { + const index = contentIndex(pane, text) orelse return modal.cursorLineCount(text); + return index.len; + } + + pub fn textPosition(pane: *Pane, text: []const u8, offset: usize) modal.Cursor { + const index = contentIndex(pane, text) orelse return modal.positionAt(text, offset); + const bounded = @min(offset, text.len); + const row = std.sort.upperBound(usize, index, bounded, struct { + fn cmp(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.cmp) - 1; + const start = index[row]; + const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; + return .{ .row = row, .col = modal.graphemeStart(text[start..end], @min(bounded - start, end - start)) }; + } + + pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { + std.debug.assert(p.panes[id] == null and !p.reserved_slots[id]); + const path_copy = try p.gpa.dupe(u8, path); + errdefer p.gpa.free(path_copy); + const pane = try Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); + errdefer p.gpa.destroy(pane); + const history = try History.create(p.gpa); + errdefer p.gpa.destroy(history); + p.reserved_slots[id] = true; + defer p.reserved_slots[id] = false; + const content = try filesystem.read(p, path); + errdefer p.gpa.free(content); + const total = lineCount(content); + const scroll: usize = if (line > 0 and line <= total) line - 1 else 0; + pane.file = .{ .path = path_copy, .content = content, .scroll = scroll, .history = history }; + pane.cur_pinned = true; + pane.cur_row = @intCast(scroll); + p.installPane(id, pane); + if (filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); + return pane; + } + + pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane { + const saved = src.file.?; + if (saved.mini_source.len > 0) { + const encoded_limit = std.base64.standard.Encoder.calcSize(Mini.max_output_bytes); + if (saved.mini_source.len >= 4096 or saved.content.len > Mini.max_output_bytes or + saved.content_b64.len > encoded_limit or saved.mini_colors_b64.len > encoded_limit) + return error.InvalidMini; + } + const content: []u8 = if (saved.content_b64.len > 0) + try dump.decodeBytes(p.gpa, saved.content_b64) + else + try p.gpa.dupe(u8, saved.content); + errdefer p.gpa.free(content); + const path = try p.gpa.dupe(u8, saved.path); + errdefer p.gpa.free(path); + + const output: ?Output.State = if (Output.fromWord(saved.origin)) |origin| blk: { + var value: Output.State = .{ .from = origin }; + try Output.setArg(&value, saved.origin_arg); + break :blk value; + } else null; + + var mini: ?Mini.State = null; + errdefer if (mini) |*state| state.deinit(p.gpa); + if (saved.mini_source.len > 0) { + if (output == null or !std.meta.eql(output.?.from, Output.Origin{ .cmd = .Mini })) return error.InvalidMini; + const source = try p.gpa.dupe(u8, saved.mini_source); + errdefer p.gpa.free(source); + const colors = try dump.decodeBytes(p.gpa, saved.mini_colors_b64); + errdefer p.gpa.free(colors); + if (colors.len != content.len or colors.len > Mini.max_output_bytes) return error.InvalidMini; + for (colors) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidMini; + mini = .{ .source = source, .colors = colors }; + } else if (saved.mini_colors_b64.len > 0) return error.InvalidMini; + + const history = try History.create(p.gpa); + errdefer p.gpa.destroy(history); + const pane = try p.newDocPane(id); + pane.file = .{ + .path = path, + .content = content, + .output = output, + .mini = mini, + .scroll = src.scroll, + .revision = @intFromBool(saved.dirty), + .history = history, + }; + pane.cur_pinned = true; + pane.cur_row = @intCast(src.scroll); + pane.cols = @max(1, src.cols); + pane.rows = @max(1, src.rows); + if (output == null and filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true, .mode = .baseline_disk } }); + return pane; + } + + pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void { + if (file.output == null) for (p.panes, 0..) |slot, id| { + if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + }; + p.gpa.free(file.path); + p.gpa.free(file.content); + if (file.mini) |*mini| mini.deinit(p.gpa); + if (file.line_starts.len > 0) p.gpa.free(file.line_starts); + if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights); + for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content); + for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content); + p.gpa.destroy(file.history); + } + + fn reportEdit(p: *Pardes, f: *State, new: []const u8) void { + if (p.fs.listeners == 0) return; + const id = for (p.panes, 0..) |slot, i| { + const pane = slot orelse continue; + if (pane.file) |*state| if (state == f) break i; + } else return; + pardes.filesystem.noteReplace(p, id, false, f.content, new); + } + + pub fn setContent(p: *Pardes, f: *State, new: []u8) void { + reportEdit(p, f, new); + if (f.mini) |*mini| mini.deinit(p.gpa); + f.mini = null; + p.gpa.free(f.content); + f.content = new; + f.revision +%= 1; + if (f.line_starts.len > 0) p.gpa.free(f.line_starts); + f.line_starts = &.{}; + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + f.highlights = &.{}; + f.highlight_start = 0; + f.syntax_dirty = true; + } + + pub fn restoreSnap(pane: *Pane, f: *State, snap: Snapshot) void { + const n = nlines(pane.gpa, f); + const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1); + const llen = modal.lineSlice(f.content, row).len; + pane.cur_row = @intCast(row); + pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); + pane.vsel = snap.vsel; + pane.msel.active = false; + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); + } + + fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Snapshot) void { + if (len.* == slots.len) { + gpa.free(slots[0].content); + std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); + len.* -= 1; + } + slots[len.*] = snap; + len.* += 1; + } + + pub fn pushUndo(p: *Pardes, pane: *Pane) void { + const f = if (pane.file) |*file| file else return; + const history = f.history; + if (history.undo_len > 0 and std.mem.eql(u8, history.undo[history.undo_len - 1].content, f.content)) return; + const snap: Snapshot = .{ + .content = p.gpa.dupe(u8, f.content) catch return, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }; + pushHistory(p.gpa, &history.undo, &history.undo_len, snap); + for (history.redo[0..history.redo_len]) |item| p.gpa.free(item.content); + history.redo_len = 0; + } + + pub fn undo(p: *Pardes, pane: *Pane) void { + const f = if (pane.file) |*file| file else return; + const history = f.history; + if (history.undo_len == 0) return; + const current: Snapshot = .{ + .content = p.gpa.dupe(u8, f.content) catch return, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }; + pushHistory(p.gpa, &history.redo, &history.redo_len, current); + history.undo_len -= 1; + const previous = history.undo[history.undo_len]; + setContent(p, f, previous.content); + restoreSnap(pane, f, previous); + } + + pub fn redo(p: *Pardes, pane: *Pane) void { + const f = if (pane.file) |*file| file else return; + const history = f.history; + if (history.redo_len == 0) return; + const current: Snapshot = .{ + .content = p.gpa.dupe(u8, f.content) catch return, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }; + pushHistory(p.gpa, &history.undo, &history.undo_len, current); + history.redo_len -= 1; + const next = history.redo[history.redo_len]; + setContent(p, f, next.content); + restoreSnap(pane, f, next); + } + + /// Commit an externally rewritten file onto the same undo history as typed + /// edits. Unsaved work remains one `u` away; there is no third merge state. + pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void { + const pane = p.panes[id] orelse return; + const f = if (pane.file) |*file| file else return; + if (std.mem.eql(u8, f.content, bytes)) return; + const new = p.gpa.dupe(u8, bytes) catch return; + pushUndo(p, pane); + setContent(p, f, new); + f.saved_revision = f.revision; + // restoreSnap only consumes cursor/selection from this synthetic snapshot. + restoreSnap(pane, f, .{ + .content = undefined, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }); + } + + /// re-highlight the visible window of any file whose syntax went stale + /// (edit, scroll, load) — visible-range-first so big files stay snappy + pub fn refreshHighlights(p: *Pardes) void { + const tz = tracy.zone(@src(), "refreshHighlights"); + defer tz.end(); + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.file == null) continue; + const f = &pane.file.?; + if (f.mini != null) { + f.syntax_dirty = false; + continue; + } + if (!f.syntax_dirty) continue; + if (!p.settings.colors) { + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + f.highlights = &.{}; + f.highlight_start = 0; + f.syntax_dirty = false; + continue; + } + const need_start = lineStart(p.gpa, f, f.scroll); + const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS)); + if (f.highlights.len > 0 and need_start >= f.highlight_start and + need_end <= f.highlight_start + f.highlights.len) + { + f.syntax_dirty = false; + continue; + } + const slack: usize = if (f.highlights.len == 0) 0 else pane.rows; + const whole = pane.colorAlgo() == .locations; + const start = if (whole) 0 else lineStart(p.gpa, f, f.scroll -| slack); + const end = if (whole) + f.content.len + else + @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack)); + const new_highlights = (switch (pane.colorAlgo()) { + .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end), + .locations => syntax.highlightLocations(p.tree_sitter_gpa, f.content, start, end), + else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end), + }) catch { + f.syntax_dirty = false; + continue; + }; + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + f.highlights = new_highlights; + f.highlight_start = if (f.highlights.len > 0) start else 0; + f.syntax_dirty = false; + } + } + + pub fn wrapWidth(pane: *const Pane, wrap: bool) usize { + if (!wrap or pane.rows > pane.wrap_line.len) return 0; + return @max(1, @as(usize, pane.cols -| gutterWidth(pane)) -| 1); + } + + pub const VisualRow = struct { start: usize, end: usize }; + + pub fn visualRow(line: []const u8, col: usize, width: usize) VisualRow { + if (width == 0) return .{ .start = 0, .end = line.len }; + var start: usize = 0; + while (true) { + const end = fitEnd(line, start, width); + if (col < end or end >= line.len) return .{ .start = start, .end = end }; + start = end; + } + } + + pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 { + const width = wrapWidth(pane, wrap); + pane.wrap_n = 0; + + const len = fillBody(null, pane, f, width, false); + const out = try arena.alloc(u8, len); + const filled = fillBody(out, pane, f, width, true); + std.debug.assert(filled == out.len); + return out; + } + + /// Run the file-body row walk. With no destination it is the exact sizing + /// pass; with one it fills that allocation and records the wrapping map. + fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) usize { + if (record_wrap) pane.wrap_n = 0; + const prefix_width = gutterWidth(pane); + // start ON the first visible line instead of walking the file to it: this + // walk was O(f.scroll) and recolorSyntax below ran the identical one again + var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n'); + if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next(); + // the line the NEXT row comes from and the byte column of it that row + // starts at — the two the map records, walked forward by the loop + var abs: i32 = @intCast(f.scroll); + var at: usize = 0; + var cur = flines.next(); + var written: usize = 0; + for (0..pane.rows) |i| { + if (i > 0) { + if (dst) |out| out[written] = '\n'; + written += 1; + } + if (width > 0 and record_wrap) { + pane.wrap_line[i] = abs; + pane.wrap_col[i] = @intCast(at); + pane.wrap_n = @intCast(i + 1); + } + if (cur) |text| { + var lbuf: [@max(config.PREFIX_W, 32)]u8 = undefined; + const prefix = lbuf[0..prefix_width]; + @memset(prefix, ' '); + if (at == 0) { + var lineno: usize = @intCast(abs + 1); + var digit: usize = prefix.len - 1; + while (true) { + digit -= 1; + prefix[digit] = '0' + @as(u8, @intCast(lineno % 10)); + lineno /= 10; + if (lineno == 0) break; + } + } + if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix); + written += prefix.len; + + const end = if (width == 0) text.len else fitEnd(text, at, width); + const take = end - at; + const cut = if (pane.hscroll > 0 and width == 0) + modal.graphemeStart(text[at..end], @min(@as(usize, @intCast(pane.hscroll)), take)) + else + 0; + const shown = text[at + cut .. end]; + if (dst) |out| @memcpy(out[written..][0..shown.len], shown); + written += shown.len; + if (width > 0 and end < text.len) { + at = end; + } else { + abs += 1; + at = 0; + cur = flines.next(); + } + } else abs += 1; + } + return written; + } + + pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { + const s = &p.surface; + const prefix_width = gutterWidth(pane); + const ch = p.chromeTheme(); + const goff = pane.scroll(); + const gcur = Terminal.gridCursor(pane); + const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff; + const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32); + // the body's first row, the way renderPane derives it (Tagbottom) + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + var vr: u16 = 0; + while (vr < body_h) : (vr += 1) { + const row_line: i32 = if (pane.wrap_n == 0) + goff + @as(i32, vr) + else if (vr < pane.wrap_n) pane.wrap_line[vr] else std.math.maxInt(i32); + const on_cursor = row_line == cur_line; + var c: u16 = 0; + while (c < prefix_width and c < tw) : (c += 1) { + const cell = s.at(tx + c, body_y + vr); + cell.default = false; // paints blank gutter rows too + if (on_cursor) { + cell.style.fg = .{ .rgb = ch.tag_fg }; + cell.style.bg = .{ .rgb = ch.tag_bg }; + } else cell.style.fg = .{ .rgb = ch.lineno }; + } + } + } + + const SynStyle = struct { fg: [3]u8, bold: bool }; + + fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { + return switch (sy) { + .none => null, + .keyword => .{ .fg = p.theme().kw, .bold = true }, + .string => .{ .fg = p.theme().str, .bold = false }, + .number => .{ .fg = p.theme().num, .bold = false }, + .comment => .{ .fg = p.theme().comment, .bold = true }, + }; + } + + /// syntax colors: recolor each content cell from its tree-sitter style byte; + /// content starts after the lineno gutter + pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void { + const highlights = if (f.mini) |mini| mini.colors else f.highlights; + const highlight_start = if (f.mini != null) 0 else f.highlight_start; + if (highlights.len == 0) return; + const s = &p.surface; + const prefix_width = gutterWidth(pane); + const tz_recolor = tracy.zone(@src(), "synRecolor"); + defer tz_recolor.end(); + // indexed start, same as bodyText — an empty tail simply paints nothing + var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); + const total = nlines(p.gpa, f); + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + var vr: u16 = 0; + while (vr < body_h) : (vr += 1) { + var base: usize = undefined; + var line: []const u8 = undefined; + var hs: usize = @intCast(@max(0, pane.hscroll)); + var limit: usize = undefined; + if (pane.wrap_n == 0) { + line = flines.next() orelse break; + base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); + limit = line.len; + } else { + if (vr >= pane.wrap_n) break; + const lrow: usize = @intCast(@max(0, pane.wrap_line[vr])); + if (lrow >= total) break; + base = lineStart(p.gpa, f, lrow); + const lend = if (lrow + 1 < total) lineStart(p.gpa, f, lrow + 1) -| 1 else f.content.len; + line = f.content[base..lend]; + hs = @intCast(pane.wrap_col[vr]); + limit = if (vr + 1 < pane.wrap_n and pane.wrap_line[vr + 1] == pane.wrap_line[vr]) + @min(line.len, @as(usize, @intCast(pane.wrap_col[vr + 1]))) + else + line.len; + } + hs = modal.graphemeStart(line, @min(hs, line.len)); + var c: usize = 0; + var screen_c: usize = 0; + while (hs + c < limit and prefix_width + screen_c < tw) { + const grapheme_end = @min(limit, modal.nextGrapheme(line, hs + c)); + const cells = graphemeDisplayWidth(line[hs + c .. grapheme_end]); + const idx = base + hs + c; + if (idx >= highlight_start) { + const hidx = idx - highlight_start; + if (hidx < highlights.len) { + if (synStyle(p, @enumFromInt(highlights[hidx]))) |ss| { + var fill: usize = 0; + while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) { + const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr); + if (cell.default) continue; + cell.style.fg = .{ .rgb = ss.fg }; + cell.style.bold = ss.bold; + } + } + } + } + screen_c += cells; + c = grapheme_end - hs; + } + } + } + + pub fn drawWrapMarkers( + p: *Pardes, + pane: *const Pane, + r: pardes.Rect, + tx: u16, + tw: u16, + body_h: u16, + pane_bg: pardes.Color, + ) void { + if (tw <= gutterWidth(pane) + 1) return; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const marker_fg = p.chromeTheme().lineno; + var row: u16 = 0; + while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) { + if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue; + p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{ + .fg = .{ .rgb = marker_fg }, + .bg = pane_bg, + }); + } + } + + pub fn paintWordSelection( + p: *Pardes, + pane: *Pane, + r: pardes.Rect, + row: i32, + word_lo: i32, + word_hi: i32, + bg: [3]u8, + ) void { + const tx = r.x + config.GUTTER; + const tw = r.w - config.GUTTER; + const prefix_width = gutterWidth(pane); + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + var vr: i32 = 0; + while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) { + const here = pane.wrapAt(vr); + if (here.line != row) continue; + var hi = word_hi; + const next = pane.wrapAt(vr + 1); + if (next.line == row) hi = @min(hi, next.at); + const lo = @max(word_lo, here.at); + if (hi <= lo) continue; + const c0 = @as(i32, prefix_width) + displayOffset(pane, row, here.at, lo); + const c1 = @as(i32, prefix_width) + displayEndOffset(pane, row, here.at, hi - 1); + var col = @max(@as(i32, prefix_width), c0); + while (col <= c1 and col < @as(i32, tw)) : (col += 1) { + const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); + cell.default = false; + cell.style.bg = .{ .rgb = bg }; + } + } + } +}; + +pub const Output = struct { + const Builtin = builtins.registry.Builtin(); + const gui_shader_source_mode = effect_sources.guiShaderSourceMode(); + + const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {}; + + pub const Origin = union(enum) { + cmd: Builtin, + query: lsp.Kind, + search, + errors, + }; + + pub const max_arg = dump.max_origin_arg; + + pub const State = struct { + from: Origin, + arg_buf: [max_arg]u8 = undefined, + arg_len: u16 = 0, + + pub fn arg(o: *const State) []const u8 { + return o.arg_buf[0..o.arg_len]; + } + }; + + pub fn setArg(o: *State, text: []const u8) error{ArgumentTooLong}!void { + if (text.len > max_arg) return error.ArgumentTooLong; + o.arg_len = @intCast(text.len); + @memcpy(o.arg_buf[0..o.arg_len], text); + } + + pub const Traits = builtins.OutputTraits; + + const file_row: Traits = .{ .name = "", .doc = true, .saves = true }; + + pub fn traits(o: Origin) Traits { + return switch (o) { + // rows are `location text`, so n/N walk them + .search => .{ .name = config.search_buffer, .steps = true }, + .errors => .{ .name = config.errors_buffer, .doc = true }, + .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, + .query => |k| switch (k) { + .hover => .{ .name = config.hover_buffer }, + // prose: an action list, a diff, a report about the backend + .code_action, .format, .status, .explain => .{ .name = config.lsp_buffer }, + .rename => .{ .name = config.search_buffer, .steps = true }, + .definition, .declaration, .type_definition, .implementation, .references => .{ + .name = config.search_buffer, + .steps = true, + .jumps = true, + }, + // The hierarchy kinds behave like references: a list of places, + // and a lone answer (one caller, one subtype) is a jump. + .incoming_calls, .outgoing_calls, .supertypes, .subtypes => .{ + .name = config.search_buffer, + .steps = true, + .jumps = true, + }, + .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{ + .name = config.search_buffer, + .steps = true, + }, + }, + }; + } + + pub fn fileTraits(out: ?State) Traits { + return traits((out orelse return file_row).from); + } + + pub const Grain = enum { + word, + line, + whole, + }; + + pub fn grain(out: ?State) Grain { + const tr = fileTraits(out); + if (tr.commands) return .whole; + return if (tr.steps) .line else .word; + } + + pub fn word(o: Origin) []const u8 { + return switch (o) { + .cmd => |b| @tagName(b), + .query => |k| @tagName(k), + .search => "/", + .errors => config.errors_buffer, + }; + } + + pub fn fromWord(w: []const u8) ?Origin { + if (w.len == 0) return null; + if (std.mem.eql(u8, w, "/")) return .search; + if (std.mem.eql(u8, w, config.errors_buffer)) return .errors; + if (std.meta.stringToEnum(Builtin, w)) |b| + if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b }; + if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; + return null; + } + + pub fn resultsFrom(p: *Pardes, pane: *Pane, from: Origin) bool { + const rp = p.panes[pane.search_pane orelse return false] orelse return false; + const f = rp.file orelse return false; + const o = f.output orelse return false; + return std.meta.eql(o.from, from); + } + + pub const Location = struct { + path: []const u8, + at: look.Spot, + end: usize, + + fn order(a: Location, b: Location) std.math.Order { + const path = std.mem.order(u8, a.path, b.path); + if (path != .eq) return path; + if (a.at.line != b.at.line) return std.math.order(a.at.line, b.at.line); + return std.math.order(a.at.col, b.at.col); + } + }; + + pub fn location(line: []const u8) Location { + const text = std.mem.trimEnd(u8, line, " \t\r\n"); + var at: usize = 1; + while (at < text.len) { + if (text[at] != ':' or at + 1 == text.len or !std.ascii.isDigit(text[at + 1])) { + at += 1; + continue; + } + const start = at; + at += 1; + while (at < text.len and (std.ascii.isDigit(text[at]) or text[at] == ':' or text[at] == '-')) at += 1; + if (at < text.len and text[at] != ' ' and text[at] != '\t') continue; + const parsed = look.parsePathLine(text[start..at]); + if (parsed.at.line == 0 or parsed.end != at - start) continue; + return .{ .path = text[0..start], .at = parsed.at, .end = at }; + } + return .{ .path = text, .at = .{}, .end = text.len }; + } + + fn sortResults(arena: std.mem.Allocator, from: Origin, content: []u8, anchor: ?usize) !?usize { + switch (from) { + .search => {}, + .cmd => |cmd| switch (cmd) { + .Find, .Grep => {}, + else => return anchor, + }, + .query => |kind| switch (kind) { + .definition, + .declaration, + .type_definition, + .implementation, + .references, + .incoming_calls, + .outgoing_calls, + .supertypes, + .subtypes, + .document_symbols, + .workspace_symbols, + .diagnostics, + .workspace_diagnostics, + .select_refs, + .rename, + => {}, + else => return anchor, + }, + .errors => return anchor, + } + if (content.len == 0) return anchor; + const path_only = std.meta.eql(from, Origin{ .cmd = .Find }); + const trailing_newline = content[content.len - 1] == '\n'; + const body = content[0 .. content.len - @intFromBool(trailing_newline)]; + var lines = std.mem.splitScalar(u8, body, '\n'); + var previous: ?Location = null; + var sorted = true; + var count: usize = 0; + while (lines.next()) |line| { + const current: Location = if (path_only) .{ .path = line, .at = .{}, .end = line.len } else location(line); + if (!path_only and current.at.line == 0) return anchor; + if (previous) |last| if (last.order(current) == .gt) { + sorted = false; + }; + previous = current; + count += 1; + } + if (sorted) return anchor; + + const Row = struct { + text: []const u8, + target: Location, + original: usize, + + fn lessThan(_: void, a: @This(), b: @This()) bool { + return switch (a.target.order(b.target)) { + .lt => true, + .eq => a.original < b.original, + .gt => false, + }; + } + }; + const rows = try arena.alloc(Row, count); + const copy = try arena.dupe(u8, body); + lines = std.mem.splitScalar(u8, copy, '\n'); + for (rows, 0..) |*row, original| { + const text = lines.next().?; + const target: Location = if (path_only) .{ .path = text, .at = .{}, .end = text.len } else location(text); + row.* = .{ + .text = text, + .target = target, + .original = original, + }; + } + std.mem.sort(Row, rows, {}, Row.lessThan); + var mapped = anchor; + var offset: usize = 0; + for (rows, 0..) |row, i| { + if (anchor == row.original) mapped = i; + @memcpy(content[offset..][0..row.text.len], row.text); + offset += row.text.len; + if (i + 1 < rows.len or trailing_newline) { + content[offset] = '\n'; + offset += 1; + } + } + std.debug.assert(offset == content.len); + return mapped; + } + + pub fn nextResult(content: []const u8, path: []const u8, at: look.Spot) usize { + const current: Location = .{ .path = path, .at = at, .end = 0 }; + var lines = std.mem.splitScalar(u8, content, '\n'); + var first: ?usize = null; + var row: usize = 0; + while (lines.next()) |line| : (row += 1) { + const target = location(line); + if (target.at.line == 0) continue; + if (first == null) first = row; + if (target.order(current) == .gt) return row; + } + return first orelse 0; + } + + pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8) !*Pane { + const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ + std.mem.trimEnd(u8, dir, "/"), traits(from).name, + }); + errdefer p.gpa.free(path); + var out: State = .{ .from = from }; + try setArg(&out, arg); + const history = try File.History.create(p.gpa); + errdefer p.gpa.destroy(history); + const pane = try p.newDocPane(id); + pane.file = .{ .path = path, .content = content, .output = out, .history = history }; + pane.cur_pinned = true; + return pane; + } + + pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void { + errdefer p.gpa.free(content); + const pane = p.panes[id] orelse return error.MissingPane; + const anchor = try sortResults(p.scratch.allocator(), from, content, initial_anchor); + 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; + try setArg(o, arg); + if (std.mem.eql(u8, rf.content, content)) { + p.gpa.free(content); + } else { + File.setContent(p, rf, content); + resetBody(p, rp); + } + p.active = id; + if (traits(from).steps) { + pane.search_pane = i; + pane.search_row = anchor; + p.armLookWalk(i); + } + return; + } + const free = p.freeSlot() orelse return error.NoPaneSlots; + const np = try open(p, free, dir, from, arg, content); + p.placeDoc(id, free, np); + p.active = id; + if (traits(from).steps) { + pane.search_pane = free; + pane.search_row = anchor; + p.armLookWalk(free); + } + } + + pub fn openJumps(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + for (p.jumps[0..p.njumps]) |j| { + const jp = p.panes[j.pane] orelse continue; + var idbuf: [16]u8 = undefined; + const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null; + const has_path = if (jp.file) |f| f.output == null else pdf_path != null; + const loc: []const u8 = if (has_path) + (if (jp.file) |f| f.path else pdf_path.?) + else + std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable; + const what: []const u8 = if (jp.file) |f| + std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") + else if (jp.image) |iv| + iv.path + else if (pdf_path) |path| + path + else + jp.cwdSlice(); + var cut = @min(what.len, 120); + while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; + if (j.line == 0) + try out.writer.print("{s} {s}\n", .{ loc, what[0..cut] }) + else + try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }); + } + const content = try out.toOwnedSlice(); + try openStepped(p, id, .{ .cmd = .Jumplist }, content); + } + + pub fn openThemes(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + for (pardes.themes) |t| + try out.writer.print(comptime config.Runtime.findAction(.theme).?.word ++ " {s}\n", .{t.name}); + const content = try out.toOwnedSlice(); + try openStepped(p, id, .{ .cmd = .ThemeSel }, content); + } + + pub fn openFonts(p: *Pardes, id: usize) !void { + if (builtins.capabilities.font_picker) { + const arena = p.scratch.allocator(); + const font_list = fonts.list(arena, null); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + for (font_list) |f| + try out.writer.print(comptime config.Runtime.findAction(.font).?.word ++ " {s}\n", .{f.name}); + const content = try out.toOwnedSlice(); + try openStepped(p, id, .{ .cmd = .FontSel }, content); + } + } + + fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void { + errdefer p.gpa.free(content); + const pane = p.panes[id] orelse return error.MissingPane; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse return error.NoPaneSlots; + const np = try open(p, free, dir, from, "", content); + p.placeDoc(id, free, np); + p.active = id; + pane.search_pane = free; + pane.search_row = null; + p.armLookWalk(free); + } + + fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 { + const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n"; + const group_header = "pardes builtins under SPC"; + const language_footer = + "\nlanguage keys (motions, not words):\n" ++ + "gd gD gy gi gr goto: definition,\n" ++ + " declaration, type-def,\n" ++ + " implementation, refs\n" ++ + "]d [d ]D [D diagnostics: next,\n" ++ + " prev, last, first\n" ++ + "= format (applies, one\n" ++ + " undo step)\n" ++ + "Tab after a . completion, in insert\n" ++ + "C-left-click definition, by mouse\n" ++ + "SPC l ... hover, rename, symbols,\n" ++ + " calls, types: above\n"; + var len: usize = if (prefix.len == 0) + full_header.len + language_footer.len + else + group_header.len + prefix.len * 2 + 2; + for (pardes.builtin_rows) |row| { + // a path-less builtin filters as the empty path: in the full listing + // (which starts with nothing) and out of every group + if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; + len += row.line.len + 1; + } + const content = try gpa.alloc(u8, len); + var at: usize = 0; + if (prefix.len == 0) { + @memcpy(content[0..full_header.len], full_header); + at = full_header.len; + } else { + @memcpy(content[0..group_header.len], group_header); + at = group_header.len; + for (prefix) |c| { + content[at] = ' '; + content[at + 1] = c; + at += 2; + } + content[at] = '\n'; + content[at + 1] = '\n'; + at += 2; + } + for (pardes.builtin_rows) |row| { + if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; + @memcpy(content[at..][0..row.line.len], row.line); + at += row.line.len; + content[at] = '\n'; + at += 1; + } + if (prefix.len == 0) { + @memcpy(content[at..][0..language_footer.len], language_footer); + at += language_footer.len; + } + std.debug.assert(at == content.len); + return content; + } + + pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void { + const content = try helpContent(p.gpa, prefix); + // content is handed off unfreed on purpose: openRead adopts it or frees + // it, and nothing between the alloc above and this line can fail. + return openRead(p, id, .{ .cmd = .Help }, prefix, content); + } + + test "full Help renders every builtin row, then the language keys" { + const content = try helpContent(std.testing.allocator, ""); + defer std.testing.allocator.free(content); + + // FIRST blank line: the end of the header (the footer opens with one too) + const body = content[(std.mem.indexOf(u8, content, "\n\n") orelse + return error.MissingHelpHeader) + 2 ..]; + var lines = std.mem.splitScalar(u8, body, '\n'); + for (pardes.builtin_rows) |row| + try std.testing.expectEqualStrings(row.line, lines.next() orelse + return error.MissingBuiltinHelpRow); + // ...and after the last row, the language-keys section: the one part of + // the keymap no builtin row can carry, closing the page. + try std.testing.expectEqualStrings("", lines.next() orelse + return error.MissingLanguageKeys); + try std.testing.expectEqualStrings("language keys (motions, not words):", lines.next() orelse + return error.MissingLanguageKeys); + try std.testing.expect(std.mem.indexOf(u8, body, "\ngd gD gy gi gr goto: definition,\n") != null); + // the group view stays a pure filter: no footer under a prefix + const group = try helpContent(std.testing.allocator, "l"); + defer std.testing.allocator.free(group); + try std.testing.expect(std.mem.indexOf(u8, group, "language keys") == null); + } + + pub fn openConfig(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try config.Runtime.writeReport(&out.writer, .{ + .startup_config_path = p.opts.startup_config_path, + .platform = @tagName(pardes.platform), + .theme_name = p.theme().name, + .compiled_default_shell = config.default_shell, + .gui_shader_source_mode = if (gui_shader_source_mode) |mode| + mode.label() + else + null, + .hover_delay_frames = config.look_preview_delay_frames, + .native_images = p.native_images, + .capabilities = builtins.capabilities, + .state = &p.settings, + }); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .Config }, "", content); + } + + /// The message-row log, oldest first — the lines that were said in passing and + /// then cleared by the next keystroke. + pub fn openMessages(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + var i: usize = 0; + while (p.messageLog(i)) |m| : (i += 1) { + if (m.pane != 0xff) try out.writer.print("{d}: ", .{m.pane}); + try out.writer.writeAll(m.slice()); + if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats}); + try out.writer.writeByte('\n'); + } + if (i == 0) try out.writer.writeAll("nothing has been said yet\n"); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .Messages }, "", content); + } + + /// The version banner plus the embedded CHANGELOG, so an installed binary can + /// say what it is and what changed without a repository beside it. + pub fn openChangelog(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try out.writer.print("pardes {s}\n\n", .{build_options.version}); + try out.writer.writeAll(@embedFile("CHANGELOG.md")); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .Changelog }, "", content); + } + + pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void { + const name = std.mem.trim(u8, argument, " \t\r\n"); + const setting = config.Runtime.find(name) orelse return error.UnknownEffect; + switch (setting.action) { + .transition, .scene => {}, + else => return error.NotAnEffect, + } + if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable; + const paths = effect_sources.forSetting(setting) orelse + return error.EffectUnavailable; + + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) }); + if (gui_shader_source_mode) |mode| + try out.writer.print("GUI shader source: {s}\n", .{mode.label()}); + try out.writer.writeByte('\n'); + for (paths) |path| try out.writer.print("/virtual/{s}\n", .{path}); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, content); + } + + pub fn openErrors(p: *Pardes, id: usize, content: []u8) !void { + return openRead(p, id, .errors, "", content); + } + + fn resetBody(p: *Pardes, pane: *Pane) void { + pane.file.?.scroll = 0; + pane.cur_row = 0; + pane.cur_col = 0; + pane.cur_pinned = true; + pane.hscroll = 0; + pane.wrap_n = 0; + pane.msel = .{}; + pane.vsel = .{}; + pane.nsel = 0; + pane.nsel_snap = 0; + pane.select = false; + pane.sel = @splat(.{}); + pane.sticky_col = -1; + pane.append_at = null; + pane.normal.clear(); + pane.look_at = null; + if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) { + p.look_hover_wait = null; + }; + if (p.look_hover_preview) |preview| if (preview.serial == pane.serial) { + p.look_hover_preview = null; + }; + if (p.drag == .select and p.panes[p.drag.select.id] == pane) p.drag = .none; + } + + fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void { + errdefer p.gpa.free(content); + const pane = p.panes[id] orelse return error.MissingPane; + for (p.panes, 0..) |slot, i| { + const hp = slot orelse continue; + const hf = if (hp.file) |*f| f else continue; + const ho = if (hf.output) |*o| o else continue; + if (!std.meta.eql(ho.from, from)) continue; + try setArg(ho, arg); + File.setContent(p, hf, content); + resetBody(p, hp); + p.active = i; + return; + } + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse return error.NoPaneSlots; + const np = try open(p, free, dir, from, arg, content); + p.placeDoc(id, free, np); + p.active = free; + } +}; + +pub const Mini = struct { + pub const max_input_bytes = 4 * 1024 * 1024; + pub const max_output_bytes = 4 * 1024 * 1024; + + pub const State = struct { + source: []u8, + colors: []u8, + + pub fn deinit(state: *State, gpa: std.mem.Allocator) void { + gpa.free(state.source); + gpa.free(state.colors); + state.* = undefined; + } + }; + + pub const Result = struct { + content: []u8, + colors: []u8, + + pub fn deinit(result: Result, gpa: std.mem.Allocator) void { + gpa.free(result.content); + gpa.free(result.colors); + } + }; + + const Row = struct { + text: []const u8 = "", + base: usize = 0, + at: usize = 0, + left: usize = 0, + ink: bool = false, + color: u8 = 0, + + fn dot(row: *Row, styles: []const u8) ?u8 { + if (row.left == 0) { + if (row.at == row.text.len) return null; + const end = modal.nextGrapheme(row.text, row.at); + const grapheme = row.text[row.at..end]; + row.left = File.graphemeDisplayWidth(grapheme); + const n = std.unicode.utf8ByteSequenceLength(grapheme[0]) catch unreachable; + const cp = std.unicode.utf8Decode(grapheme[0..n]) catch unreachable; + const blank = switch (cp) { + '\t'...'\r', ' ', 0x85, 0xa0, 0x1680, 0x2000...0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000 => true, + else => false, + }; + row.ink = !blank or grapheme.len != n; + row.color = if (styles.len == 0) 0 else styles[row.base + row.at]; + row.at = end; + } + row.left -= 1; + return if (row.ink) row.color else null; + } + }; + + fn render(output: ?Result, source: []const u8, styles: []const u8) !usize { + if (source.len == 0) return 0; + const end = source.len - @intFromBool(source[source.len - 1] == '\n'); + var lines = std.mem.splitScalar(u8, source[0..end], '\n'); + var offset: usize = 0; + while (lines.peek() != null) { + var rows: [4]Row = @splat(.{}); + for (&rows) |*row| { + const line = lines.next() orelse break; + row.text = std.mem.trimEnd(u8, line, "\r"); + row.base = @intFromPtr(line.ptr) - @intFromPtr(source.ptr); + } + var spaces: usize = 0; + while (true) { + var more = false; + for (rows) |row| more = more or row.at < row.text.len or row.left > 0; + if (!more) break; + const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } }; + var mask: u8 = 0; + var counts: [5]u8 = @splat(0); + for (&rows, 0..) |*row, y| { + for (0..2) |x| { + if (row.dot(styles)) |color| { + mask |= @as(u8, 1) << bits[y][x]; + counts[color] += 1; + } + } + } + if (mask == 0) { + spaces += 1; + continue; + } + if (spaces + 3 > max_output_bytes - offset) return error.MiniTooLarge; + var color: u8 = 0; + var most: u8 = 0; + for (counts[1..], 1..) |count, i| { + if (count > most) { + color = @intCast(i); + most = count; + } + } + if (output) |out| { + @memset(out.content[offset..][0..spaces], ' '); + @memset(out.colors[offset..][0..spaces], 0); + _ = std.unicode.utf8Encode(@as(u21, 0x2800) + mask, out.content[offset + spaces ..][0..3]) catch unreachable; + @memset(out.colors[offset + spaces ..][0..3], color); + } + offset += spaces + 3; + spaces = 0; + } + if (offset == max_output_bytes) return error.MiniTooLarge; + if (output) |out| { + out.content[offset] = '\n'; + out.colors[offset] = 0; + } + offset += 1; + } + return offset; + } + + pub fn generate(gpa: std.mem.Allocator, source: []const u8, styles: []const u8) !Result { + if (source.len > max_input_bytes) return error.MiniTooLarge; + if (!std.unicode.utf8ValidateSlice(source)) return error.InvalidUtf8; + if (styles.len != 0 and styles.len != source.len) return error.InvalidMiniColors; + for (styles) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidMiniColors; + const len = try render(null, source, styles); + const content = try gpa.alloc(u8, len); + errdefer gpa.free(content); + const colors = try gpa.alloc(u8, len); + errdefer gpa.free(colors); + const result: Result = .{ .content = content, .colors = colors }; + const written = try render(result, source, styles); + std.debug.assert(written == len); + return result; + } + + pub fn open(p: *Pardes, id: usize, argument: []const u8) !void { + const caller = p.panes[id] orelse return error.MissingPane; + const word = std.mem.trim(u8, argument, " \t\r\n"); + if (word.len == 0) return error.MissingPath; + var path_buf: [4096]u8 = undefined; + const target = filesystem.resolve(p, word, Pardes.paneDir(caller), &path_buf) orelse return error.FileNotFound; + if (target.dir) return error.NotAFile; + const source = try p.gpa.dupe(u8, target.path); + errdefer p.gpa.free(source); + const input = try filesystem.readLimit(p, source, max_input_bytes); + defer p.gpa.free(input); + const styles = try syntax.highlightFileRange(p.tree_sitter_gpa, source, input, 0, input.len); + defer p.tree_sitter_gpa.free(styles); + const result = try generate(p.gpa, input, styles); + errdefer result.deinit(p.gpa); + for (p.panes, 0..) |slot, i| { + const pane = slot orelse continue; + const file = if (pane.file) |*f| f else continue; + const old = file.mini orelse continue; + if (!std.mem.eql(u8, old.source, source)) continue; + if (std.mem.eql(u8, file.content, result.content) and std.mem.eql(u8, old.colors, result.colors)) { + p.gpa.free(source); + result.deinit(p.gpa); + } else { + File.setContent(p, file, result.content); + file.mini = .{ .source = source, .colors = result.colors }; + file.syntax_dirty = false; + Output.resetBody(p, pane); + } + p.active = i; + return; + } + const free = p.freeSlot() orelse return error.NoPaneSlots; + const dir = std.fs.path.dirname(source) orelse "/"; + const path = try std.fmt.allocPrint(p.gpa, "{s}/Mini {s}", .{ std.mem.trimEnd(u8, dir, "/"), std.fs.path.basename(source) }); + errdefer p.gpa.free(path); + const history = try File.History.create(p.gpa); + errdefer p.gpa.destroy(history); + const pane = try p.newDocPane(free); + pane.file = .{ + .path = path, + .content = result.content, + .output = .{ .from = .{ .cmd = .Mini } }, + .mini = .{ .source = source, .colors = result.colors }, + .history = history, + .syntax_dirty = false, + }; + pane.cur_pinned = true; + p.placeDoc(id, free, pane); + p.active = free; + } + + test "Mini maps every braille dot and partial line group" { + const gpa = std.testing.allocator; + const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } }; + for (0..256) |mask| { + var source: [12]u8 = undefined; + for (0..4) |row| { + for (0..2) |col| source[row * 3 + col] = if (mask & (@as(usize, 1) << bits[row][col]) != 0) 'x' else ' '; + source[row * 3 + 2] = '\n'; + } + const result = try generate(gpa, &source, ""); + defer result.deinit(gpa); + var expected: [4]u8 = undefined; + const len: usize = if (mask == 0) 0 else try std.unicode.utf8Encode(@as(u21, 0x2800) + @as(u21, @intCast(mask)), &expected); + expected[len] = '\n'; + try std.testing.expectEqualStrings(expected[0 .. len + 1], result.content); + try std.testing.expectEqual(result.content.len, result.colors.len); + for (result.colors) |color| try std.testing.expectEqual(@as(u8, 0), color); + } + for ([_]struct { source: []const u8, expected: []const u8 }{ + .{ .source = "", .expected = "" }, + .{ .source = "x", .expected = "⠁\n" }, + .{ .source = "xx\n", .expected = "⠉\n" }, + .{ .source = "x \n", .expected = "⠁\n" }, + .{ .source = "\n\n\n\nx", .expected = "\n⠁\n" }, + .{ .source = "x\r\nx\r\n", .expected = "⠃\n" }, + }) |case| { + const result = try generate(gpa, case.source, ""); + defer result.deinit(gpa); + try std.testing.expectEqualStrings(case.expected, result.content); + } + } + + test "Mini uses display cells for tabs combining text and wide characters" { + const gpa = std.testing.allocator; + for ([_]struct { source: []const u8, expected: []const u8 }{ + .{ .source = "e\u{301}界\n", .expected = "⠉⠁\n" }, + .{ .source = " x\n", .expected = " ⠁\n" }, + .{ .source = "\u{a0}x\n", .expected = "⠈\n" }, + }) |case| { + const result = try generate(gpa, case.source, ""); + defer result.deinit(gpa); + try std.testing.expectEqualStrings(case.expected, result.content); + } + const tabs = try generate(gpa, "\tx\n", ""); + defer tabs.deinit(gpa); + const spaces = config.tab_width / 2; + for (tabs.content[0..spaces]) |byte| try std.testing.expectEqual(@as(u8, ' '), byte); + try std.testing.expectEqualStrings(if (config.tab_width % 2 == 0) "⠁\n" else "⠈\n", tabs.content[spaces..]); + } + + test "Mini chooses highlighted dots over plain ink with stable color ties" { + const gpa = std.testing.allocator; + const source = "xx\nxx\nxx\nxx\n"; + var styles: [source.len]u8 = @splat(0); + styles[0] = @intFromEnum(syntax.Syn.keyword); + const rare = try generate(gpa, source, &styles); + defer rare.deinit(gpa); + try std.testing.expectEqualStrings("⣿\n", rare.content); + try std.testing.expectEqualSlices(u8, &.{ 1, 1, 1, 0 }, rare.colors); + styles[0] = @intFromEnum(syntax.Syn.string); + styles[1] = @intFromEnum(syntax.Syn.number); + const tied = try generate(gpa, source, &styles); + defer tied.deinit(gpa); + try std.testing.expectEqualSlices(u8, &.{ 2, 2, 2, 0 }, tied.colors); + styles[3] = @intFromEnum(syntax.Syn.number); + const majority = try generate(gpa, source, &styles); + defer majority.deinit(gpa); + try std.testing.expectEqualSlices(u8, &.{ 3, 3, 3, 0 }, majority.colors); + } + + test "Mini generation bounds and allocation failures leave no partial result" { + const Case = struct { + fn run(gpa: std.mem.Allocator) !void { + const result = try generate(gpa, "alpha\nbeta\ngamma\ndelta\nepsilon\n", ""); + defer result.deinit(gpa); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{}); + try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "x", &.{5})); + try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "xx", &.{0})); + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); + const source = try std.testing.allocator.alloc(u8, max_input_bytes + 1); + defer std.testing.allocator.free(source); + try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source, "")); + for (source[0..max_input_bytes], 0..) |*byte, i| byte.* = if (i % 2 == 0) 'x' else ' '; + try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source[0..max_input_bytes], "")); + try std.testing.expect(!allocator.has_induced_failure); + } + + test "Mini publishes only complete snapshots and content replacement drops metadata" { + const Case = struct { + fn run(gpa: std.mem.Allocator, path: []const u8) !void { + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("untouched\n"); + const free = p.freeSlot(); + open(p, 0, path) catch |err| { + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(free, p.freeSlot()); + try std.testing.expectEqual(source, p.panes[0].?); + try std.testing.expectEqualStrings("untouched\n", source.file.?.content); + return err; + }; + const file = &p.panes[p.active].?.file.?; + try std.testing.expectEqualStrings("⠉\n", file.content); + try std.testing.expectEqualStrings(path, file.mini.?.source); + const replacement = try gpa.dupe(u8, "plain\n"); + File.setContent(p, file, replacement); + try std.testing.expect(file.mini == null); + try std.testing.expectEqualStrings("plain\n", file.content); + } + }; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "mini.txt", .data = "xx\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + var path_buf: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/mini.txt", .{dir}); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{path}); + } +}; + +pub const Image = struct { + const GridKey = struct { + cols: u16 = 0, + rows: u16 = 0, + palette: image.PaletteMode = .commodore, + ascii: bool = true, + }; + + pub const State = struct { + path: []u8, + glyph_art: bool = false, + pmode: image.PaletteMode = .commodore, + ascii: bool = true, + tried: bool = false, + rgba: []u8 = &.{}, + iw: usize = 0, + ih: usize = 0, + /// Dump-loaded bytes, decoded lazily by the same path as a disk image. + raw: []u8 = &.{}, + grid: image.GlyphArt.Grid = .{ .cells = &.{}, .cols = 0, .rows = 0 }, + grid_key: GridKey = .{}, + + pub fn deinit(state: *State, gpa: std.mem.Allocator) void { + gpa.free(state.path); + if (state.rgba.len > 0) gpa.free(state.rgba); + if (state.raw.len > 0) gpa.free(state.raw); + if (state.grid.cells.len > 0) gpa.free(state.grid.cells); + state.* = undefined; + } + }; + + /// Construct an image pane from a path and optionally transferred dump bytes. + /// `raw` must be image_gpa-owned and ownership transfers only on success. + pub fn create(p: *pardes.Pardes, id: usize, path: []const u8, raw: []u8) !*pardes.Pane { + const path_copy = try p.image_gpa.dupe(u8, path); + errdefer p.image_gpa.free(path_copy); + const pane = try p.newDocPane(id); + pane.image = .{ .path = path_copy, .raw = raw }; + return pane; + } + + /// Serialize the binary image record used by images and, for dump-schema + /// compatibility, PDFs. Common pane metadata is supplied by the core. + pub fn dumpPane( + p: *pardes.Pardes, + arena: std.mem.Allocator, + pane: *const pardes.Pane, + tag: []const u8, + body: []const u8, + scroll: usize, + path: []const u8, + raw: []const u8, + ) !dump.Pane { + const bytes = if (raw.len > 0) raw else filesystem.read(p, path) catch &.{}; + defer if (raw.len == 0) p.gpa.free(bytes); + return .{ + .kind = .image, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .image = .{ + .path = path, + .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", + .petscii = if (pane.image) |state| state.glyph_art else false, + .palette = if (pane.image) |state| switch (state.pmode) { + .commodore => .commodore, + .terminal => .terminal, + } else .commodore, + .ascii = if (pane.image) |state| state.ascii else true, + }, + }; + } + + pub fn restore(p: *pardes.Pardes, id: usize, src: dump.Pane) !*pardes.Pane { + const saved = src.image.?; + var raw: []u8 = if (saved.bytes_b64.len > 0) + try dump.decodeBytes(p.image_gpa, saved.bytes_b64) + else + &.{}; + errdefer if (raw.len > 0) p.image_gpa.free(raw); + const pane = try create(p, id, saved.path, raw); + raw = &.{}; + pane.image.?.glyph_art = saved.petscii; + pane.image.?.pmode = switch (saved.palette) { + .commodore => .commodore, + .terminal => .terminal, + }; + pane.image.?.ascii = saved.ascii; + pane.cols = @max(1, src.cols); + pane.rows = @max(1, src.rows); + return pane; + } + + pub fn toggleGlyphArt(state: *State) void { + state.glyph_art = !state.glyph_art; + } + + pub fn togglePalette(state: *State) void { + state.pmode = if (state.pmode == .commodore) .terminal else .commodore; + } + + pub fn toggleAscii(state: *State) void { + state.ascii = !state.ascii; + } + + /// Image renderer choices are pane-local, not global Config values. Keep them + /// queryable where they apply: the live tag beside the image's path. + pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 { + return std.fmt.allocPrint( + arena, + "{s} petscii:{s} palette:{s} ascii:{s} {s}", + .{ + config.tag_image, + if (state.glyph_art) "on" else "off", + @tagName(state.pmode), + if (state.ascii) "on" else "off", + state.path, + }, + ); + } + + pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 { + const lead = config.tag_image ++ " "; + if (!std.mem.startsWith(u8, saved_tag, lead)) return null; + const path_at = lead.len; + if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null; + return saved_tag[0 .. path_at + state.path.len]; + } + + fn ensureDecoded(p: *pardes.Pardes, state: *State) void { + if (state.tried) return; + state.tried = true; + const bytes: []const u8 = if (state.raw.len > 0) + state.raw + else + filesystem.read(p, state.path) catch &.{}; + defer if (state.raw.len == 0) p.gpa.free(bytes); + if (image.decode(p.image_gpa, bytes)) |decoded| { + state.rgba = decoded.rgba; + state.iw = decoded.w; + state.ih = decoded.h; + } + } + + fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void { + const wanted = GridKey{ .cols = cols, .rows = rows, .palette = state.pmode, .ascii = state.ascii }; + if (state.grid.cells.len > 0 and std.meta.eql(state.grid_key, wanted)) return; + if (state.grid.cells.len > 0) p.image_gpa.free(state.grid.cells); + const palette = switch (state.pmode) { + .commodore => image.GlyphArt.commodore, + .terminal => image.terminal_palette, + }; + state.grid = image.GlyphArt.render( + p.image_gpa, + state.rgba, + state.iw, + state.ih, + cols, + rows, + palette, + state.ascii, + ) catch .{ .cells = &.{}, .cols = 0, .rows = 0 }; + state.grid_key = wanted; + } + + pub fn draw( + p: *pardes.Pardes, + state: *State, + pane_id: u8, + serial: u32, + x: u16, + y: u16, + cols: u16, + rows: u16, + ) void { + ensureDecoded(p, state); + if (state.rgba.len == 0 or cols == 0 or rows == 0) return; + if (!state.glyph_art and p.native_images) { + _ = p.appendImagePlace(.{ + .pane = pane_id, + .serial = serial, + .x = x, + .y = y, + .w = cols, + .h = rows, + .rgba = state.rgba, + .iw = state.iw, + .ih = state.ih, + }); + return; + } + + ensureGrid(p, state, cols, rows); + if (state.grid.cells.len == 0) return; + const offx = if (cols > state.grid.cols) (@as(usize, cols) - state.grid.cols) / 2 else 0; + const offy = if (rows > state.grid.rows) (@as(usize, rows) - state.grid.rows) / 2 else 0; + for (0..state.grid.rows) |cy| for (0..state.grid.cols) |cx| { + const cell = &state.grid.cells[cy * state.grid.cols + cx]; + const fg: pardes.Color = switch (state.pmode) { + .commodore => .{ .rgb = image.GlyphArt.commodore[cell.fg] }, + .terminal => .{ .index = cell.fg }, + }; + const bg: pardes.Color = switch (state.pmode) { + .commodore => .{ .rgb = image.GlyphArt.commodore[cell.bg] }, + .terminal => .{ .index = cell.bg }, + }; + const sx = x + @as(u16, @intCast(offx + cx)); + const sy = y + @as(u16, @intCast(offy + cy)); + if (sx < p.surface.cols and sy < p.surface.rows) + p.surface.set(sx, sy, cell.glyph[0..cell.glyph_len], .{ .fg = fg, .bg = bg }); + }; + } +}; + +pub const Pdf = struct { + pub const enabled = @import("pardes_config").mupdf; + pub const pdf = if (enabled) @import("mupdf") else struct { + pub const PageSize = struct { width: f32, height: f32 }; + pub const Raster = struct { + width: usize = 0, + height: usize = 0, + stride: usize = 0, + len: usize = 0, + pub const Band = struct { y: usize = 0, height: usize = 0, len: usize = 0 }; + pub fn wholePage(_: @This()) Band { + return .{}; + } + pub fn band(_: @This(), _: usize, _: usize) Band { + return .{}; + } + }; + }; + pub const Point = if (enabled) pdf.Point else void; + pub const Quad = if (enabled) pdf.Quad else void; + pub const Document = if (enabled) pdf.Document else opaque {}; + pub const OutlineInternalDestination = if (enabled) pdf.OutlineInternalDestination else void; + const raster_max = 256; + const raster_spare = 4; + pub const page_gap_px: u32 = 8; + const band_grain: usize = 64; + + pub const FitMode = if (enabled) enum { width, height } else void; + pub const TintMode = if (enabled) pdf.TintMode else void; + pub const TintColors = if (enabled) pdf.TintColors else void; + pub const RenderRequest = if (enabled) pdf.RenderRequest else void; + + /// Dump records must remain recognizable as PDFs even in a build without + /// MuPDF, where Look deliberately treats them as ordinary files. + pub fn isPath(path: []const u8) bool { + return std.ascii.endsWithIgnoreCase(path, ".pdf"); + } + + pub const RasterPolicy = struct { + dpi: u16, + max_dimension: u16, + match_viewport: bool, + }; + + pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 { + if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null; + const marker = " PdfSections "; + const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null; + const path_at = marker_at + marker.len; + if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null; + return saved_tag[0 .. path_at + path.len]; + } + + pub const TintKey = if (enabled) struct { + mode: TintMode, + colors: pdf.TintColors, + + pub fn eql(a: @This(), b: @This()) bool { + return a.mode == b.mode and + (a.mode == .disabled or std.meta.eql(a.colors, b.colors)); + } + } else void; + + pub const Highlight = if (enabled) pdf.Highlight else void; + pub const Highlights = if (enabled) struct { + items: []const Highlight, + /// Hover items occupy [0..active_start); search/selection follow them. + active_start: usize, + hover_page: ?usize, + + pub fn forPage(highlights: @This(), page: usize, active_page: usize) []const Highlight { + const hover = highlights.items[0..highlights.active_start]; + if (page == active_page) + return if (highlights.hover_page == page) + highlights.items + else + highlights.items[highlights.active_start..]; + return if (highlights.hover_page == page) hover else &.{}; + } + } else void; + + pub const HighlightInput = if (enabled) struct { + hover_quads: []const Quad = &.{}, + hover_page: ?usize = null, + hover_color: [3]u8, + selection_color: [3]u8, + } else void; + + pub fn buildHighlights( + state: *const State, + arena: std.mem.Allocator, + input: HighlightInput, + ) !Highlights { + if (comptime !enabled) return; + const search_len = if (state.search_results) |results| results.quads.len else 0; + const selection_len = if (state.selection) |selection| selection.quads.len else 0; + const active_start = input.hover_quads.len; + const highlights = try arena.alloc(Highlight, active_start + search_len + selection_len); + var n: usize = 0; + for (input.hover_quads) |quad| { + highlights[n] = pdf.Highlight.init( + quad, + .{ input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c }, + .custom, + ); + n += 1; + } + if (state.search_results) |results| { + for (results.quads) |item| { + highlights[n] = pdf.Highlight.init( + item.quad, + .{ 0xff, 0xd5, 0x4f, 0x70 }, + .search, + ); + n += 1; + } + } + if (state.selection) |selection| { + for (selection.quads) |quad| { + highlights[n] = pdf.Highlight.init( + quad, + .{ input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78 }, + .selection, + ); + n += 1; + } + } + return .{ .items = highlights, .active_start = active_start, .hover_page = input.hover_page }; + } + + pub const Raster = if (enabled) struct { + valid: bool = false, + page: usize = 0, + rgba: []u8 = &.{}, + /// Full page shape; rgba contains only the band below. + iw: usize = 0, + ih: usize = 0, + band_y: usize = 0, + band_h: usize = 0, + request: pdf.RenderRequest = .{}, + request_valid: bool = false, + tried: bool = false, + decorated: bool = false, + tint_key: ?TintKey = null, + revision: u32 = 0, + } else void; + + pub const SectionsOutput = if (enabled) struct { + pane: usize, + serial: u32, + revision: u32, + } else void; + + pub const SelectionUpdate = enum { failed, stationary, unchanged, changed }; + + pub const State = if (enabled) struct { + path: []u8, + document: Document, + page: usize = 0, + page_count: usize, + page_sizes: []pdf.PageSize, + page_starts: []u64, + page_heights: []u32, + document_height: u64 = 0, + layout_viewport_w: u32 = 0, + layout_viewport_h: u32 = 0, + layout_fit: FitMode = .width, + layout_valid: bool = false, + document_scroll_y: f64 = 0, + scroll_to_page_pending: bool = true, + rasters: [raster_max]Raster = undefined, + rasters_len: usize = 0, + spare: [raster_spare][]u8 = @splat(&.{}), + spare_len: usize = 0, + layout_anchor_pending: bool = false, + layout_anchor_page: usize = 0, + layout_anchor_fraction: f64 = 0, + next_raster_revision: u32 = 0, + scroll_travel: f64 = 0, + fit: FitMode = .width, + tint: TintMode = .filtered, + pan_x: u16 = 0, + pan_y: u16 = 0, + highlights_dirty: bool = false, + search_reveal_pending: bool = false, + search_results: ?pdf.SearchResults = null, + selection: ?pdf.Selection = null, + selection_text: []u8 = &.{}, + selection_anchor: ?Point = null, + selection_head: ?Point = null, + drag_anchor: ?Point = null, + drag_head: ?Point = null, + text: []u8 = &.{}, + text_tried: bool = false, + text_scroll: usize = 0, + text_scroll_clamp_pending: bool = false, + search_query: []u8 = &.{}, + search_hit: usize = 0, + reveal_viewport_w: u32 = 0, + reveal_viewport_h: u32 = 0, + reveal_fit: FitMode = .width, + reveal_viewport_valid: bool = false, + outline: ?pdf.Outline = null, + outline_tried: bool = false, + sections_output: ?SectionsOutput = null, + outline_reveal_pending: ?pdf.OutlineInternalDestination = null, + + pub fn open(gpa: std.mem.Allocator, path: []const u8, page_one_based: usize) !@This() { + const local = filesystem.localPath(path) orelse return error.NonLocalPath; + return initDocument(gpa, path, try Document.open(local), page_one_based); + } + + pub fn openBytes(gpa: std.mem.Allocator, path: []const u8, bytes: []const u8, page_one_based: usize) !@This() { + return initDocument(gpa, path, try Document.openBytes(bytes), page_one_based); + } + + fn initDocument(gpa: std.mem.Allocator, path: []const u8, opened: Document, page_one_based: usize) !@This() { + var document = opened; + errdefer document.deinit(); + const page_sizes = try gpa.alloc(pdf.PageSize, document.pages); + errdefer gpa.free(page_sizes); + for (page_sizes, 0..) |*size, page| size.* = try document.pageSize(page); + const page_starts = try gpa.alloc(u64, document.pages); + errdefer gpa.free(page_starts); + const page_heights = try gpa.alloc(u32, document.pages); + errdefer gpa.free(page_heights); + const owned_path = try gpa.dupe(u8, path); + errdefer gpa.free(owned_path); + return .{ + .path = owned_path, + .document = document, + .page = if (page_one_based > 0) + @min(page_one_based - 1, document.pages - 1) + else + 0, + .page_count = document.pages, + .page_sizes = page_sizes, + .page_starts = page_starts, + .page_heights = page_heights, + }; + } + + pub fn reload(state: *@This(), gpa: std.mem.Allocator) !void { + const preserve_anchor = !state.scroll_to_page_pending and + (state.layout_anchor_pending or + (state.layout_valid and state.page_count > 0 and state.document_height > 0)); + var anchor_page: usize = state.layout_anchor_page; + var anchor_fraction: f64 = state.layout_anchor_fraction; + if (preserve_anchor and !state.layout_anchor_pending) { + anchor_page = pageAtOffset(state, state.document_scroll_y); + const start: f64 = @floatFromInt(state.page_starts[anchor_page]); + const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[anchor_page])); + anchor_fraction = std.math.clamp( + (state.document_scroll_y - start) / height, + 0, + 1, + ); + } + + var fresh = try @This().open(gpa, state.path, state.page + 1); + errdefer fresh.deinit(gpa); + if (state.search_query.len > 0) + fresh.search_query = try gpa.dupe(u8, state.search_query); + + fresh.fit = state.fit; + fresh.tint = state.tint; + fresh.pan_x = state.pan_x; + fresh.pan_y = state.pan_y; + fresh.text_scroll = state.text_scroll; + fresh.text_scroll_clamp_pending = true; + fresh.search_hit = state.search_hit; + fresh.highlights_dirty = fresh.search_query.len > 0; + fresh.search_reveal_pending = state.search_reveal_pending; + fresh.reveal_viewport_w = state.reveal_viewport_w; + fresh.reveal_viewport_h = state.reveal_viewport_h; + fresh.reveal_fit = state.reveal_fit; + fresh.reveal_viewport_valid = state.reveal_viewport_valid; + // Revisions are part of the backend texture key. Resetting this counter + // while the pane serial stays live can alias a cached pre-reload page. + fresh.next_raster_revision = state.next_raster_revision; + fresh.sections_output = state.sections_output; + if (preserve_anchor) { + fresh.scroll_to_page_pending = false; + fresh.layout_anchor_pending = true; + fresh.layout_anchor_page = anchor_page; + fresh.layout_anchor_fraction = anchor_fraction; + } + + var old = state.*; + state.* = fresh; + old.deinit(gpa); + } + + pub fn invalidateRaster(state: *@This(), page: usize) void { + if (rasterForPage(state, page)) |raster| raster.tried = false; + } + + pub fn invalidateAllRasters(state: *@This()) void { + for (state.rasters[0..state.rasters_len]) |*raster| { + if (raster.valid) raster.tried = false; + } + } + + fn retireRgba(state: *@This(), gpa: std.mem.Allocator, rgba: []u8) void { + if (rgba.len == 0) return; + if (state.spare_len == state.spare.len) return gpa.free(rgba); + state.spare[state.spare_len] = rgba; + state.spare_len += 1; + } + + fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void { + state.retireRgba(gpa, raster.rgba); + raster.* = .{}; + } + + fn claimRgba(state: *@This(), gpa: std.mem.Allocator, bytes: usize) ?[]u8 { + for (state.spare[0..state.spare_len], 0..) |candidate, index| { + if (candidate.len != bytes) continue; + state.spare_len -= 1; + state.spare[index] = state.spare[state.spare_len]; + return candidate; + } + return gpa.alloc(u8, bytes) catch null; + } + + fn trimSpares(state: *@This(), gpa: std.mem.Allocator) void { + while (state.spare_len > 1) { + state.spare_len -= 1; + gpa.free(state.spare[state.spare_len]); + } + } + + fn dropSearchResults(state: *@This(), gpa: std.mem.Allocator) void { + if (state.search_results) |*results| results.deinit(gpa); + state.search_results = null; + } + + fn dropSelection(state: *@This(), gpa: std.mem.Allocator) void { + if (state.selection) |*selection| selection.deinit(gpa); + state.selection = null; + if (state.selection_text.len > 0) gpa.free(state.selection_text); + state.selection_text = &.{}; + state.selection_anchor = null; + state.selection_head = null; + } + + pub fn setSelection( + state: *@This(), + gpa: std.mem.Allocator, + start: Point, + end: Point, + invalidate_raster: bool, + ) SelectionUpdate { + if (state.selection != null and + state.selection_anchor != null and state.selection_head != null and + state.selection_anchor.?.x == start.x and state.selection_anchor.?.y == start.y and + state.selection_head.?.x == end.x and state.selection_head.?.y == end.y) return .unchanged; + var selection = state.document.select(gpa, state.page, start, end) catch return .failed; + const text = state.document.copySelection( + gpa, + state.page, + selection.start, + selection.end, + ) catch { + selection.deinit(gpa); + return .failed; + }; + + state.dropSelection(gpa); + state.selection = selection; + state.selection_text = text; + state.selection_anchor = start; + state.selection_head = end; + if (invalidate_raster) state.invalidateRaster(state.page); + return .changed; + } + + pub fn clearDrag(state: *@This()) void { + state.drag_anchor = null; + state.drag_head = null; + } + + pub fn clearSelection(state: *@This(), gpa: std.mem.Allocator) void { + const changed = state.selection != null or state.selection_text.len > 0; + state.dropSelection(gpa); + if (changed) state.invalidateRaster(state.page); + } + + pub fn cancelChrome(state: *@This(), gpa: std.mem.Allocator) void { + state.clearDrag(); + state.clearSelection(gpa); + if (state.search_query.len == 0) return; + state.dropSearchQuery(gpa); + state.invalidateRaster(state.page); + } + + fn invalidatePage(state: *@This(), gpa: std.mem.Allocator) void { + state.dropSearchResults(gpa); + state.dropSelection(gpa); + state.clearDrag(); + if (state.text.len > 0) gpa.free(state.text); + state.text = &.{}; + state.text_tried = false; + state.text_scroll = 0; + state.text_scroll_clamp_pending = false; + state.highlights_dirty = state.search_query.len > 0; + state.search_reveal_pending = state.search_query.len > 0; + state.search_hit = 0; + } + + fn dropSearchQuery(state: *@This(), gpa: std.mem.Allocator) void { + if (state.search_query.len > 0) gpa.free(state.search_query); + state.search_query = &.{}; + state.search_hit = 0; + state.dropSearchResults(gpa); + state.highlights_dirty = false; + state.search_reveal_pending = false; + } + + pub fn setSearchQuery(state: *@This(), gpa: std.mem.Allocator, query: []const u8) !void { + if (std.mem.eql(u8, state.search_query, query)) return; + const owned = try gpa.dupe(u8, query); + state.dropSearchQuery(gpa); + state.search_query = owned; + state.highlights_dirty = query.len > 0; + state.search_reveal_pending = query.len > 0; + state.invalidateRaster(state.page); + } + + pub fn ensureText(state: *@This(), gpa: std.mem.Allocator) []const u8 { + if (!state.text_tried) { + state.text_tried = true; + state.text = state.document.pageText(gpa, state.page) catch &.{}; + } + if (state.text_scroll_clamp_pending) { + const lines = std.mem.count(u8, state.text, "\n") + 1; + state.text_scroll = @min(state.text_scroll, lines - 1); + state.text_scroll_clamp_pending = false; + } + return state.text; + } + + pub fn resolveSearch(state: *@This(), gpa: std.mem.Allocator) void { + if (!state.highlights_dirty) return; + state.highlights_dirty = false; + state.dropSearchResults(gpa); + if (state.search_query.len == 0) return; + const results = state.document.search(gpa, state.page, state.search_query) catch return; + state.search_hit = if (results.hit_count == 0) + 0 + else + @min(state.search_hit, results.hit_count - 1); + state.search_results = results; + } + + pub fn ensureOutline(state: *@This(), gpa: std.mem.Allocator) ?*const pdf.Outline { + if (!state.outline_tried) { + state.outline_tried = true; + state.outline = state.document.outline(gpa) catch null; + } + return if (state.outline) |*outline| outline else null; + } + + pub fn renderSections( + state: *@This(), + pdf_gpa: std.mem.Allocator, + output_gpa: std.mem.Allocator, + ) ![]u8 { + const entries: []const pdf.OutlineEntry = if (state.ensureOutline(pdf_gpa)) |outline| + outline.entries + else + &.{}; + return SectionRows.render(output_gpa, state.path, entries); + } + + pub fn sectionDestination( + state: *@This(), + gpa: std.mem.Allocator, + ordinal: usize, + ) ?pdf.OutlineDestination { + const outline = state.ensureOutline(gpa) orelse return null; + return SectionRows.resolve(outline.entries, ordinal); + } + + /// Apply the one-based page/hit location encoded in a PDF search row. + /// Returns whether host pane cursor chrome must be reset. + pub fn focusLocation( + state: *@This(), + gpa: std.mem.Allocator, + line: usize, + column: usize, + ) bool { + const changed = line > 0 and state.activatePage(gpa, line - 1, true); + if (column > 0 and state.search_query.len > 0) { + state.search_hit = column - 1; + state.search_reveal_pending = true; + } + return changed; + } + + /// Change the document page while leaving pane cursor/selection chrome to + /// the UI adapter. Returns whether that pane-local chrome must be reset. + pub fn activatePage( + state: *@This(), + gpa: std.mem.Allocator, + page: usize, + reveal: bool, + ) bool { + state.outline_reveal_pending = null; + const next = @min(page, state.page_count -| 1); + const changed = next != state.page; + if (changed) { + state.invalidatePage(gpa); + state.page = next; + } + if (reveal) { + state.scroll_to_page_pending = true; + if (state.layout_valid) { + state.document_scroll_y = @floatFromInt(state.page_starts[next]); + state.scroll_to_page_pending = false; + } + } else { + state.search_reveal_pending = false; + } + return changed; + } + + pub fn toggleFit(state: *@This()) void { + state.fit = if (state.fit == .width) .height else .width; + state.pan_x = 0; + state.pan_y = 0; + state.layout_valid = false; + state.scroll_to_page_pending = true; + state.search_reveal_pending = state.search_query.len > 0; + } + + pub fn toggleTint(state: *@This()) void { + state.tint = state.tint.next(); + state.invalidateAllRasters(); + } + + pub fn queueOutlineReveal( + state: *@This(), + destination: pdf.OutlineInternalDestination, + ) void { + state.scroll_to_page_pending = false; + state.layout_anchor_pending = false; + state.outline_reveal_pending = destination; + } + + pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void { + gpa.free(state.path); + for (state.rasters[0..state.rasters_len]) |raster| + if (raster.rgba.len > 0) gpa.free(raster.rgba); + for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba); + gpa.free(state.page_sizes); + gpa.free(state.page_starts); + gpa.free(state.page_heights); + if (state.text.len > 0) gpa.free(state.text); + if (state.search_query.len > 0) gpa.free(state.search_query); + if (state.search_results) |*results| results.deinit(gpa); + if (state.selection) |*selection| selection.deinit(gpa); + if (state.selection_text.len > 0) gpa.free(state.selection_text); + if (state.outline) |*outline| outline.deinit(gpa); + state.document.deinit(); + state.* = undefined; + } + } else void; + + pub const SearchOutput = struct { + bytes: usize = 0, + rows: usize = 0, + anchor: ?usize = null, + }; + + pub fn textLines( + state: *State, + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + ) ![]const []const u8 { + if (comptime !enabled) return &.{}; + const page_text = state.ensureText(gpa); + const lines = try arena.alloc([]const u8, std.mem.count(u8, page_text, "\n") + 1); + var it = std.mem.splitScalar(u8, page_text, '\n'); + var n: usize = 0; + while (it.next()) |line| : (n += 1) lines[n] = line; + return lines; + } + + pub fn visibleText( + state: *State, + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + max_rows: usize, + ) ![]const u8 { + if (comptime !enabled) return ""; + const page_text = state.ensureText(gpa); + var start: usize = 0; + for (0..state.text_scroll) |_| { + const newline = std.mem.indexOfScalarPos(u8, page_text, start, '\n') orelse + return arena.dupe(u8, ""); + start = newline + 1; + } + if (max_rows == 0) return arena.dupe(u8, ""); + + var end = start; + var row: usize = 0; + while (row < max_rows) : (row += 1) { + const newline = std.mem.indexOfScalarPos(u8, page_text, end, '\n') orelse { + end = page_text.len; + break; + }; + if (row + 1 == max_rows) { + end = newline; + break; + } + end = newline + 1; + } + return arena.dupe(u8, page_text[start..end]); + } + + /// Materialize exact MuPDF logical hits as `path:PAGE:HIT query` rows. The + /// same hit numbering drives persistent highlights and later reveal actions. + pub fn searchRows( + state: *State, + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + pattern: []const u8, + from_cursor: bool, + out: []u8, + ) !SearchOutput { + if (comptime !enabled) return .{}; + try state.setSearchQuery(gpa, pattern); + const shown = std.fs.path.basename(state.path); + var result: SearchOutput = .{}; + const max_hits = 512; + var snippet_len = @min(pattern.len, 200); + while (snippet_len > 0 and snippet_len < pattern.len and pattern[snippet_len] & 0xc0 == 0x80) + snippet_len -= 1; + const snippet = pattern[0..snippet_len]; + for (0..state.page_count) |page| { + if (result.rows >= max_hits) break; + var found = try state.document.search(gpa, page, pattern); + defer found.deinit(gpa); + + const cursor_hit: ?usize = if (from_cursor and page == state.page) cursor: { + const selection = state.selection orelse break :cursor null; + for (found.quads) |item| { + const q = item.quad; + const center: Point = .{ + .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4, + .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4, + }; + if (selection.contains(center)) break :cursor item.hit; + } + break :cursor null; + } else null; + + for (0..found.hit_count) |hit_index| { + if (result.rows >= max_hits) break; + const line = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ + shown, page + 1, hit_index + 1, snippet, + }); + if (line.len > out.len - result.bytes) return result; + if (from_cursor and + (page < state.page or + (page == state.page and cursor_hit != null and hit_index <= cursor_hit.?))) + result.anchor = result.rows; + @memcpy(out[result.bytes..][0..line.len], line); + result.bytes += line.len; + result.rows += 1; + } + } + return result; + } + + pub const Viewport = struct { pixel_w: u32, pixel_h: u32 }; + pub const VisiblePages = struct { first: usize = 0, len: usize = 0 }; + pub const PanAxis = enum { horizontal, vertical }; + pub const PanResult = enum { moved, edge, unavailable }; + pub const ScrollResult = struct { active_page: usize }; + pub const CellPixels = struct { w: u16, h: u16 }; + pub const NormalHost = enum { + none, + leader, + command_line, + search, + search_forward, + search_backward, + }; + pub const NormalResult = struct { + host: NormalHost = .none, + page_changed: bool = false, + }; + + const PlacedGeometry = struct { + geometry: image.NativeGeometry, + pixel_offset_y: f32, + }; + + pub const PlacedRaster = if (enabled) struct { + page: usize, + revision: u32, + fit: FitMode, + pan_x: u16, + geometry: image.NativeGeometry, + pixel_offset_y: f32, + rgba: []const u8, + width: usize, + band_height: usize, + } else void; + + const VisibleRows = struct { + base: image.NativeGeometry, + y0: u32, + y1: u32, + dst_y: u32, + dst_h: u32, + pixel_offset_y: f32, + }; + + pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest { + if (comptime !enabled) return; + return .{ + .dpi = policy.dpi, + .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0, + .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0, + .max_dimension = policy.max_dimension, + }; + } + + fn pageHeight(size: pdf.PageSize, viewport: Viewport, fit: FitMode) u32 { + if (comptime !enabled) return 0; + if (fit == .height) return viewport.pixel_h; + const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) * + @as(f64, size.height) / @as(f64, size.width); + return @max(1, @as(u32, @intFromFloat(@min( + @as(f64, @floatFromInt(std.math.maxInt(u32))), + @round(scaled), + )))); + } + + fn pageAtOffset(state: *const State, offset: f64) usize { + if (comptime !enabled) return 0; + const y: u64 = @intFromFloat(std.math.clamp( + @floor(offset), + 0, + @as(f64, @floatFromInt(state.document_height -| 1)), + )); + var lo: usize = 0; + var hi: usize = state.page_count; + while (lo + 1 < hi) { + const mid = lo + (hi - lo) / 2; + if (state.page_starts[mid] <= y) lo = mid else hi = mid; + } + const end = state.page_starts[lo] + state.page_heights[lo]; + return if (y >= end and lo + 1 < state.page_count) lo + 1 else lo; + } + + fn pageVisible(state: *const State, page: usize, viewport: Viewport) bool { + if (comptime !enabled) return false; + const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y; + const bottom = top + @as(f64, @floatFromInt(state.page_heights[page])); + return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h)); + } + + pub fn visiblePages(state: *const State, viewport: Viewport) VisiblePages { + if (comptime !enabled) return .{}; + var out: VisiblePages = .{}; + var page = pageAtOffset(state, state.document_scroll_y); + if (page > 0 and pageVisible(state, page - 1, viewport)) page -= 1; + out.first = page; + while (page < state.page_count) : (page += 1) { + const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y; + if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break; + if (pageVisible(state, page, viewport)) out.len += 1; + } + return out; + } + + fn visibleContains(visible: VisiblePages, page: usize) bool { + return page >= visible.first and page - visible.first < visible.len; + } + + pub fn rasterForPage(state: *State, page: usize) ?*Raster { + if (comptime !enabled) return null; + for (state.rasters[0..state.rasters_len]) |*raster| + if (raster.valid and raster.page == page) return raster; + return null; + } + + fn rasterForPageConst(state: *const State, page: usize) ?*const Raster { + if (comptime !enabled) return null; + for (state.rasters[0..state.rasters_len]) |*raster| + if (raster.valid and raster.page == page) return raster; + return null; + } + + fn visibleRows( + state: *const State, + viewport: Viewport, + page: usize, + iw: usize, + ih: usize, + ) ?VisibleRows { + if (comptime !enabled) return null; + const page_h = state.page_heights[page]; + const base = image.nativeGeometry( + iw, + ih, + viewport.pixel_w, + page_h, + switch (state.fit) { + .width => .width, + .height => .height, + }, + state.pan_x, + 0, + ) orelse return null; + if (base.dst.h == 0 or base.src.h == 0) return null; + + const scroll_floor = @floor(state.document_scroll_y); + const fractional: f32 = @floatCast(state.document_scroll_y - scroll_floor); + const scroll_i: i64 = @intFromFloat(@min( + scroll_floor, + @as(f64, @floatFromInt(std.math.maxInt(i64))), + )); + const start_i: i64 = @intCast(@min( + state.page_starts[page], + @as(u64, std.math.maxInt(i64)), + )); + const full_y = start_i - scroll_i + @as(i64, base.dst.y); + const full_bottom = full_y + @as(i64, base.dst.h); + const visible_y = @max(@as(i64, 0), full_y); + const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom); + if (visible_bottom <= visible_y) return null; + + const rel_y0: u64 = @intCast(visible_y - full_y); + const rel_y1: u64 = @intCast(visible_bottom - full_y); + const src_y0: u32 = base.src.y + @as(u32, @intCast( + rel_y0 * base.src.h / base.dst.h, + )); + const src_y1: u32 = base.src.y + @as(u32, @intCast(@min( + @as(u64, base.src.h), + (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h, + ))); + if (src_y1 <= src_y0) return null; + return .{ + .base = base, + .y0 = src_y0, + .y1 = src_y1, + .dst_y = @intCast(visible_y), + .dst_h = @intCast(visible_bottom - visible_y), + .pixel_offset_y = -fractional, + }; + } + + fn placedGeometry( + state: *const State, + raster: *const Raster, + viewport: Viewport, + page: usize, + ) ?PlacedGeometry { + if (comptime !enabled) return null; + const rows = visibleRows(state, viewport, page, raster.iw, raster.ih) orelse return null; + const band_y: u32 = @intCast(raster.band_y); + const band_end: u32 = @intCast(raster.band_y + raster.band_h); + if (rows.y0 < band_y or rows.y1 > band_end) return null; + return .{ + .geometry = .{ + .src = .{ + .x = rows.base.src.x, + .y = rows.y0 - band_y, + .w = rows.base.src.w, + .h = rows.y1 - rows.y0, + }, + .dst = .{ + .x = rows.base.dst.x, + .y = rows.dst_y, + .w = rows.base.dst.w, + .h = rows.dst_h, + }, + }, + .pixel_offset_y = rows.pixel_offset_y, + }; + } + + pub fn placedRaster(state: *const State, viewport: Viewport, page: usize) ?PlacedRaster { + if (comptime !enabled) return null; + const raster = rasterForPageConst(state, page) orelse return null; + if (raster.rgba.len == 0) return null; + const placed = placedGeometry(state, raster, viewport, page) orelse return null; + return .{ + .page = page, + .revision = raster.revision, + .fit = state.fit, + .pan_x = state.pan_x, + .geometry = placed.geometry, + .pixel_offset_y = placed.pixel_offset_y, + .rgba = raster.rgba, + .width = raster.iw, + .band_height = raster.band_h, + }; + } + + pub fn activeGeometry(state: *const State, viewport: Viewport) ?image.NativeGeometry { + if (comptime !enabled) return null; + const raster = rasterForPageConst(state, state.page) orelse return null; + const placed = placedGeometry(state, raster, viewport, state.page) orelse return null; + return placed.geometry; + } + + pub fn pageAtViewportY(state: *const State, local_y: f64) ?usize { + if (comptime !enabled) return null; + if (!state.layout_valid or !std.math.isFinite(local_y) or local_y < 0) return null; + const document_y = state.document_scroll_y + local_y; + const page = pageAtOffset(state, document_y); + const start: f64 = @floatFromInt(state.page_starts[page]); + if (document_y < start or + document_y >= start + @as(f64, @floatFromInt(state.page_heights[page]))) return null; + return page; + } + + pub fn pageReady(state: *const State, viewport: Viewport, page: usize) bool { + if (comptime !enabled) return false; + return placedRaster(state, viewport, page) != null; + } + + pub fn nativeReady(state: *const State, viewport: Viewport) bool { + if (comptime !enabled) return false; + for (state.rasters[0..state.rasters_len]) |*raster| { + if (raster.valid and raster.rgba.len > 0 and + placedGeometry(state, raster, viewport, raster.page) != null) return true; + } + return false; + } + + pub fn pointAtPage( + state: *const State, + viewport: Viewport, + page: usize, + px: i64, + py: i64, + clamp_to_page: bool, + ) ?Point { + if (comptime !enabled) return null; + const raster = rasterForPageConst(state, page) orelse return null; + if (raster.rgba.len == 0) return null; + const placed = placedGeometry(state, raster, viewport, page) orelse return null; + return pointAtGeometry( + placed.geometry, + placed.pixel_offset_y, + raster.iw, + raster.ih, + raster.band_y, + px, + py, + clamp_to_page, + ); + } + + fn slotShape(slot: *const Raster) pdf.Raster { + const stride = slot.iw * 4; + return .{ .width = slot.iw, .height = slot.ih, .stride = stride, .len = stride * slot.ih }; + } + + fn flinging(state: *const State, viewport: Viewport) bool { + if (comptime !enabled) return false; + return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h)); + } + + fn wantedBand( + state: *const State, + viewport: Viewport, + page: usize, + shape: pdf.Raster, + is_flinging: bool, + ) pdf.Raster.Band { + if (!is_flinging) return shape.wholePage(); + const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse + return shape.wholePage(); + const first = (@as(usize, rows.y0) / band_grain) * band_grain; + const last = std.math.divCeil(usize, @as(usize, rows.y1), band_grain) catch + return shape.wholePage(); + return shape.band(first, last * band_grain - first); + } + + fn reconcile( + state: *State, + gpa: std.mem.Allocator, + request: RenderRequest, + tint_key: TintKey, + highlights: Highlights, + visible: VisiblePages, + viewport: Viewport, + ) void { + if (comptime !enabled) return; + const tz = tracy.zone(@src(), "pdf.reconcile"); + defer tz.end(); + + // Remove first so arriving pages can claim departing page buffers. Only + // the final visible set can be seen, so a fling skips crossed-over pages. + var index: usize = 0; + while (index < state.rasters_len) { + if (visibleContains(visible, state.rasters[index].page)) { + index += 1; + continue; + } + state.retireRaster(gpa, &state.rasters[index]); + state.rasters_len -= 1; + if (index != state.rasters_len) + state.rasters[index] = state.rasters[state.rasters_len]; + } + + const is_flinging = flinging(state, viewport); + var page = visible.first; + const end = visible.first + visible.len; + while (page < end) : (page += 1) { + var raster = rasterForPage(state, page); + if (raster == null) { + if (state.rasters_len == state.rasters.len) continue; + state.rasters[state.rasters_len] = .{ .valid = true, .page = page }; + state.rasters_len += 1; + raster = &state.rasters[state.rasters_len - 1]; + } + const slot = raster.?; + const page_highlights = highlights.forPage(page, state.page); + const decorated = page_highlights.len > 0; + const stale = !slot.tried or !slot.request_valid or + !slot.request.eql(request) or slot.decorated != decorated or + slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or + slot.rgba.len == 0 or slot.band_h == 0; + const uncovered = !stale and uncovered: { + const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging); + break :uncovered slot.band_y > want.y or + slot.band_y + slot.band_h < want.y + want.height; + }; + if (!stale and !uncovered) continue; + + slot.tried = true; + slot.request = request; + slot.request_valid = true; + const shape_or_null = shape: { + const tz_measure = tracy.zone(@src(), "pdf.measure"); + defer tz_measure.end(); + break :shape state.document.measureRenderAt(page, request) catch null; + }; + const shape = shape_or_null orelse continue; + const want = wantedBand(state, viewport, page, shape, is_flinging); + const fresh = state.claimRgba(gpa, want.len) orelse continue; + const filled = filled: { + { + const tz_render = tracy.zone(@src(), "pdf.render_into"); + defer tz_render.end(); + state.document.renderIntoAt( + page, + request, + shape, + want, + page_highlights, + fresh, + ) catch break :filled false; + } + const tz_tint = tracy.zone(@src(), "pdf.tint"); + defer tz_tint.end(); + pdf.tintRgba(fresh, tint_key.mode, tint_key.colors) catch + break :filled false; + break :filled true; + }; + if (!filled) { + state.retireRgba(gpa, fresh); + continue; + } + + state.retireRgba(gpa, slot.rgba); + slot.rgba = fresh; + slot.iw = shape.width; + slot.ih = shape.height; + slot.band_y = want.y; + slot.band_h = want.height; + slot.decorated = decorated; + slot.tint_key = tint_key; + state.next_raster_revision +%= 1; + if (state.next_raster_revision == 0) state.next_raster_revision = 1; + slot.revision = state.next_raster_revision; + } + state.trimSpares(gpa); + } + + pub fn renderFrame( + state: *State, + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + viewport: Viewport, + policy: RasterPolicy, + tint_key: TintKey, + highlight_input: HighlightInput, + ) VisiblePages { + if (comptime !enabled) return .{}; + ensureLayout(state, viewport); + state.resolveSearch(gpa); + const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{ + .items = &.{}, + .active_start = 0, + .hover_page = null, + }; + const request = renderRequest(viewport, policy); + var visible = visiblePages(state, viewport); + reconcile(state, gpa, request, tint_key, highlights, visible, viewport); + + rearmSearchReveal(state, viewport); + revealSearch(state, viewport, activeGeometry(state, viewport)); + visible = visiblePages(state, viewport); + reconcile(state, gpa, request, tint_key, highlights, visible, viewport); + return visible; + } + + pub fn normalizedPixel(value: f32, dimension: usize) u32 { + const scaled = std.math.clamp(value, 0, 1) * @as(f32, @floatFromInt(dimension)); + return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled)))); + } + + pub fn scaledStep(base: u32, count: u32) u32 { + return @intCast(@min( + @as(u64, std.math.maxInt(u32)), + @as(u64, base) * @max(@as(u64, 1), count), + )); + } + + pub fn scrollDocument(state: *State, viewport: Viewport, delta_pixels: f64) ?ScrollResult { + if (comptime !enabled) return null; + if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return null; + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + const next = std.math.clamp(state.document_scroll_y + delta_pixels, 0, max_scroll); + if (next == state.document_scroll_y) return null; + state.scroll_travel += @abs(next - state.document_scroll_y); + state.document_scroll_y = next; + return .{ .active_page = pageAtOffset(state, next) }; + } + + pub fn panPixels( + state: *State, + geometry: image.NativeGeometry, + axis: PanAxis, + direction: i8, + display_pixels: u32, + ) PanResult { + if (comptime !enabled) return .unavailable; + const raster = rasterForPage(state, state.page) orelse return .unavailable; + const source_full: u32 = @intCast(switch (axis) { + .horizontal => raster.iw, + .vertical => raster.ih, + }); + const crop = switch (axis) { + .horizontal => geometry.src.w, + .vertical => geometry.src.h, + }; + const source_at = switch (axis) { + .horizontal => geometry.src.x, + .vertical => geometry.src.y, + }; + const displayed = @max(@as(u32, 1), switch (axis) { + .horizontal => geometry.dst.w, + .vertical => geometry.dst.h, + }); + const overflow = source_full -| crop; + if (overflow == 0 or + (direction < 0 and source_at == 0) or + (direction > 0 and source_at >= overflow)) return .edge; + + const source_step = @max( + @as(u64, 1), + (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed, + ); + const normalized_step: u32 = @intCast(@min( + @as(u64, std.math.maxInt(u16)), + @max( + @as(u64, 1), + (source_step * std.math.maxInt(u16) + overflow - 1) / overflow, + ), + )); + const position = switch (axis) { + .horizontal => &state.pan_x, + .vertical => &state.pan_y, + }; + if (direction > 0) { + position.* = @intCast(@min( + @as(u32, std.math.maxInt(u16)), + @as(u32, position.*) + normalized_step, + )); + } else { + position.* -|= @intCast(normalized_step); + } + return .moved; + } + + fn setHorizontalEdge(state: *State, geometry: image.NativeGeometry, end: bool) void { + if (comptime !enabled) return; + const raster = rasterForPage(state, state.page) orelse return; + if (raster.iw <= geometry.src.w) return; + state.pan_x = if (end) std.math.maxInt(u16) else 0; + } + + fn rearmSearchReveal(state: *State, viewport: Viewport) void { + if (comptime !enabled) return; + if (state.search_query.len == 0) return; + if (!state.reveal_viewport_valid or + state.reveal_viewport_w != viewport.pixel_w or + state.reveal_viewport_h != viewport.pixel_h or + state.reveal_fit != state.fit) + state.search_reveal_pending = true; + } + + pub fn revealSearch( + state: *State, + viewport: Viewport, + geometry: ?image.NativeGeometry, + ) void { + if (comptime !enabled) return; + if (!state.search_reveal_pending) return; + state.reveal_viewport_w = viewport.pixel_w; + state.reveal_viewport_h = viewport.pixel_h; + state.reveal_fit = state.fit; + state.reveal_viewport_valid = true; + const results = state.search_results orelse { + state.search_reveal_pending = false; + return; + }; + if (results.hit_count == 0 or results.quads.len == 0) { + state.search_reveal_pending = false; + return; + } + state.search_hit = @min(state.search_hit, results.hit_count - 1); + const q = for (results.quads) |item| { + if (item.hit == state.search_hit) break item.quad; + } else { + state.search_reveal_pending = false; + return; + }; + state.search_reveal_pending = false; + const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4; + const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4; + if (state.fit == .width) { + ensureLayout(state, viewport); + const page_y = @as(f64, @floatFromInt(state.page_starts[state.page])) + + @as(f64, center_y) * @as(f64, @floatFromInt(state.page_heights[state.page])); + const wanted = page_y - @as(f64, @floatFromInt(viewport.pixel_h)) / 2; + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + state.document_scroll_y = std.math.clamp(wanted, 0, max_scroll); + return; + } + const placed = geometry orelse return; + const raster = rasterForPage(state, state.page) orelse return; + const full: u32 = @intCast(raster.iw); + const at = normalizedPixel(center_x, raster.iw); + if (at >= placed.src.x and at < placed.src.x + placed.src.w) return; + const overflow = full -| placed.src.w; + if (overflow == 0) return; + const wanted = @min(overflow, at -| placed.src.w / 2); + state.pan_x = @intCast( + (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, + ); + } + + pub fn stepPage(state: *State, gpa: std.mem.Allocator, delta: i64) bool { + const current: i64 = @intCast(state.page); + const last: i64 = @intCast(state.page_count -| 1); + return state.activatePage( + gpa, + @intCast(std.math.clamp(current + delta, 0, last)), + true, + ); + } + + fn scrollNormal( + state: *State, + gpa: std.mem.Allocator, + viewport: ?Viewport, + delta_pixels: f64, + ) bool { + const view = viewport orelse return false; + ensureLayout(state, view); + const result = scrollDocument(state, view, delta_pixels) orelse return false; + return result.active_page != state.page and + state.activatePage(gpa, result.active_page, false); + } + + fn moveRows( + state: *State, + gpa: std.mem.Allocator, + native_images: bool, + viewport: ?Viewport, + cell_pixels: CellPixels, + direction: i8, + count: u32, + ) bool { + if (!native_images) { + const pages: i64 = @intCast(@max(@as(u32, 1), count)); + return stepPage(state, gpa, if (direction > 0) pages else -pages); + } + return scrollNormal( + state, + gpa, + viewport, + @as(f64, @floatFromInt(scaledStep(cell_pixels.h, count))) * direction, + ); + } + + fn movePage( + state: *State, + gpa: std.mem.Allocator, + native_images: bool, + viewport: ?Viewport, + cell_pixels: CellPixels, + direction: i8, + kind: modal.Normal.Page, + count: u32, + ) bool { + if (!native_images) { + const pages: i64 = @intCast(@max(@as(u32, 1), count)); + return stepPage(state, gpa, if (direction > 0) pages else -pages); + } + const base: u32 = switch (kind) { + .half_down, .half_up => if (viewport) |view| + @max(@as(u32, 1), view.pixel_h / 2) + else + cell_pixels.h, + .down, .up => if (viewport) |view| view.pixel_h else cell_pixels.h, + }; + return scrollNormal( + state, + gpa, + viewport, + @as(f64, @floatFromInt(scaledStep(base, count))) * direction, + ); + } + + pub fn applyNormal( + state: *State, + gpa: std.mem.Allocator, + semantic: modal.Normal.Action, + native_images: bool, + cell_pixels: CellPixels, + viewport: ?Viewport, + geometry: ?image.NativeGeometry, + ) NormalResult { + if (comptime !enabled) return .{}; + var result: NormalResult = .{}; + switch (semantic) { + .escape => state.cancelChrome(gpa), + .move => |move| switch (move.motion) { + .down => result.page_changed = moveRows( + state, + gpa, + native_images, + viewport, + cell_pixels, + 1, + move.count, + ), + .up => result.page_changed = moveRows( + state, + gpa, + native_images, + viewport, + cell_pixels, + -1, + move.count, + ), + .left => if (native_images) if (geometry) |placed| { + _ = panPixels(state, placed, .horizontal, -1, scaledStep(cell_pixels.w, move.count)); + }, + .right => if (native_images) if (geometry) |placed| { + _ = panPixels(state, placed, .horizontal, 1, scaledStep(cell_pixels.w, move.count)); + }, + else => {}, + }, + .goto => |go| switch (go.target) { + .file_start => result.page_changed = state.activatePage( + gpa, + if (go.explicit_count) go.count -| 1 else 0, + true, + ), + .last_line => result.page_changed = state.activatePage(gpa, state.page_count -| 1, true), + .line_start, .first_nonws => if (native_images) if (geometry) |placed| + setHorizontalEdge(state, placed, false), + .line_end => if (native_images) if (geometry) |placed| + setHorizontalEdge(state, placed, true), + .line_down => result.page_changed = moveRows( + state, + gpa, + native_images, + viewport, + cell_pixels, + 1, + go.count, + ), + .line_up => result.page_changed = moveRows( + state, + gpa, + native_images, + viewport, + cell_pixels, + -1, + go.count, + ), + else => {}, + }, + .line => |line| switch (line) { + .start, .first_nonws => if (native_images) if (geometry) |placed| + setHorizontalEdge(state, placed, false), + .end => if (native_images) if (geometry) |placed| + setHorizontalEdge(state, placed, true), + }, + .goto_line => |go| { + if (go.explicit) + result.page_changed = state.activatePage(gpa, go.count -| 1, true); + }, + .page => |page| result.page_changed = switch (page.kind) { + .half_down, .down => movePage( + state, + gpa, + native_images, + viewport, + cell_pixels, + 1, + page.kind, + page.count, + ), + .half_up, .up => movePage( + state, + gpa, + native_images, + viewport, + cell_pixels, + -1, + page.kind, + page.count, + ), + }, + .view => |view| result.page_changed = switch (view) { + .scroll_down => moveRows(state, gpa, native_images, viewport, cell_pixels, 1, 1), + .scroll_up => moveRows(state, gpa, native_images, viewport, cell_pixels, -1, 1), + else => false, + }, + .leader => result.host = .leader, + .command_line => result.host = .command_line, + .search => result.host = .search, + .search_step => |direction| result.host = if (direction == .forward) + .search_forward + else + .search_backward, + else => {}, + } + return result; + } + + pub fn captureLayoutAnchor(state: *State) void { + if (comptime !enabled) return; + if (!state.layout_valid or state.page_count == 0 or state.document_height == 0) return; + const page = pageAtOffset(state, state.document_scroll_y); + const start: f64 = @floatFromInt(state.page_starts[page]); + const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[page])); + state.layout_anchor_page = page; + state.layout_anchor_fraction = std.math.clamp((state.document_scroll_y - start) / height, 0, 1); + state.layout_anchor_pending = true; + } + + fn consumeOutlineReveal(state: *State, viewport: Viewport) void { + const destination = state.outline_reveal_pending orelse return; + state.outline_reveal_pending = null; + const page = @min(destination.page, state.page_count -| 1); + const size = state.page_sizes[page]; + const raw_y = destination.y orelse 0; + const y = if (std.math.isFinite(raw_y)) std.math.clamp(raw_y, 0, size.height) else 0; + state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) + + @as(f64, y) / @as(f64, size.height) * @as(f64, @floatFromInt(state.page_heights[page])); + + if (destination.x) |raw_x| if (state.fit == .height and std.math.isFinite(raw_x)) { + const display_width = @as(f64, @floatFromInt(viewport.pixel_h)) * + @as(f64, size.width) / @as(f64, size.height); + const viewport_width: f64 = @floatFromInt(viewport.pixel_w); + if (display_width > viewport_width) { + const x = std.math.clamp(raw_x, 0, size.width); + const target = @as(f64, x) / @as(f64, size.width) * display_width; + const overflow = display_width - viewport_width; + const wanted = std.math.clamp(target - viewport_width / 2, 0, overflow); + state.pan_x = @intFromFloat(@round( + wanted / overflow * @as(f64, std.math.maxInt(u16)), + )); + } + }; + state.search_reveal_pending = false; + state.reveal_viewport_w = viewport.pixel_w; + state.reveal_viewport_h = viewport.pixel_h; + state.reveal_fit = state.fit; + state.reveal_viewport_valid = true; + } + + pub fn ensureLayout(state: *State, viewport: Viewport) void { + if (comptime !enabled) return; + const tz = tracy.zone(@src(), "pdf.ensure_layout"); + defer tz.end(); + if (state.layout_valid and !state.scroll_to_page_pending and + !state.layout_anchor_pending and + (state.layout_viewport_w != viewport.pixel_w or + state.layout_viewport_h != viewport.pixel_h)) captureLayoutAnchor(state); + if (!state.layout_valid or state.layout_viewport_w != viewport.pixel_w or + state.layout_viewport_h != viewport.pixel_h or state.layout_fit != state.fit) + { + var at: u64 = 0; + for (state.page_sizes, 0..) |size, page| { + state.page_starts[page] = at; + const height = pageHeight(size, viewport, state.fit); + state.page_heights[page] = height; + at = std.math.add(u64, at, height) catch std.math.maxInt(u64); + if (page + 1 < state.page_count) + at = std.math.add(u64, at, page_gap_px) catch std.math.maxInt(u64); + } + state.document_height = at; + state.layout_viewport_w = viewport.pixel_w; + state.layout_viewport_h = viewport.pixel_h; + state.layout_fit = state.fit; + state.layout_valid = true; + if (state.scroll_to_page_pending) { + state.document_scroll_y = @floatFromInt(state.page_starts[state.page]); + state.scroll_to_page_pending = false; + state.layout_anchor_pending = false; + } else if (state.layout_anchor_pending) { + const page = @min(state.layout_anchor_page, state.page_count -| 1); + state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) + + state.layout_anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page])); + state.layout_anchor_pending = false; + } + } + consumeOutlineReveal(state, viewport); + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + state.document_scroll_y = std.math.clamp(state.document_scroll_y, 0, max_scroll); + } + + pub const WordProbe = if (enabled) struct { + page: usize, + quads: []pdf.Quad, + text: []u8, + + pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void { + gpa.free(probe.quads); + gpa.free(probe.text); + probe.* = undefined; + } + } else void; + + pub const PointerDrag = if (enabled) struct { + native: bool = false, + /// Right-button Look probes this cell on release without borrowing or + /// replacing the pane's persistent MuPDF selection. + word_at: ?struct { col: u16, row: u16 } = null, + /// Drag updates keep selection geometry/text live, but their expensive + /// baked raster highlight is committed once on release. + selection_changed: bool = false, + } else struct {}; + + pub const PointerAction = enum { look, exec }; + + pub const PointerRelease = if (enabled) struct { + action: ?PointerAction = null, + text: []const u8 = &.{}, + probe: ?WordProbe = null, + + pub fn deinit(release: *@This(), gpa: std.mem.Allocator) void { + if (release.probe) |*probe| probe.deinit(gpa); + release.* = undefined; + } + } else struct { + pub fn deinit(_: *@This(), _: std.mem.Allocator) void {} + }; + + pub fn probeWord( + document: *Document, + gpa: std.mem.Allocator, + page: usize, + point: if (enabled) pdf.Point else void, + ) !?WordProbe { + if (comptime !enabled) return null; + var selection = try document.select(gpa, page, point, point); + errdefer selection.deinit(gpa); + const text = try document.copySelection(gpa, page, selection.start, selection.end); + errdefer gpa.free(text); + if (selection.quads.len == 0 or text.len == 0) { + selection.deinit(gpa); + gpa.free(text); + return null; + } + // Transfer the quad allocation out of Selection. There is intentionally + // no Selection.deinit on this success path: WordProbe is now its owner. + return .{ .page = page, .quads = selection.quads, .text = text }; + } + + pub fn pointAtGeometry( + geometry: image.NativeGeometry, + pixel_offset_y: f32, + full_width: usize, + full_height: usize, + band_y: usize, + pixel_x: i64, + pixel_y: i64, + clamp_to_page: bool, + ) ?Point { + if (comptime !enabled) return null; + if (full_width == 0 or full_height == 0 or + geometry.src.w == 0 or geometry.src.h == 0 or + geometry.dst.w == 0 or geometry.dst.h == 0) return null; + + const left: f64 = @floatFromInt(geometry.dst.x); + const top: f64 = @floatFromInt(geometry.dst.y); + const right = left + @as(f64, @floatFromInt(geometry.dst.w)); + const bottom = top + @as(f64, @floatFromInt(geometry.dst.h)); + var x: f64 = @floatFromInt(pixel_x); + var y: f64 = @as(f64, @floatFromInt(pixel_y)) - @as(f64, pixel_offset_y); + if (clamp_to_page) { + // Half-open rectangles: keep a clamped point infinitesimally inside + // the last presented pixel instead of letting it become `right`. + const epsilon = 1.0 / 1024.0; + x = std.math.clamp(x, left, @max(left, right - epsilon)); + y = std.math.clamp(y, top, @max(top, bottom - epsilon)); + } else if (x < left or x >= right or y < top or y >= bottom) { + return null; + } + + const source_x_f = @as(f64, @floatFromInt(geometry.src.x)) + + (x - left) * @as(f64, @floatFromInt(geometry.src.w)) / + @as(f64, @floatFromInt(geometry.dst.w)); + const source_y_f = @as(f64, @floatFromInt(geometry.src.y)) + + (y - top) * @as(f64, @floatFromInt(geometry.src.h)) / + @as(f64, @floatFromInt(geometry.dst.h)); + const source_x: usize = @min(full_width - 1, @as(usize, @intFromFloat(@floor(source_x_f)))); + const band_source_y: usize = @intFromFloat(@floor(source_y_f)); + const source_y = @min(full_height - 1, band_y + band_source_y); + return .{ + .x = (@as(f32, @floatFromInt(source_x)) + 0.5) / + @as(f32, @floatFromInt(full_width)), + .y = (@as(f32, @floatFromInt(source_y)) + 0.5) / + @as(f32, @floatFromInt(full_height)), + }; + } + + pub fn openPane( + core: *pardes.Pardes, + id: usize, + path: []const u8, + page_one_based: usize, + ) !*pardes.Pane { + if (comptime !enabled) return error.PdfDisabled; + var state = if (filesystem.localPath(path) != null) + try State.open(core.pdf_gpa, path, page_one_based) + else virtual: { + const bytes = try filesystem.read(core, path); + defer core.gpa.free(bytes); + break :virtual try State.openBytes(core.pdf_gpa, path, bytes, page_one_based); + }; + errdefer state.deinit(core.pdf_gpa); + const pane = try core.newDocPane(id); + pane.pdf = state; + pane.cur_pinned = true; + if (filesystem.localPath(path) != null) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); + return pane; + } + + /// Release a PDF payload while its pane slot is still installed, so the host + /// can retire the corresponding directory watch before that id is reused. + pub fn deinitPane(core: *pardes.Pardes, pane: *pardes.Pane, state: *State) void { + if (comptime !enabled) return; + for (core.panes, 0..) |slot, id| { + if (slot == pane) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + } + state.deinit(core.pdf_gpa); + } + + pub fn reloadPane( + core: *pardes.Pardes, + pane: *pardes.Pane, + ) !void { + if (comptime !enabled) return error.PdfDisabled; + const state = &(pane.pdf orelse return error.MissingPdfState); + try state.reload(core.pdf_gpa); + resetPageChrome(pane); + } + + pub fn isSectionsOutput(pane: *const pardes.Pane) bool { + if (comptime !enabled) return false; + const file = pane.file orelse return false; + const output = file.output orelse return false; + return switch (output.from) { + .cmd => |builtin| builtin == .PdfSections, + else => false, + }; + } + + fn refreshCleanSections(core: *pardes.Pardes, state: *State) void { + if (comptime !enabled) return; + const remembered = state.sections_output orelse return; + if (remembered.pane >= core.panes.len) return; + const result = core.panes[remembered.pane] orelse return; + if (result.serial != remembered.serial or !isSectionsOutput(result)) return; + const file = &result.file.?; + if (file.revision != remembered.revision) return; + + const content = state.renderSections(core.pdf_gpa, core.gpa) catch { + state.sections_output = null; + return; + }; + if (std.mem.eql(u8, file.content, content)) { + core.gpa.free(content); + return; + } + core.invalidateLookHover(remembered.pane); + File.setContent(core, file, content); + const rows = File.lineCount(file.content); + file.scroll = @min(file.scroll, rows - 1); + const row = @min(@as(usize, @intCast(@max(0, result.cur_row))), rows - 1); + result.cur_row = @intCast(row); + result.cur_col = @intCast(@min( + @as(usize, @intCast(@max(0, result.cur_col))), + modal.lineSlice(file.content, row).len, + )); + result.msel.active = false; + result.vsel.active = false; + result.nsel = 0; + state.sections_output.?.revision = file.revision; + } + + /// One successful watched-path transaction, including every piece of derived + /// PDF output. The caller owns generic update bookkeeping and status text. + pub fn reloadWatched( + core: *pardes.Pardes, + pane: *pardes.Pane, + ) !void { + try reloadPane(core, pane); + refreshCleanSections(core, &pane.pdf.?); + } + + fn rearmCleanSections( + core: *pardes.Pardes, + id: usize, + pane: *pardes.Pane, + state: *State, + ) bool { + const remembered = state.sections_output orelse return false; + if (remembered.pane >= core.panes.len) return false; + const result = core.panes[remembered.pane] orelse return false; + if (result.serial != remembered.serial or !isSectionsOutput(result)) return false; + const file = &result.file.?; + if (file.revision != remembered.revision) return false; + + file.scroll = 0; + result.cur_row = 0; + result.cur_col = 0; + result.msel.active = false; + result.vsel.active = false; + result.nsel = 0; + pane.search_pane = remembered.pane; + pane.search_row = null; + core.armLookWalk(remembered.pane); + core.active = id; + return true; + } + + /// Lazily materialise this pane's cached outline as a location list. All + /// PDF-specific ownership stays here; Pardes contributes only placement. + pub fn openSections(core: *pardes.Pardes, id: usize) void { + if (comptime !enabled) return; + const pane = core.panes[id] orelse return; + const state = &(pane.pdf orelse return); + if (rearmCleanSections(core, id, pane, state)) return; + const content = state.renderSections(core.pdf_gpa, core.gpa) catch return; + + const free = core.freeSlot() orelse { + core.gpa.free(content); + return; + }; + const dir = std.fs.path.dirname(state.path) orelse "/"; + const result = Output.open( + core, + free, + dir, + .{ .cmd = .PdfSections }, + "", + content, + ) catch { + core.gpa.free(content); + return; + }; + state.sections_output = .{ + .pane = free, + .serial = result.serial, + .revision = result.file.?.revision, + }; + core.placeDoc(id, free, result); + layout.compute(core); + core.active = id; + pane.search_pane = free; + pane.search_row = null; + core.armLookWalk(free); + } + + const SectionsOwner = struct { + id: usize, + pane: *pardes.Pane, + state: *State, + }; + + fn sectionsOwner(core: *pardes.Pardes, output_id: usize) ?SectionsOwner { + const output = core.panes[output_id] orelse return null; + if (!isSectionsOutput(output)) return null; + const file = output.file.?; + for (core.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const state = if (pane.pdf) |*pdf_state| pdf_state else continue; + const token = state.sections_output orelse continue; + if (token.pane == output_id and token.serial == output.serial and + token.revision == file.revision) + return .{ .id = id, .pane = pane, .state = state }; + } + return null; + } + + pub fn lookSection( + core: *pardes.Pardes, + output_id: usize, + path: []const u8, + at: look.Spot, + ) bool { + if (comptime !enabled) return false; + const output = core.panes[output_id] orelse return false; + if (!isSectionsOutput(output)) return false; + if (at.line == 0 or at.col == 0 or at.end_line != 0 or + !look.isPdfPath(path)) return false; + const owner = sectionsOwner(core, output_id) orelse return true; + + var joined_path: [4096]u8 = undefined; + const output_dir = std.fs.path.dirname(output.file.?.path) orelse "/"; + const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/"; + const target_path = if (std.fs.path.isAbsolute(path)) + path + else + std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true; + if (!std.mem.eql(u8, owner.state.path, target_path)) return true; + + const destination = owner.state.sectionDestination(core.pdf_gpa, at.col - 1) orelse return true; + switch (destination) { + .internal => |internal| revealOutlineDestination(core, owner.pane, internal), + .external => |uri| if (uri.len <= 256) core.emit(.{ .open_link = .from(uri) }), + .none => unreachable, + } + core.active = owner.id; + return true; + } + + pub fn resetPageChrome(pane: *pardes.Pane) void { + pane.cur_row = 0; + pane.cur_col = 0; + pane.vsel.active = false; + pane.msel.active = false; + pane.nsel = 0; + } + + pub fn activatePage( + core: *pardes.Pardes, + pane: *pardes.Pane, + page: usize, + reveal: bool, + ) void { + if (comptime !enabled) return; + const state = &(pane.pdf orelse return); + if (state.activatePage(core.pdf_gpa, page, reveal)) resetPageChrome(pane); + } + + pub fn revealOutlineDestination( + core: *pardes.Pardes, + pane: *pardes.Pane, + destination: OutlineInternalDestination, + ) void { + if (comptime !enabled) return; + activatePage(core, pane, destination.page, false); + const state = &pane.pdf.?; + state.queueOutlineReveal(destination); + // Consume immediately when geometry already exists; otherwise the PDF + // draw pass consumes the same value after the next layout transaction. + _ = ensurePaneLayout(core, pane); + } + + pub fn setPage(core: *pardes.Pardes, pane: *pardes.Pane, page: usize) void { + activatePage(core, pane, page, true); + } + + pub fn toggleFit(pane: *pardes.Pane) void { + if (comptime !enabled) return; + const state = &(pane.pdf orelse return); + state.toggleFit(); + } + + pub fn toggleTint(pane: *pardes.Pane) void { + if (comptime !enabled) return; + const state = &(pane.pdf orelse return); + state.toggleTint(); + } + + pub fn stepPanePage(core: *pardes.Pardes, pane: *pardes.Pane, delta: i64) void { + if (comptime !enabled) return; + const state = &(pane.pdf orelse return); + if (stepPage(state, core.pdf_gpa, delta)) resetPageChrome(pane); + } + + pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewport { + if (comptime !enabled) return null; + const rect = for (core.panes, 0..) |slot, id| { + if (slot == pane) break core.rects[id]; + } else return null; + const cols = rect.w -| config.GUTTER; + const rows = rect.h -| pardes.BOX_H; + if (cols == 0 or rows == 0) return null; + return .{ + .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w), + .pixel_h = @as(u32, rows) * @as(u32, core.cell_pixels.h), + }; + } + + pub fn ensurePaneLayout(core: *pardes.Pardes, pane: *pardes.Pane) ?Viewport { + if (comptime !enabled) return null; + const state = &(pane.pdf orelse return null); + const view = paneViewport(core, pane) orelse return null; + ensureLayout(state, view); + return view; + } + + pub fn tintColors(core: *const pardes.Pardes) TintColors { + if (comptime !enabled) return; + const theme = core.theme(); + return .{ + .background = theme.bg orelse theme.tag_bg, + .foreground = theme.fg orelse theme.tag_fg, + }; + } + + pub fn paneGeometry(core: *const pardes.Pardes, pane: *const pardes.Pane) ?image.NativeGeometry { + if (comptime !enabled) return null; + const state = if (pane.pdf) |*view| view else return null; + const view = paneViewport(core, pane) orelse return null; + return activeGeometry(state, view); + } + + pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row: u16) ?usize { + if (comptime !enabled) return null; + const state = pane.pdf orelse return null; + const rect = for (core.panes, 0..) |slot, id| { + if (slot == pane) break core.rects[id]; + } else return null; + const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null; + const local_y = @as(f64, @floatFromInt( + @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2, + )); + return pageAtViewportY(&state, local_y); + } + + pub fn paneNativeReady(core: *const pardes.Pardes, pane: *const pardes.Pane) bool { + if (comptime !enabled) return false; + if (!core.native_images) return false; + const state = if (pane.pdf) |*view| view else return false; + const view = paneViewport(core, pane) orelse return false; + return nativeReady(state, view); + } + + pub fn nativePageAtGridRow( + core: *const pardes.Pardes, + pane: *const pardes.Pane, + row: u16, + ) ?usize { + if (comptime !enabled) return null; + if (!core.native_images) return null; + const state = if (pane.pdf) |*view| view else return null; + const page = pageAtGridRow(core, pane, row) orelse return null; + const view = paneViewport(core, pane) orelse return null; + if (!pageReady(state, view, page)) return null; + return page; + } + + pub fn pointAt( + core: *const pardes.Pardes, + pane: *const pardes.Pane, + col: u16, + row: u16, + clamp_to_page: bool, + ) ?Point { + if (comptime !enabled) return null; + const state = pane.pdf orelse return null; + return panePointAtPage(core, pane, state.page, col, row, clamp_to_page); + } + + pub fn panePointAtPage( + core: *const pardes.Pardes, + pane: *const pardes.Pane, + page: usize, + col: u16, + row: u16, + clamp_to_page: bool, + ) ?Point { + if (comptime !enabled) return null; + const state = if (pane.pdf) |*view| view else return null; + const view = paneViewport(core, pane) orelse return null; + const rect = for (core.panes, 0..) |slot, id| { + if (slot == pane) break core.rects[id]; + } else return null; + const body_x = @as(i64, rect.x + config.GUTTER); + const body_y = @as(i64, if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H); + const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2; + const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2; + return pointAtPage(state, view, page, px, py, clamp_to_page); + } + + pub fn probeAt( + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + ) ?WordProbe { + if (comptime !enabled) return null; + const page = nativePageAtGridRow(core, pane, row) orelse return null; + const point = panePointAtPage(core, pane, page, col, row, false) orelse return null; + const state = &(pane.pdf orelse return null); + return probeWord(&state.document, core.pdf_gpa, page, point) catch null; + } + + pub fn beginDrag( + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + snap_word: bool, + ) bool { + if (comptime !enabled) return false; + const hit_page = pageAtGridRow(core, pane, row) orelse return false; + if (hit_page != pane.pdf.?.page) activatePage(core, pane, hit_page, false); + const state = &pane.pdf.?; + state.clearDrag(); + const point = pointAt(core, pane, col, row, false) orelse return false; + state.drag_anchor = point; + state.drag_head = point; + if (!snap_word) return true; + if (state.selection) |selection| if (selection.contains(point)) return true; + if (state.setSelection(core.pdf_gpa, point, point, true) == .failed) { + // Preserve the old selection transactionally, but never let an + // outside click execute its stale text. + state.clearDrag(); + return false; + } + return true; + } + + pub fn beginSelection( + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + ) bool { + return beginDrag(core, pane, col, row, true); + } + + pub fn updateSelection( + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + invalidate_raster: bool, + ) SelectionUpdate { + if (comptime !enabled) return .failed; + const state = &(pane.pdf orelse return .failed); + const anchor = state.drag_anchor orelse return .failed; + const point = pointAt(core, pane, col, row, true) orelse return .failed; + if (state.drag_head) |head| if (head.x == point.x and head.y == point.y) return .stationary; + const result = state.setSelection(core.pdf_gpa, anchor, point, invalidate_raster); + if (result != .failed) state.drag_head = point; + return result; + } + + pub fn pointerStart( + core: *pardes.Pardes, + pane: *pardes.Pane, + button: pardes.Mouse.Button, + col: u16, + row: u16, + on_tag: bool, + ) PointerDrag { + if (comptime !enabled) return .{}; + if (on_tag or !paneNativeReady(core, pane)) return .{}; + var drag: PointerDrag = .{ .native = true }; + if (button == config.look_button) { + drag.word_at = .{ .col = col, .row = row }; + } else if (button == config.select_button) { + _ = beginDrag(core, pane, col, row, false); + } else if (button == config.exec_button) { + _ = beginDrag(core, pane, col, row, true); + } + return drag; + } + + pub fn pointerUpdate( + drag: *PointerDrag, + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + ) void { + if (comptime !enabled) return; + if (!drag.native) return; + if (drag.word_at) |at| { + if (col == at.col and row == at.row) return; + if (!beginDrag(core, pane, at.col, at.row, false)) { + drag.word_at = null; + return; + } + switch (updateSelection(core, pane, col, row, false)) { + .failed => { + drag.word_at = null; + pane.pdf.?.clearDrag(); + }, + // Adjacent grid cells can still address one raster pixel. Keep + // click mode and retry farther out. + .stationary => {}, + .unchanged => drag.word_at = null, + .changed => { + drag.word_at = null; + drag.selection_changed = true; + }, + } + return; + } + switch (updateSelection(core, pane, col, row, false)) { + .failed => pane.pdf.?.clearDrag(), + .stationary, .unchanged => {}, + .changed => drag.selection_changed = true, + } + } + + pub fn pointerCancel(pane: *pardes.Pane, drag: PointerDrag) void { + if (comptime !enabled) return; + if (drag.native) if (pane.pdf) |*state| { + if (drag.selection_changed) state.invalidateRaster(state.page); + state.clearDrag(); + }; + } + + pub fn pointerRelease( + core: *pardes.Pardes, + pane: *pardes.Pane, + drag: PointerDrag, + button: pardes.Mouse.Button, + chorded: bool, + ) PointerRelease { + if (comptime !enabled) return .{}; + const state = &(pane.pdf orelse return .{}); + const slot = @intFromEnum(button); + const grid_selection = pane.sel[slot]; + pane.sel[slot].state = .none; // native quads, not projected text cells + + // Drag updates changed live geometry/text while leaving baked pixels + // stable. Commit exactly once before any chord/Exec/Look can clear state. + if (drag.selection_changed) state.invalidateRaster(state.page); + if (chorded) { + state.clearDrag(); + return .{}; + } + if (drag.word_at) |at| { + const maybe_probe = probeAt(core, pane, at.col, at.row); + state.clearDrag(); + const probe = maybe_probe orelse return .{}; + // A neighboring visible page becomes current before Look dispatch. + // WordProbe owns its text/quads independently of that state change. + if (probe.page != state.page) activatePage(core, pane, probe.page, false); + const text = probe.text; + return .{ .action = .look, .text = text, .probe = probe }; + } + if (button == config.select_button) { + const dragged = grid_selection.c0 != grid_selection.c1 or + grid_selection.r0 != grid_selection.r1; + if (!dragged) state.clearSelection(core.pdf_gpa); + pane.cur_pinned = true; + pane.mode = .normal; + pane.msel.active = false; + pane.vsel.active = false; + pane.normal.clear(); + pane.nsel = 0; + state.clearDrag(); + return .{}; + } + if (state.drag_anchor == null or state.selection_text.len == 0) { + state.clearDrag(); + return .{}; + } + const text = state.selection_text; + state.clearDrag(); + return .{ + .action = if (button == config.look_button) .look else .exec, + .text = text, + }; + } + + pub fn highlightInput( + core: *pardes.Pardes, + pane_id: usize, + pane: *const pardes.Pane, + ) HighlightInput { + if (comptime !enabled) return; + const hover_quads: []const Quad = if (core.pdf_hover_preview) |preview| + if (preview.pane == pane_id and preview.serial == pane.serial) + preview.probe.quads + else + &.{} + else + &.{}; + const hover_page = if (hover_quads.len > 0) core.pdf_hover_preview.?.probe.page else null; + const selection_color = core.theme().sel_bg; + return .{ + .hover_quads = hover_quads, + .hover_page = hover_page, + .hover_color = selection_color, + .selection_color = selection_color, + }; + } + + pub fn panPane( + core: *pardes.Pardes, + pane: *pardes.Pane, + axis: PanAxis, + direction: i8, + display_pixels: u32, + ) PanResult { + if (comptime !enabled) return .unavailable; + const placed = paneGeometry(core, pane) orelse return .unavailable; + const state = &(pane.pdf orelse return .unavailable); + return panPixels(state, placed, axis, direction, display_pixels); + } + + pub fn scrollPane(core: *pardes.Pardes, pane: *pardes.Pane, delta_pixels: f64) bool { + if (comptime !enabled) return false; + const tz = tracy.zone(@src(), "pdf.scroll_notch"); + defer tz.end(); + const state = &(pane.pdf orelse return false); + const view = ensurePaneLayout(core, pane) orelse return false; + const result = scrollDocument(state, view, delta_pixels) orelse return false; + if (result.active_page != state.page) activatePage(core, pane, result.active_page, false); + return true; + } + + pub fn verticalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void { + if (comptime !enabled) return; + if (!core.native_images) return stepPanePage(core, pane, direction); + const rows: u32 = @intCast(@max(1, config.wheel_rows)); + const pixels = scaledStep(core.cell_pixels.h, rows); + _ = scrollPane(core, pane, @as(f64, @floatFromInt(pixels)) * direction); + } + + pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void { + if (comptime !enabled) return; + const state = pane.pdf orelse return; + if (!core.native_images or state.fit != .height) return; + const cols: u32 = @intCast(@max(1, config.wheel_cols)); + _ = panPane(core, pane, .horizontal, direction, scaledStep(core.cell_pixels.w, cols)); + } + + pub fn draw( + core: *pardes.Pardes, + pane: *pardes.Pane, + rect: pardes.Rect, + pane_id: usize, + text_x: u16, + text_width: u16, + ) bool { + if (comptime !enabled) return false; + if (!core.native_images or rect.h <= pardes.BOX_H) return false; + const state = &(pane.pdf orelse return false); + const view = paneViewport(core, pane) orelse return false; + const key = TintKey{ .mode = state.tint, .colors = tintColors(core) }; + const visible = renderFrame( + state, + core.pdf_gpa, + core.scratch.allocator(), + view, + pardes.pdf_raster_policy, + key, + highlightInput(core, pane_id, pane), + ); + var placed_any = false; + var page = visible.first; + const visible_end = visible.first + visible.len; + while (page < visible_end) : (page += 1) { + const placed = placedRaster(state, view, page) orelse continue; + if (!core.appendImagePlace(.{ + .pane = @intCast(pane_id), + .serial = pane.serial, + .native = .{ + .revision = placed.revision, + .page = @intCast(placed.page), + .fit = switch (placed.fit) { + .width => .width, + .height => .height, + }, + .pan_x = placed.pan_x, + .geometry = placed.geometry, + .pixel_offset_y = placed.pixel_offset_y, + }, + .x = text_x, + .y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, + .w = text_width, + .h = rect.h - pardes.BOX_H, + .rgba = placed.rgba, + .iw = placed.width, + // The texture contains the retained band, not the full page. + .ih = placed.band_height, + })) break; + placed_any = true; + } + if (!placed_any) return false; + + // This frame has spent the motion used to choose its raster band. + state.scroll_travel = 0; + const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const chrome = core.chromeTheme(); + const theme = core.theme(); + const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; + core.surface.fill(rect.x, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); + core.surface.fill(rect.x + 1, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg }); + + const track_h: usize = rect.h - pardes.BOX_H; + const total = @max(@as(u64, 1), state.document_height); + const len = @max( + @as(usize, 1), + @as(usize, @intCast(@min( + @as(u64, track_h), + @as(u64, track_h) * view.pixel_h / total, + ))), + ); + const offset: u64 = @intFromFloat(@floor(state.document_scroll_y)); + const pos: usize = @intCast(@min( + @as(u64, track_h -| 1), + @as(u64, track_h) * offset / total, + )); + var y = pos; + while (y < track_h and y < pos + len) : (y += 1) + core.surface.fill( + rect.x, + body_y + @as(u16, @intCast(y)), + 1, + 1, + .{ .bg = .{ .rgb = chrome.scroll_thumb } }, + ); + return true; + } + + pub const SectionRows = if (enabled) struct { + pub fn usableDestination(destination: pdf.OutlineDestination) ?pdf.OutlineDestination { + return switch (destination) { + .internal => destination, + .external => |uri| if (safeHttpUri(uri)) destination else null, + .none => null, + }; + } + + fn safeHttpUri(uri: []const u8) bool { + // Effect.open_link is inline and cannot carry a larger URL. Omitting + // it here is preferable to rendering a row which can never act. + if (uri.len > 256) return false; + var has_scheme = false; + for (config.url_schemes) |scheme| { + if (std.mem.startsWith(u8, uri, scheme)) { + has_scheme = true; + break; + } + } + if (!has_scheme) return false; + for (uri) |byte| if (byte <= 0x20 or byte == 0x7f) return false; + return true; + } + + pub fn resolve(entries: []const pdf.OutlineEntry, ordinal: usize) ?pdf.OutlineDestination { + if (ordinal >= entries.len) return null; + const entry = entries[ordinal]; + if (entry.destination != .none) return usableDestination(entry.destination); + var i = ordinal + 1; + while (i < entries.len and entries[i].depth > entry.depth) : (i += 1) + if (usableDestination(entries[i].destination)) |destination| return destination; + return null; + } + + pub fn resolveOrdinals( + gpa: std.mem.Allocator, + entries: []const pdf.OutlineEntry, + ) ![]usize { + const unresolved = std.math.maxInt(usize); + const ordinals = try gpa.alloc(usize, entries.len); + @memset(ordinals, unresolved); + + const Pending = struct { depth: u8, ordinal: usize }; + var pending: [256]Pending = undefined; + var pending_len: usize = 0; + for (entries, 0..) |entry, ordinal| { + while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth) + pending_len -= 1; + + if (usableDestination(entry.destination) != null) { + ordinals[ordinal] = ordinal; + for (pending[0..pending_len]) |ancestor| + ordinals[ancestor.ordinal] = ordinal; + pending_len = 0; + } else if (entry.destination == .none) { + pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal }; + pending_len += 1; + } + } + return ordinals; + } + + fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize { + const raw = title orelse { + if (out) |buf| @memcpy(buf[0..10], "[untitled]"); + return 10; + }; + var at: usize = 0; + var i: usize = 0; + var wrote = false; + var pending_space = false; + while (i < raw.len) { + const n: usize = std.unicode.utf8ByteSequenceLength(raw[i]) catch { + pending_space = wrote; + i += 1; + continue; + }; + if (i + n > raw.len) { + pending_space = wrote; + break; + } + const cp = std.unicode.utf8Decode(raw[i .. i + n]) catch { + pending_space = wrote; + i += n; + continue; + }; + const whitespace_or_control = cp <= 0x20 or cp == 0x7f or + (cp >= 0x80 and cp <= 0x9f) or cp == 0x2028 or cp == 0x2029; + if (whitespace_or_control) { + pending_space = wrote; + } else { + if (pending_space) { + if (out) |buf| buf[at] = ' '; + at += 1; + } + if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]); + at += n; + wrote = true; + pending_space = false; + } + i += n; + } + if (!wrote) { + if (out) |buf| @memcpy(buf[0..13], "[empty title]"); + return 13; + } + return at; + } + + pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf.OutlineEntry) ![]u8 { + const target = std.fs.path.basename(path); + const ordinals = try resolveOrdinals(gpa, entries); + defer gpa.free(ordinals); + var total: usize = 0; + for (entries, 0..) |entry, ordinal| { + const resolved = ordinals[ordinal]; + if (resolved == std.math.maxInt(usize)) continue; + const destination = usableDestination(entries[resolved].destination) orelse unreachable; + total += switch (destination) { + .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), + .external => |uri| std.fmt.count("{s} ", .{uri}), + .none => unreachable, + }; + total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1; + } + const out = try gpa.alloc(u8, total); + errdefer gpa.free(out); + var at: usize = 0; + for (entries, 0..) |entry, ordinal| { + const resolved = ordinals[ordinal]; + if (resolved == std.math.maxInt(usize)) continue; + const destination = usableDestination(entries[resolved].destination) orelse unreachable; + const prefix = switch (destination) { + .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), + .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}), + .none => unreachable, + }; + at += prefix.len; + const indent = @as(usize, entry.depth) * 2; + @memset(out[at..][0..indent], ' '); + at += indent; + at += cleanTitle(out[at..], entry.title); + out[at] = '\n'; + at += 1; + } + return out; + } + } else struct {}; +}; + +pub const Terminal = struct { + pub const enabled = pardes.terminal_panes; + const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {}; + + pub const history_max = if (enabled) 64 else 8; + + pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; + pub const StreamSlot = if (enabled) ghostty_vt.TerminalStream else void; + + pub const Replay = struct { + bytes: [1024 * 1024]u8 = undefined, + head: usize = 0, + len: usize = 0, + }; + + pub const State = struct { + vt: VtSlot, + stream: StreamSlot, + replay: Replay, + reply: [256]u8 = undefined, + reply_len: u16 = 0, + }; + + const GColor = ghostty_vt.color; + + const FilterPaletteKey = struct { + bg: GColor.RGB, + fg: GColor.RGB, + base: [16]GColor.RGB, + }; + + pub const FilterPalette = if (enabled) LivePalette else struct {}; + + const LivePalette = struct { + key: ?FilterPaletteKey = null, + colors: GColor.Palette = GColor.default, + + fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette { + const bg = asGhostRgb(theme.bg orelse theme.tag_bg); + const fg = asGhostRgb(theme.fg orelse theme.tag_fg); + var base: [16]GColor.RGB = undefined; + if (theme.palette) |palette| { + for (&base, palette) |*dst, src| dst.* = asGhostRgb(src); + } else { + const synthesized = [16][3]u8{ + theme.bg orelse theme.tag_bg, + theme.kw, + theme.str, + theme.num, + theme.box, + theme.sel_bg, + theme.comment, + theme.fg orelse theme.tag_fg, + theme.lineno, + theme.kw, + theme.str, + theme.num, + theme.scroll_thumb, + theme.sel_fg, + theme.tag_fg, + theme.fg orelse theme.tag_fg, + }; + for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src); + } + + const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base }; + if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors; + + var seed = GColor.default; + for (base, 0..) |rgb, i| seed[i] = rgb; + self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false); + self.key = key; + return &self.colors; + } + }; + + fn asGhostRgb(rgb: [3]u8) GColor.RGB { + return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] }; + } + + fn asPardesColor(rgb: GColor.RGB) pardes.Color { + return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; + } + + /// The owned text standing in for `rows` live terminal rows, beginning at + /// absolute surface row `row`. The emulator grid remains untouched underneath. + pub const EditBuffer = struct { + row: i32 = 0, + rows: i32 = 1, + text: []u8 = &.{}, + }; + + /// One whole-state terminal edit boundary. Null means the pane has not yet + /// materialized an edit buffer; non-null snapshots own their text. + pub const Snapshot = struct { + ovl: ?EditBuffer, + cur_row: i32, + cur_col: i32, + vsel: Pane.CharSel, + }; + + pub const PendingCommand = struct { + bytes: []u8 = &.{}, + wait: enum { none, spawn, input } = .none, + }; + + pub fn armShellSpawn(pane: *Pane) void { + if (comptime !enabled) return; + std.debug.assert(pane.pending_command.bytes.len == 0); + pane.pending_command.wait = .spawn; + } + + pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { + if (pane.pending_command.wait == .none) return false; + const old_len = pane.pending_command.bytes.len; + const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); + const bytes = if (old_len == 0) + try pane.gpa.alloc(u8, new_len) + else + try pane.gpa.realloc(pane.pending_command.bytes, new_len); + @memcpy(bytes[old_len..][0..command.len], command); + bytes[new_len - 1] = '\r'; + pane.pending_command.bytes = bytes; + pane.greet = false; + return true; + } + + pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { + const pane = p.panes[id] orelse return; + if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; + if (prompt_marks) { + pane.pending_command.wait = .input; + releasePendingCommand(p, id, pane, false); + } else { + pane.greet = false; + releasePendingCommand(p, id, pane, true); + } + } + + pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { + if (pane.pending_command.wait == .input) + releasePendingCommand(p, id, pane, promptInputReady(pane)); + } + + fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { + if (!ready) return; + const bytes = pane.pending_command.bytes; + pane.pending_command = .{}; + if (bytes.len > 0) { + p.emitWrite(id, bytes); + pane.gpa.free(bytes); + } + } + + pub fn deinitPendingCommand(pane: *Pane) void { + if (pane.pending_command.bytes.len > 0) + pane.gpa.free(pane.pending_command.bytes); + pane.pending_command = .{}; + } + + pub fn terminalIo() std.Io { + return if (comptime !pardes.hosted) + std.Io.failing + else + std.Io.Threaded.global_single_threaded.io(); + } + + /// The three numbers ghostty's scrollbar reports; all zero without an emulator. + pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 }; + + pub fn scrollbar(pane: *const Pane) Scrollbar { + if (comptime !enabled) return .{}; + const state = pane.terminal orelse return .{}; + const sb = state.vt.screens.active.pages.scrollbar(); + return .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; + } + + /// The emulator's viewport offset, in SHELL rows: the top of what it shows. + pub fn gridOffset(pane: *const Pane) i32 { + return @intCast(scrollbar(pane).offset); + } + + /// Where the emulator itself puts the cursor, in active-area cells — the origin + /// without one, which is where an empty pane's cursor belongs anyway. + pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 }; + + pub fn gridCursor(pane: *const Pane) GridCursor { + if (comptime !enabled) return .{}; + const state = pane.terminal orelse return .{}; + const cur = state.vt.screens.active.cursor; + return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; + } + + pub fn visibleCursor(pane: *const Pane) ?GridCursor { + if (comptime !enabled) return null; + const state = pane.terminal orelse return null; + if (!state.vt.modes.get(.cursor_visible)) return null; + const cur = state.vt.screens.active.cursor; + const sb = scrollbar(pane); + const row = sb.total - sb.len + cur.y; + if (row < sb.offset or row - sb.offset >= sb.len) return null; + return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) }; + } + + /// Move the emulator's viewport by `delta` shell rows (negative scrolls back). + pub fn scrollGrid(pane: *Pane, delta: i32) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.screens.active.scroll(.{ .delta_row = delta }); + } + + /// Snap the viewport back onto live output. + pub fn followOutput(pane: *Pane) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.screens.active.scroll(.active); + } + + /// Reflow the grid. A failed reflow keeps the grid it had rather than dropping + /// a scrollback; the next resize retries with the same numbers. + pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {}; + } + + /// DECSET 2004: the program wants its pastes bracketed. + pub fn bracketedPaste(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.bracketed_paste); + } + + /// The program tracks the mouse itself, so a click in its body is its event. + pub fn reportsMouse(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + const m = &state.vt.modes; + return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); + } + + /// ...and wants them in SGR (1006) rather than the legacy X10 bytes. + pub fn mouseFormatSgr(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.mouse_format_sgr); + } + + /// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out. + pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 { + if (comptime !enabled) return &.{}; + const state = pane.terminal orelse return &.{}; + return state.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} }); + } + + /// Release the emulator's heap. The Pane allocation itself is the core's. + pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.stream.deinit(); + state.vt.deinit(gpa); + gpa.destroy(state); + pane.terminal = null; + } + + /// Allocate the live emulator half of a terminal pane. Slot ownership, serial + /// assignment, and spawn effects remain core lifecycle invariants. + pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { + const pane = try createDoc(gpa, cols, rows); + errdefer gpa.destroy(pane); + if (comptime !enabled) return pane; + const state = try gpa.create(State); + errdefer gpa.destroy(state); + state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ + .cols = cols, + .rows = rows, + .max_scrollback = 16 * 1024 * 1024, + }); + state.stream = state.vt.vtStream(); + state.replay.head = 0; + state.replay.len = 0; + state.reply_len = 0; + state.stream.handler.effects.write_pty = ptyReport; + state.stream.handler.effects.device_attributes = ptyDeviceAttrs; + pane.terminal = state; + pane.tty_filter = true; + return pane; + } + + pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { + const pane = try gpa.create(Pane); + pane.* = .{ + .gpa = gpa, + .cols = cols, + .rows = rows, + }; + return pane; + } + + /// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy + /// stay with the core; raw VT replay and viewport restoration belong here. + pub fn restore(p: *Pardes, src: dump.Pane) !*Pane { + const terminal = src.terminal.?; + if (comptime !enabled) { + const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); + errdefer p.gpa.destroy(pane); + if (terminal.stream.len > 0) + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; + return pane; + } + const bytes = if (terminal.stream_b64.len > 0) + try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) + else + &.{}; + const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); + if (bytes.len > 0) { + ingest(pane, bytes); + followOutput(pane); + if (src.scroll > 0) + scrollGrid(pane, -@as(i32, @intCast(src.scroll))); + } + return pane; + } + + /// Feed the emulator and retain the bounded suffix a dump can replay. Live + /// output and restoration share this byte path, then apply different views. + fn ingest(pane: *Pane, bytes: []const u8) void { + const state = pane.terminal.?; + const replay = &state.replay; + if (bytes.len >= replay.bytes.len) { + const kept = bytes[bytes.len - replay.bytes.len ..]; + @memcpy(&replay.bytes, kept); + replay.head = 0; + replay.len = replay.bytes.len; + } else { + const overflow = bytes.len -| (replay.bytes.len - replay.len); + replay.head = (replay.head + overflow) % replay.bytes.len; + replay.len -= overflow; + const tail = (replay.head + replay.len) % replay.bytes.len; + const first = @min(bytes.len, replay.bytes.len - tail); + @memcpy(replay.bytes[tail..][0..first], bytes[0..first]); + @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]); + replay.len += bytes.len; + } + state.stream.nextSlice(bytes); + } + + /// Return the replay ring in chronological order. Wrapped records are copied + /// into `allocator`; contiguous records remain a borrowed slice of the pane. + fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { + const state = pane.terminal orelse return &.{}; + const replay = &state.replay; + if (replay.len == 0) return &.{}; + if (replay.head + replay.len <= replay.bytes.len) + return replay.bytes[replay.head..][0..replay.len]; + const out = try allocator.alloc(u8, replay.len); + const first = replay.bytes.len - replay.head; + @memcpy(out[0..first], replay.bytes[replay.head..]); + @memcpy(out[first..], replay.bytes[0 .. replay.len - first]); + return out; + } + + test "replay ownership is terminal-only and construction rolls back on allocation failure" { + const Case = struct { + fn run(gpa: std.mem.Allocator, terminal: bool) !void { + const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12); + defer gpa.destroy(pane); + defer deinitEmulator(pane, gpa); + try std.testing.expectEqual(terminal and enabled, pane.terminal != null); + } + }; + try std.testing.expect(@sizeOf(Pane) < 128 * 1024); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); + } + + test "document construction allocates only the pane and has inert terminal defaults" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 }); + const allocator = failing.allocator(); + const pane = try createDoc(allocator, 40, 12); + defer allocator.destroy(pane); + defer deinitEmulator(pane, allocator); + try std.testing.expect(pane.terminal == null); + try std.testing.expectEqual(@as(usize, 1), failing.alloc_index); + try std.testing.expectEqual(@as(usize, 0), failing.resize_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes); + try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); + try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); + try std.testing.expect(!bracketedPaste(pane)); + try std.testing.expect(!reportsMouse(pane)); + try std.testing.expect(!mouseFormatSgr(pane)); + try std.testing.expect(!promptInputReady(pane)); + scrollGrid(pane, 100); + followOutput(pane); + resizeGrid(pane, allocator, 80, 24); + try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); + try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); + try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator)); + try std.testing.expectEqualStrings("", try replayBytes(pane, allocator)); + try std.testing.expect(!failing.has_induced_failure); + } + + test "terminal state keeps parser fragments and sends emulator replies through its owner" { + if (comptime !enabled) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + const state = pane.terminal.?; + try std.testing.expect(state.stream.handler.terminal == &state.vt); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } }); + while (p.nextEffect()) |effect| { + if (effect == .write) return error.PrematureTerminalReply; + } + p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } }); + var replies: usize = 0; + while (p.nextEffect()) |effect| { + if (effect != .write) continue; + try std.testing.expectEqual(@as(u8, 0), effect.write.pane); + try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice()); + replies += 1; + } + try std.testing.expectEqual(@as(usize, 1), replies); + try std.testing.expectEqual(@as(u16, 0), state.reply_len); + try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator)); + } + + test "replay keeps a bounded chronological suffix across large writes and wrapping" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const pane = try create(gpa, 40, 12); + defer gpa.destroy(pane); + defer deinitEmulator(pane, gpa); + try std.testing.expectEqualStrings("", try replayBytes(pane, gpa)); + ingest(pane, "hello"); + const small = try replayBytes(pane, gpa); + try std.testing.expectEqualStrings("hello", small); + try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr)); + + const capacity = pane.terminal.?.replay.bytes.len; + const input = try gpa.alloc(u8, capacity + 7); + defer gpa.free(input); + @memset(input, 0); + const ending = "\x1b[31mred\x1b[0m\r\n"; + @memcpy(input[input.len - ending.len ..], ending); + ingest(pane, input); + try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa)); + ingest(pane, "next\r\n"); + const wrapped = try replayBytes(pane, gpa); + defer gpa.free(wrapped); + try std.testing.expectEqual(capacity, wrapped.len); + try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]); + try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]); + } + + test "replay restores terminal cells from the retained stream" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n"); + const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa)); + defer gpa.free(encoded); + const restored = try restore(p, .{ + .kind = .terminal, + .tag = "", + .body = "", + .cols = pane.cols, + .rows = pane.rows, + .terminal = .{ .stream_b64 = encoded }, + }); + defer gpa.destroy(restored); + defer deinitEmulator(restored, gpa); + const before = try screenTextAlloc(pane, gpa); + defer gpa.free(before); + const after = try screenTextAlloc(restored, gpa); + defer gpa.free(after); + try std.testing.expectEqualStrings(before, after); + try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa)); + } + + /// Record and parse one live pty read, invalidate its motion surface, and + /// follow it only when the body (possibly parked under a tag edit) is raw. + pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { + // There are no pty reads at all without an emulator to parse them into. + if (comptime !enabled) return; + if (pane.terminal == null) return; + const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or + pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or + pane.ed_undo_len > 0 or pane.ed_redo_len > 0; + if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); + p.shell_rows.markStale(pane); + const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; + if (body_mode == .tty) followOutput(pane); + } + + const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 }; + const PositionPin = struct { + target: *i32, + row: RowPin, + overlay: ?*EditBuffer, + edit_row: ?i32 = null, + }; + + fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin { + if (row < 0) return .{ .offset = row }; + const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1); + var left = pages.total_rows - 1 - bounded; + var node = pages.pages.last.?; + while (left >= node.rows()) { + left -= node.rows(); + node = node.prev.?; + } + const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch { + failed.* = true; + return .{}; + }; + return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) }; + } + + fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 { + const pin = row.pin orelse return row.offset; + const alive = pages orelse return 0; + if (pin.garbage) return 0; + var after: usize = 0; + var node = alive.pages.last; + while (node) |item| : (node = item.prev) { + if (item == pin.node) + return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset; + after += item.rows(); + } + return 0; + } + + fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin { + var grid = target.*; + if (overlay) |edit| { + const lines: i32 = @intCast(modal.lineCount(edit.text)); + if (grid >= edit.row and grid < edit.row +| lines) + return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row }; + if (grid > edit.row) grid = grid -| lines +| edit.rows; + } + return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay }; + } + + fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { + const screens = &pane.terminal.?.vt.screens; + const key = screens.active_key; + const generation = screens.generation(key); + const pages = &screens.active.pages; + const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *Pane.CharSel }; + var groups: [history_max * 2 + 1]Group = undefined; + groups[0] = .{ + .overlay = if (pane.ovl) |*overlay| overlay else null, + .cursor = &pane.cur_row, + .selection = &pane.vsel, + }; + var ngroups: usize = 1; + for ([_][]Snapshot{ pane.ed_undo[0..pane.ed_undo_len], pane.ed_redo[0..pane.ed_redo_len] }) |history| { + for (history) |*snapshot| { + groups[ngroups] = .{ + .overlay = if (snapshot.ovl) |*overlay| overlay else null, + .cursor = &snapshot.cur_row, + .selection = &snapshot.vsel, + }; + ngroups += 1; + } + } + const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin }; + var spans: [groups.len]Span = undefined; + var nspans: usize = 0; + var positions: [groups.len * 2 + Pane.max_selections * 2 + 4]PositionPin = undefined; + var npositions: usize = 0; + var failed = false; + for (groups[0..ngroups]) |group| { + if (group.overlay) |overlay| { + spans[nspans] = .{ + .overlay = overlay, + .start = trackRow(pages, overlay.row, &failed), + .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed), + }; + nspans += 1; + } + positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed); + npositions += 1; + if (group.selection.active) { + positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed); + npositions += 1; + } + } + const overlay = groups[0].overlay; + var extra: [Pane.max_selections * 2 + 4]*i32 = undefined; + var nextra: usize = 0; + if (pane.msel.active) { + extra[nextra] = &pane.msel.r0; + extra[nextra + 1] = &pane.msel.r1; + nextra += 2; + } + for (pane.sels[0..pane.nsel]) |*selection| { + extra[nextra] = &selection.row; + extra[nextra + 1] = &selection.arow; + nextra += 2; + } + if (pane.append_at) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } + if (pane.look_at) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } + for (extra[0..nextra]) |target| { + positions[npositions] = trackPosition(pages, target, overlay, &failed); + npositions += 1; + } + ingest(pane, bytes); + // RIS can destroy the alternate screen and every pin it owned. + const alive = if (screens.generation(key) == generation) pages else null; + var moved = false; + for (spans[0..nspans]) |span| { + const start = pinnedRow(alive, span.start); + const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1); + moved = moved or start != span.overlay.row or rows != span.overlay.rows; + span.overlay.row = start; + span.overlay.rows = rows; + if (alive) |owner| { + if (span.start.pin) |pin| owner.untrackPin(pin); + if (span.end.pin) |pin| owner.untrackPin(pin); + } + } + for (positions[0..npositions]) |position| { + var row = pinnedRow(alive, position.row); + if (position.overlay) |edit| { + if (position.edit_row) |relative| { + row = edit.row +| relative; + } else if (row > edit.row) { + row = if (row < edit.row +| edit.rows) + edit.row + else + row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text))); + } + } + moved = moved or row != position.target.*; + position.target.* = row; + if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin); + } + if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text. + if (failed) { + const message = "terminal edit position reset: out of memory"; + @memcpy(pane.msg[0..message.len], message); + pane.msg_len = message.len; + } + } + + test "raw terminal output needs no position tracking allocations" { + if (comptime !enabled) return error.SkipZigTest; + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = p.panes[0].?; + pane.mode = .tty; + feedOutput(p, pane, "warm"); + const allocations = failing.alloc_index; + failing.fail_index = allocations; + const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins(); + feedOutput(p, pane, " output"); + try std.testing.expectEqual(allocations, failing.alloc_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins()); + } + + pub fn promptInputReady(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.screens.active_key != .alternate and + state.vt.screens.active.cursor.semantic_content == .input; + } + + /// Encode one key for the program that owns a raw terminal and queue its pty + /// write. Global chords and mode routing have already been handled by core. + pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { + var control: [1]u8 = undefined; + const bytes: ?[]const u8 = blk: { + if (key.ctrl) { + if (key.cp >= 'a' and key.cp <= 'z') { + control[0] = @intCast(key.cp - 0x60); + break :blk control[0..1]; + } + // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. + if (key.cp >= '@' and key.cp <= '_') { + control[0] = @intCast(key.cp - 0x40); + break :blk control[0..1]; + } + } + if (key.text.len > 0) break :blk key.text; + break :blk switch (key.cp) { + Key.enter => "\r", + Key.backspace => "\x7f", + Key.tab => "\t", + Key.escape => "\x1b", + Key.up => "\x1b[A", + Key.down => "\x1b[B", + Key.right => "\x1b[C", + Key.left => "\x1b[D", + Key.delete => "\x1b[3~", + else => null, + }; + }; + if (bytes) |encoded| + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); + } + + pub fn enterTty(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + if (comptime enabled) if (pane.terminal != null and pane.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: { + const screen = pane.terminal.?.vt.screens.active; + const goff: i32 = @intCast(screen.pages.scrollbar().offset); + const vp_row = pane.gridRow(pane.cur_row) - goff; + if (vp_row < 0) break :handoff; + + var grid_col: i32 = @max(0, pane.cur_col); + if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| { + if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) { + .cut => |cols| grid_col += @intCast(cols), + .keep, .blank => {}, + }; + } + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) }, + }) orelse break :handoff; + const cursor_pin = screen.cursor.page_pin.*; + var prompts = cursor_pin.promptIterator(.left_up, null); + const prompt_pin = prompts.next() orelse break :handoff; + if (click_pin.before(prompt_pin)) break :handoff; + const moves = screen.promptClickMove(click_pin); + for (0..moves.left) |_| p.emitWrite(id, "\x1b[D"); + for (0..moves.right) |_| p.emitWrite(id, "\x1b[C"); + }; + + pane.mode = .tty; + pane.msel.active = false; + pane.vsel.active = false; + pane.nsel = 0; + // A pinned row scrolls away. Raw mode must follow the program's live + // cursor, and Last must not restore a stale modal spot on the way back. + pane.cur_pinned = false; + pane.select = false; + pane.append_at = null; + pane.sticky_col = -1; + pane.normal.clear(); + } + + // Returned slices remain valid until the next cache rebuild or reset. + pub const RowsCache = struct { + /// whose grid this describes; null = the slot is free + pane: ?*const Pane = null, + /// the rows joined by '\n' — `flatSurface` hands this back verbatim + /// instead of rebuilding the join on every keystroke + text: []const u8 = &.{}, + /// slices INTO `text`, absolute grid rows from 0 + rows: [][]const u8 = &.{}, + /// `text` is a prefix of this: blanking a prompt row shortens the join, + /// and the slack is not worth a second allocation to reclaim + text_alloc: []u8 = &.{}, + stale: bool = false, + + pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.text_alloc.len > 0) gpa.free(c.text_alloc); + if (c.rows.len > 0) gpa.free(c.rows); + c.* = .{}; + } + + /// Free a stale entry. Called at the top of `update`, and nowhere else. + pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.stale) c.reset(gpa); + } + + /// `pane`'s grid moved: the entry no longer describes it. + pub fn markStale(c: *RowsCache, pane: *const Pane) void { + if (c.pane == pane) c.stale = true; + } + + pub fn dropPane(c: *RowsCache, pane: *const Pane) void { + if (c.pane != pane) return; + c.pane = null; + c.stale = true; + } + }; + + const Rows = struct { + text_alloc: []u8, + text: []const u8, + rows: [][]const u8, + }; + + const empty_grid = [1][]const u8{""}; + + /// What LEAVING raw tty mode does to one prompt row, decided from its cells + /// alone. See config.tty_blank for why any of this happens. + const PromptCut = union(enum) { + /// show the row exactly as ghostty dumped it + keep, + /// show nothing at all + blank, + /// drop this many leading COLUMNS — the prompt — and keep the rest, which + /// is what was typed at it + cut: usize, + }; + + fn promptCut(pin: ghostty_vt.Pin) PromptCut { + if (config.tty_blank == .prompt_and_input) return .blank; + const cells = pin.cells(.all); + var cols: usize = 0; + while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1; + // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or + // a repaint that has moved on. Nothing here is the prompt, so hide nothing. + if (cols == 0) return .keep; + // ...and all prompt, nothing typed yet: the row is chrome end to end. + if (cols >= cells.len) return .blank; + return .{ .cut = cols }; + } + + fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { + const cols = switch (promptCut(pin)) { + .keep => return raw, + .blank => return "", + .cut => |n| n, + }; + const cells = pin.cells(.all); + var at: usize = 0; + var col: usize = 0; + while (col < cols and at < raw.len) { + const cell = &cells[col]; + at = @min(raw.len, at + dumpedBytes(pin, cell)); + // the tail cell of a wide glyph spells nothing of its own + col += if (cell.wide == .wide) @as(usize, 2) else 1; + } + return std.mem.trimEnd(u8, raw[at..], " \t"); + } + + fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize { + switch (cell.wide) { + .spacer_head, .spacer_tail => return 0, + .narrow, .wide => {}, + } + var n: usize = switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength( + cell.codepoint(), + ) catch 1, + // A cell carrying only a colour still spells one blank in the dump. + else => 1, + }; + if (cell.content_tag == .codepoint_grapheme) { + if (pin.grapheme(cell)) |extra| for (extra) |cp| { + n += std.unicode.utf8CodepointSequenceLength(cp) catch 1; + }; + } + return n; + } + + pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { + if (comptime !enabled) return &empty_grid; + if (pane.terminal == null) return &empty_grid; + const c = &p.shell_rows; + if (!c.stale and c.pane == pane) return c.rows; + if (c.pane != null) { + c.stale = true; + return (try buildRows(p.scratch.allocator(), p, pane)).rows; + } + const built = try buildRows(p.gpa, p, pane); + c.* = .{ + .pane = pane, + .text = built.text, + .rows = built.rows, + .text_alloc = built.text_alloc, + }; + return c.rows; + } + + /// The full modal motion surface: shell history with the live edit overlay + /// spliced into the rows it covers. + pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { + const rows = try shellRows(p, pane); + if (pane.ovl == null) return rows; + var last = rows.len; + if (pane.ovl) |overlay| + last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows)))); + var count = last; + if (pane.ovl) |overlay| { + if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last) + count = count - @min( + @as(usize, @intCast(overlay.rows)), + last - @as(usize, @intCast(overlay.row)), + ) + @max(1, modal.lineCount(overlay.text)); + } + const lines = try p.scratch.allocator().alloc([]const u8, count); + var n: usize = 0; + var grid_row: usize = 0; + while (grid_row < last) : (grid_row += 1) { + if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) { + var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n'); + while (overlay_lines.next()) |line| : (n += 1) lines[n] = line; + grid_row += @intCast(overlay.rows - 1); + continue; + }; + lines[n] = if (grid_row < rows.len) rows[grid_row] else ""; + n += 1; + } + return lines[0..n]; + } + + /// Flatten `cursorLines` without rebuilding the common cached/no-overlay + /// case. Scratch-owned when a join is required. + pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 { + const cache = &p.shell_rows; + if (!cache.stale and cache.pane == pane and + lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text; + var total: usize = if (lines.len > 0) lines.len - 1 else 0; + for (lines) |line| total += line.len; + const text = try p.scratch.allocator().alloc(u8, total); + var at: usize = 0; + for (lines, 0..) |line, i| { + if (i > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..line.len], line); + at += line.len; + } + return text; + } + + /// Materialize or extend the terminal edit overlay with one exact allocation. + /// Row slices are scratch-owned/borrowed; only the joined text is installed. + pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { + const want_lo = @max(0, @min(lo, hi)); + const want_hi = @max(want_lo, @max(lo, hi)); + const fresh = pane.ovl == null; + const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; + const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); + const up = old.row - want_lo; + const down = want_hi - (old.row + lines - 1); + const extending = fresh or up > 0 or down > 0; + var row0 = old.row; + var covered = old.rows; + var text = old.text; + var owned = false; + if (extending) { + const rows = shellRows(p, pane) catch return null; + row0 = old.row - @max(0, up); + covered = old.rows + @max(0, up) + @max(0, down); + const up_len: usize = @intCast(@max(0, up)); + const down_len: usize = @intCast(@max(0, down)); + const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null; + for (parts[0..up_len], 0..) |*part, i| { + const src = @as(usize, @intCast(row0)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + const middle: usize = @intCast(old.row); + parts[up_len] = if (fresh) + (if (middle < rows.len) rows[middle] else "") + else + old.text; + for (parts[up_len + 1 ..], 0..) |*part, i| { + const src = @as(usize, @intCast(old.row + old.rows)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + text = std.mem.join(p.gpa, "\n", parts) catch return null; + owned = true; + } + + const row: usize = @intCast(@max(0, want_lo - row0)); + const line_len: i32 = @intCast(modal.lineSlice(text, row).len); + if (col > line_len) { + const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch { + if (owned) p.gpa.free(text); + return null; + }; + @memset(spaces, ' '); + const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch { + if (owned) p.gpa.free(text); + return null; + }; + if (owned) p.gpa.free(text); + text = padded; + owned = true; + } + if (owned) { + if (pane.ovl) |overlay| p.gpa.free(overlay.text); + pane.ovl = .{ .row = row0, .rows = covered, .text = text }; + } + return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row }; + } + + /// Consume a rewritten overlay, freeing the terminal edit text it replaces. + pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { + const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); + p.gpa.free(overlay.text); + overlay.text = new; + } + + /// Serialize terminal-only state; the core supplies shared pane metadata. + pub fn dumpPane( + pane: *Pane, + arena: std.mem.Allocator, + tag: []const u8, + body: []const u8, + scroll: usize, + ) !dump.Pane { + if (!enabled or pane.terminal == null) { + const text = if (pane.ovl) |overlay| overlay.text else ""; + return .{ + .kind = .terminal, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .terminal = .{ + .cwd = try arena.dupe(u8, pane.cwdSlice()), + .stream = try arena.dupe(u8, text), + .stream_b64 = &.{}, + .cursor = .{ .col = 0, .row = 0 }, + }, + }; + } + const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); + const extra = if (pane.ovl) |overlay| overlay.text.len else 0; + const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra)); + var len: usize = 0; + var lines = std.mem.splitAny(u8, full, "\n"); + var prompts = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var row: i32 = 0; + var skip: i32 = 0; + while (lines.next()) |raw| : (row += 1) { + // Hidden overlay rows still consume prompt pins to keep them aligned. + const prompt = if (pane.mode != .tty) prompts.next() else null; + if (skip > 0) { + skip -= 1; + continue; + } + if (row > 0) { + stream[len] = '\n'; + len += 1; + } + if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { + @memcpy(stream[len..][0..overlay.text.len], overlay.text); + len += overlay.text.len; + skip = overlay.rows - 1; + continue; + }; + const shown = if (prompt) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; + @memcpy(stream[len..][0..shown.len], shown); + len += shown.len; + } + return .{ + .kind = .terminal, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .terminal = .{ + .cwd = try arena.dupe(u8, pane.cwdSlice()), + .stream = stream[0..len], + .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)), + .cursor = .{ + .col = pane.terminal.?.vt.screens.active.cursor.x, + .row = pane.terminal.?.vt.screens.active.cursor.y, + }, + }, + }; + } + + fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { + const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); + var pit = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + // split yields one more item than delimiters; the extra final slot is the + // cursor row retained below. + const n_rows = std.mem.count(u8, full, "\n") + 2; + const rows = try alloc.alloc([]const u8, n_rows); + errdefer alloc.free(rows); + // Blanking a prompt row only ever SHORTENS it and the retained cursor row + // adds one separator, so the dump's length plus one bounds the join. + const text = try alloc.alloc(u8, full.len + 1); + errdefer alloc.free(text); + var at: usize = 0; + var n: usize = 0; + var it = std.mem.splitScalar(u8, full, '\n'); + while (it.next()) |raw| { + const shown = if (pit.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; + if (n > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..shown.len], shown); + rows[n] = text[at..][0..shown.len]; + at += shown.len; + n += 1; + } + text[at] = '\n'; + at += 1; + rows[n] = text[at..][0..0]; + n += 1; + std.debug.assert(n == n_rows); + return .{ .text_alloc = text, .text = text[0..at], .rows = rows }; + } + + pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { + const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: { + const state = pane.terminal orelse break :vp &.{}; + const screen = state.vt.screens.active; + var tl = screen.pages.getTopLeft(.viewport); + tl.x = 0; + const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint; + var rows_out: std.Io.Writer.Allocating = .init(arena); + try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false }); + const raw = try rows_out.toOwnedSlice(); + var prompts = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); + const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1); + var lines = std.mem.splitScalar(u8, raw, '\n'); + var n: usize = 0; + while (lines.next()) |ln| { + vp[n] = if (pane.mode != .tty) + if (prompts.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln + else + ln + else + ln; + n += 1; + } + std.debug.assert(n == vp.len); + break :vp vp; + }; + + const len = fillBody(null, pane, vp); + const out = try arena.alloc(u8, len); + const filled = fillBody(out, pane, vp); + std.debug.assert(filled == out.len); + return out; + } + + pub const BodyRow = union(enum) { + grid: i32, + edit: struct { line: []const u8, idx: usize }, + }; + + const BodyWalk = struct { + pane: *Pane, + goff: i32, + g: i32, + /// the buffer can start above the viewport: drop the lines scrolled past + skip: usize, + n: usize = 0, + lines: ?std.mem.SplitIterator(u8, .scalar) = null, + covered: i32 = 0, + line_idx: usize = 0, + + fn init(pane: *Pane) BodyWalk { + const off = pane.scroll(); + const goff = gridOffset(pane); + return .{ + .pane = pane, + .goff = goff, + .g = if (pane.mode == .tty) goff else pane.gridRow(off), + .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0, + }; + } + + fn next(w: *BodyWalk) ?BodyRow { + while (w.n < w.pane.rows) { + if (w.lines) |*it| { + if (it.next()) |line| { + const idx = w.line_idx; + w.line_idx += 1; + // Lines scrolled off the top still count: the index names a + // line of the BUFFER, not of the visible body. + if (w.skip > 0) { + w.skip -= 1; + continue; + } + w.n += 1; + return .{ .edit = .{ .line = line, .idx = idx } }; + } + // The buffer stands in for `rows` shell rows however many lines + // it actually spelled, which is the whole slide. + w.g += w.covered; + w.skip = 0; + w.lines = null; + continue; + } + if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) { + w.lines = std.mem.splitScalar(u8, o.text, '\n'); + w.covered = o.rows; + w.line_idx = 0; + continue; + }; + const vi = w.g - w.goff; + w.g += 1; + w.n += 1; + return .{ .grid = vi }; + } + return null; + } + }; + + /// Run the terminal body row walk. A null destination counts bytes; a slice + /// fills the exact allocation made from that count. + fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { + var walk: BodyWalk = .init(pane); + var written: usize = 0; + var first = true; + while (walk.next()) |row| { + if (!first) { + if (dst) |out| out[written] = '\n'; + written += 1; + } + first = false; + const bytes = switch (row) { + .edit => |e| e.line, + .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) + viewport[@intCast(vi)] + else + "", + }; + if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes); + written += bytes.len; + } + return written; + } + + // Match surviving edit-buffer rows to their original terminal styles. + const EditAnchors = struct { + /// the buffer's own text, walked in order: a line the VIEWPORT skipped still + /// consumes the row it came from, so the lines below it stay aligned + text: []const u8 = &.{}, + at: usize = 0, + shell: []const []const u8 = &.{}, + /// the covered span, absolute grid rows, as `[first, end)` + first: usize = 0, + end: usize = 0, + lines: usize = 0, + /// the line `at` names, and the first row still unclaimed + idx: usize = 0, + cursor: usize = 0, + budget: usize = 0, + active: bool = false, + + fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors { + if (o.rows <= 0 or o.row < 0) return .{}; + const first: usize = @intCast(o.row); + const screen = pane.terminal.?.vt.screens.active; + const total = screen.pages.scrollbar().total; + if (first >= total) return .{}; + const covered: usize = @intCast(o.rows); + const count = @min(covered, total - first); + const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{}; + const br = screen.pages.pin(.{ .screen = .{ + .x = screen.pages.cols - 1, + .y = @intCast(first + count - 1), + } }) orelse return .{}; + const arena = p.scratch.allocator(); + var output: std.Io.Writer.Allocating = .init(arena); + screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{}; + const shell = arena.alloc([]const u8, count) catch return .{}; + var raw = std.mem.splitScalar(u8, output.written(), '\n'); + var pins = tl.rowIterator(.right_down, br); + for (shell) |*row| { + const text = raw.next() orelse ""; + row.* = if (pins.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text + else + text; + } + const lines = std.mem.count(u8, o.text, "\n") + 1; + return .{ + .text = o.text, + .shell = shell, + .first = first, + .end = first + count, + .lines = lines, + .cursor = first, + .budget = covered + 4 * lines, + .active = true, + }; + } + + fn shellRow(a: *EditAnchors, idx: usize) ?Anchor { + if (!a.active or idx >= a.lines) return null; + var found: ?Anchor = null; + while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null); + return found; + } + + fn nextLine(a: *EditAnchors) ?[]const u8 { + if (a.at > a.text.len) return null; + const rest = a.text[a.at..]; + if (std.mem.indexOfScalar(u8, rest, '\n')) |n| { + a.at += n + 1; + return rest[0..n]; + } + // The last line has no terminator; one past the end ends the walk. + a.at = a.text.len + 1; + return rest; + } + + /// Where this line still stands over the grid, if anywhere. + fn claim(a: *EditAnchors, line: []const u8) ?Anchor { + const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end; + var k = a.cursor; + while (k < end) : (k += 1) { + if (a.budget == 0) return null; + a.budget -= 1; + if (!std.mem.eql(u8, line, a.shell[k - a.first])) continue; + a.cursor = k + 1; + return .{ .row = @intCast(k) }; + } + if (a.cursor >= a.end) return null; + const shell = a.shell[a.cursor - a.first]; + var p: usize = 0; + while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1; + var s: usize = 0; + const room = @min(line.len, shell.len) - p; + while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1; + if (p + s == 0) return null; + if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null; + const row = a.cursor; + if (s > 0 or p >= shell.len) a.cursor += 1; + return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len }; + } + }; + + const Anchor = struct { + /// the row, absolute while it comes from `EditAnchors`, viewport once + /// `recolorAnsi` has subtracted the walk's offset + row: i32, + prefix: usize = std.math.maxInt(usize), + suffix: usize = 0, + /// the row's own dumped length — what the suffix is measured from on the + /// GRID side, where the edit may have changed the byte count + shell_len: usize = 0, + + fn whole(an: Anchor) bool { + return an.prefix == std.math.maxInt(usize); + } + }; + + pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { + // No emulator, no ANSI cells: the whole pass — and the 256-colour theme + // projection behind it — is compiled out. + if (comptime !enabled) return; + const s = &p.surface; + if (pane.terminal == null) return; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + var filtered_storage: FilteredColors = undefined; + const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { + const tz_filter = tracy.zone(@src(), "filterInit"); + defer tz_filter.end(); + filtered_storage = FilteredColors.init(p, pane); + break :blk &filtered_storage; + } else null; + // DECSCNM, read once: the filtered palette folds it in itself, the raw + // path needs it per cell. + const scnm = pane.terminal.?.vt.modes.get(.reverse_colors); + const pages = &pane.terminal.?.vt.screens.active.pages; + const vp_rows: i32 = @intCast(scrollbar(pane).len); + // Which buffer lines the user has not actually changed, so a row swallowed + // by a growing buffer keeps the colour it still stands over. + var anchors: ?EditAnchors = null; + var walk: BodyWalk = .init(pane); + var lines = std.mem.splitScalar(u8, body, '\n'); + var vr: u16 = 0; + while (walk.next()) |row| : (vr += 1) { + if (vr >= body_h) break; + // Before any early exit below, or the lines fall out of step with rows. + const text = lines.next() orelse ""; + const anchor: Anchor = switch (row) { + .edit => |e| blk: { + if (anchors == null) anchors = .init(p, pane, pane.ovl.?); + var an = anchors.?.shellRow(e.idx) orelse continue; + an.row -= walk.goff; + break :blk an; + }, + .grid => |v| .{ .row = v }, + }; + const vi = anchor.row; + if (vi < 0 or vi >= vp_rows) continue; + const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue; + const cut: u16 = if (pane.mode == .tty) 0 else cut: { + if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0; + break :cut switch (promptCut(row_pin)) { + .keep => 0, + // Blanked end to end: the row shows nothing of the grid, so + // projecting the prompt's own colours onto it would be a lie. + .blank => continue, + .cut => |n| std.math.cast(u16, n) orelse continue, + }; + }; + if (cut > 0 and text.len == 0) continue; + var at: usize = 0; + var sc: u16 = 0; + var gc: u16 = cut; + var sb: usize = 0; + const mine_from = @min(anchor.prefix, text.len); + const mine_to = text.len - @min(anchor.suffix, text.len); + var crossed = false; + while (at < text.len and sc < tw) { + const stop = modal.nextGrapheme(text, at); + if (stop <= at) break; + const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1; + if (at >= mine_from and at < mine_to) { + sc += span; + at = stop; + continue; + } + if (at >= mine_to and !crossed) { + crossed = true; + const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len); + while (sb < upto) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + sb += dumpedBytes(row_pin, ci.cell); + gc = std.math.add(u16, gc, 1) catch break; + } + } + const want = stop - at; + // Consume every cell that contributed to this grapheme. A cluster + // ghostty split across several cells is still ONE printed glyph. + var covered: usize = 0; + var style: ?pardes.CellStyle = null; + while (covered < want) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head) + style = cellStyle(p, ci, filtered, scnm); + covered += dumpedBytes(row_pin, ci.cell); + gc = std.math.add(u16, gc, 1) catch break; + // A spacer contributes no bytes; without this the loop would + // spin on a row that ends in one. + if (covered == 0 and gc >= pane.cols) break; + } + while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| { + if (t.cell.wide != .spacer_tail) break; + gc = std.math.add(u16, gc, 1) catch break; + } + if (style) |st| for (0..span) |k| { + const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr); + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = st; + }; + sb += covered; + sc += span; + at = stop; + } + var tail: ?pardes.CellStyle = null; + if (anchor.whole() or anchor.suffix > 0) { + while (sc < tw) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + gc = std.math.add(u16, gc, 1) catch break; + if (ci.cell.wide == .spacer_tail) continue; + const cell = s.at(tx + sc, body_y + vr); + sc += 1; + const style = cellStyle(p, ci, filtered, scnm); + tail = style; + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = style; + } + if (tail) |style| while (sc < tw) : (sc += 1) { + const cell = s.at(tx + sc, body_y + vr); + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = style; + }; + } + } + } + + fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle { + const style = ci.style(); + var cs: pardes.CellStyle = .{ + .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm), + .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm), + .bold = style.flags.bold, + .dim = style.flags.faint, + .italic = style.flags.italic, + .blink = style.flags.blink, + .reverse = style.flags.inverse, + .invisible = style.flags.invisible, + .strikethrough = style.flags.strikethrough, + .ul = switch (style.flags.underline) { + .none => .off, + .single => .single, + .double => .double, + .curly => .curly, + .dotted => .dotted, + .dashed => .dashed, + }, + }; + if (filtered == null) switch (ci.cell.content_tag) { + .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data), + .bg_color_rgb => { + const rgb = ci.cell.content.color_rgb; + cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; + }, + else => {}, + }; + return cs; + } + + const FilteredColors = struct { + source: GColor.Palette, + target: *const GColor.Palette, + theme_bg: GColor.RGB, + theme_fg: GColor.RGB, + dynamic_bg: ?GColor.RGB, + dynamic_fg: ?GColor.RGB, + default_bg: GColor.RGB, + /// What a foreground too near `default_bg` becomes instead. + fallback_fg: GColor.RGB, + default_bg_luminance: f64, + fg_for_palette: [256]pardes.Color = undefined, + bg_for_palette: [256]pardes.Color = undefined, + /// The two answers for a cell that names no colour of its own. + fg_default: pardes.Color = undefined, + bg_default: pardes.Color = undefined, + cache_rgb: [256]GColor.RGB = undefined, + cache_key: [256]u8 = undefined, + cache_valid: [256]bool = @splat(false), + + fn init(p: *Pardes, pane: *const Pane) FilteredColors { + var source = GColor.default; + for (&source, 0..) |*rgb, i| + rgb.* = pane.terminal.?.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*; + const theme = p.theme(); + var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg); + var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg); + var dynamic_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }); + var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }); + if (pane.terminal.?.vt.modes.get(.reverse_colors)) { + std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); + std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); + } + var self: FilteredColors = .{ + .source = source, + .target = p.tty_filter_palette.get(theme), + .theme_bg = theme_bg, + .theme_fg = theme_fg, + .dynamic_bg = dynamic_bg, + .dynamic_fg = dynamic_fg, + .default_bg = theme_bg, + .fallback_fg = theme_fg, + .default_bg_luminance = luminanceOf(theme_bg), + }; + if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb); + self.default_bg_luminance = luminanceOf(self.default_bg); + if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg)) + self.fallback_fg = self.theme_bg; + + self.fg_default = asPardesColor(self.legible( + if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg, + )); + self.bg_default = asPardesColor(self.default_bg); + for (&self.source, 0..) |current, i| { + const idx: u8 = @intCast(i); + const mapped = self.paletteRgb(idx, current); + self.fg_for_palette[idx] = asPardesColor(self.legible(mapped)); + self.bg_for_palette[idx] = asPardesColor(mapped); + } + return self; + } + + fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { + return switch (style.fg_color) { + .none => self.fg_default, + .palette => |idx| self.fg_for_palette[idx], + .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{ + .default = self.dynamic_fg orelse self.theme_fg, + .palette = &self.source, + .bold = null, + })))), + }; + } + + fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { + switch (cell.content_tag) { + .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data], + .bg_color_rgb => {}, + else => switch (style.bg_color) { + .none => return self.bg_default, + .palette => |idx| return self.bg_for_palette[idx], + .rgb => {}, + }, + } + // Truecolour, from either the cell or its style. + return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?)); + } + + fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB { + if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >= + config.tty_filter_min_contrast) return rgb; + return self.fallback_fg; + } + + fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB { + if (current.eql(GColor.default[idx])) return self.target[idx]; + return self.keyedRgb(current); + } + + fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB { + return self.target[self.nearestKey(rgb)]; + } + + fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 { + const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b; + const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24); + if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb)) + return self.cache_key[slot]; + + var best: u8 = 0; + var best_distance: u32 = std.math.maxInt(u32); + for (GColor.default, 0..) |candidate, i| { + const distance = colorDistance(rgb, candidate); + // Strict comparison makes duplicate-colour ties stable at the + // lowest canonical xterm key. + if (distance < best_distance) { + best_distance = distance; + best = @intCast(i); + } + } + self.cache_rgb[slot] = rgb; + self.cache_key[slot] = best; + self.cache_valid[slot] = true; + return best; + } + }; + + const channel_luminance: [256]f64 = blk: { + @setEvalBranchQuota(20000); + var table: [256]f64 = undefined; + for (&table, 0..) |*slot, c| { + const normalized: f64 = @as(f64, @floatFromInt(c)) / 255; + slot.* = if (normalized <= 0.03928) + normalized / 12.92 + else + std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); + } + break :blk table; + }; + + fn luminanceOf(rgb: GColor.RGB) f64 { + return 0.2126 * channel_luminance[rgb.r] + + 0.7152 * channel_luminance[rgb.g] + + 0.0722 * channel_luminance[rgb.b]; + } + + /// ghostty's `RGB.contrast` with both luminances already in hand. + fn contrastOf(a_luminance: f64, b_luminance: f64) f64 { + const lighter = @max(a_luminance, b_luminance); + const darker = @min(a_luminance, b_luminance); + return (lighter + 0.05) / (darker + 0.05); + } + + fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { + const dr = @as(i32, a.r) - @as(i32, b.r); + const dg = @as(i32, a.g) - @as(i32, b.g); + const db = @as(i32, a.b) - @as(i32, b.b); + return @intCast(dr * dr + dg * dg + db * db); + } + + test "the luminance table answers exactly what ghostty computes" { + for (0..256) |i| { + const c: u8 = @intCast(i); + const grey: GColor.RGB = .{ .r = c, .g = c, .b = c }; + try std.testing.expectEqual(grey.luminance(), luminanceOf(grey)); + } + // Channel weights are asymmetric, so a grey ramp alone would not catch a + // transposed coefficient. The palette is what the tables enumerate. + for (GColor.default) |candidate| { + try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate)); + for (GColor.default) |page| { + try std.testing.expectEqual( + candidate.contrast(page), + contrastOf(luminanceOf(candidate), luminanceOf(page)), + ); + } + } + } + + test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + pane.tty_filter = false; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++ + "\x1b[38;5;196mB" ++ + "\x1b[38;2;255;0;0mC" ++ + "\x1b[0;48;5;25mD" ++ + "\x1b[0;48;2;0;95;175mE" ++ + "\x1b[0;1;2;3;4;5;7;8;9mF" ++ + "\x1b[0;48;5;25m\x1b[K" ++ + "\r\n\x1b[0;38;5;2m界" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + const raw = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg); + + pane.tty_filter = true; + _ = frame.reset(.retain_capacity); + const filtered = try p.render(frame.allocator()); + var expected_cache: FilterPalette = .{}; + const expected = expected_cache.get(p.theme()); + try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg); + try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg); + try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg); + try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg); + try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg); + + const attrs = filtered.at(tx + 5, body_y).style; + try testing.expect(attrs.bold); + try testing.expect(attrs.dim); + try testing.expect(attrs.italic); + try testing.expect(attrs.blink); + try testing.expect(attrs.reverse); + try testing.expect(attrs.invisible); + try testing.expect(attrs.strikethrough); + try testing.expectEqual(.single, attrs.ul); + + const erased = pane.terminal.?.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?; + try testing.expectEqual(.bg_color_palette, erased.cell.content_tag); + try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg); + try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg); + try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style); + // A filtered terminal never delegates either colour to a backend palette, + // including cells which were empty/default before the pass. + for (0..r.w - config.GUTTER) |col| { + const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y); + try testing.expect(!cell.default); + switch (cell.style.fg) { + .rgb => {}, + else => return error.FilteredForegroundWasNotRgb, + } + switch (cell.style.bg) { + .rgb => {}, + else => return error.FilteredBackgroundWasNotRgb, + } + } + + // Colors remains the global master gate. The pane remembers Filter while + // ANSI projection is dormant, and resumes it without replaying VT bytes. + p.settings.colors = false; + _ = frame.reset(.retain_capacity); + const plain = try p.render(frame.allocator()); + try testing.expect(pane.tty_filter); + try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg); + p.settings.colors = true; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } }); + const osc_red = pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; + try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 })); + pane.tty_filter = false; + pane.tty_filter = true; + try testing.expect(osc_red.eql(pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); + _ = frame.reset(.retain_capacity); + const osc_palette = try p.render(frame.allocator()); + try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg); + + // Dynamic default foreground/background colours key every default cell, + // including the otherwise blank end of the row. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++ + "\x1b]11;#5f5f5f\x1b\\" } }); + const dyn_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?; + const dyn_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }).?; + try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 })); + try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 })); + _ = frame.reset(.retain_capacity); + const dynamic = try p.render(frame.allocator()); + const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[196]), blank.fg); + try testing.expectEqual(asPardesColor(expected[59]), blank.bg); + + const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; + try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + _ = frame.reset(.retain_capacity); + const reversed = try p.render(frame.allocator()); + const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg); + try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg); + const reversed_explicit = reversed.at(tx, body_y).style; + try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg); + try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg); + try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg)); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } }); + _ = frame.reset(.retain_capacity); + const unreversed = try p.render(frame.allocator()); + const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg); + try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg); + + // The cache is keyed by values, not a theme name. Replacing a custom + // theme in place immediately recolours already-rendered indexed cells. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } }); + var custom = p.theme().*; + custom.name = try p.gpa.dupe(u8, "same-name"); + custom.palette = null; + custom.kw = .{ 1, 2, 3 }; + p.custom_theme = custom; + _ = frame.reset(.retain_capacity); + const custom_first = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg); + if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 }; + _ = frame.reset(.retain_capacity); + const custom_second = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg); + } + + test "terminal Filter keeps extended keys dark-to-light on a light theme" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + // Curated order is a public theme contract: helix, dark, acme. + p.settings.theme = 2; + try std.testing.expectEqualStrings("acme", p.theme().name); + var cache: FilterPalette = .{}; + const palette = cache.get(p.theme()); + try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?))); + try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?))); + } + + test "terminal Filter preserves exact palette-null light theme default roles" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + var light = p.theme().*; + light.name = try p.gpa.dupe(u8, "filter-light-defaults"); + light.bg = .{ 0xf8, 0xf8, 0xf8 }; + light.fg = .{ 0x38, 0x38, 0x38 }; + light.palette = null; + p.custom_theme = light; + + const pane = p.panes[0].?; + try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground })); + try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background })); + pane.tty_filter = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const ordinary = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + _ = frame.reset(.retain_capacity); + const reversed = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg); + } + + test "terminal Filter maps the default roles before it maps anything else" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + for (0..3) |t| { + p.settings.theme = @intCast(t); + const stage_one = FilteredColors.init(p, pane); + // `dark` declares no background of its own, which is exactly why the + // resolver reads the tag colours as the fallback rather than `.?`. + const theme = p.theme(); + try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg))); + try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg))); + // With no OSC 11 in play the mapped page IS that anchor, and the + // fallback is the other one: a background never contrasts with itself. + try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg)); + try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg)); + } + + p.settings.theme = 0; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } }); + var moved = FilteredColors.init(p, pane); + try testing.expect(!moved.default_bg.eql(moved.theme_bg)); + try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f }))); + } + + test "terminal Filter refuses a foreground that would collapse onto the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++ + "\x1b[0;30mB" ++ + "\x1b[0;31mR" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + for (0..3) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + const page = asPardesColor(fc.default_bg); + const rescued = asPardesColor(fc.fallback_fg); + _ = frame.reset(.retain_capacity); + const g = try p.render(frame.allocator()); + + // The colour each of the three would have been given with no floor. + const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 }); + const raw_black = fc.paletteRgb(0, GColor.default[0]); + const raw_red = fc.paletteRgb(1, GColor.default[1]); + + for ([_]struct { at: u16, raw: GColor.RGB }{ + .{ .at = 0, .raw = raw_white }, + .{ .at = 1, .raw = raw_black }, + .{ .at = 2, .raw = raw_red }, + }) |case| { + const cell = g.at(tx + case.at, body_y).style; + try testing.expectEqual(page, cell.bg); + if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) { + // Refused: the projection's answer is not painted, the anchor is. + try testing.expectEqual(rescued, cell.fg); + try testing.expect(!std.meta.eql(cell.fg, cell.bg)); + } else { + // Cleared the floor, so stage two leaves it exactly alone. + try testing.expectEqual(asPardesColor(case.raw), cell.fg); + } + // Either way a filtered cell delegates neither colour to a backend. + switch (cell.fg) { + .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >= + config.tty_filter_min_contrast), + else => return error.FilteredForegroundWasNotRgb, + } + } + + // At least one of the three has to have been a real collapse, or this + // theme proved nothing: white on the light theme, black on the dark. + try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or + raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast); + // A saturated red is never the page on any curated theme. + try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + } + } + + test "terminal Filter holds every projected foreground off the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + + for (0..3) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + var refused: usize = 0; + for (fc.target, 0..) |projected, key| { + const ink = fc.legible(projected); + try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + if (!ink.eql(projected)) { + refused += 1; + try testing.expect(ink.eql(fc.fallback_fg)); + // Only ever refused for being too near the page. + try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast); + } + // The key a background asks for is handed back untouched, including + // the one whose value is the page itself. + try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])])); + } + // Every curated theme owns at least one collapsing key — that is why + // the floor exists — and the floor must not be flattening the palette. + try testing.expect(refused > 0); + try testing.expect(refused < fc.target.len / 8); + } + } + + test "tty ansi colors follow the prompt hug into normal mode" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const red: pardes.Color = .{ .index = 1 }; + const blue: pardes.Color = .{ .index = 4 }; + + // tty mode projects the emulator's ansi colours cell for cell. + pane.mode = .tty; + p.shell_rows.stale = true; + const tty = try p.render(frame.allocator()); + try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg); + try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg); + + pane.mode = .normal; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const norm = try p.render(frame.allocator()); + try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme()); + try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme()); + try testing.expectEqual(red, norm.at(tx, body_y).style.fg); + try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg); + } + + test "a prompt row hidden end to end paints nothing at all" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme()); + try testing.expect(!std.meta.eql(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.bg)); + } + + pub fn palColor(p: *Pardes, idx: u8) pardes.Color { + if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; + return .{ .index = idx }; + } + + pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color { + return switch (color) { + .none => blk: { + const t = if (is_bg) p.theme().bg else p.theme().fg; + break :blk if (t) |c| .{ .rgb = c } else .default; + }, + .palette => |idx| palColor(p, idx), + .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, + }; + } + + /// executing at a prompt with typed text below it: pad the output area + /// with newlines so the command's output doesn't overwrite the buffer + pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { + // Nothing to pad away from: with no emulator there is no prompt and no + // child whose output could land on top of the edit buffer. + if (comptime !enabled) return; + const pane = p.panes[id] orelse return; + const o = pane.ovl orelse return; + if (!pane.isTerminal()) return; + const state = pane.terminal orelse return; + if (!state.vt.cursorIsAtPrompt()) return; + // the buffer's LAST surface row: its lines may outnumber the shell + // rows it covers, and it is the bottom one output must clear + const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; + const goff: i32 = @intCast(state.vt.screens.active.pages.scrollbar().offset); + const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.vt.screens.active.cursor.y))); + const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); + var i: i32 = 0; + while (i < pad) : (i += 1) p.emitWrite(id, "\r"); + } + + /// the snapshot takes ownership of a COPY of the edit buffer's text + pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot { + var ovl: ?EditBuffer = null; + if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; + return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; + } + + /// undo/redo restores the selection recorded with the snapshot (helix + /// keeps selections in its history transactions) + pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void { + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = s.ovl; + pane.cur_row = s.cur_row; + pane.cur_col = s.cur_col; + pane.cur_pinned = true; + pane.vsel = s.vsel; + pane.msel.active = false; + pane.ensureCursorVisible(); + } + + fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void { + if (len.* == slots.len) { + if (slots[0].ovl) |overlay| gpa.free(overlay.text); + std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); + len.* -= 1; + } + slots[len.*] = value; + len.* += 1; + } + + pub fn pushUndo(p: *Pardes, pane: *Pane) void { + const current = pane.ovl orelse EditBuffer{ .rows = 0 }; + if (pane.ed_undo_len > 0) { + const top = pane.ed_undo[pane.ed_undo_len - 1]; + const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and + overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null; + if (same) return; + } + const value = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value); + for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text); + pane.ed_redo_len = 0; + } + + pub fn undo(p: *Pardes, pane: *Pane) void { + if (pane.ed_undo_len == 0) return; + const current = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current); + pane.ed_undo_len -= 1; + restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]); + } + + pub fn redo(p: *Pardes, pane: *Pane) void { + if (pane.ed_redo_len == 0) return; + const current = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current); + pane.ed_redo_len -= 1; + restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]); + } + + pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { + const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal)); + const room = state.reply.len - state.reply_len; + const n = @min(room, data.len); + @memcpy(state.reply[state.reply_len..][0..n], data[0..n]); + state.reply_len += @intCast(n); + } + + const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( + @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), + ).optional.child).pointer.child).@"fn".return_type.?; + pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { + return .{}; + } +}; + +test { + _ = Pane; + _ = File; + _ = Output; + _ = Mini; + _ = Image; + _ = Pdf; + _ = Terminal; +} diff --git a/src/pardes.zig b/src/pardes.zig index 6edf3600..8d5f0ba7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1,165 +1,49 @@ -//! The pardes core: a text environment as a library, the way ghostty-vt is a -//! library. The platform shell owns the event loop and process-facing IO; it -//! feeds this core events (input, pty bytes, resizes) and reads back two plain values: -//! a Surface — the canonical cell-grid interface, which the tty shell hands to -//! vaxis nearly verbatim and the SDL shells rasterize — and a queue of Effects, -//! the IO the core wants performed (spawn a shell, write a pty, open a link). -//! Path-backed document and search operations may read synchronously; work -//! which needs a host or event loop is emitted as an Effect. -//! -//! Platform divergence inside the core is the `platform` comptime tag, used -//! the way the stdlib uses os.tag. Click-on-text semantics live in look.zig. -//! -//! src/ layout: the core lies flat at src/, and every SUBDIRECTORY is one -//! backend (tty/ gui/ lsp/) — so a file being in no directory at all is what -//! says it is core, and nothing needs a header to claim it. -//! -//! Not one key, button or piece of Look syntax is spelled in this file: every -//! one of them is a named binding in config.zig, and `hit`/`isPrefix` below are -//! the only two matchers. That is so retargeting anything is an edit in one -//! file, and so a later builtin can enumerate the bindings the way Help already -//! enumerates the leader. const std = @import("std"); -pub const animation = @import("animation.zig"); -pub const panel_animation = @import("panel_animation.zig"); +pub const layout = @import("layout.zig"); const uucode = @import("uucode"); const vaxis = @import("vaxis"); const mvzr = @import("mvzr"); -const modal = @import("modal.zig"); -const normal_input = @import("normal_input.zig"); -const look = @import("look.zig"); +pub const modal = @import("modal.zig"); +pub const look = @import("look.zig"); +pub const filesystem = @import("fs.zig"); pub const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); -const term_pane = @import("term_pane.zig"); -const file_pane = @import("file_pane.zig"); -const image_pane = @import("image_pane.zig"); -pub const pdf_pane = @import("pdf_pane.zig"); -const output_pane = @import("output_pane.zig"); -const builtins = @import("builtins.zig"); -const runtime_cfg = @import("runtime_config.zig"); -/// Every board-shaped capacity, in one table keyed on a profile rather than on -/// the platform. See src/limits.zig. -const limits = @import("limits.zig"); -const message = @import("message.zig"); +pub const panes = @import("panes.zig"); +pub const builtins = @import("builtins.zig"); +const limits = memory.limits; const selection_pipe = @import("selection_pipe.zig"); -/// acme's control filesystem, as a pure transaction over this core: the FILES -/// a script opens (`body`, `ctl`, `event`, ...) and what they mean. The -/// transport that carries requests in is a host's business (src/fuse.zig). -pub const acmefs = @import("acmefs.zig"); pub const config = @import("config.zig"); -pub const pdf_enabled = pdf_pane.enabled; -pub const pdf = pdf_pane.pdf; -pub const allocators = @import("allocators.zig"); +pub const pdf_enabled = panes.Pdf.enabled; +pub const pdf = panes.Pdf.pdf; +pub const memory = @import("memory.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); -/// The host seam: one struct of optional function pointers, with in-core -/// defaults for every method a host leaves null. See src/host.zig. -const host_mod = @import("host.zig"); -pub const Host = host_mod.Host; -pub const Fanout = host_mod.Fanout; -pub const Fallback = host_mod.Fallback; -pub const fallback_dump_path = host_mod.fallback_dump_path; -/// Tracy's frame boundary, re-exported so a host that is not a shell — the -/// fling benchmark — can delimit the same frames the tty loop delimits without -/// reaching around the core for src/tracy.zig and its build options. A no-op -/// unless -Dtracy names a Tracy checkout. +const host_io = @import("host_io.zig"); +pub const Host = host_io.Host; pub const frameMark = tracy.frameMark; -/// `p4` is ESP32-P4 firmware: a riscv32-freestanding core whose whole host is -/// a serial line. It joins `web` in having no filesystem, no ptys and no -/// config directory, which is what `hosted` below is for. pub const Platform = enum { tty, gui, web, macos, esp32p4 }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); -/// Whether a theme change FADES the anchored chrome palette or replaces it. Ten display frames -/// either way (`animation.transition_steps`), and on every screen but one that is a short legible -/// transition rather than a glitch. -/// -/// The exception is a screen reached through a UART. Each of the ten steps recolors every anchored -/// cell, so the diff finds the whole chrome dirty and spends a frame's worth of wire on it, ten times -/// over, for a fade nobody can see arrive gradually anyway. Off by default for `esp32p4` and settable -/// either way from the build, because the thing that makes it wrong is the transport rather than the -/// 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 -/// produces a SECOND executable from the same graph — see `run-isolated`. pub const isolated = @import("pardes_isolation").isolated; -/// Frontends that draw their own text, and can therefore be told which face to -/// wear. On the tty the font belongs to the terminal emulator and in the -/// browser it belongs to the page, so there the Font builtins are not -/// disabled so much as meaningless — see builtins.zig. pub const font_picker = platform == .gui or platform == .macos; -/// Platforms whose host is a real operating system: a filesystem to open, a -/// pty to fork, a config directory to watch. The browser and the P4 firmware -/// have none of the three, and every gate that used to read `platform != .web` -/// 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 -/// flash and a PageList of RAM spent parsing input that cannot arrive — and it -/// drags a pile of freestanding root hooks in behind it (os.PATH_MAX, -/// os.heap.page_allocator, a cwd handle), none of which the core itself wants. -/// False means ghostty-vt is not in the module graph at all: build.zig never -/// even asks for the dependency. -/// -/// A PLATFORM gate and deliberately NOT one derived from the target: `web` is -/// freestanding too and KEEPS the emulator, because the browser shell renders -/// terminal panes back out of a replayed dump. Every gate in the core keys off -/// this one name, and src/term_pane.zig re-exports it as `enabled` and owns -/// the whole seam — the two Pane slots included — so ghostty-vt ends up -/// imported by exactly one file. pub const terminal_panes = platform != .esp32p4; -/// ...and the one fact about that face the core keeps: the name `Font` last -/// resolved, which the Debug overlay prints. Behind the same comptime shim -/// builtins.zig and macos.zig import this file with, so a tty or web binary -/// never analyses a font-directory walk it cannot use. -const fonts = if (font_picker) @import("fonts.zig") else struct {}; +pub const fonts = if (font_picker) @import("fonts.zig") else struct {}; -/// Native PDF quality is a shell property, but the core owns MuPDF and the -/// RGBA cache. Kitty favors wire bandwidth; SDL favors physical-pixel text -/// quality and asks the renderer to cover either fit axis without upscaling. -pub const PdfRasterPolicy = pdf_pane.RasterPolicy; +pub const PdfRasterPolicy = panes.Pdf.RasterPolicy; pub const kitty_pdf_raster_policy: PdfRasterPolicy = .{ .dpi = 96, @@ -181,37 +65,17 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) { .web, .esp32p4 => kitty_pdf_raster_policy, }; -// The capacities and the two heights that are STRUCTURE, not taste: the -// fixed-size pane/column arrays, and the fact that the topbar and a tag are -// one row each (nothing here works at any other value). The layout numbers -// that ARE taste — the gutter, the line-number prefix, scrolloff, the pane -// minimums — live in config.zig with everything else a user retargets. +pub const Pane = panes.Pane; + pub const MAX_PANES = 16; -pub const PDF_PAGE_GAP_PX = pdf_pane.page_gap_px; +pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; -/// 32 fractional bits leave ample precision for resize/restored ratios while -/// allowing every possible column split to divide an initial weight exactly. -const column_weight_unit: u64 = 1 << 32; -const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; -/// how far back the jump stack remembers. Vim keeps 100; this is a session of -/// at most sixteen panes, so the depth that matters is "more visits than you -/// can hold in your head" and the oldest entry falls off the bottom. +const column_weight_unit = layout.column_weight_unit; +const max_column_weight = layout.max_column_weight; pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const BOX_H: u16 = 1; -/// Where a reduced-height tagline band sits inside its body-sized grid row, in -/// physical pixels down from the row's top. ONE rule for both pixel hosts: the -/// SDL shell (`src/gui/gui.zig`) and the AppKit shell (`src/macos.zig`, over the -/// C ABI) both call this. It lived in gui.zig, the native shell grew its own -/// copy that only ever centred, and centring is precisely the case -/// `config.gui_topbar_pane_border_px` exists to avoid: two half-bands touching -/// with a strip of window background showing between them, widening as the -/// tagline face shrinks. -/// -/// Row zero and the first pane-tag row face a shared rule instead of centering -/// two independent bands. A Tagbottom band on the final grid row faces the -/// window edge, eliminating the matching unused half-band at the bottom. pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; const border = topbarPaneBorderPixels(cell_h, tagline_h); @@ -222,37 +86,12 @@ pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u return spare / 2; } -/// The rule between the topbar band and the first pane-tag band, clamped to the -/// spare pixels those two bands have between them so a wide compiled value -/// cannot paint over either. pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); } -/// The column a compact tagline band anchors at: the left edge of the pane -/// whose tag row this cell sits on. ONE rule for both pixel hosts, for exactly -/// the reason `taglineBandOffset` is one — the SDL shell reached this through -/// its own copy of the pane walk, and the AppKit shell could not do the walk at -/// all (pane rects are not on its C ABI), so its tag rows advanced on BODY -/// pitch with the smaller glyph merely centred in each body cell. Same session, -/// same percentage, visibly looser tracking in one of the two windows. -/// -/// CELLS, and fractional on purpose: an animating panel's box is fractional, -/// and rounding here would step a sliding pane's tag row a whole body cell at a -/// time while the rest of the pane moved smoothly. -/// -/// `track` is the panel track painting this cell, when one is. It is a -/// parameter rather than something looked up here because the caller has -/// already decided which track owns the cell — the SDL shell from its paint -/// plan, the C ABI wrapper from the frame's track list — and two answers to -/// that question is the drift this function exists to prevent. -/// -/// The last resort is the cell's own column, which puts that one cell back on -/// body pitch. That is deliberate: a stale cell whose pane has closed, or any -/// cell of an attached window, still has to be legible, and a band anchored at -/// a pane that no longer exists is not. -pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?panel_animation.Track) f32 { +pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 { if (row < TOPBAR_H) return 0; if (track) |active| { const box = active.contentBox(); @@ -270,12 +109,6 @@ pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?panel_anim return @floatFromInt(col); } -/// `taglineOriginCol` for a host with no paint plan of its own: the owning -/// track is resolved from the frame's own list. The SDL shell already knows -/// which track is painting a cell and passes it; AppKit reaches the grid -/// through the C ABI and does not, so the lookup belongs here rather than in -/// the wrapper — a second answer to "which track owns this cell" is exactly -/// the drift `taglineOriginCol` was moved into the core to stop. pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { for (p.surface.panelTracks()) |track| if (track.contentBox().contains(col, row)) @@ -284,7 +117,7 @@ pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { } test "paint order is moving, then opening, then closing tombstones on top" { - const Track = panel_animation.Track; + const Track = layout.Track; // Deliberately interleaved on the way in: the phases are what order the // output, not the slot they happened to occupy. const live = [_]?Track{ @@ -298,7 +131,7 @@ test "paint order is moving, then opening, then closing tombstones on top" { .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical }, }; var out: [8]Track = undefined; - const len = panel_animation.paintOrder(&live, &closing, &out); + const len = layout.paintOrder(&live, &closing, &out); var serials: [8]u32 = undefined; for (out[0..len], 0..) |track, i| serials[i] = track.serial; @@ -307,25 +140,11 @@ test "paint order is moving, then opening, then closing tombstones on top" { // A host's array is fixed-size and the core's is not its business: writing // past it would be a buffer overrun in whichever shell had the smaller one. var tight: [2]Track = undefined; - try std.testing.expectEqual(@as(usize, 2), panel_animation.paintOrder(&live, &closing, &tight)); + try std.testing.expectEqual(@as(usize, 2), layout.paintOrder(&live, &closing, &tight)); try std.testing.expectEqual(@as(u32, 12), tight[0].serial); try std.testing.expectEqual(@as(u32, 15), tight[1].serial); } -/// The INVERSE of the two rules above: which grid column a pointer sits in, -/// given where the glyphs actually went. Compacting a tag row without -/// compacting the hit test is a click that lands one word to the right by the -/// end of the row, so these two are one feature and belong in one place. -/// -/// `x` and both widths are in whatever unit the host measures in — physical -/// pixels for SDL, points for AppKit — because only their RATIO is used. -/// -/// The topbar anchors at column zero, a pane tag row at its pane's left edge -/// and is clamped to that pane's last column so a click in the slack at the -/// right of a compacted band stays on the pane it was aimed at, and everything -/// else is the body grid. Deliberately track-blind: the pointer is aimed at -/// what is on screen NOW, and a mid-animation pane is somewhere its own -/// geometry says it is not yet. pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { const body = @max(body_w, 1); const tag = @max(tagline_w, 1); @@ -356,7 +175,7 @@ test "a compact tagline anchors at its own pane, and both shells step from the s const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); // Two panes side by side put two tags on ONE row, which is the case a @@ -371,19 +190,9 @@ test "a compact tagline anchors at its own pane, and both shells step from the s // is why row zero is right even in a window with no core to ask. try std.testing.expectEqual(@as(f32, 0), taglineOriginCol(p, 40, 0, null)); - // A row no pane tags falls back to the cell's own column, which is the - // identity that puts that cell back on body pitch rather than sliding it - // somewhere a closed pane used to be. const body_row = left.y + 2; try std.testing.expectEqual(@as(f32, 7), taglineOriginCol(p, 7, body_row, null)); - // THE CROSS-SHELL CONTRACT. gui.zig lays a compact cell out as - // `x_off + col * tag_w` with `x_off = origin * (body_w - tag_w)`; the - // AppKit shell spells the same placement as - // `origin * body_w + (col - origin) * tag_w`. They are the same line of - // algebra and this is the assertion that keeps them one: the two windows - // are supposed to be indistinguishable at the same percentage, and the - // whole bug was one of them quietly using body pitch. const body_w: f32 = 10; const tag_w: f32 = 8; for ([_]u16{ 0, 1, 5, 40, 119 }) |col| { @@ -393,12 +202,6 @@ test "a compact tagline anchors at its own pane, and both shells step from the s try std.testing.expectEqual(sdl, appkit); } - // ...and the POINTER agrees with both. Placing a glyph on a narrower pitch - // while still dividing clicks by the body cell is a hit that drifts one - // column further right for every column along the row — dead centre of the - // last word in a wide tag lands on empty space past its end. Forward and - // inverse live in different files and different languages; this is what - // keeps them inverses. for ([_]u16{ 0, 1, 4, 9 }) |offset| { const col = left.x + offset; if (offset >= left.w) break; @@ -417,42 +220,19 @@ test "a compact tagline anchors at its own pane, and both shells step from the s try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * body_w, body_row, body_w, tag_w)); } -/// A place the keyboard has been: a pane AND a spot in it, which is the whole -/// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now -/// rewind WITHIN a pane, and a Jumplist row can name a line. -/// -/// It is SAFE against the pane it names dying: `serial` is the pane's own -/// identity, so an entry whose slot has since been handed to a different pane -/// reads as dead rather than silently retargeting itself at the newcomer, and -/// sync() drops it. What it does not do is outlive the pane — ponytail: a -/// location is a place in the SESSION, not on disk, so closing a file forgets -/// the entries pointing into it. To make Ctrl-o RE-OPEN a closed file, this -/// grows a path field and jumpBy looks it when the pane is gone. pub const Loc = struct { pane: u16, serial: u32, - /// 1-based, both — this is the `path:LINE:COL` a look word spells, and - /// focusPaneLine takes exactly these. 0 = no spot, just the pane (see - /// trackJump: a shell whose cursor is still the program's). line: u32, col: u32, }; -test { - _ = @import("pdf_pane_integration_test.zig"); - _ = @import("output_pane_integration_test.zig"); -} +test {} const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; -// Kept separate from the path so a click still expands to the exact filename. -// It belongs to the live, read-only prefix rather than the editable command -// tail: saving removes it without rewriting anything the user typed there. const dirty_marker = " *"; -// Version-1 dumps originally persisted only the whole rendered tag. These were -// the two canonical tails before New joined every pane; the compatibility -// parser recognizes them as defaults while new dumps carry an explicit tail. const legacy_pane_tail = " Del"; const legacy_file_pane_tail = " Save Del"; // The defaults from the release before Newtty joined every tagline. Recognized @@ -464,24 +244,9 @@ const legacy_terminal_pane_tail = " New Del Filter"; // scrollback was not yet something you could write to a path. const prev_terminal_pane_tail = " New Newtty Del Filter"; -// Builtins: executing the name (middle-click / Tab) runs it through the ONE -// dispatcher (runBuiltin, reached from execute), no matter where the name -// appears — and Look and Exec are two of them, so the click itself is a -// builtin. One STRUCT per builtin in builtins.zig — name, comment and -// body in one place — and this enum is folded out of THAT FILE'S declarations -// at comptime, so the enum FIELD NAME is still the user-visible word (the one -// in the topbar, the one sitting in a tag, the one Help prints, the one you -// execute) and `std.meta.stringToEnum` is still the lookup with no name table -// to keep in sync. It lands here rather than in builtins.zig because a -// container cannot hold a decl folded out of its own decl list, and here it -// sits with the other two comptime folds (builtin_rows, the topbar check). const Builtin = builtins.registry.Builtin(); -/// The PDF integration suite lives beside the PDF implementation instead of -/// making the core's first sixteen hundred lines pane-specific. These direct -/// test-only calls reach the few intentionally private core transactions that -/// the suite must observe; the declaration is empty in every non-test build. -pub const pdf_test = if (@import("builtin").is_test) struct { +pub const test_api = if (@import("builtin").is_test) struct { pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { return p.tagText(arena, pane); } @@ -519,9 +284,6 @@ fn nextPipeEffect(p: *Pardes) ?u32 { return null; } -/// Perform every queued effect through the in-process host — what a real shell -/// does with the drain — and report the path the last `.save_text` among them -/// asked for, copied out of the effect into `buf`. fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 { var len: ?usize = null; while (p.nextEffect()) |effect| { @@ -538,19 +300,12 @@ 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" { - // `can_attach` and not `hosted`: 29ac9be compiled both words out of a - // frontend that never polls `takeAttach`, which is macOS — hosted, with a - // unix socket, and still no word to run here. if (comptime !can_attach) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -583,9 +338,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le 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, @@ -593,9 +345,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le } 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]); @@ -607,18 +356,18 @@ test "selection pipe prompt submits exact request and Escape cancels" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("one\ntwo\n"); + const pane = try p.setTestFile("one\ntwo\n"); pane.cur_row = 0; pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(pane.tag_edit and pane.hasPipePrompt()); + try std.testing.expect(pane.tag_edit and pane.prompt == .pipe); try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker)); try std.testing.expect(nextPipeEffect(p) == null); p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); p.update(.{ .key = .{ .cp = Key.enter } }); - try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt()); + try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; try std.testing.expectEqualStrings("tr a-z A-Z", request.command); @@ -630,7 +379,7 @@ test "selection pipe prompt submits exact request and Escape cancels" { p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt()); + try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe); try std.testing.expectEqualSlices(u8, before, pane.file.?.content); try std.testing.expect(nextPipeEffect(p) == null); } @@ -641,9 +390,9 @@ test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" defer p.deinit(); while (p.nextEffect()) |_| {} const long = "a" ** 400; - const pane = try p.hxOpenFileContent(long ++ "\nsecond\n"); + const pane = try p.setTestFile(long ++ "\nsecond\n"); p.settings.wrap = true; - const width = file_pane.wrapWidth(pane, true); + const width = panes.File.wrapWidth(pane, true); try std.testing.expect(width > 4 and long.len > width * 3); // gj holds the column INSIDE the row and lands on the next break; the line @@ -710,9 +459,6 @@ test "the message log keeps what the row forgets, and collapses repeats" { p.setMessage(0, "14:32:09 saved /x.zig"); // same event, later clock p.setMessage(0, "save: AccessDenied"); - // Two entries, not three: the clock does not make a message new. This is - // the case the de-duplication exists for and the one it used to miss, - // because `message.stamp` makes every host message unique by construction. try std.testing.expectEqual(@as(usize, 2), p.messages_len); const first = p.messageLog(0).?; try std.testing.expectEqual(@as(u16, 2), first.repeats); @@ -752,13 +498,12 @@ test "the acme chords act once per selection, not once on the primary" { while (p.nextEffect()) |_| {} // Two selections, each naming a DIFFERENT builtin, so what ran is visible // in the layout rather than in a shell nobody can read from a test. - const pane = try p.hxOpenFileContent("Newcol\nNewcol\n"); - const pl = try p.paneCursorLines(pane); - const ranges = [_]modal.HxRange{ + const pane = try p.setTestFile("Newcol\nNewcol\n"); + const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 6 }, .{ .anchor = 7, .head = 13 }, }; - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); try std.testing.expectEqual(@as(u8, 1), pane.nsel); const before = p.ncol; @@ -775,13 +520,12 @@ test "selection pipe replaces all ranges atomically and undo restores them" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("aa bb cc\n"); - const pl = try p.paneCursorLines(pane); - const ranges = [_]modal.HxRange{ + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 2 }, .{ .anchor = 6, .head = 8 }, }; - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); @@ -792,15 +536,17 @@ test "selection pipe replaces all ranges atomically and undo restores them" { try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes); try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes); - // `AA\n` for a selection that was just `aa`: helix takes a trailing newline - // back off when the input did not have one, which is what keeps a one-line - // `| tr a-z A-Z` from becoming two lines. The empty output for `cc` deletes - // it outright, which is a filter's ordinary right. + var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.scratch.deinit(); + p.scratch = .init(denied.allocator()); const outputs: []const []const u8 = &.{ "AA\n", "" }; p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); try std.testing.expectEqual(@as(u8, 1), pane.nsel); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expectEqual(@as(i32, 6), pane.sels[0].col); + try std.testing.expect(!denied.has_induced_failure); p.update(.{ .key = .{ .cp = 'u' } }); try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content); @@ -829,9 +575,8 @@ test "the four shell behaviours put their output where helix puts it" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("aa bb cc\n"); - const pl = try p.paneCursorLines(pane); - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true); + const pane = try p.setTestFile("aa bb cc\n"); + pane.setRanges(pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true); p.update(.{ .key = case.key }); for ("cmd") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); @@ -847,13 +592,12 @@ test "a command with no stdin runs once and every cursor gets that one answer" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("aa bb cc\n"); - const pl = try p.paneCursorLines(pane); - const ranges = [_]modal.HxRange{ + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 2 }, .{ .anchor = 6, .head = 8 }, }; - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); p.update(.{ .key = .{ .cp = '!' } }); // insert-output: no stdin for ("date") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); @@ -861,15 +605,12 @@ test "a command with no stdin runs once and every cursor gets that one answer" { const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; - // ONE invocation for two cursors, with nothing on its stdin — helix's - // `shell_output` cache. Two invocations of `date` could disagree, and ten - // cursors would mean ten forks to produce one answer. try std.testing.expectEqual(@as(usize, 1), request.inputs.len); try std.testing.expectEqualSlices(u8, "", request.inputs[0].bytes); p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = &.{"T"} } }); try std.testing.expectEqualSlices(u8, "Taa bb Tcc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); } test "selection pipe failure and stale completion never mutate the file" { @@ -877,7 +618,7 @@ test "selection pipe failure and stale completion never mutate the file" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("abc\n"); + const pane = try p.setTestFile("abc\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -887,16 +628,16 @@ test "selection pipe failure and stale completion never mutate the file" { const failed_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; p.update(.{ .pipe_resp = .{ .id = failed_id, .success = false, .outputs = &.{} } }); try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const stale_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n")); p.update(.{ .pipe_resp = .{ .id = stale_id, .success = true, .outputs = &.{"ABC"} } }); try std.testing.expectEqualSlices(u8, "changed\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); } test "a failed filter opens an errors buffer carrying the command's own words" { @@ -904,7 +645,7 @@ test "a failed filter opens an errors buffer carrying the command's own words" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("abc\n"); + const pane = try p.setTestFile("abc\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -925,13 +666,10 @@ test "a failed filter opens an errors buffer carrying the command's own words" { // The text is untouched — a failed filter is not an edit... try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); // ...and the cursor did not go anywhere, so `|` again edits the same file. try std.testing.expectEqual(@as(usize, 0), p.active); - // ...but the reason is now READABLE, in an +Errors buffer: the command as - // typed, what became of it, and what the shell said. Every one of those - // three used to be dropped on the floor. var found: ?[]const u8 = null; for (p.panes) |slot| { const q = slot orelse continue; @@ -950,7 +688,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - var pane = try p.hxOpenFileContent("old\n"); + var pane = try p.setTestFile("old\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -958,10 +696,10 @@ test "selection pipe rejects a reused pane slot and a superseded request" { p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const replaced_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - pane = try p.hxOpenFileContent("new\n"); // same slot, different serial + pane = try p.setTestFile("new\n"); // same slot, different serial p.update(.{ .pipe_resp = .{ .id = replaced_id, .success = true, .outputs = &.{"OLD"} } }); try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -979,7 +717,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" { try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); p.update(.{ .pipe_resp = .{ .id = latest_id, .success = true, .outputs = &.{"NEW"} } }); try std.testing.expectEqualSlices(u8, "NEW\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); } test "selection pipe binding is file-normal-only" { @@ -987,15 +725,15 @@ test "selection pipe binding is file-normal-only" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("x"); + const pane = try p.setTestFile("x"); pane.file.?.output = .{ .from = .search }; p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(!pane.hasPipePrompt()); + try std.testing.expect(pane.prompt != .pipe); pane.file.?.output = null; pane.mode = .insert; p.update(.{ .key = .{ .cp = '|', .text = "|" } }); - try std.testing.expect(!pane.hasPipePrompt()); + try std.testing.expect(pane.prompt != .pipe); try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content); } @@ -1004,7 +742,7 @@ test "insert newline adds one indent level after a closing call" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent(" callback({})\n"); + const pane = try p.setTestFile(" callback({})\n"); pane.mode = .insert; pane.cur_col = 16; @@ -1027,9 +765,6 @@ test "startup config runs builtin lines in order and isolates bad lines" { }); defer p.deinit(); - // The last valid command wins even after unknown, malformed, and - // well-formed-but-failing lines. `Kill trailing-garbage` must not be - // accepted as Kill, nor fall through to the shell from config. try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); @@ -1133,7 +868,7 @@ test "runtime theme changes animate chrome and retarget without a jump" { try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expectEqual(midflight, p.chromeTheme().*); try std.testing.expect(p.animationActive()); - for (0..animation.transition_steps) |_| p.update(.tick); + for (0..layout.Animation.transition_steps) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); // Extra ticks are inert at the exact endpoint. @@ -1224,10 +959,6 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; - // A real middle-click on the canonical pane-tag word reaches Exec, which - // must consume New as a builtin before any write can reach the shell. - // The builtins are right-aligned (see tagGap), so the column is found in - // the rendered tag rather than assumed to be at its left edge. const rendered = try p.tagText(p.scratch.allocator(), pane); const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "New").?)) + 1; var panes_before: usize = 0; @@ -1297,12 +1028,12 @@ test "Filter is ordered after Del and toggles only its terminal pane" { defer p.deinit(); while (p.nextEffect()) |_| {} const first = p.panes[0].?; - const palette_before = first.vt.colorForXterm(.{ .palette = 1 }).?; + const palette_before = first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; try testing.expect(first.tty_filter); try testing.expect(p.executeBuiltinLine(0, "Filter")); try testing.expect(!first.tty_filter); - try testing.expect(palette_before.eql(first.vt.colorForXterm(.{ .palette = 1 }).?)); + try testing.expect(palette_before.eql(first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); const second_id = p.freeSlot().?; const second = try p.newShell(second_id, ""); @@ -1312,7 +1043,7 @@ test "Filter is ordered after Del and toggles only its terminal pane" { try testing.expect(!first.tty_filter); const doc_id = p.freeSlot().?; - const doc = try image_pane.create(p, doc_id, "/tmp/filter-inert.ppm", &.{}); + const doc = try panes.Image.create(p, doc_id, "/tmp/filter-inert.ppm", &.{}); try testing.expect(!doc.tty_filter); try testing.expect(p.executeBuiltinLine(doc_id, "Filter")); try testing.expect(!doc.tty_filter); @@ -1333,9 +1064,9 @@ test "an untouched tagline ends where its layout column's widest one does" { // directory too long to leave tag_right_pad columns free const below_id = p.freeSlot().?; const below = try p.newShell(below_id, ""); - const f = p.layoutFindTerm(p.active).?; - p.layoutInsert(f.col, f.idx + 1, below_id); - p.splitBelow(p.active, below); + const f = layout.findPane(p, p.active).?; + layout.insert(p, f.col, f.idx + 1, below_id); + layout.splitBelow(p, p.active, below); p.setCwd(below_id, "/a/deep/dir/whose/name/eats/the/right/pad/the/end/of/its/own/tagline"); p.sync(); while (p.nextEffect()) |_| {} @@ -1344,19 +1075,12 @@ test "an untouched tagline ends where its layout column's widest one does" { try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x); const above_tag = try p.tagText(p.scratch.allocator(), above); const below_tag = try p.tagText(p.scratch.allocator(), below); - // one column, so the tails end together — and past the pad, at the long - // path, which is the whole point (equal at tw - tag_right_pad would prove - // nothing: that is where both sat before) try std.testing.expectEqual(below_tag.len, above_tag.len); const tail = " Save New Newtty Del Filter"; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]); try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad); try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER); - // A voter too wide for the pane is counted AT the pane's edge, not - // dropped: dropping it is a threshold, and one column of resize either - // side of the fit would move every tagline in the column by the whole pad - // while you drag the window edge. One column in, one column out. p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} const fits = (try p.tagText(p.scratch.allocator(), above)).len; @@ -1366,17 +1090,10 @@ test "an untouched tagline ends where its layout column's widest one does" { p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} - // Touching the widest tag freezes ITS gap and must move nobody: it goes on - // voting with the end it was frozen at, however much is typed after the - // builtins. (A plain click seeds the tail, so the alternative is every - // other tagline in the column snapping left the moment you click one.) p.seedTail(below); try std.testing.expect(below.appendTag(" lots and lots of typing out here")); try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); - // A pane squeezed off the bottom is not drawn, so it stops voting and the - // column falls back to the pad — 2 rows is one tagline and no room for the - // second pane at all. p.update(.{ .resize = .{ .cols = 100, .rows = 2 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(u16, 0), p.rects[below_id].h); @@ -1400,9 +1117,6 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal)); - // The two preceding releases used the generic current default and then the - // Filter tail without Save. Both upgrade, including any saved layout - // padding. const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ try p.tagPrefix(terminal), }); @@ -1425,9 +1139,6 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); - // The first dump format included tty mode in the live prefix. Its stored - // cwd still identifies where the editable bytes begin after the mode word - // disappeared from today's tag, so a custom tail must survive that move. terminal.tag_tail_len = 0; terminal.tag_init = false; p.restoreDumpTail(terminal, .{ @@ -1463,7 +1174,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { // A savable file has a distinct old default. Save remains first after the // migration so the tag's established `:w` route is unchanged. - const file = try p.hxOpenFileContent(""); + const file = try p.setTestFile(""); const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(file), legacy_file_pane_tail, @@ -1490,10 +1201,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expect(file.tag_init); try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); - // Before renderer choices appeared in the live prefix, image dumps began - // with only `img PATH`. Their custom tails still migrate through the - // pane-specific legacy-prefix recognizer. - const image_doc = try image_pane.create(p, 1, "/tmp/legacy image.ppm", &.{}); + const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, @@ -1513,18 +1221,18 @@ test "Joincol folds the active column into its right neighbor, keeping its panes const right = p.freeSlot().?; _ = try p.newShell(right, ""); - try std.testing.expect(p.layoutSplitColumn(0, right, false)); + try std.testing.expect(layout.splitColumn(p, 0, right, false)); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current - p.joinCol(); + layout.joinCol(p); try std.testing.expectEqual(@as(usize, 1), p.ncol); - const lf = p.layoutFindTerm(0) orelse return error.LostLeftPane; - const rf = p.layoutFindTerm(right) orelse return error.LostRightPane; + const lf = layout.findPane(p, 0) orelse return error.LostLeftPane; + const rf = layout.findPane(p, right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); - p.joinCol(); // no right neighbor left: inert + layout.joinCol(p); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } @@ -1558,37 +1266,32 @@ test "an unsaved file marker sits between its path and builtins until Save" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("before\n"); + const pane = try p.setTestFile("before\n"); const clean = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, clean, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); const dirty = try p.tagText(p.scratch.allocator(), pane); - const marker_at = std.mem.indexOf(u8, dirty, "/hxcase.txt *") orelse return error.MissingDirtyMarker; + const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker; const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin; try std.testing.expect(marker_at < save_at); try std.testing.expect(p.executeBuiltinLine(0, "Save")); - // DRAINED FIRST, and the drain is the point rather than ceremony: the - // marker now clears when the write LANDS, not when the effect is queued. - // This test used to pass without it, which is exactly what was wrong — a - // save the host could not do cleared the marker anyway. No frame is - // affected, because `pump` drains before it renders. while (p.nextEffect()) |effect| p.perform(effect); const saved = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, saved, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null); - file_pane.changed(p, 0, "external\n"); + panes.File.changed(p, 0, "external\n"); const reloaded = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, reloaded, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null); // A generated output is file-shaped and Save can write it to a path, but // there is no file of its own for it to be dirty against. pane.file.?.output = .{ .from = .search }; - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); const output = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, output, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } test "unknown Exec from an image writes to a terminal in the image directory" { @@ -1619,14 +1322,11 @@ test "unknown Exec from an image writes to a terminal in the image directory" { try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); } -/// The host's tty query, as a test double: which panes a program is holding, -/// and how many times the core actually bothered to ask. The count is the -/// laziness contract — nothing but a command line about to be typed may ask. const FakeTtyQuery = struct { taken: [MAX_PANES]bool = @splat(false), asked: usize = 0, - const vtable: Host.VTable = .{ .pull_tty_taken = answer }; + const vtable: Host.VTable = .{ .tty_taken = answer }; fn install(f: *FakeTtyQuery, p: *Pardes) void { p.host = .{ .ctx = f, .vtable = &vtable }; @@ -1675,9 +1375,6 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane); // ...in the directory the command was about, which is the taken pane's own try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); - // A plain/unsupported shell has no OSC 133 B to wait for. Successful - // fork acknowledgement opens the gate and the pty itself buffers input - // until that child reads it. try std.testing.expectEqual(@as(usize, 0), sent_len); p.acknowledgeShell(dst, "/bin/sh", false); while (p.nextEffect()) |effect| switch (effect) { @@ -1695,24 +1392,25 @@ test "image dump restores source bytes renderer choices and exact custom tail" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); - p.deinitPane(p.panes[0].?); + try p.deinitPane(p.panes[0].?); p.panes[0] = null; const source = "\x00embedded image bytes\xff"; const raw = try p.image_gpa.dupe(u8, source); - const pane = image_pane.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| { + const pane = panes.Image.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| { p.image_gpa.free(raw); return err; }; - pane.image.?.petscii = true; + pane.image.?.glyph_art = true; pane.image.?.pmode = .terminal; pane.image.?.ascii = false; try std.testing.expect(pane.appendTag(" Keep Del")); pane.tag_init = true; try p.dumpState(); - const first = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); - defer dump.free(gpa, first); + var first_dump = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); + defer first_dump.deinit(); + const first = first_dump.value; try std.testing.expect(first.panes[0].image.?.petscii); try std.testing.expectEqual(dump.ImagePalette.terminal, first.panes[0].image.?.palette); try std.testing.expect(!first.panes[0].image.?.ascii); @@ -1721,7 +1419,7 @@ test "image dump restores source bytes renderer choices and exact custom tail" { const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); const restored_pane = restored.panes[0].?; - try std.testing.expect(restored_pane.image.?.petscii); + try std.testing.expect(restored_pane.image.?.glyph_art); try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode); try std.testing.expect(!restored_pane.image.?.ascii); try std.testing.expect(restored_pane.tag_init); @@ -1729,8 +1427,9 @@ test "image dump restores source bytes renderer choices and exact custom tail" { try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw); try restored.dumpState(); - const redump = try dump.readZon(gpa, restored.dump_out.?, "image-redump"); - defer dump.free(gpa, redump); + var parsed = try dump.readZon(gpa, restored.dump_out.?, "image-redump"); + defer parsed.deinit(); + const redump = parsed.value; const encoded = redump.panes[0].image.?.bytes_b64; const decoded = try dump.decodeBytes(gpa, encoded); defer gpa.free(decoded); @@ -1742,9 +1441,6 @@ test "image dump restores source bytes renderer choices and exact custom tail" { } test "Exec from a document pane skips an occupied terminal in its directory and spawns" { - // The test above this pair ("unknown Exec from an image...") is the same - // setup with the terminal at its prompt, and it reuses pane 0. The single - // difference here is the verdict. const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); @@ -1795,16 +1491,8 @@ test "a Look on a directory does not type ls into an occupied terminal" { defer p.deinit(); while (p.nextEffect()) |_| {} - // /tmp rather than a made-up name: the look resolves against the real - // filesystem, so the directory has to exist for this arm to be reached — - // and in the spelling `resolve` HANDS BACK, which is the realpath. `/tmp` - // is itself on Linux and a symlink to `/private/tmp` on Darwin, and the - // `.dir` arm matches a pane by `cwdSlice()` against that realpath, on the - // documented invariant that pane paths are canonical (see `lookAt`). A - // literal "/tmp" therefore matched nothing on a Mac and forked a second - // terminal for a directory that already had one. var realbuf: [4096]u8 = undefined; - const tmp = look.resolve("/tmp", "/", &realbuf).dir; + const tmp = look.resolve(null, "/tmp", "/", &realbuf).dir; p.setCwd(0, tmp); var host: FakeTtyQuery = .{}; host.install(p); @@ -1885,10 +1573,6 @@ test "the host is asked about a tty only where a command line is about to go" { p.setCwd(1, "/tmp/pardes-lazy-b"); p.setCwd(2, "/tmp/pardes-lazy-c"); - // A frame is a frame: rendering, typing, moving the mouse and resizing ask - // nobody anything. This is the whole point of the query being a pull — the - // probe it runs walks /proc, and it used to run for every pane of every - // frame to answer a question only Exec and Look ever ask. _ = try p.render(frame.allocator()); p.update(.{ .key = .{ .cp = 'x' } }); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 4, .row = 4 } }); @@ -1902,10 +1586,6 @@ test "the host is asked about a tty only where a command line is about to go" { while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 1), host.asked); - // ...and the fallback scan asks only about the panes that could possibly - // answer yes: the cwd comparison is free and comes first, so the two shells - // sitting in other directories cost nothing. Pane 0 is asked a second time - // because it IS on the directory the command was about. host.taken[0] = true; host.asked = 0; _ = p.execute(0, "echo lazy-again"); @@ -1920,14 +1600,14 @@ test "New opens an empty scratch below the caller, inheriting its directory" { const source: usize = 2; // the right column; active starts in the left p.setCwd(source, "/tmp/pardes-scratch-dir"); - const source_col = p.layoutFindTerm(source).?.col; + const source_col = layout.findPane(p, source).?.col; try std.testing.expect(p.executeBuiltinLine(source, "New")); // no shell IO: the scratch is created in-core, focused, below the caller while (p.nextEffect()) |_| {} const id = p.active; try std.testing.expect(id != source); - try std.testing.expectEqual(source_col, p.layoutFindTerm(id).?.col); + try std.testing.expectEqual(source_col, layout.findPane(p, id).?.col); const np = p.panes[id].?; try std.testing.expect(np.file.?.output != null); // an output buffer, empty try std.testing.expectEqual(@as(usize, 0), np.file.?.content.len); @@ -1935,6 +1615,203 @@ test "New opens an empty scratch below the caller, inheriting its directory" { try std.testing.expectEqualStrings("/tmp/pardes-scratch-dir", Pardes.paneDir(np)); p.setCwd(source, "/tmp/pardes-moved"); try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); + try p.removePane(source); + p.sync(); + try std.testing.expect(np.cwd == .owned); + try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); + p.sync(); + try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); +} + +test "owned cwd preserves long paths aliases and failed updates" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = p.panes[0].?; + var path: [3072]u8 = @splat('d'); + path[0] = '/'; + for (1..12) |i| path[i * 256] = '/'; + p.setCwd(0, &path); + try std.testing.expectEqualStrings(&path, pane.cwdSlice()); + try std.testing.expect(pane.cwdSlice().ptr != &path); + const original = pane.cwdSlice(); + try pane.setOwnedCwd(original); + try std.testing.expectEqualStrings(&path, pane.cwdSlice()); + const effects = p.effects_len; + const free = p.freeSlot().?; + try std.testing.expectError(error.PathTooLong, p.newShell(free, path[0 .. effect_path_cap + 1])); + try std.testing.expect(p.panes[free] == null); + p.saveTo(0, "file.txt"); + try std.testing.expectEqual(effects, p.effects_len); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PathTooLong") != null); + + const current = pane.cwdSlice(); + allocator.fail_index = allocator.alloc_index; + p.setCwd(0, "/replacement"); + try std.testing.expectEqual(current.ptr, pane.cwdSlice().ptr); + try std.testing.expectEqualStrings(&path, pane.cwdSlice()); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "OutOfMemory") != null); + allocator.fail_index = std.math.maxInt(usize); + p.setCwd(0, "/replacement"); + try std.testing.expectEqualStrings("/replacement", pane.cwdSlice()); + const allocations = allocator.allocations; + p.setCwd(0, "/replacement"); + try std.testing.expectEqual(allocations, allocator.allocations); + pane.clearCwd(); + try std.testing.expect(pane.cwd == .none); + try std.testing.expectEqualStrings("", pane.cwdSlice()); +} + +test "owned cwd close copies are transactional across inherited chains" { + for (0..2) |failed_copy| { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + p.presentation.enabled = false; + const parent = try p.setTestFile("body\n"); + p.gpa.free(parent.file.?.path); + parent.file.?.path = try p.gpa.dupe(u8, "/work/project/source.zig"); + p.newScratchBelow(0); + const first_id = p.active; + const first = p.panes[first_id].?; + p.newScratchBelow(0); + const second = p.panes[p.active].?; + p.newScratchBelow(first_id); + const grandchild = p.panes[p.active].?; + p.sync(); + while (p.nextEffect()) |_| {} + p.parkPendingWrite(0, "queued"); + const pending = p.pending_write[0].?.bytes; + const columns = p.col_panes; + const counts = p.col_n; + const active = p.active; + const live_bytes = allocator.allocated_bytes - allocator.freed_bytes; + allocator.fail_index = allocator.alloc_index + failed_copy; + try std.testing.expectError(error.OutOfMemory, p.removePane(0)); + try std.testing.expectEqual(parent, p.panes[0].?); + try std.testing.expectEqual(parent, first.cwd.inherited); + try std.testing.expectEqual(parent, second.cwd.inherited); + try std.testing.expectEqual(first, grandchild.cwd.inherited); + try std.testing.expectEqualDeep(columns, p.col_panes); + try std.testing.expectEqualDeep(counts, p.col_n); + try std.testing.expectEqual(active, p.active); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + try std.testing.expectEqual(pending.ptr, p.pending_write[0].?.bytes.ptr); + try std.testing.expectEqual(live_bytes, allocator.allocated_bytes - allocator.freed_bytes); + + allocator.fail_index = std.math.maxInt(usize); + try p.removePane(0); + try std.testing.expect(p.panes[0] == null); + try std.testing.expect(first.cwd == .owned and second.cwd == .owned); + try std.testing.expectEqual(first, grandchild.cwd.inherited); + try std.testing.expect(first.cwdSlice().ptr != second.cwdSlice().ptr); + try p.removePane(first_id); + try std.testing.expect(grandchild.cwd == .owned); + p.sync(); + p.sync(); + try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(second)); + try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(grandchild)); + } +} + +test "owned cwd column close allocates every copy before removing any pane" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + p.presentation.enabled = false; + p.setCwd(0, "/first"); + p.setCwd(1, "/second"); + const first_child = try p.newDocPane(3); + const second_child = try p.newDocPane(4); + first_child.cwd = .{ .inherited = p.panes[0].? }; + second_child.cwd = .{ .inherited = p.panes[1].? }; + while (p.nextEffect()) |_| {} + const before = p.panes; + const columns = p.col_panes; + const counts = p.col_n; + allocator.fail_index = allocator.alloc_index + 1; + try std.testing.expectError(error.OutOfMemory, p.removeColumn(0)); + for (before, p.panes) |old, current| try std.testing.expectEqual(old, current); + try std.testing.expectEqualDeep(columns, p.col_panes); + try std.testing.expectEqualDeep(counts, p.col_n); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + try std.testing.expect(first_child.cwd == .inherited and second_child.cwd == .inherited); + allocator.fail_index = std.math.maxInt(usize); + try p.removeColumn(0); + try std.testing.expect(p.panes[0] == null and p.panes[1] == null); + p.sync(); + p.sync(); + try std.testing.expectEqualStrings("/first", first_child.cwdSlice()); + try std.testing.expectEqualStrings("/second", second_child.cwdSlice()); +} + +test "owned cwd EOF failure retains the pane and Del retries its close" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + p.presentation.enabled = false; + p.setCwd(0, "/exited"); + const parent = p.panes[0].?; + p.newScratchBelow(0); + const child = p.panes[p.active].?; + p.sync(); + while (p.nextEffect()) |_| {} + allocator.fail_index = allocator.alloc_index; + p.update(.{ .eof = .{ .pane = 0 } }); + try std.testing.expectEqual(parent, p.panes[0].?); + try std.testing.expectEqual(parent, child.cwd.inherited); + try std.testing.expectEqual(Pane.Mode.normal, parent.mode); + try std.testing.expect(std.mem.indexOf(u8, parent.msg[0..parent.msg_len], "Del retries close") != null); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + allocator.fail_index = std.math.maxInt(usize); + try std.testing.expect(p.executeBuiltinLine(0, "Del")); + try std.testing.expect(p.panes[0] == null); + p.sync(); + p.sync(); + try std.testing.expectEqualStrings("/exited", child.cwdSlice()); +} + +test "owned cwd restore either copies the directory or preserves the old core" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + p.setCwd(0, "/retained/terminal/directory"); + try p.dumpState(); + const original = p.panes[0].?; + var completed = false; + for (0..256) |failure| { + allocator.has_induced_failure = false; + allocator.fail_index = allocator.alloc_index + failure; + const replacement = p.restore(p.dump_out.?) catch { + try std.testing.expect(allocator.has_induced_failure); + try std.testing.expectEqual(original, p.panes[0].?); + try std.testing.expectEqualStrings("/retained/terminal/directory", original.cwdSlice()); + continue; + }; + defer replacement.deinit(); + try std.testing.expectEqualStrings("/retained/terminal/directory", replacement.panes[0].?.cwdSlice()); + try std.testing.expectEqual(original, p.panes[0].?); + if (!allocator.has_induced_failure) { + completed = true; + break; + } + } + allocator.fail_index = std.math.maxInt(usize); + try std.testing.expect(completed); +} + +test "owned cwd Save promotion releases the former directory" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + p.newScratchBelow(0); + const id = p.active; + const pane = p.panes[id].?; + try pane.setOwnedCwd("/old/directory"); + p.saveTo(id, "saved.txt"); + try std.testing.expect(pane.cwd == .none); + try std.testing.expect(pane.file.?.output == null); + try std.testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); + try std.testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane)); } test "Save on a scratch asks for a path in its inherited dir and makes it a file" { @@ -1952,7 +1829,7 @@ test "Save on a scratch asks for a path in its inherited dir and makes it a file // Save on a scratch arms a PATH input, prefilled with the inherited dir try std.testing.expect(p.executeBuiltinLine(id, "Save")); - try std.testing.expect(np.hasSavePrompt()); + try std.testing.expect(np.prompt == .save); try std.testing.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/")); // typing the filename and submitting converts it into an ordinary file @@ -1980,7 +1857,7 @@ test "Save on a terminal writes its plaintext scrollback and stays a terminal" { while (p.nextEffect()) |_| {} try std.testing.expect(p.executeBuiltinLine(0, "Save")); - try std.testing.expect(pane.hasSavePrompt()); + try std.testing.expect(pane.prompt == .save); try std.testing.expect(pane.appendTag("log.txt")); p.submitSave(0); @@ -2010,7 +1887,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" p.sync(); // the frame boundary that gives the new pane its geometry while (p.nextEffect()) |_| {} - const help: output_pane.Origin = .{ .cmd = .Help }; + const help: panes.Output.Origin = .{ .cmd = .Help }; const id = blk: { for (p.panes, 0..) |slot, i| { const pane = slot orelse continue; @@ -2029,7 +1906,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" // Save leads its tagline now, and the path is REQUIRED: a bare Save asks try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); - try std.testing.expect(out.hasSavePrompt()); + try std.testing.expect(out.prompt == .save); try std.testing.expect(out.appendTag("help.txt")); p.submitSave(id); @@ -2059,12 +1936,9 @@ test "Save takes the path as an argument, relative to the pane's own directory" p.update(.{ .output = .{ .pane = 0, .bytes = "typed and gone\r\n" } }); while (p.nextEffect()) |_| {} - // an argument answers the question the prompt would have asked, so no - // prompt arms — and a bare name lands under the pane's directory, the way - // a relative word in a look resolves, not under the process's cwd var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save session.txt")); - try std.testing.expect(!pane.hasSavePrompt()); + try std.testing.expect(pane.prompt != .save); try std.testing.expectEqualStrings( "/tmp/pardes-arg-save/session.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, @@ -2087,20 +1961,13 @@ test "Save takes the path as an argument, relative to the pane's own directory" ); try std.testing.expect(pane.isTerminal()); - // ...and a path that cannot be made absolute is refused outright: a shell - // that has not reported a directory has nothing to resolve against, and - // the directory pardes was started in is not a guess worth making - p.panes[0].?.cwd = .none; + p.panes[0].?.clearCwd(); try std.testing.expect(p.executeBuiltinLine(0, "Save nowhere.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); } test "a save the host could not do leaves the pane dirty" { const gpa = std.testing.allocator; - // A host that refuses every write: a read-only file, a directory that was - // removed under the pane, a full disk. The core cannot tell those apart and - // does not need to — the host says why on the message row, and this is the - // other half, which is that the pane must NOT come clean. const Refusing = struct { fn writeFile(ctx: ?*anyopaque, pane: u8, _: []const u8, _: []const u8) void { const core: *Pardes = @ptrCast(@alignCast(ctx.?)); @@ -2110,17 +1977,13 @@ test "a save the host could not do leaves the pane dirty" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("before\n"); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); + const pane = try p.setTestFile("before\n"); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); - p.host = .{ .ctx = p, .vtable = &.{ .push_write_file = Refusing.writeFile } }; + p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.writeFile } }; try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); - // STILL DIRTY. Until this, `saveFile` marked the pane saved the moment it - // QUEUED the effect, so the tag's ` *` cleared on a save that never - // happened — and `Del` makes no dirty check, so the next click threw the - // edits away with the screen saying they were safe. try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); // ...and the same save against a host that CAN write does come clean, so @@ -2129,7 +1992,7 @@ test "a save the host could not do leaves the pane dirty" { try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); - try std.testing.expectEqualStrings("after\n", p.fallback.get("/hxcase.txt").?); + try std.testing.expectEqualStrings("after\n", p.fallback.get("/test.txt").?); } test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { @@ -2137,15 +2000,15 @@ test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("before\n"); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); + const pane = try p.setTestFile("before\n"); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-copy/other.txt")); // a copy, never a rename: the pane keeps its file, and that file is still // unsaved, so the marker stays where it was - try std.testing.expectEqualStrings("/hxcase.txt", pane.file.?.path); + try std.testing.expectEqualStrings("/test.txt", pane.file.?.path); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); try std.testing.expectEqualStrings( "/tmp/pardes-copy/other.txt", @@ -2158,7 +2021,7 @@ test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { // its own path, spelled out, is the in-place write — nothing is asked of // the host but save_file, and the pane comes clean - try std.testing.expect(p.executeBuiltinLine(0, "Save /hxcase.txt")); + try std.testing.expect(p.executeBuiltinLine(0, "Save /test.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); } @@ -2173,7 +2036,7 @@ test "saves armed in one batch stay with their own panes" { const shell = try p.newShell(tty_id, "/tmp/pardes-batch"); p.setCwd(tty_id, "/tmp/pardes-batch"); p.update(.{ .output = .{ .pane = @intCast(tty_id), .bytes = "shell text\r\n" } }); - const file = try p.hxOpenFileContent("file text\n"); + const file = try p.setTestFile("file text\n"); const file_id = p.paneIdOf(file) orelse return error.MissingFilePane; try std.testing.expect(shell.isTerminal()); try std.testing.expect(tty_id != file_id); @@ -2227,7 +2090,7 @@ test "Save reaches every tagline with text behind it and no other" { defer p.deinit(); while (p.nextEffect()) |_| {} - const out = try p.hxOpenFileContent("build.zig:1:1 pub fn main\n"); + const out = try p.setTestFile("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); @@ -2257,10 +2120,10 @@ test "Save reaches every tagline with text behind it and no other" { // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; - const img = try image_pane.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); + const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); - try std.testing.expect(!img.hasSavePrompt()); + try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); } @@ -2278,11 +2141,8 @@ test "a save path that names no file is refused before anything is rewritten" { p.sync(); while (p.nextEffect()) |_| {} - // Enter on the bare prefill: a directory is not a file to become, and the - // scratch must still be a scratch afterwards — the alternative is a buffer - // renamed onto a path whose write silently failed try std.testing.expect(p.executeBuiltinLine(id, "Save")); - try std.testing.expect(scratch.hasSavePrompt()); + try std.testing.expect(scratch.prompt == .save); p.submitSave(id); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); @@ -2307,9 +2167,9 @@ test "a host with no methods at all is a complete in-process pardes" { // the absent child is SILENT: the bytes are dropped, and nothing appears // on the pane's screen to suggest a program answered - const before = p.panes[0].?.vt.screens.active.cursor.y; + const before = p.panes[0].?.terminal.?.vt.screens.active.cursor.y; p.perform(.{ .write = .{ .pane = 0, .bytes = .from("ls\r") } }); - try std.testing.expectEqual(before, p.panes[0].?.vt.screens.active.cursor.y); + try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y); // the clipboard round-trips through the in-process one p.yank = try gpa.dupe(u8, "copied"); @@ -2317,7 +2177,7 @@ test "a host with no methods at all is a complete in-process pardes" { try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items); // ...and a save with no filesystem lands in the virtual one - const doc = try p.hxOpenFileContent("body\n"); + const doc = try p.setTestFile("body\n"); var doc_id: u8 = 0; for (p.panes, 0..) |slot, i| if (slot == doc) { doc_id = @intCast(i); @@ -2332,69 +2192,6 @@ test "a host with no methods at all is a complete in-process pardes" { try std.testing.expect(p.quit); } -const RecordHost = struct { - gpa: std.mem.Allocator, - writes: std.ArrayListUnmanaged(u8) = .empty, - /// What this host would answer a clipboard read with, and whether it was - /// ever asked — a pull must reach exactly one host. - clipboard: []const u8 = "", - asked: usize = 0, - core: ?*Pardes = null, - - const vt: Host.VTable = .{ .push_pty_write = ptyWrite, .pull_read_clipboard = readClipboard }; - - fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { - _ = pane; - const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); - self.writes.appendSlice(self.gpa, bytes) catch {}; - } - - fn readClipboard(ctx: ?*anyopaque) void { - const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); - self.asked += 1; - self.core.?.update(.{ .paste = self.clipboard }); - } - - fn host(self: *RecordHost) Host { - return .{ .ctx = self, .vtable = &vt }; - } -}; - -test "a fan-out host reaches every wrapped host, each with its own state" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); - defer p.deinit(); - - var a: RecordHost = .{ .gpa = gpa }; - defer a.writes.deinit(gpa); - var b: RecordHost = .{ .gpa = gpa }; - defer b.writes.deinit(gpa); - - const wrapped = [_]Host{ a.host(), b.host() }; - var fan: Fanout = .init(&wrapped); - p.host = fan.host(); - p.perform(.{ .write = .{ .pane = 0, .bytes = .from("echo hi\r") } }); - - try std.testing.expectEqualStrings("echo hi\r", a.writes.items); - try std.testing.expectEqualStrings("echo hi\r", b.writes.items); - - // A PULL reaches ONE host, and the name is what says so. Fanned out, both - // would answer and the core would paste the clipboard twice for one Ctrl-V. - a.core = p; - b.core = p; - a.clipboard = "from-a"; - b.clipboard = "from-b"; - const doc = p.panes[p.active].?; - doc.mode = .normal; - p.perform(.read_clipboard); - try std.testing.expectEqual(@as(usize, 1), a.asked); - try std.testing.expectEqual(@as(usize, 0), b.asked); - - // a method NO wrapped host implements still falls back per-method - p.perform(.{ .open_link = .from("https://example.invalid") }); - try std.testing.expectEqualStrings("https://example.invalid", p.fallback.link.items); -} - test "a builtin that needs a pane reports capacity failure when every slot is full" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); @@ -2472,35 +2269,32 @@ test "Font keeps requested and acknowledged faces as separate plain state" { try std.testing.expectEqualStrings(installed[0].path, request); try std.testing.expect(p.settings.font.pending); try std.testing.expect(p.takeFontRequest() == null); - const fake_track: panel_animation.Track = .{ + const fake_track: layout.Track = .{ .serial = p.panes[p.active].?.serial, .pane = @intCast(p.active), .effect = .slide, .from = .{ .w = 20, .h = 10 }, .to = .{ .x = 10, .w = 20, .h = 10 }, }; - p.panel_tracks[p.active] = fake_track; - p.presented_panel_tracks[p.active] = fake_track; - p.panel_presentation_ready = true; + p.presentation.tracks[p.active] = fake_track; + p.presentation.shown_tracks[p.active] = fake_track; + p.presentation.acknowledged = true; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); try std.testing.expect(!p.settings.font.pending); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get()); try std.testing.expectEqual(@as(u16, 1375), p.settings.font.effective_size_hundredths); - try std.testing.expectEqual(FontSizeUnit.pixels, p.settings.font.effective_size_unit); - try std.testing.expect(p.panel_tracks[p.active] == null); - try std.testing.expect(p.panel_presentation_pending); + try std.testing.expectEqual(config.Runtime.FontSizeUnit.pixels, p.settings.font.effective_size_unit); + try std.testing.expect(p.presentation.tracks[p.active] == null); + try std.testing.expect(p.presentation.pending); try std.testing.expect(!p.acknowledgeFont("Duplicate Ack", 1400, .pixels)); - // Asking for the face already on screen is still a real host round trip, - // but it does not invalidate the frozen layer: its effective tuple did - // not change. A rejected request likewise never reaches acknowledgeFont. p.acknowledgePanelPresentation(&.{}); try std.testing.expect(p.executeBuiltinLine(p.active, cmd)); _ = p.takeFontRequest() orelse return error.MissingRepeatedFontRequest; - p.panel_tracks[p.active] = fake_track; + p.presentation.tracks[p.active] = fake_track; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); - try std.testing.expect(p.panel_tracks[p.active] != null); - try std.testing.expect(!p.panel_presentation_pending); + try std.testing.expect(p.presentation.tracks[p.active] != null); + try std.testing.expect(!p.presentation.pending); // A name nothing answers to changes neither request nor effective state. try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face")); @@ -2539,7 +2333,7 @@ test "restored sessions animate layout changes after bootstrap" { source.dump_out.?, ); defer restored.deinit(); - try std.testing.expectEqual(panel_animation.Transition.slide, restored.settings.panel_transition); + try std.testing.expectEqual(layout.Transition.slide, restored.settings.panel_transition); try std.testing.expect(!restored.animationActive()); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); @@ -2547,11 +2341,11 @@ test "restored sessions animate layout changes after bootstrap" { const initial = try restored.render(frame.allocator()); restored.acknowledgePanelPresentation(initial.panelTracks()); _ = try restored.newShell(1, ""); - try std.testing.expect(restored.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(restored, 0, 1, false)); restored.sync(); - try std.testing.expect(restored.panel_tracks[0] != null); - try std.testing.expect(restored.panel_tracks[1] != null); + try std.testing.expect(restored.presentation.tracks[0] != null); + try std.testing.expect(restored.presentation.tracks[1] != null); try std.testing.expect(restored.animationActive()); } @@ -2592,12 +2386,6 @@ test "restored terminals keep monotonic nonzero pane identities" { try std.testing.expectEqual(fresh.serial, restored.next_serial); } -/// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN -/// declarations rather than a table beside them — a new special key names -/// itself here, and a wrong name is impossible because there is only one. The -/// rest is the printable character; anything else is a compile error, because -/// a private-use codepoint cast to a byte would render as silent garbage in a -/// listing nobody diffs. fn chordName(comptime c: config.Chord) []const u8 { comptime { const mods = (if (c.ctrl) "C-" else "") ++ (if (c.alt) "A-" else "") ++ (if (c.shift) "S-" else ""); @@ -2610,18 +2398,6 @@ fn chordName(comptime c: config.Chord) []const u8 { } } -/// Every way to run `b` that is NOT its leader path: the chords and buttons -/// config binds to it, plus the topbar if it has a word up there (row 0 is a -/// click target, and the only shortcut that works in tty mode where SPC -/// belongs to the shell). Walked from config's own tables — window_keys' and -/// jump_keys' `cmd` columns, look_cmd/exec_cmd, topbar_str — so retargeting a -/// binding there re-renders here with nothing to keep in step. -/// -/// What is NOT here, and cannot be: a chord that reaches no builtin. Alt-n, -/// Alt-c, the tty toggle and the 1-2/1-3 cut/paste chords are inline handlers -/// with no word to index by. The day one of them becomes a builtin — a struct -/// in builtins.zig and a `cmd` column beside its binding — it appears here for -/// free, which is the upgrade path rather than a special case here. fn shortcutCount(comptime b: Builtin) comptime_int { var count = 0; for (config.window_keys) |wk| if (wk.cmd == b) { @@ -2644,9 +2420,6 @@ fn shortcuts(comptime b: Builtin) []const u8 { @setEvalBranchQuota(20000); var parts: [shortcutCount(b)][]const u8 = undefined; var part: usize = 0; - // both spellings of the four directional moves: the arrow is a real - // key someone presses, and the table carries it precisely so it is - // discoverable from the builtin as well as the other way round for (config.window_keys) |wk| { if (wk.cmd != b) continue; // the prefix has one spelling and this is it @@ -2661,9 +2434,6 @@ fn shortcuts(comptime b: Builtin) []const u8 { part += 1; } } - // the two acme verbs, a key and a mouse button each. Asked of the - // BINDING (look_cmd/exec_cmd point at a builtin) rather than of Look - // and Exec by name, so pointing look_cmd at Grep moves the row. if (config.look_cmd == b) { for (config.look_key) |k| { parts[part] = chordName(k); @@ -2720,21 +2490,6 @@ fn leaderKeys(comptime path: []const u8) []const u8 { } } -/// THE BUILTIN INDEX, flattened: one row per builtin — the leader path that -/// runs it (null: SPC does not reach it), every other way to run it, and the -/// Help line those are rendered into. SORTED BY PATH, so every prefix's -/// subtree is a contiguous run, which is all the two runtime readers need. The -/// matcher asks "exact hit? still a prefix of something?" and Help filters the -/// same rows by the same prefix. A node-and-pointer trie for forty -/// two-character paths would be ceremony. -/// -/// A row per BUILTIN and no longer a row per leader PATH, which is what makes -/// Help the complete index instead of a second builtin listing a superset of -/// what Help lists. A path-less builtin (Look, Exec, Theme) is a row like any -/// other and its empty key column is the information: SPC does not reach this -/// one, the last column does — or nothing does, which is also worth knowing. -/// The mid-chord filter is the SAME array read with a non-empty prefix, and it -/// drops those rows by itself because an empty path starts with nothing. const Row = struct { path: ?[]const u8, cmd: Builtin, line: []const u8 }; pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { // one pass per builtin per config table, and the insertion sort below is @@ -2751,9 +2506,6 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { } for (std.enums.values(Builtin), 0..) |b, i| { const keys = if (config.leader_path.get(b)) |path| leaderKeys(path) else ""; - // the name column is only padded when something follows it: a row - // whose builtin has no other shortcut ends at the name, so the listing - // carries no trailing whitespace const rest = shortcuts(b); const named = @tagName(b) ++ (if (rest.len == 0) "" else (" " ** (namew - @tagName(b).len)) ++ " " ++ rest); rows[i] = .{ @@ -2762,9 +2514,6 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { .line = keys ++ (" " ** (4 + keyw - keys.len)) ++ " " ++ named, }; } - // insertion sort by path: a group sorts right before what extends it, and - // a path-less builtin sorts after every path — DEL is not a path because - // leaderKey only ever stores a printable key const last = "\x7f"; for (1..rows.len) |i| { var j = i; @@ -2777,9 +2526,6 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { break :blk rows; }; -// config.topbar_str is a HAND-PICKED subset of the builtins in a fixed order, -// not a derivation — see it there for why each word is in or out. This is the -// check that a rename cannot silently rot it. comptime { @setEvalBranchQuota(20000); // one branch per string byte, behind the decl walk that folds Builtin var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); @@ -2792,9 +2538,6 @@ comptime { const WordBounds = struct { lo: usize, hi: usize }; -/// The whitespace-delimited word covering `col` in `str`. The topbar uses the -/// same bounds for pointer feedback and dispatch, so the word that lights up -/// is necessarily the word a middle click will execute. fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { if (col >= str.len or str[col] == ' ') return null; var lo: usize = col; @@ -2822,36 +2565,12 @@ const tutor_text = @embedFile("tutor.txt"); // ---- theme ---- -/// Halfway between two colors, channel by channel. Every selection tint pardes -/// paints is a mix of theme colors — the per-mouse-button ones pull the theme's -/// selection toward one of its own accents, a quarter of the way and so a mix -/// of a mix; the extra cursors' pulls it back toward the page — and renderPane -/// makes seven of those, which is six more than spelling the same three-channel -/// loop out at the call site is worth. fn mix(a: [3]u8, b: [3]u8) [3]u8 { var out: [3]u8 = undefined; for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2); return out; } -const TAG_TAIL_CAP = limits.max_tag_tail; // one editable command line; extra input is refused -const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record - -/// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own -/// default cell showing (the native-dark shape); `palette` null = let a child's -/// ANSI indices reach the host untranslated until that terminal enables its -/// theme-keyed Filter. The gutter's move box lives here too: it used to be a -/// pair of module constants, but a theme that wants to be -/// restrained has to be able to turn the accent DOWN, and the box is the one -/// piece of loud chrome on screen. The overlay/drag greys that were also -/// hardcoded turned out to be the dark theme's own scroll_track/lineno/tag_fg -/// spelled a second time, so they read those fields now instead. -/// -/// Selection is ONE pair, `sel_bg`/`sel_fg`, and the rest is arithmetic: the -/// three per-mouse-button tints and the dimmed extra cursors are mixed off it in -/// renderPane rather than named here. A theme author gets their selection colour -/// right and would get four more by accident — and every source gen_themes reads -/// names exactly one selection anyway. pub const Theme = struct { name: []const u8, bg: ?[3]u8, @@ -2872,28 +2591,12 @@ pub const Theme = struct { palette: ?[16][3]u8, }; -/// The three themes pardes ships with, in RING ORDER, which is the one thing -/// the files themselves cannot say: `helix` is index 0 and so what boots, and -/// NextColor walks from here out into the generated ones. Written here rather -/// than in a fourth file because the order IS the information — and it is -/// load-bearing, since test/snapshots/theme.snap captures the first three -/// steps of the ring by their colors. const curated = struct { pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.zig"); pub const acme = @import("themes/acme.zig"); }; -/// A zig file IS a struct, so the FILES are the list: this walks a container's -/// declarations — each one an imported theme file — and copies its `theme` -/// value into a real Theme. FIELD BY FIELD rather than by plain coercion, -/// because a theme file deliberately imports nothing (it is data, not code) and -/// zig will not coerce a whole anonymous struct into a named one; assigning one -/// field at a time puts each value in a result location that knows the type, -/// which is also what makes a missing field a compile error naming it. -/// -/// A FUNCTION and not a const for the same reason builtins.all is one: it is -/// only ever a signature to whoever walks it, never a value in its own way. fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { comptime { @setEvalBranchQuota(400000); @@ -2908,13 +2611,6 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { } } -/// The ring: ours, then every theme tools/gen_themes.zig exported out of the -/// helix and zed sources in vendor/themes (build.zig runs it and hands the -/// result over as a module). Adding one is dropping a file in there — there is -/// no list here to append to, which is the whole point of folding the files. -/// 228 of them: everything helix and zed ship, because "which of these is worth -/// having" is the user's call and not the build's. That length is why ThemeSel -/// exists — NextColor is a browse, not a way to arrive anywhere in particular. pub const themes = fold(curated) ++ fold(@import("generated_themes")); comptime { @@ -2922,13 +2618,6 @@ comptime { @compileError("runtime config theme index no longer fits u16"); } -// Two themes answering to one name is a bug: `Theme ` resolves by name -// and would silently pick whichever came first, and ThemeSel would list the -// loser as a row that does nothing. The generated half cannot collide with -// ITSELF — one file per theme, all imported into one struct, so zig's own -// redeclaration error already catches that — which leaves exactly the cross -// pair to check here, three times 225 rather than 228 squared. This is the -// check that makes helix's `acme.toml` vendored as `acme_helix.toml`. comptime { @setEvalBranchQuota(20000); const ours = fold(curated).len; @@ -2936,23 +2625,6 @@ comptime { @compileError("theme name \"" ++ c.name ++ "\" is both ours and generated; rename the vendored source"); } -/// The color roles attached to stable screen geometry. Document backgrounds, -/// syntax, terminal ANSI palettes, and PDF tint colors deliberately are not -/// here: those switch to `theme()` immediately while this small palette moves -/// between themes over a handful of display frames. -/// WHAT THE BOARD BOOTS WITH. An empty buffer is honest and useless: the three words that make -/// this board interesting take an address, and a board's address space is precisely the thing you -/// cannot guess. So the buffer is a tour of it - every address below comes from this repository -/// rather than from memory, which is why they are worth trusting: the two flash figures and the two -/// RAM ones are the linker script's own ORIGINs (`05-zig-p4/build.zig`'s MEMORY block), and the -/// peripheral bases are `DR_REG_*` from ESP-IDF's headers as `05-zig-p4/src/hal` uses them. -/// -/// Each command sits alone on its line because an argument list ends at the last argument - a -/// trailing comment would be `ExtraArgument` - so the notes go above the lines they describe. Run -/// one by putting the cursor on it, `x` to select the line, Tab to execute. -/// -/// EVERY LINE IS SHORT ENOUGH TO RENDER WHOLE, which is asserted rather than eyeballed: see the -/// test below. A tour whose lines wrap is a worse first screen than no tour. const boot_buffer = \\x selects a line, Tab runs it. 0x optional. \\ @@ -2977,12 +2649,6 @@ const boot_buffer = \\Gpio 33 ; -// Three times in this port a line in that buffer has been one or two characters too long for the -// board's 56-column grid, and every time it was found by reading the die's screen rather than by -// reading the source - which is the expensive way to find a string literal's length. The bound is -// the grid minus the line-number gutter minus a column, and the margin below it is deliberate: -// pinning the exact gutter width would make this test a restatement of the renderer instead of a -// statement about the text. test "every line of the board's boot buffer renders whole" { const cols: usize = @import("pardes_config").esp32p4_cols; var it = std.mem.splitScalar(u8, boot_buffer, '\n'); @@ -3022,18 +2688,15 @@ pub const ChromeTheme = struct { pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { var out: ChromeTheme = undefined; inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| - @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); + @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); return out; } }; -/// The fade, or its absence, decided at comptime so that `-Dtheme-animation=false` leaves -/// `ChromeTheme.interpolate` unreachable and therefore out of the binary entirely. Every call site -/// below is written against the shared interface and needs no condition of its own. const ChromeAnimation = if (theme_animation) - animation.Transition(ChromeTheme) + layout.Animation.Transition(ChromeTheme) else - animation.Immediate(ChromeTheme); + layout.Animation.Immediate(ChromeTheme); const initial_chrome = ChromeTheme.fromTheme(&themes[0]); // ---- the boundary types ---- @@ -3041,7 +2704,6 @@ const initial_chrome = ChromeTheme.fromTheme(&themes[0]); pub const Color = union(enum) { default, index: u8, rgb: [3]u8 }; pub const FontRole = enum(u8) { body, tagline }; -pub const FontSizeUnit = runtime_cfg.FontSizeUnit; pub const CellStyle = struct { fg: Color = .default, @@ -3057,9 +2719,6 @@ pub const CellStyle = struct { font_role: FontRole = .body, }; -/// One surface cell. `default = true` means "never painted this frame": the -/// shell renders it as the terminal's default cell (vaxis clear semantics). -// EFFECT_CODE_ASCII_DIFF_BEGIN pub const Cell = struct { text: [7]u8 = @splat(' '), len: u8 = 1, @@ -3070,9 +2729,6 @@ pub const Cell = struct { return c.text[0..c.len]; } - /// Equality of what a shell can actually present. Bytes past `len` are - /// scratch left by earlier graphemes and must never manufacture a panel - /// diff; an unpainted default cell likewise has no visible style/text. pub fn visuallyEqual(a: *const Cell, b: *const Cell) bool { if (a.default or b.default) return a.default and b.default; return a.len == b.len and @@ -3080,10 +2736,6 @@ pub const Cell = struct { std.meta.eql(a.style, b.style); } - /// The byte an ASCII transition may walk. Default cells are visibly - /// spaces; painted cells opt in only when their complete grapheme is one - /// printable byte. This keeps an intermediate frame valid UTF-8 and - /// prevents a style-only or multi-byte change from churning its glyph. pub fn printableAscii(c: *const Cell) ?u8 { if (c.default) return ' '; if (c.len != 1) return null; @@ -3092,17 +2744,13 @@ pub const Cell = struct { } }; -/// The semantic character part of one old/new panel-cell diff. It lives in -/// the core because every renderer must present the same byte at a given -/// frame. Backends receive the already-composed Cell; none implements this -/// walk or chooses its own punctuation/noise threshold. pub const AsciiDiff = struct { from: u8, to: u8, /// Long printable-byte walks complete in about the same time as the other /// panel effects. Extra distance is crossed by eased character skips. - pub const max_movement_frames = panel_animation.ascii_max_movement_frames; + pub const max_movement_frames = layout.ascii_max_movement_frames; pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { const from = old.printableAscii() orelse return null; @@ -3123,11 +2771,6 @@ pub const AsciiDiff = struct { return diff.movementFrames() + 1; } - /// Move through the u8 range with integer ease-in-out over exactly - /// `movementFrames` samples. A byte creeps at both ends and crosses the - /// middle of its distance in a few large skips, inside the same frame - /// count the old constant one-byte-per-frame walk took. Keeping this - /// integer-only makes every backend receive the same character. pub fn byteAt(diff: AsciiDiff, frame: u16) u8 { const movements: u32 = diff.movementFrames(); const at: u32 = @min(@as(u32, frame), movements); @@ -3135,18 +2778,10 @@ pub const AsciiDiff = struct { return if (diff.from < diff.to) diff.from + delta else diff.from - delta; } - /// `distance * smootherstep(at / movements)`, rounded, without touching - /// floating point. Endpoints are exact — zero at frame zero, the whole - /// distance at the last movement — and the curve is monotonic, so a byte - /// never walks backwards between samples. fn easedDistance(span: u8, movements: u32, at: u32) u32 { if (movements == 0 or at >= movements) return span; const n: u64 = at; const d: u64 = movements; - // Quintic smootherstep as one exact fraction: n^3 (10 d^2 + 6 n^2 - - // 15 d n) over d^5. The positive terms are summed first because - // 10 d^2 + 6 n^2 >= 15 d n for every n <= d: unsigned arithmetic must - // never see the intermediate go below zero. const shape = n * n * n * (10 * d * d + 6 * n * n - 15 * d * n); const denominator = d * d * d * d * d; return @intCast((@as(u64, span) * shape + denominator / 2) / denominator); @@ -3157,10 +2792,6 @@ pub const AsciiDiff = struct { } }; -/// One core-owned classification per cell in the frozen old/new grid. A -/// visual-only diff still matters to dissolve, but PanelAscii only walks the -/// `.ascii` case. That distinction fixes the old effect's habit of replacing -/// unchanged glyphs merely because their colour or other style changed. pub const PanelCellDiff = union(enum) { unchanged, visual, @@ -3176,7 +2807,6 @@ pub const PanelCellDiff = union(enum) { return diff != .unchanged; } }; -// EFFECT_CODE_ASCII_DIFF_END test "cell visual equality ignores dead grapheme tail bytes" { var a: Cell = .{ .default = false }; @@ -3199,9 +2829,6 @@ test "ASCII cell diffs ease long byte walks in both directions" { var high: Cell = .{ .default = false }; high.text[0] = 'F'; - // Ease-in-out inside exactly the five movement frames the old constant - // walk used: the first sample holds, the middle crosses two values at a - // time, and the endpoint is exact. const rising = AsciiDiff.between(&low, &high).?; try std.testing.expectEqual(@as(u16, 6), rising.frameCount()); try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(0)); @@ -3263,10 +2890,6 @@ test "ASCII diff classification skips stable and non-ASCII glyphs" { try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &unicode)); } -/// One generation of a pixel attachment. `serial` identifies the pane for its -/// whole lifetime; `revision` identifies pixels rendered later by that same -/// pane (for example, a different PDF page or zoom level). Backends must use -/// both: pane slots are reused, while a live pane may replace its pixels. pub const ImageCacheKey = if (pdf_enabled) struct { serial: u32, page: u32, @@ -3284,8 +2907,8 @@ pub const ImageCacheKey = if (pdf_enabled) struct { } }; -const PdfFitMode = pdf_pane.FitMode; -const PdfTintMode = pdf_pane.TintMode; +const PdfFitMode = panes.Pdf.FitMode; +const PdfTintMode = panes.Pdf.TintMode; /// Dynamic placement exists only for native PDF pages. Static image panes need /// only their pane identity, so feature-off builds carry a zero-bit payload. @@ -3302,9 +2925,6 @@ pub const NativePlacement = if (pdf_enabled) struct { pixel_offset_y: f32 = 0, } else struct {}; -/// A pixel image riding the surface: the shell transmits/places it over the -/// given cell rect (tty: Kitty graphics; SDL: alpha-blended GPU texture). -/// This is also the backend-neutral transport for rasterized PDF pages. pub const ImagePlace = struct { pane: u8, /// Pane slots are reused. This identity makes a cached GPU texture or @@ -3349,7 +2969,7 @@ test "pixel attachment cache key follows both pane lifetime and rendered revisio try std.testing.expectEqual(@as(usize, 4), @sizeOf(ImageCacheKey)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(NativePlacement)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(CellPixels)); - try std.testing.expectEqual(@as(usize, 0), @sizeOf(pdf_pane.PointerDrag)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(panes.Pdf.PointerDrag)); } } @@ -3382,23 +3002,14 @@ pub const Surface = struct { cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, - /// Pixel attachments are the exact visible set. PDFs can legally contain - /// arbitrarily short pages, so no fixed page-count array can represent a - /// viewport without occasionally dropping an intersecting page. images: []?ImagePlace = &.{}, nimages: usize = 0, - /// Active pane transitions, keyed by the stable pane serial carried in - /// each record. GUI shells evaluate these in shaders; the TTY remaps this - /// same frame's cells through its grid compositor. - panel_tracks: [MAX_PANES * 2]panel_animation.Track = undefined, + panel_tracks: [MAX_PANES * 2]layout.Track = undefined, npanel_tracks: usize = 0, - /// Frozen canonical cells from before the current content/lifecycle - /// transition, plus the core's semantic old/new classifications. Both are - /// core-owned and remain stable until every associated track has finished. previous_cells: []const Cell = &.{}, cell_diffs: []const PanelCellDiff = &.{}, - pub fn panelTracks(s: *const Surface) []const panel_animation.Track { + pub fn panelTracks(s: *const Surface) []const layout.Track { return s.panel_tracks[0..s.npanel_tracks]; } @@ -3431,34 +3042,12 @@ pub const Surface = struct { c.default = false; } - /// Print UTF-8 text into a row, no wrap, clipped to [x, x+w). Returns the - /// column after the last written cell. Wide glyphs take two cells. - /// - /// The text is NOT trusted to be valid UTF-8 — a file pane holds whatever - /// bytes are on disk (latin-1 source, an ELF opened by mistake), a path can - /// be any bytes at all, and a search row splices both. std's unchecked - /// iterator panics on a bad start byte, so decode by hand and paint one - /// U+FFFD per undecodable byte (what a terminal does). fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; const end = x + w; var i: usize = 0; while (i < text.len) { if (col >= end) break; - // ASCII FAST PATH. Printable ASCII is one byte, one cell, one column, and the general - // path below reaches that answer through a UTF-8 length, a decode, a freshly - // constructed grapheme iterator, a slice validation and a width lookup - per character. - // That made this function 26% of a keystroke when profiled in the ESP32-P4's - // configuration (40x12, no tree-sitter), which is the largest single item there. - // - // The guard on the NEXT byte is what makes it correct rather than merely fast: an ASCII - // base joins a following combining mark, ZWJ or spacing mark into ONE cluster, and every - // scalar that can do that is non-ASCII. So an ASCII byte followed by another ASCII byte - // (or by nothing) is a complete grapheme cluster on its own. Same condition - // `modal.nextGrapheme` uses, for the same reason. - // - // `\t`, `\r` and the C0 controls are excluded by the range test and keep their existing - // handling below; DEL is excluded too. { const b = text[i]; if (b >= 0x20 and b < 0x7f and (i + 1 == text.len or text[i + 1] < 0x80)) { @@ -3479,10 +3068,6 @@ pub const Surface = struct { var cp_slice: []const u8 = "\u{FFFD}"; var consumed: usize = 1; if (decoded != null) { - // A surface cell is a grapheme, not a codepoint. Keeping the - // complete cluster makes combining marks visible and keeps ZWJ, - // modifier, flag and Indic sequences in the same screen cell - // that cursor/edit math treats as one unit. var git = uucode.grapheme.utf8Iterator(text[i..]); if (git.nextGrapheme()) |g| { const candidate = text[i .. i + g.end]; @@ -3498,10 +3083,6 @@ pub const Surface = struct { i += consumed; var cp = decoded orelse 0xFFFD; if (cp == '\r') continue; - // A Surface cell is already positioned, not a terminal byte - // stream. Expand tabs here so every Surface consumer — GUI, tty, - // web, and macOS — sees the same configured run of blank cells - // instead of asking its font for a control-character glyph. if (cp == '\t') { const spaces = @min(config.tab_width, end - col); s.fill(col, y, spaces, 1, style); @@ -3517,18 +3098,7 @@ pub const Surface = struct { 1 else @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode)); - // a DOUBLE-width glyph with one column left is not drawn at all. - // Writing it puts one cell in the surface and two on the glass, and - // when that column is the screen's last the terminal wraps the tail - // onto the next row — where our own model says "space", so the diff - // render never repaints it and the smear outlives the frame. A - // blank at the edge is what every terminal does with the same - // problem. Reachable from any byte cut through wide text: hscroll's - // and soft wrap's both. if (width == 2 and col + 1 >= end) break; - // The cross-host Cell ABI has seven payload bytes. Preserve a valid - // codepoint prefix when a modern emoji cluster is longer; its full - // width and edit boundary still come from the complete cluster. var shown = cp_slice; if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) { const cap = @typeInfo(@FieldType(Cell, "text")).array.len; @@ -3608,10 +3178,6 @@ test "surface print keeps combining and wide graphemes in their display cells" { } test "the ASCII fast path in surface print paints what the general arm paints" { - // The fast path skips a UTF-8 length, a decode, a grapheme iterator, a slice validation and a - // width lookup, so it can only be judged against those: the reference below is `print` with the - // fast-path block deleted and nothing else changed. Both routes paint into equally sized - // surfaces and every cell plus the returned column must match. const ref = struct { fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; @@ -3703,11 +3269,6 @@ test "the ASCII fast path in surface print paints what the general arm paints" { } }; - // The scalars that extend an ASCII base into ONE cluster - a combining mark, a ZWJ sequence, a - // spacing mark, a variation selector - are exactly what the guard on the next byte exists for. - // Wide glyphs check the two-cell accounting either side of the fast path, and the three invalid - // sequences check that a bad start byte, a truncated tail and a bad continuation each still - // become one U+FFFD per undecodable byte instead of being swallowed. const neighbours = [_][]const u8{ "", "x", "\u{301}", "\u{200d}\u{1f680}", "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}", @@ -3738,7 +3299,7 @@ test "insert and normal modes edit complete Unicode graphemes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); - const pane = try p.hxOpenFileContent("a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "界" ++ "z"); + const pane = try p.setTestFile("a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "界" ++ "z"); pane.mode = .insert; pane.cur_col = 22; // on z, after the CJK grapheme @@ -3760,7 +3321,7 @@ test "insert and normal modes edit complete Unicode graphemes" { p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqualStrings("aλz", pane.file.?.content); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.cur_col = 3; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.normalReplaceChar(pane, 'λ'); @@ -3768,7 +3329,7 @@ test "insert and normal modes edit complete Unicode graphemes" { try std.testing.expectEqual(@as(i32, 2), pane.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.cur_col = 0; pane.vsel = .{ .active = true, .row = 0, .col = 3, .explicit = true }; p.normalReplaceChar(pane, 'λ'); @@ -3777,11 +3338,87 @@ test "insert and normal modes edit complete Unicode graphemes" { try std.testing.expectEqual(@as(i32, 2), pane.vsel.col); } +test "flat text movement and selection replay need no scratch rows" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const row_text = "one (two) λ\r\n"; + const text = try gpa.alloc(u8, 4096 * row_text.len); + defer gpa.free(text); + for (0..4096) |row| @memcpy(text[row * row_text.len ..][0..row_text.len], row_text); + const pane = try p.setTestFile(text); + _ = try panes.File.lineIndex(gpa, &pane.file.?); + var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.scratch.deinit(); + p.scratch = .init(denied.allocator()); + + pane.cur_row = 4094; + p.handleNormal(pane, .{ .cp = 'l' }); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + p.handleNormal(pane, .{ .cp = 'j' }); + try std.testing.expectEqual(@as(i32, 4095), pane.cur_row); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + p.handleNormal(pane, .{ .cp = 'h' }); + try std.testing.expectEqual(@as(i32, 0), pane.cur_col); + p.executeNormalAction(pane, .{ .textobject = .{ .char = 'w', .around = false } }); + try std.testing.expectEqual(@as(i32, 2), pane.cur_col); + try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); + + pane.vsel.active = false; + pane.cur_row = 1024; + pane.cur_col = 0; + pane.nsel = 1; + pane.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; + p.handleNormal(pane, .{ .cp = 'l' }); + p.handleNormal(pane, .{ .cp = 'j' }); + try std.testing.expectEqual(@as(u8, 1), pane.nsel); + try std.testing.expectEqual(@as(i32, 1025), pane.cur_row); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expectEqual(@as(i32, 3073), pane.sels[0].row); + try std.testing.expectEqual(@as(i32, 1), pane.sels[0].col); + try std.testing.expect(!denied.has_induced_failure); + try std.testing.expectEqual(@as(usize, 0), p.scratch.queryCapacity()); + + pane.nsel = 0; + pane.cur_col = 4; + p.executeNormalAction(pane, .match_bracket); + try std.testing.expect(denied.has_induced_failure); + try std.testing.expectEqual(@as(i32, 4), pane.cur_col); + denied.fail_index = std.math.maxInt(usize); + p.executeNormalAction(pane, .match_bracket); + try std.testing.expectEqual(@as(i32, 8), pane.cur_col); +} + +test "flat text replacement preserves a terminal fragment origin and direction" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + p.update(.{ .output = .{ .pane = 0, .bytes = "first\r\nsecond\r\nthird\r\n界a\r\n" } }); + const pane = p.panes[0].?; + pane.mode = .normal; + pane.cur_pinned = true; + pane.cur_row = 3; + pane.cur_col = 0; + pane.vsel = .{ .active = true, .row = 3, .col = 3, .explicit = true }; + p.normalReplaceChar(pane, 'λ'); + try std.testing.expectEqual(@as(i32, 3), pane.ovl.?.row); + try std.testing.expectEqualStrings("λλ", pane.ovl.?.text); + try std.testing.expectEqual(@as(i32, 3), pane.cur_row); + try std.testing.expectEqual(@as(i32, 0), pane.cur_col); + try std.testing.expectEqual(@as(i32, 3), pane.vsel.row); + try std.testing.expectEqual(@as(i32, 2), pane.vsel.col); + try std.testing.expect(pane.vsel.active and pane.vsel.explicit); + const lines = try p.paneCursorLines(pane); + try std.testing.expectEqualStrings("first", lines[0]); + try std.testing.expectEqualStrings("λλ", lines[3]); + const flat = try p.flatSurface(pane); + try std.testing.expect(std.mem.startsWith(u8, flat, "first\nsecond\nthird\nλλ\n")); +} + test "Unicode display cells map back to body and tag byte cursors" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); - const pane = try p.hxOpenFileContent("a界e\u{301}z\n"); + const pane = try p.setTestFile("a界e\u{301}z\n"); const f = &pane.file.?; p.gpa.free(f.path); f.path = try p.gpa.dupe(u8, "界e\u{301}.txt"); @@ -3822,7 +3459,7 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); - const pane = try p.hxOpenFileContent("\treturn x\n"); + const pane = try p.setTestFile("\treturn x\n"); const f = &pane.file.?; f.highlights = try p.tree_sitter_gpa.alloc(u8, f.content.len); @memset(f.highlights, @intFromEnum(syntax.Syn.none)); @@ -3848,13 +3485,9 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, 3), pane.cur_col); - // Display-column conversion must not turn a click in the blank space - // after EOL into a click on the final byte. Besides moving the modal - // cursor, that would make a no-drag Look expand the last word instead of - // remaining inert over blank space. const line = modal.lineSlice(f.content, 0); const virtual: u16 = 3; - const blank_x = text_x + @as(u16, @intCast(file_pane.displayWidth(line))) + virtual; + const blank_x = text_x + @as(u16, @intCast(panes.File.displayWidth(line))) + virtual; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = blank_x, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = blank_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, @intCast(line.len + @as(usize, virtual))), pane.cur_col); @@ -3868,7 +3501,7 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column // The immediately-adjacent EOL cell and an in-line separator are blank // too. Pointer expansion must not lean left into the preceding word. - const adjacent: i32 = @intCast(config.PREFIX_W + file_pane.displayWidth(line)); + const adjacent: i32 = @intCast(config.PREFIX_W + panes.File.displayWidth(line)); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null); const separator: i32 = @intCast(config.PREFIX_W + config.tab_width + "return".len); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null); @@ -3882,11 +3515,11 @@ test "Look hover waits without mutating the pane and input cancels it" { const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("alpha beta gamma\n"); + const pane = try p.setTestFile("alpha beta gamma\n"); while (p.nextEffect()) |_| {} const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest; _ = try p.newShell(other_id, ""); - try std.testing.expect(p.layoutSplitColumn(0, other_id, false)); + try std.testing.expect(layout.splitColumn(p, 0, other_id, false)); p.sync(); while (p.nextEffect()) |_| {} @@ -3941,9 +3574,6 @@ test "Look hover waits without mutating the pane and input cancels it" { p.update(.{ .output = .{ .pane = @intCast(other_id), .bytes = "busy\r\n" } }); try std.testing.expect(p.look_hover_preview != null); - // A real leave cannot be represented as an out-of-range motion: motion - // coordinates are deliberately clamped for drags. It also clears the - // ordinary resize-handle hint, not only this Look-specific preview. const seam_x = p.col_x[0] + p.col_w[0] - 1; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = seam_x, .row = body_y } }); _ = frame.reset(.retain_capacity); @@ -3956,9 +3586,6 @@ test "Look hover waits without mutating the pane and input cancels it" { const left_seam = try p.render(frame.allocator()); try std.testing.expect(!std.mem.eql(u8, "╎", left_seam.at(seam_x, body_y).grapheme())); - // Blank space after EOL has a pointer cell but no Look operand. Ageing it - // to completion must turn the animation clock back off without drawing a - // misleading one-cell preview. const blank_x = rect.x + config.GUTTER + config.PREFIX_W + 30; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = blank_x, .row = body_y } }); for (0..delay) |_| p.update(.tick); @@ -4020,7 +3647,7 @@ test "plain left click clears explicit modal selection" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 40, .rows = 10 }); defer p.deinit(); - const pane = try p.hxOpenFileContent("abcdef\n"); + const pane = try p.setTestFile("abcdef\n"); pane.cur_row = 0; pane.cur_col = 5; pane.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true }; @@ -4044,7 +3671,7 @@ test "plain left click clears explicit modal selection" { test "selection drag and queued release do not enter panel pending state" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 10, .tty_only = true }); defer p.deinit(); - const pane = try p.hxOpenFileContent("alpha beta\n"); + const pane = try p.setTestFile("alpha beta\n"); p.acknowledgePanelPresentation(&.{}); const rect = p.rects[0]; const x = rect.x + config.GUTTER + config.PREFIX_W + 1; @@ -4052,7 +3679,7 @@ test "selection drag and queued release do not enter panel pending state" { p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y } }); - try std.testing.expect(!p.panel_presentation_pending); + try std.testing.expect(!p.presentation.pending); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y } }); try std.testing.expect(p.drag == .none); try std.testing.expectEqual(.done, pane.sel[sel_slot].state); @@ -4066,10 +3693,6 @@ pub const Mouse = struct { kind: Kind, col: u16, row: u16, - /// Ctrl held during the click. Only the left press reads it (ctrl-click = - /// goto-definition, the one chord every editor with an LSP has); every - /// other button ignores it, because acme's button semantics are already - /// the vocabulary here and modifiers are not part of it. ctrl: bool = false, }; @@ -4078,9 +3701,6 @@ pub const Key = struct { text: []const u8 = "", ctrl: bool = false, alt: bool = false, - /// Only consulted for keys whose codepoint does NOT already carry the - /// shift (Escape and friends) — `A` is `A`, not shift-`a`, so `hit()` - /// ignores this field unless a binding explicitly asks for it. shift: bool = false, pub const enter: u21 = 0x0D; @@ -4099,42 +3719,21 @@ pub const Key = struct { pub const delete: u21 = 0xF0009; }; -/// Does this press match a binding? Every keymap test in the core goes through -/// here, so the modifier rules are written once instead of once per modifier -/// (the three is/isC/isA predicates this replaces each spelled out the same -/// comparison with a different pair of negations). -/// -/// A binding is a LIST: most have two spellings that must reach the same arm — -/// `h` and Left, `Ctrl-f` and PageDown — and the `or` chain that used to do -/// that at every call site is now one loop here. fn hit(key: Key, binding: []const config.Chord) bool { for (binding) |c| { if (key.cp != c.cp or key.ctrl != c.ctrl or key.alt != c.alt) continue; - // shift is carried in the codepoint for anything printable (`A` is - // `A`, not shift-`a`), so it is consulted ONLY when a binding asks for - // it — otherwise every letter binding would newly demand shift be up if (c.shift and !key.shift) continue; return true; } return false; } -/// `Pane.sel` is indexed by @intFromEnum(button), so this is the SELECT -/// button's slot: where a sweep lands and where the left half of every acme -/// chord reads its selection from. Named rather than the bare 0 it used to be -/// so that config.select_button is genuinely the only place the choice is -/// made. const sel_slot = @intFromEnum(config.select_button); -/// The same test for a MODAL PREFIX (`g`, `z`, `m`, `mi`, `]`...). config -/// spells those as bare codepoints rather than Chords because pardes stores -/// the codepoint itself in pane.pending until the next key completes the -/// sequence — so this is the one place the two shapes meet, and it is the same -/// comparison the stored byte gets a few lines later. fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } -fn roleBindingName(comptime role: normal_input.Role) []const u8 { +fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { return switch (role) { .prefix_goto => "goto_prefix", .prefix_view => "view_prefix", @@ -4150,18 +3749,13 @@ fn roleBindingName(comptime role: normal_input.Role) []const u8 { }; } -/// The single physical-key -> BODY-NORMAL vocabulary seam. A key may carry -/// several roles (`h` is both move-left and `gh`'s line-start); the pure -/// normal_input parser chooses among them from its explicit prefix state. -/// Both text panes and PDF panes call this exact function before adapting the -/// resulting semantic Action. -fn normalInput(key: Key) normal_input.Input { - var out: normal_input.Input = .{ +fn normalInput(key: Key) modal.Normal.Input { + var out: modal.Normal.Input = .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, }; - inline for (std.enums.values(normal_input.Role)) |role| { + inline for (std.enums.values(modal.Normal.Role)) |role| { const name = comptime roleBindingName(role); if (!@hasDecl(config, name)) @compileError("normal input role has no config binding: " ++ name); @@ -4190,51 +3784,19 @@ pub const Event = union(enum) { resize: struct { cols: u16, rows: u16, - /// Physical pixels in one grid cell, present only when native PDF - /// placement is compiled in. Defaults keep headless/core callers - /// useful and give terminals which cannot report pixels the - /// conventional 1:2 cell aspect. cell_pixels: CellPixels = .{}, }, output: struct { pane: u8, bytes: []const u8 }, eof: struct { pane: u8 }, - /// a language query the shell ran on a worker has finished. `rows` is - /// `+Search`-format text (see lsp.zig) and is borrowed for this call only, - /// exactly like `output` bytes. An id the core no longer recognises is a - /// stale answer (the pane was closed, or a newer query superseded it) and - /// is dropped. - lsp_resp: struct { id: u32, rows: []const u8 }, - /// A selection-pipe worker finished. Every output is borrowed for this - /// update only; success is atomic, so a failed/nonzero invocation carries - /// no usable outputs and changes nothing. - /// `failure` is borrowed for this update like `outputs`, and is what the - /// core turns into an `+Errors` pane. Null with `success = false` means - /// nobody ever ran it — a host with no `pull_pipe` at all. + lsp_resp: struct { id: u32, rows: ?[]const u8 }, pipe_resp: struct { id: u32, success: bool, outputs: []const []const u8, failure: ?selection_pipe.Failure = null, }, - /// a file the shell was asked to watch changed on disk; `bytes` are the - /// exact snapshot the host hashed, borrowed for this call like `output`. - /// Text panes adopt a copy; PDF panes reopen the path so MuPDF owns its - /// random-access document. A shell with no filesystem (the browser) or no - /// watcher simply never sends one — nothing in the core waits for it. file_changed: struct { pane: u8, bytes: []const u8 }, - /// Text from the SYSTEM clipboard, borrowed for this call. Two ways in, - /// one handler (applyPaste): SOLICITED, the answer to a `read_clipboard` - /// the core emitted for `SPC p` / `SPC P` / `SPC R`, which decides where - /// it lands; and UNSOLICITED — an outer terminal's bracketed paste, a - /// Cmd-V, the browser's paste event — which means `SPC p`, paste after. - /// Neither touches the default register: that is `y`'s alone (helix). paste: []const u8, - /// One builtin command line, handed to the shell by a pardes launched - /// INSIDE this one (see nested.zig) — `Look /abs/path` and nothing else - /// today. Borrowed for this call exactly like `output` bytes. It comes in - /// as an EVENT rather than a direct executeBuiltinLine call so it gets the - /// trailing sync and the ordinary effect drain: `Look` on a directory - /// emits a `.spawn` the shell has to perform. command: []const u8, /// Native shells may preserve sub-cell wheel distance in physical pixels. /// TTY button events still enter through the ordinary mouse path. @@ -4245,131 +3807,46 @@ pub const Event = union(enum) { /// this must not clamp onto and preview the final grid cell. pointer_leave, tick, - /// ONE FILESYSTEM REQUEST from a process that opened a file under the - /// acme-style control mount (src/acmefs.zig, served by src/fuse.zig). - /// `data` is borrowed for this call exactly like `output` bytes, which is - /// why this — like them — never goes through `postEvent`. The answer - /// leaves as an `Effect.fs_reply` in the same update, so the transport - /// that asked is the one that writes it back: no thread, no waiting and - /// no filesystem knowledge anywhere in here. - fs_req: acmefs.Req, + fs_req: filesystem.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 }; -/// WHAT `pty/ctl`'s `sig` VERB CAN SEND. Named rather than numeric because the -/// core is freestanding: it has no `SIGINT` to name and a number written here -/// would be one platform's number travelling to a host that may not share it. -/// Five, and deliberately not the whole of signal(7): these are the ones a -/// human at a terminal already has a key or a `kill` for, and every one of -/// them means something to a program on a tty. `sig USR1` at a shell is a -/// message to a daemon, not a terminal operation, and nothing asked for it. pub const PtySignal = enum(u8) { int, term, hup, quit, kill }; /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. +pub const WatchMode = enum { reconcile, baseline_disk }; +pub const effect_path_cap = 256; + pub const Effect = union(enum) { - spawn: struct { pane: u8, cwd: Buf(256) }, + spawn: struct { pane: u8, cwd: Buf(effect_path_cap) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, - /// Deliver a signal to whatever is on this pane's tty — `pty/ctl`'s - /// `sig INT`, i.e. the ^C a script cannot type because ^C is not a byte - /// the pty would interpret on its own. The only genuinely new capability - /// the `pty/` directory added: `spawn` and `resize_pty` above were already - /// here, so `exec` and `winsize` are those two acquiring a name. signal_pty: struct { pane: u8, sig: PtySignal }, open_link: Buf(256), /// write this pane's file content to its path; the shell reads both off /// the core (content is unbounded, effects are fixed-size values) save_file: struct { pane: u8 }, - /// Write this pane's text to a path WITHOUT converting the pane — a - /// terminal's scrollback, a results buffer's rows, a file copied elsewhere. - /// The path travels HERE, bounded exactly like a spawn's cwd, so two saves - /// armed in one batch cannot cross; the BYTES are read off the pane when - /// this is performed, the way save_file reads a file pane. `serial` is the - /// pane it was armed for: a slot freed and reused before the drain writes - /// nothing rather than another pane's text to this path. - save_text: struct { pane: u8, serial: u32, path: Buf(256) }, - /// a serialized state dump is ready in core.dump_out; write it to the - /// path dump.outPath resolves (acme-style: another instance loads it - /// with -l, or the Restore builtin loads it into this one) + save_text: struct { pane: u8, serial: u32, path: Buf(effect_path_cap) }, write_dump, - /// mirror the yank register OUT to the system clipboard; the shell reads - /// it off the core (OSC 52 out, SDL_SetClipboardText, NSPasteboard). - /// Emitted ONLY by the explicit clipboard commands — see setClipboard. set_clipboard, - /// ...and the other direction: ask the shell to READ the system clipboard. - /// The answer comes back as an ordinary `Event.paste`, which the core - /// routes to whichever of `SPC p` / `SPC P` / `SPC R` asked for it - /// (Pardes.clip_pending). No payload: the bytes travel in the event. read_clipboard, - /// answer a language query OFF the event loop and post the rows back as an - /// `lsp_resp` Event. The shell reads the file's path and content off the - /// core (like save_file) and must SNAPSHOT them before the worker starts — - /// the core keeps editing while this is in flight. lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) }, /// Snapshot the matching request with pipeRequest(id), then run it away /// from the UI/event loop and answer with pipe_resp. pipe: struct { id: u32 }, - /// start (`on`) or stop watching this pane's file on disk. Starting, the - /// shell reads the path off the core exactly like save_file does; stopping - /// carries nothing, because by the time an `off` is drained the pane is - /// already freed — the shell remembers what it watches per pane id. - /// Real text files and PDFs ask for this; an output buffer has no file - /// behind it. - watch: struct { pane: u8, on: bool }, - /// Load/unload the one runtime theme file watch. The path lives in the - /// core's fixed request buffer; carrying only its generation keeps this - /// already-large effect ring compact. + watch: struct { pane: u8, on: bool, mode: WatchMode = .reconcile }, theme_file: struct { generation: u32, on: bool }, /// Write the build-time theme ring below the per-user config directory. /// The pane receives the completion/error message from the native host. dump_themes: struct { pane: u8 }, - /// The answer to an `Event.fs_req`. The bytes are NOT in here: `payload` - /// says where they live (a staging buffer in the core, or a range of a - /// pane's live text) and `fsPayload` resolves it during the drain, so a - /// megabyte read costs one `writev` and no copy. `.again` means the core - /// has nothing yet and the transport must ask again later — acme's - /// 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`. + fs_reply: filesystem.Reply, 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, @@ -4392,22 +3869,6 @@ pub const Effect = union(enum) { } }; -pub const Mode = enum { normal, insert, tty }; - -/// An owned editable buffer and the absolute surface row of its first line. -/// Files use row zero; terminal overlays may begin anywhere in scrollback. -pub const EditText = struct { text: []u8, row0: i32 }; - -/// One mouse selection (block-shaped), per button. c/r are text-area relative; -/// r counts from the tag row (body starts at BOX_H). -pub const Sel = struct { - state: enum { none, dragging, done } = .none, - c0: i32 = 0, - c1: i32 = 0, - r0: i32 = 0, - r1: i32 = 0, -}; - const LookHoverWait = struct { col: u16, row: u16, @@ -4421,15 +3882,7 @@ const LookHoverPreview = struct { row: u16, pane: usize, serial: u32, - /// Null when the operand is the pane's modal selection. The renderer uses - /// that marker to paint the live modal range subtly even while tag/search - /// editing would normally hide it. A kept mouse selection carries its - /// exact screen-space range; an ordinary word carries the click expansion. - sel: ?Sel, - /// A wrapped file word is one logical source span, not one rectangular - /// screen selection. Keeping it in source coordinates lets the renderer - /// paint every visible continuation while Look receives the exact same - /// bytes, including a path which crosses a soft-wrap boundary. + sel: ?Pane.Sel, file_word: ?FileWordSpan = null, }; @@ -4444,7 +3897,7 @@ const PdfWordPreview = if (pdf_enabled) struct { row: u16, pane: usize, serial: u32, - probe: pdf_pane.WordProbe, + probe: panes.Pdf.WordProbe, fn deinit(preview: *@This(), gpa: std.mem.Allocator) void { preview.probe.deinit(gpa); @@ -4452,576 +3905,11 @@ const PdfWordPreview = if (pdf_enabled) struct { } } else void; -/// A modal line selection (helix `x`): whole rows [r0, r1], absolute. -pub const LineSel = struct { - active: bool = false, - r0: i32 = 0, - r1: i32 = 0, -}; - -/// A modal char-range selection: the anchor lives here, the head is the pane -/// cursor. Since the helix motion model landed, EVERY motion leaves one of -/// these — `explicit` separates user-intent selections (v / x / X / terminal -/// n/N / file-search n/N) from bare motion residue: the acme Enter/Tab chords -/// only act on explicit ones. Mutually exclusive with LineSel. -pub const CharSel = struct { - active: bool = false, - row: i32 = 0, - col: i32 = 0, - explicit: bool = false, -}; - -/// One position in the n/N walk: a look-able span on one row of one pane, -/// inclusive of both columns. Also what the walk REMEMBERS having stood on -/// (Pane.look_at) — see lookStand for why the cursor alone cannot say. -pub const LookSpot = struct { - row: i32, - col0: i32, - col1: i32, -}; - -/// ponytail: at most this many cursors at once. helix's `Selection.ranges` is -/// an unbounded Vec; a fixed array keeps a Pane trivially copyable (the undo -/// snapshots memcpy it) and costs nothing at one cursor. The ceiling only -/// bites on `C`/`Alt-s` over a very long selection, where the extra ranges are -/// simply not created — raise the bound if that ever matters. -pub const MAX_SELS = 64; - -/// One selection range in PANE coordinates: the block-cursor cell and the -/// anchor cell. Deliberately the same pair `cur_row`/`cur_col` + `vsel` -/// already are, so a range moves in and out of the primary slot without a -/// conversion. -pub const SelRange = struct { - row: i32, - col: i32, - arow: i32, - acol: i32, - /// this range's own j/k goal column (helix Range::old_visual_position); - /// the PRIMARY's copy is Pane.sticky_col - sticky: i32 = -1, -}; - -const PdfSlot = if (pdf_enabled) ?pdf_pane.State else void; - -/// The emulator's raw-byte dump/replay ring, and NOTHING where there is no pty -/// to read bytes from — same shape as `PdfSlot`, for a much harder reason. It -/// is a MEGABYTE inline in every Pane: on the P4 the whole heap is 384 KiB, so -/// carrying it would make `gpa.create(Pane)` fail before anything could ask -/// for a grid, and `Pardes.init` — which creates a pane unconditionally — -/// could not return. -const ReplaySlot = if (terminal_panes) [TTY_REPLAY_CAP]u8 else void; - -/// The staging buffer for the query replies ghostty computes. Its only writer -/// is term_pane's `ptyReport` callback, which does not exist without an -/// emulator, so `reply_len` there is permanently 0 and `sync` never reads it. -const ReplySlot = if (terminal_panes) [256]u8 else void; - -fn hasPdf(pane: *const Pane) bool { - return if (comptime pdf_enabled) pane.pdf != null else false; -} - -fn hasPdfSelection(pane: *const Pane) bool { - return if (comptime pdf_enabled) - if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false - else - false; -} - -const Prompt = union(enum) { - none, - search: u16, - pipe: u16, - /// Save on a scratch buffer or a terminal: the tail is a path to write to. - save: u16, -}; - -pub const Pane = struct { - /// A pane's working directory. `.inherited` is a live `*Pane` link kept - /// valid by deferred teardown (see PaneAllocator) + reapPanes' fixup. - pub const Cwd = union(enum) { none, inherited: *Pane, owned: []const u8 }; - vt: term_pane.VtSlot, - stream: term_pane.StreamSlot, - /// The same allocator Pardes holds. A pane already owns heap (its content, - /// its emulator, its undo stacks) and Pardes frees all of it; this is here - /// so the pane methods that need the file's LINE INDEX — scrollBy and - /// ensureCursorVisible — can build it. The alternative was - /// threading an allocator through ensureCursorVisible's 33 call sites. - gpa: std.mem.Allocator, - /// WHICH pane this is, for anything that outlives the pane: slots are - /// REUSED (freeSlot hands back the lowest free one), so a remembered id - /// alone can silently come to mean an unrelated pane. Handed out by - /// Pardes.next_serial and never reused. The one reader is the jump stack. - serial: u32 = 0, - mode: Mode = .normal, - vweight: f32 = 1, - cols: u16, - rows: u16, - greet: bool = false, - pending_command: term_pane.PendingCommand = .{}, - file: ?file_pane.State = null, - image: ?image_pane.State = null, - pdf: PdfSlot = if (pdf_enabled) null else {}, - msel: LineSel = .{}, - vsel: CharSel = .{}, - /// MULTIPLE CURSORS. helix's Selection is a list of ranges plus a primary - /// index; pardes keeps the PRIMARY exactly where it has always been — - /// cur_row/cur_col + vsel — and the other ranges here, document-ordered - /// and disjoint (helix's Selection::normalize). That split is the whole - /// design: every motion, operator, renderer and mouse path in this file - /// still reads one selection, so with `nsel == 0` not a byte of behaviour - /// moves, and the 800 differential cases and 66 snapshots keep proving it. - /// The extra ranges are driven by replaying the single-selection key - /// handler once per range (see replaySels). - sels: [MAX_SELS - 1]SelRange = undefined, - nsel: u8 = 0, - /// helix select/extend mode (`v`): motions extend the selection from its - /// fixed anchor instead of replacing it. Reported as mode "select"; - /// pane.mode stays .normal (insert/tty transitions drop it). - select: bool = false, - /// sticky goal column for j/k runs (helix old_visual_position): any - /// non-vertical range write resets it to -1. - sticky_col: i32 = -1, - /// an `a` append session's original block-cursor cell: Esc backs the - /// cursor up one grapheme and rebuilds the appended-over selection from - /// here (helix doc.restore_cursor). Null outside `a` sessions. - append_at: ?struct { row: i32, col: i32 } = null, - /// Compact storage for normal_input.State's match sub-prefix. - pending2: u21 = 0, - /// Compact storage for normal_input.State's `mr` held char. - pending_ch: u21 = 0, - /// Compact storage for normal_input.State's count (0 = none). - count: u32 = 0, - /// last f/F/t/T motion, for Alt-. repeat - find_op: u8 = 0, - find_ch: u21 = 0, - /// Compact storage for normal_input.State's typed prefix. - pending: u21 = 0, - /// Tag-tail input state. The tag text is presentation; this tag carries - /// which operation owns it and the tail offset restored on submit/cancel. - prompt: Prompt = .none, - /// WHICH of helix's shell commands armed `prompt.pipe`. Beside the prompt - /// rather than inside it because `promptAt` reads all three prompt kinds - /// through one prong, and a payload here would have split that. - pipe_how: normal_input.PipeBehavior = .replace, - search_pane: ?usize = null, - search_row: ?usize = null, - /// Where n/N last stood in this pane, or null when the walk has not been - /// here. Distinct from `search_row`, which points into the RESULTS BUFFER - /// a search armed on this pane; this one is a place in the pane's own - /// text, and n/N step it in every kind of pane. - look_at: ?LookSpot = null, - /// The selection an `s`/`S` input was armed on, as gap offsets over the - /// motion surface. Every keystroke re-derives the preview FROM here rather - /// than from the previous preview — which is what helix's regex_prompt - /// does (it reverts to its snapshot before each update), what makes typing - /// a pattern one character at a time land on the same answer as pasting it - /// whole, and what makes Esc a plain restore with nothing else to undo. - /// `nsel_snap == 0` means no such input is armed; exitTagEdit, the one - /// place the prompt is cleared, clears it too. - sel_snap: [MAX_SELS]modal.HxRange = undefined, - nsel_snap: u8 = 0, - sel_snap_pri: u8 = 0, - /// ...including whether it was a user-intent selection: a preview IS one - /// (you picked those matches), but restoring must not silently promote - /// motion residue into something the acme chords will act on - sel_snap_expl: bool = false, - /// the editable tag tail: a bounded one-line command buffer. Input that - /// does not fit is refused atomically. - tag_tail: [TAG_TAIL_CAP]u8 = undefined, - tag_tail_len: usize = 0, - tag_init: bool = false, - tag_edit: bool = false, - tag_sel: bool = false, - /// the body mode a tag edit hijacked (tags are always insert); terminals - /// restore it on exit so clicking the tag never changes the pane's mode - tag_mode: Mode = .normal, - /// THE tag coordinate space: UTF-8 byte offsets into the WHOLE rendered - /// tag, prefix ++ tail (tagText), always on grapheme boundaries. Motions - /// and edits use these offsets; rendering and pointer input convert at the - /// screen boundary. The prefix is live chrome, so it is selectable, - /// yankable and executable but READ-ONLY: every edit op measures from - /// `edit0` (= tagPrefix().len, the first editable byte) and does nothing - /// left of it. - tag_col: u16 = 0, - tag_anchor: u16 = 0, - ed_undo: [term_pane.history_max]term_pane.Snapshot = undefined, - ed_undo_len: usize = 0, - ed_redo: [term_pane.history_max]term_pane.Snapshot = undefined, - ed_redo_len: usize = 0, - /// Working directory: shell-reported bytes (.owned, in cwd_buf), a live - /// link to the pane it was opened from (.inherited), or unknown (.none). - /// The inherited pointer is kept valid by deferred pane teardown + fixup. - cwd: Cwd = .none, - cwd_buf: [limits.cwd_buf_cap]u8 = undefined, - /// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines, - /// or the terminal's shell rows with its edit buffer standing in). Tracks - /// the shell cursor until pinned by a click or a key. - cur_pinned: bool = false, - cur_row: i32 = 0, - cur_col: i32 = 0, - /// horizontal scroll, file panes only (terminals wrap at pty width, they - /// never have wider lines): content columns hidden left of the gutter. - /// No scrollbar — the wheel and cursor movement (with scrolloff) drive it, - /// the goal is just being able to read long lines. Byte columns, like the - /// rest of the file-pane code. - hscroll: i32 = 0, - /// THE WRAP MAP, and the whole of what soft line breaks are: for every body - /// row of the LAST frame, the document line it showed and the byte column - /// of that line the row started at. `wrap_n == 0` says the body was NOT - /// wrapped, i.e. the rows are the lines from `scroll()` down one each — - /// which is exactly what wrapAt/wrapRow below fall back to, so with the - /// toggle off not one reader computes anything it did not compute before. - /// - /// INVALIDATION, the part that rots if nobody says it out loud: written in - /// EXACTLY ONE PLACE, file_pane.bodyText, on every build of a file pane's - /// body. So it is at worst one frame old — which is what a mouse click - /// wants (you click the character you can SEE), and it is fresh for the - /// render passes, every one of which runs after bodyText inside the same - /// renderPane call. Nothing else may write it; a second writer is a second - /// truth, and the first click on a stale row is how you find out. - /// - /// ponytail: a fixed `limits.wrap_rows` rows (256; 128 on the board). A pane - /// taller than that does not wrap at all — bodyText leaves wrap_n at 0 and - /// clips the way it always did — rather than half-recording a mapping - /// every site here would then have to distrust. `wrapWidth` derives that - /// refusal from `wrap_line.len` itself, so the bound follows the array. - /// Grow the arrays the day a taller window turns up. - wrap_line: [limits.wrap_rows]i32 = undefined, - wrap_col: [limits.wrap_rows]i32 = undefined, - wrap_n: u16 = 0, - sel: [3]Sel = @splat(.{}), - /// terminals only: the typed-text buffer standing in for shell rows - ovl: ?term_pane.EditBuffer = null, - /// every raw pty byte, in order — a bounded dump/replay ring. Once full, - /// new output evicts the oldest bytes while the live terminal still sees - /// every byte. A megabyte, inline: see `ReplaySlot`. - tty_stream: ReplaySlot = if (terminal_panes) undefined else {}, - tty_stream_head: usize = 0, - tty_stream_len: usize = 0, - /// Terminal-only, pane-local presentation mode. Ghostty remains the owner - /// of the unmodified VT palette and dynamic OSC colours; the renderer - /// projects them through the active Pardes theme when this is set. - tty_filter: bool = false, - /// query replies ghostty computed (DSR, DA, kitty); the stream handler has - /// no path to the effect queue, so they land here and sync() drains them - /// into write effects. Bounded: replies are tiny escape sequences. - reply: ReplySlot = if (terminal_panes) undefined else {}, - reply_len: u16 = 0, - /// THE TRANSIENT MESSAGE: what just happened to this pane, drawn on its - /// LAST row until the next key or mouse event wipes it (see update). Fixed - /// and inline like `reply` above — a message is one short line, so a pane - /// that never sees one still costs nothing to carry it, and there is no - /// allocation to fail at the moment something is trying to be reported. - /// Written in exactly one place, Pardes.setMessage, by a SHELL. - msg: [256]u8 = undefined, - msg_len: u16 = 0, - - fn tagSlice(p: *const Pane) []const u8 { - return p.tag_tail[0..p.tag_tail_len]; - } - fn promptAt(p: *const Pane) ?u16 { - return switch (p.prompt) { - .none => null, - .search, .pipe, .save => |at| at, - }; - } - - fn hasSearchPrompt(p: *const Pane) bool { - return switch (p.prompt) { - .search => true, - else => false, - }; - } - - fn hasPipePrompt(p: *const Pane) bool { - return switch (p.prompt) { - .pipe => true, - else => false, - }; - } - - fn hasSavePrompt(p: *const Pane) bool { - return switch (p.prompt) { - .save => true, - else => false, - }; - } - - fn appendTag(p: *Pane, text: []const u8) bool { - if (text.len > p.tag_tail.len - p.tag_tail_len) return false; - @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); - p.tag_tail_len += text.len; - return true; - } - - fn insertTagByte(p: *Pane, at: usize, byte: u8) bool { - if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false; - std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]); - p.tag_tail[at] = byte; - p.tag_tail_len += 1; - return true; - } - - fn removeTagByte(p: *Pane, at: usize) void { - if (at >= p.tag_tail_len) return; - std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]); - p.tag_tail_len -= 1; - } - - pub fn cwdSlice(p: *const Pane) []const u8 { - return switch (p.cwd) { - .none => "", - .owned => |dir| dir, - .inherited => |src| src.cwdSlice(), - }; - } - - /// Shell-reported directory: own the bytes in cwd_buf. - pub fn setOwnedCwd(pane: *Pane, dir: []const u8) void { - const n = @min(dir.len, pane.cwd_buf.len); - @memcpy(pane.cwd_buf[0..n], dir[0..n]); - pane.cwd = .{ .owned = pane.cwd_buf[0..n] }; - } - - pub fn isTerminal(pane: *const Pane) bool { - const no_pdf = if (comptime pdf_enabled) pane.pdf == null else true; - return pane.file == null and pane.image == null and no_pdf; - } - - /// The one coloring choice keyed on what a pane IS, so the highlight - /// producer (refreshHighlights) and the render pass agree on the algorithm. - pub const ColorAlgo = enum { none, tty, source, diff, locations }; - pub fn colorAlgo(pane: *const Pane) ColorAlgo { - if (pane.isTerminal()) return .tty; - if (pane.file) |f| { - if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; - // A RENDERING, not a document: +Grep, +Search, +Lsp and their kin - // have no language of their own, and their rows quote several at - // once. Colour each row by the file its location names instead. - // Buffers with no locations in them (+Help, +Config) match nothing - // and stay plain, so this needs no table of which origins qualify. - // - // `saves` is the line between the two: a New scratch and a real - // file are output-shaped but ARE documents, with one language and - // an edit on every keystroke — they keep `.source`, which is both - // right for them and what keeps a megabyte of scratch off the - // whole-buffer pass below. - if (f.output != null and !output_pane.fileTraits(f.output).saves) return .locations; - return .source; - } - return .none; - } - - pub fn pdfPath(pane: *const Pane) ?[]const u8 { - if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path; - return null; - } - - pub fn pdfPage(pane: *const Pane) ?usize { - if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.page; - return null; - } - - /// Surface row of shell row `g`. The edit buffer's lines stand in for the - /// `rows` shell rows it covers, so everything below it slides by the - /// difference — the identity on files and on terminals nobody has typed - /// a newline into, which is why the rest of the row math can stay naive. - pub fn surfRow(pane: *const Pane, g: i32) i32 { - const o = pane.ovl orelse return g; - if (g <= o.row) return g; - const lines: i32 = @intCast(modal.lineCount(o.text)); - if (g >= o.row + o.rows) return g + lines - o.rows; - return @min(g, o.row + lines - 1); // inside the buffer: its own rows - } - - /// the inverse; every surface row inside the edit buffer maps to its anchor - pub fn gridRow(pane: *const Pane, s: i32) i32 { - const o = pane.ovl orelse return s; - if (s <= o.row) return s; - const lines: i32 = @intCast(modal.lineCount(o.text)); - if (s < o.row + lines) return o.row; - return s - lines + o.rows; - } - - /// current scroll offset: file top line, or the scrollback offset - pub fn scroll(pane: *Pane) i32 { - if (pane.file) |f| return @intCast(f.scroll); - if (comptime pdf_enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); - return pane.surfRow(term_pane.gridOffset(pane)); - } - - /// The document position a BODY ROW begins at — `vr` 0 is the first row - /// under the tag. The screen->document half of the wrap map, and the half - /// the mouse asks: a click lands on the character the user can see, which - /// is last frame's arrangement, which is what the map holds. - pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { - if (pane.wrap_n > 0 and vr >= 0 and vr < @as(i32, pane.wrap_n)) - return .{ .line = pane.wrap_line[@intCast(vr)], .at = pane.wrap_col[@intCast(vr)] }; - // unwrapped: rows ARE lines, and the byte column a row starts at is the - // horizontal scroll (always 0 on a terminal, which never has one) - return .{ .line = pane.scroll() + vr, .at = pane.hscroll }; - } - - /// ...and back: the body row `line`:`col` renders on, plus the byte column - /// that row starts at — subtract it from a document column to get a screen - /// one. `row` is -1 when the position is not on screen, which only a - /// wrapped body ever says: unwrapped the arithmetic answers for any line at - /// all and the callers' own bounds checks do the rejecting, as before. - pub fn wrapRow(pane: *Pane, line: i32, col: i32) struct { row: i32, at: i32 } { - if (pane.wrap_n == 0) return .{ .row = line - pane.scroll(), .at = pane.hscroll }; - var i: u16 = 0; - while (i < pane.wrap_n) : (i += 1) { - if (pane.wrap_line[i] != line) continue; - // the LAST row of a line owns every column past its start, so a - // cursor parked on the trailing newline still has somewhere to draw - if (i + 1 < pane.wrap_n and pane.wrap_line[i + 1] == line and col >= pane.wrap_col[i + 1]) continue; - return .{ .row = @intCast(i), .at = pane.wrap_col[i] }; - } - return .{ .row = -1, .at = 0 }; - } - - /// ponytail: `delta` is LOGICAL LINES, wrapped or not — one `j` past the - /// bottom scrolls a whole line even when that line is five screen rows, and - /// a wheel tick or a Ctrl-d page counts lines rather than rows. So a body - /// full of long lines scrolls in jumps, and the view can never sit at the - /// MIDDLE of a wrapped line. That is the ceiling the whole feature buys its - /// smallness with: wrap is render + hit-test and nothing else in the editor - /// knows about it. The upgrade is to make f.scroll a (line, row-within-line) - /// pair, which every reader of it — this, the scrollbar, the syntax window, - /// bodyText, ensureCursorVisible, the gutter click — would then have to - /// learn; do that when scrolling long lines actually annoys someone. - fn scrollBy(pane: *Pane, delta: i32) void { - if (pane.file) |*f| { - const max: i64 = @intCast(file_pane.nlines(pane.gpa, f) -| 1); - const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); - const next: usize = @intCast(n); - if (next != f.scroll) { - f.scroll = next; - f.syntax_dirty = true; - } - } else if (hasPdf(pane)) { - if (comptime pdf_enabled) { - const pv = &pane.pdf.?; - const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); - pv.text_scroll = @intCast(std.math.clamp( - @as(i64, @intCast(pv.text_scroll)) + delta, - 0, - max, - )); - } - } else { - // the vt scrolls in SHELL rows; convert through the edit buffer - const off = term_pane.gridOffset(pane); - term_pane.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); - } - } - - pub fn ensureCursorVisible(pane: *Pane) void { - // scrolloff margin, shrunk on short panes so the band stays non-empty - var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); - const off = pane.scroll(); - // A WRAPPED body shows fewer LINES than it has rows, and scrolling is - // still by line, so the bottom of the view is not off+rows-1 — a long - // line at the bottom would leave the cursor below the last row it can - // actually see. What the map is asked for is the COUNT of lines that - // fit, not which ones: this runs on every cursor move and the map is - // last FRAME's, but several keys can arrive between two renders (an - // autorepeated j, a paste) and then its absolute line numbers name a - // scroll offset that has already moved on — reading them cost a - // batched j four extra lines of scroll per keystroke. A count is - // scroll-independent, and when nothing wrapped it is exactly `rows`, - // so this whole block is a no-op on an unwrapped body and the margin - // clamp below reduces to the short-pane one above it. - // - // ponytail: last frame's line count applied to this frame's offset. It - // is exact whenever a render happened in between (the normal case) and - // an estimate mid-batch, self-correcting on the next key. The exact - // answer is to re-walk the lines from `off` accumulating wrapped - // heights — do that when a batch visibly lands the cursor off screen. - var last = off + @as(i32, pane.rows) - 1; - if (pane.wrap_n > 0) { - const lines_shown = pane.wrap_line[pane.wrap_n - 1] - pane.wrap_line[0]; - last = off + lines_shown; - margin = @min(margin, @divTrunc(@max(0, lines_shown), 2)); - } - if (pane.cur_row < off + margin) { - pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 - } else if (pane.cur_row > last - margin) { - // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); - // terminals overshoot harmlessly — the vt clamps at the live bottom - var to = pane.cur_row + margin; - if (pane.file) |*f| to = @min(to, @as(i32, @intCast(file_pane.nlines(pane.gpa, f) -| 1))); - if (comptime pdf_enabled) { - if (pane.pdf) |pv| - to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); - } - pane.scrollBy(@max(0, to - last)); - } - // the horizontal mirror, files only: keep scroll_off columns of - // context around the cursor (wheel-driven hscroll is exempt — it - // never moves the cursor, and a cursor move pulls the view back). - // A wrapped body has nothing to scroll sideways, and its hscroll is - // left ALONE rather than zeroed: turn the wrap back off and the view - // you had is still there. - if (pane.file) |f| { - if (pane.wrap_n != 0) return; - const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, config.PREFIX_W)); - const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); - const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))); - const raw_cur: usize = @intCast(@max(0, pane.cur_col)); - const raw_scroll: usize = @intCast(@max(0, pane.hscroll)); - const cur = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_cur))); - const visual_scroll = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_scroll))); - var target = visual_scroll; - if (cur < visual_scroll + hmargin) - target = @max(0, cur - hmargin) - else if (cur > visual_scroll + w - 1 - hmargin) - target = cur - (w - 1 - hmargin); - if (target != visual_scroll) pane.hscroll = @intCast(file_pane.rawAtDisplay(line, @intCast(target))); - } - } - - fn pinCursor(pane: *Pane) void { - if (pane.cur_pinned) return; - if (pane.file != null or hasPdf(pane)) { - pane.cur_row = pane.scroll(); - pane.cur_col = 0; - } else { - const cur = term_pane.gridCursor(pane); - pane.cur_row = pane.surfRow(@as(i32, cur.y) + term_pane.gridOffset(pane)); - pane.cur_col = @intCast(cur.x); - } - pane.cur_pinned = true; - } -}; - const Drag = union(enum) { none, - /// `corner` is what makes this a CORNER grab: the press landed on a cell - /// that is both this v-border and one of the two adjoining columns' own - /// h-borders, and then the one drag moves both boundaries — cur_x the - /// column pair, cur_y `corner.col`'s pane pair at index `corner.idx`. - /// null is a plain edge drag and cur_y is only carried along for the - /// preview. The drag is a `border_v` on left_col either way; only the row - /// half changes which column it belongs to. - /// - /// Both adjoining columns count, left_col FIRST. The v handle IS left_col's - /// last cell, so left_col's horizontal hint is drawn straight THROUGH it - /// and its crossing reads as a full cross; the right column's spans start - /// one cell further right, so its crossing reads as a T butting into the - /// junction. Either way the two lines meet AT the handle cell, which is - /// what makes both grabbable. - /// - /// ponytail: a corner still moves exactly TWO boundaries, never three, so - /// when BOTH columns happen to be split at the grabbed row the LEFT one - /// wins and the right column's seam is left alone — the gesture that - /// existed before is bit-for-bit unchanged. border_v: struct { left_col: usize, cur_x: u16, corner: ?struct { col: usize, idx: usize } = null, cur_y: u16 = 0 }, border_h: struct { col: usize, top_idx: usize, cur_y: u16 }, move: struct { id: usize, cur_x: u16, cur_y: u16 }, - /// a left sweep along a pane's TAG row: it drives the tag's own cursor and - /// selection (rendered-tag columns) rather than the body's block selection, - /// which is what makes a one-line tag select like a line of text tag: struct { id: usize }, /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag — /// the drag's own release is then inert @@ -5032,13 +3920,9 @@ const Drag = union(enum) { ctrl: bool = false, /// This gesture began over a usable native PDF raster. The payload is /// zero-bit when PDF support is absent. - pdf: pdf_pane.PointerDrag = .{}, + pdf: panes.Pdf.PointerDrag = .{}, }, - /// The physical button whose release balances this gesture. Layout and - /// tag drags are all left-button gestures; a text selection remembers its - /// own acme button. Deriving this from the gesture keeps multi-button - /// chords exact without a parallel held-button mask. fn button(drag: Drag) ?Mouse.Button { return switch (drag) { .none => null, @@ -5048,33 +3932,12 @@ const Drag = union(enum) { } }; -// The two border clamps, pulled out as plain arithmetic on plain numbers for -// one reason: a CORNER drag runs both of them off the same mouse position, and -// the thing that has to hold is that each one only ever looks at its own axis. -// A clamp that consulted the other axis — or a single "is this point legal" -// test over the pair — would freeze the whole gesture the moment either edge -// hit its stop, when what the hand expects is the free axis to keep tracking -// and the pinned one to sit at the wall. Being pure, they are also the piece -// worth a test; see below. - -/// Where a vertical border drag settles: `mcol` clamped so neither column of -/// the pair falls under MINW. `lx`/`lw` are the left column's x and width, -/// `rw` the right column's. Degenerate pairs (a window too narrow to hold two -/// minimums at all) pass the mouse through rather than snapping to a lie. fn clampBorderCol(lx: u16, lw: u16, rw: u16, mcol: u16) u16 { const lo = lx + config.MINW; const hi = lx +| lw +| rw -| config.MINW; return if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol; } -/// Where a horizontal border drag settles: `mrow` clamped so either pane may -/// shrink to just its tag row (BOX_H) but no further. `ay`/`ah` are the upper -/// pane's y and height, `bh` the lower pane's. -/// -/// The handle is the seam row that is a BODY row, which is the upper pane's -/// LAST row normally and — with Tagbottom, where that row is the upper pane's -/// tag — the lower pane's FIRST. That is the same seam one row further down, -/// so both walls simply move with it. fn clampBorderRow(ay: u16, ah: u16, bh: u16, mrow: u16, tag_bottom: bool) u16 { const d: u16 = if (tag_bottom) 1 else 0; const lo = ay + BOX_H - 1 + d; @@ -5091,10 +3954,6 @@ 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 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); @@ -5115,9 +3974,6 @@ test "a corner drag's two axes clamp independently" { try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0)); try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false)); - // Tagbottom moves the handle to the LOWER pane's first row, one further - // down, and both walls travel with it — either pane still bottoms out at - // its bare tag row and neither can be squeezed away try std.testing.expectEqual(row_lo + 1, clampBorderRow(ay, ah, bh, 0, true)); try std.testing.expectEqual(row_hi + 1, clampBorderRow(ay, ah, bh, 999, true)); try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, true)); @@ -5129,30 +3985,20 @@ test "a corner drag's two axes clamp independently" { try std.testing.expectEqual(@as(u16, 9), clampBorderRow(TOPBAR_H, 1, 0, 9, false)); } -/// The screen row that is the handle between column `c`'s pane pair `k` and -/// `k+1`: the upper pane's LAST body row, or with Tagbottom — where that row is -/// the upper pane's tag — the lower pane's FIRST. The one place this rule -/// lives; the h hit test, the corner search and the hover hint all read it here. fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 { - const r = p.rects[p.col_terms[c][k]]; + const r = p.rects[p.col_panes[c][k]]; return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1; } -/// The corner fixture: the classic two-column boot with a SECOND pane added to -/// the RIGHT column, so both columns have a seam of their own and the v handle -/// between them can find either. fn cornerFixture(gpa: std.mem.Allocator) !*Pardes { const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30, .shells = 3 }); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); - p.active = p.col_terms[1][0]; + p.active = p.col_panes[1][0]; p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell below it, same column p.sync(); return p; } -/// Moves column 1's own seam off column 0's, by the ordinary h-border gesture, -/// and answers the row it landed on. Both columns split at the SAME row is the -/// tie case, which is a different test. fn nudgeRightSeam(p: *Pardes, delta: i32) u16 { const from = seamRowOf(p, 1, 0); const inside = p.col_x[1] + p.col_w[1] / 2; @@ -5175,8 +4021,8 @@ test "a v-handle press at the RIGHT column's seam drags both boundaries" { try std.testing.expect(right_seam != seamRowOf(p, 0, 0)); const w0 = p.col_weight[0]; - const v_left = p.panes[p.col_terms[0][0]].?.vweight; - const v_right = p.panes[p.col_terms[1][0]].?.vweight; + const v_left = p.panes[p.col_panes[0][0]].?.vweight; + const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } }); try std.testing.expect(p.drag == .border_v); @@ -5191,8 +4037,8 @@ test "a v-handle press at the RIGHT column's seam drags both boundaries" { // both halves committed: the column pair widened, and the RIGHT column's // pane pair reweighted — while the left column's panes stayed put try std.testing.expect(p.col_weight[0] > w0); - try std.testing.expect(p.panes[p.col_terms[1][0]].?.vweight != v_right); - try std.testing.expectEqual(v_left, p.panes[p.col_terms[0][0]].?.vweight); + try std.testing.expect(p.panes[p.col_panes[1][0]].?.vweight != v_right); + try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); } test "a tie row still moves the LEFT column's pane pair only" { @@ -5207,8 +4053,8 @@ test "a tie row still moves the LEFT column's pane pair only" { try std.testing.expectEqual(left_seam, seamRowOf(p, 1, 0)); const handle = p.col_x[0] + p.col_w[0] - 1; - const v_left = p.panes[p.col_terms[0][0]].?.vweight; - const v_right = p.panes[p.col_terms[1][0]].?.vweight; + const v_left = p.panes[p.col_panes[0][0]].?.vweight; + const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = left_seam } }); const corner = p.drag.border_v.corner orelse return error.NoCorner; @@ -5217,8 +4063,8 @@ test "a tie row still moves the LEFT column's pane pair only" { p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = left_seam - 4 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = left_seam - 4 } }); p.sync(); - try std.testing.expect(p.panes[p.col_terms[0][0]].?.vweight != v_left); - try std.testing.expectEqual(v_right, p.panes[p.col_terms[1][0]].?.vweight); + try std.testing.expect(p.panes[p.col_panes[0][0]].?.vweight != v_left); + try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a v-handle press at nobody's seam is still a plain edge drag" { @@ -5233,8 +4079,8 @@ test "a v-handle press at nobody's seam is still a plain edge drag" { while (row == left_seam or row == right_seam) row += 1; const w0 = p.col_weight[0]; - const v_left = p.panes[p.col_terms[0][0]].?.vweight; - const v_right = p.panes[p.col_terms[1][0]].?.vweight; + const v_left = p.panes[p.col_panes[0][0]].?.vweight; + const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = row } }); try std.testing.expect(p.drag == .border_v); try std.testing.expect(p.drag.border_v.corner == null); @@ -5243,8 +4089,8 @@ test "a v-handle press at nobody's seam is still a plain edge drag" { p.sync(); // exactly ONE boundary moved try std.testing.expect(p.col_weight[0] > w0); - try std.testing.expectEqual(v_left, p.panes[p.col_terms[0][0]].?.vweight); - try std.testing.expectEqual(v_right, p.panes[p.col_terms[1][0]].?.vweight); + try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); + try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a RIGHT-column corner's two axes clamp independently" { @@ -5264,33 +4110,27 @@ test "a RIGHT-column corner's two axes clamp independently" { // and the mirror: below the bottom at mid-width. y parks, x tracks again p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = 999 } }); try std.testing.expectEqual(handle, p.drag.border_v.cur_x); - const a = p.rects[p.col_terms[1][0]]; - const b = p.rects[p.col_terms[1][1]]; + const a = p.rects[p.col_panes[1][0]]; + const b = p.rects[p.col_panes[1][1]]; try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.settings.tag_bottom), p.drag.border_v.cur_y); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } }); } test "a new column takes width only from the column that created it" { if (platform == .web) return; - // 124 deliberately makes the thirty-one-cell source split into unequal - // rounded halves. Independent width rounding moved the right column by a - // cell here; cumulative boundaries keep it pinned. const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(0, 2, true)); + try std.testing.expect(layout.splitColumn(p, 0, 2, true)); p.sync(); - // ids 2, 0, 1 now own 1/4, 1/4 and 1/2 of the screen. Splitting the - // middle column must consume its own thirty cells in place: the columns - // on both sides retain their exact rectangles, not merely their weights. const left_before = p.rects[2]; const right_before = p.rects[1]; _ = try p.newShell(3, ""); - try std.testing.expect(p.layoutSplitColumn(0, 3, false)); + try std.testing.expect(layout.splitColumn(p, 0, 3, false)); p.sync(); try std.testing.expectEqual(left_before.x, p.rects[2].x); @@ -5302,51 +4142,42 @@ test "a new column takes width only from the column that created it" { const narrow = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2 - 1, .rows = 10 }); defer narrow.deinit(); - try std.testing.expect(!narrow.layoutSplitColumn(0, 1, false)); + try std.testing.expect(!layout.splitColumn(narrow, 0, 1, false)); try std.testing.expectEqual(@as(usize, 1), narrow.ncol); - // Public callers may chain surgery before sync. The first split leaves - // two minimum-width columns, so the second must read the freshly-derived - // source width and refuse rather than consulting the old full-width rect. const sequential = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2, .rows = 10 }); defer sequential.deinit(); _ = try sequential.newShell(1, ""); - try std.testing.expect(sequential.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(sequential, 0, 1, false)); _ = try sequential.newShell(2, ""); - try std.testing.expect(!sequential.layoutSplitColumn(0, 2, false)); + try std.testing.expect(!layout.splitColumn(sequential, 0, 2, false)); try std.testing.expectEqual(@as(usize, 2), sequential.ncol); - // Even a deliberately coarse restored proportion divides into two usable - // columns. Rebase preserves every ratio while giving an odd numerator an - // exact half instead of rendering weight 1:2 from the value 3. const coarse = try Pardes.init(std.testing.allocator, .{ .cols = 22, .rows = 10 }); defer coarse.deinit(); coarse.col_weight[0] = 3; _ = try coarse.newShell(1, ""); - try std.testing.expect(coarse.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(coarse, 0, 1, false)); coarse.sync(); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[0]); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[1]); - // A failed rebase is transactional even when the source pane is being - // MOVED out of a stack. Previously absorb/remove ran before overflow was - // discovered, so false meant the pane had silently vanished. const extreme = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20 }); defer extreme.deinit(); _ = try extreme.newShell(1, ""); - try std.testing.expect(extreme.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(extreme, 0, 1, false)); _ = try extreme.newShell(2, ""); - extreme.layoutInsert(0, 1, 2); + layout.insert(extreme, 0, 1, 2); extreme.col_weight[0] = std.math.maxInt(u64) / 2 + 2; // odd and cannot double extreme.col_weight[1] = 1; - extreme.computeGeom(); - const terms_before = extreme.col_terms; + layout.compute(extreme); + const panes_before = extreme.col_panes; const counts_before = extreme.col_n; const weights_before = extreme.col_weight; const source_vweight = extreme.panes[0].?.vweight; const sibling_vweight = extreme.panes[2].?.vweight; - try std.testing.expect(!extreme.layoutSplitColumn(0, 0, false)); - try std.testing.expectEqual(terms_before, extreme.col_terms); + try std.testing.expect(!layout.splitColumn(extreme, 0, 0, false)); + try std.testing.expectEqual(panes_before, extreme.col_panes); try std.testing.expectEqual(counts_before, extreme.col_n); try std.testing.expectEqual(weights_before, extreme.col_weight); try std.testing.expectEqual(source_vweight, extreme.panes[0].?.vweight); @@ -5358,27 +4189,24 @@ test "Newcol refuses an unsplittable restored weight before spawning" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 200, .rows = 20 }); defer p.deinit(); - // Restore accepts each individual weight up to maxInt/6. Deleting three - // one-pane columns can legitimately coalesce four such values into an odd - // survivor above maxInt/2, which cannot be globally doubled for a split. for (1..4) |id| { _ = try p.newShell(id, ""); - try std.testing.expect(p.layoutSplitColumn(id - 1, id, false)); + try std.testing.expect(layout.splitColumn(p, id - 1, id, false)); } const restored_cap = std.math.maxInt(u64) / 6; for (0..4) |column| p.col_weight[column] = restored_cap; p.col_weight[3] -= 1; for ([_]usize{ 3, 2, 1 }) |id| { const pane = p.panes[id].?; - p.layoutRemove(id); - p.deinitPane(pane); + try p.deinitPane(pane); + layout.removePane(p, id); p.panes[id] = null; } while (p.nextEffect()) |_| {} try std.testing.expect(p.col_weight[0] > std.math.maxInt(u64) / 2); try std.testing.expect(p.col_weight[0] % 2 == 1); - try std.testing.expect(!p.layoutCanSplitColumn(0)); + try std.testing.expect(!layout.canSplitColumn(p, 0)); const panes_before = p.panes; const serial_before = p.next_serial; try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); @@ -5392,22 +4220,22 @@ test "layout commits publish finite tracks only for changed panes" { defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); const untouched = p.rects[1]; p.settings.panel_transition = .slide; _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(0, 2, true)); + try std.testing.expect(layout.splitColumn(p, 0, 2, true)); p.sync(); // Pane 1 belongs to the unrelated right column. Its exact layout stayed // fixed, so it does not receive a presentation record either. - try std.testing.expect(p.panel_tracks[1] == null); - try std.testing.expect(p.panel_tracks[0] != null); - try std.testing.expect(p.panel_tracks[2] != null); - try std.testing.expectEqual(panel_animation.Phase.moving, p.panel_tracks[0].?.phase); - try std.testing.expectEqual(panel_animation.Phase.opening, p.panel_tracks[2].?.phase); + try std.testing.expect(p.presentation.tracks[1] == null); + try std.testing.expect(p.presentation.tracks[0] != null); + try std.testing.expect(p.presentation.tracks[2] != null); + try std.testing.expectEqual(layout.Phase.moving, p.presentation.tracks[0].?.phase); + try std.testing.expectEqual(layout.Phase.opening, p.presentation.tracks[2].?.phase); try std.testing.expectEqual(untouched, p.rects[1]); try std.testing.expect(p.animationActive()); @@ -5416,7 +4244,7 @@ test "layout commits publish finite tracks only for changed panes" { const surface = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len); - for (0..panel_animation.Transition.slide.frames()) |_| p.update(.tick); + for (0..layout.Transition.slide.frames()) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); @@ -5424,13 +4252,32 @@ test "layout commits publish finite tracks only for changed panes" { p.settings.panel_transition = .zoom; p.update(.{ .resize = .{ .cols = 101, .rows = 17 } }); try std.testing.expect(!p.animationActive()); - for (p.panel_tracks) |track| try std.testing.expect(track == null); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); +} + +test "screen resize keeps its previous storage until allocation succeeds" { + var failing: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .cols = 80, .rows = 24, .tty_only = true }); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + const cells = surface.cells; + p.update(.{ .resize = .{ .cols = 81, .rows = 25 } }); + failing.fail_index = failing.alloc_index; + try std.testing.expectError(error.OutOfMemory, p.render(frame.allocator())); + try std.testing.expectEqual(cells.ptr, surface.cells.ptr); + try std.testing.expectEqual(cells.len, surface.cells.len); + try std.testing.expectEqual(@as(u16, 80), surface.cols); + try std.testing.expectEqual(@as(u16, 24), surface.rows); + failing.fail_index = std.math.maxInt(usize); + const resized = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(u16, 81), resized.cols); + try std.testing.expectEqual(@as(u16, 25), resized.rows); + try std.testing.expectEqual(@as(usize, 81 * 25), resized.cells.len); } test "vertical close samples only a canonical baseline containing that pane" { - // Deleting an opener before its first canonical frame must not animate - // unrelated cells from the older global baseline as though they belonged - // to the short-lived pane. { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); @@ -5441,15 +4288,15 @@ test "vertical close samples only a canonical baseline containing that pane" { p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); p.acknowledgePanelPresentation(opening.panelTracks()); - p.removePane(1); + try p.removePane(1); p.sync(); - try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks); + try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); } // Once the pane itself has reached a canonical acknowledged frame, that @@ -5464,19 +4311,19 @@ test "vertical close samples only a canonical baseline containing that pane" { p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - for (0..panel_animation.Transition.vertical.frames()) |_| p.update(.tick); + for (0..layout.Transition.vertical.frames()) |_| p.update(.tick); _ = frame.reset(.retain_capacity); const canonical = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len); p.acknowledgePanelPresentation(canonical.panelTracks()); const serial = p.panes[1].?.serial; - p.removePane(1); + try p.removePane(1); p.sync(); - try std.testing.expectEqual(@as(usize, 1), p.nclosing_panel_tracks); - try std.testing.expectEqual(serial, p.closing_panel_tracks[0].serial); + try std.testing.expectEqual(@as(usize, 1), p.presentation.closing_len); + try std.testing.expectEqual(serial, p.presentation.closing[0].serial); } } @@ -5490,7 +4337,7 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); @@ -5501,22 +4348,19 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch // Corrupt one cached classification to prove a later live frame derives it again // from the frozen old cells and freshly rendered new cells. - p.panel_cell_diffs[changed_index] = .unchanged; + p.presentation.diffs[changed_index] = .unchanged; _ = frame.reset(.retain_capacity); const refreshed = try p.render(frame.allocator()); try std.testing.expect(refreshed.cell_diffs[changed_index].changed()); p.acknowledgePanelPresentation(refreshed.panelTracks()); - // A second opener before the first canonical endpoint has no truthful - // single old grid. Submit this layout canonically instead of rewinding to - // the boot baseline or manufacturing a stale closing pane. _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(1, 2, false)); + try std.testing.expect(layout.splitColumn(p, 1, 2, false)); p.sync(); - try std.testing.expect(!p.panel_diff_pending and !p.panel_diff_ready); - try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks); - for (p.panel_tracks) |track| try std.testing.expect(track == null); - try std.testing.expect(p.panel_presentation_pending); + try std.testing.expect(p.presentation.diff_state == .none); + try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); + try std.testing.expect(p.presentation.pending); _ = frame.reset(.retain_capacity); const snapped = try p.render(frame.allocator()); @@ -5533,7 +4377,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" { p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const animated = try p.render(frame.allocator()); @@ -5543,7 +4387,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" { // empty record set; producers cannot resume on its next frame. p.acknowledgePanelPresentation(&.{}); try std.testing.expect(!p.animationActive()); - try std.testing.expect(!p.panel_diff_pending and !p.panel_diff_ready); + try std.testing.expect(p.presentation.diff_state == .none); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); @@ -5557,15 +4401,12 @@ test "canonical fallback and transition toggle retire unpresented tracks" { const moving = try p.render(frame.allocator()); p.acknowledgePanelPresentation(moving.panelTracks()); try std.testing.expect(moving.panelTracks().len > 0); - p.applySettingBuiltin(runtime_cfg.find("PanelZoom").?, null); - try std.testing.expectEqual(panel_animation.Transition.zoom, p.settings.panel_transition); - try std.testing.expect(p.panel_presentation_pending); - try std.testing.expect(p.presentedPointer(10, 4) == null); - for (p.panel_tracks) |track| try std.testing.expect(track == null); - - // Once canonical has replaced that sample, make another real transition. - // Re-applying the effective tagline percentage is inert, but changing it - // invalidates the frozen raster's metrics and therefore snaps the tracks. + p.applySettingBuiltin(config.Runtime.find("PanelZoom").?, null); + try std.testing.expectEqual(layout.Transition.zoom, p.settings.panel_transition); + try std.testing.expect(p.presentation.pending); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); + p.acknowledgePanelPresentation(&.{}); p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; @@ -5577,14 +4418,14 @@ test "canonical fallback and transition toggle retire unpresented tracks" { const old_tagline_percent = p.settings.font.tagline_percent; var percent_buf: [3]u8 = undefined; const same_percent = try std.fmt.bufPrint(&percent_buf, "{d}", .{old_tagline_percent}); - p.applySettingBuiltin(runtime_cfg.find("TaglineSize").?, same_percent); + p.applySettingBuiltin(config.Runtime.find("TaglineSize").?, same_percent); try std.testing.expect(p.animationActive()); const changed_percent: u8 = if (old_tagline_percent == 73) 74 else 73; const changed_text = try std.fmt.bufPrint(&percent_buf, "{d}", .{changed_percent}); - p.applySettingBuiltin(runtime_cfg.find("TaglineSize").?, changed_text); + p.applySettingBuiltin(config.Runtime.find("TaglineSize").?, changed_text); try std.testing.expectEqual(changed_percent, p.settings.font.tagline_percent); - try std.testing.expect(p.panel_presentation_pending); - for (p.panel_tracks) |track| try std.testing.expect(track == null); + try std.testing.expect(p.presentation.pending); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); } test "pointer coordinates follow presented panel geometry" { @@ -5592,7 +4433,7 @@ test "pointer coordinates follow presented panel geometry" { defer p.deinit(); const pane = p.panes[0].?; - const moving: panel_animation.Track = .{ + const moving: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -5603,13 +4444,13 @@ test "pointer coordinates follow presented panel geometry" { p.acknowledgePanelPresentation(&.{moving}); // One shared physical-to-logical map feeds all pointer gestures. The // selected sample is 26.25% across and 35% down the presented rectangle. - try std.testing.expectEqual(Pardes.PointerCell{ .col = 21, .row = 7 }, p.presentedPointer(30, 8).?); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?); // Canonical cells covered only by the not-yet-arrived target are inert. - try std.testing.expect(p.presentedPointer(2, 2) == null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null); // Unrelated screen space remains in the ordinary grid coordinate system. - try std.testing.expectEqual(Pardes.PointerCell{ .col = 100, .row = 10 }, p.presentedPointer(100, 10).?); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?); - p.acknowledgePanelPresentation(&.{panel_animation.Track{ + p.acknowledgePanelPresentation(&.{layout.Track{ .serial = pane.serial, .pane = 0, .phase = .opening, @@ -5617,11 +4458,11 @@ test "pointer coordinates follow presented panel geometry" { .from = .{ .x = 40, .y = 10, .w = 0, .h = 0 }, .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 }, }}); - try std.testing.expect(p.presentedPointer(40, 10) == null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 40, 10) == null); // Tracks paint in pane-slot order inside a phase. The later slot is the // top quad and therefore owns an overlap, even though both are moving. - const overlap = [_]panel_animation.Track{ .{ + const overlap = [_]layout.Track{ .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -5636,13 +4477,10 @@ test "pointer coordinates follow presented panel geometry" { .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 }, .to = .{ .x = 60, .y = 2, .w = 20, .h = 4 }, } }; - // Slot one is deliberately absent from the fixture, so install this - // synthetic overlap directly; the production acknowledgement rejects - // dead pane lifetimes before they can participate in input. - p.presented_panel_tracks = @splat(null); - p.presented_panel_tracks[0] = overlap[0]; - p.presented_panel_tracks[1] = overlap[1]; - try std.testing.expectEqual(Pardes.PointerCell{ .col = 65, .row = 3 }, p.presentedPointer(25, 3).?); + p.presentation.shown_tracks = @splat(null); + p.presentation.shown_tracks[0] = overlap[0]; + p.presentation.shown_tracks[1] = overlap[1]; + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?); } test "queued pointer input is inert until a changed layout is presented" { @@ -5653,42 +4491,42 @@ test "queued pointer input is inert until a changed layout is presented" { p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - try std.testing.expect(p.panel_presentation_pending); - try std.testing.expect(p.presentedPointer(10, 4) == null); + try std.testing.expect(p.presentation.pending); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); - var tracks: [MAX_PANES]panel_animation.Track = undefined; + var tracks: [MAX_PANES]layout.Track = undefined; var len: usize = 0; - for (p.panel_tracks) |maybe| if (maybe) |track| { + for (p.presentation.tracks) |maybe| if (maybe) |track| { tracks[len] = track; len += 1; }; p.acknowledgePanelPresentation(tracks[0..len]); - try std.testing.expect(!p.panel_presentation_pending); + try std.testing.expect(!p.presentation.pending); // Whether this particular opening sample exposes the chosen cell is the // transition's concern; it is no longer rejected merely as speculative. - try std.testing.expect(p.presentedPointer(99, 4) != null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 99, 4) != null); } test "back-to-back layout commits retarget from the last presented boxes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const initial = try p.render(frame.allocator()); p.acknowledgePanelPresentation(initial.panelTracks()); - const shown_a = Pardes.panelBox(p.rects[0]); + const shown_a = layout.panelBox(p.rects[0]); p.settings.panel_transition = .slide; p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; p.sync(); - const target_b = p.panel_tracks[0] orelse return error.MissingFirstRetarget; + const target_b = p.presentation.tracks[0] orelse return error.MissingFirstRetarget; try std.testing.expect(target_b.from.eql(shown_a)); // No render or acknowledgement of B: the pixels are still A. C must not @@ -5696,21 +4534,21 @@ test "back-to-back layout commits retarget from the last presented boxes" { p.col_weight[0] = column_weight_unit * 2; p.col_weight[1] = column_weight_unit; p.sync(); - const target_c = p.panel_tracks[0] orelse return error.MissingSecondRetarget; + const target_c = p.presentation.tracks[0] orelse return error.MissingSecondRetarget; try std.testing.expect(target_c.from.eql(shown_a)); - try std.testing.expect(target_c.to.eql(Pardes.panelBox(p.rects[0]))); - try std.testing.expectEqual(panel_animation.Phase.moving, target_c.phase); + try std.testing.expect(target_c.to.eql(layout.panelBox(p.rects[0]))); + try std.testing.expectEqual(layout.Phase.moving, target_c.phase); _ = frame.reset(.retain_capacity); const latest = try p.render(frame.allocator()); - var submitted: ?panel_animation.Track = null; + var submitted: ?layout.Track = null; for (latest.panelTracks()) |track| if (track.pane == 0) { submitted = track; break; }; const track = submitted orelse return error.MissingSubmittedRetarget; p.acknowledgePanelPresentation(latest.panelTracks()); - try std.testing.expect(p.presented_panel_layout[0].?.box.eql(track.visualBox())); + try std.testing.expect(p.presentation.shown[0].?.box.eql(track.visualBox())); } test "an unpresented opening pane remains in the opening paint phase" { @@ -5723,16 +4561,16 @@ test "an unpresented opening pane remains in the opening paint phase" { p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - const first = p.panel_tracks[1] orelse return error.MissingOpeningTrack; - try std.testing.expectEqual(panel_animation.Phase.opening, first.phase); + const first = p.presentation.tracks[1] orelse return error.MissingOpeningTrack; + try std.testing.expectEqual(layout.Phase.opening, first.phase); _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(1, 2, true)); + try std.testing.expect(layout.splitColumn(p, 1, 2, true)); p.sync(); - const retargeted = p.panel_tracks[1] orelse return error.MissingOpeningRetarget; - try std.testing.expectEqual(panel_animation.Phase.opening, retargeted.phase); + const retargeted = p.presentation.tracks[1] orelse return error.MissingOpeningRetarget; + try std.testing.expectEqual(layout.Phase.opening, retargeted.phase); try std.testing.expect(retargeted.from.eql(first.from)); } @@ -5805,9 +4643,6 @@ test "core composes character motion out of the new grid, not a fade" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - // Row zero is sliding in from the left screen edge: it holds real glyphs - // from two columns further right, and the cells its text has not reached - // yet keep the frozen old grid rather than a blend or a blank. const presented = try p.composeAsciiTransitions(arena.allocator(), &surface); try std.testing.expect(presented != &surface); var seen: [8]u8 = undefined; @@ -5816,7 +4651,7 @@ test "core composes character motion out of the new grid, not a fade" { try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme()); // The last active sample is the untouched canonical grid, with no copy. - surface.panel_tracks[0].frame = panel_animation.Transition.edges.frames() - 1; + surface.panel_tracks[0].frame = layout.Transition.edges.frames() - 1; try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface); } @@ -5824,24 +4659,24 @@ test "pointer rejects panel content cells which have not materialized" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - const target: panel_animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; - p.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); - @memset(p.panel_cell_diffs, .unchanged); + const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; + p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.presentation.diffs, .unchanged); for (2..6) |row| { for (4..36) |col| { // Distances on both sides of frame five prove that finished ASCII // bytes are clickable while bytes still walking are not. - p.panel_cell_diffs[row * p.screen_w + col] = .{ .ascii = .{ + p.presentation.diffs[row * p.screen_w + col] = .{ .ascii = .{ .from = ' ', .to = @intCast(' ' + (col - 4) % 12 + 1), } }; } } - p.panel_diff_ready = true; + p.presentation.diff_state = .ready; - for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| { + for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { const frame: u16 = if (effect == .dissolve) 3 else 5; - const track: panel_animation.Track = .{ + const track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .opening, @@ -5860,22 +4695,22 @@ test "pointer rejects panel content cells which have not materialized" { const col: u16 = @intCast(4 + relative_col); const row: u16 = @intCast(2 + relative_row); const visible = switch (effect) { - .dissolve => panel_animation.dissolveRevealed( + .dissolve => layout.dissolveRevealed( pane.serial, rcol, rrow, track.amount(), ), - .ascii => switch (p.panelCellDiff(col, row)) { + .ascii => switch (p.presentation.cellDiff(p.screen_w, p.screen_h, col, row)) { .ascii => |diff| diff.complete(track.frame), .unchanged, .visual => true, }, else => unreachable, }; - const mapped = p.presentedPointer(col, row); + const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row); try std.testing.expectEqual(visible, mapped != null); if (mapped) |point| - try std.testing.expectEqual(Pardes.PointerCell{ .col = col, .row = row }, point); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point); saw_visible = saw_visible or visible; saw_hidden = saw_hidden or !visible; }; @@ -5888,12 +4723,12 @@ test "unchanged content cells remain clickable through data effects" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - p.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); - @memset(p.panel_cell_diffs, .unchanged); - p.panel_diff_ready = true; - const box: panel_animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; + p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.presentation.diffs, .unchanged); + p.presentation.diff_state = .ready; + const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; - for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| { + for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { p.acknowledgePanelPresentation(&.{.{ .serial = pane.serial, .pane = 0, @@ -5902,7 +4737,7 @@ test "unchanged content cells remain clickable through data effects" { .from = box, .to = box, }}); - try std.testing.expectEqual(Pardes.PointerCell{ .col = 8, .row = 3 }, p.presentedPointer(8, 3).?); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?); } } @@ -5912,13 +4747,13 @@ test "stationary Look hover follows only acknowledged panel samples" { defer p.deinit(); const pane = p.panes[0].?; const rect = p.rects[0]; - const target: panel_animation.Box = .{ + const target: layout.Box = .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; - var track: panel_animation.Track = .{ + var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -5933,7 +4768,7 @@ test "stationary Look hover follows only acknowledged panel samples" { // Advancing the producer-side track alone cannot move the semantic cell // under a stationary pointer. track.frame = track.effect.frames() - 1; - p.panel_tracks[0] = track; + p.presentation.tracks[0] = track; p.refreshLookHoverFromRaw(); try std.testing.expectEqual(first, p.look_hover_wait.?); @@ -5963,9 +4798,6 @@ test "stationary Look hover follows only acknowledged panel samples" { try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); - // The motion intent itself survives temporary invisibility. When a later - // successfully presented sample materializes under the stationary raw - // pointer, it can begin a fresh delay without synthetic mouse motion. track.frame = track.effect.frames() - 1; p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(p.look_hover_wait != null); @@ -5975,8 +4807,8 @@ test "held drag follows acknowledged panels and balances an invisible release" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - const target = Pardes.panelBox(p.rects[0]); - var track: panel_animation.Track = .{ + const target = layout.panelBox(p.rects[0]); + var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -6000,7 +4832,7 @@ test "held drag follows acknowledged panels and balances an invisible release" { // Advancing producer state alone cannot move input. Only the sample the // host says it actually drew may remap the stationary held endpoint. track.frame = track.effect.frames() - 1; - p.panel_tracks[0] = track; + p.presentation.tracks[0] = track; try std.testing.expectEqual(first_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(first_row, pane.sel[sel_slot].r1); p.acknowledgePanelPresentation(&.{track}); @@ -6017,7 +4849,7 @@ test "held drag follows acknowledged panels and balances an invisible release" { .y = target.y + target.h * 0.5, }; p.acknowledgePanelPresentation(&.{track}); - try std.testing.expect(p.presentedPointer(raw_col, raw_row) == null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) == null); try std.testing.expectEqual(last_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(last_row, pane.sel[sel_slot].r1); @@ -6046,12 +4878,9 @@ test "repeated non-dyadic column splits preserve every unrelated boundary" { defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - // Make the pair 418/561 through the real divider path. This ratio was a - // counterexample for f32 weights: splitting the right side twice changed - // the already-created middle boundary by one cell. const handle = p.col_x[0] + p.col_w[0] - 1; const untouched = p.col_weight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } }); @@ -6065,101 +4894,43 @@ test "repeated non-dyadic column splits preserve every unrelated boundary" { try std.testing.expectEqual(@as(u16, 418), p.rects[0].w); _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(1, 2, false)); + try std.testing.expect(layout.splitColumn(p, 1, 2, false)); p.sync(); const left_before = p.rects[0]; const middle_before = p.rects[1]; _ = try p.newShell(3, ""); - try std.testing.expect(p.layoutSplitColumn(2, 3, false)); + try std.testing.expect(layout.splitColumn(p, 2, 3, false)); p.sync(); try std.testing.expectEqual(left_before, p.rects[0]); try std.testing.expectEqual(middle_before, p.rects[1]); } -pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; - -const LayoutSnapshot = struct { - serial: u32, - box: panel_animation.Box, -}; +pub const Rect = layout.Rect; pub const Options = struct { tty_only: bool = false, /// initial shell panes: 1 (default) or 3 for the classic two-column boot. /// A `file` outranks this — see there. shells: u8 = 1, - /// argv FILE (resolved absolute): boot with it as the ONLY pane, focused - /// and filling the window. It names the whole boot layout, so `shells` is - /// not consulted at all (it never was — the file arm always won). file: ?[]const u8 = null, file_line: usize = 0, - /// argv NAMED SOMETHING THAT IS NOT THERE — no file, no directory, nothing - /// `look.resolve` could make a target of. The word as the human typed it - /// and the directory they typed it in, and it boots one `+Errors` pane - /// saying so. - /// - /// A launch is not a failure worth refusing. `pardes nosuchfile` used to - /// return `BadArgs` out of `main`, which std prints as `error: BadArgs` - /// with a return trace under it — indistinguishable from a crash for a - /// typo, and it left the human with no editor at all. Outranked by `file` - /// for the same reason `file` outranks `shells`: only one of them can name - /// the boot layout, and they are never both set. - /// - /// `dir` is the LAUNCH DIRECTORY, and the pane needs it for the same - /// reasons every other pane needs one: it is where a `Grep` from that pane - /// walks, where its `Newtty` spawns a shell, and what its `Save` prefills. missing: ?struct { word: []const u8, dir: []const u8 } = null, tty_toggle: u21 = config.tty_toggle_default, /// load a dump of another instance instead of spawning shells (acme -l) load_path: ?[]const u8 = null, - /// `--nested`: run a full session even inside another pardes. The core - /// never reads it; it rides here because it is the shells that would - /// otherwise open the nested.zig socket, and this is the way argv already - /// reaches them. nested: bool = false, - /// Native main fills this with the contents of the per-user config init. - /// Keeping discovery out of the core makes constructors and web builds - /// deterministic; when present, each line is dispatched as a builtin - /// before init returns and therefore before any frontend can render. startup_config: ?[]const u8 = null, - /// SERVE ACME'S CONTROL FILESYSTEM for this session (`--fs`), and where. - /// `null` is off; `""` means "derive the mount point" (a per-session - /// directory under `$XDG_RUNTIME_DIR`); anything else is the directory - /// `--fs=` named, which scripts and the snapshot harness need because - /// they have to predict it. - /// - /// One field rather than a flag plus a path: two of those encode a state - /// ("no filesystem, mounted here") that means nothing. The core never - /// mounts anything — a mount is a host's business, and one host (the - /// browser) has no filesystem at all — but the option rides here because - /// argv already reaches the shells this way, like `nested`. - fs: ?[]const u8 = null, - /// SERVE THAT SAME TREE OVER 9P2000 on a unix socket (`--fs9`), and under - /// what name. `null` is off; `""` means "derive the socket name" (the - /// session name in a daemon, this pid anywhere else); anything else is the - /// name `--fs9=` gave, which scripts need because they have to - /// predict `$XDG_RUNTIME_DIR/pardes-9p-.sock`. - /// - /// INDEPENDENT of `fs` above: either, both or neither. They are two - /// transports onto one tree (`src/acmefs.zig`) and neither is the other's - /// prerequisite — on Linux the FUSE mount needs `fusermount3` and a - /// kernel with FUSE, and this needs neither, which is the whole reason - /// docs/9p.typ §12.4 calls them complementary rather than competing. - /// - /// Read by `detached/server.zig` and nowhere else so far: a daemon polls - /// its own listener in the one `poll(2)` it already runs, which costs it - /// no thread. The tty and GUI shells would each need their own wake for - /// it, exactly as they need one for `/dev/fuse`, and they take `fs` only. - fs9: ?[]const u8 = null, - /// Native launcher's resolved per-user `pardes` directory. Relative - /// ThemeFile operands and DumpThemes are rooted here. Null for web and - /// direct core callers which did not opt into per-user configuration. + ninep_name: []const u8 = "", + ninep_tcp: ?[]const u8 = null, + ninep_quic: ?[]const u8 = null, + ninep_identity: struct { + socket_path: []const u8 = "", + tcp_address: ?std.Io.net.IpAddress = null, + quic_address: ?std.Io.net.IpAddress = null, + } = .{}, + mounts: []const filesystem.Mount = &.{}, config_dir: ?[]const u8 = null, - /// ...and WHERE that came from, which is a separate fact: the path - /// resolves even when the file does not exist, and that is precisely the - /// case the Config builtin is asked about. Null on the web and in every - /// core test, where there is no per-user config to name. startup_config_path: ?[]const u8 = null, image_allocator: ?std.mem.Allocator = null, pdf_allocator: ?std.mem.Allocator = null, @@ -6167,20 +4938,13 @@ pub const Options = struct { /// Where each frame's Surface text is built. Hosts pass a purpose-built /// stack-fallback arena; null means the general allocator. frame_allocator: ?std.mem.Allocator = null, - /// Initial grid. Shell contract: for LIVE sessions leave these at the - /// defaults and deliver the real size as the first resize EVENT — the core - /// defers an integrated shell's greeting until after a resize AND its OSC - /// 133 B input mark (so `ls` cannot race startup and wraps to the real pane - /// width); pre-sizing here means that resize never fires and the greeting - /// never runs. Pre-size only for dump loads (nothing greets, and it avoids - /// reflowing replayed content twice). cols: u16 = 80, rows: u16 = 24, }; /// The tag-tail marker each behaviour arms with. One function so the prompt /// that is DRAWN and the command that is PARSED can never disagree. -fn pipeMarker(how: normal_input.PipeBehavior) []const u8 { +fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 { return switch (how) { .replace => config.pipe_marker, .ignore => config.pipe_marker_to, @@ -6189,9 +4953,6 @@ fn pipeMarker(how: normal_input.PipeBehavior) []const u8 { }; } -/// One line that was said on a message row. Fixed storage so the log cannot -/// fail: `setMessage` is reached from `reportError`, which is reached from -/// paths that are reporting an allocation failure. pub const LoggedMessage = struct { pub const cap = 256; text: [cap]u8 = undefined, @@ -6215,15 +4976,10 @@ const PendingPipe = struct { command: []u8, cwd: []u8, inputs: []selection_pipe.Input, - ranges: [MAX_SELS]modal.HxRange, + ranges: [Pane.max_selections]modal.Selection, primary: u8, explicit: bool, - how: normal_input.PipeBehavior, - /// How many ranges the selection had. Not always `inputs.len`: `!` and - /// `A-!` send NO stdin and run the command ONCE, so they submit a single - /// empty input and paste that one answer at every range — helix's - /// `shell_output` cache, which is why `date` at ten cursors gives ten - /// identical stamps rather than ten different ones. + how: modal.Normal.PipeBehavior, nranges: u8, fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { @@ -6244,29 +5000,18 @@ const PendingPipe = struct { } }; -/// The acme verb the core just performed, for a shell that can answer with -/// something physical. macOS taps the trackpad under the finger that asked -/// (NSHapticFeedbackManager); the SDL shell already does the same thing with a -/// gamepad — `rumble` in src/gui/deck.zig, "a brief gentle ack for -/// execute/look, not a buzz". Two verbs rather than one flag because they -/// deserve to feel different: Exec did something, Look went somewhere. pub const Haptic = enum { none, exec, look }; /// Zero-sized off macOS, the way PdfSlot is off -Dmupdf: no other shell reads /// the field, so no other shell carries it. const HapticSlot = if (platform == .macos) Haptic else void; -/// Deferred pane teardown. A dropped pane's memory outlives the frame it died -/// in: it is doomed here and actually torn down one full frame later, so any -/// `*Pane` captured that frame — an effect, another pane's inherited cwd — -/// stays valid long enough for the per-frame fixup pass to repair it. `fresh` -/// holds this frame's drops, `stale` the previous frame's, freed next reap. -pub const PaneAllocator = struct { - fresh: [2 * MAX_PANES]?*Pane = @splat(null), - stale: [2 * MAX_PANES]?*Pane = @splat(null), - - fn doom(a: *PaneAllocator, pane: *Pane) void { - for (&a.fresh) |*slot| if (slot.* == null) { +pub const RetiredPanes = struct { + current: [2 * MAX_PANES]?*Pane = @splat(null), + previous: [2 * MAX_PANES]?*Pane = @splat(null), + + fn retire(a: *RetiredPanes, pane: *Pane) void { + for (&a.current) |*slot| if (slot.* == null) { slot.* = pane; return; }; @@ -6274,12 +5019,6 @@ pub const PaneAllocator = struct { // 2*MAX_PANES cannot fill in a single frame. unreachable; } - - fn isDoomed(a: *const PaneAllocator, pane: *const Pane) bool { - for (a.fresh) |s| if (s == pane) return true; - for (a.stale) |s| if (s == pane) return true; - return false; - } }; pub const Pardes = struct { @@ -6294,193 +5033,59 @@ pub const Pardes = struct { resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), - /// Deferred teardown of dropped panes (see PaneAllocator). - pane_alloc: PaneAllocator = .{}, + reserved_slots: [MAX_PANES]bool = @splat(false), + /// Deferred teardown of dropped panes (see RetiredPanes). + retired_panes: RetiredPanes = .{}, // layout: columns own x by weight; panes own y by vweight within a column. ncol: usize = 0, - /// Fixed-point horizontal proportions. Integer sums make splitting W - /// into A+B exactly associative, so an unrelated boundary cannot move - /// through floating-point regrouping after repeated source-local splits. col_weight: [MAX_COLS]u64 = @splat(column_weight_unit), - col_terms: [MAX_COLS][MAX_PANES]usize = undefined, + col_panes: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), // derived each sync - rects: [MAX_PANES]Rect = undefined, + rects: [MAX_PANES]Rect = @splat(.{}), col_x: [MAX_COLS]u16 = undefined, col_w: [MAX_COLS]u16 = undefined, - /// Last committed layout and the finite visual tracks derived from it. - /// Both are indexed by pane slot; serial rejects slot reuse. - layout_snapshot: [MAX_PANES]?LayoutSnapshot = @splat(null), - layout_snapshot_ready: bool = false, - /// Boot/config replay is not a visible layout event, and a host resize is - /// already continuous physical motion. Those paths snap this presentation - /// cache once instead of manufacturing panel transitions. - panel_animation_enabled: bool = false, - snap_panel_layout_once: bool = false, - /// Tracks the core is preparing for the next frame, and the independent - /// tracks a backend has actually put on screen. Input must follow the - /// latter: a failed GPU submit or a delayed AppKit draw cannot make an - /// unpresented animation tick clickable. - panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null), - presented_panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null), - /// Deleted pane lifetimes cannot stay in the slot-indexed live table: a - /// slot may be reused while its old pixels are still leaving. Tombstones - /// are dense plain records and never retain a functional Pane. - closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined, - nclosing_panel_tracks: usize = 0, - presented_closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined, - npresented_closing_panel_tracks: usize = 0, - /// Last canonical grid a backend explicitly acknowledged, and the frozen - /// semantic diff from it to the first canonical frame of this transition. - /// These buffers are capacity-reused and own no pane resources. - presented_cells: []Cell = &.{}, - presented_cells_cols: u16 = 0, - presented_cells_rows: u16 = 0, - presented_cells_valid: bool = false, - /// Pane lifetimes and boxes represented by `presented_cells`. This is - /// deliberately separate from `presented_panel_layout`, which follows - /// partially animated pixels. A closing tombstone may sample the frozen - /// grid only when that grid actually contains the exact pane box. - presented_cells_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), - panel_cell_diffs: []PanelCellDiff = &.{}, - panel_diff_pending: bool = false, - panel_diff_ready: bool = false, - /// Canonical pane lifetimes/boxes captured by render, and the visual boxes - /// from the last successful acknowledgement. Retargeting reads the latter, - /// never a logical layout which may not have reached the screen yet. - submitted_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), - submitted_panel_layout_ready: bool = false, - presented_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), - /// Becomes true on the first host acknowledgement. Sans-host unit callers - /// retain direct canonical pointer semantics; a real frontend thereafter - /// gets the strict unpresented interval below. - panel_presentation_ready: bool = false, - /// A layout mutation has not reached a backend yet. Pointer gestures are - /// inert during this normally sub-frame interval; guessing frame-zero - /// geometry would make queued input address pixels nobody has presented. - panel_presentation_pending: bool = false, + presentation: layout.Presentation = .{}, active: usize = 0, - /// THE focus history: where the keyboard has BEEN, oldest first, and `jcur` - /// is the entry it is at NOW (so `jumps[jcur]` is always the live spot and - /// the entries past it are the ones Ctrl-i walks forward into). Maintained - /// in exactly one place — sync() — and read by everything that asks "where - /// was I": Back/Forward, Last, the Jumplist buffer, prevFocus when a pane - /// closes, Last, and the directory order a look resolves in. - /// - /// One list, not two. A jumplist kept beside a focus history is two things - /// to keep agreeing, and they would disagree the first time one of them - /// forgot a pane the other still names. jumps: [MAX_JUMPS]Loc = undefined, njumps: usize = 0, jcur: usize = 0, - /// THE PANES THAT HAVE LOOKED, oldest first, at most one entry each — the - /// spine n/N walks (lookWalkPanes). Serials rather than slots, for the - /// same reason the jumplist stores them: a freed slot is reused, and an - /// entry naming a dead pane must not resolve to the newcomer sitting in - /// its place. - /// - /// Not the jumplist, though it looks like one. `jumps` records where FOCUS - /// has been, and a look moves focus to what it OPENED; this records where - /// the look was made FROM, which is the pane holding the list you are - /// working through. The two answer different questions and would only - /// coincide by accident. look_src: [MAX_PANES]u32 = undefined, n_look_src: usize = 0, - /// Serial of the pane whose stream n/N is actively walking. Focus may - /// leave it when Enter/Look opens a row; provenance does not. Null/stale - /// falls back to the ordinary history order. look_walk_owner: ?u32 = null, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, - /// Every user-settable, queryable display/runtime choice in one plain - /// authoritative record. Generated setting builtins mutate it directly; - /// rendering and the Config report read those same fields. - settings: runtime_cfg.State = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, - /// One theme-derived Ghostty palette shared by all filtered terminals. - /// Its value key makes a same-name ThemeFile reload invalidate it without - /// coupling terminal rendering to the theme-selection call sites. - tty_filter_palette: term_pane.FilterPalette = .{}, - /// Delivery bookkeeping, not configuration: prevents a synchronous host - /// pump from taking one still-pending font request more than once before it - /// acknowledges success or rejection. + settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, + tty_filter_palette: panes.Terminal.FilterPalette = .{}, font_request_taken: bool = false, - /// One user theme loaded from a .zon file. The parsed value owns its name; - /// all color fields are inline. `theme_file_generation` makes a queued IO - /// request stale as soon as another ThemeFile/Theme/NextColor command wins. custom_theme: ?Theme = null, - custom_theme_active: bool = false, - /// Sized by `limits.host_path_cap`, which is 0 where the platform has - /// no filesystem to hold a theme file: `set` then refuses every non-empty - /// path and `themeFileRequest` answers `PathTooLong`, which is the honest - /// answer on a board whose only IO is a UART. - theme_file_path: runtime_cfg.Text(limits.host_path_cap) = .{}, + theme_file_path: config.Runtime.Text(limits.host_path_cap) = .{}, theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome), - /// False only while startup configuration or a dump restore is selecting - /// its first theme. No frame is rendered in that interval. - /// - /// Nothing to do with `-Dtheme-animation`: that is decided by the type of - /// `chrome_animation`, so a build without the fade does not carry a flag - /// saying so. animate_theme_changes: bool = false, - /// Native pixel attachments supported by the shell (Kitty graphics in a - /// terminal, GPU textures in SDL). Image panes dynamically fall back to - /// the PETSCII matcher without it. native_images: bool = false, quit: bool = false, - /// The Look or Exec that has happened and not yet been felt, taken by the - /// shell once per pump (takeHaptic). A pulse, not a queue: five Execs - /// inside one keystroke are still one thing the hand did. haptic: HapticSlot = if (platform == .macos) .none else {}, drag: Drag = .none, hover_col: u16 = 0, hover_row: u16 = 0, pointer_raw_col: u16 = 0, pointer_raw_row: u16 = 0, - /// True only after buttonless motion. Presentation acknowledgements may - /// remap that stationary hover; keyboard/button/wheel input clears the - /// intent so an ordinary repaint cannot silently re-arm a cancelled hint. raw_hover_intent: bool = false, - /// Hosts explicitly tell us when the pointer leaves. Keeping this bit - /// separate from the last coordinates lets a drag retain its endpoint - /// while idle resize-handle hints disappear immediately outside a window. pointer_inside: bool = false, look_hover_wait: ?LookHoverWait = null, look_hover_preview: ?LookHoverPreview = null, pdf_hover_preview: if (pdf_enabled) ?PdfWordPreview else void = if (pdf_enabled) null else {}, - /// touchpad drift guard, counted down in horizontal wheel ticks — see - /// config.wheelTick, which owns the whole rule. Global, and clock-free on - /// purpose: the core has no clock, so "recently scrolled vertically" can - /// only mean "in the last few wheel events", which is all the heuristic - /// needs. - /// ponytail: it therefore never times out — only a horizontal tick spends - /// it, so a sideways swipe an hour after a scroll still pays the toll. If - /// that ever bites, clear it on any non-wheel event: a keypress or a click - /// is proof the gesture ended, and still needs no clock. wheel_guard: u8 = 0, ctrl_w_pending: bool = false, - /// A key is being REPLAYED over several selections (replaySels). The one - /// thing the replay cannot do is let a per-pass action that is really a - /// per-KEYSTROKE action fire once per range: the undo snapshot must be - /// taken once, the yank register accumulates instead of being overwritten, - /// and anything that opens, closes or focuses a pane (runBuiltin) or asks - /// the language backend (lspRequest) happens on the first pass and stops - /// the replay dead — see multiOnce. multi_on: bool = false, multi_first: bool = false, multi_stop: bool = false, - /// SPC leader in flight, holding the key path typed so far (empty = just - /// SPC). Global like ctrl_w_pending — there is one leader and it acts on - /// the active pane, whose transient message row shows the pending path. leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, - /// the TOPBAR holds the keyboard, parked at this UTF-8 byte offset of the - /// row-0 line. Global like leader_on for the same reason: row 0 is not a - /// pane and never will be, so its one piece of focus state cannot live on - /// one. `null` = the panes have the keyboard, which is every other frame. topbar_col: ?u16 = null, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, @@ -6490,18 +5095,6 @@ pub const Pardes = struct { /// how many `execute` calls are on the stack — see max_exec_depth exec_depth: u8 = 0, - /// The one language query in flight. ONE, deliberately: every one of these - /// is a keystroke the user is waiting on, so a second press means "I meant - /// this one" — the id bump makes the older answer stale and lspResponse - /// drops it. A queue would only buy the right to render an answer nobody - /// is waiting for any more. - /// `arg` holds the replacement name for rename and the query for workspace - /// symbols. `serial` rejects a response after its pane slot was reused; - /// `revision` makes a mutating rename conditional on the source snapshot - /// the worker actually analysed. `row`/`col` are where the cursor was when - /// the question was asked. Only `completion` reads them, and only to undo - /// itself: Tab diverted instead of indenting, so an empty answer has to put - /// the indent back — but only if the cursor has not moved since. lsp_seq: u32 = 0, lsp_wait: ?struct { id: u32, @@ -6514,32 +5107,17 @@ pub const Pardes = struct { col: i32 = 0, } = null, - /// One current shell-filter request. A newer submit frees and supersedes - /// it; old worker answers then fail the id check. The request itself owns - /// every byte a shell snapshots while draining the id-only effect. pipe_seq: u32 = 0, pipe_wait: ?PendingPipe = null, - /// acme's control filesystem, when a host serves one (`pardes --fs`). - /// Zero-initialised and inert: a core nobody scripts pays for one branch - /// per edit and nothing else. See src/acmefs.zig. - fs: acmefs.State = .{}, + fs: filesystem.Namespace = .{}, - /// Pending effects, drained by the shell after each update. The bounded - /// ring preserves byte order; once full, later effects are refused so no - /// already-queued write can be reordered or silently evicted. + // Reject overflow: evicting an older effect would reorder a byte stream. effects: [limits.effect_cap]Effect = undefined, 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. + // Each pty retains its own overflow tail until earlier effects drain. 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. @@ -6550,27 +5128,18 @@ pub const Pardes = struct { host: Host = .{}, /// The in-program answers behind every unimplemented host method. Per /// instance, so several cores behind one fan-out host stay independent. - fallback: Fallback, + fallback: host_io.Fallback, /// The message-row log: a fixed ring, never allocated, never grown. See /// `logMessage` and the `Messages` builtin. messages: [limits.message_log]LoggedMessage = @splat(.{}), /// Next slot to write. `messages_len` saturates at the ring's size. messages_head: usize = 0, messages_len: usize = 0, - /// Input the loop has not consumed yet. Single-threaded: a host's worker - /// threads keep their own thread-safe inbox and post from the loop thread, - /// which is what keeps this ring lock-free. in_q: [64]Event = undefined, in_head: usize = 0, in_len: usize = 0, - /// helix's DEFAULT register (gpa-owned): what `y`/`d`/`c` write and - /// `p`/`P`/`R` read. Never the system clipboard — `SPC y`/`SPC p` are the - /// two commands that cross that line. yank: ?[]u8 = null, - /// a `SPC p`/`SPC P`/`SPC R` waiting on the shell's clipboard read, or - /// null. At most one: a second request replaces the first, and any - /// keystroke abandons it (update). clip_pending: ?ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, @@ -6581,12 +5150,6 @@ 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, @@ -6597,8 +5160,8 @@ pub const Pardes = struct { /// that still own their loop pass their own arena to `render` instead. frame_arena: std.heap.ArenaAllocator, /// the terminal motion surface, memoized against the pane it was built - /// for — see term_pane.RowsCache for the lifetime rule - shell_rows: term_pane.RowsCache = .{}, + /// for — see panes.Terminal.RowsCache for the lifetime rule + shell_rows: panes.Terminal.RowsCache = .{}, pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; @@ -6611,6 +5174,11 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .fs = .{ + .socket_path = opts.ninep_identity.socket_path, + .tcp_address = opts.ninep_identity.tcp_address, + .quic_address = opts.ninep_identity.quic_address, + }, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), @@ -6618,36 +5186,20 @@ pub const Pardes = struct { .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); + for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); + p.opts.mounts = &.{}; + p.opts.ninep_identity = .{}; if (opts.file) |path| { - // FILE argv boot: the doc alone, filling the window. Naming a file - // is asking to READ it, not to be handed a shell you did not ask - // for and have to close — and the launch directory is one Newcol - // away when it is wanted. Doc, PDF and image all boot the same way. const opened = initial_doc: { if (comptime pdf_enabled) if (look.isPdfPath(path)) - break :initial_doc pdf_pane.openPane(p, 0, path, opts.file_line); + break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line); if (look.isImagePath(path)) - break :initial_doc image_pane.create(p, 0, path, &.{}); - break :initial_doc file_pane.open(p, 0, path, opts.file_line); + break :initial_doc panes.Image.create(p, 0, path, &.{}); + break :initial_doc panes.File.open(p, 0, path, opts.file_line); }; - // ...AND IF IT WILL NOT OPEN, SAY SO IN THE WINDOW. `pardes /root` - // is a directory that resolves and cannot be read, so it arrives - // here as a `.file` and used to take the whole launch down with - // `error: PermissionDenied` and a return trace out of `main` — a - // crash, to the human, for asking to read something they are not - // allowed to read. Every environment reason lands in the same - // `+Errors` pane a missing name does, because they are the same - // event to whoever typed it: pardes cannot show you that. - // - // OUT OF MEMORY IS NOT ONE OF THEM and goes back to the caller. - // A core that could not allocate a file cannot allocate the pane - // explaining it, and pretending otherwise turns a clean failure - // into a second one. Every opener does its fallible IO BEFORE it - // claims a pane slot (`look.readFile`, then `newDocPane`), so slot - // 0 is still free here — which is what makes this legal. if (opened) |_| {} else |err| { if (err == error.OutOfMemory) return err; - const why = switch (err) { + const why = switch (@as(anyerror, err)) { error.PermissionDenied => "permission denied", error.FileNotFound => "no file of that name", error.IsDirectory => "that is a directory, and not one that could be read", @@ -6658,82 +5210,35 @@ pub const Pardes = struct { }; const content = try std.fmt.allocPrint(gpa, "cannot open\n\n\t{s}\n\n{s}\n", .{ path, why }); errdefer gpa.free(content); - _ = try output_pane.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); + _ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); } p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (opts.missing) |missing| { - // ARGV NAMED NOTHING. `+Errors` and not the message row, because a - // launch has no pane to put a message row on yet — and because - // this is exactly what acme's `+Errors` is: output from the - // program rather than from a word anybody clicked (output_pane - // `Origin.errors`). Filling the window with it makes the answer - // unmissable, which a one-line message under a shell prompt is - // not. - // - // The word AS TYPED, not a resolved path: there is nothing to - // resolve, and `pardes ~/notes/tdoo.md` wants to see its own typo - // back rather than an absolute path built around it. The pane's - // DIRECTORY is the launch directory all the same — see `missing`: - // "" would put this pane at `/+Errors` and point every word that - // reads a pane's directory at the root of the filesystem. const content = try std.fmt.allocPrint( gpa, "file or directory not found\n\n\t{s}\n", .{missing.word}, ); errdefer gpa.free(content); - _ = try output_pane.open(p, 0, missing.dir, .errors, "", content); + _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (comptime platform == .esp32p4) { - // BARE METAL BOOTS AN EMPTY OUTPUT BUFFER, and a shell is not a layout preference - // here but an impossibility: there is no operating system under this, so there is - // nothing to fork and no pty to give a terminal pane. Booting one anyway produced - // exactly what that describes - a pane whose tag ends in `Filter`, whose pty is the - // Fallback's silent one, with no gutter, no buffer, and no key that reaches anything. - // Measured on an ESP32-P4 over the serial line: every keystroke vanished. - // - // An output buffer is the right default rather than a file pane, and not only because - // `opts.file` cannot work here (the P4's embedded allowlist is empty by design - see - // source_manifest.zig - so `look.readFile` has nothing to resolve a path against). It - // is what the platform's own words WANT: `Peek`, `Poke` and `Hexdump` each fill an - // output buffer, so booting into one means the first dump lands in the same kind of - // pane the boot pane already is. It is editable text with no file behind it, which is - // the honest description of a buffer on a board with no filesystem. - // AND IT BOOTS WITH SOMETHING IN IT. An empty buffer is honest and useless: the three - // words that make this board interesting take an address, and a board's address space is - // precisely the thing you cannot guess. - // - // SHORT, because the window is fourteen rows. The first draft opened with four lines of - // prose about there being no operating system, which is true, unhelpful, and cost a - // third of the screen before the first command. One header line earns its place; the - // rest of the screen is addresses. - // - // Each command sits alone on its line because an argument list ends at the last argument - // - a trailing comment would be `ExtraArgument` - so the labels go above. Every address - // is from this repository or from a session that read it on this die: the flash and RAM - // figures are the linker script's own ORIGINs (`05-zig-p4/build.zig`), the peripheral - // bases are the `DR_REG_*` values `05-zig-p4/src/hal` uses, and the two LP addresses at - // the end are named in ESP-IDF's own headers: 0x5011002c is LP_SYSTEM_REG_LP_STORE0, a - // general-purpose retention register that holds what you put in it, and 0x501101a4 is - // LP_SYSTEM_REG_RNG_DATA, the hardware random generator. Between them they demonstrate - // the whole point of a volatile read: one address gives back what was written and the - // other never gives the same answer twice. Both verified on this die. const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); - _ = try output_pane.open(p, 0, "", .{ .cmd = .New }, "", content); + _ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content); p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (opts.tty_only) { _ = try p.newShell(0, ""); p.panes[0].?.mode = .tty; p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (opts.shells >= 3) { // classic layout: two columns, the left one split in two. _ = try p.newShell(0, ""); @@ -6742,23 +5247,23 @@ pub const Pardes = struct { for (p.panes[0..3]) |slot| slot.?.greet = true; p.ncol = 2; p.col_n[0] = 2; - p.col_terms[0][0] = 0; - p.col_terms[0][1] = 1; + p.col_panes[0][0] = 0; + p.col_panes[0][1] = 1; p.col_n[1] = 1; - p.col_terms[1][0] = 2; + p.col_panes[1][0] = 2; } else { // ponytail: 1 and 3 are the only boot layouts; anything else acts as 1 _ = try p.newShell(0, ""); p.panes[0].?.greet = true; p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); - p.panel_animation_enabled = true; + p.presentation.enabled = true; // A config init that opens a file with `Look …` armed the pulse before // anyone touched anything. Nobody asked for that, so boot is silent. _ = p.takeHaptic(); @@ -6772,11 +5277,11 @@ pub const Pardes = struct { p.teardownPane(pane); slot.* = null; }; - for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { + for (&p.retired_panes.previous) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; - for (&p.pane_alloc.fresh) |*slot| if (slot.*) |pane| { + for (&p.retired_panes.current) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; @@ -6793,8 +5298,7 @@ pub const Pardes = struct { p.scratch.deinit(); p.frame_arena.deinit(); gpa.free(p.surface.cells); - if (p.presented_cells.len > 0) gpa.free(p.presented_cells); - if (p.panel_cell_diffs.len > 0) gpa.free(p.panel_cell_diffs); + p.presentation.deinit(gpa); if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); } @@ -6830,115 +5334,103 @@ pub const Pardes = struct { return config.topbar_str; } - /// Drop a pane: its slot is freed for reuse now, but the allocation is - /// doomed and actually torn down a frame later (reapPanes), so pointers to - /// it survive the frame. Callers still null `panes[id]` themselves. - pub fn deinitPane(p: *Pardes, pane: *Pane) void { - // Logical close, while the pane is still installed: stop the file/PDF - // watch keyed to this slot and drop any hover it owns. The heap - // teardown is deferred (reapPanes) so pointers to it survive the frame. - const watched = (if (pane.file) |f| f.output == null else false) or hasPdf(pane); + fn detachCwds(p: *Pardes, parents: []const *Pane) !void { + var copies: [MAX_PANES]?[]u8 = @splat(null); + errdefer for (copies) |copy| if (copy) |bytes| p.gpa.free(bytes); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const source = switch (pane.cwd) { + .inherited => |parent| parent, + else => continue, + }; + for (parents) |parent| if (source == parent) { + copies[id] = try p.gpa.dupe(u8, paneDir(source)); + break; + }; + } + for (copies, 0..) |copy, id| if (copy) |bytes| { + p.panes[id].?.cwd = .{ .owned = bytes }; + }; + } + + // The slot closes now; allocation teardown waits one frame. Caller clears panes[id]. + pub fn deinitPane(p: *Pardes, pane: *Pane) !void { + try p.detachCwds(&.{pane}); + p.retirePane(pane); + } + + fn retirePane(p: *Pardes, pane: *Pane) void { + const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf(); for (p.panes, 0..) |slot, id| if (slot == pane) { if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); - // A pane's filesystem state dies WITH the pane, here, while the - // slot still names it: the alternative is a script that held its - // `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); + p.retired_panes.retire(pane); } - /// The real teardown, run by reapPanes once the pane has been doomed for a - /// full frame (or at deinit). Frees every heap payload the pane owns. + // Retired panes keep their payloads until the following frame or core teardown. fn teardownPane(p: *Pardes, pane: *Pane) void { if (p.lookHoverPane()) |hovered| { if (hovered < p.panes.len and p.panes[hovered] == pane) p.cancelLookHover(); } p.shell_rows.dropPane(pane); - term_pane.deinitPendingCommand(pane); + panes.Terminal.deinitPendingCommand(pane); if (pane.image) |*iv| { iv.deinit(p.image_gpa); } - if (comptime pdf_enabled) if (pane.pdf) |*pv| pdf_pane.deinitPane(p, pane, pv); - if (pane.file) |*file| file_pane.deinit(p, pane, file); + if (comptime pdf_enabled) if (pane.pdf) |*pv| panes.Pdf.deinitPane(p, pane, pv); + if (pane.file) |*file| panes.File.deinit(p, pane, file); if (pane.ovl) |o| p.gpa.free(o.text); for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); - term_pane.deinitEmulator(pane, p.gpa); + panes.Terminal.deinitEmulator(pane, p.gpa); + pane.clearCwd(); p.gpa.destroy(pane); } - /// Once a frame: repair live panes' pointers to doomed panes, then free the - /// panes doomed a full frame ago. Fixup runs first so no pointer outlives - /// the memory. `stale` (last frame's dead) is freed; `fresh` becomes stale. fn reapPanes(p: *Pardes) void { - for (p.panes) |slot| if (slot) |pane| p.fixupPaneRefs(pane); - for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { + for (&p.retired_panes.previous) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; - p.pane_alloc.stale = p.pane_alloc.fresh; - p.pane_alloc.fresh = @splat(null); - } - - /// Visit each Pane field that holds a pane pointer; when it names a doomed - /// pane, snapshot that pane's directory into our own bytes so the link can - /// die with it. One field carries a pointer today (the inherited cwd); the - /// comptime walk keeps that honest as fields come and go. - fn fixupPaneRefs(p: *Pardes, pane: *Pane) void { - inline for (@typeInfo(Pane).@"struct".fields) |f| { - if (f.type == Pane.Cwd) switch (@field(pane, f.name)) { - .inherited => |src| if (p.pane_alloc.isDoomed(src)) pane.setOwnedCwd(paneDir(src)), - else => {}, - }; - } + p.retired_panes.previous = p.retired_panes.current; + p.retired_panes.current = @splat(null); } - /// Put a fully constructed pane in a free slot and give it the monotonic - /// identity every slot-reuse guard relies on. Pane kinds construct their - /// own payloads; this registration rule remains a core invariant. - fn installPane(p: *Pardes, id: usize, pane: *Pane) void { + pub fn installPane(p: *Pardes, id: usize, pane: *Pane) void { std.debug.assert(p.panes[id] == null); p.next_serial += 1; pane.serial = p.next_serial; + p.rects[id] = .{}; p.panes[id] = pane; } - /// Allocate a pane slot with a live terminal emulator and queue the spawn - /// effect (optionally in a directory); the shell answers by forking a pty - /// and wiring reads back as Event.output for this pane id. pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); - if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256) - const pane = try term_pane.create(p.gpa, p.screen_w, p.screen_h); - term_pane.armShellSpawn(pane); + if (cwd.len > effect_path_cap) return error.PathTooLong; + const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); + panes.Terminal.armShellSpawn(pane); p.installPane(id, pane); p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); return pane; } - /// a doc pane (file/image/PDF): no pty and no spawn. Whatever emulator half - /// it still needs is term_pane's business — see `createDoc` there. pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); - const pane = try term_pane.createDoc(p.gpa, p.screen_w, p.screen_h); + const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); p.installPane(id, pane); return pane; } - /// An empty output buffer opened FROM `from_id`: no file behind it, its cwd - /// a live link to the opener so Save can prefill that directory. New and - /// Newcol place it (below, or in a column). Installed in slot `free`. fn newScratch(p: *Pardes, from_id: usize, free: usize) !*Pane { const src = p.panes[from_id] orelse return error.MissingPane; const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); - const np = try output_pane.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); + const np = try panes.Output.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); np.cwd = .{ .inherited = src }; np.cur_pinned = true; return np; @@ -6947,25 +5439,25 @@ pub const Pardes = struct { /// New: a scratch below the calling pane, in its column. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; - const sf = p.layoutFindTerm(from_id) orelse return; + const sf = layout.findPane(p, from_id) orelse return; const np = p.newScratch(from_id, free) catch return; - p.layoutInsert(sf.col, sf.idx + 1, free); - p.splitBelow(from_id, np); + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, from_id, np); p.active = free; } /// Newcol: a scratch in a fresh column beside the calling pane. pub fn newScratchColumn(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; - if (!p.layoutCanSplitColumn(from_id)) return; + if (!layout.canSplitColumn(p, from_id)) return; _ = p.newScratch(from_id, free) catch return; - std.debug.assert(p.layoutSplitColumn(from_id, free, false)); + std.debug.assert(layout.splitColumn(p, from_id, free, false)); p.active = free; } pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { - if (slot == null) break i; + if (slot == null and !p.reserved_slots[i]) break i; } else null; } @@ -6976,14 +5468,6 @@ pub const Pardes = struct { } else null; } - /// `id` just performed a look: put it on top of the walk's spine. - /// - /// MOVE to the top rather than push, so a pane you keep looking out of - /// stays one entry instead of filling the list with itself — the walk's - /// order is "which panes, most recent first", not "how many times". - /// Dropping the oldest when full can only ever discard a DEAD pane's - /// serial: MAX_PANES entries with no duplicates already names every slot - /// there is. fn noteLookSource(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; var w: usize = 0; @@ -7001,25 +5485,12 @@ pub const Pardes = struct { p.look_walk_owner = pane.serial; } - /// Arm n/N on a concrete live pane without pretending that pane has - /// already performed a Look. Result producers use this when a fresh or - /// refilled list supersedes older walk history. An empty answer cannot - /// supersede anything: it has no position to resume, so preserve the pane - /// whose prior Look established the walk instead of stealing provenance. pub fn armLookWalk(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (pane.file) |file| if (file.content.len == 0) return; p.look_walk_owner = pane.serial; } - /// 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; // Anything already parked for this pane owns the stream's position, so @@ -7034,9 +5505,6 @@ pub const Pardes = struct { 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; @@ -7059,62 +5527,32 @@ pub const Pardes = struct { /// changes it observes, e.g. via /proc//cwd before each frame). pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void { const pane = p.panes[id] orelse return; - const n = @min(cwd.len, pane.cwd_buf.len); const cur = switch (pane.cwd) { .owned => |dir| dir, else => "", }; - if (cur.len == n and std.mem.eql(u8, cur, cwd[0..n])) return; + if (std.mem.eql(u8, cur, cwd)) return; + pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err); if (p.lookHoverPane() == id) p.cancelLookHover(); - pane.setOwnedCwd(cwd[0..n]); - } - - /// Can a command line be typed into this pane RIGHT NOW: a terminal whose - /// tty still belongs to the prompt the host forked. A terminal running vim - /// answers false and is then treated exactly like a document pane — the - /// command goes to some other shell (ttyForDir), because keystrokes are all - /// a full-screen program would make of it. - /// - /// The occupancy half of that question is the host's to answer (look.ttyTaken - /// walks the processes under the pane's shell pid against the tty's - /// foreground process group) and it is asked HERE, lazily: only for a pane a - /// command line is about to go to, and only at the moment it is about to go - /// there. It used to be pushed in by every host on every frame for every - /// pane, which bought nothing — nothing else in the core has ever wanted the - /// answer, and a verdict one frame old is a worse one than a verdict taken - /// now. The cheap half is tested first, so a pane in the wrong directory - /// costs no syscalls at all. - /// - /// No query (web has no processes, a dump replay has no shells yet, and the - /// core's own tests install their own) means every terminal is a prompt, - /// which is exactly how pardes behaved before the probe existed. + } + fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (!pane.isTerminal()) return false; return !p.hostTtyTaken(id); } - /// What a shell should exec for the next terminal — a bare name to be - /// looked up, or an absolute path to be used as it stands. The resolving - /// is the shells' half: the core has no filesystem to ask. pub fn shellBin(p: *const Pardes) []const u8 { const selected = p.settings.shell.requested.get(); return if (selected.len == 0) config.default_shell else selected; } - /// The native host resolved the request (or chose a fallback) for a real - /// spawn. Record the executable that actually ran; web never calls this - /// because it has no process backend. pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void { - if (id < MAX_PANES) term_pane.shellSpawned(p, id, prompt_marks); + if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks); if (p.settings.shell.effective.set(executable)) p.settings.shell.pending = false; } - /// Small backend reads over the same plain state the builtins mutate. - /// Take the newest unresolved Font request once. `pending` remains true - /// until the host explicitly acknowledges success or rejection; requested - /// name/path remain queryable either way. pub fn takeFontRequest(p: *Pardes) ?[]const u8 { if (comptime !font_picker) return null; if (!p.settings.font.pending or p.font_request_taken) return null; @@ -7124,15 +5562,11 @@ pub const Pardes = struct { return path; } - /// Record the face a GUI host is currently wearing. Boot, zoom and backing - /// scale changes are observations, not answers to a pending Font request: - /// only acknowledgeFont may resolve one after takeFontRequest handed it to - /// the host. pub fn observeFont( p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, - unit: FontSizeUnit, + unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.effective_name.set(effective_name)) return false; @@ -7147,7 +5581,7 @@ pub const Pardes = struct { p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, - unit: FontSizeUnit, + unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.pending or !p.font_request_taken) return false; @@ -7160,9 +5594,6 @@ pub const Pardes = struct { if (!p.observeFont(effective_name, effective_size_hundredths, unit)) return false; p.settings.font.pending = false; p.font_request_taken = false; - // The host has already replaced its face/atlas at this boundary. Even - // when the new face measures to the same grid, a frozen old panel - // layer cannot truthfully be rasterized through those new glyphs. if (changed) p.abandonPanelAnimations(); return true; } @@ -7180,9 +5611,6 @@ pub const Pardes = struct { return p.theme_file_generation; } - /// Resolve and queue `ThemeFile` without doing filesystem work in the - /// core. Relative paths belong to the per-user config directory; absolute - /// paths remain useful for trying a file elsewhere. pub fn requestThemeFile(p: *Pardes, id: usize, argument: []const u8) void { if (comptime !hosted) return; const input = std.mem.trim(u8, argument, " \t\r\n"); @@ -7244,9 +5672,6 @@ pub const Pardes = struct { p.reportError(request.pane, "theme file", err); } - /// Parse and atomically wear one exact ZON snapshot. Parse failure leaves - /// the last valid custom/built-in theme untouched. `animate` is false for - /// the launcher's pre-frame drain and true for an interactive load/reload. pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool { const request = p.themeFileRequest(generation) orelse return false; if (bytes.len > 1024 * 1024) { @@ -7277,7 +5702,6 @@ pub const Pardes = struct { p.chrome_animation.snap(target_chrome); const old = p.custom_theme; p.custom_theme = parsed; - p.custom_theme_active = true; if (old) |theme_value| std.zon.parse.free(p.gpa, theme_value); p.invalidateThemeDependentRasters(); _ = p.scratch.reset(.retain_capacity); @@ -7285,37 +5709,61 @@ pub const Pardes = struct { return true; } - /// Post the transient message on `id`'s last row. Called by a SHELL once - /// the IO it narrates has actually happened, exactly as the shell completes - /// a `save_file` effect: the core neither writes files nor owns a - /// clock, so both the outcome and the wall time in `text` come from there - /// (message.zig spells it, once, for both native shells). - /// - /// Nothing here decides WHEN it goes away — update does, on the next key or - /// mouse event — and nothing here knows whether the row is free: an armed - /// prompt simply outranks a message at render time, so a message posted - /// under one is stored and invisible rather than refused. - /// A message row that is NOT worth remembering: unsolicited progress from a - /// language server, which arrives several times a second for the whole of a - /// large index. - /// - /// `rust-analyzer: Indexing 47% core` is a different string every tick by - /// construction, so no de-duplication can collapse it, and at the client's - /// throttle of one per 150ms per server it takes about NINETEEN SECONDS to - /// push every save, error and reload out of a 128-entry ring. A log that - /// one indexing run empties is not a log. Progress belongs on the row, - /// where it is read as it happens and then replaced; the log is for things - /// that were said once. + pub const Message = struct { + const libc = std.c; + const Tm = extern struct { + sec: c_int, + min: c_int, + hour: c_int, + mday: c_int, + mon: c_int, + year: c_int, + wday: c_int, + yday: c_int, + isdst: c_int, + gmtoff: c_long, + zone: ?[*:0]const u8, + }; + extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm; + + pub fn body(text: []const u8) []const u8 { + if (text.len < 10) return text; + if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text; + for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| { + if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text; + } + return text[10..]; + } + + pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 { + var clock: [8]u8 = "--:--:--".*; + const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false; + if (!notime) { + var ts: libc.timespec = undefined; + _ = libc.clock_gettime(.REALTIME, &ts); + const secs: libc.time_t = ts.sec; + var tm: Tm = undefined; + if (localtime_r(&secs, &tm) != null) { + _ = std.fmt.bufPrint(&clock, "{d:0>2}:{d:0>2}:{d:0>2}", .{ + @as(u32, @intCast(tm.hour)), + @as(u32, @intCast(tm.min)), + @as(u32, @intCast(tm.sec)), + }) catch {}; + } + } + const head = std.fmt.bufPrint(buf, "{s} {s} ", .{ &clock, verb }) catch return buf[0..0]; + const room = buf.len - head.len; + const tail = if (subject.len <= room) subject else subject[subject.len - room ..]; + @memcpy(buf[head.len..][0..tail.len], tail); + return buf[0 .. head.len + tail.len]; + } + }; + pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void { p.showMessage(id, text); } pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { - // LOGGED FIRST, and logged even when the pane is gone. A message row - // is cleared by the next keystroke (see `clearMessages`), so anything - // said while the user was looking elsewhere — a save that failed, a - // watcher's reload, a builtin's complaint — used to be unrecoverable - // the instant it appeared. `Messages` reads this back. p.logMessage(id, text); p.showMessage(id, text); } @@ -7327,23 +5775,14 @@ pub const Pardes = struct { @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); } - /// Append to the ring, oldest overwritten. No allocation and no failure: - /// this sits under every `reportError` in the program, including the ones - /// raised because an allocation just failed. fn logMessage(p: *Pardes, id: usize, text: []const u8) void { if (text.len == 0) return; const pane: u8 = if (id < MAX_PANES) @intCast(id) else 0xff; - // What is STORED is truncated to the slot, so what is COMPARED must be - // too: comparing a 300-byte message against its own 256-byte record - // never matched, and two identical long messages each got their own - // row. And the comparison is on `message.body` — the row without its - // clock — because a stamp makes every host message unique by - // construction, which defeated this entirely for `saved`/`reloaded`. const kept = text[0..@min(text.len, LoggedMessage.cap)]; if (p.messages_len > 0) { const last = &p.messages[(p.messages_head + limits.message_log - 1) % limits.message_log]; if (last.pane == pane and - std.mem.eql(u8, message.body(last.slice()), message.body(kept))) + std.mem.eql(u8, Message.body(last.slice()), Message.body(kept))) { // The NEWEST wording wins, so the row carries the latest clock // rather than the moment the run started. @@ -7374,31 +5813,24 @@ pub const Pardes = struct { p.setMessage(id, text); } - /// Apply one watched-path snapshot to the payload which owns that path. - /// True means the pane now represents this successful host transaction; - /// native watchers commit their generation only then, so a transient PDF - /// reopen/allocation failure remains retryable. fn applyWatchedFileChanged(p: *Pardes, id: u8, bytes: []const u8) bool { if (id >= MAX_PANES) return false; const pane = p.panes[id] orelse return false; if (comptime pdf_enabled) if (pane.pdf != null) { - pdf_pane.reloadWatched(p, pane) catch |err| { + panes.Pdf.reloadWatched(p, pane) catch |err| { p.reportError(id, "PDF reload", err); return false; }; return true; }; if (pane.file == null) return false; - file_pane.changed(p, id, bytes); + panes.File.changed(p, id, bytes); return if (p.panes[id]) |current| if (current.file) |file| std.mem.eql(u8, file.content, bytes) else false else false; } - /// Synchronous host seam for a watched file. Event.update routes through - /// the same payload operation, while native watchers use this spelling to - /// learn whether they may commit the observed disk generation. pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool { p.invalidateLookHover(id); const applied = p.applyWatchedFileChanged(id, bytes); @@ -7407,9 +5839,6 @@ pub const Pardes = struct { return applied; } - /// Content replacement invalidates a preview whose operand was expanded - /// from that pane. Payload modules call this for derived buffers they - /// refresh as part of the same transaction. pub fn invalidateLookHover(p: *Pardes, id: usize) void { if (p.lookHoverPane() != id) return; p.raw_hover_intent = false; @@ -7424,9 +5853,6 @@ 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; @@ -7470,11 +5896,6 @@ pub const Pardes = struct { 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 - /// the host's borrow window instead — which is also what keeps the pty read - /// path copy-free. pub fn postEvent(p: *Pardes, ev: Event) void { switch (ev) { .key => |k| std.debug.assert(k.text.len == 0), @@ -7497,31 +5918,21 @@ pub const Pardes = struct { return ev; } - /// Has a program taken this pane's tty? A host that cannot tell says no, - /// which is how pardes behaved before the probe existed. Public because - /// `pty/status` reports it: it is the one field of that file the core does - /// not own itself, and asking here rather than reaching for the vtable in - /// acmefs keeps the null-method default in one place. pub fn hostTtyTaken(p: *const Pardes, id: usize) bool { - const f = p.host.vtable.pull_tty_taken orelse return false; + const f = p.host.vtable.tty_taken orelse return false; return f(p.host.ctx, @intCast(id)); } fn hostWriteFile(p: *Pardes, pane: u8, path: []const u8, bytes: []const u8) void { - if (p.host.vtable.push_write_file) |f| return f(p.host.ctx, pane, path, bytes); + if (p.host.vtable.write_file) |f| return f(p.host.ctx, pane, path, bytes); // The in-process filesystem reports the same way a real host does, so // an OOM here leaves the pane dirty rather than looking saved. if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, "save", error.OutOfMemory); } - /// A HOST'S ANSWER TO `save_file`, and the only one it needs to give: the - /// write did not happen. Puts the reason on the pane's message row and - /// takes back the optimistic clean mark `perform` made, so the tag keeps - /// its ` *` and the edits keep being edits. See host.zig - /// `push_write_file` for why this is a call and not a return value. pub fn saveFailed(p: *Pardes, id: u8, what: []const u8, err: anyerror) void { if (p.panes[id]) |pane| if (pane.file) |*f| { - // The `-%` spelling acmefs.zig already uses for "make this dirty". + // The `-%` spelling fs.zig already uses for "make this dirty". f.saved_revision = f.revision -% 1; }; p.reportError(id, what, err); @@ -7534,14 +5945,7 @@ pub const Pardes = struct { return pane.pdfPath(); } - /// THE BYTES BEHIND AN `.fs_reply`, resolved in the drain. A filesystem - /// read answers with either something the handler formatted (staged in the - /// core, valid until the next request) or a window onto a pane's live text, - /// which is handed over WITHOUT A COPY — the same trick, and the same - /// serial check, `.save_text` uses to write a megabyte it never duplicated. - /// A slot reused between the answer and this call resolves to nothing - /// rather than to another pane's text. - pub fn fsPayload(p: *const Pardes, r: acmefs.Reply) []const u8 { + pub fn fsPayload(p: *const Pardes, r: filesystem.Reply) []const u8 { return switch (r.payload) { .none => &.{}, .staged => |n| p.fs.out.items[0..@min(n, p.fs.out.items.len)], @@ -7555,120 +5959,85 @@ pub const Pardes = struct { }; } - /// Perform one effect through the host, falling back per METHOD (not per - /// host) to the in-process implementation. This is the switch that used to - /// be copied into all four shells. pub fn perform(p: *Pardes, e: Effect) void { const v = p.host.vtable; switch (e) { - .spawn => |s| if (v.push_spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { + .spawn => |s| if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { p.fallback.spawned[s.pane] = true; }, // A pane with no child is silent: nothing invents output on its // screen, and the bytes are dropped rather than transcribed. - .write => |w| if (v.push_pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), - .resize_pty => |r| if (v.push_pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), - // A host with no signal method has no child to signal: the - // fallback host's ptys are silent (see `.write` above), so there - // is nothing to record and nothing to lie about. - .signal_pty => |s| if (v.push_pty_signal) |f| f(p.host.ctx, s.pane, s.sig), - .open_link => |u| if (v.push_open_link) |f| + .write => |w| if (v.pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), + .resize_pty => |r| if (v.pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), + .signal_pty => |s| if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), + .open_link => |u| if (v.open_link) |f| f(p.host.ctx, u.slice()) else p.fallback.setLink(u.slice()), .save_file => |sf| { const pane = p.panes[sf.pane] orelse return; const f = if (pane.file) |*file| file else return; - // CLEAN HERE rather than where the effect was queued, and - // BEFORE the call so the host can take it back. `saveFile` used - // to set `saved_revision` at emit time, so a write that could - // not happen still cleared the tag's ` *` and left the edits one - // `Del` from gone — `Del` makes no dirty check. Here is also - // where `f.content` is read, so the revision recorded is the - // revision of the bytes that actually went out. + const serial = pane.serial; f.saved_revision = f.revision; p.hostWriteFile(sf.pane, f.path, f.content); + const saved_pane = p.panes[sf.pane] orelse return; + if (saved_pane.serial != serial) return; + const saved = if (saved_pane.file) |*file| file else return; + if (saved.saved_revision != saved.revision or !saved.watch_after_save) return; + saved.watch_after_save = false; + if (filesystem.localPath(saved.path) != null) + p.emit(.{ .watch = .{ .pane = sf.pane, .on = true } }); }, .save_text => |st| { const pane = p.panes[st.pane] orelse return; if (pane.serial != st.serial) return; // a recycled slot: not ours - // `Save ` is a COPY: it does not clean this pane, - // because the file the pane has open is not the file that was - // written. The one case that does clean is a scratch buffer - // adopting the path, and `saveTo` handles that by emitting - // `save_file` for the pane's own path instead. if (pane.file) |f| return p.hostWriteFile(st.pane, st.path.slice(), f.content); if (!pane.isTerminal()) return; - const text = term_pane.screenTextAlloc(pane, p.gpa) catch return; + const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch return; defer p.gpa.free(text); p.hostWriteFile(st.pane, st.path.slice(), text); }, .write_dump => { const out = p.dump_out orelse return; - if (v.push_write_dump) |f| { + if (v.write_dump) |f| { f(p.host.ctx, out); } else { - // A real host reports where it landed, which is what puts - // `Restore ` in the topbar; the virtual one owes the - // same, or the bytes it holds are unreachable. - _ = p.fallback.writeFile(fallback_dump_path, out); - p.setLastDump(fallback_dump_path); + _ = p.fallback.writeFile(host_io.Fallback.dump_path, out); + p.setLastDump(host_io.Fallback.dump_path); } }, .set_clipboard => { const text = p.yank orelse ""; - if (v.push_set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); + if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); }, // No desktop to ask: answer from the in-process clipboard at once, // which is the same shape as a host answering later. - .read_clipboard => if (v.pull_read_clipboard) |f| + .read_clipboard => if (v.read_clipboard) |f| f(p.host.ctx) else p.update(.{ .paste = p.fallback.clipboard.items }), - .lsp => |q| if (v.pull_lsp) |f| + .lsp => |q| if (v.lsp) |f| f(p.host.ctx, .{ .id = q.id, .kind = q.kind, .pane = q.pane, .offset = q.offset, .arg = q.arg.slice() }) else p.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }), - .pipe => |q| if (v.pull_pipe) |f| + .pipe => |q| if (v.pipe) |f| f(p.host.ctx, q.id) else p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }), - .watch => |w| if (v.push_watch_file) |f| - f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on) + .watch => |w| if (v.watch_file) |f| + f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on, w.mode) else { p.fallback.watched[w.pane] = w.on; }, - .theme_file => |t| if (v.push_watch_theme) |f| f(p.host.ctx, t.generation, t.on), - .dump_themes => |d| if (v.push_dump_themes) |f| f(p.host.ctx, d.pane), - // The bytes are read off the core HERE, in the drain, exactly as - // save_file reads a file pane: the reply named where they live and - // 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. + .theme_file => |t| if (v.watch_theme) |f| f(p.host.ctx, t.generation, t.on), + .dump_themes => |d| if (v.dump_themes) |f| f(p.host.ctx, d.pane), + .fs_reply => {}, .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| + .detach => |d| if (v.detach) |f| f(p.host.ctx) else p.reportError(d.pane, "detach", error.NotAttached), @@ -7677,45 +6046,29 @@ pub const Pardes = struct { } } - /// ONE ITERATION OF THE LOOP, and the reason the core owns it: the ORDER - /// here — wait, apply input, perform effects, poll, render, present — was - /// copied into four shells and drifted in each. A host supplies the parts - /// only it can (blocking, pixels, processes) and nothing else. - /// - /// It is one PUMP and never a `while`: no host gives up its outer loop. - /// AppKit owns NSApplication's run loop, the browser owns the frame - /// callback, and both Linux hosts keep a thin one so Restore can swap the - /// whole core between frames. pub fn pump(p: *Pardes, h: Host) !void { p.host = h; const v = h.vtable; - if (v.pull_wait_input) |f| f(h.ctx, if (p.animationActive()) animation.frame_ms else 0); + if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) layout.Animation.frame_ms else 0); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); // A quitting frame has already freed what it would draw. if (p.quit) return; - if (v.push_poll_frame) |f| f(h.ctx); + if (v.poll_frame) |f| f(h.ctx); _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); - if (v.push_present) |f| f(h.ctx, surface); - if (v.push_post_present) |f| f(h.ctx); - // Animation TIME is not spent here. `wait_input` was told how long it - // may sleep; a display clock wakes faster than that on input, so only - // the host knows when a real frame interval has passed. Each spends it - // by handing back one `.tick`. + if (v.present) |f| f(h.ctx, surface); + if (v.post_present) |f| f(h.ctx); } pub fn update(p: *Pardes, ev: Event) void { - // Free a motion surface that went bad during the LAST update, before - // anything in this one can ask for it. Nothing frees it mid-update: - // handlers hand `rows` around for the length of a single update. p.shell_rows.sweep(p.gpa); - // A preview describes the frame under an idle pointer. Any state - // change can replace that text or geometry, so it cancels; buttonless - // motion is the one event that debounces/re-arms it, and ticks only - // age the current candidate. switch (ev) { .tick => {}, + .fs_req => |req| if (req.changesPane()) { + p.raw_hover_intent = false; + p.cancelLookHover(); + }, .mouse => |m| if (!(m.button == .none and m.kind == .motion)) { p.raw_hover_intent = false; p.cancelLookHover(); @@ -7737,38 +6090,15 @@ pub const Pardes = struct { p.cancelLookHover(); }, } - // A transient message is exactly as old as your last input: touch the - // keyboard or the mouse and it is gone, on every pane, because a - // message is a report and you have just proved you are back. Only - // INPUT counts — a resize, pty output, a watch or an answering worker - // all repaint without you, and a message that a background shell could - // wipe would be one you never got to read. - // - // An ARMED PROMPT is a different occupant of the same row and is not - // touched here: it lives in tag_tail, it is what the keystroke is being - // typed INTO, and it ends at Enter or Esc. So clearing here can never - // fight one — at worst it clears something the prompt was already - // hiding. switch (ev) { .key, .mouse => { for (p.panes) |slot| { if (slot) |pane| pane.msg_len = 0; } - // ...and the same reasoning bounds a clipboard read in flight. - // A terminal that gates or refuses the OSC 52 request never - // answers at all, so the request cannot be allowed to sit and - // then fire minutes later into whatever pane is focused by - // then: it lives exactly until your next keystroke, and a - // shell that answers within one round trip (every one but a - // refusing tty) is unaffected. p.clip_pending = null; }, else => {}, } - // Which input this update IS, in acme's origin alphabet, so every - // event record the handlers below produce is attributed without any - // of them being told: `K` for the keyboard, `M` for the mouse. A - // filesystem write says `E`/`F` for itself (see acmefs). if (p.fs.listeners != 0) p.fs.origin = switch (ev) { .key => 'K', .mouse => 'M', @@ -7776,26 +6106,23 @@ pub const Pardes = struct { }; switch (ev) { .resize => |sz| { - p.snap_panel_layout_once = true; + p.presentation.snap_once = true; if (comptime pdf_enabled) { - var before: [MAX_PANES]?pdf_pane.Viewport = @splat(null); + var before: [MAX_PANES]?panes.Pdf.Viewport = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; - if (pane.pdf != null) before[id] = pdf_pane.paneViewport(p, pane); + if (pane.pdf != null) before[id] = panes.Pdf.paneViewport(p, pane); } p.screen_w = sz.cols; p.screen_h = sz.rows; p.cell_pixels.w = @max(1, sz.cell_pixels.w); p.cell_pixels.h = @max(1, sz.cell_pixels.h); - // Compare the effective per-pane pixel viewport, not the - // resize event itself: duplicate SIGWINCH notifications - // must not undo a reader's manual pan. - p.computeGeom(); + layout.compute(p); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf) |*pv| { - if (!std.meta.eql(before[id], pdf_pane.paneViewport(p, pane))) { - pdf_pane.captureLayoutAnchor(pv); + if (!std.meta.eql(before[id], panes.Pdf.paneViewport(p, pane))) { + panes.Pdf.captureLayoutAnchor(pv); pv.layout_valid = false; pv.search_reveal_pending = pv.search_query.len > 0; } @@ -7809,25 +6136,20 @@ pub const Pardes = struct { }, .output => |o| { const pane = p.panes[o.pane] orelse return; - // The RAW bytes, before the emulator eats them. `pty/data`'s - // read is the only thing that wants them — the grid is a - // rendering and cannot be un-rendered — and this is one load - // and one branch on a pane nobody is reading. See - // `acmefs.notePtyOutput`. - acmefs.notePtyOutput(p, o.pane, o.bytes); - term_pane.feedOutput(p, pane, o.bytes); + filesystem.notePtyOutput(p, o.pane, o.bytes); + panes.Terminal.feedOutput(p, pane, o.bytes); + }, + .eof => |e| p.removePane(e.pane) catch |err| { + if (p.panes[e.pane]) |pane| pane.mode = .normal; + p.reportError(e.pane, "terminal exited; Del retries close", err); }, - .eof => |e| p.removePane(e.pane), .lsp_resp => |r| p.lspResponse(r.id, r.rows), .pipe_resp => |r| p.pipeResponse(r.id, r.success, r.outputs, r.failure), .file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes), .key => |key| p.handleKey(key), .mouse => |m| { - // Layout is only committed on RELEASE. Mark that one frame as - // a direct-manipulation snap before dragRelease mutates the - // weights; text/tag/PDF drags never touch panel presentation. if (m.kind == .release) switch (p.drag) { - .border_v, .border_h, .move => p.snap_panel_layout_once = true, + .border_v, .border_h, .move => p.presentation.snap_once = true, else => {}, }; p.handleMouse(m); @@ -7836,24 +6158,21 @@ pub const Pardes = struct { if (comptime pdf_enabled) { if (scroll.pane < MAX_PANES) { if (p.panes[scroll.pane]) |pane| { - if (hasPdf(pane) and p.native_images) - _ = pdf_pane.scrollPane(p, pane, @floatCast(scroll.delta_pixels)); + if (pane.hasPdf() and p.native_images) + _ = panes.Pdf.scrollPane(p, pane, @floatCast(scroll.delta_pixels)); } } } }, .paste => |bytes| p.applyPaste(bytes), .command => |line| _ = p.executeBuiltinLine(p.active, line), - // One filesystem request in, one answer out, in this update. The - // whole of the concurrency is that the transport asked from the - // loop thread; see acmefs.zig's header. - .fs_req => |r| p.emit(.{ .fs_reply = acmefs.handle(p, r) }), + .fs_req => |r| p.emit(.{ .fs_reply = filesystem.handle(p, r) }), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, .pointer_leave => p.pointer_inside = false, .tick => { p.chrome_animation.advance(); - p.advancePanelAnimations(); + p.presentation.advance(); p.advanceLookHover(); }, } @@ -7862,15 +6181,6 @@ pub const Pardes = struct { p.fsReport(); } - /// TAG EDITS, which no single call site owns: a tag is assembled from a - /// live prefix and an editable tail by half a dozen paths (typing, a prompt - /// arming, a Save clearing the dirty marker, a shell reporting a new cwd), - /// so it is diffed at the END of an update, where it is finally settled. - /// acme can hook `textinsert` on the tag itself because its tag IS a text - /// buffer; pardes's is a rendering, so the diff is the honest equivalent. - /// - /// Costs nothing when nobody is listening: one branch, and the snapshots - /// are only allocated for panes a script has opened. fn fsReport(p: *Pardes) void { if (p.fs.listeners == 0) return; for (p.panes, 0..) |slot, id| { @@ -7879,30 +6189,14 @@ pub const Pardes = struct { const tag = p.tagText(p.scratch.allocator(), pane) catch continue; const snap = &p.fs.panes[id].tag_snap; if (std.mem.eql(u8, snap.items, tag)) continue; - // First sight of a tag is not an edit: the script just opened the - // file and can read `tag` for itself. `release` drops the snapshot - // with the last reader, so this stays true across re-opens. if (snap.capacity != 0 or snap.items.len != 0) - acmefs.noteReplace(p, id, true, snap.items, tag); + filesystem.noteReplace(p, id, true, snap.items, tag); snap.clearRetainingCapacity(); snap.appendSlice(p.gpa, tag) catch {}; } } - /// THE DEFAULT REGISTER, and nothing else. helix: an ordinary `y`/`d`/`c` - /// writes here and the system clipboard never hears about it — which is - /// also the bug this spelling fixes, because a mirror on every write made - /// deleting one character clobber whatever the desktop was holding. - /// `SPC y` is the command that crosses over (setClipboard below). fn setYank(p: *Pardes, text: []const u8) void { - // Inside a multi-selection replay the register collects EVERY range's - // text, in document order — the passes run last-range-first, so each - // new piece goes in front of what is already there. - // ponytail: helix keeps one register VALUE per range and pastes - // value[i] back at range[i]; pardes has a single register, so N - // cursors yank one newline-joined blob and a paste puts that whole - // blob at every cursor. Written down as a differential waiver rather - // than faked — a per-range register is its own feature. if (p.multi_on and !p.multi_first) { const old = p.yank orelse ""; const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n"; @@ -7915,16 +6209,6 @@ pub const Pardes = struct { p.yank = p.gpa.dupe(u8, text) catch null; } - /// ...and the register PLUS the system clipboard, which is the whole - /// difference between `y` and `SPC y`. - /// - /// One mirror per KEYSTROKE rather than per cursor: a multi-selection - /// replay runs last-range-first and `multi_first` marks its first pass, so - /// emitting there queues exactly one effect — and the shell reads - /// `core.yank` when it DRAINS, by which time every later pass has folded - /// its range in. That asymmetry used to be a silent hole: the old mirror - /// sat past the join's early return, so a multi-cursor yank reached the - /// clipboard on one cursor and not on two. fn setClipboard(p: *Pardes, text: []const u8) void { p.setYank(text); if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} }); @@ -7933,32 +6217,19 @@ pub const Pardes = struct { /// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers. pub const ClipRequest = struct { pane: usize, - /// the pane's identity, not its slot: the answer can arrive whole - /// keystrokes later (a tty's OSC 52 round trip) and a freed slot is - /// reused by an unrelated pane. serial: u32, mode: enum { after, before, replace }, }; - /// `SPC p` / `SPC P` / `SPC R`: ask the shell for the system clipboard and - /// remember what to do with it. The request is deliberately fire-and-hope - /// — a terminal that refuses the OSC 52 read simply never answers, and the - /// next keystroke drops the request (see update) rather than letting a - /// paste land minutes late in whatever pane is focused by then. pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode")) void { const pane = p.panes[id] orelse return; p.clip_pending = .{ .pane = id, .serial = pane.serial, .mode = mode }; p.emit(.read_clipboard); } - /// Type text at a pane's program, the way a terminal emulator pastes: - /// bracketed when the app set mode 2004 (readline/vim/helix strip the - /// markers and refuse to run what arrives), else with `\n` turned to `\r`, - /// because a raw newline in an unbracketed paste IS the Enter key and a - /// multi-line paste would run every line but the last. pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void { if (text.len == 0) return; - if (term_pane.bracketedPaste(pane)) { + if (panes.Terminal.bracketedPaste(pane)) { p.emitWrite(id, "\x1b[200~"); p.emitWrite(id, text); p.emitWrite(id, "\x1b[201~"); @@ -7971,11 +6242,6 @@ pub const Pardes = struct { p.emitWrite(id, cp); } - /// The shell answered with system-clipboard text — or the desktop pasted - /// into us unasked. Either way the bytes are pasted WITHOUT going through - /// the register: helix's clipboard commands and the default register are - /// separate stores, and a paste that quietly overwrote your `y` would be - /// the same clobbering bug in the other direction. fn applyPaste(p: *Pardes, bytes: []const u8) void { const req = p.clip_pending; p.clip_pending = null; @@ -7984,30 +6250,19 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; - // A pane in tty mode has no editable buffer to paste INTO — the pty - // owns its screen. Type the bytes at the program instead, which is - // also what makes a desktop paste (Ctrl-Shift-V, middle click, the - // window manager's own) reach a shell at all. if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes); switch (if (req) |r| r.mode else .after) { - .after => p.pasteText(pane, bytes, false), - .before => p.pasteText(pane, bytes, true), + .after => p.pasteText(pane, bytes, false, 1), + .before => p.pasteText(pane, bytes, true, 1), .replace => p.replaceWithText(pane, bytes), } } - /// `SPC y` / `SPC Y`: the selection to the system clipboard. `main_only` - /// is helix's capital — every cursor's text joined, versus the primary - /// selection's alone. A PDF has no editable buffer to replay over, so its - /// own selection answers directly. pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { if (comptime pdf_enabled) if (pane.pdf) |pv| { if (pv.selection_text.len > 0) p.setClipboard(pv.selection_text); return; }; - // the ordinary `y` path, so what reaches the clipboard is exactly what - // the key would have put in the register — including the multi-cursor - // join, which is replaySels' business and not a second implementation if (pane.nsel > 0 and !main_only) { p.replaySels(pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } }); } else { @@ -8019,11 +6274,6 @@ pub const Pardes = struct { p.emit(.{ .set_clipboard = {} }); } - /// A per-KEYSTROKE action reached from inside a per-SELECTION replay — - /// one that opens, closes or focuses a pane, or asks the language backend. - /// It must happen once rather than once per cursor, and once it has, the - /// remaining passes are meaningless (the pane they would edit may be gone), - /// so the replay stops. True = this pass may go ahead. fn multiOnce(p: *Pardes) bool { if (!p.multi_on) return true; p.multi_stop = true; @@ -8032,16 +6282,6 @@ pub const Pardes = struct { // ---- tag + selection text (chord sources) ---- - /// the live tag prefix: the pane's cwd/path, plus pane-local state whose - /// owner reports it (PDF view and image renderer choices). Those choices - /// mutate only through builtins under SPC t. The mode used to lead this as a word; it is - /// the one character in the layout box now (renderPane), so every tagline - /// starts four columns further left and spends them on the path instead. - /// - /// Arena-allocated like everything the renderer is handed — the tag is - /// retained through the frame and a cwd can be rewritten under us by the - /// next shell report, so this owns its bytes rather than lending the - /// pane's. pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( @@ -8049,9 +6289,9 @@ pub const Pardes = struct { "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) |*state| return image_pane.tagPrefix(arena, state); + if (pane.image) |*state| return panes.Image.tagPrefix(arena, state); if (pane.file) |f| { - if (output_pane.fileTraits(f.output).saves and f.revision != f.saved_revision) + if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) return std.fmt.allocPrint(arena, "{s}{s}", .{ f.path, dirty_marker }); return arena.dupe(u8, f.path); } @@ -8064,13 +6304,6 @@ pub const Pardes = struct { return defaultTail(pane); } - /// The untouched command tail for this pane class. `curTail` and tagGap - /// must ask the same question: otherwise a terminal renders Filter but - /// still votes for the shorter generic tail when a column is aligned. - /// - /// Save leads wherever the pane holds text of its own — every pane with a - /// file, an output buffer included, plus every terminal. What is left is an - /// image and a PDF: their bytes on disk already are what they are. fn defaultTail(pane: *const Pane) []const u8 { if (pane.file != null) return file_pane_tail; if (pane.isTerminal()) return terminal_pane_tail; @@ -8084,60 +6317,6 @@ pub const Pardes = struct { return null; } - /// Spaces to sit between the path and the commands, so the commands END - /// `tag_right_pad` columns short of the pane's edge — right-aligned, with - /// that many columns left free to type in. - /// - /// Real spaces rather than a second print at an offset, because the tag is - /// ONE buffer that tag_col, the mouse, the motions and the chord all index - /// by the same columns; two separately-positioned pieces would need a - /// column-to-offset map that none of them has. Being characters is also - /// what makes both paddings editable, which is the point: `:` lands at the - /// start of this gap, and there are `tag_right_pad` free columns past the - /// commands to type into. - /// - /// Zero once the tag has been touched. From then on the spaces are IN - /// tag_tail and belong to you — recomputing would both double them and - /// slide the commands sideways under your cursor as you type. So an - /// untouched tag reflows with the pane and an edited one stays put. - /// - /// The end column is shared by the whole LAYOUT COLUMN — every DRAWN pane - /// at the same x and width, which is exactly the set whose taglines sit - /// above one another on screen (`h == 0` is a pane squeezed off the bottom - /// by a shrunk window: renderPane returns early on it, so it has no words - /// to line up with and gets no vote). `tag_right_pad` sets the end, but a - /// path too long to fit inside that pad used to collapse only ITS pane's - /// gap to zero, which left one row's commands jammed against the path while - /// the row below kept its out at the pad. Now the column moves out - /// together, so the words stay in a line and a click walks down them. - /// - /// Alignment is measured in display cells. Cursor and selection state use - /// UTF-8 byte offsets, then map through the same grapheme-width helpers at - /// the screen boundary. - /// - /// Two rules make up the "when possible": - /// - /// - a voter that does not FIT is counted at the pane's right edge rather - /// than dropped. Dropping it would leave the rest of the column its - /// typing room, but it is a threshold: one column of resize either side - /// of the fit moves every tagline in the column by the whole pad, and - /// dragging a window edge across that width snaps the words back and - /// forth under the pointer. Clamping buys a crossing one column wide - /// and monotone, and the price is a column of taglines that can end - /// hard against the right edge with nothing left over to type in. - /// - a TOUCHED tag votes with the end it was FROZEN at, not with its live - /// tail: the gap is baked into tag_tail as leading spaces, and counting - /// what you type after the builtins would drag the column sideways on - /// 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. - /// - /// 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; @@ -8148,25 +6327,19 @@ pub const Pardes = struct { for (p.panes, 0..) |slot, qid| if (slot) |q| { const qr = p.rects[qid]; if (qr.h == 0 or qr.x != r.x or qr.w != r.w) continue; - // prefix ++ the spaces in front of the words ++ the words: the - // frozen gap for a touched tail, the default's own single leading - // space for an untouched one (which is why there is no +1 here) const words = defaultTail(q); const laid = if (q.tag_init) q.tagSlice() else words; const lead = laid.len - std.mem.trimStart(u8, laid, " ").len; - const q_end = file_pane.displayWidth(p.tagPrefix(q) catch continue) + lead + file_pane.displayWidth(std.mem.trimStart(u8, words, " ")); + const q_end = panes.File.displayWidth(p.tagPrefix(q) catch continue) + lead + panes.File.displayWidth(std.mem.trimStart(u8, words, " ")); end = @max(end, @min(q_end, tw)); }; return end -| used; } - /// the tag exactly as it is rendered: prefix ++ gap ++ tail. THE text - /// tag_col and tag_anchor index, so the renderer, the mouse, the motions - /// and the chord all read the same bytes at the same columns. pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try p.tagPrefix(pane); const tail = curTail(pane); - const gap = p.tagGap(pane, file_pane.displayWidth(prefix) + file_pane.displayWidth(tail)); + const gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.displayWidth(tail)); const out = try arena.alloc(u8, prefix.len + gap + tail.len); @memcpy(out[0..prefix.len], prefix); @memset(out[prefix.len..][0..gap], ' '); @@ -8174,14 +6347,11 @@ pub const Pardes = struct { return out; } - /// Take the laid-out tail into the pane's own buffer, once, on first touch. - /// The gap comes along as ordinary characters — that is what hands the - /// padding to you to edit, and what freezes it against reflow from here on. pub fn seedTail(p: *Pardes, pane: *Pane) void { if (pane.tag_init) return; const tail = curTail(pane); const prefix = (p.tagPrefix(pane) catch return); - const gap = p.tagGap(pane, file_pane.displayWidth(prefix) + file_pane.displayWidth(tail)); + const gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.displayWidth(tail)); if (gap + tail.len > pane.tag_tail.len) return; @memset(pane.tag_tail[0..gap], ' '); @memcpy(pane.tag_tail[gap..][0..tail.len], tail); @@ -8189,11 +6359,6 @@ pub const Pardes = struct { pane.tag_init = true; } - /// focus the tag for editing, seeding the tail on first touch and parking - /// the byte cursor at the grapheme displayed under screen column `col`. - /// NEGATIVE means the tail's first WORD, which is where `:` and a tagline - /// hop land: a place no click can name, so it needs no sentinel of its own - /// and the callers need no prefix length. fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len); p.seedTail(pane); @@ -8203,28 +6368,19 @@ pub const Pardes = struct { pane.tag_sel = false; // a one-line tag has no use for normal mode: always insert pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); const end = edit0 + @as(i32, @intCast(pane.tag_tail_len)); - // Past the gap that right-aligns the builtins, not at the first - // editable column: `:` promises the tail's START, and the start of a - // run of layout spaces is not it. Landing there would cost `:w` its - // second keystroke — w would select the whitespace and execute nothing - // — and `:w` being the same two keys every time is the whole point of - // the door. The spaces stay editable; h and Left still walk into them. const tail = pane.tagSlice(); const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len); if (col < 0) { pane.tag_col = @intCast(@min(edit0 + lead, end)); } else { const text = p.tagText(p.scratch.allocator(), pane) catch return; - pane.tag_col = @intCast(@min(text.len, file_pane.rawAtDisplay(text, @intCast(col)))); + pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @intCast(col)))); } } fn exitTagEdit(pane: *Pane) void { - // tags are always insert; leaving one restores the body mode: files - // back to normal, terminals to whatever they had — a click (this runs - // on every body press) must never change a shell pane's mode if (pane.isTerminal()) { if (pane.tag_edit) pane.mode = pane.tag_mode; } else pane.mode = .normal; @@ -8240,9 +6396,6 @@ pub const Pardes = struct { return .{ .lo = @min(a, c), .hi = @max(a, c) }; } - /// the text a tag Enter/Tab chord (and `y`) acts on: the char selection, - /// else the file-ish word under the cursor. Over the WHOLE rendered tag, so - /// the path is a word like any other — Enter on it looks it. fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 { const text = p.tagText(p.scratch.allocator(), pane) catch return null; if (pane.tag_sel) { @@ -8254,9 +6407,6 @@ pub const Pardes = struct { return if (b.hi > b.lo) text[b.lo..b.hi] else null; } - /// tag-edit key dispatch: a modal one-line editor over the rendered tag, - /// sharing the pane's mode — the cursor moves over all of it, edits reach - /// only the tail. Newlines are always dropped. fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void { if (hit(key, config.escape)) { exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it @@ -8270,9 +6420,6 @@ pub const Pardes = struct { } return; } - // the prefix is live chrome, not text you own: the cursor may sit in it - // (that is how the path selects), but every edit below is measured from - // the first EDITABLE column and simply does nothing to the left of it. const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len); const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail_len)); if (key.text.len > 0) { @@ -8311,29 +6458,19 @@ pub const Pardes = struct { /// write a helix range back onto the tag cursor + selection: the rendered /// tag's one-line mirror of setPaneRange. - fn setTagRange(pane: *Pane, text: []const u8, r: modal.HxRange) void { + fn setTagRange(pane: *Pane, text: []const u8, r: modal.Selection) void { const lo = @min(r.anchor, r.head); const hi = @max(r.anchor, r.head); - pane.tag_col = @intCast(modal.hxCursor(text, r)); + pane.tag_col = @intCast(modal.selectionCursor(text, r)); pane.tag_sel = modal.nextGrapheme(text, lo) < hi; // one grapheme IS the block cursor if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else modal.prevGrapheme(text, hi)); } - /// normal mode ON the tag — where `:` lands. The body's own helix motions - /// with the WHOLE rendered tag as a one-line document (so the path selects - /// like any other text, and Enter on it looks it), plus insert entry and - /// the acme chords; editing keys stay in insert (`i` then type, like the - /// mouse path) and never reach left of `edit0`. fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void { const text = p.tagText(p.scratch.allocator(), pane) catch return; const cur: usize = @min(@as(usize, pane.tag_col), text.len); // Esc abandons the command line: back to the body, tail kept as text if (hit(key, config.escape)) return exitTagEdit(pane); - // the chord: run the selection (or the word under the cursor) and drop - // back into the body — the whole point of `:`. The same two bindings as - // everywhere else (config.look_key / exec_key), so the command line is - // `:w` by default. - // Nothing under the cursor means nothing ran: the tag keeps focus. if (hit(key, config.look_key) or hit(key, config.exec_key)) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; const txt = p.tagChordText(pane) orelse return; @@ -8342,23 +6479,10 @@ pub const Pardes = struct { p.runBuiltin(cmd, id, "", txt); return; } - // y — yank what the chord would run: the selection, else the word under - // the cursor. The path is selectable, so this is how you copy it out. - // - // The ONE register write that still mirrors to the system clipboard - // without `SPC` in front of it, and it is not an exception so much as - // the only spelling available: a tag is always in insert mode, the - // leader is body-normal only, so `SPC y` cannot be pressed here — and - // "copy this path somewhere else" is the entire reason the chord - // exists. A path that only reached the internal register would be a - // key that does nothing you can observe. if (hit(key, config.tag_yank)) { if (p.tagChordText(pane)) |txt| p.setClipboard(txt); return; } - // insert entry (one line, so I/A are the tail's ends). The prefix is - // read-only, so entering insert inside it parks at the first editable - // column instead — you can never be typing into the path. if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { @@ -8375,15 +6499,6 @@ pub const Pardes = struct { pane.mode = .insert; return; } - // h/j/k/l — a tagline is a place in the LAYOUT, so the four letters walk - // it: focus the neighbour and land on ITS tagline, still in normal mode, - // so tag-to-tag navigation never drops through a body. Runs the SAME - // Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w` run, off the - // SAME table (config.window_keys) — one focusDir, one binding. Only the - // LETTER column: the arrows keep the in-tag grapheme motion below, so - // the letters cost nothing. Nothing in that direction = stay put, tag - // and all: focusDir left `active` alone, so there is nothing to undo — - // EXCEPT upwards, where "nothing" is still something (see below). const dir: ?Builtin = for (config.window_keys) |wk| { if (hit(key, &.{wk.letter})) break wk.cmd; } else null; @@ -8391,11 +6506,6 @@ pub const Pardes = struct { const from = p.active; p.runBuiltin(d, from, "", null); if (p.active == from) { - // above the topmost tagline is the TOPBAR — row 0, the global - // one. It is not a pane, so focusDir can never reach it; this - // one fallback is what makes `k` walk off the top of the - // layout instead of dying there. Only from a tagline: a body's - // `SPC w k`/`Ctrl-w k` keeps its pane-to-pane meaning. if (d == .Up) { exitTagEdit(pane); p.topbar_col = 0; @@ -8411,14 +6521,7 @@ pub const Pardes = struct { if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r); } - /// the one-line normal-mode motion vocabulary as a helix range over `text`, - /// or null when `key` is not one of them: arrows by grapheme, `0`/`$`/`^` - /// (Home/End) to the ends, and the word motions, which select the span they - /// traverse exactly like the body's. A pane's tag and the TOPBAR are the - /// same one-line normal mode over different bytes, so the keys are read in - /// one place; only `h`/`l` differ — a tagline spends them on the layout, - /// the topbar has no layout — and each caller answers those itself. - fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.HxRange { + fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.Selection { const target: ?usize = if (hit(key, config.line_move_left)) modal.prevGrapheme(text, cur) else if (hit(key, config.line_move_right)) @@ -8447,16 +6550,9 @@ pub const Pardes = struct { else null; const t = wt orelse return null; - return modal.hxWordMove(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); + return modal.moveWord(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); } - /// normal mode ON the topbar — row 0, where `k` off a top-row tagline - /// lands. The same one-line vocabulary a tag has (lineMotion), plus `h`/`l` - /// as plain grapheme motion: up here they have no neighbouring window to - /// walk to, so they cost nothing. Enter/Tab runs the word under the cursor - /// through the very dispatch a middle click on it uses, and `j` drops back - /// onto a tagline. The bar is chrome with no tail of its own, so there is - /// deliberately no insert mode and no selection: focus, move, run, leave. fn topbarKey(p: *Pardes, key: Key) void { var tb_buf: [1200]u8 = undefined; const bar = p.topbar(&tb_buf); @@ -8467,19 +6563,12 @@ pub const Pardes = struct { p.topbar_col = null; // the active pane still has its body focus return; } - // the chord: run the word under the cursor, exactly as a middle click - // on it does. Leave the bar FIRST — `Kill` lives up here and tears the - // session down, the same hazard the pane-tag chord has with `Del`. if (hit(key, config.look_key) or hit(key, config.exec_key)) { const word = wordAtCol(bar, cur); p.topbar_col = null; if (word.len > 0) _ = p.execute(p.active, word); return; } - // j — back down onto a tagline, the mirror of the k that got you here: - // the pane you came from if it still holds the top row, else the - // leftmost pane that does. Recomputed, never remembered, so a pane - // deleted while the bar had focus strands nobody. if (hit(key, config.topbar_down)) { var dest: ?usize = null; for (p.panes, 0..) |slot, i| { @@ -8503,7 +6592,7 @@ pub const Pardes = struct { else if (hit(key, config.topbar_right)) modal.nextGrapheme(bar, cur) else if (lineMotion(bar, cur, key)) |r| - modal.hxCursor(bar, r) + modal.selectionCursor(bar, r) else null; // never past the last cell: there is nothing to append up here, so the @@ -8519,7 +6608,7 @@ pub const Pardes = struct { /// block-selected text, newline-joined per row; reads the rendered screen /// so typed text and shell output select identically. Scratch-owned. - fn selectionText(p: *Pardes, pane: *Pane, sl: Sel) ![]const u8 { + fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 { const arena = p.scratch.allocator(); const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); @@ -8532,8 +6621,8 @@ pub const Pardes = struct { var count_row: i32 = 0; while (count_it.next()) |line| : (count_row += 1) { if (count_row < r0 or count_row > r1) continue; - const b0 = @min(file_pane.renderedLineByteCol(pane, count_row, line, c0), line.len); - const b1 = modal.nextGrapheme(line, @min(file_pane.renderedLineByteCol(pane, count_row, line, c1), line.len)); + const b0 = @min(panes.File.renderedLineByteCol(pane, count_row, line, c0), line.len); + const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, count_row, line, c1), line.len)); total += b1 - b0 + @intFromBool(selected > 0); selected += 1; } @@ -8549,18 +6638,14 @@ pub const Pardes = struct { at += 1; } first = false; - const b0 = @min(file_pane.renderedLineByteCol(pane, v, line, c0), line.len); - const b1 = modal.nextGrapheme(line, @min(file_pane.renderedLineByteCol(pane, v, line, c1), line.len)); + const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, c0), line.len); + const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, c1), line.len)); @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]); at += b1 - b0; } return out; } - /// Is (r,c) inside the span (ar,ac)..(br,bc), in reading order? Either end - /// may be given first — a selection swept upwards has its anchor after its - /// head — and the coordinate SYSTEM is the caller's: screen cells for a - /// mouse selection, absolute rows for a modal one. fn spanHas(r: i32, c: i32, ar: i32, ac: i32, br: i32, bc: i32) bool { const fwd = ar < br or (ar == br and ac <= bc); const sr, const sc = if (fwd) .{ ar, ac } else .{ br, bc }; @@ -8570,10 +6655,6 @@ pub const Pardes = struct { const ExpandedWord = struct { lo: usize, hi: usize }; - /// The single spelling rule used by pointer and keyboard expansion. - /// Spaces inside an explicit @`command run` belong to that run; every - /// other separator is no operand. In particular, do not let wordBounds' - /// left scan make a blank cell borrow its neighbour. fn expandedWord(line: []const u8, col: usize) ?ExpandedWord { if (col >= line.len) return null; const b = config.wordBounds(line, col); @@ -8585,7 +6666,7 @@ pub const Pardes = struct { /// acme: a no-drag middle/right click expands to the word under it — /// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling) - fn expandedSel(p: *Pardes, pane: *Pane, at: Sel) ?Sel { + fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { var sl = at; if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl; const text = p.paneText(pane) catch return null; @@ -8594,10 +6675,10 @@ pub const Pardes = struct { while (it.next()) |line| : (v += 1) { if (v != sl.r0) continue; const display_col: usize = @intCast(@max(0, sl.c0)); - const col = file_pane.renderedLineByteCol(pane, v, line, display_col); + const col = panes.File.renderedLineByteCol(pane, v, line, display_col); const b = expandedWord(line, col) orelse return null; - sl.c0 = @intCast(file_pane.renderedLineDisplayCol(pane, v, line, b.lo)); - sl.c1 = @intCast(file_pane.renderedLineDisplayCol(pane, v, line, b.hi) - 1); + sl.c0 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.lo)); + sl.c1 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.hi) - 1); return sl; } return null; @@ -8605,16 +6686,16 @@ pub const Pardes = struct { /// the word under the modal cursor as a pane-local selection (paneText /// coords: row 0 is the tag; file panes carry the line-number prefix) - fn cursorWordSel(p: *Pardes, pane: *Pane) Sel { + fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel { const w = pane.wrapRow(pane.cur_row, pane.cur_col); const vrow = w.row + @as(i32, BOX_H); const vcol = if (pane.file != null) - file_pane.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) + panes.File.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, panes.File.gutterWidth(pane)) else blk: { - const pl = p.paneCursorLines(pane) catch break :blk pane.cur_col; - const local = pane.cur_row - pl.row0; - if (local < 0 or @as(usize, @intCast(local)) >= pl.lines.len) break :blk pane.cur_col; - break :blk file_pane.lineDisplayOffset(pl.lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col))); + const lines = p.paneCursorLines(pane) catch break :blk pane.cur_col; + const local = pane.cur_row; + if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.cur_col; + break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col))); }; return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; } @@ -8628,30 +6709,14 @@ pub const Pardes = struct { return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } - /// Run one acme chord (`Look`/`Exec`) once per EXPLICIT selection, in - /// document order. False when there is only the primary range, which is - /// the caller's cue to take its own single-selection path unchanged. - /// - /// The bytes of every range are copied BEFORE the first builtin runs, for - /// the reason `submitPipe` copies too: a `Look` opens panes and an `Exec` - /// can run a builtin that edits or closes the very pane these offsets are - /// into. After that the loop owns nothing of the pane but its slot, and - /// re-checks even that. - /// - /// FOCUS FOLLOWS THE PRIMARY, not the last range. `lookAt` sets `p.active` - /// for every target it opens, so without this the pane you end up looking - /// at is whichever selection happened to sort last — an accident rather - /// than an answer. `Exec` moves focus for neither, so this costs it - /// nothing. fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool { if (pane.nsel == 0) return false; - const pl = p.paneCursorLines(pane) catch return false; - const text = p.flatSurface(pane, pl) catch return false; - var ranges: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, text, pl.row0, &ranges); + const text = p.flatSurface(pane) catch return false; + var ranges: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(text, 0, &ranges); if (got.n < 2) return false; - var texts: [MAX_SELS][]u8 = undefined; + var texts: [Pane.max_selections][]u8 = undefined; var made: usize = 0; defer for (texts[0..made]) |t| p.gpa.free(t); for (ranges[0..got.n]) |range| { @@ -8675,9 +6740,6 @@ pub const Pardes = struct { for (texts[0..made], 0..) |txt, i| { p.runBuiltin(cmd, id, "", txt); if (i == got.pri) primary_active = p.active; - // The pane this loop is standing on can be closed by what it just - // ran — a selection whose text is `Del` is a legal Exec. Same - // slot-and-serial re-check `replaySels` makes for the same reason. const still = p.panes[id] orelse break; if (still.serial != serial) break; } @@ -8694,19 +6756,16 @@ pub const Pardes = struct { /// Exactly what a no-drag middle/right click will dispatch. text: ?[]const u8 = null, /// What hover paints. Null names the pane's live modal selection. - preview: ?Sel = null, + preview: ?Pane.Sel = null, /// File words stay logical so a soft-wrapped operand is not cut into /// unrelated rendered fragments. Null for tags and non-file panes. file_word: ?FileWordSpan = null, /// A newly expanded word is installed in the gesture's button slot; /// an existing selection is only borrowed and must not replace it. - expanded: ?Sel = null, + expanded: ?Pane.Sel = null, }; - /// Resolve a no-drag pointer gesture without mutating Pane. Click release - /// and delayed Look hover call this same production primitive, so existing - /// selection precedence and word expansion cannot drift into two policies. - fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Sel) PointerOperand { + fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand { const visible = clicked.r0 - @as(i32, BOX_H); const wrapped = pane.wrapAt(visible); const row = wrapped.line; @@ -8715,7 +6774,7 @@ pub const Pardes = struct { pane, wrapped.line, wrapped.at, - clicked.c0 - (if (pane.file != null) @as(i32, config.PREFIX_W) else 0), + clicked.c0 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ) else clicked.c0; @@ -8742,7 +6801,7 @@ pub const Pardes = struct { return result; } if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { - const line = file_pane.sourceLine(pane, row); + const line = panes.File.sourceLine(pane, row); const source_col: usize = @intCast(@max(0, col)); const b = expandedWord(line, source_col) orelse return result; const lo = std.math.cast(i32, b.lo) orelse return result; @@ -8750,18 +6809,14 @@ pub const Pardes = struct { result.text = line[b.lo..b.hi]; result.file_word = .{ .row = row, .lo = lo, .hi = hi }; - // The transient middle/right slot still carries screen endpoints, - // but no text is reconstructed from this rectangular legacy - // shape. The logical span above is authoritative for dispatch and - // hover painting. var expanded = clicked; const first = pane.wrapRow(row, lo); const last = pane.wrapRow(row, hi - 1); if (first.row >= 0 and last.row >= 0) { expanded.r0 = first.row + @as(i32, BOX_H); - expanded.c0 = @as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, row, first.at, lo); + expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo); expanded.r1 = last.row + @as(i32, BOX_H); - expanded.c1 = @as(i32, config.PREFIX_W) + file_pane.displayEndOffset(pane, row, last.at, hi - 1); + expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1); } result.preview = expanded; result.expanded = expanded; @@ -8776,13 +6831,6 @@ pub const Pardes = struct { return result; } - /// What an execute takes as its ARGUMENT: text selected ANYWHERE (acme — - /// the chord argument is whatever is selected, in any window), searched - /// `first` (the pane the execute happened in), then the active pane (making - /// a selection focuses its pane, so it holds the most recent one), then - /// slot order. Per pane a kept left selection wins, else an explicit modal - /// (v/x, n/N) one. Scratch-owned: dead at the next arena reset, so a caller - /// that keeps it (the 2-1 chord) copies. fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { var k: usize = 0; while (k < p.panes.len + 2) : (k += 1) { @@ -8806,14 +6854,6 @@ pub const Pardes = struct { return null; } - /// Splice a chord argument onto what the gesture pointed at. acme's 2-1 - /// chord means "run this WITH that", and that is a command LINE: `Grep` - /// plus a held `foo` is the same string `Grep foo` you could have typed, - /// so it goes down the one path that already knows how to split a name - /// from its tail. The alternative — a second argument threaded past the - /// dispatcher — is what used to be here, and it could not survive Exec - /// becoming an ordinary builtin with one argument slot like every other. - /// Scratch-owned; `txt` itself when there is nothing to splice. fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 { const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n"); if (a.len == 0) return txt; @@ -8822,21 +6862,9 @@ pub const Pardes = struct { } fn handleKey(p: *Pardes, key: Key) void { - // the topbar holds the keyboard (`k` off the topmost tagline): row 0 is - // its own one-line normal mode and owns every key until Esc or a chord. - // Before the pane lookup because it needs no pane — that is the point. if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; - // a SPC leader in flight swallows the next key, whatever it is — - // before Ctrl-w, so a modified key abandons the sequence instead of - // arming a second prefix on top of it if (p.leader_on) return p.leaderKey(key); - // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l or the - // arrows), running the SAME builtins `SPC w h/j/k/l` runs off the SAME - // table a tagline's own h/j/k/l reads. It stays despite the leader - // covering it because it reaches one place the leader cannot: a pane - // in raw tty mode never sees SPC (the shell owns every printable key), - // so this is the only keyboard way out of one. if (p.ctrl_w_pending) { p.ctrl_w_pending = false; for (config.window_keys) |wk| { @@ -8856,46 +6884,31 @@ pub const Pardes = struct { const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; - const src = p.splitParent(p.active); - const f = p.layoutFindTerm(src).?; - p.layoutInsert(f.col, f.idx + 1, free); - p.splitBelow(src, nt); + const src = layout.splitParent(p, p.active); + const f = layout.findPane(p, src).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, src, nt); p.active = free; return; } - // the jump chords, global for the same reason: Ctrl-o has to get you - // out of wherever you are, including a pane in raw tty mode. Above the - // acme chords below, which is what makes Ctrl-i reachable at all — on - // a kitty-protocol host it arrives as its own key, and where it does - // not it IS Tab (0x09) and falls through to Exec, see config.jump_keys. for (config.jump_keys) |jk| { if (hit(key, &.{jk.chord})) return p.runBuiltin(jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { - const f = p.layoutFindTerm(p.active).?; + const f = layout.findPane(p, p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { - _ = p.layoutSplitColumn(p.active, p.active, false); + _ = layout.splitColumn(p, p.active, p.active, false); } return; } - // the configured Ctrl-key, or Shift-Esc, toggles raw tty mode in and - // out (terminals only); tty is deliberately off the normal editing - // path. Shift-Esc needs a host that reports modifiers on Escape (the - // kitty keyboard protocol); where it doesn't it arrives as a plain - // Escape and still means what Escape always means. const tty_alt = hit(key, config.tty_toggle_alt); const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or tty_alt; if (pane.isTerminal() and tty_toggle) { if (pane.mode == .tty) { - // Shift-Esc IN tty is what Escape is in normal mode: Last, - // the pane you were in before this one. The pane keeps its tty - // mode, so coming back lands you in the program you left rather - // than in normal mode on top of it — and Ctrl- is still how you leave tty in place. if (tty_alt) return p.runBuiltin(.Last, p.active, "", null); pane.mode = .normal; - pane.pending = 0; - } else term_pane.enterTty(p, p.active); + pane.normal.clear(); + } else panes.Terminal.enterTty(p, p.active); return; } // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc. @@ -8907,75 +6920,42 @@ pub const Pardes = struct { if (hit(key, config.tty_paste_clipboard)) return p.clipRequest(p.active, .after); if (hit(key, config.tty_paste)) return p.typeToTty(p.active, pane, p.yank orelse return); } - // `|` owns the same visible one-line tag input as search, but Enter - // snapshots an asynchronous shell filter. Escape is a pure cancel: - // restore the old tail and never emit a request. - if (pane.hasPipePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { - const prompt_at = pane.promptAt().?; - if (hit(key, config.search_submit)) p.submitPipe(p.active); - pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); - exitTagEdit(pane); - pane.mode = .normal; - pane.pending = 0; - return; - } - // a save input in flight (scratch or terminal): Enter writes the path, - // Esc abandons; both drop the prompt text and return to the body. - if (pane.hasSavePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { - const prompt_at = pane.promptAt().?; - if (hit(key, config.search_submit)) - p.submitSave(p.active) - else - pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); - exitTagEdit(pane); - pane.mode = .normal; - pane.pending = 0; - return; - } - // a search input in flight (`/` or Find): Enter searches, Esc abandons; - // both restore the tag tail and hand focus back to the body. - if (pane.hasSearchPrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { - const prompt_at = pane.promptAt().?; - if (hit(key, config.search_submit)) - p.submitSearch(p.active) - else if (selRegexArmed(pane)) |_| - p.applySelRegex(pane, "", false); - pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); + if (pane.prompt != .none and (hit(key, config.search_submit) or hit(key, config.escape))) { + const submit = hit(key, config.search_submit); + switch (pane.prompt) { + .pipe => |pipe| { + if (submit) p.submitPipe(p.active); + pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len); + }, + .save => |at| { + if (submit) p.submitSave(p.active) else pane.tag_tail_len = @min(at, pane.tag_tail_len); + }, + .search => |at| { + if (submit) + p.submitSearch(p.active) + else if (selRegexArmed(pane)) |_| + p.applySelRegex(pane, "", false); + pane.tag_tail_len = @min(at, pane.tag_tail_len); + }, + .none => unreachable, + } exitTagEdit(pane); pane.mode = .normal; - pane.pending = 0; + pane.normal.clear(); return; } - // tag editing intercepts every other key: a modal one-line editor over - // the tail, sharing the pane's mode. Above the body chords — a focused - // tag owns Enter/Tab too (that IS the `:` command line). if (pane.tag_edit) { if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); - // an armed `s`/`S` re-runs its pattern after EVERY keystroke: that - // live preview is what makes it interactive. Through the slot, not - // `pane` — a tag chord above can run a builtin that closed it. const pn = p.panes[p.active] orelse return; if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split); return; } - // the acme chords in the body — look at / execute (config.look_key and - // exec_key, Enter and Tab by default) the EXPLICIT modal selection - // (v/x/X, terminal n/N, search n/N); implicit motion residue falls back - // to the file-ish word under the cursor. if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; p.pinPaneCursor(pane); - const explicit = (p.native_images and hasPdfSelection(pane)) or + const explicit = (p.native_images and pane.hasPdfSelection()) or (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { - // ONE ACTION PER SELECTION. Both chords used to read the - // PRIMARY range and drop the other cursors on the floor, which - // is the one thing a multi-cursor editor must not do with a - // command the user aimed at every cursor. `chordEachSel` is - // the `submitPipe` shape — `paneRanges` once, forward, copies - // taken before anything runs — and returns false when there is - // nothing multi about this keystroke, which is every keystroke - // with one cursor and therefore the unchanged path below. if (p.chordEachSel(pane, cmd)) return; if (p.currentSelText(pane)) |txt| { pane.vsel.active = false; @@ -8990,10 +6970,7 @@ pub const Pardes = struct { p.runBuiltin(cmd, p.active, "", word); return; } - // PDFs share BODY-NORMAL recognition with text, then deliberately - // adapt only navigation and the cross-pane command/search actions. - // Returning here keeps unsupported edits away from placeholder cells. - if (pane.mode == .normal and hasPdf(pane)) return p.handlePdfNormal(pane, key); + if (pane.mode == .normal and pane.hasPdf()) return p.handlePdfNormal(pane, key); switch (pane.mode) { .normal => { p.handleNormal(pane, key); @@ -9002,40 +6979,23 @@ pub const Pardes = struct { if (hit(key, config.escape)) { pane.mode = .normal; pane.msel.active = false; - pane.pending = 0; - // leaving an `a` append session: the cursor backs up one - // grapheme and the appended-over span becomes the - // implicit selection (helix doc.restore_cursor) + pane.normal.clear(); if (pane.append_at) |aa| { pane.append_at = null; - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); - const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); - // helix restore_cursor is Range::new(from, prev(to)) on - // the GAP range, so it can never walk back past the - // append origin — a session whose edits ate everything - // typed collapses ONTO it. Without the clamp the cursor - // lands one cell before where the append began (`la`, - // Backspace, Esc). + const text = p.flatSurface(pane) catch return; + const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); const back = @max(a_off, modal.prevGrapheme(text, gap)); - const bc = modal.hxPos(text, back); + const bc = modal.positionAt(text, back); pane.cur_row = @intCast(bc.row); pane.cur_col = @intCast(bc.col); pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; pane.cur_pinned = true; pane.ensureCursorVisible(); - // The other cursors move the same way the primary did — - // every one of them was handed the same keys, so every - // session shrank by the same grapheme — but they - // COLLAPSE rather than span: only the primary remembers - // where its append began (append_at is one field). - // ponytail: helix restores every range's appended-over - // span; store an origin per SelRange if that matters. for (pane.sels[0..pane.nsel]) |*s| { - const sgap = modal.hxOff(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); + const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap); - const bp = modal.hxPos(text, b2); + const bp = modal.positionAt(text, b2); s.row = @intCast(bp.row); s.col = @intCast(bp.col); s.arow = s.row; @@ -9046,23 +7006,16 @@ pub const Pardes = struct { } p.handleInsert(pane, key); }, - .tty => term_pane.forwardKey(p, p.active, key), + .tty => panes.Terminal.forwardKey(p, p.active, key), } } - /// A key after SPC: walk the leader tree (leader_rows, the comptime table). - /// `?` at any depth opens Help scoped to the path typed so far; an exact - /// path runs its builtin with no arguments; a key that only extends a - /// group keeps waiting. ANYTHING else abandons the sequence — a typo must - /// not leave the next keystroke armed at a builtin that closes panes, and - /// the indicator vanishing is the receipt (vim and helix drop unmapped - /// leader keys the same way). Esc lands here as one of those. fn leaderKey(p: *Pardes, key: Key) void { p.leader_on = false; // only "still a prefix" below re-arms it if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return; const c: u8 = @intCast(key.cp); if (c == config.leader_help) { - output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]) catch |err| + panes.Output.openHelp(p, p.active, p.leader_keys[0..p.leader_n]) catch |err| p.reportError(p.active, "help", err); return; } @@ -9083,190 +7036,30 @@ pub const Pardes = struct { } } - /// move focus to the nearest pane in `dir` of `from` (overlap-aware - /// nearest edge). `from` is the pane the builtin ran on, which is the - /// active one for a key but the CLICKED one for a name executed in a tag. - pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { - const a = p.rects[from]; - var best: ?usize = null; - var best_d: i32 = 0; - for (p.panes, 0..) |slot, i| { - if (slot == null or i == from) continue; - const r = p.rects[i]; - const vov = a.y < r.y + r.h and r.y < a.y + a.h; - const hov = a.x < r.x + r.w and r.x < a.x + a.w; - const ok = switch (dir) { - .left => r.x + r.w <= a.x and vov, - .right => r.x >= a.x + a.w and vov, - .up => r.y + r.h <= a.y and hov, - .down => r.y >= a.y + a.h and hov, - }; - if (!ok) continue; - const d: i32 = switch (dir) { - .left => @as(i32, a.x) - @as(i32, r.x + r.w), - .right => @as(i32, r.x) - @as(i32, a.x + a.w), - .up => @as(i32, a.y) - @as(i32, r.y + r.h), - .down => @as(i32, r.y) - @as(i32, a.y + a.h), - }; - if (best == null or d < best_d) { - best = i; - best_d = d; - } - } - if (best) |b| { - p.active = b; - // a count typed before the hop was meant for the pane you left - p.panes[b].?.pending = 0; - } - } - - // ---- move-drag placement ---- - - const MovePlacement = struct { - preview_col: usize, - above_id: usize, - row: u16, - above_y: u16, - above_h: u16, - }; + // ---- helix-modal normal mode ---- - fn targetColumn(p: *Pardes, cur_x: u16) usize { - var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0; - for (0..p.ncol) |c| { - if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) { - tc = c; - break; - } + pub fn paneCursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { + const arena = p.scratch.allocator(); + if (pane.file) |*f| return panes.File.cursorLines(arena, pane, f); + if (pane.hasPdf()) { + if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena); + unreachable; } - return tc; + return panes.Terminal.cursorLines(p, pane); } - fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 { - if (h < 2) return null; - const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1; - const lo = y +| min_each; - const hi = y + h - min_each; - if (lo > hi) return y + h / 2; - return std.math.clamp(cur_y, lo, hi); - } - - fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement { - const src = p.layoutFindTerm(id) orelse return null; - const tc = p.targetColumn(cur_x); - if (tc == src.col and p.col_n[src.col] == 1) return null; - if (tc == src.col) { - const sr = p.rects[id]; - if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; - } - var heights: [MAX_PANES]u16 = @splat(0); - for (0..p.ncol) |c| { - for (0..p.col_n[c]) |k| { - const pid = p.col_terms[c][k]; - heights[pid] = p.rects[pid].h; - } - } - if (p.col_n[src.col] > 1) { - const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx + 1]; - heights[sib] +|= p.rects[id].h; - } - var y: u16 = TOPBAR_H; - var last: ?MovePlacement = null; - for (0..p.col_n[tc]) |k| { - const pid = p.col_terms[tc][k]; - if (pid == id) continue; - const h = heights[pid]; - const row = splitRowForExtent(y, h, cur_y) orelse { - y +|= h; - continue; - }; - const placement: MovePlacement = .{ - .preview_col = tc, - .above_id = pid, - .row = row, - .above_y = y, - .above_h = h, - }; - last = placement; - if (cur_y < y + h) return placement; - y +|= h; - } - return last; - } - - /// drop pane `id` below the pane under the cursor, converting on-screen - /// heights to weights so ONLY the split pane changes size - fn moveTerm(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { - const placement = p.movePlacement(id, cur_x, cur_y) orelse return; - const src = p.layoutFindTerm(id) orelse return; - const source_multi = p.col_n[src.col] > 1; - var heights: [MAX_PANES]u16 = @splat(0); - for (0..p.ncol) |c| { - for (0..p.col_n[c]) |k| { - const pid = p.col_terms[c][k]; - heights[pid] = p.rects[pid].h; - } - } - p.layoutRemove(id); - if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { - const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx]; - heights[sib] +|= p.rects[id].h; - } - const af = p.layoutFindTerm(placement.above_id) orelse return; - const upper_h = @max(1, placement.row -| placement.above_y); - const lower_h = @max(1, placement.above_h -| upper_h); - heights[placement.above_id] = upper_h; - heights[id] = lower_h; - p.layoutInsert(af.col, af.idx + 1, id); - p.setColumnWeights(af.col, &heights); - if (source_multi and src.col < p.ncol and src.col != af.col) p.setColumnWeights(src.col, &heights); - } - - fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { - if (col >= p.ncol) return; - for (0..p.col_n[col]) |k| { - const pid = p.col_terms[col][k]; - if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid])); - } - } - - // ---- helix-modal normal mode ---- - - const PaneLines = struct { - lines: []const []const u8, - row0: i32, // absolute row of lines[0] - }; - - /// The lines the cursor moves over, absolute rows. File: all content lines. - /// Terminal: the whole history+active grid with the edit buffer's lines - /// standing in for the rows it covers, so motions ride the scrollback and - /// the typed text alike. Scratch-arena backed. - /// (pub only for test/hxdiff.zig — the helix differential harness dumps - /// this surface as a tty case's final text.) - pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines { - const arena = p.scratch.allocator(); - if (pane.file) |*f| return .{ .lines = try file_pane.cursorLines(arena, pane, f), .row0 = 0 }; - if (hasPdf(pane)) { - if (comptime pdf_enabled) return .{ - .lines = try pdf_pane.textLines(&pane.pdf.?, p.pdf_gpa, arena), - .row0 = 0, - }; - unreachable; - } - return .{ .lines = try term_pane.cursorLines(p, pane), .row0 = 0 }; - } - - fn paneByteAtDisplay(p: *Pardes, pane: *Pane, row: i32, from_raw: i32, display_col: i32) i32 { - if (pane.file != null) - return file_pane.byteAtRowDisplay(pane, row, from_raw, display_col); - const pl = p.paneCursorLines(pane) catch return @max(0, from_raw + display_col); - const local = row - pl.row0; - if (local < 0 or @as(usize, @intCast(local)) >= pl.lines.len) - return @max(0, from_raw + display_col); - return @intCast(file_pane.byteAtDisplayFrom( - pl.lines[@intCast(local)], - @intCast(@max(0, from_raw)), - @intCast(@max(0, display_col)), - )); + fn paneByteAtDisplay(p: *Pardes, pane: *Pane, row: i32, from_raw: i32, display_col: i32) i32 { + if (pane.file != null) + return panes.File.byteAtRowDisplay(pane, row, from_raw, display_col); + const lines = p.paneCursorLines(pane) catch return @max(0, from_raw + display_col); + const local = row; + if (local < 0 or @as(usize, @intCast(local)) >= lines.len) + return @max(0, from_raw + display_col); + return @intCast(panes.File.byteAtDisplayFrom( + lines[@intCast(local)], + @intCast(@max(0, from_raw)), + @intCast(@max(0, display_col)), + )); } /// Freeze the live terminal cursor into the modal coordinate space. The @@ -9274,434 +7067,29 @@ pub const Pardes = struct { fn pinPaneCursor(p: *Pardes, pane: *Pane) void { if (pane.cur_pinned) return; pane.pinCursor(); - if (pane.file == null and !hasPdf(pane)) + if (pane.file == null and !pane.hasPdf()) pane.cur_col = p.paneByteAtDisplay(pane, pane.cur_row, 0, pane.cur_col); } - fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor { - const r: i32 = pane.cur_row - pl.row0; - return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, pane.cur_col)) }; - } - - fn fromModalCursor(pane: *Pane, pl: PaneLines, c: modal.Cursor) void { - pane.cur_row = @as(i32, @intCast(c.row)) + pl.row0; - pane.cur_col = @intCast(c.col); - pane.cur_pinned = true; - } - - fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor { - if (lines.len == 0) return c; - const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1; - const target = lines[row]; - if (target.len == 0) return .{ .row = row, .col = 0 }; - const source = if (c.row < lines.len) lines[c.row] else ""; - const goal = file_pane.rawDisplayCol(source, c.col); - const mapped = file_pane.rawAtDisplay(target, goal); - const last = modal.prevGrapheme(target, target.len); - return .{ .row = row, .col = modal.graphemeStart(target, @min(mapped, last)) }; - } - - // ---- helix range plumbing (see modal.zig "helix range engine") ---- - // The pane's cursor + vsel cells render ONE helix gap range over the flat - // motion surface. Every motion builds the current range, transforms it the - // way the helix command would, and writes it back: normal mode REPLACES - // the selection with the motion's range, select mode (v) extends it via - // put_cursor. The differential suite (zig build hxdiff) pins all of this - // against a real helix, key for key. - - /// the flat motion surface: file content as-is; terminals join the - /// cursor-lines dump (scratch-arena backed, same lifetime as pl) - fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 { + fn flatSurface(p: *Pardes, pane: *Pane) ![]const u8 { if (pane.file) |f| return f.content; - if (hasPdf(pane)) { + if (pane.hasPdf()) { if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa); unreachable; } - return term_pane.flatSurface(p, pane, pl.lines); - } - - fn paneOff(pane: *Pane, text: []const u8, c: modal.Cursor) usize { - if (pane.file != null) return file_pane.textOffset(pane, text, c); - return modal.hxOff(text, c); - } - - fn paneLineStart(pane: *Pane, text: []const u8, row: usize) usize { - if (pane.file != null) return file_pane.textLineStart(pane, text, row); - return modal.lineStartOffset(text, row); - } - - fn paneLineCount(pane: *Pane, text: []const u8) usize { - if (pane.file != null) return file_pane.textLineCount(pane, text); - return modal.hxLineCount(text); - } - - fn panePos(pane: *Pane, text: []const u8, off: usize) modal.Cursor { - if (pane.file != null) return file_pane.textPosition(pane, text, off); - return modal.hxPos(text, off); - } - - /// the current selection as a helix gap range over `text`, whose first - /// line is absolute row `row0` (0 for the motion surface and for file - /// content; a terminal's edit buffer starts wherever it was anchored) - fn paneRange(pane: *Pane, text: []const u8, row0: i32) modal.HxRange { - const c = paneOff(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); - if (pane.msel.active) { - // legacy line selection (file-search results highlight): linewise - const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); - const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); - const s = modal.lineStartOffset(text, r0); - const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); - return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; - } - if (pane.vsel.active) return cellRange(text, pane.vsel.row - row0, pane.vsel.col, pane.cur_row - row0, pane.cur_col); - return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; - } - - /// a gap range from its two block-cursor CELLS — the arithmetic paneRange - /// does for cur/vsel, shared with the extra selections, which are stored - /// in exactly the same shape - fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.HxRange { - const a = modal.hxOff(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) }); - const c = modal.hxOff(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) }); - return cellOffRange(text, a, c); - } - - /// the same, from the two cells' gap offsets - fn cellOffRange(text: []const u8, a: usize, c: usize) modal.HxRange { - if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; - return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; - } - - /// the inverse: a gap range's cursor and anchor CELLS (equal for a bare - /// 1-wide cursor). setPaneRange's own conversion, factored out so the - /// extra selections write back through the same three lines. - fn rangeCells(text: []const u8, r: modal.HxRange) struct { cur: usize, anc: usize } { - if (r.head > r.anchor) return .{ .cur = modal.prevGrapheme(text, r.head), .anc = r.anchor }; - if (r.head < r.anchor) return .{ .cur = r.head, .anc = modal.prevGrapheme(text, r.anchor) }; - return .{ .cur = r.head, .anc = r.head }; - } - - /// write a helix range back into pane state. `explicit` marks user-intent - /// selections (v/x/X, terminal n/N, file-search n/N) — the acme chords - /// act only on those; motion residue stays implicit. - fn setPaneRange(pane: *Pane, pl: PaneLines, text: []const u8, r0: modal.HxRange, explicit: bool) void { - var r = r0; - if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 - const off = rangeCells(text, r); - const cc = panePos(pane, text, off.cur); - // a bare block cursor has both cells on the same offset — the common - // case by far — and this conversion is not free even indexed - const ac = if (off.anc == off.cur) cc else panePos(pane, text, off.anc); - pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0; - pane.cur_col = @intCast(cc.col); - pane.vsel = .{ - .active = off.anc != off.cur or pane.select, - .row = @as(i32, @intCast(ac.row)) + pl.row0, - .col = @intCast(ac.col), - .explicit = explicit or pane.select, - }; - pane.msel.active = false; - pane.nsel = 0; // writing ONE range means the selection IS that range - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.pending = 0; - pane.ensureCursorVisible(); - } - - // ---- the OTHER selections (helix Selection.ranges / primary_index) ---- - // Two functions read and write the whole list; everything else in this - // file still speaks the single primary range, and replaySels below is what - // makes an ordinary key act at every cursor. - - /// The whole selection as helix gap ranges over `text`, DOCUMENT ORDER - /// (pane.sels is kept that way, so this only has to slot the primary in). - /// Returns how many were written and which index is the primary. - fn paneRanges(pane: *Pane, text: []const u8, row0: i32, out: *[MAX_SELS]modal.HxRange) struct { n: usize, pri: usize } { - const pr = paneRange(pane, text, row0); - var n: usize = 0; - var pri: usize = 0; - var placed = false; - for (pane.sels[0..pane.nsel]) |s| { - const r = cellRange(text, s.arow - row0, s.acol, s.row - row0, s.col); - if (!placed and @min(pr.anchor, pr.head) <= @min(r.anchor, r.head)) { - pri = n; - out[n] = pr; - n += 1; - placed = true; - } - out[n] = r; - n += 1; - } - if (!placed) { - pri = n; - out[n] = pr; - n += 1; - } - return .{ .n = n, .pri = pri }; - } - - /// Write a whole selection back — helix's `Selection::new`: min-width-1, - /// sorted by start, overlapping ranges merged (the primary following its - /// range through a merge). `ranges[pri]` lands in the primary slot through - /// setPaneRange, so nothing downstream can tell it apart from a lone - /// cursor; the rest become pane.sels. `sticky` carries each range's own - /// j/k goal column, -1 for the ones that have none. - fn setPaneRanges(pane: *Pane, pl: PaneLines, text: []const u8, in: []const modal.HxRange, sticky: []const i32, pri0: usize, explicit: bool) void { - if (in.len == 0) return; // helix asserts non-empty; here it just means "no change" - var r: [MAX_SELS]modal.HxRange = undefined; - var st: [MAX_SELS]i32 = undefined; - var n: usize = @min(in.len, MAX_SELS); - var pri: usize = @min(pri0, n - 1); - for (in[0..n], 0..) |x, i| { - r[i] = x; - if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head); - st[i] = if (i < sticky.len) sticky[i] else -1; - } - // insertion sort by start — n is tiny and usually already ordered - var i: usize = 1; - while (i < n) : (i += 1) { - var j = i; - while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) { - std.mem.swap(modal.HxRange, &r[j], &r[j - 1]); - std.mem.swap(i32, &st[j], &st[j - 1]); - if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j; - } - } - // merge overlaps (helix Range::overlaps + Range::merge, kept forward: - // a merged range takes the union and loses its direction only when the - // two disagree, which is what helix's else-branch does) - var k: usize = 0; - i = 1; - while (i < n) : (i += 1) { - const a = r[k]; - const b = r[i]; - const af = @min(a.anchor, a.head); - const at = @max(a.anchor, a.head); - const bf = @min(b.anchor, b.head); - const bt = @max(b.anchor, b.head); - if (af == bf or (at > bf and bt > af)) { - r[k] = if (a.anchor > a.head and b.anchor > b.head) - .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) } - else - .{ .anchor = @min(af, bf), .head = @max(at, bt) }; - if (pri == i) pri = k; - if (st[k] < 0) st[k] = st[i]; - continue; - } - k += 1; - r[k] = b; - st[k] = st[i]; - if (pri == i) pri = k; - } - n = k + 1; - setPaneRange(pane, pl, text, r[pri], explicit); - pane.sticky_col = st[pri]; - var w: usize = 0; - for (r[0..n], 0..) |x, idx| { - if (idx == pri) continue; - const c = rangeCells(text, x); - const cc = modal.hxPos(text, c.cur); - const ac = modal.hxPos(text, c.anc); - pane.sels[w] = .{ - .row = @as(i32, @intCast(cc.row)) + pl.row0, - .col = @intCast(cc.col), - .arow = @as(i32, @intCast(ac.row)) + pl.row0, - .acol = @intCast(ac.col), - .sticky = st[idx], - }; - w += 1; - } - pane.nsel = @intCast(w); - } - - /// The helix keys that act on the selection LIST rather than on the text. - /// Each reads the whole list and writes a whole list back; none is a - /// motion, which is why Action.scope marks them exempt from per-range - /// replay. The goal columns are dropped on the way through — every - /// one of these is a fresh intent about WHERE the cursors are, the same - /// reason setPaneRange resets sticky_col. - fn multiSelAction(pane: *Pane, pl: PaneLines, text: []const u8, kind: normal_input.Multi, cnt: usize) void { - var rs: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, text, pl.row0, &rs); - const n = got.n; - const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - if (kind == .remove_primary) { - if (n < 2) return; // helix: "no selections remaining" - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - for (rs[0..n], 0..) |r, i| { - if (i == got.pri) continue; - out[m] = r; - m += 1; - } - // helix Selection::remove: the NEXT range takes over, or the - // previous one when the primary was last - return setPaneRanges(pane, pl, text, out[0..m], &.{}, @min(got.pri, m - 1), expl); - } - if (kind == .rotate_forward or kind == .rotate_backward) { - const step = cnt % n; - const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n; - return setPaneRanges(pane, pl, text, rs[0..n], &.{}, pri, expl); - } - if (kind == .merge) { - // helix merge_selections: first.merge(last) — the ranges are - // sorted, so that is simply the whole span - const lo = @min(rs[0].anchor, rs[0].head); - const hi = @max(rs[n - 1].anchor, rs[n - 1].head); - const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head; - const one: modal.HxRange = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi }; - return setPaneRanges(pane, pl, text, &.{one}, &.{}, 0, expl); - } - if (kind == .merge_consecutive) { - // ranges that TOUCH become one; setPaneRanges already merges the - // ones that overlap, so widening each by a grapheme says exactly - // "consecutive counts as overlapping" and nothing else - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - var pri: usize = 0; - for (rs[0..n], 0..) |r, i| { - if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) { - const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head)); - const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head)); - out[m - 1] = .{ .anchor = lo, .head = hi }; - if (i == got.pri) pri = m - 1; - continue; - } - if (i == got.pri) pri = m; - out[m] = r; - m += 1; - } - return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); - } - if (kind == .split_newline) { - // helix selection::split_on_newline — one range per line the - // selection covers, the newlines themselves left out - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - for (rs[0..n]) |r| { - const from = @min(r.anchor, r.head); - const to = @max(r.anchor, r.head); - if (from == to) { - if (m < MAX_SELS) { - out[m] = r; - m += 1; - } - continue; - } - var start = from; - while (start < to and m < MAX_SELS) { - const eol = modal.hxLineEndIdx(text, modal.hxLineOf(text, start)); - if (eol >= to) { - out[m] = .{ .anchor = start, .head = to }; - m += 1; - break; - } - out[m] = .{ .anchor = start, .head = eol }; - m += 1; - start = eol + 1; - } - } - if (m == 0) return; - return setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 - } - if (kind == .trim) { - // helix trim_selections: whitespace off both ends; ranges that are - // empty or all whitespace are dropped entirely - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - for (rs[0..n]) |r| { - var from = @min(r.anchor, r.head); - var to = @max(r.anchor, r.head); - while (from < to and std.ascii.isWhitespace(text[from])) from += 1; - while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1; - if (from >= to) continue; - out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }; - m += 1; - } - if (m == 0) { // helix: collapse_selection + keep_primary_selection - const c = modal.hxCursor(text, rs[got.pri]); - return setPaneRange(pane, pl, text, .{ .anchor = c, .head = c }, false); - } - // helix: the first survivor that OVERLAPS the old primary, else the last - const pf = @min(rs[got.pri].anchor, rs[got.pri].head); - const pt = @max(rs[got.pri].anchor, rs[got.pri].head); - var pri = m - 1; - for (out[0..m], 0..) |r, i| { - const f = @min(r.anchor, r.head); - const t = @max(r.anchor, r.head); - if (f == pf or (t > pf and pt > f)) { - pri = i; - break; - } - } - return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); - } - // C / Alt-C — helix copy_selection_on_line, a copy of each range on the - // next/previous line that is long enough to hold its columns. - // ponytail: BYTE columns, not helix's visual ones, so a TAB counts as - // one column here. Everything else in this file measures the same way - // (hscroll, the mouse, the renderer's gutter), and fixing it means - // teaching all of them tab stops at once. - const below = kind == .copy_below; - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - var pri: usize = 0; - const nlines = modal.hxLineCount(text); - for (rs[0..n], 0..) |r, ri| { - const is_pri = ri == got.pri; - // head-exclusive: back the leading end off onto its own cell - const hp = modal.hxPos(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head); - const ap = modal.hxPos(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor)); - const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1; - if (m >= MAX_SELS) break; - if (is_pri) pri = m; - out[m] = r; - m += 1; - var made: usize = 0; - var k: usize = 0; - while (made < cnt and m < MAX_SELS) : (k += 1) { - const d = (k + 1) * height; - const arow = if (below) ap.row + d else ap.row -| d; - const hrow = if (below) hp.row + d else hp.row -| d; - if (arow >= nlines or hrow >= nlines) break; - const a2 = modal.hxOff(text, .{ .row = arow, .col = ap.col }); - const h2 = modal.hxOff(text, .{ .row = hrow, .col = hp.col }); - // a line too short to reach the column is skipped, not clamped - if (modal.hxPos(text, a2).col == ap.col and modal.hxPos(text, h2).col == hp.col) { - if (is_pri) pri = m; - out[m] = modal.hxPutCursor(text, .{ .anchor = a2, .head = a2 }, h2, true); - m += 1; - made += 1; - } - if (arow == 0 and hrow == 0) break; - } - } - setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); + const lines = try panes.Terminal.cursorLines(p, pane); + return panes.Terminal.flatSurface(p, pane, lines); } - // ---- `s` / `S`: the selection LIST from a regex ---- - // The other two list-making keys, and the only ones that need a pattern - // typed first. They reuse the `/` input wholesale (startSearch — the tag - // tail IS the prompt) and differ from it in one thing: the pattern is - // re-applied on every keystroke, so the selection is the preview. - - /// helix `s` / `S`: arm the tag input for a regex, remembering the - /// selection it is about to rewrite. Nothing moves yet — every keystroke - /// below re-derives the preview from this snapshot, and Enter simply stops - /// while Esc puts the snapshot back. fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void { - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - const got = paneRanges(pane, text, pl.row0, &pane.sel_snap); + const text = p.flatSurface(pane) catch return; + const got = pane.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - p.startSearch(pane, if (split) config.split_marker else config.select_marker); + p.startPrompt(pane, .{ .search = if (split) config.split_marker else config.select_marker }); } - /// The pattern an armed `s`/`S` input holds right now, and which of the two - /// it is — read back off the MARKER, exactly the way submitSearch decides - /// which search is running. Null for `/`, Find, Grep and Rename. fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { const prompt_at = switch (pane.prompt) { .search => |at| at, @@ -9716,37 +7104,12 @@ pub const Pardes = struct { return .{ .pat = armed[slash + 1 ..], .split = split }; } - /// Put the selection back the way `s`/`S` found it and then, if `pat` - /// compiles and hits, rewrite it: helix's select_on_matches (every match - /// INSIDE each range becomes a range) and split_on_matches (each range - /// becomes the pieces BETWEEN its matches). - /// - /// Anything that yields nothing — an empty pattern, one that will not - /// compile, one that does not match — leaves the snapshot standing, which - /// is helix's "nothing selected" and also what makes typing a pattern one - /// character at a time bearable: every prefix of it is one of those. - /// - /// ponytail: MAX_SELS ranges, and matches past that are dropped rather - /// than growing the list — the ceiling the whole selection model has. - /// ponytail: mvzr searches from each match's end, so `^` and `$` assert - /// against THAT position rather than against a line the way helix's - /// multi_line regex does, and `.` matches a newline like any other byte. - /// Both are waived. `[^\n]` LOOKS like the workaround for the second and - /// must not be suggested as one: the live preview compiles every prefix, - /// and the prefix `[^\` panics mvzr (index out of bounds in parseCharSet, - /// mvzr.zig valueFor) before the pattern can ever be finished. Guarding - /// the compile is what would make the advice sayable. fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void { - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; + const text = p.flatSurface(pane) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; - var out: [MAX_SELS]modal.HxRange = undefined; + var out: [Pane.max_selections]modal.Selection = undefined; var m: usize = 0; if (pat.len > 0) if (mvzr.compile(pat)) |re| { - // helix smart-case: a pattern with no uppercase in it matches - // case-blind. mvzr has no such flag — "lowercase your string" is - // its own advice — and ASCII folding is byte for byte, so a - // lowercased copy of the surface has exactly the same offsets. var hay_all = text; if (for (pat) |c| { if (std.ascii.isUpper(c)) break false; @@ -9762,23 +7125,20 @@ pub const Pardes = struct { const hay = hay_all[from..to]; var at: usize = 0; var piece = from; // split: where the next piece begins - while (at < hay.len and m < MAX_SELS) { + while (at < hay.len and m < Pane.max_selections) { const hit_at = re.matchPos(at, hay) orelse break; if (split) { out[m] = .{ .anchor = piece, .head = from + hit_at.start }; m += 1; piece = from + hit_at.end; } else if (from + hit_at.start != to) { - // a match sitting right off the END of the range is - // dropped (helix: what `\b` and empty matches produce - // there), everything else becomes a range out[m] = .{ .anchor = from + hit_at.start, .head = from + hit_at.end }; m += 1; } // an empty match would otherwise never advance at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1; } - if (split and piece < to and m < MAX_SELS) { + if (split and piece < to and m < Pane.max_selections) { out[m] = .{ .anchor = piece, .head = to }; m += 1; } @@ -9791,95 +7151,15 @@ pub const Pardes = struct { r.anchor = @min(r.anchor, text.len); r.head = @min(r.head, text.len); } - return setPaneRanges(pane, pl, text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); + return pane.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); } - setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) - } - - /// Pane keeps compact codepoints for dump/layout stability; this pair is - /// the only bridge to the parser's typed state. Recognition never reads - /// these representation fields directly. - fn paneNormalState(pane: *const Pane) normal_input.State { - const prefix: normal_input.Prefix = if (pane.pending == config.goto_prefix) - .goto - else if (pane.pending == config.view_prefix) - .view - else if (pane.pending == config.match_prefix) - .match - else if (pane.pending == config.find_char_fwd) - .find_fwd - else if (pane.pending == config.find_char_back) - .find_back - else if (pane.pending == config.till_char_fwd) - .till_fwd - else if (pane.pending == config.till_char_back) - .till_back - else if (pane.pending == config.replace_prefix) - .replace - else if (pane.pending == config.next_prefix) - .next - else if (pane.pending == config.prev_prefix) - .prev - else - .none; - const match_sub: normal_input.MatchSub = if (pane.pending2 == config.match_inside) - .inside - else if (pane.pending2 == config.match_around) - .around - else if (pane.pending2 == config.surround_add) - .surround_add - else if (pane.pending2 == config.surround_replace) - .surround_replace - else if (pane.pending2 == config.surround_delete) - .surround_delete - else - .none; - return .{ - .count = pane.count, - .prefix = prefix, - .match_sub = match_sub, - .held_char = pane.pending_ch, - }; - } - - fn putPaneNormalState(pane: *Pane, state: normal_input.State) void { - pane.count = state.count; - pane.pending = switch (state.prefix) { - .none => 0, - .goto => config.goto_prefix, - .view => config.view_prefix, - .match => config.match_prefix, - .find_fwd => config.find_char_fwd, - .find_back => config.find_char_back, - .till_fwd => config.till_char_fwd, - .till_back => config.till_char_back, - .replace => config.replace_prefix, - .next => config.next_prefix, - .prev => config.prev_prefix, - }; - pane.pending2 = switch (state.match_sub) { - .none => 0, - .inside => config.match_inside, - .around => config.match_around, - .surround_add => config.surround_add, - .surround_replace => config.surround_replace, - .surround_delete => config.surround_delete, - }; - pane.pending_ch = state.held_char; + pane.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } - /// Everything one keystroke may CONSUME on the way through the modal - /// handler. A key means the same thing at every cursor, so the replay puts - /// all of it back before each pass and keeps whatever the PRIMARY's pass - /// left. (sticky_col is deliberately absent: it is per-range, and rides - /// along in SelRange.sticky instead.) const KeyState = struct { - mode: Mode, + mode: Pane.Mode, select: bool, - count: u32, - pending: u21, - pending2: u21, - pending_ch: u21, + normal: modal.Normal.State, find_op: u8, find_ch: u21, append_at: @FieldType(Pane, "append_at"), @@ -9888,10 +7168,7 @@ pub const Pardes = struct { return .{ .mode = pane.mode, .select = pane.select, - .count = pane.count, - .pending = pane.pending, - .pending2 = pane.pending2, - .pending_ch = pane.pending_ch, + .normal = pane.normal, .find_op = pane.find_op, .find_ch = pane.find_ch, .append_at = pane.append_at, @@ -9901,10 +7178,7 @@ pub const Pardes = struct { fn into(s: KeyState, pane: *Pane) void { pane.mode = s.mode; pane.select = s.select; - pane.count = s.count; - pane.pending = s.pending; - pane.pending2 = s.pending2; - pane.pending_ch = s.pending_ch; + pane.normal = s.normal; pane.find_op = s.find_op; pane.find_ch = s.find_ch; pane.append_at = s.append_at; @@ -9913,41 +7187,29 @@ pub const Pardes = struct { /// what a replayed key does at each cursor const Replay = union(enum) { - normal: normal_input.Action, + normal: modal.Normal.Action, insert: Key, }; - /// Run one keystroke at EVERY cursor, by replaying the single-selection - /// handler once per range. This IS the multiple-cursor mechanism, and it - /// is why one cursor costs nothing: with `nsel == 0` nobody calls it, and - /// the handler underneath is the same code the 800 differential cases pin. - /// - /// Two rules make the replay legal without helix's change-mapping: - /// * ranges are visited LAST FIRST, so an edit never disturbs the - /// row/col of a range still waiting its turn — everything it touches - /// is below. - /// * a finished pass's result is recorded as a distance from the END of - /// the surface, which an edit strictly before it cannot change (the - /// text and the position shift by exactly the same amount). fn replaySels(p: *Pardes, pane: *Pane, what: Replay) void { const id = p.active; const serial = pane.serial; // the whole selection in pane coordinates, document order. pane.sels // is already ordered, so this only slots the primary into place. - var list: [MAX_SELS]SelRange = undefined; + var list: [Pane.max_selections]Pane.SelRange = undefined; var n: usize = 0; var pri: usize = 0; - const prim: SelRange = .{ + const prim: Pane.SelRange = .{ .row = pane.cur_row, .col = pane.cur_col, .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col, .sticky = pane.sticky_col, }; - const pr = selStart(prim); + const pr = Pane.selStart(prim); var placed = false; for (pane.sels[0..pane.nsel]) |s| { - const sr = selStart(s); + const sr = Pane.selStart(s); if (!placed and (pr.row < sr.row or (pr.row == sr.row and pr.col <= sr.col))) { pri = n; list[n] = prim; @@ -9969,7 +7231,7 @@ pub const Pardes = struct { var after_expl = explicit; // each pass's result: cursor and anchor cells as distances from the // end of the surface text, plus the range's own j/k goal column - var res: [MAX_SELS]struct { cur: usize, anc: usize, sticky: i32 } = undefined; + var res: [Pane.max_selections]struct { cur: usize, anc: usize, sticky: i32 } = undefined; p.multi_on = true; p.multi_stop = false; var passes: usize = 0; @@ -9994,25 +7256,18 @@ pub const Pardes = struct { .normal => |normal_action| p.executeNormalAction(pane, normal_action), .insert => |insert_key| p.insertKey(pane, insert_key), } - // the stop check comes FIRST: the pass that set it may have freed - // this very pane (a builtin closing it), so nothing below may read - // through the pointer if (p.multi_stop) break; if (i == pri) { after = KeyState.of(pane); after_expl = pane.vsel.explicit; } - const pl = p.paneCursorLines(pane) catch { - p.multi_stop = true; // out of memory mid-replay: collapse, don't guess - break; - }; - const text = p.flatSurface(pane, pl) catch { + const text = p.flatSurface(pane) catch { p.multi_stop = true; break; }; - const co = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - pl.row0)), .col = @intCast(@max(0, pane.cur_col)) }); + const co = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const ao = if (pane.vsel.active) - modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - pl.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) else co; res[i] = .{ .cur = text.len - @min(co, text.len), .anc = text.len - @min(ao, text.len), .sticky = pane.sticky_col }; @@ -10020,270 +7275,30 @@ pub const Pardes = struct { p.multi_on = false; p.multi_first = false; if (p.multi_stop) { - // the pass reached outside the buffer (a builtin, a language - // query) and may have closed or reused the pane it ran on: drop - // back to one cursor rather than replaying it n more times p.multi_stop = false; const pn = p.panes[id] orelse return; if (pn.serial == serial) pn.nsel = 0; return; } - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - var rs: [MAX_SELS]modal.HxRange = undefined; - var st: [MAX_SELS]i32 = undefined; + const text = p.flatSurface(pane) catch return; + var rs: [Pane.max_selections]modal.Selection = undefined; + var st: [Pane.max_selections]i32 = undefined; for (res[0..n], 0..) |r, k| { - rs[k] = cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); + rs[k] = Pane.cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); st[k] = r.sticky; } - setPaneRanges(pane, pl, text, rs[0..n], st[0..n], pri, after_expl); + pane.setRanges(text, rs[0..n], st[0..n], pri, after_expl); after.into(pane); pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass } - /// a range's start CELL (document order key) — the smaller of its two ends - fn selStart(s: SelRange) struct { row: i32, col: i32 } { - if (s.arow < s.row or (s.arow == s.row and s.acol < s.col)) return .{ .row = s.arow, .col = s.acol }; - return .{ .row = s.row, .col = s.col }; - } - - /// The last line a goto may land on: helix skips the empty trailing line. - /// Called from the three `g`/`G` Action branches that need it and nowhere - /// else — it used to be eager at the top of body-normal execution, so every - /// keystroke of every kind paid a full count of the buffer's newlines. - /// "Does the buffer end in a newline" is the same question as the walk to - /// the last line start that stood here, and it is one byte instead of a - /// second pass. - fn maxLine(text: []const u8) usize { - const nl = modal.hxLineCount(text); - return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1; - } - - /// point-target motion: collapse there (extend in select mode) - fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void { - setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false); - } - - /// word motions select their traversed span (extend mode: head only) - fn wordMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize, target: modal.WordTarget) void { - const wr = modal.hxWordMove(text, range, cnt, target); - const res = if (pane.select) modal.hxPutCursor(text, range, modal.hxCursor(text, wr), true) else wr; - setPaneRange(pane, pl, text, res, false); - } - - /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move) - fn findMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, ch: u21, fwd: bool, till: bool, cnt: usize) void { - const cur = modal.hxCursor(text, range); - const t = modal.hxFindTarget(text, cur, ch, fwd, till, cnt) orelse return; - const res = if (pane.select) - modal.hxPutCursor(text, range, t, true) - else - modal.hxPutCursor(text, .{ .anchor = cur, .head = cur }, t, true); - setPaneRange(pane, pl, text, res, false); - } - - /// j/k and friends: sticky goal column, clamped onto short lines' newline - fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void { - const cur = modal.hxCursor(text, range); - const pos = panePos(pane, text, cur); - const goal: usize = if (pane.sticky_col >= 0) - @intCast(pane.sticky_col) - else - file_pane.rawDisplayCol(modal.lineSlice(text, pos.row), pos.col); - // modal.hxVertTarget with the row we already have and the indexed - // offset conversion — it would otherwise recount the buffer's newlines - // and walk to the target line, two more full passes per j/k - const last_row = paneLineCount(pane, text) - 1; - const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt); - const target_col = file_pane.rawAtDisplay(modal.lineSlice(text, nline), goal); - const t = paneOff(pane, text, .{ .row = nline, .col = target_col }); - // extend mode never walks onto the empty trailing line (helix) - if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; - setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); - pane.sticky_col = @intCast(goal); - } - - /// `gj`/`gk`: one VISUAL line, following the automatic breaks a wrapped - /// body draws rather than the newlines in the file. The goal column is the - /// one INSIDE the visual row, so a run of them walks straight down a - /// paragraph; on the last visual row of a line the step crosses into the - /// next line's first row, exactly as the eye does. - /// - /// With wrapping off — Wrap unset, a terminal pane, an output pane too - /// narrow to record its map — a line is one visual row and this IS - /// verticalMove, which is why nothing upstream branches on the setting. - fn visualMove( - pane: *Pane, - pl: PaneLines, - text: []const u8, - range: modal.HxRange, - down: bool, - cnt: usize, - width: usize, - ) void { - const cur = modal.hxCursor(text, range); - const pos = panePos(pane, text, cur); - const last_row = paneLineCount(pane, text) - 1; - var row = pos.row; - var line = modal.lineSlice(text, row); - var vrow = file_pane.visualRow(line, pos.col, width); - const goal: usize = if (pane.sticky_col >= 0) - @intCast(pane.sticky_col) - else - file_pane.rawDisplayCol(line[vrow.start..vrow.end], pos.col -| vrow.start); - var steps = @max(1, cnt); - while (steps > 0) : (steps -= 1) { - if (down) { - if (vrow.end < line.len) { - vrow = file_pane.visualRow(line, vrow.end, width); - continue; - } - if (row == last_row) break; - row += 1; - line = modal.lineSlice(text, row); - vrow = file_pane.visualRow(line, 0, width); - } else { - if (vrow.start > 0) { - vrow = file_pane.visualRow(line, vrow.start - 1, width); - continue; - } - if (row == 0) break; - row -= 1; - line = modal.lineSlice(text, row); - vrow = file_pane.visualRow(line, line.len, width); - } - } - // The newline slot is a real cursor position, but the first byte of the - // NEXT visual row is not: landing there would read as two rows moved. - var target_col = vrow.start + file_pane.rawAtDisplay(line[vrow.start..vrow.end], goal); - if (vrow.end < line.len and target_col >= vrow.end) - target_col = modal.graphemeStart(line, vrow.end - 1); - const t = paneOff(pane, text, .{ .row = row, .col = target_col }); - // extend mode never walks onto the empty trailing line (helix) - if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; - setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); - pane.sticky_col = @intCast(goal); - } - - /// How wide a wrapped row of this pane is, or 0 when it does not wrap. - /// Only a file-backed body wraps: a terminal's rows are the emulator's - /// own, already broken where it decided to break them. fn paneWrapWidth(p: *const Pardes, pane: *const Pane) usize { if (pane.file == null) return 0; - return file_pane.wrapWidth(pane, p.settings.wrap); - } - - /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows - fn halfPageMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool) void { - const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2)); - pane.scrollBy(if (down) half else -half); - verticalMove(pane, pl, text, range, down, @intCast(half)); - } - - /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift - /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0 - fn scrollViewMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, delta: i32) void { - const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); - pane.scrollBy(delta); - const top = pane.scroll(); - const last_row: i32 = @intCast(paneLineCount(pane, text) - 1); - const cur = modal.hxCursor(text, range); - if (delta > 0) { - const snap: i32 = @max(0, @min(top + margin, last_row)); - const head = paneLineStart(pane, text, @intCast(snap)); - if (head <= cur) return; - const anchor = if (pane.select) range.anchor else head; - setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); - } else { - const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); - const head = paneLineStart(pane, text, @intCast(snap)); - if (head >= cur) return; - const anchor = if (pane.select) range.anchor else head; - setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); - } + return panes.File.wrapWidth(pane, p.settings.wrap); } - /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) - fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: enum { top, center, bottom }, cnt: usize) void { - const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); - const top = pane.scroll(); - const last_row: i32 = @intCast(paneLineCount(pane, text) - 1); - const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top); - const n: i32 = @intCast(cnt - 1); - var vline: i32 = switch (which) { - .top => top + margin + n, - .center => top + @divTrunc(last_vis, 2), - .bottom => top + last_vis - (margin + n), - }; - vline = @max(vline, top + margin); - vline = @min(vline, top + last_vis - margin); - const row: i32 = std.math.clamp(vline, 0, last_row); - pointMove(pane, pl, text, range, paneLineStart(pane, text, @intCast(row))); - } - - /// helix Range::line_range — the inclusive line span a range covers - fn rangeLineSpan(text: []const u8, r: modal.HxRange) struct { start: usize, end: usize } { - const from = @min(r.anchor, r.head); - const to = @max(r.anchor, r.head); - const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from); - return .{ .start = modal.hxLineOf(text, from), .end = modal.hxLineOf(text, to_adj) }; - } - - fn lineStartOrEof(text: []const u8, line: usize) usize { - if (line >= modal.hxLineCount(text)) return text.len; - return modal.lineStartOffset(text, line); - } - - /// helix `x` extend_line_below: full lines incl. the newline, cursor ON - /// the last one's '\n'; an already-line-bounded selection grows downward - fn lineSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize) void { - const span = rangeLineSpan(text, range); - const start = modal.lineStartOffset(text, span.start); - const end = lineStartOrEof(text, span.end + 1); - const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end; - const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full)); - setPaneRange(pane, pl, text, .{ .anchor = start, .head = head }, true); - } - - /// helix `X` extend_to_line_bounds (direction kept) - fn lineBoundsSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { - const span = rangeLineSpan(text, range); - const start = modal.lineStartOffset(text, span.start); - const end = lineStartOrEof(text, span.end + 1); - const r: modal.HxRange = if (range.head < range.anchor) - .{ .anchor = end, .head = start } - else - .{ .anchor = start, .head = end }; - setPaneRange(pane, pl, text, r, true); - } - - /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) - fn shrinkSelToLineBounds(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { - const span = rangeLineSpan(text, range); - if (span.start == span.end) return; - const from = @min(range.anchor, range.head); - const to = @max(range.anchor, range.head); - var start = modal.lineStartOffset(text, span.start); - var end = lineStartOrEof(text, span.end + 1); - if (start != from) start = lineStartOrEof(text, span.start + 1); - if (end != to) end = modal.lineStartOffset(text, span.end); - const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - const r: modal.HxRange = if (range.head < range.anchor) - .{ .anchor = end, .head = start } - else - .{ .anchor = start, .head = end }; - setPaneRange(pane, pl, text, r, expl); - } - - /// Keys that are about the selection LIST, or about the session rather - /// than the text: they act ONCE however many cursors there are. The - /// multi-cursor family rewrites the list wholesale, and the rest would - /// either fight the replay (Esc, undo) or fire n times (`:`, `/`, n/N, SPC). - /// fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { - var state = paneNormalState(pane); - const parsed = normal_input.parse(&state, normalInput(key)); - putPaneNormalState(pane, state); + const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); const semantic = switch (parsed) { .pending, .ignored, .unbound => return, .action => |value| value, @@ -10293,16 +7308,11 @@ pub const Pardes = struct { p.replaySels(pane, .{ .normal = semantic }); } - /// Text-pane adapter for the semantic BODY-NORMAL vocabulary. Parsing is - /// complete before this function runs; this switch reads document state - /// only to execute the already-recognized action. - fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: normal_input.Action) void { + fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.Action) void { p.pinPaneCursor(pane); - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - const lines = pl.lines; - const range = paneRange(pane, text, pl.row0); - const cur = modal.hxCursor(text, range); + const text = p.flatSurface(pane) catch return; + const range = pane.primaryRange(text, 0); + const cur = modal.selectionCursor(text, range); switch (semantic) { .escape => { @@ -10311,35 +7321,35 @@ pub const Pardes = struct { }, .goto => |go| switch (go.target) { .file_start => { - const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, maxLine(text)) else 0; - return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); + const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, Pane.maxLine(text)) else 0; + return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, - .last_line => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, maxLine(text))), - .line_start => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))), + .last_line => return pane.pointMove(text, range, modal.lineStartOffset(text, Pane.maxLine(text))), + .line_start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .line_end => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; - return pointMove(pane, pl, text, range, ls + nw); + return pane.pointMove(text, range, ls + nw); }, - .line_down => return visualMove(pane, pl, text, range, true, go.count, p.paneWrapWidth(pane)), - .line_up => return visualMove(pane, pl, text, range, false, go.count, p.paneWrapWidth(pane)), + .line_down => return pane.visualMove(text, range, true, go.count, p.paneWrapWidth(pane)), + .line_up => return pane.visualMove(text, range, false, go.count, p.paneWrapWidth(pane)), .column => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; - return pointMove(pane, pl, text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1)); + const slice = text[ls..modal.lineEndOffset(text, line)]; + return pane.pointMove(text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1)); }, - .view_top => return gotoWindow(pane, pl, text, range, .top, go.count), - .view_center => return gotoWindow(pane, pl, text, range, .center, go.count), - .view_bottom => return gotoWindow(pane, pl, text, range, .bottom, go.count), + .view_top => return pane.gotoWindow(text, range, .top, go.count), + .view_center => return pane.gotoWindow(text, range, .center, go.count), + .view_bottom => return pane.gotoWindow(text, range, .bottom, go.count), }, .view => |view| switch (view) { .top => { @@ -10354,8 +7364,8 @@ pub const Pardes = struct { pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); }, - .scroll_down => return scrollViewMove(pane, pl, text, range, 1), - .scroll_up => return scrollViewMove(pane, pl, text, range, -1), + .scroll_down => return pane.scrollViewMove(text, range, 1), + .scroll_up => return pane.scrollViewMove(text, range, -1), }, .find => |find| { const op: u8 = switch (find.kind) { @@ -10366,9 +7376,7 @@ pub const Pardes = struct { }; pane.find_op = op; pane.find_ch = find.char; - return findMove( - pane, - pl, + return pane.findMove( text, range, find.char, @@ -10379,26 +7387,27 @@ pub const Pardes = struct { }, .replace_char => |char| return p.normalReplaceChar(pane, char), .match_bracket => { - const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return; - return pointMove(pane, pl, text, range, modal.hxOff(text, mc)); + const lines = p.paneCursorLines(pane) catch return; + const mc = modal.matchBracket(lines, modal.positionAt(text, cur)) orelse return; + return pane.pointMove(text, range, modal.offsetAt(text, mc)); }, - .textobject => |object| return p.textobjectSelect(pane, pl, object.char, object.around), + .textobject => |object| return p.textobjectSelect(pane, text, object.char, object.around), .surround_add => |char| return p.surroundAdd(pane, char), - .surround_delete => |char| return p.surroundDelete(pane, pl, char), - .surround_replace => |replace| return p.surroundReplace(pane, pl, replace.from, replace.to), + .surround_delete => |char| return p.surroundDelete(pane, char), + .surround_replace => |replace| return p.surroundReplace(pane, replace.from, replace.to), .paragraph => |paragraph| { - const r2 = modal.hxParaMove(text, range, paragraph.count, paragraph.direction == .forward, pane.select); - return setPaneRange(pane, pl, text, r2, false); + const r2 = modal.moveParagraph(text, range, paragraph.count, paragraph.direction == .forward, pane.select); + return pane.setRange(text, 0, r2, false); }, .add_newline => |newline| return p.addNewline(pane, newline.direction == .forward, newline.count), .diagnostic => |diagnostic| { const fwd = diagnostic.direction == .forward; if (!diagnostic.endpoint) { - if (output_pane.resultsFrom(p, pane, .{ .query = .diagnostics }) and + if (panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; return p.lspRequest(p.active, .diagnostics, ""); } - if (!output_pane.resultsFrom(p, pane, .{ .query = .diagnostics })) + if (!panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics })) return p.lspRequest(p.active, .diagnostics, ""); if (!fwd) { pane.search_row = null; @@ -10414,21 +7423,21 @@ pub const Pardes = struct { .left => { var target = cur; for (0..move.count) |_| target = modal.prevGrapheme(text, target); - return pointMove(pane, pl, text, range, target); + return pane.pointMove(text, range, target); }, .right => { var target = cur; for (0..move.count) |_| target = modal.nextGrapheme(text, target); - return pointMove(pane, pl, text, range, target); + return pane.pointMove(text, range, target); }, - .down => return verticalMove(pane, pl, text, range, true, move.count), - .up => return verticalMove(pane, pl, text, range, false, move.count), - .next_word_start => return wordMove(pane, pl, text, range, move.count, .next_word_start), - .prev_word_start => return wordMove(pane, pl, text, range, move.count, .prev_word_start), - .next_word_end => return wordMove(pane, pl, text, range, move.count, .next_word_end), - .next_long_word_start => return wordMove(pane, pl, text, range, move.count, .next_long_word_start), - .prev_long_word_start => return wordMove(pane, pl, text, range, move.count, .prev_long_word_start), - .next_long_word_end => return wordMove(pane, pl, text, range, move.count, .next_long_word_end), + .down => return pane.verticalMove(text, range, true, move.count), + .up => return pane.verticalMove(text, range, false, move.count), + .next_word_start => return pane.wordMove(text, range, move.count, .next_word_start), + .prev_word_start => return pane.wordMove(text, range, move.count, .prev_word_start), + .next_word_end => return pane.wordMove(text, range, move.count, .next_word_end), + .next_long_word_start => return pane.wordMove(text, range, move.count, .next_long_word_start), + .prev_long_word_start => return pane.wordMove(text, range, move.count, .prev_long_word_start), + .next_long_word_end => return pane.wordMove(text, range, move.count, .next_long_word_end), }, .repeat_find => |count| { if (pane.find_op == 0) return; @@ -10437,45 +7446,45 @@ pub const Pardes = struct { var repeated = range; var moved = false; for (0..count) |_| { - const cc = modal.hxCursor(text, repeated); - const target = modal.hxFindTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break; + const cc = modal.selectionCursor(text, repeated); + const target = modal.findTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break; repeated = if (pane.select) - modal.hxPutCursor(text, repeated, target, true) + modal.moveSelectionCursor(text, repeated, target, true) else - modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, target, true); + modal.moveSelectionCursor(text, .{ .anchor = cc, .head = cc }, target, true); moved = true; } if (!moved) return; - return setPaneRange(pane, pl, text, repeated, false); + return pane.setRange(text, 0, repeated, false); }, .line => |line_kind| switch (line_kind) { - .start => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))), + .start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .end => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; - return pointMove(pane, pl, text, range, ls + nw); + return pane.pointMove(text, range, ls + nw); }, }, .goto_line => |go| { if (!go.explicit) return; - const line = @min(@as(usize, go.count) - 1, maxLine(text)); - return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); + const line = @min(@as(usize, go.count) - 1, Pane.maxLine(text)); + return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, .page => |page| switch (page.kind) { // Counts were parsed historically but these four text view // actions intentionally move exactly one viewport unit. - .half_down => return halfPageMove(pane, pl, text, range, true), - .half_up => return halfPageMove(pane, pl, text, range, false), - .down => return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)), - .up => return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)), + .half_down => return pane.halfPageMove(text, range, true), + .half_up => return pane.halfPageMove(text, range, false), + .down => return pane.scrollViewMove(text, range, @as(i32, pane.rows)), + .up => return pane.scrollViewMove(text, range, -@as(i32, pane.rows)), }, .insert => |insert| { const where: InsertAt = switch (insert.kind) { @@ -10495,7 +7504,7 @@ pub const Pardes = struct { } else { pane.select = true; if (pane.msel.active) { - setPaneRange(pane, pl, text, range, true); + pane.setRange(text, 0, range, true); } else if (!pane.vsel.active) { pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true }; } else { @@ -10504,22 +7513,22 @@ pub const Pardes = struct { } pane.cur_pinned = true; }, - .line => return lineSelect(pane, pl, text, range, select.count), - .line_bounds => return lineBoundsSelect(pane, pl, text, range), - .shrink_to_line_bounds => return shrinkSelToLineBounds(pane, pl, text, range), - .collapse => return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false), + .line => return pane.lineSelect(text, range, select.count), + .line_bounds => return pane.lineBoundsSelect(text, range), + .shrink_to_line_bounds => return pane.shrinkSelToLineBounds(text, range), + .collapse => return pane.setRange(text, 0, .{ .anchor = cur, .head = cur }, false), .flip => { const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, explicit); + return pane.setRange(text, 0, .{ .anchor = range.head, .head = range.anchor }, explicit); }, - .all => return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false), + .all => return pane.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { if (multi.kind == .keep_primary) { pane.nsel = 0; return; } - return multiSelAction(pane, pl, text, multi.kind, multi.count); + return pane.multiSelAction(text, multi.kind, multi.count); }, .select_regex => |split| return p.startSelRegex(pane, split), .edit => |edit| switch (edit.kind) { @@ -10528,14 +7537,8 @@ pub const Pardes = struct { .change => return p.normalChange(pane), .yank => return p.normalYank(pane), .replace_with_yank => return p.normalReplaceYank(pane), - .paste_after => { - pane.count = edit.count; - return p.normalPaste(pane, false); - }, - .paste_before => { - pane.count = edit.count; - return p.normalPaste(pane, true); - }, + .paste_after => return p.pasteText(pane, p.yank orelse return, false, edit.count), + .paste_before => return p.pasteText(pane, p.yank orelse return, true, edit.count), .switch_case => return p.normalCase(pane, .toggle), .lowercase => return p.normalCase(pane, .lower), .uppercase => return p.normalCase(pane, .upper), @@ -10563,8 +7566,8 @@ pub const Pardes = struct { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, - .pipe_selection => |how| return p.startPipe(pane, how), - .search => return p.startSearch(pane, config.search_marker), + .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), + .search => return p.startPrompt(pane, .{ .search = config.search_marker }), .search_step => |direction| return p.lookWalk( if (direction == .forward) @as(i32, 1) else -1, ), @@ -10573,50 +7576,29 @@ pub const Pardes = struct { // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ---- - fn startPipe(p: *Pardes, pane: *Pane, how: normal_input.PipeBehavior) void { - // A buffer that IS a file, or the scratch that becomes one: a filter - // rewrites bytes the pane owns. Never a terminal (shell output cannot - // be rewritten), never a rendering that its next refill would discard. - const f = pane.file orelse return; - if (!output_pane.fileTraits(f.output).saves) return; - p.seedTail(pane); - if (!pane.tag_init) return; - const prompt_at: u16 = @intCast(pane.tag_tail_len); - if (!pane.appendTag(pipeMarker(how))) return; - pane.prompt = .{ .pipe = prompt_at }; - pane.pipe_how = how; - pane.tag_edit = true; - pane.tag_sel = false; - pane.mode = .insert; - pane.pending = 0; - pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); - } - - /// Snapshot command/cwd/ranges/selection bytes before emitting the id-only - /// effect. Every allocation is owned by pipe_wait, so the frontend can - /// copy it synchronously and the core can keep editing immediately after. fn submitPipe(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const f = pane.file orelse return; - if (!output_pane.fileTraits(f.output).saves) return; + if (!panes.Output.fileTraits(f.output).saves) return; + const prompt = switch (pane.prompt) { + .pipe => |pipe| pipe, + else => return, + }; const tail = pane.tagSlice(); - const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; - const marker = pipeMarker(pane.pipe_how); + const armed = tail[@min(prompt.at, tail.len)..]; + const marker = pipeMarker(prompt.how); if (!std.mem.startsWith(u8, armed, marker)) return; const command = armed[marker.len..]; if (command.len == 0) return; - var ranges: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, f.content, 0, &ranges); + var ranges: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(f.content, 0, &ranges); // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`. - const ninputs = if (pane.pipe_how.pipes()) got.n else 1; + const ninputs = if (prompt.how.pipes()) got.n else 1; const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return; var made: usize = 0; for (ranges[0..ninputs], 0..) |range, i| { - // A behaviour that sends no stdin still submits one input, empty: - // the runner's contract is one invocation per input, and `!` wants - // exactly one invocation with nothing on its stdin. - const bytes = if (pane.pipe_how.pipes()) bytes: { + const bytes = if (prompt.how.pipes()) bytes: { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (hi > f.content.len) break; @@ -10656,7 +7638,7 @@ pub const Pardes = struct { .ranges = ranges, .primary = @intCast(got.pri), .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active, - .how = pane.pipe_how, + .how = prompt.how, .nranges = @intCast(got.n), }; p.emit(.{ .pipe = .{ .id = p.pipe_seq } }); @@ -10671,14 +7653,6 @@ pub const Pardes = struct { return null; } - /// Put a failed filter where it can be READ: the command, which selection - /// it was, what became of it, and the command's own stderr underneath. - /// - /// An `+Errors` pane rather than the message row, because a message row is - /// 256 bytes and one line, and the useful half of a shell failure is the - /// text the shell wrote — `sh: line 1: trr: command not found`, a compiler - /// diagnostic, a `jq` parse error with a column in it. Truncating that to - /// fit a row would throw away the reason and keep the label. fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Failure) void { var out: std.Io.Writer.Allocating = .init(p.gpa); defer out.deinit(); @@ -10701,17 +7675,12 @@ pub const Pardes = struct { if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return; } else { // No diagnosis at all: nobody ran it. The detached daemon, the - // browser and the board all leave `pull_pipe` null on purpose. + // browser and the board all leave `pipe` null on purpose. w.writeAll("this session cannot run filters\n") catch return; } const content = out.toOwnedSlice() catch return; - // FOCUS STAYS WITH THE TEXT. `openRead` moves `p.active` to the buffer - // it opens, which is right for a `Grep` you asked to read and wrong for - // a report you did not: a failed filter should put the reason on screen - // and leave the cursor in the file you were filtering, ready to fix the - // command and press `|` again. const was = p.active; - output_pane.openErrors(p, wait.pane, content) catch |err| { + panes.Output.openErrors(p, wait.pane, content) catch |err| { p.gpa.free(content); // Nowhere to put the report is itself worth one line. p.reportError(wait.pane, "pipe", err); @@ -10720,10 +7689,6 @@ pub const Pardes = struct { if (p.panes[was] != null) p.active = was; } - /// WHERE range `i`'s output goes, as a span of the pre-edit content to be - /// replaced by it. The three writing behaviours differ in exactly this and - /// nothing else: `|` swaps the selection out, `!` opens a hole at its - /// start, `A-!` at its end. Null when the range no longer fits the text. fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } { const range = wait.ranges[i]; const lo = @min(range.anchor, range.head); @@ -10736,14 +7701,6 @@ pub const Pardes = struct { }; } - /// WHAT range `i` receives. - /// - /// Two helix rules live here. A behaviour that sends no stdin ran the - /// command ONCE, so every range gets `outputs[0]` — ten cursors and `date` - /// give ten identical stamps rather than ten racing ones. And a command - /// that added a trailing newline to a selection which did not have one has - /// it taken back off, which is what keeps `| tr a-z A-Z` on one line from - /// splitting it in two. fn pipeOutput( wait: *const PendingPipe, outputs: []const []const u8, @@ -10781,16 +7738,10 @@ pub const Pardes = struct { const pane = p.panes[wait.pane] orelse return; if (pane.serial != wait.serial) return; const f = if (pane.file) |*file| file else return; - if (!output_pane.fileTraits(f.output).saves) return; - // THE FILE MOVED UNDER THE FILTER. One keystroke during a ten-second - // command was enough to discard the whole result in silence, which is - // indistinguishable from the filter having done nothing at all. + if (!panes.Output.fileTraits(f.output).saves) return; if (f.revision != wait.revision) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); - // `A-|` ran the command FOR ITS EFFECT. There is nothing to splice and - // the selection is left exactly where it was, which is the whole - // difference between it and `|`. if (wait.how == .ignore) return; const n = wait.nranges; @@ -10811,7 +7762,7 @@ pub const Pardes = struct { const final_len = std.math.add(usize, kept, total_output) catch return; const replacement = p.gpa.alloc(u8, final_len) catch return; - var new_ranges: [MAX_SELS]modal.HxRange = undefined; + var new_ranges: [Pane.max_selections]modal.Selection = undefined; var read_at: usize = 0; var write_at: usize = 0; for (0..n) |i| { @@ -10824,10 +7775,6 @@ pub const Pardes = struct { @memcpy(replacement[write_at .. write_at + output.len], output); write_at += output.len; const out_end = write_at; - // THE OUTPUT IS WHAT ENDS UP SELECTED, for all three behaviours - // that write — helix `shell()` builds its new range around the - // inserted text, keeping the original's direction, so a `!` can be - // followed straight by another operator on what it just produced. new_ranges[i] = if (range.anchor > range.head) .{ .anchor = out_end, .head = out_start } else @@ -10838,9 +7785,8 @@ pub const Pardes = struct { // One async request is one history transaction, even at 64 cursors. p.pushUndo(pane); - file_pane.setContent(p, f, replacement); - const pl = p.paneCursorLines(pane) catch return; - setPaneRanges(pane, pl, f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); + panes.File.setContent(p, f, replacement); + pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); pane.select = false; pane.ensureCursorVisible(); } @@ -10850,12 +7796,11 @@ pub const Pardes = struct { const Search = enum { text, find, grep }; const SearchStart = enum { top, cursor }; - fn paneDir(pane: *const Pane) []const u8 { - // an inherited cwd is a live link that outranks a scratch buffer's own - // synthetic path: follow it to the pane it was opened from. + pub fn paneDir(pane: *const Pane) []const u8 { switch (pane.cwd) { .inherited => |src| return paneDir(src), - else => {}, + .owned => |cwd| return cwd, + .none => {}, } if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; if (comptime pdf_enabled) if (pane.pdf) |pv| @@ -10864,41 +7809,47 @@ pub const Pardes = struct { return pane.cwdSlice(); } - /// `/` (and the Find builtin) on any pane: type the pattern into the tag - /// tail after `marker` — the existing modal one-line editor, visible while - /// typing, nothing that disappears. Enter/Esc are intercepted in handleKey. - pub fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void { - p.seedTail(pane); - if (!pane.tag_init) return; - const prompt_at: u16 = @intCast(pane.tag_tail_len); - if (!pane.appendTag(marker)) return; - pane.prompt = .{ .search = prompt_at }; - pane.tag_edit = true; - pane.tag_sel = false; - pane.mode = .insert; - pane.pending = 0; - // tag_col and the prompt offset are both UTF-8 byte offsets. - pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); - } - - /// Save on a pane with no file of its own — an output buffer or a terminal - /// — arms a PATH input in the tag, prefilled with the pane's directory (an - /// inherited scratch follows the pane it was opened from). submitSave - /// hands what you type to saveTo. - pub fn startSavePrompt(p: *Pardes, pane: *Pane) void { + pub fn startPrompt(p: *Pardes, pane: *Pane, request: union(enum) { + search: []const u8, + pipe: modal.Normal.PipeBehavior, + save, + }) void { + const marker = switch (request) { + .search => |marker| marker, + .save => config.save_marker, + .pipe => |how| pipe: { + const file = pane.file orelse return; + if (!panes.Output.fileTraits(file.output).saves) return; + break :pipe pipeMarker(how); + }, + }; p.seedTail(pane); if (!pane.tag_init) return; + const dir = if (request == .save) paneDir(pane) else ""; + const slash = request == .save and (dir.len == 0 or dir[dir.len - 1] != '/'); + var room = pane.tag_tail.len - pane.tag_tail_len; + if (marker.len > room) return; + room -= marker.len; + if (dir.len > room) return; + room -= dir.len; + if (slash and room == 0) return; + const prefix_len = (p.tagPrefix(pane) catch return).len; const prompt_at: u16 = @intCast(pane.tag_tail_len); - if (!pane.appendTag(config.save_marker)) return; - const dir = paneDir(pane); - _ = pane.appendTag(dir); - if (dir.len == 0 or dir[dir.len - 1] != '/') _ = pane.appendTag("/"); - pane.prompt = .{ .save = prompt_at }; + _ = pane.appendTag(marker); + if (request == .save) { + _ = pane.appendTag(dir); + if (slash) _ = pane.appendTag("/"); + } + pane.prompt = switch (request) { + .search => .{ .search = prompt_at }, + .pipe => |how| .{ .pipe = .{ .at = prompt_at, .how = how } }, + .save => .{ .save = prompt_at }, + }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; - pane.pending = 0; - pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); + pane.normal.clear(); + pane.tag_col = @intCast(prefix_len + pane.tag_tail_len); } /// Enter on a save input: the path is everything past the marker. @@ -10922,35 +7873,9 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; const f = if (pane.file) |*file| file else return; if (f.output != null) return; // nothing behind it yet: saveTo, with a path - // NOT marked saved here: the effect has only been QUEUED. `perform`'s - // `.save_file` arm cleans the pane if and only if the host says the - // bytes landed — see there. p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); } - /// Commit a path — prompted, typed after the word, or chorded onto it. - /// - /// The pane is left ALONE: a terminal stays a terminal, a results buffer - /// keeps its rows and its place in the n/N ring, and an open file keeps the - /// file it has, so `Save ` is a copy and never a rename. The one - /// pane that changes is the scratch New opened, which exists to become the - /// file you name and does (output traits: `saves`). - /// - /// A relative path resolves against the PANE's directory — the way a look - /// resolves a relative word — and never against whatever directory the - /// process happened to start in. `.`, `..` and doubled slashes normalize - /// with it, so `Save ./notes` and `Save notes` are one path and one answer - /// to "is this the file I already have open". - /// - /// What it will not do is guess. A path that names no FILE (empty, or - /// ending in `/` — the bare prompt prefill accepted with Enter), one that - /// carries a newline (a multi-line selection chorded onto the word), and - /// one that does not resolve ABSOLUTE (a terminal whose shell has not - /// reported a directory yet, where the alternative is writing into - /// whatever directory pardes was started in) are all refused, and say so - /// on the message row. That check comes FIRST because the scratch's branch - /// below rewrites the pane's identity: a host write can only fail silently - /// afterwards, so a buffer must never become a "file" that never existed. pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void { const pane = p.panes[id] orelse return; if (path.len == 0 or path[path.len - 1] == '/' or @@ -10968,16 +7893,16 @@ pub const Pardes = struct { if (pane.isTerminal()) p.askWrite(id, pane.serial, full); return; }; - if (f.output != null and output_pane.fileTraits(f.output).saves) { + if (f.output != null and panes.Output.fileTraits(f.output).saves) { const owned = p.gpa.dupe(u8, full) catch return; p.gpa.free(f.path); f.path = owned; f.output = null; // an ordinary file pane from here on - pane.cwd = .none; // its directory is now its own path's dirname + f.watch_after_save = true; + pane.clearCwd(); pane.tag_init = false; // re-derive the tag as a plain file pane.tag_tail_len = 0; p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); - p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); return; } // its own path, spelled out: the in-place write, so the pane comes clean @@ -10985,15 +7910,8 @@ pub const Pardes = struct { p.askWrite(id, pane.serial, full); } - /// The bound on a save path, which travels inside its effect: nothing is - /// stashed, so two saves armed in one batch cannot be confused for each - /// other and a whole buffer is never copied to write it. - const SavePath = Effect.Buf(256); + const SavePath = Effect.Buf(effect_path_cap); - /// Ask the host to write this pane's text at `path` without touching the - /// pane. The bytes are the pane's own, so the drain reads them back off it - /// (perform, .save_text) the way save_file does — with `serial` saying - /// which pane asked, since the slot may be freed before the drain. fn askWrite(p: *Pardes, id: usize, serial: u32, path: []const u8) void { p.emit(.{ .save_text = .{ .pane = @intCast(id), @@ -11002,16 +7920,8 @@ pub const Pardes = struct { } }); } - /// Enter on an armed input: the pattern is everything past the marker's - /// `/` (so a pattern may itself contain slashes), and the marker names the - /// search — " Find /" walks the filesystem for NAMES, " Grep /" for - /// CONTENTS, " /" reads the pane's own text. No `/` left means the editor - /// ate the marker: nothing to run. fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - // `s`/`S` have already applied themselves keystroke by keystroke; - // Enter re-runs the final pattern so a submit is one code path with - // the preview and cannot disagree with what is on screen. if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); const tail = pane.tagSlice(); const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; @@ -11025,33 +7935,13 @@ pub const Pardes = struct { else .text; p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err); - // ...and the bare `/` GOES there. Find and Grep answer with OTHER - // files, and opening the first of them on submit would rearrange the - // screen before you have read what was found; `/` searched the text - // already in front of you, so its first hit is a scroll, not a jump. if (kind == .text) p.lookFirstHit(id); } - /// Land ON the first hit of a `/`, instead of beside a list of them. - /// - /// No new motion: the results buffer is FOCUSED and then the two ordinary - /// verbs run in the order a hand would run them — the step `n` is, and the - /// look Enter is. Focusing is the part that cannot be skipped and the - /// reason this is not just a call to the walk: lookWalkPanes deliberately - /// leaves the ACTIVE pane out of the ring, so `n` pressed straight after a - /// search steps whichever list was looked most recently, which in a - /// session with any history at all is not the one that just answered. - /// - /// Everything the walk needs to be reversible from here is left by the - /// step itself (landLookSpot's `look_at`), so `N` afterwards goes back to - /// the row above exactly as it would have if you had pressed `n` yourself. fn lookFirstHit(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const rid = pane.search_pane orelse return; const rp = p.panes[rid] orelse return; - // Nothing matched: the empty buffer is the answer, and aiming the walk - // at a list with no rows would send it round the ring into a NEIGHBOUR - // and open a row from some other search. if ((rp.file orelse return).content.len == 0) return; p.active = rid; p.lookWalk(1); @@ -11064,30 +7954,6 @@ pub const Pardes = struct { p.runBuiltin(config.look_cmd, rid, "", txt); } - /// Fill this pane's results buffer with everything matching `pat_raw` - /// (plain substring, case-insensitive) — ONE function for both searches, - /// because they differ only in where the rows come from and agree on every - /// row being a LOOK TARGET, which is what makes n/N work: - /// text (`/`) — the pane's own flat text, the same view Look and Execute - /// read, so a terminal searches its scrollback exactly as a file - /// searches its content. Rows are `location text`, the location ended - /// by a SPACE (a trailing `:` would read as part of it): the pane's - /// path when it has a real file, else `@pN`. - /// find — the pane's DIRECTORY, walked like fd. Rows are bare paths. - /// grep — the CONTENTS of every file under every pane's directory, - /// walked like `grep -R`. Rows are `path:LINE:COL-ENDCOL text`, the - /// path relative to THIS pane's directory (absolute for a hit outside - /// it). - /// Both searches that match TEXT name the match's whole span, so looking a - /// row — which is all n/N do — selects what matched rather than parking on - /// its first cell. Find's rows are bare paths and have nothing to span. - /// No matches = an empty buffer. - /// - /// `start` is where the WALK begins, which belongs to the gesture and not - /// to the search: a click POINTS at one of the hits, so its list is armed - /// there and the first step goes to the next one (acme's button-3 walking a - /// word). `/`, Find and Grep point at nothing, so their list is walked from - /// the top, which is also the only place a list of OTHER files could start. pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); @@ -11096,16 +7962,11 @@ pub const Pardes = struct { // where the pane lives: a file's directory, a shell's cwd — the walk // root, and the directory the results buffer is named in. const dir = paneDir(pane); - const out = try arena.alloc(u8, look.search_max_output_bytes); + const out = try arena.alloc(u8, filesystem.search_max_output_bytes); var out_len: usize = 0; var nrows: usize = 0; var anchor: ?usize = null; if (kind == .grep) { - // One walk per PLACE the session is open on: every pane's - // directory, minus the ones another pane's already contains, so a - // tree two panes sit in is greped once and a pane deep inside - // another's tree adds nothing. Slot order, so the same session - // gives the same buffer twice running. var roots: [MAX_PANES][]const u8 = undefined; var nroots: usize = 0; for (p.panes) |slot| { @@ -11130,12 +7991,12 @@ pub const Pardes = struct { nroots += 1; } for (roots[0..nroots]) |r| - out_len += try look.grep(arena, p.gpa, r, dir, pat, out[out_len..]); + out_len += try filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]); } else if (kind == .find) { - out_len = try look.find(arena, dir, pat, out); - } else if (hasPdf(pane)) { + out_len = try filesystem.find(arena, dir, pat, out); + } else if (pane.hasPdf()) { if (comptime pdf_enabled) { - const found = try pdf_pane.searchRows( + const found = try panes.Pdf.searchRows( &pane.pdf.?, p.pdf_gpa, arena, @@ -11148,7 +8009,7 @@ pub const Pardes = struct { anchor = found.anchor; } } else { - const pl = try p.paneCursorLines(pane); + const lines = try p.paneCursorLines(pane); // a real file names itself; a terminal or an output buffer has no path const has_path = if (pane.file) |f| f.output == null else false; var idbuf: [16]u8 = undefined; @@ -11158,12 +8019,9 @@ pub const Pardes = struct { std.fs.path.basename(pane.file.?.path) else std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong; - // the hit at or before the cursor is the one you are ON, so arming - // there makes the first step land on the NEXT one: a click on the - // second `foo` goes to the third, not back to the first. const cl: usize = @intCast(@max(0, pane.cur_row)); const cc: usize = @intCast(@max(0, pane.cur_col)); - for (pl.lines, 0..) |ln, i| { + for (lines, 0..) |ln, i| { const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{ loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"), @@ -11178,31 +8036,14 @@ pub const Pardes = struct { const content = try p.gpa.dupe(u8, out[0..out_len]); // the buffer records WHICH search filled it, pattern and all: Find and // Grep are builtins (words you can execute), the bare `/` is a key - const from: output_pane.Origin = switch (kind) { + const from: panes.Output.Origin = switch (kind) { .text => .search, .find => .{ .cmd = .Find }, .grep => .{ .cmd = .Grep }, }; - // 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. - try output_pane.fillResults(p, id, dir, from, pat, content, anchor); - } - - /// Step to the next/previous row of this pane's results buffer and ACT on - /// it — which of the two acme verbs that is comes from the buffer's own - /// traits. A location list (every search, every language answer) Looks the - /// leading `path:LINE:COL` word; a command list (ThemeSel, FontSel) Execs - /// the whole row. False = no live results to step. - /// - /// n/N used to BE this, and are not any more (lookWalk): stepping a list - /// of places now selects and stops, because a step that also opened meant - /// you could not walk past a hit without landing on it. What still comes - /// through here is what is not n/N at all: `]d`/`[d`, whose whole job is - /// to GO to the next diagnostic, and acme's button-3, where clicking a - /// word that names nothing searches for it and goes to the first hit. + try panes.Output.fillResults(p, id, dir, from, pat, content, anchor); + } + fn searchStep(p: *Pardes, id: usize, delta: i32) bool { const pane = p.panes[id] orelse return false; const rid = pane.search_pane orelse return false; @@ -11210,32 +8051,47 @@ pub const Pardes = struct { const rf = if (rp.file) |*f| f else return false; // one question covers both hazards: a freed slot can be reused by an // unrelated pane, and a buffer of PROSE has nowhere to step to - const tr = output_pane.fileTraits(rf.output); + const tr = panes.Output.fileTraits(rf.output); if (!tr.steps) return false; // fresh results: n starts at the first row, N has nothing behind it - const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n")); + const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n") + + @intFromBool(rf.content.len > 0 and rf.content[rf.content.len - 1] != '\n')); const step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; if (step < 0 or step >= nrows) return true; // armed, nowhere left to go - const r: i32 = @intCast(step); - pane.search_row = @intCast(step); - // select the result row in the results pane and keep it in view + _ = p.jumpResult(id, @intCast(step)); + p.active = id; + return true; + } + + fn jumpResult(p: *Pardes, id: usize, row: usize) bool { + const pane = p.panes[id] orelse return false; + const rid = pane.search_pane orelse return false; + const rp = p.panes[rid] orelse return false; + const rf = if (rp.file) |*f| f else return false; + const tr = panes.Output.fileTraits(rf.output); + const ln = modal.lineSlice(rf.content, row); + if (ln.len == 0) return false; + const r: i32 = @intCast(row); + pane.search_row = row; rp.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.vsel.active = false; rp.nsel = 0; rp.cur_row = r; rp.cur_pinned = true; - // in view, but WITHOUT scrolloff: a results pane is short, and a - // three-row margin on a seven-row one means every single n scrolls the - // list out from under the eye. A row already on screen moves nothing. const off = rp.scroll(); const last = off + @as(i32, rp.rows) - 1; if (r < off) rp.scrollBy(r - off) else if (r > last) rp.scrollBy(r - last); - const ln = modal.lineSlice(rf.content, @intCast(step)); var realbuf: [4096]u8 = undefined; const span = if (tr.commands) wholeRowSpan(ln) else - look.lookableLineSpan(ln, paneDir(rp), &realbuf); + look.lineSpan(p, ln, paneDir(rp), &realbuf) orelse blk: { + const target = panes.Output.location(ln); + break :blk if (target.at.line > 0) + look.Span{ .start = 0, .end = target.end } + else + null; + }; if (span) |selected| { rp.cur_col = @intCast(selected.start); rp.look_at = .{ @@ -11247,55 +8103,30 @@ pub const Pardes = struct { rp.cur_col = 0; rp.look_at = null; } - // Both arms are the BUILTIN, run on the results pane — the same call a - // middle or right click on that row would make, so a stepped row and a - // clicked row can never drift apart. A command row goes whole (its - // argument is the tail after the name); a location row is cut to the - // leading file-ish word, since the rest of it is the matched text. if (tr.commands) { p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); - } else { - var hi: usize = 0; - while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; - p.runBuiltin(config.look_cmd, rid, "", ln[0..hi]); + } else if (span) |selected| { + p.lookAt(rid, ln[selected.start..selected.end]); } - // the look may focus what it opened — a Find row opens a whole new - // file pane every time — so focus comes back to the pane that owns the - // search and the next n keeps stepping. A `/` row looks at the - // searching pane itself, so this is what already happened there. - p.active = id; + p.armLookWalk(rid); return true; } - /// Ask the backend something about the symbol under the cursor. Only a - /// real file can be asked: a terminal's rows are a program's output and an - /// output buffer is our own text, neither of which has a language behind - /// it. Unsupported kinds never get here (the keymap drops them), so a - /// backend that answers nothing simply never opens a buffer. pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { if (!p.multiOnce()) return; // one question per keystroke, from the primary if (!lsp.supports.contains(kind)) return; const pane = p.panes[id] orelse return; - // `status` is about the BACKEND, not about a document, so it answers - // from ANY pane — a terminal, a +Search, anywhere. That matters - // precisely when the pane you are sitting in is the thing going wrong. - // Every other kind needs a real file: a terminal's rows are a - // program's output and an output buffer is our own text. if (kind != .status) { const f = pane.file orelse return; if (f.output != null) return; } if (arg.len > 128) return; // the effect's arg is a Buf(128) - // A rename's argument becomes an identifier in someone's source. Zig - // buffers get Zig's exact rule; any other language the client speaks - // gets the weakest honest one (no whitespace, no quotes — the server - // validates the rest and answers nothing when it hates the name). if (kind == .rename) { const zig_buf = if (pane.file) |f| std.mem.endsWith(u8, f.path, ".zig") else true; if (zig_buf and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) return; if (!zig_buf and std.mem.indexOfAny(u8, arg, " \t\r\n\"\\") != null) return; } - const off = if (pane.file) |f| modal.hxOff(f.content, .{ + const off = if (pane.file) |f| modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }) else 0; @@ -11321,14 +8152,6 @@ pub const Pardes = struct { const LspEdit = struct { start: usize, end: usize, text: []const u8 }; - /// Parse a mutating response into ordered replacements. Two record forms, - /// never mixed: `@edit START END` substitutes `fallback` at every range - /// (the ZLS rename path — the text is the request's own argument), and - /// `@put START END PCT` carries its own percent-encoded replacement (a - /// protocol server's rename or format, whose per-range text only the - /// server knows). `fallback == null` rejects the @edit form outright — a - /// format response has no argument to substitute. Anything malformed - /// parses to null and null changes nothing. fn parseLspEdits(p: *Pardes, bytes: []const u8, fallback: ?[]const u8) ?[]LspEdit { if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null; const a = p.scratch.allocator(); @@ -11384,10 +8207,6 @@ pub const Pardes = struct { return out[0..n]; } - /// Where the cursor lands after `edits` replace their ranges: text before - /// the first edit keeps its offset, text between edits shifts by the - /// accumulated delta, and a cursor inside a replaced range clamps into - /// the replacement. fn mapLspEditOffset(edits: []const LspEdit, old: usize) usize { var old_at: usize = 0; var new_at: usize = 0; @@ -11402,10 +8221,6 @@ pub const Pardes = struct { return new_at + (old - old_at); } - /// Apply parsed edit records as ONE undo transaction, or nothing: ranges - /// must be ordered, non-overlapping and in bounds, and the file revision - /// must still be the one the worker was asked about — the user may have - /// typed while the server thought. True when the buffer changed. fn applyLspEdits(p: *Pardes, pane: *Pane, revision: u32, edits: []const LspEdit) bool { const f = if (pane.file) |*file| file else return false; if (f.revision != revision) return false; @@ -11426,7 +8241,7 @@ pub const Pardes = struct { const final_len = std.math.add(usize, f.content.len - removed, added) catch return false; const replacement = p.gpa.alloc(u8, final_len) catch return false; - const old_cursor = modal.hxOff(f.content, .{ + const old_cursor = modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }); @@ -11443,8 +8258,8 @@ pub const Pardes = struct { @memcpy(replacement[write_at..], f.content[read_at..]); p.pushUndo(pane); - file_pane.setContent(p, f, replacement); - const cursor = modal.hxPos(f.content, mapped_cursor); + panes.File.setContent(p, f, replacement); + const cursor = modal.positionAt(f.content, mapped_cursor); pane.cur_row = @intCast(cursor.row); pane.cur_col = @intCast(cursor.col); pane.vsel.active = false; @@ -11455,21 +8270,11 @@ pub const Pardes = struct { return true; } - /// A worker answered. The two MUTATING kinds are consumed first: a - /// response made of edit records is applied atomically (rename substitutes - /// the argument or the server's own text, `=` applies the formatter), and - /// only a response that is NOT records renders — a rename that spans other - /// files arrives as location rows and opens as a PREVIEW list instead of - /// being half-applied, and a format that could not run stays prose. - /// Every other response is the ordinary look/output path: - /// one row, a goto -> jump straight there (helix jumps on a single - /// location and shows a picker on several) - /// anything else -> an output buffer, which n/N already steps. That - /// buffer IS the picker; there was never one to write. - pub fn lspResponse(p: *Pardes, id: u32, rows: []const u8) void { + pub fn lspResponse(p: *Pardes, id: u32, response: ?[]const u8) void { const w = p.lsp_wait orelse return; if (w.id != id) return; // superseded by a newer press, or the pane died p.lsp_wait = null; + const rows = response orelse return; const pane = p.panes[w.pane] orelse return; if (pane.serial != w.serial) return; if (w.kind == .rename or w.kind == .format) { @@ -11489,107 +8294,51 @@ pub const Pardes = struct { } else return; } if (rows.len == 0) { - // No rows is a legal answer everywhere except here. Tab DIVERTED - // instead of indenting, so an empty answer would eat the keystroke - // — a dot in a comment, a dot in a string, a half-typed line - // nothing can be made of — and a Tab that silently does nothing is - // worse than not having the feature. So the indent happens now, - // late, on the condition that nothing has moved: same pane, still - // in insert, one cursor, and the cursor still on the cell the Tab - // was pressed at. Anyone who kept typing during the query gets - // nothing rather than four spaces landing 300ms behind their hands. if (w.kind == .completion and pane.mode == .insert and pane.nsel == 0 and pane.cur_row == w.row and pane.cur_col == w.col) p.insertTab(pane); return; } - const from: output_pane.Origin = .{ .query = w.kind }; + const from: panes.Output.Origin = .{ .query = w.kind }; const nrows = std.mem.count(u8, rows, "\n"); - if (output_pane.traits(from).jumps and nrows == 1) { + if (panes.Output.traits(from).jumps and nrows == 1) { const ln = std.mem.trimEnd(u8, rows, "\n"); - var hi: usize = 0; - while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; - // exactly what a `/` result row does when n steps onto it: look the - // `path:LINE:COL` token from the pane that asked, so placement, - // dedup-onto-an-open-pane and centering are the ONE look path. - // (helix would also push its jumplist here; pardes has none, so - // there is nothing to push — do not read this as one.) - return p.lookAt(w.pane, ln[0..hi]); + return p.lookAt(w.pane, ln[0..panes.Output.location(ln).end]); } 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; - // 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) catch |err| + panes.Output.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null) catch |err| { p.reportError(w.pane, "language response", err); + return; + }; + if (panes.Output.traits(from).jumps) { + const result = p.panes[pane.search_pane orelse return] orelse return; + const file = pane.file orelse return; + const row = panes.Output.nextResult(result.file.?.content, lsp.rel(dir, file.path), .{ + .line = @as(usize, @intCast(@max(0, w.row))) + 1, + .col = @as(usize, @intCast(@max(0, w.col))) + 1, + }); + _ = p.jumpResult(w.pane, row); + } } // ---- n/N: the walk over look-able text ---- - /// How many rows ONE PRESS may scan, across every pane it visits. A - /// shell's motion surface is its whole scrollback and every whitespace run - /// on it costs a realpath, so the walk is bounded. - /// - /// Running out STOPS the walk where it stands rather than treating the - /// pane as exhausted and moving on, and that distinction is load-bearing: - /// giving up in the middle of a pane and hopping to the next one would - /// make the two directions disagree about where a pane ENDS, and n/N have - /// to be exact inverses. Not moving is the one failure that always is. - /// A pane whose next look-able text is eight thousand rows away is a pane - /// to scroll, not to step. const max_look_rows = 8192; - /// Where a step STARTS inside a pane. `col` null enters the pane at the - /// row's edge — every span on it is ahead of you — which is what a hop - /// from a neighbouring pane does. `strict` says the column is a position - /// the walk itself established, so the span sitting ON it is the one you - /// are already at and the step must go past it. const LookFrom = struct { row: i32, col: ?i32, strict: bool = false }; - /// Entering a pane from a neighbour: the first row going forward, the last - /// going back. Spelled once because it is exactly what makes the two - /// directions inverses across a pane boundary. fn lookEdge(delta: i32) LookFrom { return .{ .row = if (delta > 0) 0 else std.math.maxInt(i32), .col = null }; } - /// Where the walk currently stands in `pane`. - /// - /// `Pane.look_at` and not the cursor alone, because the cursor cannot - /// answer the question. A cursor parked on the first look-able span may - /// mean the walk put it there — so the next step is the SECOND span — or - /// that the pane simply opened that way, which is every fresh +Search, and - /// there the next step must be the FIRST. `search_row` answered the same - /// question the same way for the same reason. When the recorded stand no - /// longer matches the cursor you have moved it yourself since, and the - /// cursor wins: the walk continues from where you are looking. fn lookStand(pane: *Pane) LookFrom { if (pane.look_at) |s| if (s.row == pane.cur_row and s.col0 == pane.cur_col) return .{ .row = s.row, .col = s.col0, .strict = true }; return .{ .row = pane.cur_row, .col = pane.cur_col }; } - /// The panes n/N walk, in the order it walks them: every pane that has - /// performed a LOOK, most recent first, then the OUTPUT buffers none has, - /// newest first — and, only when that comes to nothing at all, the pane - /// you are in. - /// - /// The look history is the spine because looking is what marks a pane as - /// the one you are reading things OUT of — the +Search you are stepping, - /// the diagnostics list, the shell whose `ls` rows you keep opening. The - /// unlooked output buffers come after it so a fresh `/`, which has looked - /// at nothing yet, still has somewhere for the first `n` to go: its own - /// results. FILO among them, so two searches step the newer list first. - /// - /// The ACTIVE pane is the fallback and NOT a member, which is the - /// difference between n continuing a list and n wandering off it. Look a - /// row out of a +Search and focus lands in the file that opened; the next - /// n has to go back to the +Search, not start walking the paths that - /// happen to be in the source you just opened. Only when nothing has - /// looked and no buffer has answered — a shell one minute into a session, - /// which is where n/N started life — is the pane in front of you the list. fn lookWalkPanes(p: *Pardes, out: *[MAX_PANES]usize) []const usize { var n: usize = 0; var i = p.n_look_src; @@ -11624,13 +8373,6 @@ pub const Pardes = struct { return out[0..n]; } - /// Is a span starting at `col0` PAST `from` in the direction of travel? - /// Only the row a walk STARTED on is filtered — every span on a row it - /// arrived at is ahead of it — and the comparison is against `col0` rather - /// than the whitespace run's start. Those are different columns the moment - /// a wrapper is peeled: `(mise.toml)` is a run starting at 0 and a span - /// starting at 1, and a backward step filtered on the run would find the - /// span it is standing on still ahead of it and never leave the row. fn lookPast(col0: i32, from: LookFrom, on_start_row: bool, delta: i32) bool { if (!on_start_row) return true; const c = from.col orelse return true; @@ -11638,9 +8380,6 @@ pub const Pardes = struct { return if (from.strict) col0 < c else col0 <= c; } - /// The whole row as one span, first non-blank cell to last — the `.whole` - /// grain. The trailing trim keeps a padded row selecting the command and - /// not the padding. fn wholeRowSpan(ln: []const u8) ?look.Span { var lo: usize = 0; while (lo < ln.len and (ln[lo] == ' ' or ln[lo] == '\t')) lo += 1; @@ -11648,33 +8387,11 @@ pub const Pardes = struct { return if (hi > lo) .{ .start = lo, .end = hi } else null; } - /// The next STEPPABLE span in `pane` from `from`, in `delta`'s direction, - /// or null when the pane has none left that way. `budget` is the caller's - /// remaining row allowance and is spent here; a null return with a budget - /// of zero means GAVE UP, not exhausted (see max_look_rows). - /// - /// WHAT A SPAN IS comes from the pane's grain (output_pane.Grain) and is - /// the one thing about this motion a buffer gets to change: - /// .word free text — a terminal, a file, a PDF — where a row may hold - /// several places and every look-able run is a stop: an `ls` - /// line hops big.txt -> plain.txt -> sub (look.lookableSpan). - /// .line a results buffer, where a row IS one location: one stop per - /// row, on the largest run its head resolves as, and the matched - /// text after it is not a second stop (look.lookableLineSpan). - /// .whole a command list (ThemeSel, FontSel), where the line is the - /// word: `Theme gruvbox` has no path inside it to pick out. - /// Same motion, same selection, same Enter/Tab afterwards. - /// - /// Symmetric by construction in all three, and that is the whole point: - /// both directions ask the same question about the same rows, and both - /// compare against `col0` — the column the walk parks the cursor on. So a - /// step forward off a span and a step back onto it are the same two - /// positions read in the two orders. - fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?LookSpot { - const pl = p.paneCursorLines(pane) catch return null; - const nrows: i32 = @intCast(pl.lines.len); + fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?Pane.LookSpot { + const lines = p.paneCursorLines(pane) catch return null; + const nrows: i32 = @intCast(lines.len); if (nrows == 0) return null; - const grain: output_pane.Grain = if (pane.file) |*f| output_pane.grain(f.output) else .word; + const grain: panes.Output.Grain = if (pane.file) |*f| panes.Output.grain(f.output) else .word; const dir = paneDir(pane); var realbuf: [4096]u8 = undefined; const start = std.math.clamp(from.row, 0, nrows - 1); @@ -11682,18 +8399,18 @@ pub const Pardes = struct { while (r >= 0 and r < nrows) : (r += delta) { if (budget.* == 0) return null; budget.* -= 1; - const ln = pl.lines[@intCast(r)]; + const ln = lines[@intCast(r)]; const on_start = r == start; switch (grain) { .word => { - var best: ?LookSpot = null; + var best: ?Pane.LookSpot = null; var i: usize = 0; while (i < ln.len) { while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1; const t0 = i; while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1; if (i == t0) break; - const sp = look.lookableSpan(ln[t0..i], dir, &realbuf) orelse continue; + const sp = look.wordSpan(p, ln[t0..i], dir, &realbuf) orelse continue; const col0: i32 = @intCast(t0 + sp.start); if (!lookPast(col0, from, on_start, delta)) continue; best = .{ .row = r, .col0 = col0, .col1 = @intCast(t0 + sp.end - 1) }; @@ -11705,7 +8422,7 @@ pub const Pardes = struct { // scan past: the row either offers it or it does not .line, .whole => { const sp = (if (grain == .line) - look.lookableLineSpan(ln, dir, &realbuf) + look.lineSpan(p, ln, dir, &realbuf) else wholeRowSpan(ln)) orelse continue; const col0: i32 = @intCast(sp.start); @@ -11717,56 +8434,10 @@ pub const Pardes = struct { return null; } - /// n/N: move the SELECTION to the next/previous look-able text and open - /// NOTHING. Enter looks what this leaves selected, and that separation is - /// the change: a step is a motion you can take twenty of and then decide, - /// where it used to be twenty panes. - /// - /// The sequence stepped is the concatenation, in lookWalkPanes' order, of - /// each pane's look-able spans in document order, AND IT IS A RING. `n` is - /// the next position on that ring and `N` the previous one, computed the - /// same way from the same state — so x presses one way and x back land - /// exactly where you started, across pane boundaries included: a pane - /// entered forward is entered at its FIRST span, and leaving it backward - /// from that span drops into the previous pane's LAST. - /// - /// A ring rather than a list with two ends, for two reasons that turn out - /// to be one. A shell's cursor sits at the PROMPT, below everything it has - /// printed, so a walk that could not come round would have nowhere to go - /// on the very first press — which is the case n/N was written for. And a - /// ring is still exactly reversible, so nothing is given up for it: acme's - /// search has always been one, and this is that. - /// - /// (One press is not symmetric, and cannot be: from a cursor the walk has - /// never stood on, the first step ACQUIRES a position rather than moving - /// one — see lookStand. Every press after that is exact.) - /// - /// Position stays in each pane (`look_at`); the one global serial records - /// only WHICH stream owns the next step after a Look moves focus away. - /// Serials make deletion/reuse stale safely, and refilling a list simply - /// re-arms that list without manufacturing a second cursor. - /// - /// ONE MOTION, EVERYWHERE. Not a pane kind, not a buffer kind, not a mode: - /// n/N are this walk in all of them, which is the other half of making - /// them trustworthy. A PDF used to step its results buffer and jump; it - /// steps the same ring now, which IS that buffer, and Enter does the - /// jumping. The single thing any buffer gets to change is the GRAIN of - /// what a step selects, and it changes it by BEING a kind of buffer rather - /// than by a branch here (output_pane.Grain, read in lookSpanIn): free - /// text steps every look-able word, a results list steps one ROW at a time - /// — its head is the location and the rest is the match — and a command - /// list steps the whole line, because a ThemeSel row is a word to run and - /// not a place to go. - /// - /// `]d`/`[d` are not n/N. They are helix's diagnostic motions, their job - /// is to ARRIVE at the next diagnostic, and they still reach searchStep. fn lookWalk(p: *Pardes, delta: i32) void { var buf: [MAX_PANES]usize = undefined; const order = p.lookWalkPanes(&buf); if (order.len == 0) return; - // A Look may move focus away from the list it came from. Continue the - // explicitly armed origin first; only a stale/missing owner falls back - // to the pane under focus and then the history head. const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null; const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0; const at = if (owner) |wanted| @@ -11774,10 +8445,6 @@ pub const Pardes = struct { else active_at; var from = lookStand(p.panes[order[at]] orelse return); - // ...then every OTHER pane once, in the direction of travel, entered - // at its edge — and `k == order.len` brings the starting pane round a - // second time, from ITS edge, which is the wrap. That bound is also - // what makes a screen with nothing look-able on it terminate. var budget: usize = max_look_rows; var k: usize = 0; while (k <= order.len) : (k += 1) { @@ -11791,11 +8458,7 @@ pub const Pardes = struct { } } - /// Select `spot` and focus its pane. The selection is EXPLICIT so Enter's - /// look chord acts on it, with the anchor on the span's last cell and the - /// cursor on its FIRST — the same shape the old terminal stepper left, and - /// the reason `col0` is the position the walk compares against. - fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: LookSpot) void { + fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: Pane.LookSpot) void { p.pinPaneCursor(pane); // fresh out of tty mode the cursor still tracks the shell pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true }; pane.msel.active = false; @@ -11810,16 +8473,13 @@ pub const Pardes = struct { } /// Route shared edit operations to a file's content or a terminal overlay. - fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { + fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?panes.EditText { if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; - if (pane.image != null or hasPdf(pane)) return null; - return term_pane.editText(p, pane, lo, hi, col); + if (pane.image != null or pane.hasPdf()) return null; + return panes.Terminal.editText(p, pane, lo, hi, col); } - /// editText for an op whose selection can END on a line's newline cell: - /// eating that newline joins with the line BELOW, so a terminal's buffer - /// has to cover that row too (a file's content always already does). - fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?EditText { + fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?panes.EditText { const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return null; const r: usize = @intCast(@max(0, b.hi_row - eb.row0)); if (r + 1 < modal.lineCount(eb.text)) return eb; @@ -11829,14 +8489,14 @@ pub const Pardes = struct { /// install a rewritten editable text (frees the old one) fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - if (pane.file) |*f| return file_pane.setContent(p, f, new); - term_pane.setEditText(p, pane, new); + if (pane.file) |*f| return panes.File.setContent(p, f, new); + panes.Terminal.setEditText(p, pane, new); } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { - if (hasPdf(pane)) return; + if (pane.hasPdf()) return; p.pinPaneCursor(pane); // snapshot once per insert session (WITH the pre-insert selection) so // `u` undoes the whole session and restores what was selected @@ -11844,19 +8504,15 @@ pub const Pardes = struct { pane.select = false; pane.append_at = null; pane.sticky_col = -1; - const pl = p.paneCursorLines(pane) catch { + const text = p.flatSurface(pane) catch { pane.mode = .insert; pane.msel.active = false; pane.vsel.active = false; - pane.pending = 0; + pane.normal.clear(); return; }; - const text = p.flatSurface(pane, pl) catch return; - const cur = toModalCursor(pane, pl); - const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0; - // helix selection-aware entry: `i` to the selection's START (the - // selection flips and survives until the first edit); `a` one past - // its END, remembering the origin cell for the Esc restore + const cur = pane.toModalCursor(); + const line = panes.File.textLine(pane, text, cur.row); const b: ?Bounds = if (pane.vsel.active) vselBounds(pane) else null; switch (where) { .at => { @@ -11873,19 +8529,19 @@ pub const Pardes = struct { const hi_row = if (b) |bb| bb.hi_row else pane.cur_row; const hi_col = if (b) |bb| bb.hi_col else pane.cur_col; pane.append_at = .{ .row = lo_row, .col = lo_col }; - const gap = modal.nextGrapheme(text, modal.hxOff(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); - const gc = modal.hxPos(text, gap); + const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); + const gc = modal.positionAt(text, gap); pane.cur_row = @intCast(gc.row); pane.cur_col = @intCast(gc.col); pane.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false }; pane.cur_pinned = true; }, .line_start => { - fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)); + pane.fromModalCursor(.{ .row = cur.row, .col = modal.firstNonWs(line) }); pane.vsel.active = false; }, .line_end => { - pane.cur_col = @intCast(llen); + pane.cur_col = @intCast(line.len); pane.cur_pinned = true; pane.vsel.active = false; }, @@ -11895,7 +8551,7 @@ pub const Pardes = struct { const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else pane.cur_row; const eb = p.editText(pane, abs, abs, -1) orelse return; const row: usize = @intCast(@max(0, abs - eb.row0)); - const ind = modal.hxIndentString(modal.lineSlice(eb.text, row)); + const ind = modal.indentText(modal.lineSlice(eb.text, row)); const arena = p.scratch.allocator(); const block_len = std.math.mul(usize, cnt, ind.len + 1) catch return; const block = arena.alloc(u8, block_len) catch return; @@ -11922,28 +8578,23 @@ pub const Pardes = struct { pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; if (cnt > 1 and !p.multi_on) opened: { - const pl2 = p.paneCursorLines(pane) catch break :opened; - const t2 = p.flatSurface(pane, pl2) catch break :opened; - var rs: [MAX_SELS]modal.HxRange = undefined; - const m = @min(cnt, MAX_SELS); + const t2 = p.flatSurface(pane) catch break :opened; + var rs: [Pane.max_selections]modal.Selection = undefined; + const m = @min(cnt, Pane.max_selections); for (0..m) |k| { - const o = modal.hxOff(t2, .{ .row = @intCast(@max(0, pane.cur_row - pl2.row0) + @as(i32, @intCast(k))), .col = ind.len }); + const o = modal.offsetAt(t2, .{ .row = @intCast(@max(0, pane.cur_row) + @as(i32, @intCast(k))), .col = ind.len }); rs[k] = .{ .anchor = o, .head = o }; } - setPaneRanges(pane, pl2, t2, rs[0..m], &.{}, 0, false); + pane.setRanges(t2, rs[0..m], &.{}, 0, false); } }, } pane.mode = .insert; pane.msel.active = false; - pane.pending = 0; + pane.normal.clear(); pane.ensureCursorVisible(); } - /// insert mode. ONE path for both pane kinds: a file edits its content, a - /// terminal edits the buffer standing in for its shell rows (editText - /// materializes and grows it), so typing, Enter, joins and the kill runs - /// mean exactly the same thing in a shell pane as in a document. fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void { if (pane.nsel == 0) return p.insertKey(pane, key); p.replaySels(pane, .{ .insert = key }); @@ -11954,38 +8605,30 @@ pub const Pardes = struct { if (hit(key, config.insert_backspace_alias)) return p.insertKey(pane, .{ .cp = Key.backspace }); if (hit(key, config.insert_enter_alias)) return p.insertKey(pane, .{ .cp = Key.enter }); if (hit(key, config.insert_delete_alias)) return p.insertKey(pane, .{ .cp = Key.delete }); - // a selection carried into insert (i/a) survives only until the next - // key: helix maps it through every edit, pardes drops it instead — - // its only pardes use (the acme chords) needs explicit selections - // anyway, and those never enter insert mode pane.vsel.active = false; if (!pane.cur_pinned) p.pinPaneCursor(pane); - // arrows and paging are pure motion over the WHOLE surface, so they - // run before editText — a terminal must not freeze shell rows into an - // edit buffer just because you walked across them switch (key.cp) { Key.left, Key.right, Key.up, Key.down => { - const pl = p.paneCursorLines(pane) catch return; - const cur0 = toModalCursor(pane, pl); + const lines = p.paneCursorLines(pane) catch return; + const cur0 = pane.toModalCursor(); const nc = switch (key.cp) { - Key.left => modal.charLeft(pl.lines, cur0), - Key.right => modal.charRight(pl.lines, cur0), - Key.up => insertVerticalCursor(pl.lines, cur0, false), - Key.down => insertVerticalCursor(pl.lines, cur0, true), + Key.left => modal.charLeft(lines, cur0), + Key.right => modal.charRight(lines, cur0), + Key.up => Pane.insertVerticalCursor(lines, cur0, false), + Key.down => Pane.insertVerticalCursor(lines, cur0, true), else => cur0, }; - fromModalCursor(pane, pl, nc); + pane.fromModalCursor(nc); pane.ensureCursorVisible(); return; }, Key.page_up, Key.page_down => { // helix binds insert pageup/pagedown to the same view // scroll + cursor snap as normal mode - const pl = p.paneCursorLines(pane) catch return; - const flat = p.flatSurface(pane, pl) catch return; - const range = paneRange(pane, flat, pl.row0); + const flat = p.flatSurface(pane) catch return; + const range = pane.primaryRange(flat, 0); const step: i32 = @intCast(@max(1, pane.rows)); - scrollViewMove(pane, pl, flat, range, if (key.cp == Key.page_down) step else -step); + pane.scrollViewMove(flat, range, if (key.cp == Key.page_down) step else -step); return; }, Key.home => { @@ -11996,9 +8639,9 @@ pub const Pardes = struct { }, Key.end => { // helix insert End: past the last char (goto_line_end_newline) - const pl = p.paneCursorLines(pane) catch return; - const cur0 = toModalCursor(pane, pl); - pane.cur_col = @intCast(if (cur0.row < pl.lines.len) pl.lines[cur0.row].len else 0); + const text = p.flatSurface(pane) catch return; + const cur0 = pane.toModalCursor(); + pane.cur_col = @intCast(panes.File.textLine(pane, text, cur0.row).len); pane.cur_pinned = true; pane.ensureCursorVisible(); return; @@ -12023,19 +8666,16 @@ pub const Pardes = struct { } // helix insert-mode kills (word/line; deleteSpan is exclusive) if (hit(key, config.delete_word_backward)) { - // helix delete_word_backward: to the previous word start — - // crossing the newline at col 0, which takes the word before it - // too, so on the buffer's first line a terminal grows up one row const e2 = if (c.row == 0 and c.col == 0) (p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; - const g = modal.hxOff(e2.text, c2); + const g = modal.offsetAt(e2.text, c2); if (g == 0) return; - const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); + const wr = modal.moveWord(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); const from = @min(wr.anchor, wr.head); - const fc = modal.hxPos(e2.text, from); + const fc = modal.positionAt(e2.text, from); const new = modal.deleteSpan(p.gpa, e2.text, fc, c2) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; @@ -12045,19 +8685,16 @@ pub const Pardes = struct { return; } if (hit(key, config.delete_word_forward)) { - // helix delete_word_forward: to the next word END (trailing - // whitespace survives), crossing newlines at line ends — at the - // buffer's last line a terminal grows down one row to allow it const e2 = if (c.col >= modal.lineSlice(text, c.row).len and c.row + 1 >= modal.lineCount(text)) (p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; - const g = modal.hxOff(e2.text, c2); - const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .next_word_end); + const g = modal.offsetAt(e2.text, c2); + const wr = modal.moveWord(e2.text, .{ .anchor = g, .head = g }, 1, .next_word_end); const to = @max(wr.anchor, wr.head); if (to <= g) return; - const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.hxPos(e2.text, to)) catch return; + const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.positionAt(e2.text, to)) catch return; p.setEditText(pane, new); pane.cur_pinned = true; return; @@ -12080,7 +8717,7 @@ pub const Pardes = struct { switch (key.cp) { Key.enter => { const line = modal.lineSlice(text, c.row); - const indent = modal.hxNewlineIndentWidth(line, c.col); + const indent = modal.newlineIndentWidth(line, c.col); const arena = p.scratch.allocator(); const block = arena.alloc(u8, 1 + indent) catch return; block[0] = '\n'; @@ -12134,31 +8771,10 @@ pub const Pardes = struct { pane.cur_pinned = true; }, Key.tab => { - // Tab straight after a `.` asks the language backend what - // could go there — an output buffer of DEFINITIONS, one row - // per candidate, not an autocomplete popup and not an - // insertion. Only where an answer is possible: a terminal, an - // output buffer or a file the backend does not speak still - // indents, because a Tab that silently does nothing is worse - // than not having the feature. The extension list stays the - // backend's (lsp.speaks); this only asks. (An answer that - // comes back EMPTY indents too, late — see lspResponse.) - // - // Never with several cursors. A language query is a - // per-KEYSTROKE action inside a per-SELECTION replay, so - // multiOnce would stop the replay dead: the other cursors - // would neither ask nor indent and the whole multicursor would - // collapse on a Tab. Every other insert key applies to all of - // them, and so does this one — by indenting. const ln = modal.lineSlice(text, c.row); 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; - // 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; @@ -12169,9 +8785,6 @@ pub const Pardes = struct { } } - /// helix insert_tab with a Spaces indent style: spaces to the next tab - /// 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 = .{ @@ -12189,7 +8802,7 @@ pub const Pardes = struct { const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; /// a range's two cells, normalized to document order - fn cellBounds(s: SelRange) Bounds { + fn cellBounds(s: Pane.SelRange) Bounds { if (s.row < s.arow or (s.row == s.arow and s.col < s.acol)) return .{ .lo_row = s.row, .lo_col = s.col, .hi_row = s.arow, .hi_col = s.acol }; return .{ .lo_row = s.arow, .lo_col = s.acol, .hi_row = s.row, .hi_col = s.col }; @@ -12199,30 +8812,23 @@ pub const Pardes = struct { fn vselBounds(pane: *Pane) Bounds { return cellBounds(.{ .row = pane.cur_row, .col = pane.cur_col, .arow = pane.vsel.row, .acol = pane.vsel.col }); } - /// the char selection as text. Read off the pane's SURFACE (file content / - /// terminal shell rows + edit buffer), not the rendered body: a yank of a - /// whole line has to carry its newline, the way a file's does, or p/P - /// paste it charwise. Scratch-owned. fn vselText(p: *Pardes, pane: *Pane) []const u8 { const arena = p.scratch.allocator(); const b = vselBounds(pane); - const text = if (pane.file) |f| f.content else surface: { - const pl = p.paneCursorLines(pane) catch return ""; - break :surface p.flatSurface(pane, pl) catch return ""; - }; + const text = p.flatSurface(pane) catch return ""; return modal.rangeText(arena, text, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch ""; } /// join surface rows [r0, r1] (absolute) with '\n'; scratch-owned fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 { const arena = p.scratch.allocator(); - const pl = p.paneCursorLines(pane) catch return ""; + const lines = p.paneCursorLines(pane) catch return ""; const rows_count: usize = @intCast(@max(0, r1 - r0 + 1)); var total: usize = rows_count -| 1; var i = r0; while (i <= r1) : (i += 1) { - if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) - total += pl.lines[@intCast(i)].len; + if (i >= 0 and @as(usize, @intCast(i)) < lines.len) + total += lines[@intCast(i)].len; } const out = arena.alloc(u8, total) catch return ""; var at: usize = 0; @@ -12232,8 +8838,8 @@ pub const Pardes = struct { out[at] = '\n'; at += 1; } - if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) { - const line = pl.lines[@intCast(i)]; + if (i >= 0 and @as(usize, @intCast(i)) < lines.len) { + const line = lines[@intCast(i)]; @memcpy(out[at..][0..line.len], line); at += line.len; } @@ -12262,7 +8868,7 @@ pub const Pardes = struct { p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; - clampCursor(pane, d.content, eb.row0); + pane.clampCursor(d.content, eb.row0); return; } if (pane.msel.active) { @@ -12321,32 +8927,17 @@ pub const Pardes = struct { } } - /// helix p/P: the DEFAULT register, after/before the selection. - fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void { - p.pasteText(pane, p.yank orelse return, before); - } - - /// ...and the paste itself, over text from wherever: the register above, - /// or the system clipboard `SPC p` asked the shell for, which deliberately - /// never passes through the register on its way here. - /// - /// Text ending in '\n' pastes as whole lines below/above the SELECTION's - /// line span; anything else splices inline at the selection's outer edge. - /// The paste (repeated times) becomes the implicit selection, - /// cursor on its last char (linewise: ON the last pasted line's newline). - fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool) void { + fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool, count: usize) void { if (y0.len == 0) return; p.pushUndo(pane); pane.select = false; pane.sticky_col = -1; - const cnt: usize = @max(1, pane.count); - pane.count = 0; const arena = p.scratch.allocator(); var y: []const u8 = y0; - if (cnt > 1) { - const total = std.math.mul(usize, cnt, y0.len) catch return; + if (count > 1) { + const total = std.math.mul(usize, count, y0.len) catch return; const buf = arena.alloc(u8, total) catch return; - for (0..cnt) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0); + for (0..count) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0); y = buf; } const b: Bounds = if (pane.vsel.active) @@ -12357,9 +8948,6 @@ pub const Pardes = struct { const row0 = eb.row0; if (y[y.len - 1] == '\n') { const block_text = y[0 .. y.len - 1]; - // a yanked BLANK line is "\n": the block is empty and lineCount - // says 0 lines, but it still pastes as one (empty) line — without - // the floor every `n - 1` below underflows and panics. const n = @max(1, modal.lineCount(block_text)); var out: []u8 = undefined; if (before) { @@ -12420,7 +9008,7 @@ pub const Pardes = struct { p.gpa.free(d.deleted); const n = modal.lineCount(d.content); const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row - eb.row0))), if (n == 0) 0 else n - 1); - const ind = modal.hxIndentString(modal.lineSlice(d.content, row)); + const ind = modal.indentText(modal.lineSlice(d.content, row)); const arena = p.scratch.allocator(); const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return; const new = modal.insertAt(p.gpa, d.content, .{ .row = row, .col = 0 }, block) catch return; @@ -12431,7 +9019,7 @@ pub const Pardes = struct { pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); pane.ensureCursorVisible(); return; } @@ -12445,9 +9033,9 @@ pub const Pardes = struct { p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; - clampCursor(pane, d.content, eb.row0); + pane.clampCursor(d.content, eb.row0); pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); return; } if (pane.msel.active) { @@ -12492,7 +9080,7 @@ pub const Pardes = struct { pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); pane.ensureCursorVisible(); } else { p.pushUndo(pane); @@ -12506,18 +9094,10 @@ pub const Pardes = struct { const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); } } - // ---- helix change ops (r R ~ ` J > < Ctrl-a m-mode ]space) ---- - // Terminals go through the same edit buffer as files: since editText - // materializes one over whatever rows the op names, every one of these - // works the same in a shell pane as in a document. - - /// the selection as an inclusive cursor range in `content`, whose first - /// line is absolute row `row0`: vsel span, msel line span, else the char - /// under the cursor (helix's implicit 1-wide selection) fn selRange(pane: *Pane, content: []const u8, row0: i32) modal.Range { if (pane.vsel.active) { const b = vselBounds(pane); @@ -12553,7 +9133,7 @@ pub const Pardes = struct { fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u21) void { pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; - const before = paneRange(pane, eb.text, eb.row0); + const before = pane.primaryRange(eb.text, eb.row0); const lo = @min(before.anchor, before.head); const hi = @max(before.anchor, before.head); var graphemes: usize = 0; @@ -12566,11 +9146,11 @@ pub const Pardes = struct { const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return; p.setEditText(pane, new); const end = lo + graphemes * encoded_len; - const mapped: modal.HxRange = if (before.head < before.anchor) + const mapped: modal.Selection = if (before.head < before.anchor) .{ .anchor = end, .head = lo } else .{ .anchor = lo, .head = end }; - setPaneRange(pane, .{ .lines = &.{}, .row0 = eb.row0 }, new, mapped, pane.vsel.explicit); + pane.setRange(new, eb.row0, mapped, pane.vsel.explicit); } /// `R`: replace the selection (or the cursor char) with the DEFAULT @@ -12620,9 +9200,6 @@ pub const Pardes = struct { /// `from`. Ascending and disjoint. const TextChange = struct { from: usize, to: usize, ins: []const u8 }; - /// map an original-text offset through a change list (insertions AT a - /// position push it right — helix Assoc::After; positions inside a - /// deleted span collapse to its start) fn mapThroughChanges(chs: []const TextChange, pos: usize) usize { var delta: i64 = 0; for (chs) |ch| { @@ -12658,20 +9235,15 @@ pub const Pardes = struct { return out; } - /// `J`: helix join_selections — join the selection's line span (a bare - /// cursor joins with the next line): each '\n' + following indent become - /// one space, EXCEPT before content-less lines (no space) — and on the - /// buffer's last line the trailing newline is deleted. The selection and - /// cursor map through the edit; the count is ignored (helix). fn normalJoin(p: *Pardes, pane: *Pane) void { // a join always eats the newline of its last line, so the buffer has // to reach one row PAST the selection const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row + 1, -1) orelse return; const text = eb.text; - const range = paneRange(pane, text, eb.row0); - const span = rangeLineSpan(text, range); - const nlines = modal.hxLineCount(text); + const range = pane.primaryRange(text, eb.row0); + const span = Pane.rangeLineSpan(text, range); + const nlines = modal.cursorLineCount(text); var end = span.end; if (span.start == end) end = @min(end + 1, nlines - 1); if (end <= span.start) return; @@ -12680,26 +9252,26 @@ pub const Pardes = struct { var chs_len: usize = 0; var l = span.start; while (l < end) : (l += 1) { - const from = modal.hxLineEndIdx(text, l); + const from = modal.lineEndOffset(text, l); var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; - const sep: []const u8 = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) "" else " "; + const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " "; chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; chs_len += 1; } if (chs_len == 0) return; p.pushUndo(pane); - const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); + const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) - modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs[0..chs_len]) catch return; const nc = mapThroughChanges(chs[0..chs_len], cur_off); const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); - const cc = modal.hxPos(new, nc); - const ac = modal.hxPos(new, na); + const cc = modal.positionAt(new, nc); + const ac = modal.positionAt(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { @@ -12712,18 +9284,13 @@ pub const Pardes = struct { pane.ensureCursorVisible(); } - /// `>` / `<`: helix indent/unindent over the selection's line span. - /// Blank (all-whitespace) lines are skipped; `>` inserts count levels - /// realigned to the next INDENT_W stop; `<` removes up to count levels of - /// leading whitespace (a tab advances to the next stop). Cursor and - /// selection map through the edit. fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void { pane.select = false; const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row, -1) orelse return; const text = eb.text; - const range = paneRange(pane, text, eb.row0); - const span = rangeLineSpan(text, range); + const range = pane.primaryRange(text, eb.row0); + const span = Pane.rangeLineSpan(text, range); const arena = p.scratch.allocator(); // one run of spaces, sliced per line: `>` never inserts more than this const pad = arena.alloc(u8, modal.INDENT_W * cnt) catch return; @@ -12733,7 +9300,7 @@ pub const Pardes = struct { var l = span.start; while (l <= span.end) : (l += 1) { const ls = modal.lineStartOffset(text, l); - const le = modal.hxLineEndIdx(text, l); + const le = modal.lineEndOffset(text, l); const line = text[ls..le]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; // blank lines stay blank (helix) @@ -12762,17 +9329,17 @@ pub const Pardes = struct { } if (chs_len == 0) return; p.pushUndo(pane); - const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); + const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) - modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs[0..chs_len]) catch return; const nc = mapThroughChanges(chs[0..chs_len], cur_off); const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); - const cc = modal.hxPos(new, nc); - const ac = modal.hxPos(new, na); + const cc = modal.positionAt(new, nc); + const ac = modal.positionAt(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { @@ -12785,19 +9352,6 @@ pub const Pardes = struct { pane.ensureCursorVisible(); } - /// `Ctrl-c`: helix toggle_comments. Every line the selection touches gets - /// the language's line-comment token put in front of it — or taken off, - /// and WHICH of the two is decided once for the whole set: one uncommented - /// non-blank line among them and everything gets commented. That single - /// decision is why Action.scope runs this once instead of per cursor; - /// replayed, a half-commented block would end up half-commented the other - /// way round. - /// - /// The rest is helix's find_line_comment, quirks included: the token goes - /// in at the SHALLOWEST indent in the set (so a deeper line is commented - /// mid-whitespace), all-blank lines are skipped entirely and do not vote, - /// and uncommenting also eats one space after the token unless some line - /// lacks it. fn normalToggleComment(p: *Pardes, pane: *Pane) void { const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; pane.select = false; // helix exit_select_mode @@ -12807,19 +9361,13 @@ pub const Pardes = struct { b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); } - // ...and ONE ROW PAST them, like normalJoin: a selection may end on - // its last line's newline cell, and a terminal buffer that stops at - // that line has nowhere to put it (a file's content always does). - // Never past the surface's own last row, though — materialising a row - // that does not exist yet would ADD a blank line to the pane, and this - // op may well decide to change nothing. - const pl0 = p.paneCursorLines(pane) catch return; - const last_row = pl0.row0 + @as(i32, @intCast(pl0.lines.len)) - 1; + const surface = p.flatSurface(pane) catch return; + const last_row = @as(i32, @intCast(panes.File.textLineCount(pane, surface))) - 1; const eb = p.editText(pane, b.lo_row, @min(b.hi_row + 1, last_row), -1) orelse return; const text = eb.text; - var rs: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, text, eb.row0, &rs); - const nlines = modal.hxLineCount(text); + var rs: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(text, eb.row0, &rs); + const nlines = modal.cursorLineCount(text); const arena = p.scratch.allocator(); // the lines the ranges cover, each ONE ONCE and in order (helix's // min_next_line: two cursors on one line comment it once) @@ -12827,7 +9375,7 @@ pub const Pardes = struct { var lines_len: usize = 0; var next: usize = 0; for (rs[0..got.n]) |r| { - const span = rangeLineSpan(text, r); + const span = Pane.rangeLineSpan(text, r); var l = @max(span.start, next); const end = @min(span.end + 1, nlines); while (l < end) : (l += 1) { @@ -12836,10 +9384,6 @@ pub const Pardes = struct { } next = @max(next, end); } - // which token: the file's EXTENSION, which is the same thing - // src/syntax.zig tells languages apart by, read off the one table in - // config. A terminal and an output buffer have no extension and get - // the default, which is what helix does for a buffer with no language. const ext = if (pane.file) |f| std.fs.path.extension(f.path) else ""; var token: []const u8 = config.comment_token_default; lang: for (config.comment_tokens) |row| { @@ -12853,7 +9397,7 @@ pub const Pardes = struct { var margin: usize = 1; var live: usize = 0; for (lines[0..lines_len]) |l| { - const line = text[modal.lineStartOffset(text, l)..modal.hxLineEndIdx(text, l)]; + const line = text[modal.lineStartOffset(text, l)..modal.lineEndOffset(text, l)]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; indent = @min(indent, nw); @@ -12867,7 +9411,7 @@ pub const Pardes = struct { var chs_len: usize = 0; for (lines[0..lines_len]) |l| { const ls = modal.lineStartOffset(text, l); - const le = modal.hxLineEndIdx(text, l); + const le = modal.lineEndOffset(text, l); const line = text[ls..le]; if (modal.firstNonWs(line) == line.len) continue; // blank lines untouched const at = ls + indent; @@ -12882,15 +9426,15 @@ pub const Pardes = struct { p.pushUndo(pane); // one edit, and the WHOLE selection rides through it (helix maps the // selection with the transaction) - var cells: [MAX_SELS]SelRange = undefined; + var cells: [Pane.max_selections]Pane.SelRange = undefined; const new = p.applyChanges(text, chs[0..chs_len]) catch return; for (rs[0..got.n], 0..) |r, i| { - const c = rangeCells(new, .{ + const c = Pane.rangeCells(new, .{ .anchor = mapThroughChanges(chs[0..chs_len], r.anchor), .head = mapThroughChanges(chs[0..chs_len], r.head), }); - const cc = modal.hxPos(new, c.cur); - const ac = modal.hxPos(new, c.anc); + const cc = modal.positionAt(new, c.cur); + const ac = modal.positionAt(new, c.anc); cells[i] = .{ .row = @as(i32, @intCast(cc.row)) + eb.row0, .col = @intCast(cc.col), @@ -12899,28 +9443,23 @@ pub const Pardes = struct { }; } p.setEditText(pane, new); - const pl2 = p.paneCursorLines(pane) catch return; - const t2 = p.flatSurface(pane, pl2) catch return; - for (cells[0..got.n], 0..) |s, i| rs[i] = cellRange(t2, s.arow - pl2.row0, s.acol, s.row - pl2.row0, s.col); - setPaneRanges(pane, pl2, t2, rs[0..got.n], &.{}, got.pri, expl); + const t2 = p.flatSurface(pane) catch return; + for (cells[0..got.n], 0..) |s, i| rs[i] = Pane.cellRange(t2, s.arow, s.acol, s.row, s.col); + pane.setRanges(t2, rs[0..got.n], &.{}, got.pri, expl); } - /// `Ctrl-a` / `Ctrl-x`: increment/decrement the SELECTION as a decimal - /// integer (helix: the selected fragment itself, no number scan around - /// the cursor); a fragment that isn't an integer is a no-op. The new - /// number becomes the selection, cursor on its last char. fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void { const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); const arena = p.scratch.allocator(); const frag = modal.rangeText(arena, eb.text, r.a, r.b) catch return; - const rep = (modal.hxIncrement(arena, frag, delta) catch null) orelse return; + const rep = (modal.incrementDecimal(arena, frag, delta) catch null) orelse return; p.pushUndo(pane); pane.select = false; const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, rep) catch return; p.setEditText(pane, new); const start = modal.lineStartOffset(new, r.a.row) + r.a.col; - const cc = modal.hxPos(new, modal.prevGrapheme(new, start + rep.len)); + const cc = modal.positionAt(new, modal.prevGrapheme(new, start + rep.len)); pane.vsel = .{ .active = modal.nextGrapheme(rep, 0) < rep.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.msel.active = false; pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; @@ -12964,43 +9503,39 @@ pub const Pardes = struct { }; } - /// `mi` / `ma`: select inside/around a textobject (pure range - /// math over the motion surface — works on terminals too). Word and - /// paragraph objects are helix textobject.rs ports; pairs/quotes are the - /// plain-text scans (quotes line-scoped — ponytail). - fn textobjectSelect(p: *Pardes, pane: *Pane, pl: PaneLines, obj: u21, around: bool) void { - const text = p.flatSurface(pane, pl) catch return; - const range = paneRange(pane, text, pl.row0); + fn textobjectSelect(p: *Pardes, pane: *Pane, text: []const u8, obj: u21, around: bool) void { + const range = pane.primaryRange(text, 0); switch (obj) { 'w', 'W' => { - const r = modal.hxTextobjectWord(text, range, around, obj == 'W'); - return setPaneRange(pane, pl, text, r, false); + const r = modal.selectWord(text, range, around, obj == 'W'); + return pane.setRange(text, 0, r, false); }, 'p' => { - const r = modal.hxTextobjectParagraph(text, range, around, 1); - return setPaneRange(pane, pl, text, r, false); + const r = modal.selectParagraph(text, range, around, 1); + return pane.setRange(text, 0, r, false); }, else => {}, } - const cur = modal.hxPos(text, modal.hxCursor(text, range)); + const lines = p.paneCursorLines(pane) catch return; + const cur = modal.positionAt(text, modal.selectionCursor(text, range)); const pair: ?modal.Range = switch (obj) { - '\'', '"', '`' => modal.enclosingQuote(pl.lines, cur, @intCast(obj)), - '(', ')' => modal.enclosingPair(pl.lines, cur, '(', ')'), - '[', ']' => modal.enclosingPair(pl.lines, cur, '[', ']'), - '{', '}' => modal.enclosingPair(pl.lines, cur, '{', '}'), - '<', '>' => modal.enclosingPair(pl.lines, cur, '<', '>'), + '\'', '"', '`' => modal.enclosingQuote(lines, cur, @intCast(obj)), + '(', ')' => modal.enclosingPair(lines, cur, '(', ')'), + '[', ']' => modal.enclosingPair(lines, cur, '[', ']'), + '{', '}' => modal.enclosingPair(lines, cur, '{', '}'), + '<', '>' => modal.enclosingPair(lines, cur, '<', '>'), else => null, }; const pr = pair orelse return; - var a = modal.hxOff(text, pr.a); - var head = modal.nextGrapheme(text, modal.hxOff(text, pr.b)); + var a = modal.offsetAt(text, pr.a); + var head = modal.nextGrapheme(text, modal.offsetAt(text, pr.b)); if (!around) { // inside: shrink off the delimiters; an EMPTY pair collapses to // a 1-wide cursor on the closing char (helix) a = modal.nextGrapheme(text, a); head = modal.prevGrapheme(text, head); } - setPaneRange(pane, pl, text, .{ .anchor = a, .head = head }, false); + pane.setRange(text, 0, .{ .anchor = a, .head = head }, false); } /// `ms`: wrap the selection (or the cursor char) in a pair; the wrap @@ -13026,27 +9561,25 @@ pub const Pardes = struct { } /// `md`: delete the enclosing pair's chars; the cursor maps through - fn surroundDelete(p: *Pardes, pane: *Pane, pl: PaneLines, ch: u21) void { + fn surroundDelete(p: *Pardes, pane: *Pane, ch: u21) void { const pr = pairFor(ch) orelse return; - // the pair is found over the motion surface, whose rows are absolute; - // the edit runs in the buffer covering those rows, and every offset - // below is taken in THAT text so the cursor maps through it - const cur = toModalCursor(pane, pl); + const lines = p.paneCursorLines(pane) catch return; + const cur = pane.toModalCursor(); const r = (if (pr.o == pr.c) - modal.enclosingQuote(pl.lines, cur, pr.o) + modal.enclosingQuote(lines, cur, pr.o) else - modal.enclosingPair(pl.lines, cur, pr.o, pr.c)) orelse return; + modal.enclosingPair(lines, cur, pr.o, pr.c)) orelse return; const lo: i32 = @intCast(@min(r.a.row, cur.row)); const hi: i32 = @intCast(@max(r.b.row, cur.row)); - const eb = p.editText(pane, lo + pl.row0, hi + pl.row0, -1) orelse return; + const eb = p.editText(pane, lo, hi, -1) orelse return; const text = eb.text; - const drow = pl.row0 - eb.row0; // surface row -> buffer row + const drow = -eb.row0; // surface row -> buffer row const ra: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.a.row)) + drow), .col = r.a.col }; const rb: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.b.row)) + drow), .col = r.b.col }; p.pushUndo(pane); - const a_off = modal.hxOff(text, ra); - const b_off = modal.hxOff(text, rb); - const cur_off = modal.hxOff(text, .{ .row = @intCast(@as(i32, @intCast(cur.row)) + drow), .col = cur.col }); + const a_off = modal.offsetAt(text, ra); + const b_off = modal.offsetAt(text, rb); + const cur_off = modal.offsetAt(text, .{ .row = @intCast(@as(i32, @intCast(cur.row)) + drow), .col = cur.col }); // the close first, so the open's position stays valid var new = modal.deleteChar(p.gpa, text, rb) catch return; p.setEditText(pane, new); @@ -13055,7 +9588,7 @@ pub const Pardes = struct { var nc = cur_off; if (nc > b_off) nc -= 1; if (nc > a_off) nc -= 1; - const cc = modal.hxPos(new, nc); + const cc = modal.positionAt(new, nc); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); pane.vsel.active = false; @@ -13065,16 +9598,17 @@ pub const Pardes = struct { } /// `mr`: swap the enclosing pair's chars for 's - fn surroundReplace(p: *Pardes, pane: *Pane, pl: PaneLines, from: u21, to: u21) void { + fn surroundReplace(p: *Pardes, pane: *Pane, from: u21, to: u21) void { const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; - const cur = toModalCursor(pane, pl); + const lines = p.paneCursorLines(pane) catch return; + const cur = pane.toModalCursor(); const r = (if (fp.o == fp.c) - modal.enclosingQuote(pl.lines, cur, fp.o) + modal.enclosingQuote(lines, cur, fp.o) else - modal.enclosingPair(pl.lines, cur, fp.o, fp.c)) orelse return; - const eb = p.editText(pane, @as(i32, @intCast(r.a.row)) + pl.row0, @as(i32, @intCast(r.b.row)) + pl.row0, -1) orelse return; - const drow = pl.row0 - eb.row0; // surface row -> buffer row + modal.enclosingPair(lines, cur, fp.o, fp.c)) orelse return; + const eb = p.editText(pane, @as(i32, @intCast(r.a.row)), @as(i32, @intCast(r.b.row)), -1) orelse return; + const drow = -eb.row0; // surface row -> buffer row const ar: usize = @intCast(@as(i32, @intCast(r.a.row)) + drow); const br: usize = @intCast(@as(i32, @intCast(r.b.row)) + drow); p.pushUndo(pane); @@ -13086,57 +9620,25 @@ pub const Pardes = struct { // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- - /// pull the cursor back inside `text` after a rewrite; `row0` is the - /// absolute surface row of its first line (0 for a file) - fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { - const n = modal.lineCount(text); - const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); - const llen = modal.lineSlice(text, row).len; - pane.cur_row = @as(i32, @intCast(row)) + row0; - pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); - pane.cur_pinned = true; - pane.vsel.active = false; - pane.msel.active = false; - pane.ensureCursorVisible(); - } - fn pushUndo(p: *Pardes, pane: *Pane) void { // one keystroke, one undo step — even when it edited at ten cursors. if (p.multi_on and !p.multi_first) return; - if (pane.file != null) return file_pane.pushUndo(p, pane); - term_pane.pushUndo(p, pane); + if (pane.file != null) return panes.File.pushUndo(p, pane); + panes.Terminal.pushUndo(p, pane); } fn doUndo(p: *Pardes, pane: *Pane) void { - if (pane.file != null) return file_pane.undo(p, pane); - term_pane.undo(p, pane); + if (pane.file != null) return panes.File.undo(p, pane); + panes.Terminal.undo(p, pane); } fn doRedo(p: *Pardes, pane: *Pane) void { - if (pane.file != null) return file_pane.redo(p, pane); - term_pane.redo(p, pane); + if (pane.file != null) return panes.File.redo(p, pane); + panes.Terminal.redo(p, pane); } 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| { @@ -13153,7 +9655,7 @@ pub const Pardes = struct { 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 rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1; if (row == handle) return .{ .col = col, .row = handle }; } @@ -13172,7 +9674,7 @@ pub const Pardes = struct { 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 rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.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 }; } @@ -13190,14 +9692,10 @@ pub const Pardes = struct { } const PointerTextSelection = struct { - sel: Sel, + sel: Pane.Sel, on_tag: bool, }; - /// Map one physical grid cell into the selection coordinate space shared - /// by Look, Exec and the hover preview. Geometry lives here once: the tag - /// is always selection row zero and body rows start at BOX_H, even when - /// Tagbottom swaps their physical positions. fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16) ?PointerTextSelection { if (p.panes[id] == null) return null; const r = p.rects[id]; @@ -13233,9 +9731,6 @@ pub const Pardes = struct { return null; } - /// Debounce by semantic grid cell rather than raw motion events. A host - /// may report the same pixel position every frame; those reports must not - /// postpone the preview forever. fn noteLookHover(p: *Pardes, col: u16, row: u16) void { const delay = config.look_preview_delay_frames orelse return; _ = delay; @@ -13254,14 +9749,9 @@ pub const Pardes = struct { p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial }; } - /// Re-evaluate a stationary pointer against the frame the host just - /// presented. `noteLookHover` preserves an existing delay or preview only - /// when this still resolves to the same canonical cell and pane lifetime; - /// moving to another semantic target re-arms it, and an invisible panel - /// cell cancels it. fn refreshLookHoverFromRaw(p: *Pardes) void { if (!p.pointer_inside or !p.raw_hover_intent) return; - const mapped = p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row) orelse + const mapped = p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row) orelse return p.cancelLookHover(); p.hover_col = mapped.col; p.hover_row = mapped.row; @@ -13277,8 +9767,8 @@ pub const Pardes = struct { if (pane.serial != waiting.serial) return p.cancelLookHover(); const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row) orelse return p.cancelLookHover(); - if (comptime pdf_enabled) if (!pointed.on_tag and pdf_pane.paneNativeReady(p, pane)) { - const probe = pdf_pane.probeAt(p, pane, waiting.col, waiting.row) orelse + if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) { + const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse return p.cancelLookHover(); const ready = waiting.*; p.look_hover_wait = null; @@ -13307,190 +9797,10 @@ pub const Pardes = struct { p.look_hover_wait = null; } - const PointerCell = struct { col: u16, row: u16 }; - - fn boxContainsCell(box: panel_animation.Box, col: u16, row: u16) bool { - const x = @as(f32, @floatFromInt(col)) + 0.5; - const y = @as(f32, @floatFromInt(row)) + 0.5; - return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; - } - - /// Input follows the panel pixels the shaders/TTY compositor present. - /// Opening panels are drawn last, so they are probed first here too. A - /// cell in final geometry which has not appeared yet is deliberately not - /// clickable; otherwise a user could act on invisible content. - fn presentedPointer(p: *const Pardes, col: u16, row: u16) ?PointerCell { - if (p.panel_presentation_pending) return null; - // Closing pixels belong to a dead pane lifetime. They occlude the - // canonical survivor underneath while visible, but can never dispatch - // into either that survivor or a slot which reused the old pane id. - var closing = p.npresented_closing_panel_tracks; - while (closing > 0) { - closing -= 1; - const track = p.presented_closing_panel_tracks[closing]; - if (!track.active()) continue; - if (boxContainsCell(track.contentBox(), col, row) and - boxContainsCell(track.presented(), col, row)) return null; - } - const phases = [_]panel_animation.Phase{ .opening, .moving }; - for (phases) |phase| { - // Backends paint pane slots forward within a phase. Probe them in - // reverse so overlapping transition quads address the top pixel. - var index = p.presented_panel_tracks.len; - while (index > 0) { - index -= 1; - const track = p.presented_panel_tracks[index] orelse continue; - if (!track.active() or track.phase != phase) continue; - switch (track.effect) { - .slide, .zoom => { - const shown = track.presented(); - if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; - const x = @as(f32, @floatFromInt(col)) + 0.5; - const y = @as(f32, @floatFromInt(row)) + 0.5; - const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); - const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); - const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w)); - const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h)); - return .{ - .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, p.screen_w -| 1))), - .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, p.screen_h -| 1))), - }; - }, - .vertical => { - const clip = track.contentBox(); - if (!boxContainsCell(clip, col, row)) continue; - const shown = track.presented(); - if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) - return null; - const x = @as(f32, @floatFromInt(col)) + 0.5; - const y = @as(f32, @floatFromInt(row)) + 0.5; - const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); - const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); - return .{ - .col = @intFromFloat(@floor(clip.x + u * clip.w)), - .row = @intFromFloat(@floor(clip.y + v * clip.h)), - }; - }, - .dissolve, .ascii => { - if (!boxContainsCell(track.to, col, row)) continue; - if (!p.panelCellChanged(col, row)) - return .{ .col = col, .row = row }; - const relative_col: u16 = @intFromFloat(@floor( - @as(f32, @floatFromInt(col)) + 0.5 - track.to.x, - )); - const relative_row: u16 = @intFromFloat(@floor( - @as(f32, @floatFromInt(row)) + 0.5 - track.to.y, - )); - const visible = switch (track.effect) { - .dissolve => panel_animation.dissolveRevealed( - track.serial, - relative_col, - relative_row, - track.amount(), - ), - .ascii => switch (p.panelCellDiff(col, row)) { - .ascii => |diff| diff.complete(track.frame), - .unchanged, .visual => true, - }, - else => unreachable, - }; - return if (visible) .{ .col = col, .row = row } else null; - }, - // Every glyph in a motion effect's pane is in flight, - // changed or not, so a cell becomes a truthful input target - // only once its own glyph has settled on the canonical one. - .edges, .fall, .wave, .curtain, .scramble, .typewriter => { - if (!boxContainsCell(track.to, col, row)) continue; - const area = panel_animation.CellArea.of(track.to); - const settled = std.meta.eql( - panel_animation.charSource( - track, - col -| area.x0, - row -| area.y0, - area, - ), - panel_animation.CharSource.settled, - ); - return if (settled) .{ .col = col, .row = row } else null; - }, - .off => {}, - } - } - } - for (p.presented_panel_tracks) |maybe| { - const track = maybe orelse continue; - if (!track.active() or (track.effect != .slide and track.effect != .zoom and - track.effect != .vertical)) continue; - if (boxContainsCell(track.to, col, row)) return null; - } - return .{ .col = col, .row = row }; - } - - /// Commit the exact panel samples a backend successfully presented. An - /// empty slice means that backend drew the canonical grid directly. - /// Records are keyed by pane slot so hit-test order stays identical to the - /// renderer even when a native ABI publishes them in paint order. - pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const panel_animation.Track) void { - var presented: [MAX_PANES]?panel_animation.Track = @splat(null); - var presented_closing: [MAX_PANES]panel_animation.Track = undefined; - var nclosing: usize = 0; - var layout: [MAX_PANES]?LayoutSnapshot = if (p.submitted_panel_layout_ready) - p.submitted_panel_layout - else - @splat(null); - if (!p.submitted_panel_layout_ready) for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; - }; - for (&layout, 0..) |*snapshot, id| if (snapshot.*) |saved| { - const pane = p.panes[id] orelse { - snapshot.* = null; - continue; - }; - if (pane.serial != saved.serial) snapshot.* = null; - }; - for (tracks) |track| { - if (track.phase == .closing) { - const current = for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |candidate| { - if (candidate.serial == track.serial and candidate.pane == track.pane and - candidate.effect == track.effect) break true; - } else false; - if (!current or !track.active() or nclosing == presented_closing.len) continue; - presented_closing[nclosing] = track; - nclosing += 1; - continue; - } - const id: usize = track.pane; - if (id >= p.panes.len or !track.active()) continue; - const pane = p.panes[id] orelse continue; - if (pane.serial != track.serial) continue; - presented[id] = track; - layout[id] = .{ .serial = track.serial, .box = track.visualBox() }; - } - p.presented_panel_tracks = presented; - p.presented_closing_panel_tracks = presented_closing; - p.npresented_closing_panel_tracks = nclosing; - p.presented_panel_layout = layout; - p.panel_presentation_ready = true; - p.panel_presentation_pending = false; - // An empty acknowledgement means canonical cells, not an invisible - // transition. Retire any producer records a direct/headless fallback - // deliberately did not present, then make this exact frame the future - // old-grid baseline. Animated acknowledgements keep their baseline - // frozen until the canonical endpoint is actually presented. - if (tracks.len == 0) { - p.panel_tracks = @splat(null); - p.nclosing_panel_tracks = 0; - p.panel_diff_pending = false; - p.panel_diff_ready = false; - p.rememberPresentedCells(); - } - // A held gesture follows the pixels just acknowledged even when the - // physical pointer stayed still. Its payload already is the last - // successfully mapped endpoint, so an invisible sample simply leaves - // that endpoint intact for a later frame or balanced release. + pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void { + p.presentation.acknowledge(p, tracks); if (p.pointer_inside and p.drag != .none) { - if (p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row)) |mapped| { + if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| { p.hover_col = mapped.col; p.hover_row = mapped.row; p.dragUpdate(mapped.col, mapped.row); @@ -13499,57 +9809,8 @@ pub const Pardes = struct { p.refreshLookHoverFromRaw(); } - fn rememberPresentedCells(p: *Pardes) void { - p.panel_diff_pending = false; - p.panel_diff_ready = false; - const cells = p.surface.cells; - if (cells.len == 0) { - p.presented_cells_valid = false; - p.presented_cells_layout = @splat(null); - return; - } - if (p.presented_cells.len != cells.len) { - const next = p.gpa.alloc(Cell, cells.len) catch { - p.presented_cells_valid = false; - p.presented_cells_layout = @splat(null); - return; - }; - if (p.presented_cells.len > 0) p.gpa.free(p.presented_cells); - p.presented_cells = next; - } - @memcpy(p.presented_cells, cells); - p.presented_cells_cols = p.surface.cols; - p.presented_cells_rows = p.surface.rows; - p.presented_cells_valid = true; - p.presented_cells_layout = if (p.submitted_panel_layout_ready) - p.submitted_panel_layout - else - @splat(null); - } - - fn panelCellChanged(p: *const Pardes, col: u16, row: u16) bool { - if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or - p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return false; - return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col].changed(); - } - - fn panelCellDiff(p: *const Pardes, col: u16, row: u16) PanelCellDiff { - if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or - p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return .unchanged; - return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col]; - } - - /// A backend is about to replace an unpresentable animated frame with the - /// canonical grid. Stop producer tracks too, so a later successful retry - /// cannot resume halfway through an animation after canonical was shown. pub fn abandonPanelAnimations(p: *Pardes) void { - p.panel_tracks = @splat(null); - p.nclosing_panel_tracks = 0; - p.presented_panel_tracks = @splat(null); - p.npresented_closing_panel_tracks = 0; - p.panel_diff_pending = false; - p.panel_diff_ready = false; - p.panel_presentation_pending = p.panel_presentation_ready; + p.presentation.cancel(); p.cancelLookHover(); } @@ -13571,11 +9832,8 @@ pub const Pardes = struct { return; } p.raw_hover_intent = false; - const mapped = p.presentedPointer(raw_col, raw_row) orelse { + const mapped = p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) orelse { p.cancelLookHover(); - // A disappearing transition cell may hide the release, but the - // press it balances already has a last valid endpoint in Drag. - // End only the gesture owned by this exact physical button. if (m.kind == .release) p.dragRelease(m.button); return; }; @@ -13596,9 +9854,6 @@ pub const Pardes = struct { p.chord_arg = null; } } - // ...and any press at all takes the keyboard back off the topbar: the - // mouse names where focus goes, so leaving a cursor parked on row 0 - // while you click into a pane would just be a lie if (m.kind == .press) p.topbar_col = null; switch (m.button) { @@ -13610,41 +9865,33 @@ pub const Pardes = struct { _ = config.wheelTick(&p.wheel_guard, true); const id = hovered orelse return; const pane = p.panes[id].?; - if (hasPdf(pane)) - pdf_pane.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1) + if (pane.hasPdf()) + panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1) else pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); }, .wheel_left, .wheel_right => { if (m.kind != .press) return; - // a mostly-vertical two-finger swipe's sideways drift dies - // here rather than sliding the view out from under a scroll. - // Charged against the gesture, not the pane, so it runs before - // we ask what is hovered. if (!config.wheelTick(&p.wheel_guard, false)) return; const id = hovered orelse return; const pane = p.panes[id].?; - if (hasPdf(pane)) { - pdf_pane.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1); + if (pane.hasPdf()) { + panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1); // ponytail: no right clamp — overscroll shows blank and the // next cursor move or left wheel pulls it back } else if (pane.file != null and !p.settings.wrap) { // wrapped there is nothing off to the right to reach - const line = file_pane.sourceLine(pane, pane.cur_row); - const visual = file_pane.rawDisplayCol(line, @intCast(@max(0, pane.hscroll))); + const line = panes.File.sourceLine(pane, pane.cur_row); + const visual = panes.File.rawDisplayCol(line, @intCast(@max(0, pane.hscroll))); const next: usize = if (m.button == .wheel_right) visual +| @as(usize, @intCast(config.wheel_cols)) else visual -| @as(usize, @intCast(config.wheel_cols)); - pane.hscroll = @intCast(file_pane.rawAtDisplay(line, next)); + pane.hscroll = @intCast(panes.File.rawAtDisplay(line, next)); } }, config.select_button => switch (m.kind) { .press => { - // acme 2-1: a select press during an EXECUTE drag captures - // a selection (heldSelection) as the execute's argument; - // the execute drag keeps running to its release, which - // consumes it. if (p.drag == .select and p.drag.select.button == config.exec_button) { if (p.heldSelection(p.drag.select.id)) |tx| { if (p.chord_arg) |old| p.gpa.free(old); @@ -13658,15 +9905,12 @@ pub const Pardes = struct { if (p.panes[p.drag.select.id]) |t| { t.sel[@intFromEnum(config.look_button)].state = .none; if (comptime pdf_enabled) - pdf_pane.pointerCancel(t, p.drag.select.pdf); + panes.Pdf.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; } if (mrow < TOPBAR_H) return; // topbar: a select click is deliberately inert - // the layout box (gutter cells of the tag row) wins over - // the resize handles: a tag-only pane's single row IS its - // pair's h-handle, and the box must stay grabbable to move if (hovered) |mid| { const mr = p.rects[mid]; const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y; @@ -13675,17 +9919,6 @@ pub const Pardes = struct { return; } } - // resize handles next: a pane's own trailing edge (v: the - // left column's last col; h: the seam row between a stacked - // 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). - // The v test still wins outright, but it now also asks - // whether this same cell is one of the adjoining columns' h - // handles — that cell is the corner where the two lines - // meet, and grabbing it drags both boundaries at once. Its - // OWN column is asked first, so a row where both are split - // is the gesture it always was (see Drag.border_v). for (0..p.ncol -| 1) |c| { if (mcol == p.col_x[c] + p.col_w[c] -| 1) { const corner: @FieldType(@FieldType(Drag, "border_v"), "corner") = if (p.seamIdxAt(c, mrow)) |k| @@ -13708,11 +9941,6 @@ pub const Pardes = struct { const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; - // the same two rows renderPane painted through (see there): - // the tag's, and the body's first. A Sel row is still 0 for - // the tag and BOX_H upward for the body whichever end they - // are at, so the mapping happens HERE and everything - // downstream of a Sel is untouched. const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; if (mcol < r.x + config.GUTTER) { @@ -13720,10 +9948,6 @@ pub const Pardes = struct { p.active = id; pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y))); } else if (mrow >= tag_y and mrow < tag_y + BOX_H) { - // left press on the tag row: focus the tag AT that - // column (a rendered-tag column — the mouse and the tag - // agree, no clamp into the tail) and anchor a sweep, so - // dragging selects any span of it, path included p.active = id; p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + config.GUTTER)); pane.tag_anchor = pane.tag_col; @@ -13734,15 +9958,11 @@ pub const Pardes = struct { const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H); pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; - // Ctrl rides on the drag rather than firing here: the - // click does not place the modal cursor until RELEASE - // (dragRelease), and a goto asked at press time would - // answer about wherever the cursor happened to be. p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl, - .pdf = pdf_pane.pointerStart( + .pdf = panes.Pdf.pointerStart( p, pane, config.select_button, @@ -13771,7 +9991,7 @@ pub const Pardes = struct { if (p.panes[p.drag.select.id]) |t| { t.sel[@intFromEnum(p.drag.select.button)].state = .none; if (comptime pdf_enabled) - pdf_pane.pointerCancel(t, p.drag.select.pdf); + panes.Pdf.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; @@ -13782,15 +10002,7 @@ pub const Pardes = struct { if (m.button == config.exec_button) { var tb_buf: [1200]u8 = undefined; const bar = p.topbar(&tb_buf); - const word = wordAtCol(bar, file_pane.rawAtDisplay(bar, mcol)); - // a topbar word runs on the PRESS — there is no - // drag to chord into, so the argument is simply - // whatever is selected right now: select a word, - // ...but only for a builtin that HAS somewhere to - // put one (see takesArg): a word left selected in - // some pane an hour ago is not an argument to - // Kill, and splicing it made a name that matches - // no builtin and therefore went to a shell. + const word = wordAtCol(bar, panes.File.rawAtDisplay(bar, mcol)); const named = std.meta.stringToEnum(Builtin, word); const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) @@ -13809,17 +10021,11 @@ pub const Pardes = struct { const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H; if (mcol < r.x + config.GUTTER and !on_tag) { - // gutter: right scrolls DOWN to here, mirroring left's up; - // middle matches left. Right focuses (look lands you - // there); middle does not. const local = @as(i32, mrow) - @as(i32, body_y); if (m.button == config.look_button) p.active = id; pane.scrollBy(if (m.button == config.look_button) local else -local); } else if (p.pointerTextSelection(id, mcol, mrow)) |pointed| { if (m.button == config.look_button) p.active = id; - // Click and delayed hover enter through the same physical - // cell mapper. From here on both also share expandedSel; - // hover merely stores the result outside Pane.sel. pane.sel[@intFromEnum(m.button)] = pointed.sel; p.drag = .{ .select = .{ @@ -13827,7 +10033,7 @@ pub const Pardes = struct { .button = m.button, // A native body gesture stays native even when its // point later misses a letterbox or selection. - .pdf = pdf_pane.pointerStart( + .pdf = panes.Pdf.pointerStart( p, pane, m.button, @@ -13862,14 +10068,9 @@ pub const Pardes = struct { clampBorderCol(p.col_x[c], p.col_w[c], p.col_w[c + 1], mcol) else mcol; - // a corner also drives its column's pane pair, off the SAME - // mouse position but through its own clamp — the geometry a - // clamp reads (widths for x, heights for y) is frozen for the - // whole drag and never crosses axes, so one edge parked at its - // stop leaves the other tracking the mouse if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) { - const a = p.rects[p.col_terms[k.col][k.idx]]; - const b = p.rects[p.col_terms[k.col][k.idx + 1]]; + const a = p.rects[p.col_panes[k.col][k.idx]]; + const b = p.rects[p.col_panes[k.col][k.idx + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else { d.cur_y = mrow; @@ -13879,8 +10080,8 @@ pub const Pardes = struct { const cc = d.col; const k = d.top_idx; if (k + 1 < p.col_n[cc]) { - const a = p.rects[p.col_terms[cc][k]]; - const b = p.rects[p.col_terms[cc][k + 1]]; + const a = p.rects[p.col_panes[cc][k]]; + const b = p.rects[p.col_panes[cc][k + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else d.cur_y = mrow; }, @@ -13893,20 +10094,6 @@ pub const Pardes = struct { const r = p.rects[s.id]; const b = @intFromEnum(s.button); pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); - // A Tagbottom drag STAYS IN THE REGION ITS ANCHOR STARTED - // IN, which is the one place the two coordinate spaces - // genuinely disagree: Sel row 0 is the tag and the body - // counts up from BOX_H, but on screen the tag is now BELOW - // the body. Sweeping down past the last line onto the - // tagline — the ordinary "select to the end" gesture — would - // therefore drive r1 to the TOP of Sel space and invert the - // selection: the highlight jumps above the anchor, and a 1-2 - // Cut on it deletes lines nobody swept. So a body-anchored - // drag is clamped to the body rows and a tag-anchored one - // (execute/look only; the left button's tag sweep is its own - // .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. if (p.settings.tag_bottom) { const body_h = r.h -| BOX_H; pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0) @@ -13915,33 +10102,20 @@ pub const Pardes = struct { @as(i32, @min(@max(mrow, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H); } else pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y); if (comptime pdf_enabled) - pdf_pane.pointerUpdate(&s.pdf, p, pane, mcol, mrow); + panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow); } }, .tag => |d| { - // the tag cursor follows the mouse across the rendered tag; the - // row is ignored (a tag is one line) and the anchor stays where - // the press put it, so this is the mouse's `v` const pane = p.panes[d.id] orelse return; const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); const text = p.tagText(p.scratch.allocator(), pane) catch return; - pane.tag_col = @intCast(@min(text.len, file_pane.rawAtDisplay(text, @intCast(@max(0, c))))); + pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @intCast(@max(0, c))))); pane.tag_sel = pane.tag_col != pane.tag_anchor; }, .none => {}, } } - /// THE ACME INVERSION: while a script holds this pane's `event` file open, - /// buttons 2 and 3 in it belong to the script. The words in its tag are - /// ITS commands — `Step`, `Run`, `Clear` — and pardes has never heard of - /// them, so it reports the click and performs nothing. Returns true when - /// the caller must not run the builtin. - /// - /// Keyboard Enter/Tab are deliberately NOT suppressed, unlike acme (which - /// has no keyboard equivalent to suppress): a scripted pane stays - /// editable, and a script that dies mid-run cannot leave you unable to - /// execute anything in it. fn reportGesture( p: *Pardes, id: usize, @@ -13953,46 +10127,27 @@ pub const Pardes = struct { ) bool { if (!p.fs.scripted(id)) return false; const is_look = cmd == config.look_cmd; - const action: acmefs.Action = if (is_look) + const action: filesystem.Action = if (is_look) (if (on_tag) .tag_look else .body_look) else (if (on_tag) .tag_exec else .body_exec); const named = std.meta.stringToEnum(Builtin, commandText(text)) != null; const range = p.gestureRange(id, text, on_tag, operand); - // acme(4)'s two flag vocabularies at the same bit positions. For an - // exec, bit 1 is "this is a builtin"; for a look, it is "pardes can - // act on this without loading a file", which is the same fact plus a - // word that is not a path. Bit 2 is acme's "the text indicated is a - // null string that has a non-null expansion", which is exactly what an - // empty range plus text means here. - var flag: u32 = if (named) acmefs.flag_builtin else 0; - if (range.q0 == range.q1 and text.len > 0) flag |= acmefs.flag_expansion; + var flag: u32 = if (named) filesystem.flag_builtin else 0; + if (range.q0 == range.q1 and text.len > 0) flag |= filesystem.flag_expansion; if (is_look) { - if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= acmefs.flag_filename; - } else if (chorded) flag |= acmefs.flag_chorded; - return acmefs.noteAction(p, id, action, range.q0, range.q1, flag, text); - } - - /// THE BYTE RANGE A GESTURE NAMES, in the coordinates `addr` and `data` - /// speak — and it must name the TEXT REPORTED WITH IT, because a script - /// that does not recognise a record writes it back and pardes then - /// re-derives the text from these two numbers. A range that started at the - /// pointer instead of at the operand would execute `l D` for a click on - /// the `l` of `Del`. - /// - /// So the start comes from whatever the operand actually resolved: an - /// expanded word's own column, or the leading corner of the selection it - /// reused. When neither is available — a modal `v`/`x` selection, a - /// terminal's projected screen, a PDF — the answer is acme's null range at - /// the click, which its flag bit 2 already has a meaning for: the text - /// travels, the range does not claim to be it. - fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) acmefs.PaneFs.Range { + if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= filesystem.flag_filename; + } else if (chorded) flag |= filesystem.flag_chorded; + return filesystem.noteAction(p, id, action, range.q0, range.q1, flag, text); + } + + fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) filesystem.PaneState.Range { const pane = p.panes[id] orelse return .{}; if (on_tag) { const tag = p.tagText(p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = @min(sel.c0, sel.c1); - const at = file_pane.rawAtDisplay(tag, @intCast(@max(0, lead))); + const at = panes.File.rawAtDisplay(tag, @intCast(@max(0, lead))); const q0: u32 = @intCast(@min(at, tag.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) }; } @@ -14005,14 +10160,14 @@ pub const Pardes = struct { const top = @min(sel.r0, sel.r1) - @as(i32, BOX_H); if (top < 0) break :lead null; const w = pane.wrapAt(top); - const col = p.paneByteAtDisplay(pane, w.line, w.at, @min(sel.c0, sel.c1) - config.PREFIX_W); + const col = p.paneByteAtDisplay(pane, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane)); break :lead .{ .row = @intCast(@max(0, w.line)), .col = @intCast(@max(0, col)), }; } else null; const cursor = start orelse return .{}; - const q0: u32 = @intCast(@min(modal.hxOff(f.content, cursor), f.content.len)); + const q0: u32 = @intCast(@min(modal.offsetAt(f.content, cursor), f.content.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, f.content.len)) }; } @@ -14027,10 +10182,6 @@ pub const Pardes = struct { p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); const operand = text orelse { - // Nothing expanded — a click on `#`, `*`, `|`, a blank cell. A - // WATCHED pane still owns it: the script decides what a character - // pardes has no word for means, and life.py's grid is made of - // exactly those characters. if (gesture) |g| _ = p.reportGesture(id, cmd, "", g.on_tag, g.operand, arg != null); return; }; @@ -14061,22 +10212,18 @@ pub const Pardes = struct { p.col_weight[c + 1] = pair - left; } } - // and the corner's other half. The two commits are independent - // — column weights are widths, pane vweights are heights, and - // neither reads the other — so the order here does not matter - // and a failed one cannot lose the other. if (d.corner) |k| { if (d.cur_y != seamRowOf(p, k.col, k.idx)) - p.applyRowSplit(k.col, k.idx, d.cur_y); + layout.applyRowSplit(p, k.col, k.idx, d.cur_y); } }, .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx)) - p.applyRowSplit(d.col, d.top_idx, d.cur_y), + layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y), .move => |d| { - p.moveTerm(d.id, d.cur_x, d.cur_y); + layout.movePane(p, d.id, d.cur_x, d.cur_y); // a file moved into the left column evicts a lone unused shell if (p.panes[d.id]) |mt| if (mt.file != null) { - if (p.layoutFindTerm(d.id)) |f| if (f.col == 0) + if (layout.findPane(p, d.id)) |f| if (f.col == 0) p.evictLonePristineTty(0, d.id); }; }, @@ -14084,7 +10231,7 @@ pub const Pardes = struct { const pane = p.panes[s.id] orelse return; if (comptime pdf_enabled) { if (s.pdf.native) { - var release = pdf_pane.pointerRelease( + var release = panes.Pdf.pointerRelease( p, pane, s.pdf, @@ -14111,60 +10258,34 @@ pub const Pardes = struct { const b = @intFromEnum(s.button); pane.sel[b].state = .done; if (s.button == config.select_button) { - // keep a dragged selection highlighted; a plain click clears - // it. Either way pin the cursor; files also enter normal - // mode, terminals keep the mode they had (tty keeps the - // mouse usable, insert keeps typing where you clicked). const sl = pane.sel[sel_slot]; const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; if (!dragged) pane.sel[sel_slot].state = .none; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and pane.mode != .tty) { - // the row the user clicked, as the LAST FRAME drew it: - // which line it showed and the byte column it started - // at, so a click lands on the character under the - // pointer whether or not that row is a continuation const w = pane.wrapAt(body_vis); pane.cur_row = w.line; pane.cur_col = p.paneByteAtDisplay( pane, w.line, w.at, - sl.c1 - (if (pane.file != null) @as(i32, config.PREFIX_W) else 0), + sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ); pane.cur_pinned = true; if (!pane.isTerminal()) pane.mode = .normal; pane.msel.active = false; - pane.pending = 0; - // A fresh left gesture replaces every modal selection. - // Keeping an explicit v/x anchor made a plain click - // extend it, leaving no mouse-only way to dismiss it. + pane.normal.clear(); pane.vsel.active = false; pane.nsel = 0; // a click says WHERE the one cursor is - // Ctrl-click IS `gd`, asked now that the cursor has - // landed — the mouse spelling of the keyboard motion, - // through the identical request. A ctrl-DRAG still - // selects and still asks, about where it started, - // which is the same thing `gd` would answer. if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { - // acme execute (middle) / look (right): a no-drag click - // expands to the word under it first; a captured chord - // argument rides along and is consumed here. - // WHERE THE CLICK LANDED, before any expansion — as a body - // position, the way the left button converts its drag end. const clk = pane.sel[b]; const operand = p.pointerOperand(pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded else if (operand.text == null) pane.sel[b].state = .none; - // A look that names no file SEARCHES from where you are, so - // the click has to say where that is and the search walks - // forward from there. Not PINNED — a shell's cursor still - // belongs to the shell, and the search reads this in the - // same event. if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) { pane.cur_row = operand.row; pane.cur_col = operand.col; @@ -14182,38 +10303,6 @@ pub const Pardes = struct { } } - /// Commit a horizontal border to the pane weights: in column `cc`, the pane - /// at index `k` and the one under it split their combined height at row - /// `cur_y`. Shared by the plain h-drag and the h half of a corner drag — - /// the same gesture landing on the same boundary must settle identically - /// whether or not a column edge came along for the ride. - fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void { - if (k + 1 >= p.col_n[cc]) return; - const a = p.panes[p.col_terms[cc][k]] orelse return; - const b = p.panes[p.col_terms[cc][k + 1]] orelse return; - const ra = p.rects[p.col_terms[cc][k]]; - const rb = p.rects[p.col_terms[cc][k + 1]]; - const combined: f32 = @floatFromInt(ra.h + rb.h); - // the handle is the upper pane's LAST row (inclusive, hence the +1), or - // with Tagbottom the lower pane's FIRST — which is that row's height - // already, with nothing to add. Either way a no-drag click hands back - // exactly ra.h and nothing moves. - var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y); - nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H)); - const pair = a.vweight + b.vweight; - a.vweight = pair * (nt / combined); - b.vweight = pair - a.vweight; - } - - /// acme 1-2 (Cut) / 1-3 (Paste): middle or right tapped while the left - /// button holds a selection drag. The first tap converts the mouse - /// selection into the modal one (vsel anchored at the drag start, cursor - /// at its end; a plain click is a bare cursor). Cut yanks + deletes the - /// selection; Paste replaces it with the yank register, or splices the - /// register in literally at a bare cursor — so 1-2 then 1-3 in one hold - /// nets out to Snarf (copy: the text returns, the register keeps it). - /// In a tty-mode shell 1-3 instead pastes into the pty (forwarded click - /// + bracketed paste) and 1-2 is inert. fn chordCutPaste(p: *Pardes, cut: bool) void { const s = &p.drag.select; const pane = p.panes[s.id] orelse return; @@ -14228,13 +10317,6 @@ pub const Pardes = struct { return; }; if (pane.mode == .tty) { - // tty: nothing can be cut — 1-2 stays inert; the pty owns the - // screen. 1-3 pastes like a terminal emulator: the click is - // forwarded first (only when the app listens for mouse) so - // mouse-aware programs put their cursor under it, then the - // register is typed — bracketed when the app set mode 2004 - // (readline/vim/helix strip the markers), else with \n turned - // to \r like any unbracketed paste. if (!s.chorded) { s.chorded = true; pane.sel[sel_slot].state = .none; // drop the sweep highlight @@ -14242,14 +10324,11 @@ pub const Pardes = struct { if (cut) return; const y = p.yank orelse return; if (y.len == 0) return; - if (term_pane.reportsMouse(pane)) { - // dragUpdate keeps sel[sel_slot] tracking the held select button, so - // the click lands where the mouse is at this tap; 1-based, - // body-relative (the tag row is ours, not the app's) + if (panes.Terminal.reportsMouse(pane)) { const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999)); const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, BOX_H) + 1, 1, 9999)); var mb: [32]u8 = undefined; - if (term_pane.mouseFormatSgr(pane)) { + if (panes.Terminal.mouseFormatSgr(pane)) { p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return); } else { // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them @@ -14265,7 +10344,7 @@ pub const Pardes = struct { s.chorded = true; const sl = pane.sel[sel_slot]; pane.sel[sel_slot].state = .none; - const pfx: i32 = if (pane.file != null) config.PREFIX_W else 0; + const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; // both ends of the sweep through the same last-frame map, so a // chord over wrapped rows cuts the text that was under it const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, BOX_H))); @@ -14278,7 +10357,7 @@ pub const Pardes = struct { pane.cur_col = col1; pane.cur_pinned = true; pane.msel.active = false; - pane.pending = 0; + pane.normal.clear(); if (!pane.isTerminal()) pane.mode = .normal; pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false }; pane.nsel = 0; @@ -14295,7 +10374,7 @@ pub const Pardes = struct { const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; p.pushUndo(pane); const new = modal.insertAt(p.gpa, f.content, at, y) catch return; - file_pane.setContent(p, f, new); + panes.File.setContent(p, f, new); pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { @@ -14309,45 +10388,7 @@ pub const Pardes = struct { pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); - } else p.normalPaste(pane, true); // terminal: splice run text at the cursor - } - - // ---- layout surgery ---- - - pub fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { - for (0..p.ncol) |c| { - for (0..p.col_n[c]) |k| { - if (p.col_terms[c][k] == id) return .{ .col = c, .idx = k }; - } - } - return null; - } - - fn layoutInsert(p: *Pardes, c: usize, idx: usize, id: usize) void { - var k = p.col_n[c]; - while (k > idx) : (k -= 1) p.col_terms[c][k] = p.col_terms[c][k - 1]; - p.col_terms[c][idx] = id; - p.col_n[c] += 1; - } - - /// Remove from the layout. An emptied column hands its width to a neighbor - /// (else every surviving column reflows sideways) before shifting down. - pub fn layoutRemove(p: *Pardes, id: usize) void { - const f = p.layoutFindTerm(id) orelse return; - const c = f.col; - var k = f.idx; - while (k + 1 < p.col_n[c]) : (k += 1) p.col_terms[c][k] = p.col_terms[c][k + 1]; - p.col_n[c] -= 1; - if (p.col_n[c] == 0) { - if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; - var j = c; - while (j + 1 < p.ncol) : (j += 1) { - p.col_terms[j] = p.col_terms[j + 1]; - p.col_n[j] = p.col_n[j + 1]; - p.col_weight[j] = p.col_weight[j + 1]; - } - p.ncol -= 1; - } + } else p.pasteText(pane, p.yank orelse return, true, 1); } /// Newtty: a shell in the caller's directory, raw from the first frame, @@ -14358,188 +10399,20 @@ pub const Pardes = struct { const nt = p.newShell(free, paneDir(src)) catch return; nt.greet = true; nt.mode = .tty; - const parent = p.splitParent(from); - const f = p.layoutFindTerm(parent).?; - p.layoutInsert(f.col, f.idx + 1, free); - p.splitBelow(parent, nt); + const parent = layout.splitParent(p, from); + const f = layout.findPane(p, parent).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, parent, nt); p.active = free; } - /// Joincol: fold the active pane's column into the one on its right, - /// carrying its panes and width across. Inert without a right neighbour. - pub fn joinCol(p: *Pardes) void { - const f = p.layoutFindTerm(p.active) orelse return; - const c = f.col; - if (c + 1 >= p.ncol) return; - const dst = c + 1; - p.col_weight[dst] +|= p.col_weight[c]; - for (0..p.col_n[c]) |k| p.col_terms[dst][p.col_n[dst] + k] = p.col_terms[c][k]; - p.col_n[dst] += p.col_n[c]; - var j = c; - while (j + 1 < p.ncol) : (j += 1) { - p.col_terms[j] = p.col_terms[j + 1]; - p.col_n[j] = p.col_n[j + 1]; - p.col_weight[j] = p.col_weight[j + 1]; - } - p.ncol -= 1; - } - - /// Whether `source_id` can donate half its width to a new column. This is - /// public so callers that must create a pane first can reject before that - /// creation emits any native-side work. - pub fn layoutCanSplitColumn(p: *Pardes, source_id: usize) bool { - if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false; - const source = p.layoutFindTerm(source_id) orelse return false; - // Refresh derived widths: public layout surgery may be chained between - // syncs, and a cached width must never admit a now-too-narrow split. - p.computeGeom(); - if (p.col_w[source.col] < config.MINW * 2) return false; - - const weight = p.col_weight[source.col]; - if (weight >= 2 and weight % 2 == 0) return true; - for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2) - return false; - return weight > 0; - } - - /// Put `id` in a fresh column immediately beside `source_id`, taking the - /// new column's width only from the source column. `before` is used by a - /// first document, which belongs to the left of the shell that opened it; - /// Newcol and Alt-c put the new column on the right. - /// - /// `id == source_id` moves one pane out of a stack. Its vertical weight is - /// absorbed before removal, while the column's horizontal weight remains - /// in place to be split. In either form the total column weight is exactly - /// unchanged, so every unrelated column keeps both its width and its x. - pub fn layoutSplitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { - if (id >= MAX_PANES or p.panes[id] == null) return false; - if (!p.layoutCanSplitColumn(source_id)) return false; - const source = p.layoutFindTerm(source_id) orelse return false; - const source_col = source.col; - var old_weight = p.col_weight[source_col]; - const needs_rebase = old_weight < 2 or old_weight % 2 != 0; - if (needs_rebase) old_weight *= 2; - if (id == source_id) { - if (p.col_n[source_col] <= 1) return false; - p.absorbVWeight(id); - p.layoutRemove(id); - } else if (p.layoutFindTerm(id) != null) return false; - - // A pathological hand-written dump can restore a positive proportion - // below one fixed-point quantum. Rebase every column together before - // splitting; ratios and therefore geometry remain identical. - if (needs_rebase) { - for (0..p.ncol) |column| p.col_weight[column] *= 2; - } - const source_weight = old_weight / 2; - const new_weight = old_weight - source_weight; - p.col_weight[source_col] = source_weight; - const c = source_col + @intFromBool(!before); - var j = p.ncol; - while (j > c) : (j -= 1) { - p.col_terms[j] = p.col_terms[j - 1]; - p.col_n[j] = p.col_n[j - 1]; - p.col_weight[j] = p.col_weight[j - 1]; - } - p.col_weight[c] = new_weight; - p.col_terms[c][0] = id; - p.col_n[c] = 1; - p.ncol += 1; - return true; - } - - /// Snap every pane in column `c` to its on-screen row count so later - /// weight edits move ONLY the panes they name: computeGeom rounds - /// round(avail*w/vsum) per pane, and with fractional weights a split or - /// absorb elsewhere in the column can jiggle a bystander by a row. - fn snapColWeights(p: *Pardes, c: usize) void { - for (0..p.col_n[c]) |k| { - const pid = p.col_terms[c][k]; - if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); - } - } - - /// Hand a dying pane's rows to ONE sibling (the pane above, or below for - /// the topmost) so the rest of the column keeps its sizes bit-identical — - /// the deletion mirror of splitBelow. Call while `id` is still in the - /// layout, with rects current. - pub fn absorbVWeight(p: *Pardes, id: usize) void { - const f = p.layoutFindTerm(id) orelse return; - if (p.col_n[f.col] <= 1) return; - p.snapColWeights(f.col); - // the pane above, but never a result list: walking past a stack of them - // lands on the pane that spawned it, so those keep their heights and - // only the spawner grows. The topmost pane has only what is below it. - var sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; - var k = f.idx; - while (k > 0) : (k -= 1) { - sib = p.col_terms[f.col][k - 1]; - if (p.panes[sib]) |pp| if (if (pp.file) |ff| output_pane.fileTraits(ff.output).doc else true) break; - } - if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); - } - - /// The parent a new pane splits from must be tall enough that splitBelow - /// leaves the NEW pane at least 2 body rows (the parent keeps tag+1 row, - /// the new pane needs tag+2). A too-short choice is swapped for a - /// qualifying pane (same column first, keeping the split local), else the - /// tallest pane anywhere. - fn splitParent(p: *Pardes, want: usize) usize { - const need = 2 * BOX_H + 3; - if (p.rects[want].h >= need) return want; - if (p.layoutFindTerm(want)) |f| for (0..p.col_n[f.col]) |k| { - if (p.rects[p.col_terms[f.col][k]].h >= need) return p.col_terms[f.col][k]; - }; - var tallest = want; - for (0..p.ncol) |c| for (0..p.col_n[c]) |k| { - const pid = p.col_terms[c][k]; - if (p.rects[pid].h >= need) return pid; - if (p.rects[pid].h > p.rects[tallest].h) tallest = pid; - }; - return tallest; - } - - /// Open a window BELOW `src` (acme-style) without rebalancing the column: - /// shrink ONLY src to its content height (cursor row kept visible) and hand - /// the freed rows to `nw` — together they fill src's old slot and the other - /// panes keep their sizes bit-identical (weights snap to row counts). - fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { - const src = p.panes[src_id] orelse return; - const src_h = p.rects[src_id].h; - const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; - const cur: u16 = if (!src.isTerminal()) body / 2 else term_pane.gridCursor(src).y + 1; - // cap keep so a content-full source still leaves the new pane a tag + - // a few body rows (an Alt-n from a full shell was born 0 rows tall) - const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); - if (p.layoutFindTerm(src_id)) |f| for (0..p.col_n[f.col]) |k| { - const pid = p.col_terms[f.col][k]; - if (p.panes[pid]) |pp| if (pp != nw) { - pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); - }; - }; - src.vweight = @floatFromInt(BOX_H + keep); - nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); - // a non-doc buffer is worth exactly its own text: a three-hit +Search - // is four rows, not half the source. Nothing else can want the rows, - // so they go straight back to the pane they were taken from. - if (nw.file) |f| if (!output_pane.fileTraits(f.output).doc) { - // trimmed: every row ends in a newline, and the empty line after - // the last one is not a result - const want: f32 = @floatFromInt(BOX_H + file_pane.lineCount(std.mem.trimEnd(u8, f.content, "\n"))); - if (want < nw.vweight) { - src.vweight += nw.vweight - want; - nw.vweight = want; - } - }; - } - /// when a doc lands in `col`, a lone pristine shell there is clutter — drop /// it; absorbVWeight hands its space to the doc fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { var n_tty: usize = 0; var tty_id: usize = 0; for (0..p.col_n[col]) |k| { - const cid = p.col_terms[col][k]; + const cid = p.col_panes[col][k]; if (p.panes[cid]) |ct| if (ct.isTerminal()) { n_tty += 1; tty_id = cid; @@ -14549,27 +10422,16 @@ pub const Pardes = struct { const tt = p.panes[tty_id] orelse return; // No typing, cursor on the first prompt line, no scrollback: this is // the throwaway boot placeholder a document may replace. - if (tt.ovl != null or term_pane.gridCursor(tt).y != 0 or - term_pane.scrollbar(tt).total > tt.rows) return; - p.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows - p.absorbVWeight(tty_id); - p.layoutRemove(tty_id); - p.deinitPane(tt); + if (tt.ovl != null or panes.Terminal.gridCursor(tt).y != 0 or + panes.Terminal.scrollbar(tt).total > tt.rows) return; + p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err); + layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows + layout.absorbVWeight(p, tty_id); + layout.removePane(p, tty_id); p.panes[tty_id] = null; if (p.active == tty_id) p.active = keep_id; } - /// a terminal already in `dir` and still at its prompt, else a fresh shell - /// there at the bottom of the rightmost column. Backs middle-click send - /// from a file pane. Does NOT focus (execute keeps you where you were; look - /// focuses). - /// - /// A terminal whose tty is TAKEN (vim, a pager, an agent) is not a match for - /// its own cwd: it cannot run a command line, so the scan keeps going and - /// spawns rather than pretending it found somewhere to type. The directory - /// is compared FIRST because that comparison is free and the occupancy - /// question costs a walk through /proc — a window full of shells in other - /// directories is not worth one syscall. fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { for (p.panes, 0..) |slot, i| if (slot) |tt| { if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue; @@ -14580,11 +10442,11 @@ pub const Pardes = struct { nt.greet = false; const rc = if (p.ncol > 0) p.ncol - 1 else 0; if (p.col_n[rc] > 0) { - const src = p.splitParent(p.col_terms[rc][p.col_n[rc] - 1]); - const f = p.layoutFindTerm(src).?; - p.splitBelow(src, nt); - p.layoutInsert(f.col, f.idx + 1, free); - } else p.layoutInsert(rc, p.col_n[rc], free); + const src = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); + const f = layout.findPane(p, src).?; + layout.splitBelow(p, src, nt); + layout.insert(p, f.col, f.idx + 1, free); + } else layout.insert(p, rc, p.col_n[rc], free); return free; } @@ -14595,25 +10457,27 @@ pub const Pardes = struct { errdefer p.gpa.free(content); const path = try p.gpa.dupe(u8, "/Tutor"); errdefer p.gpa.free(path); + const history = try panes.File.History.create(p.gpa); + errdefer p.gpa.destroy(history); const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content }; + pane.file = .{ .path = path, .content = content, .history = history }; pane.cur_pinned = true; return pane; } - /// TEST-ONLY, called by test/hxdiff.zig (the helix differential harness): - /// swap the tty_only boot pane for a file pane holding `content` verbatim - /// — file_pane.open minus the disk read (cases carry their buffer inline). - /// Unreachable from any shell; keep it dumb. - pub fn hxOpenFileContent(p: *Pardes, content: []const u8) !*Pane { + pub fn setTestFile(p: *Pardes, content: []const u8) !*Pane { const copy = try p.gpa.dupe(u8, content); errdefer p.gpa.free(copy); - const path = try p.gpa.dupe(u8, "/hxcase.txt"); + const path = try p.gpa.dupe(u8, "/test.txt"); errdefer p.gpa.free(path); - p.deinitPane(p.panes[0].?); + const history = try panes.File.History.create(p.gpa); + errdefer p.gpa.destroy(history); + const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); + errdefer p.gpa.destroy(pane); + try p.deinitPane(p.panes[0].?); p.panes[0] = null; - const pane = try p.newDocPane(0); - pane.file = .{ .path = path, .content = copy }; + pane.file = .{ .path = path, .content = copy, .history = history }; + p.installPane(0, pane); pane.cur_pinned = true; p.active = 0; p.sync(); @@ -14623,40 +10487,29 @@ pub const Pardes = struct { fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void { if (comptime !pdf_enabled) return; if (pane.pdf == null) return; - var state = paneNormalState(pane); - // The way OUT of a PDF, because plain Escape below is the document's - // own cancel (selection, search overlay) and never moves focus. Before - // the parser: `config.escape` matches a shifted Escape too, since shift - // is consulted only where a binding asks for it. if (hit(key, config.leave_pane)) { - state.clear(); - putPaneNormalState(pane, state); + pane.normal.clear(); pane.select = false; return p.runBuiltin(.Last, p.active, "", null); } - // A PDF has no body character to find, so its bare `f` is the direct - // document-outline door. Prefix continuations still go through the - // shared parser, and text/terminal panes retain `f` unchanged. - if (state.prefix == .none and isPrefix(key, 'f')) { - state.clear(); - putPaneNormalState(pane, state); + if (pane.normal.prefix == .none and isPrefix(key, 'f')) { + pane.normal.clear(); return p.runBuiltin(.PdfSections, p.active, "", null); } - const parsed = normal_input.parse(&state, normalInput(key)); - putPaneNormalState(pane, state); + const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); switch (parsed) { .pending, .ignored, .unbound => {}, .action => |semantic| { - const result = pdf_pane.applyNormal( + const result = panes.Pdf.applyNormal( &pane.pdf.?, p.pdf_gpa, semantic, p.native_images, .{ .w = p.cell_pixels.w, .h = p.cell_pixels.h }, - pdf_pane.paneViewport(p, pane), - pdf_pane.paneGeometry(p, pane), + panes.Pdf.paneViewport(p, pane), + panes.Pdf.paneGeometry(p, pane), ); - if (result.page_changed) pdf_pane.resetPageChrome(pane); + if (result.page_changed) panes.Pdf.resetPageChrome(pane); switch (result.host) { .none => {}, .leader => { @@ -14667,7 +10520,7 @@ pub const Pardes = struct { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, - .search => p.startSearch(pane, config.search_marker), + .search => p.startPrompt(pane, .{ .search = config.search_marker }), .search_forward => p.lookWalk(1), .search_backward => p.lookWalk(-1), } @@ -14675,71 +10528,37 @@ pub const Pardes = struct { } } - /// stack a fresh doc pane at the top of the LEFT column (acme convention: - /// files left, terminals right), halving ONLY the old top pane's slot so - /// the rest of the column keeps its sizes; then evict a leftover pristine - /// shell. The placement of last resort — a first doc normally takes a - /// column of its own (placeDoc), and this only runs when the column bar - /// is full. fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void { const lc = 0; - if (p.col_n[lc] > 0) if (p.panes[p.col_terms[lc][0]]) |top| { - p.snapColWeights(lc); - const h = p.rects[p.col_terms[lc][0]].h; + if (p.col_n[lc] > 0) if (p.panes[p.col_panes[lc][0]]) |top| { + layout.snapColWeights(p, lc); + const h = p.rects[p.col_panes[lc][0]].h; nt.vweight = @floatFromInt(@max(1, h / 2)); top.vweight = @floatFromInt(@max(1, h -| h / 2)); }; - p.layoutInsert(lc, 0, free); + layout.insert(p, lc, 0, free); p.active = free; p.evictLonePristineTty(lc, free); } // ---- 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). `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, 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. + pane.normal.clear(); + if (pane.hasPdf()) { 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); + if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) panes.Pdf.resetPageChrome(pane); } return; } 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; + if (at.line > panes.File.nlines(p.gpa, f)) return; if (landing == .center) { const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { @@ -14748,11 +10567,6 @@ pub const Pardes = struct { } } } - // 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; @@ -14760,36 +10574,16 @@ pub const Pardes = struct { pane.ensureCursorVisible(); } - /// Select the span a RANGED look word names (config.range_sep): the two - /// ends are block-cursor CELLS, so this is the same cellRange/setPaneRange - /// pair every motion writes back through — the cursor lands on the span's - /// last cell with the anchor on its first, which is where helix leaves you - /// after a search too. - /// - /// EXPLICIT, so the acme chords act on it like a v/x selection: the whole - /// point of `n` selecting a hit is being able to chord the match straight - /// into the next command. - /// - /// Everything clamps, because a range is a claim about a file that may - /// have changed underneath it: hxOff pins a row past the end to the last - /// line and a column past the end to that line's terminator, so a stale - /// row selects what is still there instead of crashing or highlighting - /// garbage. A whole-lines range (no end column) runs to the terminator by - /// asking for a column no line can have, which is helix's own `x`. fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void { - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; + const text = p.flatSurface(pane) catch return; // saturating, not `- 1`: `f.zig:0-5` is a legal thing to type and a // 1-based zero is the same nothing an absent number is const acol: i32 = @intCast(at.col -| 1); const ecol: i32 = if (at.end_col > 0) @intCast(at.end_col - 1) else std.math.maxInt(i32); - const r = cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); - setPaneRange(pane, pl, text, r, true); + const r = Pane.cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); + pane.setRange(text, 0, r, true); } - /// focus the pane already loaded on `path` (exact match), if any: file - /// panes recenter on a :NN line like the look dedup always has, image - /// panes just focus. Returns false when no pane holds that path. fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; @@ -14809,50 +10603,19 @@ pub const Pardes = struct { return false; } - /// LOOK — the Look builtin's body, and so what a right click, an Enter and - /// the word `Look` all end at. Resolve `txt` against the panes' directories - /// and open (or focus) whatever it names; a word that names nothing is a - /// search of the pane it came from, which is acme's button-3. pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { const pane = p.panes[id] orelse return; p.noteHaptic(.look); - // ...and this pane is now the head of the n/N walk. Recorded HERE, at - // the one dispatcher every look reaches, so a right click, an Enter, a - // stepped result row and the word `Look` all count alike. p.noteLookSource(id); const trimmed = std.mem.trim(u8, txt, " \t\r\n"); - // `` @`ls -la` `` names a COMMAND, not a path: run it, and land in the - // pane that answers — looking at a thing means being SHOWN it, and a - // command's output is what there is to be shown. Looking at a - // DIRECTORY has always been exactly this (below: focus a shell there - // and make it `ls`); this is that rule spelled generally. if (config.commandWord(trimmed)) |cmd| { if (p.execute(id, cmd)) |dst| p.active = dst; return; } var realbuf: [4096]u8 = undefined; - // an already-loaded pane wins BEFORE any filesystem resolve: the - // web build has no fs (a look would otherwise be inert even for - // panes sitting in the session), and native gets the same dedup - // it always did, just without touching disk. Pane paths are - // canonical (realpath'd or dump-given), so match the word as-is - // here and joined onto each directory below. const pl = look.parsePathLine(trimmed); - if (comptime pdf_enabled) if (pdf_pane.lookSection(p, id, pl.path, pl.at)) return; + if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.at)) return; - // The word is resolved against the pane DIRECTORIES in access - // order: the pane the click came from FIRST — its answer is the - // one taken, so nothing that resolves today moves — then every - // other live pane, most recently focused first (the jump stack runs - // least-recent -> active, so it is that array backwards; the - // clicked pane is not always `active`, a right click does not - // focus). Only when ALL of them fail does the word fall through to - // the search below: a name you can read in one window is openable - // from any of them. Each attempt is a realpath + an open and - // shells share cwds constantly, so a seen-list holds every - // directory to one try; an absolute word — and `@pN`, which reads - // no directory at all — answers the same everywhere and stops - // after the first pass. var found: look.Target = .none; var seen: [MAX_PANES][]const u8 = undefined; var nseen: usize = 0; @@ -14870,51 +10633,27 @@ pub const Pardes = struct { if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j| if (p.focusPaneByPath(j, pl.at)) return; } - found = look.resolve(txt, dir, &realbuf); + found = look.resolve(p, txt, dir, &realbuf); if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; } switch (found) { - // acme button-3: a word that names no file/dir is a search of - // the pane it was clicked in — exactly what `/` runs, and then a - // step onto a hit, so a click GOES somewhere and clicking again - // goes to the next one. Paths (src/a/b.rs:100) still resolve above - // and open; only the non-file case falls through here. A shell - // searches its scrollback like anything else, EXCEPT in tty - // mode, where the click belongs to the program on the other - // end; an image pane has no text to search either way. .none => { const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; if (pane.image != null or bmode == .tty) return; - // ...and then STEP it, which is the other half of button-3: a - // click does not merely LIST the hits, it goes to one — the - // one AFTER the word clicked, since runSearch armed the walk - // where the click put the cursor. Asking again is free: the - // same pattern refills its own list rather than opening a - // second, so clicking a word repeatedly walks its hits. p.runSearch(id, trimmed, .text, .cursor) catch |err| { p.reportError(id, "search", err); return; }; const at = pane.search_row; _ = p.searchStep(id, 1); - // past the last hit, back to the first: acme's search is a - // RING, and a step that could not move left the row where it - // was (searchStep clamps rather than wrapping, because n/N are - // also how `]d`/`[d` walk to the end of a list and stop). if (at != null and pane.search_row == at) { pane.search_row = null; _ = p.searchStep(id, 1); } }, - // `@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, .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. - // A terminal whose tty is taken is not that terminal: `ls\r` - // typed into vim is `ls\r` typed into vim. for (p.panes, 0..) |slot, i| { if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) { p.active = i; @@ -14922,26 +10661,20 @@ pub const Pardes = struct { return; }; } - // A LOOK WITH NOWHERE TO PUT THE ANSWER SAYS SO. All four - // slot checks in this function were a bare `orelse return`, - // which with one selection merely felt like a dead key and with - // several means "I opened nine of your fourteen and told you - // nothing". `NoPaneSlots` is the error output_pane.zig already - // raises for this, through the channel a test already pins. const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err); nt.greet = true; - const src = p.splitParent(id); - const f = p.layoutFindTerm(src).?; - p.layoutInsert(f.col, f.idx + 1, free); - p.splitBelow(src, nt); + const src = layout.splitParent(p, id); + const f = layout.findPane(p, src).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, src, nt); p.active = free; }, .file => |target| { if (comptime pdf_enabled) if (target.kind == .pdf) { if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = pdf_pane.openPane(p, free, target.path, target.at.line) catch |err| + const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); p.placeDoc(id, free, nt); return; @@ -14949,22 +10682,12 @@ pub const Pardes = struct { // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - // ...and WHY a file would not open, which `look.readFile` now - // distinguishes: permission denied, a pipe, too large. - const nt = file_pane.open(p, free, target.path, target.at.line) catch |err| + const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1); p.placeDoc(id, free, nt); - // center the target line: the pane's real body height only - // exists after placement, so lay out now and pull the - // scroll up by half a body (line 0 opens stay at the top) - p.computeGeom(); + layout.compute(p); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; - // ...and only THEN select a range, restoring that scroll: - // setPaneRange keeps its cursor visible, and a pane this fresh - // has no true geometry yet for it to judge against (pane.rows - // is only refreshed in sync), so the centering just computed is - // the answer and ensureCursorVisible's is not. if (target.at.end_line != 0) { const centered = nt.file.?.scroll; p.selectSpan(nt, target.at); @@ -14974,52 +10697,27 @@ pub const Pardes = struct { .image => |target| { if (p.focusPaneByPath(target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = image_pane.create(p, free, target.path, &.{}) catch |err| + const nt = panes.Image.create(p, free, target.path, &.{}) catch |err| return p.reportError(id, "look", err); p.placeDoc(id, free, nt); }, } } - /// how deep `execute` may re-enter itself. Nothing can reach this today: - /// every door back in strips at least one word (`Exec X` -> `X`) or one - /// pair of delimiters (`` @`X` `` -> `X`), so the command line strictly - /// shrinks and a cycle cannot close — executing the bare word `Exec` runs - /// out of argument immediately. The counter is here so that a syntax added - /// later which does NOT shrink (an alias, a macro) stops instead of hanging - /// the editor, and the ceiling is small because a human nesting eight deep - /// has made a different mistake. const max_exec_depth = 8; - /// EXECUTE — the Exec builtin's body, and so what a middle click, a Tab - /// and the word `Exec` all end at. A builtin's NAME runs the builtin; - /// anything else is a command line typed at a shell. Returns the pane it - /// was typed into, which is what Look focuses and an execute deliberately - /// does not. pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { const pane = p.panes[id] orelse return null; const cmd = commandText(txt); if (cmd.len == 0) return null; - // Before the builtin dispatch, and only at depth zero: `Exec ls` comes - // back through here as `ls` (executeBuiltinLine holds the depth), and - // one Tab is one thing the hand did, however many words it unwraps to. if (p.exec_depth == 0) p.noteHaptic(.exec); if (p.executeBuiltinLine(id, cmd)) return null; if (p.exec_depth >= max_exec_depth) return null; p.exec_depth += 1; defer p.exec_depth -= 1; - // Anything not in the builtin vocabulary is a command line typed at - // a shell. Startup config calls executeBuiltinLine directly and never - // reaches this fallback. - // A terminal runs in itself — as long as its tty is still the prompt we - // forked. Every document kind — real/output file, image, or PDF — and a - // terminal currently held by a full-screen program run in a terminal for - // their directory (an existing prompt when possible, otherwise a freshly - // forked shell). In particular, never emit a PTY write addressed to an - // image's no-PTY pane slot, and never type a command line at vim. const dst = (if (p.takesCommandLine(id)) id else p.ttyForDir(paneDir(pane))) orelse return null; - term_pane.padOutputBelowEdits(p, dst); - if (term_pane.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { + panes.Terminal.padOutputBelowEdits(p, dst); + if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { p.reportError(id, "queue command", err); return dst; }) return dst; @@ -15036,20 +10734,9 @@ pub const Pardes = struct { return cmd; } - /// Parse and dispatch exactly one builtin command. This is the canonical - /// builtin path used both by ordinary Exec and by startup configuration; - /// unlike execute(), it deliberately has no external-shell fallback. - /// False means blank, malformed, unknown, or recursion-limited. pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { const cmd = commandText(txt); if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false; - // The builtins that take an ARGUMENT match their name with a TAIL: - // `Restore `, `Find `, `Grep `, `Rename `, - // `WsSymbols `, `Theme `, `Font ` (gui only), and the - // two verbs themselves — `Look `, `Exec `, which is what - // makes `` @`Look .` `` nest (the tail goes straight back through - // here). Every other name must match WHOLE, so `Kill foo` is a shell - // command and not Kill. const sp = std.mem.indexOfAny(u8, cmd, " \t"); const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: { const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null; @@ -15068,17 +10755,6 @@ pub const Pardes = struct { while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line); } - /// Run a builtin on pane `id`. `txt` is the executed text (Restore reads - /// its path back out of it) and `arg` the builtin's ARGUMENT — the tail - /// after the name, which is why Grep and Find run straight away when there - /// is one instead of asking, and which for Look and Exec is the whole - /// operand. A gesture that points at a word (a click, an Enter) passes it - /// as `arg` with no `txt`: it named no builtin, config.look_cmd did. The - /// leader passes "" and null: a key path names a builtin, never an - /// argument. The topbar builtins are global; the pane-scoped ones (Save, - /// Del, Delcol, the image toggles, and the window group, which moves focus - /// relative to `id`) act on `id`. The null-pane check is the one guard - /// every builtin used to share, so it stays here rather than in each. fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse return; @@ -15086,10 +10762,8 @@ pub const Pardes = struct { builtins.registry.dispatch(b, c); } - /// Generated setting builtins converge here. The descriptor is compile- - /// time data but the state is ordinary owned data; no vtable or callback - /// layer sits between the command enum and these fields. - pub fn applySettingBuiltin(p: *Pardes, setting: runtime_cfg.Setting, arg: ?[]const u8) void { + pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void { + const previous_colors = p.settings.colors; const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; switch (setting.action) { @@ -15105,109 +10779,66 @@ pub const Pardes = struct { const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); const matches = fonts.list(p.scratch.allocator(), want); if (matches.len == 0) return; - if (!runtime_cfg.requestFont(&p.settings, matches[0].path, matches[0].name)) return; + if (!p.settings.requestFont(matches[0].path, matches[0].name)) return; p.font_request_taken = false; }, - else => _ = runtime_cfg.applySimple(&p.settings, setting, arg), + else => _ = p.settings.apply(setting, arg), + } + if (p.settings.colors != previous_colors) { + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.file) |*file| file.syntax_dirty = true; + } } const transition_changed = setting.action == .transition and p.settings.panel_transition != previous_transition; const tagline_metrics_changed = setting.action == .tagline_size and p.settings.font.tagline_percent != previous_tagline_percent; if (transition_changed or tagline_metrics_changed) { - // The backend may still show the last acknowledged sample. Keep - // input inert until it has replaced that sample with canonical - // pixels instead of merely forgetting the producer-side tracks. - // A tagline-size change also invalidates the raster metrics used - // by a frozen old frame; snapping is the only honest old/new pair. p.abandonPanelAnimations(); } } - /// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a - /// document: it is the result list belonging to the pane that asked for it, - /// so it never claims a column and is never anyone else's split parent — it - /// lands right below `from_id`, be that a shell, a file or another list, - /// and `from_id` alone pays the rows (several lists just stack there). - /// A real doc joins the docs: any doc already open is the split parent (the - /// source if it IS one, else the one most recently worked in) and the - /// newcomer lands right below it, so docs share a column. The FIRST doc of - /// the session instead gets a column of its own on the left (acme: files - /// left, shells right). Only the calling/source column donates half its - /// width; every other column keeps its boundary. A full column bar, or a - /// result list whose source died, stacks into the leftmost. - /// A new file opens a column only when the split leaves both sides wide - /// enough to read; otherwise it stacks as a pane. Halving is exact, so the - /// narrower side is floor(width/2) >= min_cells iff width >= 2*min_cells. - fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool { - const f = p.layoutFindTerm(source_id) orelse return false; - p.computeGeom(); - return p.col_w[f.col] >= min_cells * 2; - } - pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { - const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc + const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; - if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null or hasPdf(src)) { + if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) { src_id = from_id; }; - // Opened from somewhere that is NOT a doc (a shell, a results list): - // the file joins the column it was last being READ in, which is the - // newest doc on the jump stack. The stack already IS that record — it - // is where the keyboard has been — so nothing new is remembered here; - // asking it is the whole change. What this replaces was the first doc - // in slot order, i.e. pane-id ALLOCATION order, so which column your - // file landed in depended on how the session had handed out ids rather - // than on where you were working. - // - // The answer wanted is the COLUMN, and it stays right once the pane - // itself is closed: trackJump compacts dead entries out, so the walk - // falls through to the next doc remembered in that same column. The - // serial test is the one trackJump uses — slots are reused, so an entry - // whose pane has been replaced names a pane that is gone, not the - // newcomer sitting in its slot (`free`, this very pane, among them). if (doc and src_id == null) { var n = p.njumps; while (n > 0) : (n -= 1) { const j = p.jumps[n - 1]; const pp = p.panes[j.pane] orelse continue; if (pp.serial != j.serial) continue; - if (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp)) { + if (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf()) { src_id = j.pane; break; } } } - // A doc no jump remembers is still a doc: a restored session focuses - // one pane, not each, and the stack is finite. The rule that a second - // doc never claims a second column outranks knowing where you were, so - // the old slot-order scan stays as the fallback. if (doc and src_id == null) for (p.panes, 0..) |sl, i| { - if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp))) { + if (sl) |pp| if (i != free and (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf())) { src_id = i; break; }; }; - // A result list belongs to its spawner: it lands directly BELOW it — - // on top of the lists already there — and its rows come out of the - // SPAWNER, never a bystander, so opening another list (or deleting - // one, see absorbVWeight) leaves every other pane's height untouched. - if (!doc) if (p.layoutFindTerm(from_id)) |sf| { - p.layoutInsert(sf.col, sf.idx + 1, free); - p.splitBelow(from_id, nt); // NOT splitParent: no bystander pays + if (!doc) if (layout.findPane(p, from_id)) |sf| { + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays p.active = free; return; }; if (src_id) |sid| { - const src = p.splitParent(sid); - const sf = p.layoutFindTerm(src).?; - p.layoutInsert(sf.col, sf.idx + 1, free); - p.splitBelow(src, nt); + const src = layout.splitParent(p, sid); + const sf = layout.findPane(p, src).?; + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, src, nt); p.active = free; return; } - if (doc and p.ncol < MAX_COLS and p.columnFitsHalves(from_id, 100)) { - if (!p.layoutSplitColumn(from_id, free, true)) return p.stackDocLeft(free, nt); + if (doc and p.ncol < MAX_COLS and layout.columnFitsHalves(p, from_id, 100)) { + if (!layout.splitColumn(p, from_id, free, true)) return p.stackDocLeft(free, nt); p.active = free; return; } @@ -15219,7 +10850,7 @@ pub const Pardes = struct { pub fn dumpState(p: *Pardes) !void { const arena = p.scratch.allocator(); var slot_to_pane: [MAX_PANES]?usize = @splat(null); - var panes: [MAX_PANES]dump.Pane = undefined; + var dump_panes: [MAX_PANES]dump.Pane = undefined; var panes_len: usize = 0; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; @@ -15227,32 +10858,25 @@ pub const Pardes = struct { const tag = try p.tagText(arena, pane); const body = try p.bodyText(arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); - // BY POINTER: `origin_arg` is a slice into the File's own inline - // buffer, and a by-value capture would leave it pointing at a - // stack copy that dies before the ZON is written. - var dp: dump.Pane = if (pane.file) |*f| try file_pane.dumpPane( + var dp: dump.Pane = if (pane.file) |*f| try panes.File.dumpPane( arena, pane, f, tag, body, scroll, - if (f.output) |o| output_pane.word(o.from) else "", + if (f.output) |o| panes.Output.word(o.from) else "", if (f.output) |*o| o.arg() else "", - ) else if (hasPdf(pane)) blk: { + ) else if (pane.hasPdf()) blk: { if (comptime !pdf_enabled) unreachable; const pv = &pane.pdf.?; - // Keep the dump schema backwards-compatible: a raster-backed - // document rides the existing binary image record, while its - // `scroll` field is the zero-based PDF page. Restore inspects - // the extension and reconstructs the semantic PDF pane. - break :blk try image_pane.dumpPane(arena, pane, tag, body, pv.page, pv.path, &.{}); + break :blk try panes.Image.dumpPane(p, arena, pane, tag, body, pv.page, pv.path, &.{}); } else if (pane.image) |iv| - try image_pane.dumpPane(arena, pane, tag, body, scroll, iv.path, iv.raw) + try panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw) else - try term_pane.dumpPane(pane, arena, tag, body, scroll); + try panes.Terminal.dumpPane(pane, arena, tag, body, scroll); dp.tag_tail = if (pane.tag_init) pane.tagSlice() else null; - panes[panes_len] = dp; + dump_panes[panes_len] = dp; panes_len += 1; } @@ -15261,7 +10885,7 @@ pub const Pardes = struct { var column_ids: [MAX_COLS][MAX_PANES]usize = undefined; for (0..p.ncol) |c| { var ids_len: usize = 0; - for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_terms[c][k]]) |compact| { + for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_panes[c][k]]) |compact| { column_ids[c][ids_len] = compact; ids_len += 1; }; @@ -15274,13 +10898,16 @@ pub const Pardes = struct { } } + var mounts: [filesystem.max_mounts]dump.Mount = undefined; + for (p.fs.mounts.items, 0..) |mount, i| mounts[i] = .{ .name = mount.name, .dial = mount.dial }; const state: dump.State = .{ .screen = .{ .cols = p.screen_w, .rows = p.screen_h }, .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, .theme = p.theme().name, .columns = columns[0..columns_len], - .panes = panes[0..panes_len], + .panes = dump_panes[0..panes_len], + .mounts = mounts[0..p.fs.mounts.items.len], }; try dump.validate(state); var out: std.Io.Writer.Allocating = .init(p.gpa); @@ -15291,10 +10918,18 @@ pub const Pardes = struct { p.emit(.write_dump); } - /// Initialize from another instance's dump: panes reconstructed (terminals - /// by replaying their raw VT streams into fresh emulators), no spawns — - /// loaded terminals are dead replays, scrollable and selectable. + pub fn restore(p: *Pardes, zon_bytes: []const u8) !*Pardes { + var opts = p.opts; + opts.cols = p.screen_w; + opts.rows = p.screen_h; + return initDump(p.gpa, opts, zon_bytes, p); + } + pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { + return initDump(gpa, opts, zon_bytes, null); + } + + fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; const pdf_gpa = opts.pdf_allocator orelse gpa; const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; @@ -15305,6 +10940,11 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .fs = .{ + .socket_path = opts.ninep_identity.socket_path, + .tcp_address = opts.ninep_identity.tcp_address, + .quic_address = opts.ninep_identity.quic_address, + }, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), @@ -15312,8 +10952,32 @@ pub const Pardes = struct { .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); - const st = try dump.readZon(gpa, zon_bytes, "load"); - defer dump.free(gpa, st); + if (previous) |old| { + p.next_serial = old.next_serial; + p.lsp_seq = old.lsp_seq; + p.pipe_seq = old.pipe_seq; + p.cell_pixels = old.cell_pixels; + p.native_images = old.native_images; + p.fs.socket_path = old.fs.socket_path; + p.fs.tcp_address = old.fs.tcp_address; + p.fs.quic_address = old.fs.quic_address; + } + for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); + p.opts.mounts = &.{}; + p.opts.ninep_identity = .{}; + var parsed = try dump.readZon(gpa, zon_bytes, "load"); + defer parsed.deinit(); + const st = parsed.value; + for (st.mounts) |mount| { + var already_mounted = false; + for (p.fs.mounts.items) |existing| { + if (!std.mem.eql(u8, mount.name, existing.name)) continue; + if (!std.mem.eql(u8, mount.dial, existing.dial)) return error.MountConflict; + already_mounted = true; + break; + } + if (!already_mounted) try p.fs.mount(gpa, mount.name, mount.dial); + } for (themes, 0..) |t, i| { if (std.mem.eql(u8, t.name, st.theme)) p.settings.theme = @intCast(i); @@ -15323,23 +10987,23 @@ pub const Pardes = struct { const pane: *Pane = switch (src.kind) { .terminal => terminal: { const t = src.terminal.?; - const restored = try term_pane.restore(p, src); + const restored = try panes.Terminal.restore(p, src); p.installPane(i, restored); - p.setCwd(i, t.cwd); + try restored.setOwnedCwd(t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty; break :terminal restored; }, - .file => try file_pane.restore(p, i, src), + .file => try panes.File.restore(p, i, src), .image => restore_image: { const im = src.image.?; - if (pdf_pane.isPath(im.path)) { + if (panes.Pdf.isPath(im.path)) { var raw: []u8 = if (im.bytes_b64.len > 0) try dump.decodeBytes(gpa, im.bytes_b64) else &.{}; errdefer if (raw.len > 0) gpa.free(raw); if (comptime pdf_enabled) { - if (pdf_pane.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| { + if (panes.Pdf.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| { if (raw.len > 0) gpa.free(raw); raw = &.{}; restored.cols = @max(1, src.cols); @@ -15354,15 +11018,17 @@ pub const Pardes = struct { const content = if (raw.len > 0) raw else try gpa.dupe(u8, ""); raw = &.{}; errdefer if (content.len > 0) gpa.free(content); + const history = try panes.File.History.create(gpa); + errdefer gpa.destroy(history); const restored = try p.newDocPane(i); - restored.file = .{ .path = path, .content = content }; + restored.file = .{ .path = path, .content = content, .history = history }; restored.cur_pinned = true; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); - p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); + p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true, .mode = .baseline_disk } }); break :restore_image restored; } - break :restore_image try image_pane.restore(p, i, src); + break :restore_image try panes.Image.restore(p, i, src); }, }; p.restoreDumpTail(pane, src); @@ -15374,14 +11040,14 @@ pub const Pardes = struct { const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight))); p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded)))); p.col_n[c] = @min(col.panes.len, MAX_PANES); - for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_terms[c][k] = pid; + for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_panes[c][k] = pid; } p.active = @min(st.active, MAX_PANES - 1); p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); - p.panel_animation_enabled = true; + p.presentation.enabled = true; _ = p.takeHaptic(); // see init: a restored session is not a gesture return p; } @@ -15393,12 +11059,6 @@ pub const Pardes = struct { return; } const tail_class = tailClass(pane); - // Early dumps put tty mode in the live prefix itself. The cwd stored - // beside the replay stream is the stable half of that historical - // prefix; use it to recover both custom tails and defaults even though - // today's tag has only the mode box plus cwd. Check this before the - // current prefix because a not-yet-reported cwd is legitimately empty - // and therefore a prefix of every saved tag. if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) { const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return; if (std.mem.startsWith(u8, src.tag, legacy)) @@ -15409,25 +11069,18 @@ pub const Pardes = struct { return p.restoreTailAt(pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); if (src.kind != .image) return; const saved = src.image.?; - if (pane.image) |*state| if (image_pane.legacySavedPrefix(state, src.tag)) |legacy| + if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy| return p.restoreTailAt(pane, src.tag, legacy, .generic); - if (pdf_pane.isPath(saved.path)) if (pdf_pane.legacySavedPrefix(saved.path, src.tag)) |legacy| + if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy| p.restoreTailAt(pane, src.tag, legacy, .generic); } - /// Compatibility path for dumps without explicit tag_tail: recover what - /// followed the rendered prefix, upgrading every historical default while - /// retaining genuinely edited bytes. fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { const pfx = p.tagPrefix(pane) catch return; const class = tailClass(pane); p.restoreTailAt(pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); } - /// Which family of historical defaults a saved tail is read against. An - /// output buffer is its own class rather than a file: it wears the file - /// tail today, but the tail it was DUMPED with was the generic one, and a - /// real file must not inherit that recognition (see restoreTailAt). const TailClass = enum { generic, file, output, terminal }; fn tailClass(pane: *const Pane) TailClass { @@ -15436,11 +11089,6 @@ pub const Pardes = struct { return .generic; } - /// A dirty marker may be present in the rendered compatibility tag of an - /// untouched file. It is live prefix chrome, not a custom command tail; - /// consume it while recovering old dumps so it disappears after Save. Only - /// a pane with a file to be dirty AGAINST ever rendered one — a real file, - /// or the scratch that is becoming one. fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 { if (class == .generic or class == .terminal) return live; if (!std.mem.startsWith(u8, saved_tag, live)) return live; @@ -15459,22 +11107,10 @@ pub const Pardes = struct { ) void { if (!std.mem.startsWith(u8, saved_tag, pfx)) return; const rest = saved_tag[pfx.len..]; - // A saved tag carries its layout gap, because the padding is real - // characters — and the pane it is restored into is very often a - // different width than the one it was dumped from. So compare what the - // tail SAYS and not where it sat: leading spaces are layout, never - // content, and a default that came back padded is still a default. const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { .generic => &.{ pane_tail, prev_pane_tail, legacy_pane_tail }, .file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, - // An output buffer wore the GENERIC default until Save reached it, - // and a terminal's has now been through three shapes; both upgrade - // from that family. The scratch is an output buffer that wore the - // FILE defaults all along (it was the one that could Save), so its - // row carries both. Recognition stays scoped per class: the - // generic default sitting on a real FILE is still text its owner - // typed and is still kept. .output => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail, @@ -15487,11 +11123,11 @@ pub const Pardes = struct { pane.tag_init = true; } - fn removePane(p: *Pardes, id: usize) void { + pub fn removePane(p: *Pardes, id: usize) !void { const pane = p.panes[id] orelse return; - p.absorbVWeight(id); - p.layoutRemove(id); - p.deinitPane(pane); + try p.deinitPane(pane); + layout.absorbVWeight(p, id); + layout.removePane(p, id); p.panes[id] = null; if (p.active == id) p.active = p.prevFocus(id) orelse { p.quit = true; @@ -15500,10 +11136,28 @@ pub const Pardes = struct { }; } - /// where focus falls when the active pane closes: the most recently - /// focused pane still alive (else any live one). Null = nothing left. - /// Walks the jump stack newest-first, so it answers exactly what it always - /// did — a pane's newest entry sits where the old MRU put the pane. + pub fn removeColumn(p: *Pardes, id: usize) !void { + const place = layout.findPane(p, id) orelse return; + var ids: [MAX_PANES]usize = undefined; + var parents: [MAX_PANES]*Pane = undefined; + const count = p.col_n[place.col]; + for (0..count) |i| { + ids[i] = p.col_panes[place.col][i]; + parents[i] = p.panes[ids[i]].?; + } + try p.detachCwds(parents[0..count]); + for (ids[0..count], parents[0..count]) |closed, pane| { + p.retirePane(pane); + layout.removePane(p, closed); + p.panes[closed] = null; + } + if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { + p.quit = true; + p.emit(.quit); + return; + }; + } + pub fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.njumps; while (i > 0) { @@ -15517,27 +11171,6 @@ pub const Pardes = struct { return null; } - /// THE PUSH RULE, and the only place it is written down. - /// - /// A location is worth remembering when you cannot see it any more: focus - /// ended this update in a DIFFERENT pane, or more than a bodyful of rows - /// away in the same one. Anything closer is the cursor strolling, and the - /// current entry just follows it — so h/j/k/w/b never grow the list, while - /// a goto-line, a search hit, a goto-definition and every focus change do. - /// (Vim's rule is a hand-kept list of "jump commands"; this one asks the - /// question those commands are a proxy for, and needs no list.) - /// - /// It is read HERE, once per update, and nowhere else: what a call site - /// does transiently is invisible, which is what keeps n/N over a results - /// buffer — which focuses each hit and comes straight back — from pushing - /// two entries per keystroke. That transparency is the whole reason the - /// rule lives in sync() rather than at the sites that move focus, which is - /// where the heuristics used to be scattered. - /// The same PLACE, which is the one question the push rule asks: the same - /// live pane, and near enough within it that the cursor was only strolling. - /// Distance is a bodyful because that is what "you cannot see it any more" - /// means on a screen. A location with no line — an unpinned shell, whose - /// cursor belongs to the program — has no distance to be at. fn samePlace(p: *const Pardes, a: Loc, b: Loc) bool { if (a.pane != b.pane or a.serial != b.serial) return false; if (a.line == 0 or b.line == 0) return true; @@ -15546,9 +11179,6 @@ pub const Pardes = struct { } fn trackJump(p: *Pardes) void { - // dead entries first, in one compacting pass. A slot is reused, so the - // test is the SERIAL: an entry whose pane has been replaced names a - // pane that no longer exists, not the newcomer sitting in its slot. var w: usize = 0; var cur: usize = 0; for (p.jumps[0..p.njumps], 0..) |j, i| { @@ -15562,13 +11192,6 @@ pub const Pardes = struct { p.jcur = @min(cur, w -| 1); const pane = p.panes[p.active] orelse return; - // An UNPINNED cursor belongs to the program on the other end of the - // pty, not to you, so such a pane is remembered as a place and not as - // a spot: line 0 is the "no line" focusPaneLine already understands, - // and going back there focuses the shell without dragging its view up - // to scrollback row 0. It also has no line to be FAR from, which is - // what keeps the first keypress in a shell (which pins the cursor - // wherever the prompt is) from reading as a jump. const now: Loc = .{ .pane = @intCast(p.active), .serial = pane.serial, @@ -15582,22 +11205,6 @@ pub const Pardes = struct { // a new jump made from the middle of the list drops everything ahead of // it, the way vim's does: the future you did not take is not history. if (p.njumps > 0) p.njumps = p.jcur + 1; - // ...and neither is a hop STRAIGHT BACK to the entry under this one. - // That is not two jumps, it is the same two places again: Esc between a - // doc and its shell, `SPC w k` / `SPC w j`, clicking back and forth. - // Appending would grow the stack by one per press until the ping-pong - // is the only thing it remembers — sixty-four presses and every older - // place is gone. - // - // So SWAP the two instead of appending, rather than the other obvious - // move of leaving them alone and walking jcur back down onto the older - // one. The stack has a second job: it is also the focus history, and - // prevFocus, Last and the look order all read it backwards on the - // promise that it "runs least-recent -> active". Parking jcur mid-array - // leaves the pane you are IN somewhere other than the top and quietly - // breaks all three — a look would resolve against the directory of the - // pane you just left before the one you are in. Swapping keeps the - // promise, keeps the length, and leaves Ctrl-o stepping out past both. if (p.njumps >= 2 and p.samePlace(p.jumps[p.njumps - 2], now)) { p.jumps[p.njumps - 2] = p.jumps[p.njumps - 1]; p.jumps[p.njumps - 1] = now; @@ -15613,18 +11220,6 @@ pub const Pardes = struct { p.njumps += 1; } - /// Ctrl-o / Ctrl-i (the Back and Forward builtins): move the CURSOR into - /// 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; @@ -15637,42 +11232,37 @@ pub const Pardes = struct { /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { p.reapPanes(); - p.computeGeom(); - p.syncPanelAnimations(); + layout.compute(p); + p.presentation.sync(p); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; - if (comptime terminal_panes) if (pane.reply_len > 0) { + if (comptime terminal_panes) if (pane.terminal) |state| { var off: u16 = 0; - while (off < pane.reply_len) { - const n = @min(pane.reply_len - off, 64); - p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pane.reply[off .. off + n]) } }); + while (off < state.reply_len) { + const n = @min(state.reply_len - off, 64); + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(state.reply[off .. off + n]) } }); off += n; } - pane.reply_len = 0; + state.reply_len = 0; }; const r = p.rects[id]; const cols = @max(1, r.w -| config.GUTTER); const rows = @max(1, r.h -| BOX_H); // the tag steals the top row - // a pane shrunk to just its tag keeps its last real grid: no - // pty/vt reflow while the body is hidden, so re-enlarging brings - // it back exactly as it was if ((cols != pane.cols or rows != pane.rows) and r.h > BOX_H) { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { - term_pane.resizeGrid(pane, p.gpa, cols, rows); + panes.Terminal.resizeGrid(pane, p.gpa, cols, rows); p.shell_rows.markStale(pane); // reflow moved every row p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); } pane.cols = cols; pane.rows = rows; + if (pane.file) |*file| file.syntax_dirty = true; } - term_pane.releasePendingCommandIfReady(p, id, pane); - // Greet only after the real size AND OSC 133 B: arbitrary startup - // output (or OSC A plus a prompt drawn in pieces) does not prove - // readline owns echo, and injecting there leaves `ls` unmarked. - if (pane.greet and pane.isTerminal() and p.resize_count > 0 and term_pane.promptInputReady(pane)) { + panes.Terminal.releasePendingCommandIfReady(p, id, pane); + if (pane.greet and pane.isTerminal() and p.resize_count > 0 and panes.Terminal.promptInputReady(pane)) { p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } }); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } }); pane.greet = false; @@ -15680,282 +11270,8 @@ pub const Pardes = struct { } } - fn panelBox(rect: Rect) panel_animation.Box { - return .{ - .x = @floatFromInt(rect.x), - .y = @floatFromInt(rect.y), - .w = @floatFromInt(rect.w), - .h = @floatFromInt(rect.h), - }; - } - - /// Turn one committed layout into backend-neutral transition records. - /// Layout remains authoritative and takes effect immediately; these tracks - /// are presentation data only, so disabling an effect cannot strand stale - /// geometry or alter hit testing. - fn syncPanelAnimations(p: *Pardes) void { - const initializing = !p.layout_snapshot_ready or !p.panel_animation_enabled; - if (initializing or p.snap_panel_layout_once) { - const had_presented_layout = p.layout_snapshot_ready; - p.layout_snapshot = @splat(null); - p.panel_tracks = @splat(null); - p.nclosing_panel_tracks = 0; - p.panel_diff_pending = false; - p.panel_diff_ready = false; - // Resize/direct-manipulation snaps still wait for their backend - // presentation acknowledgement. Clearing here would make input - // follow canonical geometry while the last submitted pixels were - // still animated. Initialization has no prior frame to preserve. - if (initializing) { - p.presented_panel_tracks = @splat(null); - p.npresented_closing_panel_tracks = 0; - } - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; - } - p.layout_snapshot_ready = true; - p.snap_panel_layout_once = false; - if (had_presented_layout and p.panel_presentation_ready) - p.panel_presentation_pending = true; - return; - } - - const effect = p.settings.panel_transition; - if (effect.needsPreviousGrid() and (p.panel_diff_pending or p.panel_diff_ready)) { - var second_change = false; - for (p.panes, p.layout_snapshot, 0..) |slot, snapshot, id| { - const pane = slot orelse { - second_change = second_change or snapshot != null; - continue; - }; - const target = panelBox(p.rects[id]); - second_change = second_change or snapshot == null or - snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target); - } - if (second_change) { - // One frozen old grid cannot honestly describe two overlapping - // generations. Snap rapid layout churn instead of rewinding a - // new opener or sliding stale survivor cells as a tombstone. - p.abandonPanelAnimations(); - p.layout_snapshot = @splat(null); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; - } - return; - } - } - var changed = false; - var animated_change = false; - for (p.panes, 0..) |slot, id| { - const pane = slot orelse { - if (p.layout_snapshot[id]) |old| { - changed = true; - if (effect.lifecycleOnly()) { - if (p.appendClosingPanelTrack(.{ - .serial = old.serial, - .pane = @intCast(id), - .phase = .closing, - .effect = effect, - .from = old.box, - .to = panel_animation.closingBox(effect, old.box), - })) animated_change = true; - } - } - p.layout_snapshot[id] = null; - p.panel_tracks[id] = null; - // Never let pixels from a dead pane address a reused slot. - p.presented_panel_tracks[id] = null; - continue; - }; - const target = panelBox(p.rects[id]); - const previous = p.layout_snapshot[id]; - p.layout_snapshot[id] = .{ .serial = pane.serial, .box = target }; - - if (effect == .off) { - changed = changed or previous == null or previous.?.serial != pane.serial or - !previous.?.box.eql(target); - p.panel_tracks[id] = null; - continue; - } - if (previous) |old| { - if (old.serial == pane.serial and old.box.eql(target)) continue; - const same_lifetime = old.serial == pane.serial; - const prior = if (p.panel_tracks[id]) |track| - if (track.serial == pane.serial and track.active()) track else null - else - null; - if (effect.lifecycleOnly() and same_lifetime) { - // A vertical lifecycle transition deliberately leaves - // every survivor at canonical geometry. A still-opening - // lifetime remains an opener when another layout commit - // retargets it; an established survivor gets no record. - if (prior) |active| if (active.phase == .opening) { - var next = active; - next.from = panel_animation.openingBox(effect, target, p.screen_w); - next.to = target; - p.panel_tracks[id] = next; - changed = true; - animated_change = true; - continue; - }; - p.panel_tracks[id] = null; - changed = true; - continue; - } - if (effect.lifecycleOnly() and !same_lifetime) { - _ = p.appendClosingPanelTrack(.{ - .serial = old.serial, - .pane = @intCast(id), - .phase = .closing, - .effect = effect, - .from = old.box, - .to = panel_animation.closingBox(effect, old.box), - }); - } - const shown = if (p.presented_panel_layout[id]) |snapshot| - if (snapshot.serial == pane.serial) snapshot.box else null - else - null; - const from = if (!same_lifetime) - panel_animation.openingBox(effect, target, p.screen_w) - else if (shown) |box| - box - else if (p.panel_presentation_ready and prior != null) - prior.?.from - else - old.box; - const next: panel_animation.Track = .{ - .serial = pane.serial, - .pane = @intCast(id), - .phase = if (!same_lifetime or - (prior != null and prior.?.phase == .opening)) .opening else .moving, - .effect = effect, - .from = from, - .to = target, - }; - p.panel_tracks[id] = next; - changed = true; - animated_change = true; - } else { - const next: panel_animation.Track = .{ - .serial = pane.serial, - .pane = @intCast(id), - .phase = .opening, - .effect = effect, - .from = panel_animation.openingBox(effect, target, p.screen_w), - .to = target, - }; - p.panel_tracks[id] = next; - changed = true; - animated_change = true; - } - } - if (animated_change and effect.needsPreviousGrid()) { - p.panel_diff_pending = true; - p.panel_diff_ready = false; - } - if (changed and p.panel_presentation_ready) p.panel_presentation_pending = true; - } - - fn appendClosingPanelTrack(p: *Pardes, track: panel_animation.Track) bool { - std.debug.assert(track.phase == .closing); - const baseline = p.presented_cells_layout[track.pane] orelse return false; - if (!p.presented_cells_valid or baseline.serial != track.serial or - !baseline.box.eql(track.from)) return false; - if (p.nclosing_panel_tracks == p.closing_panel_tracks.len) { - // Bounded presentation history: under pathological delete/reuse - // churn, retire the oldest (and therefore furthest-progressed) - // tombstone rather than retaining a pane or allocating per Del. - std.mem.copyForwards( - panel_animation.Track, - p.closing_panel_tracks[0 .. p.closing_panel_tracks.len - 1], - p.closing_panel_tracks[1..], - ); - p.nclosing_panel_tracks -= 1; - } - p.closing_panel_tracks[p.nclosing_panel_tracks] = track; - p.nclosing_panel_tracks += 1; - return true; - } - - fn advancePanelAnimations(p: *Pardes) void { - for (&p.panel_tracks) |*slot| { - const track = if (slot.*) |*track| track else continue; - track.frame +|= 1; - if (!track.active()) slot.* = null; - } - var out: usize = 0; - for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |value| { - var track = value; - track.frame +|= 1; - if (!track.active()) continue; - p.closing_panel_tracks[out] = track; - out += 1; - } - p.nclosing_panel_tracks = out; - } - - fn columnBoundary(width: u16, prefix: u128, total: u128) u16 { - if (total == 0) return 0; - const pixels = (@as(u128, width) * prefix + total / 2) / total; - return @intCast(@min(@as(u128, width), pixels)); - } - - pub fn computeGeom(p: *Pardes) void { - if (p.ncol == 0) return; - var wsum: u128 = 0; - for (0..p.ncol) |c| wsum += p.col_weight[c]; - if (wsum == 0) wsum = 1; - - // Round cumulative boundaries, not each width independently. Splitting - // one weight W into A+B=W then leaves every boundary before A and - // after B bit-identical, at every screen width; independent rounding - // can move a later column by one cell even though its own weight and - // the total did not change. - var prefix: u128 = 0; - for (0..p.ncol) |c| { - const last = c + 1 == p.ncol; - const x = columnBoundary(p.screen_w, prefix, wsum); - prefix += p.col_weight[c]; - const end: u16 = if (last) - p.screen_w - else - columnBoundary(p.screen_w, prefix, wsum); - const cw = end -| x; - p.col_x[c] = x; - p.col_w[c] = cw; - - var vsum: f32 = 0; - for (0..p.col_n[c]) |k| { - if (p.panes[p.col_terms[c][k]]) |pane| vsum += pane.vweight; - } - if (vsum <= 0) vsum = 1; - - var y: u16 = TOPBAR_H; - const avail_h = p.screen_h -| TOPBAR_H; - for (0..p.col_n[c]) |k| { - const id = p.col_terms[c][k]; - const pane = p.panes[id] orelse continue; - const lastk = k + 1 == p.col_n[c]; - const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum; - // every pane wants at least one row, so a column with more - // panes than the window has rows would walk `y` off the bottom - // and hand renderPane a rect outside the surface (assert, then - // panic — shrink a window with a few stacked panes). Clamp to - // what is left: the panes past the edge get h = 0 and render - // nothing until the window grows back. - const room = p.screen_h -| y; - const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh))))); - p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; - y +|= ch; - } - } - } - pub fn theme(p: *const Pardes) *const Theme { - if (p.custom_theme_active) return &p.custom_theme.?; + if (p.custom_theme) |*custom| return custom; return &themes[p.settings.theme]; } @@ -15971,15 +11287,9 @@ pub const Pardes = struct { if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true; const scene = p.settings.scene_effects; if (scene.crt or scene.ripple or scene.glitch) return true; - for (p.panel_tracks) |track| if (track != null and track.?.active()) return true; - for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track| - if (track.active()) return true; - return false; + return p.presentation.animating(); } - /// Arm the pulse. Look wins a tie because a Look that runs a command - /// (`` @`ls` ``, which is one gesture spelled as both) is felt as the - /// thing the user asked for, not as the shell it happened to need. fn noteHaptic(p: *Pardes, pulse: Haptic) void { if (comptime platform != .macos) return; if (p.haptic == .look) return; @@ -16007,13 +11317,9 @@ pub const Pardes = struct { }; } - /// The sole live-session theme mutation path. Target colors change now; - /// anchored chrome retargets from its currently displayed palette. Only - /// PDFs whose pixels depend on target theme colors are marked stale, once; - /// untinted source rasters remain byte-for-byte resident. pub fn setThemeIndex(p: *Pardes, index: usize) void { if (index >= themes.len or - (!p.custom_theme_active and p.theme_file_path.get().len == 0 and + (p.custom_theme == null and p.theme_file_path.get().len == 0 and index == @as(usize, p.settings.theme))) return; const target_chrome = ChromeTheme.fromTheme(&themes[index]); if (p.animate_theme_changes) @@ -16022,7 +11328,6 @@ pub const Pardes = struct { p.chrome_animation.snap(target_chrome); if (p.custom_theme) |theme_value| { p.custom_theme = null; - p.custom_theme_active = false; std.zon.parse.free(p.gpa, theme_value); } if (p.theme_file_path.get().len > 0) { @@ -16037,12 +11342,13 @@ pub const Pardes = struct { // ---- render: build the canonical surface ---- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { - file_pane.refreshHighlights(p); + panes.File.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { + const cells = try p.gpa.alloc(Cell, ncells); p.gpa.free(s.cells); - s.cells = try p.gpa.alloc(Cell, ncells); + s.cells = cells; } s.cols = p.screen_w; s.rows = p.screen_h; @@ -16060,43 +11366,10 @@ pub const Pardes = struct { const pane = slot.* orelse continue; try p.renderPane(arena, pane, p.rects[id], id, id == p.active); } - // ---- the transient message row: the pane's last body row ---- - // - // 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. - // Drawn after every pane's body so it lands OVER whatever that row was - // showing, and painted across the whole row — it is the tagline's twin, - // and reading as one strip rather than a stamp on a body line is what - // keeps it from being mistaken for content. Out here rather than at the - // end of renderPane because renderPane returns early for a native PDF - // page and for an image, and a `/` on a PDF is a real search whose - // prompt has to be visible like any other. - // - // Exactly two things can occupy the row and an ARMED PROMPT beats a - // MESSAGE, because they are not the same kind of thing: a message is a - // report of what already happened and the next keystroke wipes it, a - // prompt is what that keystroke is being typed into and it lives until - // Enter or Esc. - // - // ponytail: the row is draw-only. A click on it lands wherever the body - // under it says (tag clicks map to tag_col on the TAG row), so a prompt - // that moved off the tagline cannot be clicked into or swept the way it - // could up there — the keyboard still edits it in full. Upgrade path - // is a hit test here that maps a press on this row to tag_col + the - // marker offset, i.e. the tag row's own mapping with a constant added. for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; const r = p.rects[id]; - // no body row (a one-row pane, or one squeezed out entirely): the - // tag row is not ours to overwrite, so the message just waits. One - // guard for both placements, because the row picked below is the - // last BODY row either way — "the pane has a body row" is the whole - // condition, and it is what keeps r.h - 1 - BOX_H from underflowing - // or landing above the pane. if (r.w <= config.GUTTER or r.h <= BOX_H) continue; - // the same one input model renderPane cut off the tagline; only one - // of the two can ever be armed (a body key arms one, exitTagEdit - // clears both) const prompt_at = pane.promptAt(); const text = if (prompt_at) |at| pane.tagSlice()[@min(at, pane.tag_tail_len)..] @@ -16108,34 +11381,13 @@ pub const Pardes = struct { if (text.len == 0 and !leader_here) continue; const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; - // the pane's last BODY row: its last row outright, or one up from - // that when Tagbottom has taken the last for the tagline. The first - // cut of Tagbottom sent this row to the pane's FIRST instead — the - // far end, symmetric with the tag — which put the prompt you are - // typing as far as the pane allows from the tag you are typing - // into. Beside the tagline is where it is read, so it stays there. const row = if (p.settings.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1; - // In the EDITOR's colours, not the tag bar's: this row is the one - // place the program talks back to you about the buffer you are in, - // and it reads as part of that buffer rather than as another strip - // of chrome. th and not chrome for the same reason a selection - // uses th — it is not attached to any geometry, so it arrives with - // the theme instead of sliding in over the chrome animation. const msg_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = if (th.bg) |c| .{ .rgb = c } else .default, }; - // the WHOLE row, the way the tagline fills its own before printing: - // a message is a section and not a stamp, and print writes only the - // cells it needs — so without the fill the body row shows through - // to the right of a short message and reads as one garbled line. s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg }); if (text.len > 0) _ = s.print(tx, row, tw, text, msg_style); - // The pending SPC leader path, right-aligned. It used to sit on the - // active pane's tagline, where it had to fight the builtins in the - // tail for the same columns; down here it is beside the rest of the - // transient state, and printed AFTER the message so a long one - // loses its last columns rather than hiding what you are typing. if (leader_here) { var ibuf: [16]u8 = @splat(' '); @memcpy(ibuf[1..4], "SPC"); @@ -16147,17 +11399,12 @@ pub const Pardes = struct { const w: u16 = @intCast(iw); if (w < tw) _ = s.print(tx + tw - w, row, w, ibuf[0..iw], msg_style); } - // ...and the cursor follows the text it edits. tag_col is a byte - // offset, so the prompt maps its suffix through display widths; a - // cursor LEFT of the marker is still over the part - // of the tag that stayed on the tagline, and the tag cursor - // renderPane already placed there is the right one. if (id != p.active) continue; const at = prompt_at orelse continue; const prompt0 = (p.tagPrefix(pane) catch continue).len + at; const col = @as(usize, pane.tag_col); if (col >= prompt0) { - const prompt_col = file_pane.displayWidth(text[0..@min(col - prompt0, text.len)]); + const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]); if (prompt_col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert }; } @@ -16166,9 +11413,6 @@ pub const Pardes = struct { // global tagbar: full width, top row s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg }, - // The blank tail is the same visible band as the printed words. - // SDL used to repair this at draw time by special-casing row zero, - // but native hosts consume the role carried by each cell. .font_role = .tagline, }); var tb_buf: [1200]u8 = undefined; @@ -16177,16 +11421,11 @@ pub const Pardes = struct { .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); - // The topbar is executable chrome, just like a button row. Pane text - // already previews the exact operand a Look would use; row zero has - // no Pane and used to fall through that machinery without any pointer - // feedback at all. Paint the same word the click dispatcher resolves, - // immediately, while leaving whitespace inert. if (p.pointer_inside and p.hover_row < TOPBAR_H) { const bar = p.topbar(&tb_buf); - if (wordBoundsAtCol(bar, file_pane.rawAtDisplay(bar, p.hover_col))) |bounds| { - var col = file_pane.rawDisplayCol(bar, bounds.lo); - const hi = file_pane.rawDisplayCol(bar, bounds.hi); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo); + const hi = panes.File.rawDisplayCol(bar, bounds.hi); while (col < hi and col < s.cols) : (col += 1) { const cell = s.at(@intCast(col), 0); cell.default = false; @@ -16195,32 +11434,17 @@ pub const Pardes = struct { } } } - // the topbar's cursor, if it has the keyboard. AFTER the pane loop on - // purpose: there is exactly one Surface cursor and the bar's must beat - // the active pane's. Always a block — the bar has no insert mode. if (p.topbar_col) |c| { - const col = file_pane.rawDisplayCol(p.topbar(&tb_buf), c); + const col = panes.File.rawDisplayCol(p.topbar(&tb_buf), c); if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = false }; } - // resize-handle hint / drag previews: a dash overlay that keeps the - // underlying colors (border drags + hover), or the move indicator. A - // drag holds the coordinates of the last mouse event, so a resize - // mid-drag (tiling WM, font-size change) can leave them off the new - // surface — every arm below checks before it draws. switch (p.drag) { .border_v => |d| { if (d.cur_x < s.cols) { var row: u16 = TOPBAR_H; while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); } - // a corner lights BOTH splits, which is the whole tell that you - // grabbed the crossing and not an edge. The horizontal half runs - // across ITS OWN column and stops at the vertical preview rather - // than at that column's current edge, so the two dashes stay - // joined at the cell under the mouse: through the handle for a - // left-column corner, butting into it from the right neighbour - // for a right-column one. if (d.corner) |k| if (d.cur_y < s.rows) { var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; @@ -16232,7 +11456,7 @@ pub const Pardes = struct { while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }, .move => |d| if (d.cur_x < s.cols) { - if (p.movePlacement(d.id, d.cur_x, d.cur_y)) |placement| { + if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { var col: u16 = p.col_x[placement.preview_col]; while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); @@ -16257,12 +11481,6 @@ pub const Pardes = struct { while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } } - // the containment test above can only ever light the column the - // hovered cell is IN, and a v handle is its LEFT column's cell. - // So when that column has no seam here but its right neighbour - // does, light the neighbour's: that is the corner a press would - // take (Drag.border_v), and a grabbable crossing has to be - // visible before it is grabbed. for (0..p.ncol -| 1) |cn| { if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; if (p.seamIdxAt(cn, p.hover_row) != null) continue; @@ -16283,19 +11501,15 @@ pub const Pardes = struct { var sb_total: usize = undefined; if (at.file) |*f| { sb_off = f.scroll; - sb_total = file_pane.nlines(p.gpa, f); + sb_total = panes.File.nlines(p.gpa, f); } else if (at.pdfPage()) |page| { sb_off = page; sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { - const sb = term_pane.scrollbar(at); + const sb = panes.Terminal.scrollbar(at); sb_off = sb.offset; sb_total = sb.total; } - // The face belongs to the SHELL, so the core can only name the one - // it was asked for; nothing has asked when this is empty and the - // shell is still in whatever it booted in. Clamped because a name - // is a file stem and a path component can be as long as a path. const effective_font = p.settings.font.effective_name.get(); const font_name = if (effective_font.len == 0) "default" else effective_font; var ov_buf: [256]u8 = undefined; @@ -16334,60 +11548,17 @@ pub const Pardes = struct { } } }; - p.submitted_panel_layout = @splat(null); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - p.submitted_panel_layout[id] = .{ - .serial = pane.serial, - .box = panelBox(p.rects[id]), - }; - } - p.submitted_panel_layout_ready = true; - // The old grid stays frozen for the transition, while terminals, - // watches, hover chrome, and other live data may change the new grid - // between samples. Rebuild the cheap byte mask every frame so the - // published (old, new, changed) triple is always coherent. - if ((p.panel_diff_pending or p.panel_diff_ready) and !try p.preparePanelDiff()) { - // There was no successfully presented same-sized old grid. A - // content effect cannot guess one: snap this transition rather - // than animating uninitialised/stale cells. - for (&p.panel_tracks) |*slot| { - const track = slot.* orelse continue; - if (track.effect.needsPreviousGrid()) slot.* = null; - } - p.nclosing_panel_tracks = 0; - p.panel_diff_pending = false; - p.panel_diff_ready = false; - } - if (p.panel_diff_ready) { - s.previous_cells = p.presented_cells; - s.cell_diffs = p.panel_cell_diffs; - } - // Moving panes first, then new panes, then inert closing tombstones on - // top. The rule is `panel_animation.paintOrder` so that it has exactly - // one definition: every host receives this same deterministic dense - // record set and none of them needs to sort it again. - s.npanel_tracks = panel_animation.paintOrder( - &p.panel_tracks, - p.closing_panel_tracks[0..p.nclosing_panel_tracks], - &s.panel_tracks, - ); + try p.presentation.submit(p, s); return p.composeAsciiTransitions(arena, s); } - // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN - /// Lazily copy the canonical grid only when an active core-composed - /// character track still shows something other than its final glyph. The - /// returned Surface is the sole backend boundary, so every shell rasterizes - /// the exact same intermediate characters and style-only/non-ASCII changes - /// pass through once. fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { _ = p; if (!canonical.hasPanelDiff()) return canonical; var presented: ?*Surface = null; for (canonical.panelTracks()) |track| { if (!track.effect.composedByCore() or track.phase == .closing) continue; - const area = panel_animation.CellArea.of(track.to); + const area = layout.CellArea.of(track.to); const col_end = @min(canonical.cols, area.x0 +| area.cols); const row_end = @min(canonical.rows, area.y0 +| area.rows); var row: u16 = area.y0; @@ -16411,15 +11582,10 @@ pub const Pardes = struct { return presented orelse canonical; } - /// The character one cell presents this frame, or null when the canonical - /// cell is already the honest answer. PanelAscii walks the semantic byte - /// distance of a *changed* cell; the motion effects carry every glyph in - /// the pane, because text flying in from a screen edge has to bring its - /// unchanged glyphs along with it. fn composedCell( canonical: *const Surface, - track: panel_animation.Track, - area: panel_animation.CellArea, + track: layout.Track, + area: layout.CellArea, col: u16, row: u16, index: usize, @@ -16431,13 +11597,10 @@ pub const Pardes = struct { }; const byte = diff.byteAt(track.frame); if (byte == diff.to) return null; - // Frame zero is the exact old cell. Once a byte is walking, the - // semantic destination owns presentation style, and at the endpoint - // the untouched canonical cell wins bit-for-bit instead. if (track.frame == 0) return canonical.previous_cells[index]; return withByte(canonical.cells[index], byte); } - return switch (panel_animation.charSource(track, col - area.x0, row - area.y0, area)) { + return switch (layout.charSource(track, col - area.x0, row - area.y0, area)) { .old => canonical.previous_cells[index], .byte => |byte| withByte(canonical.cells[index], byte), // Churn belongs on the glyph, not on the pane's empty space, and it @@ -16480,50 +11643,6 @@ pub const Pardes = struct { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } - fn preparePanelDiff(p: *Pardes) !bool { - const count = p.surface.cells.len; - if (!p.presented_cells_valid or - p.presented_cells_cols != p.surface.cols or - p.presented_cells_rows != p.surface.rows or - p.presented_cells.len != count) return false; - if (p.panel_cell_diffs.len != count) { - const next = try p.gpa.alloc(PanelCellDiff, count); - if (p.panel_cell_diffs.len > 0) p.gpa.free(p.panel_cell_diffs); - p.panel_cell_diffs = next; - } - for (p.panel_cell_diffs, p.presented_cells, p.surface.cells) |*diff, *old, *new| - diff.* = PanelCellDiff.between(old, new); - // The fixed Track ABI already carried two padding bytes after frame. - // They now hold the core-computed ASCII duration: exactly one sample - // beyond the longest eased walk in this pane, so there is neither a - // forced endpoint jump nor a long invisible tail for nearby glyphs. - for (&p.panel_tracks) |*slot| { - const track = if (slot.*) |*track| track else continue; - if (track.effect != .ascii) continue; - var longest: u16 = 1; - var row: u16 = 0; - while (row < p.surface.rows) : (row += 1) { - var col: u16 = 0; - while (col < p.surface.cols) : (col += 1) { - if (!boxContainsCell(track.to, col, row)) continue; - const index = @as(usize, row) * p.surface.cols + col; - switch (p.panel_cell_diffs[index]) { - .ascii => |diff| longest = @max(longest, diff.frameCount()), - .unchanged, .visual => {}, - } - } - } - track.frame_count = @max(track.frame_count, longest); - } - p.panel_diff_pending = false; - p.panel_diff_ready = true; - return true; - } - // EFFECT_CODE_ASCII_COMPOSITOR_END - - /// Paint a selection expressed in the coordinate space used by pointer - /// gestures. Both the persistent mouse selections and the delayed Look - /// preview come through this one clipping/mapping path. fn paintPointerSelection( s: *Surface, pane: *const Pane, @@ -16532,7 +11651,7 @@ pub const Pardes = struct { tw: u16, tag_y: u16, body_y: u16, - sl: Sel, + sl: Pane.Sel, bg: [3]u8, fg: ?[3]u8, ) void { @@ -16543,13 +11662,10 @@ pub const Pardes = struct { var row: u16 = 0; while (row < r.h) : (row += 1) { if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; - // A Sel row is independent of Tagbottom: zero is the tag and - // BOX_H upward is the body. This is the render-side inverse of - // pointerTextSelection. const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H; - // File line numbers occupy PREFIX_W only in the body. The tag is + // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. - var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, config.PREFIX_W)) else c0; + var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else c0; while (col <= c1 and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), sy); cell.default = false; @@ -16568,21 +11684,9 @@ pub const Pardes = struct { const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter const tw = r.w - config.GUTTER; - // The pane's two anchor rows, computed once: the tagline's, and the - // 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.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.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 - // scrollbar's second column. `.default` means the host terminal's own - // background, which is what a themeless dark pane wants. const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; - // ...and the same background as a colour to do arithmetic on, which the - // dimmed selections below need. A theme with a null bg cannot say what - // the host's own cell looks like, so its tag bar stands in — the - // substitution pdf_pane.tintColors already makes. const page_rgb = th.bg orelse th.tag_bg; // text area resets to terminal-default cells (vaxis clear semantics); @@ -16595,31 +11699,6 @@ pub const Pardes = struct { if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); } - // the layout box: the pane's MODE, one character, in the gutter cells - // of the tag row. Same box you drag a pane by — the whole GUTTER is - // still painted and the `.move` press hit-test in handleMouse is - // untouched — it just carries the one piece of state that used to cost - // four columns of every tagline. - // The glyph goes in column 0, directly above the scrollbar's ink - // column below it, so a pane's chrome reads as one line down its left - // edge; column 1 stays plain colour, and that blank half is what keeps - // the thing reading as a BOX rather than as a letter someone dropped - // in the gutter. - // - // The mode shown is the BODY's. A tag edit hijacks pane.mode to insert - // (tags are always insert; the real one is parked in tag_mode), and a - // badge that flipped every time you clicked a tagline would be - // reporting the tag's mode on the pane's box. Reading tag_mode also - // makes the parked value VISIBLE: a terminal being tag-edited still - // shows `$`, which is exactly the invariant ttyclick.snap exists for. - // - // The ink is picked off the box's own brightness instead of being - // named in the theme, because box/box_dim come from a generated - // theme's cursor and selection colours — light on a light theme — and - // a fixed white would vanish there. Rec.601-ish integer weights. - // ponytail: two colours, black or white, chosen at a fixed threshold. - // Upgrade to the theme's own fg/bg pair when a theme turns up whose - // box wants a tint rather than a contrast. const box_bg = if (active) chrome.box else chrome.box_dim; const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; @@ -16647,44 +11726,23 @@ pub const Pardes = struct { .font_role = .tagline, }); const tag = try p.tagText(arena, pane); - // An armed input — `/`, Find, Grep, Rename, WsSymbols, Select/Split, - // `|` — is still TYPED INTO the tag tail: same buffer, same offsets, - // same one-line modal editor, same Enter and same Esc. Only where it is - // DRAWN moved. It is cut off the tagline here and printed on the pane's - // message row instead (see render), so the builtins in the tail stay - // readable while you type instead of being pushed off the right edge by - // a long pattern. - // - // The prompt offset is in the tail while tag_col is in the rendered - // tag, so add the live prefix length when clipping the editable span. const prompt_at = pane.promptAt(); const tag_cut = if (prompt_at) |at| @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at) else tag.len; _ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style); - // Paint tag hover before every real tag selection. This common point - // is above the native-PDF/image early returns, so their tag operands - // are no longer hidden, while an explicit sweep remains authoritative. if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } - // tag char selection highlight (helix v/x, or a tagline sweep), - // inclusive [lo, hi], mapped from byte offsets to display cells. - // - // Every selection on screen paints in the LIVE theme (`th`) and not in - // `chrome`: a highlight is not attached to any geometry, it appears - // under the range you just swept, so it has to arrive with the theme - // the way syntax colours do rather than slide in over the chrome - // animation's frames. if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); - var col = file_pane.rawDisplayCol(tag, b.lo); + var col = panes.File.rawDisplayCol(tag, b.lo); const hi = modal.nextGrapheme(tag, b.hi); - const end = file_pane.rawDisplayCol(tag, hi) -| 1; + const end = panes.File.rawDisplayCol(tag, hi) -| 1; while (col <= end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); cell.default = false; @@ -16695,21 +11753,15 @@ pub const Pardes = struct { // cursor while editing the tag: byte offset mapped to its display cell if (active and pane.tag_edit) { // bar while typing, block for `:` normal mode (same rule as a body) - const col = file_pane.rawDisplayCol(tag, pane.tag_col); + const col = panes.File.rawDisplayCol(tag, pane.tag_col); if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.mode == .insert }; } - // A native PDF page uses the same backend-neutral pixel attachment as - // an image. Without native pixels it deliberately falls through: its - // extracted text projection becomes an ordinary readable body. if (comptime pdf_enabled) - if (hasPdf(pane) and pdf_pane.draw(p, pane, r, id, tx, tw)) return; + if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; - // image pane: the picture fills the body — petscii glyph art into the - // cells, or a pixel attachment the shell places (kitty). Plain - // thumbless gutter so it reads like any other pane. if (pane.image) |*iv| { - image_pane.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H); + panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); @@ -16739,55 +11791,21 @@ pub const Pardes = struct { } } - // Coloring is one algorithm per pane, chosen by title (colorAlgo): the - // terminal projects its own ANSI, a file lays tree-sitter or diff - // shading over its content. source and diff share this pass because - // both feed f.highlights, which refreshHighlights filled with whichever - // this same choice named. Order is load-bearing — gutter, recolor, then - // wrap markers; the selection/cursor passes below win over all three. const tz_color = tracy.zone(@src(), "paneRecolor"); switch (pane.colorAlgo()) { - // 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), + .tty => if (p.settings.colors) panes.Terminal.recolorAnsi(p, pane, r, tx, tw, body_h, body), // `.locations` joins them because it feeds the same `f.highlights` // — only the pass that FILLED it differs (refreshHighlights). .source, .diff, .locations => { const f = &pane.file.?; - file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); - if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); - file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); + panes.File.drawGutter(p, pane, r, tx, tw, body_h, active); + if (p.settings.colors) panes.File.recolorSyntax(p, pane, f, r, tx, tw, body_h); + panes.File.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); }, .none => {}, } tz_color.end(); - // mouse selections (pane-local coords), one pass per button — later - // buttons win on overlap. A left .done stays highlighted after release; - // middle/right .done are transient (they fire their action on release). - // - // Which button drew a sweep is worth seeing, so each gets the theme's - // selection tinted toward one of the theme's own syntax accents: three - // colours that are visibly not each other on a dark theme and on a - // light one, without asking a theme to name three more. A QUARTER of - // the accent, which is the mix of a mix — at a half, an accent as - // bright as the theme's text lands on top of sel_fg and the selected - // text stops being readable on a couple of dozen generated themes. - // `num` gives way to `comment` when a theme paints numbers and strings - // alike — the shipped helix theme does, 24 of the generated ones do — - // because two buttons landing on one colour is the whole thing this - // avoids. - // ponytail: 20 of the 228 themes colour two of these three scopes the - // same anyway and still collapse two buttons. Upgrade path is to walk - // the theme for a third colour far enough from the other two, rather - // than naming the scopes here. const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; const sel_btn = [3][3]u8{ mix(th.sel_bg, mix(th.sel_bg, th.kw)), @@ -16800,7 +11818,7 @@ pub const Pardes = struct { // win over this quiet affordance. const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); if (preview.file_word) |word| - file_pane.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) + panes.File.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| if (@max(sel.r0, sel.r1) >= BOX_H) paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); @@ -16826,12 +11844,9 @@ pub const Pardes = struct { const hi = @max(pane.msel.r0, pane.msel.r1); var row: u16 = BOX_H; // never paint the tag row while (row < r.h) : (row += 1) { - // walked by SCREEN row and asked what LINE each one shows, - // because a wrapped line is several rows. wrapAt degenerates to - // `off + row` when nothing wrapped, which is what this was. const ar = pane.wrapAt(@as(i32, row) - @as(i32, BOX_H)).line; if (ar < lo or ar > hi) continue; - var col: u16 = if (pane.file != null) config.PREFIX_W else 0; + var col: u16 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; while (col < tw) : (col += 1) { const cell = s.at(tx + col, body_y + row - BOX_H); cell.default = false; @@ -16840,29 +11855,17 @@ pub const Pardes = struct { } } } - // modal char selection (helix `v`): stream-shaped anchor..head - // highlight. The EXTRA cursors are the same shape drawn dimmer, and - // each of them also paints its own cursor cell bright: there is one - // hardware cursor and the primary owns it, so a secondary cursor has - // to be a cell colour or it is invisible. - // The extra cursors show in INSERT mode too — that is exactly when you - // need to see where your typing is landing — while the primary's - // selection highlight stays normal-mode-only, as it always was. - // ...and an armed `s`/`S` shows the primary WHATEVER shape it is, even - // though the pane is in insert mode for the tag and even when the match - // is a single cell: the hardware cursor is off in the tag, so a preview - // that leans on it shows every match except the one you are on. const preview = selRegexArmed(pane) != null; const show_prim = (pane.mode == .normal and pane.vsel.active) or preview or (modal_hover and pane.vsel.active); const show_extra = pane.mode != .tty and pane.nsel > 0; if (show_prim or show_extra) { - const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0; + const vpfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; var si: usize = 0; while (si <= pane.nsel) : (si += 1) { const primary = si == pane.nsel; if (if (primary) !show_prim else !show_extra) continue; - const sr = if (primary) SelRange{ + const sr = if (primary) Pane.SelRange{ .row = pane.cur_row, .col = pane.cur_col, .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, @@ -16870,27 +11873,12 @@ pub const Pardes = struct { } else pane.sels[si]; const bnd = cellBounds(sr); const hover_only = primary and modal_hover and pane.mode != .normal and !preview; - // an extra cursor is the selection colour turned down: the - // highlight pulled halfway back to the page, so the primary is - // the one that reads as "here" at a glance. On a light theme - // that dims toward white rather than toward black, which is the - // same statement. The INK stays put — helix's two selection - // styles differ in their background too, and dimming both ends - // walks the text and its cell toward each other until neither - // is readable on the themes whose selection is already close to - // the page. const bg = if (hover_only) mix(page_rgb, mix(page_rgb, th.sel_bg)) else if (primary) th.sel_bg else mix(th.sel_bg, page_rgb); - // ...and this walks SCREEN rows too, asking the map which line - // and which byte column of it each one shows. Unwrapped that is - // `off + vr` / `hscroll`, i.e. the arithmetic this was, and it - // is also the cheaper loop: a linewise selection over a whole - // file used to iterate once per LINE to reject all but a - // screenful of them. var vr: i32 = 0; while (vr + @as(i32, BOX_H) < @as(i32, r.h)) : (vr += 1) { const w = pane.wrapAt(vr); @@ -16898,16 +11886,16 @@ pub const Pardes = struct { const visible_line = modal.lineSlice(body, @intCast(vr)); const cstart: i32 = if (w.line == bnd.lo_row) (if (pane.file != null) - file_pane.displayOffset(pane, w.line, w.at, bnd.lo_col) + panes.File.displayOffset(pane, w.line, w.at, bnd.lo_col) else - file_pane.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx + panes.File.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx else vpfx; const cend: i32 = if (w.line == bnd.hi_row) (if (pane.file != null) - file_pane.displayEndOffset(pane, w.line, w.at, bnd.hi_col) + panes.File.displayEndOffset(pane, w.line, w.at, bnd.hi_col) else - file_pane.lineDisplayEndOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.hi_col)))) + vpfx + panes.File.lineDisplayEndOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.hi_col)))) + vpfx else @as(i32, tw) - 1; var col: i32 = @max(cstart, vpfx); @@ -16923,9 +11911,9 @@ pub const Pardes = struct { const cw = pane.wrapRow(sr.row, sr.col); const crow = cw.row + @as(i32, BOX_H); const ccol = (if (pane.file != null) - file_pane.displayOffset(pane, sr.row, cw.at, sr.col) + panes.File.displayOffset(pane, sr.row, cw.at, sr.col) else - file_pane.lineDisplayOffset( + panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cw.row))), @intCast(@max(0, cw.at)), @intCast(@max(0, sr.col)), @@ -16942,22 +11930,19 @@ pub const Pardes = struct { // cursor: tracks the shell cursor until pinned by a click or a key // (the tag cursor above wins while the tag is focused) if (active and !pane.tag_edit) { - const cur = term_pane.gridCursor(pane); if (pane.mode != .tty) { - const goff = term_pane.gridOffset(pane); + const cur = panes.Terminal.gridCursor(pane); + const goff = panes.Terminal.gridOffset(pane); const crow = if (pane.cur_pinned) pane.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x)); - // which ROW of a wrapped line the cursor is on, and which byte - // column that row starts at — `off`/`hscroll` when nothing - // wrapped, so this is the same two lines it always was const cwp = pane.wrapRow(crow, ccol); const prow = cwp.row + @as(i32, BOX_H); // Files store source-byte columns; the Surface stores display // cells, so account for every expanded tab before the cursor. const cx = if (pane.file != null) - @as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, crow, cwp.at, ccol) + @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, crow, cwp.at, ccol) else if (pane.cur_pinned) - file_pane.lineDisplayOffset( + panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cwp.row))), @intCast(@max(0, cwp.at)), @intCast(@max(0, ccol)), @@ -16966,35 +11951,17 @@ pub const Pardes = struct { ccol; if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - BOX_H)), .bar = pane.mode == .insert }; - } else if (cur.y + BOX_H < r.h and cur.x < tw) { - s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y, .bar = pane.mode == .insert }; + } else if (panes.Terminal.visibleCursor(pane)) |cur| { + if (cur.y + BOX_H < r.h and cur.x < tw) + s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; } } - // gutter below the tag row: scrollbar track + thumb. (The move box on - // the tag row itself is drawn up with the tag, since it now carries - // the mode and belongs with the rest of that row.) - // - // The bar is ONE column: column 1 is the track/thumb, column 2 is the - // pane's own background. That second fill is not optional — render() - // pre-fills the whole surface with scroll_track so the gaps between - // panes read as chrome, so a column left unpainted here keeps the - // track colour and the bar looks two wide again. - // - // The move box above stays the full GUTTER even though only half of it - // has ink in it. It is a target you drag, not a gauge you read, and its - // whole width is the affordance — the step where the box ends and the - // narrower bar begins is the one place on screen that says those are - // two different pieces of chrome. - // - // Nothing here touches layout or hit-testing: the gutter is still - // config.GUTTER columns and the click handlers still scroll on any of - // them, so the blank column is still live. Only the ink narrowed. if (r.h > BOX_H) { s.fill(r.x, body_y, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(r.x + 1, body_y, 1, r.h - BOX_H, .{ .bg = pane_bg }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ - .total = file_pane.nlines(p.gpa, f), + .total = panes.File.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, } else if (pane.pdfPage()) |page| .{ @@ -17002,7 +11969,7 @@ pub const Pardes = struct { .offset = page, .len = 1, } else blk: { - const gsb = term_pane.scrollbar(pane); + const gsb = panes.Terminal.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; const track_h: usize = r.h - BOX_H; @@ -17033,20 +12000,14 @@ pub const Pardes = struct { return true; } - /// The pane body as text. Image: blank rows (the picture draws over it). - /// File: line-numbered content from f.scroll. - /// Terminal: viewport rows, padded to the grid height, prompt rows blanked - /// outside tty mode (OSC 133), the edit buffer standing in for the shell - /// rows it covers — which is where a buffer holding more lines than those - /// rows pushes the output below it down the screen. fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { if (pane.image != null) { const buf = try arena.alloc(u8, pane.rows -| 1); @memset(buf, '\n'); return buf; } - if (hasPdf(pane)) { - if (comptime pdf_enabled) return pdf_pane.visibleText( + if (pane.hasPdf()) { + if (comptime pdf_enabled) return panes.Pdf.visibleText( &pane.pdf.?, p.pdf_gpa, arena, @@ -17054,17 +12015,14 @@ pub const Pardes = struct { ); unreachable; } - if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.settings.wrap); - return term_pane.bodyText(arena, pane); + if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); + return panes.Terminal.bodyText(arena, pane); } }; test "Esc alternates between two panes of the SAME kind" { if (platform == .web) return; const gpa = std.testing.allocator; - // An ABSOLUTE boot path, the way main.zig resolves argv: a file pane's - // directory is its path's dirname, and a relative one leaves nothing for - // the look below to resolve against. var cwdbuf: [4096]u8 = undefined; const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return))); var pathbuf: [4096]u8 = undefined; @@ -17074,9 +12032,6 @@ test "Esc alternates between two panes of the SAME kind" { defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 40 } }); const a = p.active; - // A second FILE. This is the case the doc<->terminal hop Esc used to run - // could not do AT ALL: both panes are docs, so it had nothing of "the other - // kind" to reach and Esc did nothing. p.runBuiltin(.Look, a, "", "build.zig"); p.sync(); const b = p.active; @@ -17093,10 +12048,6 @@ test "Esc alternates between two panes of the SAME kind" { test "a boot file that will not open boots an errors pane rather than failing the launch" { if (platform == .web) return; const gpa = std.testing.allocator; - // A path `look.resolve` would have accepted and `readFile` cannot open. - // Spelled as a name that is simply not there rather than by chmod-ing a - // fixture to 000, because the second answers differently when the suite - // runs as root and this must fail the same way everywhere. const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, @@ -17107,7 +12058,7 @@ test "a boot file that will not open boots an errors pane rather than failing th const pane = p.panes[0].?; const f = pane.file.?; - try std.testing.expectEqual(output_pane.Origin.errors, f.output.?.from); + try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // The reason IN WORDS, not an error name: `PermissionDenied` on a screen // is jargon, and the whole point of this pane is that a human reads it. try std.testing.expect(std.mem.indexOf(u8, f.content, "cannot open") != null); @@ -17129,26 +12080,99 @@ test "argv naming nothing boots an errors pane rather than failing the launch" { // shell a bare `pardes` boots: the answer is the whole screen. try std.testing.expectEqual(@as(u8, 1), p.ncol); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); - try std.testing.expectEqual(@as(usize, 0), p.col_terms[0][0]); + try std.testing.expectEqual(@as(usize, 0), p.col_panes[0][0]); try std.testing.expectEqual(@as(usize, 0), p.active); for (p.panes[1..]) |slot| try std.testing.expect(slot == null); const pane = p.panes[0].?; try std.testing.expect(!pane.isTerminal()); const f = pane.file.?; - try std.testing.expectEqual(output_pane.Origin.errors, f.output.?.from); + try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // ...and it says what happened AND what was asked for. The word as typed, // which is the half a bare "not found" leaves out. try std.testing.expect(std.mem.indexOf(u8, f.content, "not found") != null); try std.testing.expect(std.mem.indexOf(u8, f.content, "notes/tdoo.md") != null); - // THE PANE'S DIRECTORY IS THE LAUNCH DIRECTORY, asserted through the path - // because that is what `paneDir` reads: it decides where a `Grep` from - // this pane walks, where its `Newtty` spawns, and what its `Save` - // prefills. Passing "" here put the pane at `/+Errors`, i.e. rooted every - // one of those at `/`. try std.testing.expectEqualStrings("/home/pardes-test/work/+Errors", f.path); } +test "raw terminal cursor obeys visibility without hiding modal and tag cursors" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + p.update(.{ .output = .{ .pane = 0, .bytes = "abc" } }); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const visible = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + const rect = p.rects[0]; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H; + try std.testing.expectEqual(rect.x + config.GUTTER + 3, visible.x); + try std.testing.expectEqual(body_y, visible.y); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + pane.mode = .normal; + pane.cur_pinned = true; + pane.cur_row = 0; + pane.cur_col = 1; + const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x); + try std.testing.expectEqual(body_y, modal_cursor.y); + p.enterTagEdit(pane, 0); + const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y); + Pardes.exitTagEdit(pane); + pane.mode = .tty; + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25h\x1b[?1049h\x1b[2;5H" } }); + const alternate = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(rect.x + config.GUTTER + 4, alternate.x); + try std.testing.expectEqual(body_y + 1, alternate.y); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049l\x1b[?25h" } }); + const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(visible.x, returned.x); + try std.testing.expectEqual(visible.y, returned.y); +} + +test "raw terminal cursor follows its active row only while that row is visible" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + for ([_]bool{ false, true }) |tag_bottom| { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.settings.tag_bottom = tag_bottom; + const pane = p.panes[0].?; + pane.mode = .tty; + for (0..60) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "row\r\n" } }); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3;4H" } }); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const live = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + const rect = p.rects[0]; + const body_y = if (tag_bottom) rect.y else rect.y + BOX_H; + try std.testing.expectEqual(rect.x + config.GUTTER + 3, live.x); + try std.testing.expectEqual(body_y + 2, live.y); + + panes.Terminal.scrollGrid(pane, -1); + const scrolled = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(live.x, scrolled.x); + try std.testing.expectEqual(live.y + 1, scrolled.y); + panes.Terminal.scrollGrid(pane, -@as(i32, pane.rows)); + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + panes.Terminal.followOutput(pane); + const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(live.x, returned.x); + try std.testing.expectEqual(live.y, returned.y); + } +} + test "Esc back into a tty leaves its view at the prompt" { if (platform == .web) return; const gpa = std.testing.allocator; @@ -17168,7 +12192,7 @@ test "Esc back into a tty leaves its view at the prompt" { try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); - try std.testing.expectEqual(Mode.tty, sp.mode); + try std.testing.expectEqual(Pane.Mode.tty, sp.mode); // tty mode follows output to the bottom, so a screenful and a half of it // rides the view down and leaves that pin far up in the scrollback. @@ -17177,7 +12201,7 @@ test "Esc back into a tty leaves its view at the prompt" { p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); - const live = sp.vt.screens.active.pages.scrollbar().offset; + const live = sp.terminal.?.vt.screens.active.pages.scrollbar().offset; try std.testing.expect(live > 0); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // out to the doc @@ -17186,16 +12210,13 @@ test "Esc back into a tty leaves its view at the prompt" { p.sync(); try std.testing.expectEqual(shell, p.active); - // The prompt is still on screen. Restoring the stale pin used to yank the - // view up to scrollback row 0, where it sat until the next keystroke's echo - // scrolled it back down — "type something and the tty jumps to the prompt". - try std.testing.expectEqual(live, sp.vt.screens.active.pages.scrollbar().offset); + try std.testing.expectEqual(live, sp.terminal.?.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" }); + const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/memory.zig" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc @@ -17250,21 +12271,18 @@ test "Shift-Esc in tty hops to the doc and leaves the shell in tty" { // Shift-Esc still gets you IN, exactly as the configured Ctrl-key does. const shift_esc: Key = .{ .cp = Key.escape, .shift = true }; p.update(.{ .key = shift_esc }); - try std.testing.expectEqual(Mode.tty, shell_pane.mode); + try std.testing.expectEqual(Pane.Mode.tty, shell_pane.mode); - // ...and out of tty it is Escape-in-normal-mode instead of a toggle: the - // doc takes focus and the shell KEEPS its tty mode, so coming back lands - // in the program you left rather than in normal mode on top of it. p.update(.{ .key = shift_esc }); try std.testing.expect(p.active != shell); try std.testing.expect(!p.panes[p.active].?.isTerminal()); - try std.testing.expectEqual(Mode.tty, shell_pane.mode); + try std.testing.expectEqual(Pane.Mode.tty, shell_pane.mode); // The configured Ctrl-key is now the only thing that leaves tty in place. p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // back to the shell try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); - try std.testing.expectEqual(Mode.normal, shell_pane.mode); + try std.testing.expectEqual(Pane.Mode.normal, shell_pane.mode); try std.testing.expectEqual(shell, p.active); } @@ -17309,9 +12327,6 @@ test "hopping between two panes does not grow the jump stack" { } try std.testing.expectEqual(depth, p.njumps); - // The collapse must not eat history: Back still walks OUT of the ping-pong - // to the place before it, which is what makes the entry a cursor move - // rather than a deletion. const before = p.active; p.runBuiltin(.Back, p.active, "", null); p.sync(); @@ -17336,10 +12351,6 @@ test "only the SPC clipboard commands cross to the system clipboard" { p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const pane = p.panes[0].?; - // An ordinary yank fills the DEFAULT REGISTER and asks the shell for - // nothing. This is the whole helix split, and the bug it closes: before - // it, every y/d/c mirrored out, so deleting one character threw away - // whatever the desktop was holding. _ = drainedEffect(p, .set_clipboard); p.update(.{ .key = .{ .cp = 'y' } }); try std.testing.expect(p.yank != null and p.yank.?.len > 0); @@ -17354,9 +12365,6 @@ test "only the SPC clipboard commands cross to the system clipboard" { const yanked = p.yank orelse return error.MissingYank; try std.testing.expect(drainedEffect(p, .set_clipboard)); - // `SPC p` cannot read the clipboard itself: it ASKS, and the answer comes - // back as an ordinary paste event whenever (or never — a terminal may - // refuse the OSC 52 read, which is a no-op and not a hang). const before = pane.file.?.content.len; p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'p' } }); @@ -17412,13 +12420,10 @@ test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { _ = drainWrites(p, &buf); const pane = p.panes[0].?; - term_pane.enterTty(p, 0); - try std.testing.expectEqual(Mode.tty, pane.mode); + panes.Terminal.enterTty(p, 0); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); p.setYank("one\ntwo"); - // Ctrl-V types the DEFAULT REGISTER at the program. Unbracketed, so the - // newline becomes Enter's \r — a raw \n would run `one` and leave `two` - // half-typed. p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); try std.testing.expectEqualStrings("one\rtwo", drainWrites(p, &buf)); // and it asked the desktop for nothing on the way @@ -17426,13 +12431,13 @@ test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { // Under mode 2004 the same keystroke brackets instead, which is what stops // readline from RUNNING a multi-line paste. - pane.vt.modes.set(.bracketed_paste, true); + pane.terminal.?.vt.modes.set(.bracketed_paste, true); p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); try std.testing.expectEqualStrings("\x1b[200~one\ntwo\x1b[201~", drainWrites(p, &buf)); // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and the // answer lands at the program rather than in an edit buffer. - pane.vt.modes.set(.bracketed_paste, false); + pane.terminal.?.vt.modes.set(.bracketed_paste, false); p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); try std.testing.expect(p.clip_pending != null); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); @@ -17452,7 +12457,7 @@ test "an unasked desktop paste reaches a tty pane's program, not its buffer" { _ = drainWrites(p, &buf); const pane = p.panes[0].?; - term_pane.enterTty(p, 0); + panes.Terminal.enterTty(p, 0); // No request behind it: the window manager's own paste, or SDL answering a // Ctrl-Shift-V the desktop handled. It still has to reach the shell. p.update(.{ .paste = "ls -la" }); @@ -17470,12 +12475,8 @@ test "a paste larger than the effect ring reaches the program whole and in order const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - term_pane.enterTty(p, 0); + panes.Terminal.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)); @@ -17487,7 +12488,7 @@ test "a paste larger than the effect ring reaches the program whole and in order test "a bracketed paste larger than the ring still closes its bracket" { if (platform == .web) return; - if (comptime !term_pane.enabled) return; + if (comptime !panes.Terminal.enabled) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -17495,14 +12496,10 @@ test "a bracketed paste larger than the ring still closes its bracket" { const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - term_pane.enterTty(p, 0); + panes.Terminal.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].?)); + try std.testing.expect(panes.Terminal.bracketedPaste(p.panes[0].?)); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'z'); @@ -17522,15 +12519,12 @@ test "pasted bytes still queued at shutdown are freed, not leaked" { const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - term_pane.enterTty(p, 0); + panes.Terminal.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); } @@ -17555,14 +12549,14 @@ test "leaving tty hides the prompt and keeps the command typed at it" { const rowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { - const rows = term_pane.shellRows(pp, pane) catch return null; + const rows = panes.Terminal.shellRows(pp, pane) catch return null; for (rows) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; } }.at; const bodyRowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { - const body = term_pane.bodyText(pp.scratch.allocator(), pane) catch return null; + const body = panes.Terminal.bodyText(pp.scratch.allocator(), pane) catch return null; var it = std.mem.splitScalar(u8, body, '\n'); while (it.next()) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; @@ -17577,18 +12571,12 @@ test "leaving tty hides the prompt and keeps the command typed at it" { bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); - // Out of tty the prompt goes and the command stays — LEFT-HUGGED, so it - // lines up with the output below instead of sitting in a bay of blanks - // where the prompt used to be. sp.mode = .normal; try std.testing.expectEqualStrings( "grep -rn TODO src/", bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); - // The MOTION SURFACE cuts either way, and deliberately: it is what the - // cursor moves over, and in tty mode nothing moves over it — the keys all - // belong to the program. p.shell_rows.stale = true; try std.testing.expectEqualStrings( "grep -rn TODO src/", @@ -17624,7 +12612,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" p.shell_rows.stale = true; // The command shows LEFT-HUGGED, so its column 3 is the '3'... - const rows = try term_pane.shellRows(p, sp); + const rows = try panes.Terminal.shellRows(p, sp); var row: i32 = 0; const at = for (rows, 0..) |r, i| { if (std.mem.indexOf(u8, r, "0123456789") != null) break i; @@ -17638,7 +12626,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" sp.cur_col = 3; sp.cur_pinned = true; while (p.nextEffect()) |_| {} - term_pane.enterTty(p, shell); + panes.Terminal.enterTty(p, shell); // The walk is arrow keys the shell understands. Seven lefts: readline's // cursor sits past the '9' and the click was on the '3'. @@ -17655,24 +12643,6 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" try std.testing.expectEqual(@as(usize, 7), lefts); } -// THE BOARD'S MEMORY PRESSURE, REPRODUCED ON AN ORDINARY NATIVE TARGET. -// -// These live in pardes.zig and not in limits.zig because every one of them -// drives `Pardes.init`: the table alone cannot say what a boot costs. The one -// test that needs nothing but the numbers — the desktop-capacity regression -// guard — stays in src/limits.zig. -// -// All three use the FixedBufferAllocator (or the accounting FailingAllocator) -// as the CORE'S OWN gpa and hand the same allocator to every `Options` arena, -// mirroring src/esp32p4.zig's `allocators.init(a)`: on the board every tier is a -// `StackFallbackAllocator` with a 4 KiB or zero buffer, so effectively all of -// it spills onto the single heap. Calling `allocators.init` here instead would -// hand a desktop build its 32 MiB static `.bss` tier and serve every request -// out of that, making a 384 KiB budget mean nothing. - -/// The board grid. These mirror `pardes_config.esp32p4_cols/esp32p4_rows` (build.zig -/// defaults, read at src/esp32p4.zig:235) rather than reading them, because a -/// native build does not set the P4 options at all. const board_cols: u16 = 56; const board_rows: u16 = 14; @@ -17680,9 +12650,6 @@ fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options { return .{ .cols = cols, .rows = rows, - // No pty is spawned by a test host, but `tty_only` is the one-pane boot - // and therefore the closest a hosted build gets to the board's - // single-output-buffer boot. .tty_only = true, .frame_allocator = gpa, .image_allocator = gpa, @@ -17691,52 +12658,18 @@ fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options { }; } -/// A boot and its teardown as one `!void` call. `checkAllAllocationFailures` -/// requires exactly that shape and `Pardes.init` returns `*Pardes` with a -/// `deinit` obligation, so the harness cannot call it directly. fn bootAndTearDown(gpa: std.mem.Allocator, cols: u16, rows: u16) !void { const p = try Pardes.init(gpa, boardBudgetOptions(gpa, cols, rows)); p.deinit(); } -// WHAT THE BOARD'S 384 KiB BUYS, CHECKED FROM A DESKTOP. -// -// What a hosted build CANNOT do is boot in 384 KiB, and the reason is not a -// capacity: `@sizeOf(Pane)` carries the ghostty-vt Terminal, 1.1 MiB of it, and -// what removes that on the board is `terminal_panes` — a CAPABILITY keyed on -// the platform (src/limits.zig says why it is not in the table). So there is no -// build option that turns a desktop into the board, and a test that pretended -// otherwise would be asserting a FixedBufferAllocator refuses a 2.2 MiB -// request. That was written, it asserted nothing, and it is gone. -// -// What a hosted build CAN check is every product the board's budget is spent -// on, because both factors are visible here: the board's CAPS are literals in -// src/limits.zig, and the ELEMENT SIZES are the same structs this target -// compiles (`Effect`, `Snapshot`, `Cell` and `PanelCellDiff` hold no pointers, -// so riscv32 and x86_64 agree about all four). That is the product a code -// change actually moves: nobody shrinks the board's heap, but somebody adds a -// `Buf(512)` arm to `Effect` and costs it 49 KiB it does not have. -// -// The caps are spelled as LITERALS rather than read from `limits`, because on -// this build `limits` holds the desktop numbers; these are the board's, they -// are its contract, and a derivation would agree with itself. -// -// MEASURED on x86_64-linux Debug at this commit: `@sizeOf(Effect)` 272, -// `@sizeOf(term_pane.Snapshot)` 56, `@sizeOf(Cell)` 26, `@sizeOf(PanelCellDiff)` 3 — -// so the three products are 34,816 + 1,792 + 43,120 = 79,728 bytes, a fifth of -// the heap, against bounds of 49,152 / 12,288 / 49,152 and a total of 196,608. test "board heap: every inline ring the board pays for still fits its budget" { const heap = limits.board_heap_bytes; - // THE EFFECT RING, the largest single inline cost in `Pardes` — 4096 - // entries on a desktop is 1.09 MiB of the 1.14 MiB the struct occupies. - // The board holds 128 (src/limits.zig `effect_cap`). const effect_ring = 128 * @sizeOf(Effect); // ...and the undo history, the largest in `Pane` once the terminal is out: // 16 snapshots on the board against 256 on a desktop. - const undo_history = 2 * 16 * @sizeOf(term_pane.Snapshot); - // ...and the three per-cell arrays the core owns at the board's own grid, - // which the next test pins the SHAPE of; this one pins the COST. + const undo_history = 2 * 16 * @sizeOf(panes.Terminal.Snapshot); const grid = @as(usize, board_cols) * board_rows * (2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff)); // Each of the three separately, so a failure names the one that grew @@ -17744,45 +12677,12 @@ test "board heap: every inline ring the board pays for still fits its budget" { try std.testing.expect(effect_ring <= heap / 8); try std.testing.expect(undo_history <= heap / 32); try std.testing.expect(grid <= heap / 8); - // ...and together, against the half of the heap the firmware measured as - // available after its own .bss, stack and vaxis's two grids. Three eighths - // is what the individual bounds already allow; asserting the sum as well is - // what catches two of them growing a little each. try std.testing.expect(effect_ring + undo_history + grid <= heap / 2); } -// EVERY CELL IS PAID FOR FOUR TIMES on the board — vaxis's `Screen` and -// `InternalScreen`, and the core's `Surface.cells` and `presented_cells` — plus -// the core's per-cell diff classification. Two of those four are vaxis's and -// invisible from here; what this pins is the three buffers the CORE owns, so -// that adding a fourth core-owned per-cell array fails loudly instead of -// quietly costing the board another 20 KiB. -// -// MEASURED at this commit: `@sizeOf(Cell)` is 26 and `@sizeOf(PanelCellDiff)` -// is 3, so the core spends 55 bytes per cell — 43,120 bytes at the board's -// 56x14 grid, an eighth of the whole heap and the largest single grid-scaled -// cost in the program. -test "board heap: the core owns exactly three per-cell arrays" { +test "board heap: transition grids fit the budget and the surface borrows its snapshots" { const per_cell = 2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff); - // Five NAMES, three BUFFERS: `Surface.previous_cells` and - // `Surface.cell_diffs` are views published onto the two the core owns (see - // `render`), so they cost nothing. Counted by reflection because a fifth - // name is exactly the change this test exists to catch. - const grid_slices = comptime blk: { - var n: usize = 0; - for (@typeInfo(Pardes).@"struct".fields ++ @typeInfo(Surface).@"struct".fields) |f| { - const info = @typeInfo(f.type); - if (info != .pointer or info.pointer.size != .slice) continue; - if (info.pointer.child == Cell or info.pointer.child == PanelCellDiff) n += 1; - } - break :blk n; - }; - try std.testing.expectEqual(@as(usize, 5), grid_slices); - - // The diff array is only allocated for a transition that needs the previous - // grid, so the sequence here is the shortest one that makes all three real: - // boot, acknowledge, split with `vertical` armed, render. const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16, .tty_only = true }); defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); @@ -17791,34 +12691,53 @@ test "board heap: the core owns exactly three per-cell arrays" { p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); const cells = @as(usize, p.screen_w) * p.screen_h; try std.testing.expectEqual(cells, p.surface.cells.len); - try std.testing.expectEqual(cells, p.presented_cells.len); - try std.testing.expectEqual(cells, p.panel_cell_diffs.len); + try std.testing.expectEqual(cells, p.presentation.previous_cells.len); + try std.testing.expectEqual(cells, p.presentation.diffs.len); + try std.testing.expect(p.surface.previous_cells.ptr == p.presentation.previous_cells.ptr); + try std.testing.expect(p.surface.cell_diffs.ptr == p.presentation.diffs.ptr); + try std.testing.expect(p.surface.cells.ptr != p.surface.previous_cells.ptr); const owned = p.surface.cells.len * @sizeOf(Cell) + - p.presented_cells.len * @sizeOf(Cell) + - p.panel_cell_diffs.len * @sizeOf(PanelCellDiff); + p.presentation.previous_cells.len * @sizeOf(Cell) + + p.presentation.diffs.len * @sizeOf(PanelCellDiff); try std.testing.expectEqual(cells * per_cell, owned); - // ...and the board's own grid has to leave the other seven eighths of the - // heap for everything else. 43,120 of 49,152 at the numbers above; a cell - // that grew by two bytes would spend the margin. try std.testing.expect(@as(usize, board_cols) * board_rows * per_cell <= limits.board_heap_bytes / 8); } -// EVERY ALLOCATION IN A BOOT, FAILED IN TURN. Eight of them at this commit, so -// the sweep is eight boots and costs milliseconds. `checkAllAllocationFailures` -// is the whole test because it asserts precisely the three things that matter: -// a failed allocation surfaces `error.OutOfMemory` rather than being swallowed -// into a half-built instance, `allocated_bytes == freed_bytes` at that point -// (so the failure path needs no `deinit` and leaves nothing dangling), and the -// allocation count is deterministic. +test "LSP failure clears only its matching wait without completion indentation" { + if (!lsp.supports.contains(.completion)) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("x"); + pane.mode = .insert; + pane.cur_col = 1; + + p.lspRequest(p.active, .completion, ""); + const old_id = p.lsp_wait.?.id; + p.lspRequest(p.active, .completion, ""); + const current_id = p.lsp_wait.?.id; + p.update(.{ .lsp_resp = .{ .id = old_id, .rows = null } }); + try std.testing.expectEqual(current_id, p.lsp_wait.?.id); + p.update(.{ .lsp_resp = .{ .id = current_id, .rows = null } }); + try std.testing.expect(p.lsp_wait == null); + try std.testing.expectEqualStrings("x", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + + p.lspRequest(p.active, .completion, ""); + p.update(.{ .lsp_resp = .{ .id = p.lsp_wait.?.id, .rows = "" } }); + try std.testing.expect(p.lsp_wait == null); + try std.testing.expectEqualStrings("x ", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 4), pane.cur_col); +} + test "board heap: every allocation failure during boot is a clean OutOfMemory" { try std.testing.checkAllAllocationFailures( std.testing.allocator, diff --git a/src/pdf.zig b/src/pdf.zig index b7e12fe8..a12be0b1 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -726,6 +726,13 @@ pub const Document = struct { document.* = undefined; } + pub fn openBytes(bytes: []const u8) !Document { + var page_count: c_int = 0; + const handle = c.pardes_pdf_open_memory(bytes.ptr, bytes.len, &page_count) orelse + return error.OpenFailed; + return .{ .handle = handle, .pages = @intCast(page_count) }; + } + /// Load and flatten the PDF-native outline/bookmarks. MuPDF's temporary /// tree and the bridge's flat view are both dropped before this returns. pub fn outline( diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c index bd72a54a..8e433fde 100644 --- a/src/pdf_bridge.c +++ b/src/pdf_bridge.c @@ -316,15 +316,17 @@ pardes_pdf_grown_capacity(size_t current, size_t needed, size_t maximum) return capacity; } -pardes_pdf_document * -pardes_pdf_open(const char *path, int *page_count) +static pardes_pdf_document * +pardes_pdf_open_source(const char *path, const unsigned char *bytes, size_t length, int *page_count) { pardes_pdf_document *state; fz_context *ctx; fz_document *doc = NULL; + fz_buffer *buffer = NULL; + fz_stream *stream = NULL; int pages = 0; - if (path == NULL || page_count == NULL) + if ((path == NULL && bytes == NULL) || page_count == NULL) return NULL; state = pardes_pdf_allocate_document(); @@ -344,14 +346,27 @@ pardes_pdf_open(const char *path, int *page_count) } fz_var(doc); + fz_var(buffer); + fz_var(stream); fz_try(ctx) { fz_register_document_handlers(ctx); - doc = fz_open_document(ctx, path); + if (path != NULL) { + doc = fz_open_document(ctx, path); + } else { + buffer = fz_new_buffer_from_copied_data(ctx, bytes, length); + stream = fz_open_buffer(ctx, buffer); + doc = fz_open_document_with_stream(ctx, "application/pdf", stream); + } pages = fz_count_pages(ctx, doc); if (pages < 1) fz_throw(ctx, FZ_ERROR_FORMAT, "PDF has no pages"); } + fz_always(ctx) + { + fz_drop_stream(ctx, stream); + fz_drop_buffer(ctx, buffer); + } fz_catch(ctx) { fz_report_error(ctx); @@ -369,6 +384,18 @@ pardes_pdf_open(const char *path, int *page_count) return state; } +pardes_pdf_document * +pardes_pdf_open(const char *path, int *page_count) +{ + return pardes_pdf_open_source(path, NULL, 0, page_count); +} + +pardes_pdf_document * +pardes_pdf_open_memory(const unsigned char *bytes, size_t length, int *page_count) +{ + return pardes_pdf_open_source(NULL, bytes, length, page_count); +} + void pardes_pdf_close(pardes_pdf_document *document) { diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index a3d2de89..81287578 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -172,6 +172,7 @@ enum { }; pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count); +pardes_pdf_document *pardes_pdf_open_memory(const unsigned char *bytes, size_t length, int *page_count); void pardes_pdf_close(pardes_pdf_document *document); /* Page dimensions in PDF points after crop/rotation, without rasterizing. */ diff --git a/src/pdf_pane.zig b/src/pdf_pane.zig deleted file mode 100644 index 8f163488..00000000 --- a/src/pdf_pane.zig +++ /dev/null @@ -1,2763 +0,0 @@ -//! PDF panes: MuPDF-owned document state, continuous layout, navigation, -//! search/selection/outline behavior, raster reconciliation, native placement, -//! and the direct input/render seam to the shared Pardes grid. -//! Cross-pane placement and output ownership remain in pardes.zig. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const image = @import("image.zig"); -const config = @import("config.zig"); -const file_pane = @import("file_pane.zig"); -const look = @import("look.zig"); -const modal = @import("modal.zig"); -const output_pane = @import("output_pane.zig"); -const tracy = @import("tracy.zig"); -const normal_input = @import("normal_input.zig"); - -pub const enabled = @import("pardes_config").mupdf; -pub const pdf = if (enabled) @import("mupdf") else struct { - pub const PageSize = struct { width: f32, height: f32 }; - pub const Raster = struct { - width: usize = 0, - height: usize = 0, - stride: usize = 0, - len: usize = 0, - pub const Band = struct { y: usize = 0, height: usize = 0, len: usize = 0 }; - pub fn wholePage(_: @This()) Band { - return .{}; - } - pub fn band(_: @This(), _: usize, _: usize) Band { - return .{}; - } - }; -}; -pub const Point = if (enabled) pdf.Point else void; -pub const Quad = if (enabled) pdf.Quad else void; -pub const Document = if (enabled) pdf.Document else opaque {}; -pub const OutlineInternalDestination = if (enabled) pdf.OutlineInternalDestination else void; -const raster_max = 256; -const raster_spare = 4; -pub const page_gap_px: u32 = 8; -const band_grain: usize = 64; - -pub const FitMode = if (enabled) enum { width, height } else void; -pub const TintMode = if (enabled) pdf.TintMode else void; -pub const TintColors = if (enabled) pdf.TintColors else void; -pub const RenderRequest = if (enabled) pdf.RenderRequest else void; - -/// Dump records must remain recognizable as PDFs even in a build without -/// MuPDF, where Look deliberately treats them as ordinary files. -pub fn isPath(path: []const u8) bool { - return std.ascii.endsWithIgnoreCase(path, ".pdf"); -} - -test "PDF dump paths are recognized independent of MuPDF support" { - try std.testing.expect(isPath("manual.pdf")); - try std.testing.expect(isPath("MANUAL.PDF")); - try std.testing.expect(!isPath("manual.pdf.txt")); - try std.testing.expect(!isPath("pdf")); -} - -pub const RasterPolicy = struct { - dpi: u16, - max_dimension: u16, - match_viewport: bool, -}; - -/// Recover the dynamic PDF prefix written by older version-1 dumps. Fit and -/// tint intentionally start fresh on restore, so matching the newly generated -/// prefix cannot recover a custom tail; the stable PdfSections marker and the -/// exact path delimit the old prefix without parsing renderer state. -pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 { - if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null; - const marker = " PdfSections "; - const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null; - const path_at = marker_at + marker.len; - if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null; - return saved_tag[0 .. path_at + path.len]; -} - -test "legacy PDF prefix is delimited by its stable marker and exact path" { - const path = "/tmp/a document.pdf"; - const tag = "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del"; - try std.testing.expectEqualStrings( - "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path, - legacySavedPrefix(path, tag).?, - ); -} - -pub const TintKey = if (enabled) struct { - mode: TintMode, - colors: pdf.TintColors, - - pub fn eql(a: @This(), b: @This()) bool { - return a.mode == b.mode and - (a.mode == .disabled or std.meta.eql(a.colors, b.colors)); - } -} else void; - -pub const Highlight = if (enabled) pdf.Highlight else void; -pub const Highlights = if (enabled) struct { - items: []const Highlight, - /// Hover items occupy [0..active_start); search/selection follow them. - active_start: usize, - hover_page: ?usize, - - pub fn forPage(highlights: @This(), page: usize, active_page: usize) []const Highlight { - const hover = highlights.items[0..highlights.active_start]; - if (page == active_page) - return if (highlights.hover_page == page) - highlights.items - else - highlights.items[highlights.active_start..]; - return if (highlights.hover_page == page) hover else &.{}; - } -} else void; - -pub const HighlightInput = if (enabled) struct { - hover_quads: []const Quad = &.{}, - hover_page: ?usize = null, - hover_color: [3]u8, - selection_color: [3]u8, -} else void; - -pub fn buildHighlights( - state: *const State, - arena: std.mem.Allocator, - input: HighlightInput, -) !Highlights { - if (comptime !enabled) return; - const search_len = if (state.search_results) |results| results.quads.len else 0; - const selection_len = if (state.selection) |selection| selection.quads.len else 0; - const active_start = input.hover_quads.len; - const highlights = try arena.alloc(Highlight, active_start + search_len + selection_len); - var n: usize = 0; - for (input.hover_quads) |quad| { - highlights[n] = pdf.Highlight.init( - quad, - .{ input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c }, - .custom, - ); - n += 1; - } - if (state.search_results) |results| { - for (results.quads) |item| { - highlights[n] = pdf.Highlight.init( - item.quad, - .{ 0xff, 0xd5, 0x4f, 0x70 }, - .search, - ); - n += 1; - } - } - if (state.selection) |selection| { - for (selection.quads) |quad| { - highlights[n] = pdf.Highlight.init( - quad, - .{ input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78 }, - .selection, - ); - n += 1; - } - } - return .{ .items = highlights, .active_start = active_start, .hover_page = input.hover_page }; -} - -pub const Raster = if (enabled) struct { - valid: bool = false, - page: usize = 0, - rgba: []u8 = &.{}, - /// Full page shape; rgba contains only the band below. - iw: usize = 0, - ih: usize = 0, - band_y: usize = 0, - band_h: usize = 0, - request: pdf.RenderRequest = .{}, - request_valid: bool = false, - tried: bool = false, - decorated: bool = false, - tint_key: ?TintKey = null, - revision: u32 = 0, -} else void; - -pub const SectionsOutput = if (enabled) struct { - pane: usize, - serial: u32, - revision: u32, -} else void; - -pub const SelectionUpdate = enum { failed, stationary, unchanged, changed }; - -/// Plain owned state for one PDF pane. Document navigation, search, selection, -/// layout and raster behavior live beside it; Pardes retains only cross-pane -/// focus/dispatch, cell rectangles and surface attachment. -pub const State = if (enabled) struct { - path: []u8, - document: Document, - page: usize = 0, - page_count: usize, - page_sizes: []pdf.PageSize, - page_starts: []u64, - page_heights: []u32, - document_height: u64 = 0, - layout_viewport_w: u32 = 0, - layout_viewport_h: u32 = 0, - layout_fit: FitMode = .width, - layout_valid: bool = false, - document_scroll_y: f64 = 0, - scroll_to_page_pending: bool = true, - rasters: [raster_max]Raster = undefined, - rasters_len: usize = 0, - spare: [raster_spare][]u8 = @splat(&.{}), - spare_len: usize = 0, - layout_anchor_pending: bool = false, - layout_anchor_page: usize = 0, - layout_anchor_fraction: f64 = 0, - next_raster_revision: u32 = 0, - scroll_travel: f64 = 0, - fit: FitMode = .width, - tint: TintMode = .filtered, - pan_x: u16 = 0, - pan_y: u16 = 0, - highlights_dirty: bool = false, - search_reveal_pending: bool = false, - search_results: ?pdf.SearchResults = null, - selection: ?pdf.Selection = null, - selection_text: []u8 = &.{}, - selection_anchor: ?Point = null, - selection_head: ?Point = null, - drag_anchor: ?Point = null, - drag_head: ?Point = null, - text: []u8 = &.{}, - text_tried: bool = false, - text_scroll: usize = 0, - text_scroll_clamp_pending: bool = false, - search_query: []u8 = &.{}, - search_hit: usize = 0, - reveal_viewport_w: u32 = 0, - reveal_viewport_h: u32 = 0, - reveal_fit: FitMode = .width, - reveal_viewport_valid: bool = false, - outline: ?pdf.Outline = null, - outline_tried: bool = false, - sections_output: ?SectionsOutput = null, - outline_reveal_pending: ?pdf.OutlineInternalDestination = null, - - pub fn open(gpa: std.mem.Allocator, path: []const u8, page_one_based: usize) !@This() { - var document = try Document.open(path); - errdefer document.deinit(); - const page_sizes = try gpa.alloc(pdf.PageSize, document.pages); - errdefer gpa.free(page_sizes); - for (page_sizes, 0..) |*size, page| size.* = try document.pageSize(page); - const page_starts = try gpa.alloc(u64, document.pages); - errdefer gpa.free(page_starts); - const page_heights = try gpa.alloc(u32, document.pages); - errdefer gpa.free(page_heights); - const owned_path = try gpa.dupe(u8, path); - errdefer gpa.free(owned_path); - return .{ - .path = owned_path, - .document = document, - .page = if (page_one_based > 0) - @min(page_one_based - 1, document.pages - 1) - else - 0, - .page_count = document.pages, - .page_sizes = page_sizes, - .page_starts = page_starts, - .page_heights = page_heights, - }; - } - - /// Reopen the file behind this pane without exposing a half-reloaded - /// document. MuPDF owns document-derived objects (pages, text, outlines, - /// selections and rendered pixels), so a live reload replaces the whole - /// State and carries over only user-facing view/configuration data. - pub fn reload(state: *@This(), gpa: std.mem.Allocator) !void { - const preserve_anchor = !state.scroll_to_page_pending and - (state.layout_anchor_pending or - (state.layout_valid and state.page_count > 0 and state.document_height > 0)); - var anchor_page: usize = state.layout_anchor_page; - var anchor_fraction: f64 = state.layout_anchor_fraction; - if (preserve_anchor and !state.layout_anchor_pending) { - anchor_page = pageAtOffset(state, state.document_scroll_y); - const start: f64 = @floatFromInt(state.page_starts[anchor_page]); - const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[anchor_page])); - anchor_fraction = std.math.clamp( - (state.document_scroll_y - start) / height, - 0, - 1, - ); - } - - var fresh = try @This().open(gpa, state.path, state.page + 1); - errdefer fresh.deinit(gpa); - if (state.search_query.len > 0) - fresh.search_query = try gpa.dupe(u8, state.search_query); - - fresh.fit = state.fit; - fresh.tint = state.tint; - fresh.pan_x = state.pan_x; - fresh.pan_y = state.pan_y; - fresh.text_scroll = state.text_scroll; - fresh.text_scroll_clamp_pending = true; - fresh.search_hit = state.search_hit; - fresh.highlights_dirty = fresh.search_query.len > 0; - fresh.search_reveal_pending = state.search_reveal_pending; - // A retained query is still active, but a reload is not a new request - // to center its hit. Preserve the viewport observation so only a real - // viewport/fit change re-arms revealSearch on the next frame. - fresh.reveal_viewport_w = state.reveal_viewport_w; - fresh.reveal_viewport_h = state.reveal_viewport_h; - fresh.reveal_fit = state.reveal_fit; - fresh.reveal_viewport_valid = state.reveal_viewport_valid; - // Revisions are part of the backend texture key. Resetting this counter - // while the pane serial stays live can alias a cached pre-reload page. - fresh.next_raster_revision = state.next_raster_revision; - fresh.sections_output = state.sections_output; - if (preserve_anchor) { - fresh.scroll_to_page_pending = false; - fresh.layout_anchor_pending = true; - fresh.layout_anchor_page = anchor_page; - fresh.layout_anchor_fraction = anchor_fraction; - } - - var old = state.*; - state.* = fresh; - old.deinit(gpa); - } - - pub fn invalidateRaster(state: *@This(), page: usize) void { - if (rasterForPage(state, page)) |raster| raster.tried = false; - } - - pub fn invalidateAllRasters(state: *@This()) void { - for (state.rasters[0..state.rasters_len]) |*raster| { - if (raster.valid) raster.tried = false; - } - } - - fn retireRgba(state: *@This(), gpa: std.mem.Allocator, rgba: []u8) void { - if (rgba.len == 0) return; - if (state.spare_len == state.spare.len) return gpa.free(rgba); - state.spare[state.spare_len] = rgba; - state.spare_len += 1; - } - - fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void { - state.retireRgba(gpa, raster.rgba); - raster.* = .{}; - } - - fn claimRgba(state: *@This(), gpa: std.mem.Allocator, bytes: usize) ?[]u8 { - for (state.spare[0..state.spare_len], 0..) |candidate, index| { - if (candidate.len != bytes) continue; - state.spare_len -= 1; - state.spare[index] = state.spare[state.spare_len]; - return candidate; - } - return gpa.alloc(u8, bytes) catch null; - } - - fn trimSpares(state: *@This(), gpa: std.mem.Allocator) void { - while (state.spare_len > 1) { - state.spare_len -= 1; - gpa.free(state.spare[state.spare_len]); - } - } - - fn dropSearchResults(state: *@This(), gpa: std.mem.Allocator) void { - if (state.search_results) |*results| results.deinit(gpa); - state.search_results = null; - } - - fn dropSelection(state: *@This(), gpa: std.mem.Allocator) void { - if (state.selection) |*selection| selection.deinit(gpa); - state.selection = null; - if (state.selection_text.len > 0) gpa.free(state.selection_text); - state.selection_text = &.{}; - state.selection_anchor = null; - state.selection_head = null; - } - - /// Transactionally replace the word-snapped selection and its owned text. - /// `stationary` is reserved for the UI's pre-probe drag check; this state - /// operation returns only failed, unchanged, or changed. - pub fn setSelection( - state: *@This(), - gpa: std.mem.Allocator, - start: Point, - end: Point, - invalidate_raster: bool, - ) SelectionUpdate { - if (state.selection != null and - state.selection_anchor != null and state.selection_head != null and - state.selection_anchor.?.x == start.x and state.selection_anchor.?.y == start.y and - state.selection_head.?.x == end.x and state.selection_head.?.y == end.y) return .unchanged; - var selection = state.document.select(gpa, state.page, start, end) catch return .failed; - const text = state.document.copySelection( - gpa, - state.page, - selection.start, - selection.end, - ) catch { - selection.deinit(gpa); - return .failed; - }; - - state.dropSelection(gpa); - state.selection = selection; - state.selection_text = text; - state.selection_anchor = start; - state.selection_head = end; - if (invalidate_raster) state.invalidateRaster(state.page); - return .changed; - } - - pub fn clearDrag(state: *@This()) void { - state.drag_anchor = null; - state.drag_head = null; - } - - pub fn clearSelection(state: *@This(), gpa: std.mem.Allocator) void { - const changed = state.selection != null or state.selection_text.len > 0; - state.dropSelection(gpa); - if (changed) state.invalidateRaster(state.page); - } - - /// Escape's cancel: everything transient a reader can SEE — the mouse - /// selection and the search overlay — and nothing that says WHERE in the - /// document they are. Page, scroll, fit and tint are what the pane is, not - /// chrome. Allocation-free, so it cannot half-cancel. - pub fn cancelChrome(state: *@This(), gpa: std.mem.Allocator) void { - state.clearDrag(); - state.clearSelection(gpa); - if (state.search_query.len == 0) return; - state.dropSearchQuery(gpa); - state.invalidateRaster(state.page); - } - - fn invalidatePage(state: *@This(), gpa: std.mem.Allocator) void { - state.dropSearchResults(gpa); - state.dropSelection(gpa); - state.clearDrag(); - if (state.text.len > 0) gpa.free(state.text); - state.text = &.{}; - state.text_tried = false; - state.text_scroll = 0; - state.text_scroll_clamp_pending = false; - state.highlights_dirty = state.search_query.len > 0; - state.search_reveal_pending = state.search_query.len > 0; - state.search_hit = 0; - } - - /// Forget the query, its hits, and every flag derived from them. Shared by - /// the cancel above and by the replacement below, which owns new bytes the - /// caller allocated before anything here was dropped. - fn dropSearchQuery(state: *@This(), gpa: std.mem.Allocator) void { - if (state.search_query.len > 0) gpa.free(state.search_query); - state.search_query = &.{}; - state.search_hit = 0; - state.dropSearchResults(gpa); - state.highlights_dirty = false; - state.search_reveal_pending = false; - } - - pub fn setSearchQuery(state: *@This(), gpa: std.mem.Allocator, query: []const u8) !void { - if (std.mem.eql(u8, state.search_query, query)) return; - const owned = try gpa.dupe(u8, query); - state.dropSearchQuery(gpa); - state.search_query = owned; - state.highlights_dirty = query.len > 0; - state.search_reveal_pending = query.len > 0; - state.invalidateRaster(state.page); - } - - pub fn ensureText(state: *@This(), gpa: std.mem.Allocator) []const u8 { - if (!state.text_tried) { - state.text_tried = true; - state.text = state.document.pageText(gpa, state.page) catch &.{}; - } - if (state.text_scroll_clamp_pending) { - const lines = std.mem.count(u8, state.text, "\n") + 1; - state.text_scroll = @min(state.text_scroll, lines - 1); - state.text_scroll_clamp_pending = false; - } - return state.text; - } - - pub fn resolveSearch(state: *@This(), gpa: std.mem.Allocator) void { - if (!state.highlights_dirty) return; - state.highlights_dirty = false; - state.dropSearchResults(gpa); - if (state.search_query.len == 0) return; - const results = state.document.search(gpa, state.page, state.search_query) catch return; - state.search_hit = if (results.hit_count == 0) - 0 - else - @min(state.search_hit, results.hit_count - 1); - state.search_results = results; - } - - pub fn ensureOutline(state: *@This(), gpa: std.mem.Allocator) ?*const pdf.Outline { - if (!state.outline_tried) { - state.outline_tried = true; - state.outline = state.document.outline(gpa) catch null; - } - return if (state.outline) |*outline| outline else null; - } - - pub fn renderSections( - state: *@This(), - pdf_gpa: std.mem.Allocator, - output_gpa: std.mem.Allocator, - ) ![]u8 { - const entries: []const pdf.OutlineEntry = if (state.ensureOutline(pdf_gpa)) |outline| - outline.entries - else - &.{}; - return SectionRows.render(output_gpa, state.path, entries); - } - - pub fn sectionDestination( - state: *@This(), - gpa: std.mem.Allocator, - ordinal: usize, - ) ?pdf.OutlineDestination { - const outline = state.ensureOutline(gpa) orelse return null; - return SectionRows.resolve(outline.entries, ordinal); - } - - /// Apply the one-based page/hit location encoded in a PDF search row. - /// Returns whether host pane cursor chrome must be reset. - pub fn focusLocation( - state: *@This(), - gpa: std.mem.Allocator, - line: usize, - column: usize, - ) bool { - const changed = line > 0 and state.activatePage(gpa, line - 1, true); - if (column > 0 and state.search_query.len > 0) { - state.search_hit = column - 1; - state.search_reveal_pending = true; - } - return changed; - } - - /// Change the document page while leaving pane cursor/selection chrome to - /// the UI adapter. Returns whether that pane-local chrome must be reset. - pub fn activatePage( - state: *@This(), - gpa: std.mem.Allocator, - page: usize, - reveal: bool, - ) bool { - state.outline_reveal_pending = null; - const next = @min(page, state.page_count -| 1); - const changed = next != state.page; - if (changed) { - state.invalidatePage(gpa); - state.page = next; - } - if (reveal) { - state.scroll_to_page_pending = true; - if (state.layout_valid) { - state.document_scroll_y = @floatFromInt(state.page_starts[next]); - state.scroll_to_page_pending = false; - } - } else { - state.search_reveal_pending = false; - } - return changed; - } - - pub fn toggleFit(state: *@This()) void { - state.fit = if (state.fit == .width) .height else .width; - state.pan_x = 0; - state.pan_y = 0; - state.layout_valid = false; - state.scroll_to_page_pending = true; - state.search_reveal_pending = state.search_query.len > 0; - } - - pub fn toggleTint(state: *@This()) void { - state.tint = state.tint.next(); - state.invalidateAllRasters(); - } - - pub fn queueOutlineReveal( - state: *@This(), - destination: pdf.OutlineInternalDestination, - ) void { - state.scroll_to_page_pending = false; - state.layout_anchor_pending = false; - state.outline_reveal_pending = destination; - } - - pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void { - gpa.free(state.path); - for (state.rasters[0..state.rasters_len]) |raster| - if (raster.rgba.len > 0) gpa.free(raster.rgba); - for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba); - gpa.free(state.page_sizes); - gpa.free(state.page_starts); - gpa.free(state.page_heights); - if (state.text.len > 0) gpa.free(state.text); - if (state.search_query.len > 0) gpa.free(state.search_query); - if (state.search_results) |*results| results.deinit(gpa); - if (state.selection) |*selection| selection.deinit(gpa); - if (state.selection_text.len > 0) gpa.free(state.selection_text); - if (state.outline) |*outline| outline.deinit(gpa); - state.document.deinit(); - state.* = undefined; - } -} else void; - -test "feature-off PDF state is zero-sized" { - if (!enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(State)); -} - -pub const SearchOutput = struct { - bytes: usize = 0, - rows: usize = 0, - anchor: ?usize = null, -}; - -pub fn textLines( - state: *State, - gpa: std.mem.Allocator, - arena: std.mem.Allocator, -) ![]const []const u8 { - if (comptime !enabled) return &.{}; - const page_text = state.ensureText(gpa); - const lines = try arena.alloc([]const u8, std.mem.count(u8, page_text, "\n") + 1); - var it = std.mem.splitScalar(u8, page_text, '\n'); - var n: usize = 0; - while (it.next()) |line| : (n += 1) lines[n] = line; - return lines; -} - -pub fn visibleText( - state: *State, - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - max_rows: usize, -) ![]const u8 { - if (comptime !enabled) return ""; - const page_text = state.ensureText(gpa); - var start: usize = 0; - for (0..state.text_scroll) |_| { - const newline = std.mem.indexOfScalarPos(u8, page_text, start, '\n') orelse - return arena.dupe(u8, ""); - start = newline + 1; - } - if (max_rows == 0) return arena.dupe(u8, ""); - - var end = start; - var row: usize = 0; - while (row < max_rows) : (row += 1) { - const newline = std.mem.indexOfScalarPos(u8, page_text, end, '\n') orelse { - end = page_text.len; - break; - }; - if (row + 1 == max_rows) { - end = newline; - break; - } - end = newline + 1; - } - return arena.dupe(u8, page_text[start..end]); -} - -/// Materialize exact MuPDF logical hits as `path:PAGE:HIT query` rows. The -/// same hit numbering drives persistent highlights and later reveal actions. -pub fn searchRows( - state: *State, - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - pattern: []const u8, - from_cursor: bool, - out: []u8, -) !SearchOutput { - if (comptime !enabled) return .{}; - try state.setSearchQuery(gpa, pattern); - const shown = std.fs.path.basename(state.path); - var result: SearchOutput = .{}; - const max_hits = 512; - var snippet_len = @min(pattern.len, 200); - while (snippet_len > 0 and snippet_len < pattern.len and pattern[snippet_len] & 0xc0 == 0x80) - snippet_len -= 1; - const snippet = pattern[0..snippet_len]; - for (0..state.page_count) |page| { - if (result.rows >= max_hits) break; - var found = try state.document.search(gpa, page, pattern); - defer found.deinit(gpa); - - const cursor_hit: ?usize = if (from_cursor and page == state.page) cursor: { - const selection = state.selection orelse break :cursor null; - for (found.quads) |item| { - const q = item.quad; - const center: Point = .{ - .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4, - .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4, - }; - if (selection.contains(center)) break :cursor item.hit; - } - break :cursor null; - } else null; - - for (0..found.hit_count) |hit_index| { - if (result.rows >= max_hits) break; - const line = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ - shown, page + 1, hit_index + 1, snippet, - }); - if (line.len > out.len - result.bytes) return result; - if (from_cursor and - (page < state.page or - (page == state.page and cursor_hit != null and hit_index <= cursor_hit.?))) - result.anchor = result.rows; - @memcpy(out[result.bytes..][0..line.len], line); - result.bytes += line.len; - result.rows += 1; - } - } - return result; -} - -pub const Viewport = struct { pixel_w: u32, pixel_h: u32 }; -pub const VisiblePages = struct { first: usize = 0, len: usize = 0 }; -pub const PanAxis = enum { horizontal, vertical }; -pub const PanResult = enum { moved, edge, unavailable }; -pub const ScrollResult = struct { active_page: usize }; -pub const CellPixels = struct { w: u16, h: u16 }; -pub const NormalHost = enum { - none, - leader, - command_line, - search, - search_forward, - search_backward, -}; -pub const NormalResult = struct { - host: NormalHost = .none, - page_changed: bool = false, -}; - -const PlacedGeometry = struct { - geometry: image.NativeGeometry, - pixel_offset_y: f32, -}; - -pub const PlacedRaster = if (enabled) struct { - page: usize, - revision: u32, - fit: FitMode, - pan_x: u16, - geometry: image.NativeGeometry, - pixel_offset_y: f32, - rgba: []const u8, - width: usize, - band_height: usize, -} else void; - -const VisibleRows = struct { - base: image.NativeGeometry, - y0: u32, - y1: u32, - dst_y: u32, - dst_h: u32, - pixel_offset_y: f32, -}; - -pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest { - if (comptime !enabled) return; - return .{ - .dpi = policy.dpi, - .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0, - .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0, - .max_dimension = policy.max_dimension, - }; -} - -fn pageHeight(size: pdf.PageSize, viewport: Viewport, fit: FitMode) u32 { - if (comptime !enabled) return 0; - if (fit == .height) return viewport.pixel_h; - const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) * - @as(f64, size.height) / @as(f64, size.width); - return @max(1, @as(u32, @intFromFloat(@min( - @as(f64, @floatFromInt(std.math.maxInt(u32))), - @round(scaled), - )))); -} - -fn pageAtOffset(state: *const State, offset: f64) usize { - if (comptime !enabled) return 0; - const y: u64 = @intFromFloat(std.math.clamp( - @floor(offset), - 0, - @as(f64, @floatFromInt(state.document_height -| 1)), - )); - var lo: usize = 0; - var hi: usize = state.page_count; - while (lo + 1 < hi) { - const mid = lo + (hi - lo) / 2; - if (state.page_starts[mid] <= y) lo = mid else hi = mid; - } - const end = state.page_starts[lo] + state.page_heights[lo]; - return if (y >= end and lo + 1 < state.page_count) lo + 1 else lo; -} - -fn pageVisible(state: *const State, page: usize, viewport: Viewport) bool { - if (comptime !enabled) return false; - const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y; - const bottom = top + @as(f64, @floatFromInt(state.page_heights[page])); - return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h)); -} - -pub fn visiblePages(state: *const State, viewport: Viewport) VisiblePages { - if (comptime !enabled) return .{}; - var out: VisiblePages = .{}; - var page = pageAtOffset(state, state.document_scroll_y); - if (page > 0 and pageVisible(state, page - 1, viewport)) page -= 1; - out.first = page; - while (page < state.page_count) : (page += 1) { - const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y; - if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break; - if (pageVisible(state, page, viewport)) out.len += 1; - } - return out; -} - -fn visibleContains(visible: VisiblePages, page: usize) bool { - return page >= visible.first and page - visible.first < visible.len; -} - -pub fn rasterForPage(state: *State, page: usize) ?*Raster { - if (comptime !enabled) return null; - for (state.rasters[0..state.rasters_len]) |*raster| - if (raster.valid and raster.page == page) return raster; - return null; -} - -fn rasterForPageConst(state: *const State, page: usize) ?*const Raster { - if (comptime !enabled) return null; - for (state.rasters[0..state.rasters_len]) |*raster| - if (raster.valid and raster.page == page) return raster; - return null; -} - -fn visibleRows( - state: *const State, - viewport: Viewport, - page: usize, - iw: usize, - ih: usize, -) ?VisibleRows { - if (comptime !enabled) return null; - const page_h = state.page_heights[page]; - const base = image.nativeGeometry( - iw, - ih, - viewport.pixel_w, - page_h, - switch (state.fit) { - .width => .width, - .height => .height, - }, - state.pan_x, - 0, - ) orelse return null; - if (base.dst.h == 0 or base.src.h == 0) return null; - - const scroll_floor = @floor(state.document_scroll_y); - const fractional: f32 = @floatCast(state.document_scroll_y - scroll_floor); - const scroll_i: i64 = @intFromFloat(@min( - scroll_floor, - @as(f64, @floatFromInt(std.math.maxInt(i64))), - )); - const start_i: i64 = @intCast(@min( - state.page_starts[page], - @as(u64, std.math.maxInt(i64)), - )); - const full_y = start_i - scroll_i + @as(i64, base.dst.y); - const full_bottom = full_y + @as(i64, base.dst.h); - const visible_y = @max(@as(i64, 0), full_y); - const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom); - if (visible_bottom <= visible_y) return null; - - const rel_y0: u64 = @intCast(visible_y - full_y); - const rel_y1: u64 = @intCast(visible_bottom - full_y); - const src_y0: u32 = base.src.y + @as(u32, @intCast( - rel_y0 * base.src.h / base.dst.h, - )); - const src_y1: u32 = base.src.y + @as(u32, @intCast(@min( - @as(u64, base.src.h), - (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h, - ))); - if (src_y1 <= src_y0) return null; - return .{ - .base = base, - .y0 = src_y0, - .y1 = src_y1, - .dst_y = @intCast(visible_y), - .dst_h = @intCast(visible_bottom - visible_y), - .pixel_offset_y = -fractional, - }; -} - -fn placedGeometry( - state: *const State, - raster: *const Raster, - viewport: Viewport, - page: usize, -) ?PlacedGeometry { - if (comptime !enabled) return null; - const rows = visibleRows(state, viewport, page, raster.iw, raster.ih) orelse return null; - const band_y: u32 = @intCast(raster.band_y); - const band_end: u32 = @intCast(raster.band_y + raster.band_h); - if (rows.y0 < band_y or rows.y1 > band_end) return null; - return .{ - .geometry = .{ - .src = .{ - .x = rows.base.src.x, - .y = rows.y0 - band_y, - .w = rows.base.src.w, - .h = rows.y1 - rows.y0, - }, - .dst = .{ - .x = rows.base.dst.x, - .y = rows.dst_y, - .w = rows.base.dst.w, - .h = rows.dst_h, - }, - }, - .pixel_offset_y = rows.pixel_offset_y, - }; -} - -pub fn placedRaster(state: *const State, viewport: Viewport, page: usize) ?PlacedRaster { - if (comptime !enabled) return null; - const raster = rasterForPageConst(state, page) orelse return null; - if (raster.rgba.len == 0) return null; - const placed = placedGeometry(state, raster, viewport, page) orelse return null; - return .{ - .page = page, - .revision = raster.revision, - .fit = state.fit, - .pan_x = state.pan_x, - .geometry = placed.geometry, - .pixel_offset_y = placed.pixel_offset_y, - .rgba = raster.rgba, - .width = raster.iw, - .band_height = raster.band_h, - }; -} - -pub fn activeGeometry(state: *const State, viewport: Viewport) ?image.NativeGeometry { - if (comptime !enabled) return null; - const raster = rasterForPageConst(state, state.page) orelse return null; - const placed = placedGeometry(state, raster, viewport, state.page) orelse return null; - return placed.geometry; -} - -pub fn pageAtViewportY(state: *const State, local_y: f64) ?usize { - if (comptime !enabled) return null; - if (!state.layout_valid or !std.math.isFinite(local_y) or local_y < 0) return null; - const document_y = state.document_scroll_y + local_y; - const page = pageAtOffset(state, document_y); - const start: f64 = @floatFromInt(state.page_starts[page]); - if (document_y < start or - document_y >= start + @as(f64, @floatFromInt(state.page_heights[page]))) return null; - return page; -} - -pub fn pageReady(state: *const State, viewport: Viewport, page: usize) bool { - if (comptime !enabled) return false; - return placedRaster(state, viewport, page) != null; -} - -pub fn nativeReady(state: *const State, viewport: Viewport) bool { - if (comptime !enabled) return false; - for (state.rasters[0..state.rasters_len]) |*raster| { - if (raster.valid and raster.rgba.len > 0 and - placedGeometry(state, raster, viewport, raster.page) != null) return true; - } - return false; -} - -pub fn pointAtPage( - state: *const State, - viewport: Viewport, - page: usize, - px: i64, - py: i64, - clamp_to_page: bool, -) ?Point { - if (comptime !enabled) return null; - const raster = rasterForPageConst(state, page) orelse return null; - if (raster.rgba.len == 0) return null; - const placed = placedGeometry(state, raster, viewport, page) orelse return null; - return pointAtGeometry( - placed.geometry, - placed.pixel_offset_y, - raster.iw, - raster.ih, - raster.band_y, - px, - py, - clamp_to_page, - ); -} - -fn slotShape(slot: *const Raster) pdf.Raster { - const stride = slot.iw * 4; - return .{ .width = slot.iw, .height = slot.ih, .stride = stride, .len = stride * slot.ih }; -} - -fn flinging(state: *const State, viewport: Viewport) bool { - if (comptime !enabled) return false; - return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h)); -} - -fn wantedBand( - state: *const State, - viewport: Viewport, - page: usize, - shape: pdf.Raster, - is_flinging: bool, -) pdf.Raster.Band { - if (!is_flinging) return shape.wholePage(); - const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse - return shape.wholePage(); - const first = (@as(usize, rows.y0) / band_grain) * band_grain; - const last = std.math.divCeil(usize, @as(usize, rows.y1), band_grain) catch - return shape.wholePage(); - return shape.band(first, last * band_grain - first); -} - -/// Keep exactly the visible page rasters resident and refresh only stale or -/// uncovered bands. Rendering is transactional: existing pixels remain -/// presentable until a replacement is fully rendered and tinted. -fn reconcile( - state: *State, - gpa: std.mem.Allocator, - request: RenderRequest, - tint_key: TintKey, - highlights: Highlights, - visible: VisiblePages, - viewport: Viewport, -) void { - if (comptime !enabled) return; - const tz = tracy.zone(@src(), "pdf.reconcile"); - defer tz.end(); - - // Remove first so arriving pages can claim departing page buffers. Only - // the final visible set can be seen, so a fling skips crossed-over pages. - var index: usize = 0; - while (index < state.rasters_len) { - if (visibleContains(visible, state.rasters[index].page)) { - index += 1; - continue; - } - state.retireRaster(gpa, &state.rasters[index]); - state.rasters_len -= 1; - if (index != state.rasters_len) - state.rasters[index] = state.rasters[state.rasters_len]; - } - - const is_flinging = flinging(state, viewport); - var page = visible.first; - const end = visible.first + visible.len; - while (page < end) : (page += 1) { - var raster = rasterForPage(state, page); - if (raster == null) { - if (state.rasters_len == state.rasters.len) continue; - state.rasters[state.rasters_len] = .{ .valid = true, .page = page }; - state.rasters_len += 1; - raster = &state.rasters[state.rasters_len - 1]; - } - const slot = raster.?; - const page_highlights = highlights.forPage(page, state.page); - const decorated = page_highlights.len > 0; - const stale = !slot.tried or !slot.request_valid or - !slot.request.eql(request) or slot.decorated != decorated or - slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or - slot.rgba.len == 0 or slot.band_h == 0; - const uncovered = !stale and uncovered: { - const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging); - break :uncovered slot.band_y > want.y or - slot.band_y + slot.band_h < want.y + want.height; - }; - if (!stale and !uncovered) continue; - - slot.tried = true; - slot.request = request; - slot.request_valid = true; - const shape_or_null = shape: { - const tz_measure = tracy.zone(@src(), "pdf.measure"); - defer tz_measure.end(); - break :shape state.document.measureRenderAt(page, request) catch null; - }; - const shape = shape_or_null orelse continue; - const want = wantedBand(state, viewport, page, shape, is_flinging); - const fresh = state.claimRgba(gpa, want.len) orelse continue; - const filled = filled: { - { - const tz_render = tracy.zone(@src(), "pdf.render_into"); - defer tz_render.end(); - state.document.renderIntoAt( - page, - request, - shape, - want, - page_highlights, - fresh, - ) catch break :filled false; - } - const tz_tint = tracy.zone(@src(), "pdf.tint"); - defer tz_tint.end(); - pdf.tintRgba(fresh, tint_key.mode, tint_key.colors) catch - break :filled false; - break :filled true; - }; - if (!filled) { - state.retireRgba(gpa, fresh); - continue; - } - - state.retireRgba(gpa, slot.rgba); - slot.rgba = fresh; - slot.iw = shape.width; - slot.ih = shape.height; - slot.band_y = want.y; - slot.band_h = want.height; - slot.decorated = decorated; - slot.tint_key = tint_key; - state.next_raster_revision +%= 1; - if (state.next_raster_revision == 0) state.next_raster_revision = 1; - slot.revision = state.next_raster_revision; - } - state.trimSpares(gpa); -} - -/// Resolve all pane-owned render decisions for one surface frame. The caller -/// supplies only backend policy, theme-derived tint/highlight colors, and the -/// viewport; it then transports the returned visible placements to Surface. -pub fn renderFrame( - state: *State, - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - viewport: Viewport, - policy: RasterPolicy, - tint_key: TintKey, - highlight_input: HighlightInput, -) VisiblePages { - if (comptime !enabled) return .{}; - ensureLayout(state, viewport); - state.resolveSearch(gpa); - const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{ - .items = &.{}, - .active_start = 0, - .hover_page = null, - }; - const request = renderRequest(viewport, policy); - var visible = visiblePages(state, viewport); - reconcile(state, gpa, request, tint_key, highlights, visible, viewport); - - rearmSearchReveal(state, viewport); - revealSearch(state, viewport, activeGeometry(state, viewport)); - visible = visiblePages(state, viewport); - reconcile(state, gpa, request, tint_key, highlights, visible, viewport); - return visible; -} - -pub fn normalizedPixel(value: f32, dimension: usize) u32 { - const scaled = std.math.clamp(value, 0, 1) * @as(f32, @floatFromInt(dimension)); - return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled)))); -} - -pub fn scaledStep(base: u32, count: u32) u32 { - return @intCast(@min( - @as(u64, std.math.maxInt(u32)), - @as(u64, base) * @max(@as(u64, 1), count), - )); -} - -pub fn scrollDocument(state: *State, viewport: Viewport, delta_pixels: f64) ?ScrollResult { - if (comptime !enabled) return null; - if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return null; - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - const next = std.math.clamp(state.document_scroll_y + delta_pixels, 0, max_scroll); - if (next == state.document_scroll_y) return null; - state.scroll_travel += @abs(next - state.document_scroll_y); - state.document_scroll_y = next; - return .{ .active_page = pageAtOffset(state, next) }; -} - -pub fn panPixels( - state: *State, - geometry: image.NativeGeometry, - axis: PanAxis, - direction: i8, - display_pixels: u32, -) PanResult { - if (comptime !enabled) return .unavailable; - const raster = rasterForPage(state, state.page) orelse return .unavailable; - const source_full: u32 = @intCast(switch (axis) { - .horizontal => raster.iw, - .vertical => raster.ih, - }); - const crop = switch (axis) { - .horizontal => geometry.src.w, - .vertical => geometry.src.h, - }; - const source_at = switch (axis) { - .horizontal => geometry.src.x, - .vertical => geometry.src.y, - }; - const displayed = @max(@as(u32, 1), switch (axis) { - .horizontal => geometry.dst.w, - .vertical => geometry.dst.h, - }); - const overflow = source_full -| crop; - if (overflow == 0 or - (direction < 0 and source_at == 0) or - (direction > 0 and source_at >= overflow)) return .edge; - - const source_step = @max( - @as(u64, 1), - (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed, - ); - const normalized_step: u32 = @intCast(@min( - @as(u64, std.math.maxInt(u16)), - @max( - @as(u64, 1), - (source_step * std.math.maxInt(u16) + overflow - 1) / overflow, - ), - )); - const position = switch (axis) { - .horizontal => &state.pan_x, - .vertical => &state.pan_y, - }; - if (direction > 0) { - position.* = @intCast(@min( - @as(u32, std.math.maxInt(u16)), - @as(u32, position.*) + normalized_step, - )); - } else { - position.* -|= @intCast(normalized_step); - } - return .moved; -} - -fn setHorizontalEdge(state: *State, geometry: image.NativeGeometry, end: bool) void { - if (comptime !enabled) return; - const raster = rasterForPage(state, state.page) orelse return; - if (raster.iw <= geometry.src.w) return; - state.pan_x = if (end) std.math.maxInt(u16) else 0; -} - -fn rearmSearchReveal(state: *State, viewport: Viewport) void { - if (comptime !enabled) return; - if (state.search_query.len == 0) return; - if (!state.reveal_viewport_valid or - state.reveal_viewport_w != viewport.pixel_w or - state.reveal_viewport_h != viewport.pixel_h or - state.reveal_fit != state.fit) - state.search_reveal_pending = true; -} - -pub fn revealSearch( - state: *State, - viewport: Viewport, - geometry: ?image.NativeGeometry, -) void { - if (comptime !enabled) return; - if (!state.search_reveal_pending) return; - state.reveal_viewport_w = viewport.pixel_w; - state.reveal_viewport_h = viewport.pixel_h; - state.reveal_fit = state.fit; - state.reveal_viewport_valid = true; - const results = state.search_results orelse { - state.search_reveal_pending = false; - return; - }; - if (results.hit_count == 0 or results.quads.len == 0) { - state.search_reveal_pending = false; - return; - } - state.search_hit = @min(state.search_hit, results.hit_count - 1); - const q = for (results.quads) |item| { - if (item.hit == state.search_hit) break item.quad; - } else { - state.search_reveal_pending = false; - return; - }; - state.search_reveal_pending = false; - const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4; - const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4; - if (state.fit == .width) { - ensureLayout(state, viewport); - const page_y = @as(f64, @floatFromInt(state.page_starts[state.page])) + - @as(f64, center_y) * @as(f64, @floatFromInt(state.page_heights[state.page])); - const wanted = page_y - @as(f64, @floatFromInt(viewport.pixel_h)) / 2; - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - state.document_scroll_y = std.math.clamp(wanted, 0, max_scroll); - return; - } - const placed = geometry orelse return; - const raster = rasterForPage(state, state.page) orelse return; - const full: u32 = @intCast(raster.iw); - const at = normalizedPixel(center_x, raster.iw); - if (at >= placed.src.x and at < placed.src.x + placed.src.w) return; - const overflow = full -| placed.src.w; - if (overflow == 0) return; - const wanted = @min(overflow, at -| placed.src.w / 2); - state.pan_x = @intCast( - (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, - ); -} - -pub fn stepPage(state: *State, gpa: std.mem.Allocator, delta: i64) bool { - const current: i64 = @intCast(state.page); - const last: i64 = @intCast(state.page_count -| 1); - return state.activatePage( - gpa, - @intCast(std.math.clamp(current + delta, 0, last)), - true, - ); -} - -fn scrollNormal( - state: *State, - gpa: std.mem.Allocator, - viewport: ?Viewport, - delta_pixels: f64, -) bool { - const view = viewport orelse return false; - ensureLayout(state, view); - const result = scrollDocument(state, view, delta_pixels) orelse return false; - return result.active_page != state.page and - state.activatePage(gpa, result.active_page, false); -} - -fn moveRows( - state: *State, - gpa: std.mem.Allocator, - native_images: bool, - viewport: ?Viewport, - cell_pixels: CellPixels, - direction: i8, - count: u32, -) bool { - if (!native_images) { - const pages: i64 = @intCast(@max(@as(u32, 1), count)); - return stepPage(state, gpa, if (direction > 0) pages else -pages); - } - return scrollNormal( - state, - gpa, - viewport, - @as(f64, @floatFromInt(scaledStep(cell_pixels.h, count))) * direction, - ); -} - -fn movePage( - state: *State, - gpa: std.mem.Allocator, - native_images: bool, - viewport: ?Viewport, - cell_pixels: CellPixels, - direction: i8, - kind: normal_input.Page, - count: u32, -) bool { - if (!native_images) { - const pages: i64 = @intCast(@max(@as(u32, 1), count)); - return stepPage(state, gpa, if (direction > 0) pages else -pages); - } - const base: u32 = switch (kind) { - .half_down, .half_up => if (viewport) |view| - @max(@as(u32, 1), view.pixel_h / 2) - else - cell_pixels.h, - .down, .up => if (viewport) |view| view.pixel_h else cell_pixels.h, - }; - return scrollNormal( - state, - gpa, - viewport, - @as(f64, @floatFromInt(scaledStep(base, count))) * direction, - ); -} - -/// Apply the PDF-owned portion of one parsed normal-mode action. The result -/// carries only host UI work; document page/search/pan/scroll state is updated -/// here directly from plain inputs. -pub fn applyNormal( - state: *State, - gpa: std.mem.Allocator, - semantic: normal_input.Action, - native_images: bool, - cell_pixels: CellPixels, - viewport: ?Viewport, - geometry: ?image.NativeGeometry, -) NormalResult { - if (comptime !enabled) return .{}; - var result: NormalResult = .{}; - switch (semantic) { - // Escape stays in the document and cancels what is drawn over it. The - // way OUT of a PDF is Shift-Escape, which the host takes before this - // parse ever runs — a reader who pressed Escape to drop a selection - // was not asking to be moved to another pane. - .escape => state.cancelChrome(gpa), - .move => |move| switch (move.motion) { - .down => result.page_changed = moveRows( - state, - gpa, - native_images, - viewport, - cell_pixels, - 1, - move.count, - ), - .up => result.page_changed = moveRows( - state, - gpa, - native_images, - viewport, - cell_pixels, - -1, - move.count, - ), - .left => if (native_images) if (geometry) |placed| { - _ = panPixels(state, placed, .horizontal, -1, scaledStep(cell_pixels.w, move.count)); - }, - .right => if (native_images) if (geometry) |placed| { - _ = panPixels(state, placed, .horizontal, 1, scaledStep(cell_pixels.w, move.count)); - }, - else => {}, - }, - .goto => |go| switch (go.target) { - .file_start => result.page_changed = state.activatePage( - gpa, - if (go.explicit_count) go.count -| 1 else 0, - true, - ), - .last_line => result.page_changed = state.activatePage(gpa, state.page_count -| 1, true), - .line_start, .first_nonws => if (native_images) if (geometry) |placed| - setHorizontalEdge(state, placed, false), - .line_end => if (native_images) if (geometry) |placed| - setHorizontalEdge(state, placed, true), - .line_down => result.page_changed = moveRows( - state, - gpa, - native_images, - viewport, - cell_pixels, - 1, - go.count, - ), - .line_up => result.page_changed = moveRows( - state, - gpa, - native_images, - viewport, - cell_pixels, - -1, - go.count, - ), - else => {}, - }, - .line => |line| switch (line) { - .start, .first_nonws => if (native_images) if (geometry) |placed| - setHorizontalEdge(state, placed, false), - .end => if (native_images) if (geometry) |placed| - setHorizontalEdge(state, placed, true), - }, - .goto_line => |go| { - if (go.explicit) - result.page_changed = state.activatePage(gpa, go.count -| 1, true); - }, - .page => |page| result.page_changed = switch (page.kind) { - .half_down, .down => movePage( - state, - gpa, - native_images, - viewport, - cell_pixels, - 1, - page.kind, - page.count, - ), - .half_up, .up => movePage( - state, - gpa, - native_images, - viewport, - cell_pixels, - -1, - page.kind, - page.count, - ), - }, - .view => |view| result.page_changed = switch (view) { - .scroll_down => moveRows(state, gpa, native_images, viewport, cell_pixels, 1, 1), - .scroll_up => moveRows(state, gpa, native_images, viewport, cell_pixels, -1, 1), - else => false, - }, - .leader => result.host = .leader, - .command_line => result.host = .command_line, - .search => result.host = .search, - .search_step => |direction| result.host = if (direction == .forward) - .search_forward - else - .search_backward, - else => {}, - } - return result; -} - -pub fn captureLayoutAnchor(state: *State) void { - if (comptime !enabled) return; - if (!state.layout_valid or state.page_count == 0 or state.document_height == 0) return; - const page = pageAtOffset(state, state.document_scroll_y); - const start: f64 = @floatFromInt(state.page_starts[page]); - const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[page])); - state.layout_anchor_page = page; - state.layout_anchor_fraction = std.math.clamp((state.document_scroll_y - start) / height, 0, 1); - state.layout_anchor_pending = true; -} - -fn consumeOutlineReveal(state: *State, viewport: Viewport) void { - const destination = state.outline_reveal_pending orelse return; - state.outline_reveal_pending = null; - const page = @min(destination.page, state.page_count -| 1); - const size = state.page_sizes[page]; - const raw_y = destination.y orelse 0; - const y = if (std.math.isFinite(raw_y)) std.math.clamp(raw_y, 0, size.height) else 0; - state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) + - @as(f64, y) / @as(f64, size.height) * @as(f64, @floatFromInt(state.page_heights[page])); - - if (destination.x) |raw_x| if (state.fit == .height and std.math.isFinite(raw_x)) { - const display_width = @as(f64, @floatFromInt(viewport.pixel_h)) * - @as(f64, size.width) / @as(f64, size.height); - const viewport_width: f64 = @floatFromInt(viewport.pixel_w); - if (display_width > viewport_width) { - const x = std.math.clamp(raw_x, 0, size.width); - const target = @as(f64, x) / @as(f64, size.width) * display_width; - const overflow = display_width - viewport_width; - const wanted = std.math.clamp(target - viewport_width / 2, 0, overflow); - state.pan_x = @intFromFloat(@round( - wanted / overflow * @as(f64, std.math.maxInt(u16)), - )); - } - }; - state.search_reveal_pending = false; - state.reveal_viewport_w = viewport.pixel_w; - state.reveal_viewport_h = viewport.pixel_h; - state.reveal_fit = state.fit; - state.reveal_viewport_valid = true; -} - -pub fn ensureLayout(state: *State, viewport: Viewport) void { - if (comptime !enabled) return; - const tz = tracy.zone(@src(), "pdf.ensure_layout"); - defer tz.end(); - if (state.layout_valid and !state.scroll_to_page_pending and - !state.layout_anchor_pending and - (state.layout_viewport_w != viewport.pixel_w or - state.layout_viewport_h != viewport.pixel_h)) captureLayoutAnchor(state); - if (!state.layout_valid or state.layout_viewport_w != viewport.pixel_w or - state.layout_viewport_h != viewport.pixel_h or state.layout_fit != state.fit) - { - var at: u64 = 0; - for (state.page_sizes, 0..) |size, page| { - state.page_starts[page] = at; - const height = pageHeight(size, viewport, state.fit); - state.page_heights[page] = height; - at = std.math.add(u64, at, height) catch std.math.maxInt(u64); - if (page + 1 < state.page_count) - at = std.math.add(u64, at, page_gap_px) catch std.math.maxInt(u64); - } - state.document_height = at; - state.layout_viewport_w = viewport.pixel_w; - state.layout_viewport_h = viewport.pixel_h; - state.layout_fit = state.fit; - state.layout_valid = true; - if (state.scroll_to_page_pending) { - state.document_scroll_y = @floatFromInt(state.page_starts[state.page]); - state.scroll_to_page_pending = false; - state.layout_anchor_pending = false; - } else if (state.layout_anchor_pending) { - const page = @min(state.layout_anchor_page, state.page_count -| 1); - state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) + - state.layout_anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page])); - state.layout_anchor_pending = false; - } - } - consumeOutlineReveal(state, viewport); - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - state.document_scroll_y = std.math.clamp(state.document_scroll_y, 0, max_scroll); -} - -/// The owned result of asking MuPDF for the word at one page-space point. -/// It deliberately carries no pane state: callers may retain it for a hover -/// or consume it for Look without installing a user selection or activating a -/// page. Quads and text have separate lifetimes in MuPDF, and remain separate -/// here rather than making a transient hover impersonate `pdf.Selection`. -pub const WordProbe = if (enabled) struct { - page: usize, - quads: []pdf.Quad, - text: []u8, - - pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void { - gpa.free(probe.quads); - gpa.free(probe.text); - probe.* = undefined; - } -} else void; - -/// Complete state of one native PDF pointer gesture. Core stores this beside -/// its generic drag routing; every PDF-specific transition is implemented by -/// pointerStart/pointerUpdate/pointerRelease below. The feature-off spelling -/// is deliberately empty so a build without MuPDF carries no latent UI state. -pub const PointerDrag = if (enabled) struct { - native: bool = false, - /// Right-button Look probes this cell on release without borrowing or - /// replacing the pane's persistent MuPDF selection. - word_at: ?struct { col: u16, row: u16 } = null, - /// Drag updates keep selection geometry/text live, but their expensive - /// baked raster highlight is committed once on release. - selection_changed: bool = false, -} else struct {}; - -pub const PointerAction = enum { look, exec }; - -/// Plain post-transaction work for the core. `text` is either pane-owned -/// selection text or a slice owned by `probe`; both stay valid through the -/// immediate builtin dispatch. Only the independent probe allocation is -/// released afterward, so Exec may delete the source pane safely. -pub const PointerRelease = if (enabled) struct { - action: ?PointerAction = null, - text: []const u8 = &.{}, - probe: ?WordProbe = null, - - pub fn deinit(release: *@This(), gpa: std.mem.Allocator) void { - if (release.probe) |*probe| probe.deinit(gpa); - release.* = undefined; - } -} else struct { - pub fn deinit(_: *@This(), _: std.mem.Allocator) void {} -}; - -/// Word-snap a point and copy its text without changing document or UI state. -/// Empty MuPDF selections are reported as `null`; every non-null result owns -/// both slices and must be deinitialized by the caller. -pub fn probeWord( - document: *Document, - gpa: std.mem.Allocator, - page: usize, - point: if (enabled) pdf.Point else void, -) !?WordProbe { - if (comptime !enabled) return null; - var selection = try document.select(gpa, page, point, point); - errdefer selection.deinit(gpa); - const text = try document.copySelection(gpa, page, selection.start, selection.end); - errdefer gpa.free(text); - if (selection.quads.len == 0 or text.len == 0) { - selection.deinit(gpa); - gpa.free(text); - return null; - } - // Transfer the quad allocation out of Selection. There is intentionally - // no Selection.deinit on this success path: WordProbe is now its owner. - return .{ .page = page, .quads = selection.quads, .text = text }; -} - -/// Invert the exact native placement of one retained raster band. The -/// destination is shifted by `pixel_offset_y` at presentation time, while its -/// source y is local to the retained band; both adjustments happen here before -/// returning normalized full-page coordinates. -pub fn pointAtGeometry( - geometry: image.NativeGeometry, - pixel_offset_y: f32, - full_width: usize, - full_height: usize, - band_y: usize, - pixel_x: i64, - pixel_y: i64, - clamp_to_page: bool, -) ?Point { - if (comptime !enabled) return null; - if (full_width == 0 or full_height == 0 or - geometry.src.w == 0 or geometry.src.h == 0 or - geometry.dst.w == 0 or geometry.dst.h == 0) return null; - - const left: f64 = @floatFromInt(geometry.dst.x); - const top: f64 = @floatFromInt(geometry.dst.y); - const right = left + @as(f64, @floatFromInt(geometry.dst.w)); - const bottom = top + @as(f64, @floatFromInt(geometry.dst.h)); - var x: f64 = @floatFromInt(pixel_x); - var y: f64 = @as(f64, @floatFromInt(pixel_y)) - @as(f64, pixel_offset_y); - if (clamp_to_page) { - // Half-open rectangles: keep a clamped point infinitesimally inside - // the last presented pixel instead of letting it become `right`. - const epsilon = 1.0 / 1024.0; - x = std.math.clamp(x, left, @max(left, right - epsilon)); - y = std.math.clamp(y, top, @max(top, bottom - epsilon)); - } else if (x < left or x >= right or y < top or y >= bottom) { - return null; - } - - const source_x_f = @as(f64, @floatFromInt(geometry.src.x)) + - (x - left) * @as(f64, @floatFromInt(geometry.src.w)) / - @as(f64, @floatFromInt(geometry.dst.w)); - const source_y_f = @as(f64, @floatFromInt(geometry.src.y)) + - (y - top) * @as(f64, @floatFromInt(geometry.src.h)) / - @as(f64, @floatFromInt(geometry.dst.h)); - const source_x: usize = @min(full_width - 1, @as(usize, @intFromFloat(@floor(source_x_f)))); - const band_source_y: usize = @intFromFloat(@floor(source_y_f)); - const source_y = @min(full_height - 1, band_y + band_source_y); - return .{ - .x = (@as(f32, @floatFromInt(source_x)) + 0.5) / - @as(f32, @floatFromInt(full_width)), - .y = (@as(f32, @floatFromInt(source_y)) + 0.5) / - @as(f32, @floatFromInt(full_height)), - }; -} - -// ---- core seam ---------------------------------------------------------- -// -// These functions own everything a pane does because its payload is a PDF. -// Pardes supplies the shared layout rectangle, allocators and Surface; this -// module changes the PDF state and paints its native attachments directly. -// Cross-pane placement remains a tiny Pardes primitive; the PDF-specific -// +PdfSections ownership and Look adapter live here beside their state. - -pub fn openPane( - core: *pardes.Pardes, - id: usize, - path: []const u8, - page_one_based: usize, -) !*pardes.Pane { - if (comptime !enabled) return error.PdfDisabled; - var state = try State.open(core.pdf_gpa, path, page_one_based); - errdefer state.deinit(core.pdf_gpa); - const pane = try core.newDocPane(id); - pane.pdf = state; - pane.cur_pinned = true; - core.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); - return pane; -} - -/// Release a PDF payload while its pane slot is still installed, so the host -/// can retire the corresponding directory watch before that id is reused. -pub fn deinitPane(core: *pardes.Pardes, pane: *pardes.Pane, state: *State) void { - if (comptime !enabled) return; - for (core.panes, 0..) |slot, id| { - if (slot == pane) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); - } - state.deinit(core.pdf_gpa); -} - -/// Reopen one live pane after its watched path changed. MuPDF deliberately -/// reopens the pathname so it can retain random access without requiring a -/// native watcher to allocate and copy the whole document first. -pub fn reloadPane( - core: *pardes.Pardes, - pane: *pardes.Pane, -) !void { - if (comptime !enabled) return error.PdfDisabled; - const state = &(pane.pdf orelse return error.MissingPdfState); - try state.reload(core.pdf_gpa); - resetPageChrome(pane); -} - -pub fn isSectionsOutput(pane: *const pardes.Pane) bool { - if (comptime !enabled) return false; - const file = pane.file orelse return false; - const output = file.output orelse return false; - return switch (output.from) { - .cmd => |builtin| builtin == .PdfSections, - else => false, - }; -} - -/// Refresh the exact generated outline buffer owned by this document. Content -/// revision is the ownership boundary: once a user edits it, live reload must -/// never overwrite it even if the slot and origin still look familiar. -fn refreshCleanSections(core: *pardes.Pardes, state: *State) void { - if (comptime !enabled) return; - const remembered = state.sections_output orelse return; - if (remembered.pane >= core.panes.len) return; - const result = core.panes[remembered.pane] orelse return; - if (result.serial != remembered.serial or !isSectionsOutput(result)) return; - const file = &result.file.?; - if (file.revision != remembered.revision) return; - - const content = state.renderSections(core.pdf_gpa, core.gpa) catch { - state.sections_output = null; - return; - }; - if (std.mem.eql(u8, file.content, content)) { - core.gpa.free(content); - return; - } - core.invalidateLookHover(remembered.pane); - file_pane.setContent(core, file, content); - const rows = file_pane.lineCount(file.content); - file.scroll = @min(file.scroll, rows - 1); - const row = @min(@as(usize, @intCast(@max(0, result.cur_row))), rows - 1); - result.cur_row = @intCast(row); - result.cur_col = @intCast(@min( - @as(usize, @intCast(@max(0, result.cur_col))), - modal.lineSlice(file.content, row).len, - )); - result.msel.active = false; - result.vsel.active = false; - result.nsel = 0; - state.sections_output.?.revision = file.revision; -} - -/// One successful watched-path transaction, including every piece of derived -/// PDF output. The caller owns generic update bookkeeping and status text. -pub fn reloadWatched( - core: *pardes.Pardes, - pane: *pardes.Pane, -) !void { - try reloadPane(core, pane); - refreshCleanSections(core, &pane.pdf.?); -} - -fn rearmCleanSections( - core: *pardes.Pardes, - id: usize, - pane: *pardes.Pane, - state: *State, -) bool { - const remembered = state.sections_output orelse return false; - if (remembered.pane >= core.panes.len) return false; - const result = core.panes[remembered.pane] orelse return false; - if (result.serial != remembered.serial or !isSectionsOutput(result)) return false; - const file = &result.file.?; - if (file.revision != remembered.revision) return false; - - file.scroll = 0; - result.cur_row = 0; - result.cur_col = 0; - result.msel.active = false; - result.vsel.active = false; - result.nsel = 0; - pane.search_pane = remembered.pane; - pane.search_row = null; - core.armLookWalk(remembered.pane); - core.active = id; - return true; -} - -/// Lazily materialise this pane's cached outline as a location list. All -/// PDF-specific ownership stays here; Pardes contributes only placement. -pub fn openSections(core: *pardes.Pardes, id: usize) void { - if (comptime !enabled) return; - const pane = core.panes[id] orelse return; - const state = &(pane.pdf orelse return); - if (rearmCleanSections(core, id, pane, state)) return; - const content = state.renderSections(core.pdf_gpa, core.gpa) catch return; - - const free = core.freeSlot() orelse { - core.gpa.free(content); - return; - }; - const dir = std.fs.path.dirname(state.path) orelse "/"; - const result = output_pane.open( - core, - free, - dir, - .{ .cmd = .PdfSections }, - "", - content, - ) catch { - core.gpa.free(content); - return; - }; - state.sections_output = .{ - .pane = free, - .serial = result.serial, - .revision = result.file.?.revision, - }; - core.placeDoc(id, free, result); - core.computeGeom(); - core.active = id; - pane.search_pane = free; - pane.search_row = null; - core.armLookWalk(free); -} - -const SectionsOwner = struct { - id: usize, - pane: *pardes.Pane, - state: *State, -}; - -/// Reverse the state-side ownership token. Merely having +PdfSections origin -/// is insufficient: an output may outlive its document, and duplicate panes -/// may legitimately have the same pathname. -fn sectionsOwner(core: *pardes.Pardes, output_id: usize) ?SectionsOwner { - const output = core.panes[output_id] orelse return null; - if (!isSectionsOutput(output)) return null; - const file = output.file.?; - for (core.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const state = if (pane.pdf) |*pdf_state| pdf_state else continue; - const token = state.sections_output orelse continue; - if (token.pane == output_id and token.serial == output.serial and - token.revision == file.revision) - return .{ .id = id, .pane = pane, .state = state }; - } - return null; -} - -/// Interpret the ordinal column used only by a live, exact +PdfSections -/// output. Stale/orphaned outputs are consumed inertly rather than resolving -/// their old ordinal against a different document generation. -pub fn lookSection( - core: *pardes.Pardes, - output_id: usize, - path: []const u8, - at: look.Spot, -) bool { - if (comptime !enabled) return false; - const output = core.panes[output_id] orelse return false; - if (!isSectionsOutput(output)) return false; - if (at.line == 0 or at.col == 0 or at.end_line != 0 or - !look.isPdfPath(path)) return false; - const owner = sectionsOwner(core, output_id) orelse return true; - - var joined_path: [4096]u8 = undefined; - const output_dir = std.fs.path.dirname(output.file.?.path) orelse "/"; - const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/"; - const target_path = if (std.fs.path.isAbsolute(path)) - path - else - std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true; - if (!std.mem.eql(u8, owner.state.path, target_path)) return true; - - const destination = owner.state.sectionDestination(core.pdf_gpa, at.col - 1) orelse return true; - switch (destination) { - .internal => |internal| revealOutlineDestination(core, owner.pane, internal), - .external => |uri| if (uri.len <= 256) core.emit(.{ .open_link = .from(uri) }), - .none => unreachable, - } - core.active = owner.id; - return true; -} - -pub fn resetPageChrome(pane: *pardes.Pane) void { - pane.cur_row = 0; - pane.cur_col = 0; - pane.vsel.active = false; - pane.msel.active = false; - pane.nsel = 0; -} - -pub fn activatePage( - core: *pardes.Pardes, - pane: *pardes.Pane, - page: usize, - reveal: bool, -) void { - if (comptime !enabled) return; - const state = &(pane.pdf orelse return); - if (state.activatePage(core.pdf_gpa, page, reveal)) resetPageChrome(pane); -} - -pub fn revealOutlineDestination( - core: *pardes.Pardes, - pane: *pardes.Pane, - destination: OutlineInternalDestination, -) void { - if (comptime !enabled) return; - activatePage(core, pane, destination.page, false); - const state = &pane.pdf.?; - state.queueOutlineReveal(destination); - // Consume immediately when geometry already exists; otherwise the PDF - // draw pass consumes the same value after the next layout transaction. - _ = ensurePaneLayout(core, pane); -} - -pub fn setPage(core: *pardes.Pardes, pane: *pardes.Pane, page: usize) void { - activatePage(core, pane, page, true); -} - -pub fn toggleFit(pane: *pardes.Pane) void { - if (comptime !enabled) return; - const state = &(pane.pdf orelse return); - state.toggleFit(); -} - -pub fn toggleTint(pane: *pardes.Pane) void { - if (comptime !enabled) return; - const state = &(pane.pdf orelse return); - state.toggleTint(); -} - -pub fn stepPanePage(core: *pardes.Pardes, pane: *pardes.Pane, delta: i64) void { - if (comptime !enabled) return; - const state = &(pane.pdf orelse return); - if (stepPage(state, core.pdf_gpa, delta)) resetPageChrome(pane); -} - -pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewport { - if (comptime !enabled) return null; - const rect = for (core.panes, 0..) |slot, id| { - if (slot == pane) break core.rects[id]; - } else return null; - const cols = rect.w -| config.GUTTER; - const rows = rect.h -| pardes.BOX_H; - if (cols == 0 or rows == 0) return null; - return .{ - .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w), - .pixel_h = @as(u32, rows) * @as(u32, core.cell_pixels.h), - }; -} - -pub fn ensurePaneLayout(core: *pardes.Pardes, pane: *pardes.Pane) ?Viewport { - if (comptime !enabled) return null; - const state = &(pane.pdf orelse return null); - const view = paneViewport(core, pane) orelse return null; - ensureLayout(state, view); - return view; -} - -pub fn tintColors(core: *const pardes.Pardes) TintColors { - if (comptime !enabled) return; - const theme = core.theme(); - return .{ - .background = theme.bg orelse theme.tag_bg, - .foreground = theme.fg orelse theme.tag_fg, - }; -} - -pub fn paneGeometry(core: *const pardes.Pardes, pane: *const pardes.Pane) ?image.NativeGeometry { - if (comptime !enabled) return null; - const state = if (pane.pdf) |*view| view else return null; - const view = paneViewport(core, pane) orelse return null; - return activeGeometry(state, view); -} - -pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row: u16) ?usize { - if (comptime !enabled) return null; - const state = pane.pdf orelse return null; - const rect = for (core.panes, 0..) |slot, id| { - if (slot == pane) break core.rects[id]; - } else return null; - const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; - if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null; - const local_y = @as(f64, @floatFromInt( - @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2, - )); - return pageAtViewportY(&state, local_y); -} - -pub fn paneNativeReady(core: *const pardes.Pardes, pane: *const pardes.Pane) bool { - if (comptime !enabled) return false; - if (!core.native_images) return false; - const state = if (pane.pdf) |*view| view else return false; - const view = paneViewport(core, pane) orelse return false; - return nativeReady(state, view); -} - -pub fn nativePageAtGridRow( - core: *const pardes.Pardes, - pane: *const pardes.Pane, - row: u16, -) ?usize { - if (comptime !enabled) return null; - if (!core.native_images) return null; - const state = if (pane.pdf) |*view| view else return null; - const page = pageAtGridRow(core, pane, row) orelse return null; - const view = paneViewport(core, pane) orelse return null; - if (!pageReady(state, view, page)) return null; - return page; -} - -pub fn pointAt( - core: *const pardes.Pardes, - pane: *const pardes.Pane, - col: u16, - row: u16, - clamp_to_page: bool, -) ?Point { - if (comptime !enabled) return null; - const state = pane.pdf orelse return null; - return panePointAtPage(core, pane, state.page, col, row, clamp_to_page); -} - -pub fn panePointAtPage( - core: *const pardes.Pardes, - pane: *const pardes.Pane, - page: usize, - col: u16, - row: u16, - clamp_to_page: bool, -) ?Point { - if (comptime !enabled) return null; - const state = if (pane.pdf) |*view| view else return null; - const view = paneViewport(core, pane) orelse return null; - const rect = for (core.panes, 0..) |slot, id| { - if (slot == pane) break core.rects[id]; - } else return null; - const body_x = @as(i64, rect.x + config.GUTTER); - const body_y = @as(i64, if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H); - const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2; - const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2; - return pointAtPage(state, view, page, px, py, clamp_to_page); -} - -pub fn probeAt( - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, -) ?WordProbe { - if (comptime !enabled) return null; - const page = nativePageAtGridRow(core, pane, row) orelse return null; - const point = panePointAtPage(core, pane, page, col, row, false) orelse return null; - const state = &(pane.pdf orelse return null); - return probeWord(&state.document, core.pdf_gpa, page, point) catch null; -} - -pub fn beginDrag( - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, - snap_word: bool, -) bool { - if (comptime !enabled) return false; - const hit_page = pageAtGridRow(core, pane, row) orelse return false; - if (hit_page != pane.pdf.?.page) activatePage(core, pane, hit_page, false); - const state = &pane.pdf.?; - state.clearDrag(); - const point = pointAt(core, pane, col, row, false) orelse return false; - state.drag_anchor = point; - state.drag_head = point; - if (!snap_word) return true; - if (state.selection) |selection| if (selection.contains(point)) return true; - if (state.setSelection(core.pdf_gpa, point, point, true) == .failed) { - // Preserve the old selection transactionally, but never let an - // outside click execute its stale text. - state.clearDrag(); - return false; - } - return true; -} - -pub fn beginSelection( - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, -) bool { - return beginDrag(core, pane, col, row, true); -} - -pub fn updateSelection( - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, - invalidate_raster: bool, -) SelectionUpdate { - if (comptime !enabled) return .failed; - const state = &(pane.pdf orelse return .failed); - const anchor = state.drag_anchor orelse return .failed; - const point = pointAt(core, pane, col, row, true) orelse return .failed; - if (state.drag_head) |head| if (head.x == point.x and head.y == point.y) return .stationary; - const result = state.setSelection(core.pdf_gpa, anchor, point, invalidate_raster); - if (result != .failed) state.drag_head = point; - return result; -} - -/// Begin the native portion of a generic pointer gesture. Tag clicks and PDF -/// panes without a presentable raster remain ordinary grid gestures. A Look -/// click defers its side-effect-free word probe until release; select/Exec -/// establish the persistent selection transaction immediately. -pub fn pointerStart( - core: *pardes.Pardes, - pane: *pardes.Pane, - button: pardes.Mouse.Button, - col: u16, - row: u16, - on_tag: bool, -) PointerDrag { - if (comptime !enabled) return .{}; - if (on_tag or !paneNativeReady(core, pane)) return .{}; - var drag: PointerDrag = .{ .native = true }; - if (button == config.look_button) { - drag.word_at = .{ .col = col, .row = row }; - } else if (button == config.select_button) { - _ = beginDrag(core, pane, col, row, false); - } else if (button == config.exec_button) { - _ = beginDrag(core, pane, col, row, true); - } - return drag; -} - -/// Advance selection state without baking a new raster. A right-click stays a -/// pure word probe until it crosses into a second grid cell; at that point it -/// becomes the same native selection drag as Exec. -pub fn pointerUpdate( - drag: *PointerDrag, - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, -) void { - if (comptime !enabled) return; - if (!drag.native) return; - if (drag.word_at) |at| { - if (col == at.col and row == at.row) return; - if (!beginDrag(core, pane, at.col, at.row, false)) { - drag.word_at = null; - return; - } - switch (updateSelection(core, pane, col, row, false)) { - .failed => { - drag.word_at = null; - pane.pdf.?.clearDrag(); - }, - // Adjacent grid cells can still address one raster pixel. Keep - // click mode and retry farther out. - .stationary => {}, - .unchanged => drag.word_at = null, - .changed => { - drag.word_at = null; - drag.selection_changed = true; - }, - } - return; - } - switch (updateSelection(core, pane, col, row, false)) { - .failed => pane.pdf.?.clearDrag(), - .stationary, .unchanged => {}, - .changed => drag.selection_changed = true, - } -} - -/// Cancel a native gesture before release (the acme 2-3 / 3-2 chord). Word -/// probes own nothing until release; changed selections still need their one -/// raster commit before their transient anchors are dropped. -pub fn pointerCancel(pane: *pardes.Pane, drag: PointerDrag) void { - if (comptime !enabled) return; - if (drag.native) if (pane.pdf) |*state| { - if (drag.selection_changed) state.invalidateRaster(state.page); - state.clearDrag(); - }; -} - -/// Finalize every pane mutation before returning command work to the core. -/// The caller may dispatch `action` immediately and then call deinit; no path -/// after this function needs the source pane to remain alive. -pub fn pointerRelease( - core: *pardes.Pardes, - pane: *pardes.Pane, - drag: PointerDrag, - button: pardes.Mouse.Button, - chorded: bool, -) PointerRelease { - if (comptime !enabled) return .{}; - const state = &(pane.pdf orelse return .{}); - const slot = @intFromEnum(button); - const grid_selection = pane.sel[slot]; - pane.sel[slot].state = .none; // native quads, not projected text cells - - // Drag updates changed live geometry/text while leaving baked pixels - // stable. Commit exactly once before any chord/Exec/Look can clear state. - if (drag.selection_changed) state.invalidateRaster(state.page); - if (chorded) { - state.clearDrag(); - return .{}; - } - if (drag.word_at) |at| { - const maybe_probe = probeAt(core, pane, at.col, at.row); - state.clearDrag(); - const probe = maybe_probe orelse return .{}; - // A neighboring visible page becomes current before Look dispatch. - // WordProbe owns its text/quads independently of that state change. - if (probe.page != state.page) activatePage(core, pane, probe.page, false); - const text = probe.text; - return .{ .action = .look, .text = text, .probe = probe }; - } - if (button == config.select_button) { - const dragged = grid_selection.c0 != grid_selection.c1 or - grid_selection.r0 != grid_selection.r1; - if (!dragged) state.clearSelection(core.pdf_gpa); - pane.cur_pinned = true; - pane.mode = .normal; - pane.msel.active = false; - pane.vsel.active = false; - pane.pending = 0; - pane.nsel = 0; - state.clearDrag(); - return .{}; - } - if (state.drag_anchor == null or state.selection_text.len == 0) { - state.clearDrag(); - return .{}; - } - const text = state.selection_text; - state.clearDrag(); - return .{ - .action = if (button == config.look_button) .look else .exec, - .text = text, - }; -} - -pub fn highlightInput( - core: *pardes.Pardes, - pane_id: usize, - pane: *const pardes.Pane, -) HighlightInput { - if (comptime !enabled) return; - const hover_quads: []const Quad = if (core.pdf_hover_preview) |preview| - if (preview.pane == pane_id and preview.serial == pane.serial) - preview.probe.quads - else - &.{} - else - &.{}; - const hover_page = if (hover_quads.len > 0) core.pdf_hover_preview.?.probe.page else null; - const selection_color = core.theme().sel_bg; - return .{ - .hover_quads = hover_quads, - .hover_page = hover_page, - .hover_color = selection_color, - .selection_color = selection_color, - }; -} - -pub fn panPane( - core: *pardes.Pardes, - pane: *pardes.Pane, - axis: PanAxis, - direction: i8, - display_pixels: u32, -) PanResult { - if (comptime !enabled) return .unavailable; - const placed = paneGeometry(core, pane) orelse return .unavailable; - const state = &(pane.pdf orelse return .unavailable); - return panPixels(state, placed, axis, direction, display_pixels); -} - -pub fn scrollPane(core: *pardes.Pardes, pane: *pardes.Pane, delta_pixels: f64) bool { - if (comptime !enabled) return false; - const tz = tracy.zone(@src(), "pdf.scroll_notch"); - defer tz.end(); - const state = &(pane.pdf orelse return false); - const view = ensurePaneLayout(core, pane) orelse return false; - const result = scrollDocument(state, view, delta_pixels) orelse return false; - if (result.active_page != state.page) activatePage(core, pane, result.active_page, false); - return true; -} - -pub fn verticalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void { - if (comptime !enabled) return; - if (!core.native_images) return stepPanePage(core, pane, direction); - const rows: u32 = @intCast(@max(1, config.wheel_rows)); - const pixels = scaledStep(core.cell_pixels.h, rows); - _ = scrollPane(core, pane, @as(f64, @floatFromInt(pixels)) * direction); -} - -pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void { - if (comptime !enabled) return; - const state = pane.pdf orelse return; - if (!core.native_images or state.fit != .height) return; - const cols: u32 = @intCast(@max(1, config.wheel_cols)); - _ = panPane(core, pane, .horizontal, direction, scaledStep(core.cell_pixels.w, cols)); -} - -/// Render every visible page attachment and the PDF-owned scrollbar. The -/// canonical text body remains the core's fallback when native pixels are -/// unavailable, so false means renderPane should continue normally. -pub fn draw( - core: *pardes.Pardes, - pane: *pardes.Pane, - rect: pardes.Rect, - pane_id: usize, - text_x: u16, - text_width: u16, -) bool { - if (comptime !enabled) return false; - if (!core.native_images or rect.h <= pardes.BOX_H) return false; - const state = &(pane.pdf orelse return false); - const view = paneViewport(core, pane) orelse return false; - const key = TintKey{ .mode = state.tint, .colors = tintColors(core) }; - const visible = renderFrame( - state, - core.pdf_gpa, - core.scratch.allocator(), - view, - pardes.pdf_raster_policy, - key, - highlightInput(core, pane_id, pane), - ); - var placed_any = false; - var page = visible.first; - const visible_end = visible.first + visible.len; - while (page < visible_end) : (page += 1) { - const placed = placedRaster(state, view, page) orelse continue; - if (!core.appendImagePlace(.{ - .pane = @intCast(pane_id), - .serial = pane.serial, - .native = .{ - .revision = placed.revision, - .page = @intCast(placed.page), - .fit = switch (placed.fit) { - .width => .width, - .height => .height, - }, - .pan_x = placed.pan_x, - .geometry = placed.geometry, - .pixel_offset_y = placed.pixel_offset_y, - }, - .x = text_x, - .y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, - .w = text_width, - .h = rect.h - pardes.BOX_H, - .rgba = placed.rgba, - .iw = placed.width, - // The texture contains the retained band, not the full page. - .ih = placed.band_height, - })) break; - placed_any = true; - } - if (!placed_any) return false; - - // This frame has spent the motion used to choose its raster band. - state.scroll_travel = 0; - const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; - const chrome = core.chromeTheme(); - const theme = core.theme(); - const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; - core.surface.fill(rect.x, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); - core.surface.fill(rect.x + 1, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg }); - - const track_h: usize = rect.h - pardes.BOX_H; - const total = @max(@as(u64, 1), state.document_height); - const len = @max( - @as(usize, 1), - @as(usize, @intCast(@min( - @as(u64, track_h), - @as(u64, track_h) * view.pixel_h / total, - ))), - ); - const offset: u64 = @intFromFloat(@floor(state.document_scroll_y)); - const pos: usize = @intCast(@min( - @as(u64, track_h -| 1), - @as(u64, track_h) * offset / total, - )); - var y = pos; - while (y < track_h and y < pos + len) : (y += 1) - core.surface.fill( - rect.x, - body_y + @as(u16, @intCast(y)), - 1, - 1, - .{ .bg = .{ .rgb = chrome.scroll_thumb } }, - ); - return true; -} - -pub const SectionRows = if (enabled) struct { - pub fn usableDestination(destination: pdf.OutlineDestination) ?pdf.OutlineDestination { - return switch (destination) { - .internal => destination, - .external => |uri| if (safeHttpUri(uri)) destination else null, - .none => null, - }; - } - - fn safeHttpUri(uri: []const u8) bool { - // Effect.open_link is inline and cannot carry a larger URL. Omitting - // it here is preferable to rendering a row which can never act. - if (uri.len > 256) return false; - var has_scheme = false; - for (config.url_schemes) |scheme| { - if (std.mem.startsWith(u8, uri, scheme)) { - has_scheme = true; - break; - } - } - if (!has_scheme) return false; - for (uri) |byte| if (byte <= 0x20 or byte == 0x7f) return false; - return true; - } - - /// A structural node goes to the first later DFS entry still below it - /// which carries a usable destination. Nodes with their own unusable URI - /// are not structural: omit them instead of silently changing their link. - pub fn resolve(entries: []const pdf.OutlineEntry, ordinal: usize) ?pdf.OutlineDestination { - if (ordinal >= entries.len) return null; - const entry = entries[ordinal]; - if (entry.destination != .none) return usableDestination(entry.destination); - var i = ordinal + 1; - while (i < entries.len and entries[i].depth > entry.depth) : (i += 1) - if (usableDestination(entries[i].destination)) |destination| return destination; - return null; - } - - /// Resolve every rendered row in one DFS pass. `resolve` above stays the - /// public single-ordinal policy used by Look; bulk materialisation avoids - /// rescanning the same descendant chain for every structural ancestor. - /// The one-usize-per-entry table is transient (64 KiB at MuPDF's 8192 - /// outline-item limit) and stores source ordinals, so URI slices continue - /// to borrow from the document-owned outline instead of being copied. - pub fn resolveOrdinals( - gpa: std.mem.Allocator, - entries: []const pdf.OutlineEntry, - ) ![]usize { - const unresolved = std.math.maxInt(usize); - const ordinals = try gpa.alloc(usize, entries.len); - @memset(ordinals, unresolved); - - const Pending = struct { depth: u8, ordinal: usize }; - // OutlineEntry.depth is u8. A valid DFS path therefore cannot hold - // more than 256 simultaneously unresolved ancestors, independent of - // the tighter limit enforced by the MuPDF bridge. - var pending: [256]Pending = undefined; - var pending_len: usize = 0; - for (entries, 0..) |entry, ordinal| { - while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth) - pending_len -= 1; - - if (usableDestination(entry.destination) != null) { - ordinals[ordinal] = ordinal; - for (pending[0..pending_len]) |ancestor| - ordinals[ancestor.ordinal] = ordinal; - pending_len = 0; - } else if (entry.destination == .none) { - pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal }; - pending_len += 1; - } - } - return ordinals; - } - - fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize { - const raw = title orelse { - if (out) |buf| @memcpy(buf[0..10], "[untitled]"); - return 10; - }; - var at: usize = 0; - var i: usize = 0; - var wrote = false; - var pending_space = false; - while (i < raw.len) { - const n: usize = std.unicode.utf8ByteSequenceLength(raw[i]) catch { - pending_space = wrote; - i += 1; - continue; - }; - if (i + n > raw.len) { - pending_space = wrote; - break; - } - const cp = std.unicode.utf8Decode(raw[i .. i + n]) catch { - pending_space = wrote; - i += n; - continue; - }; - const whitespace_or_control = cp <= 0x20 or cp == 0x7f or - (cp >= 0x80 and cp <= 0x9f) or cp == 0x2028 or cp == 0x2029; - if (whitespace_or_control) { - pending_space = wrote; - } else { - if (pending_space) { - if (out) |buf| buf[at] = ' '; - at += 1; - } - if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]); - at += n; - wrote = true; - pending_space = false; - } - i += n; - } - if (!wrote) { - if (out) |buf| @memcpy(buf[0..13], "[empty title]"); - return 13; - } - return at; - } - - pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf.OutlineEntry) ![]u8 { - const target = std.fs.path.basename(path); - const ordinals = try resolveOrdinals(gpa, entries); - defer gpa.free(ordinals); - var total: usize = 0; - for (entries, 0..) |entry, ordinal| { - const resolved = ordinals[ordinal]; - if (resolved == std.math.maxInt(usize)) continue; - const destination = usableDestination(entries[resolved].destination) orelse unreachable; - total += switch (destination) { - .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), - .external => |uri| std.fmt.count("{s} ", .{uri}), - .none => unreachable, - }; - total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1; - } - const out = try gpa.alloc(u8, total); - errdefer gpa.free(out); - var at: usize = 0; - for (entries, 0..) |entry, ordinal| { - const resolved = ordinals[ordinal]; - if (resolved == std.math.maxInt(usize)) continue; - const destination = usableDestination(entries[resolved].destination) orelse unreachable; - const prefix = switch (destination) { - .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), - .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}), - .none => unreachable, - }; - at += prefix.len; - const indent = @as(usize, entry.depth) * 2; - @memset(out[at..][0..indent], ' '); - at += indent; - at += cleanTitle(out[at..], entry.title); - out[at] = '\n'; - at += 1; - } - return out; - } -} else struct {}; - -test "PDF section rows preserve DFS ordinals and sanitise hierarchy" { - if (!enabled) return; - const entries = [_]pdf.OutlineEntry{ - .{ .depth = 0, .title = null, .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 1, .title = " Child\n\tTitle\x01 Café \u{2028} Next ", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = 12, .y = 34 } } }, - .{ .depth = 0, .title = "", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 0, .x = null, .y = null } } }, - .{ .depth = 0, .title = "External", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/manual" } }, - .{ .depth = 0, .title = "Dead branch", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 0, .title = "Unsafe", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:alert" } }, - .{ .depth = 0, .title = "Linked branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 1, .title = "Deep link", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/deep" } }, - }; - const rows = try SectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries); - defer std.testing.allocator.free(rows); - try std.testing.expectEqualStrings( - "manual.pdf:3:1 [untitled]\n" ++ - "manual.pdf:3:2 Child Title Café Next\n" ++ - "manual.pdf:1:3 [empty title]\n" ++ - "https://example.com/manual External\n" ++ - "https://example.com/deep Linked branch\n" ++ - "https://example.com/deep Deep link\n", - rows, - ); - try std.testing.expect(SectionRows.resolve(&entries, 4) == null); - try std.testing.expect(SectionRows.resolve(&entries, 5) == null); - const resolved = try SectionRows.resolveOrdinals(std.testing.allocator, &entries); - defer std.testing.allocator.free(resolved); - for (entries, 0..) |_, ordinal| { - const single = SectionRows.resolve(&entries, ordinal); - if (resolved[ordinal] == std.math.maxInt(usize)) { - try std.testing.expect(single == null); - } else { - const bulk = SectionRows.usableDestination(entries[resolved[ordinal]].destination) orelse - return error.MissingBulkPdfSectionDestination; - try std.testing.expect(single != null); - try std.testing.expect(std.meta.eql(single.?, bulk)); - } - } - - // Every allocation site in the ordinal table and output growth remains - // atomic: the testing allocator sees each induced failure cleaned up - // before the first index at which the whole render can succeed. - var rendered = false; - for (0..64) |fail_index| { - var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ - .fail_index = fail_index, - }); - const failure_gpa = failing.allocator(); - const attempt = SectionRows.render(failure_gpa, "/tmp/manual.pdf", &entries) catch |err| { - try std.testing.expectEqual(error.OutOfMemory, err); - continue; - }; - failure_gpa.free(attempt); - rendered = true; - break; - } - try std.testing.expect(rendered); -} - -test "bulk PDF section resolution matches single Look policy across DFS boundaries" { - if (!enabled) return; - const entries = [_]pdf.OutlineEntry{ - .{ .depth = 0, .title = "Resolved root", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 1, .title = "Unsafe branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:unsafe" } }, - .{ .depth = 2, .title = "Safe grandchild", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 1, .x = 4, .y = 8 } } }, - .{ .depth = 1, .title = "Unresolved child", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 1, .title = "Sibling", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = null, .y = null } } }, - .{ .depth = 0, .title = "Unresolved root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 0, .title = "Next root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 3, .x = null, .y = null } } }, - }; - const bulk = try SectionRows.resolveOrdinals(std.testing.allocator, &entries); - defer std.testing.allocator.free(bulk); - const none = std.math.maxInt(usize); - try std.testing.expectEqualSlices(usize, &.{ 2, none, 2, none, 4, none, 6 }, bulk); - for (entries, 0..) |_, ordinal| { - const single = SectionRows.resolve(&entries, ordinal); - if (bulk[ordinal] == none) { - try std.testing.expect(single == null); - } else { - const destination = SectionRows.usableDestination(entries[bulk[ordinal]].destination) orelse - return error.MissingBoundaryPdfSectionDestination; - try std.testing.expect(single != null); - try std.testing.expect(std.meta.eql(single.?, destination)); - } - } -} - -test "PDF word probe owns quads and text without a selection object" { - if (comptime !enabled) return; - var document = try Document.open("docs/design.pdf"); - defer document.deinit(); - var found = try document.search(std.testing.allocator, 0, "Pardes"); - defer found.deinit(std.testing.allocator); - const quad = found.quads[0].quad; - const point = Point{ - .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4, - .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4, - }; - - // Exactly one shared probe exercises the primitive used by both UI paths; - // click-vs-hover equivalence belongs to production structure, not a second - // test that can only restate this MuPDF result. - var probe = (try probeWord(&document, std.testing.allocator, 0, point)) orelse - return error.MissingPdfWordProbe; - defer probe.deinit(std.testing.allocator); - try std.testing.expectEqual(@as(usize, 0), probe.page); - try std.testing.expect(probe.quads.len > 0); - try std.testing.expect(std.ascii.indexOfIgnoreCase(probe.text, "Pardes") != null); -} - -test "PDF point mapping restores band origin and fractional placement" { - if (comptime !enabled) return; - const point = pointAtGeometry( - .{ - .src = .{ .x = 20, .y = 10, .w = 40, .h = 20 }, - .dst = .{ .x = 100, .y = 30, .w = 80, .h = 20 }, - }, - -0.5, - 200, - 200, - 80, - 120, - 34, - false, - ) orelse return error.MissingPdfMappedPoint; - // x: src 30; y: band 80 + local src 14. The returned coordinates address - // pixel centers, matching MuPDF's normalized page-space contract. - try std.testing.expectApproxEqAbs(@as(f32, 30.5 / 200.0), point.x, 0.000_001); - try std.testing.expectApproxEqAbs(@as(f32, 94.5 / 200.0), point.y, 0.000_001); -} diff --git a/src/pdf_pane_integration_test.zig b/src/pdf_pane_integration_test.zig deleted file mode 100644 index f0a8be8f..00000000 --- a/src/pdf_pane_integration_test.zig +++ /dev/null @@ -1,1819 +0,0 @@ -//! End-to-end PDF pane tests. Production state and behavior stay in pdf_pane.zig; -//! this module exercises their direct seam with Pardes layout, input, and output. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const pdf_pane = @import("pdf_pane.zig"); -const file_pane = @import("file_pane.zig"); -const look = @import("look.zig"); -const pdf_impl = pdf_pane.pdf; -const image = @import("image.zig"); -const config = @import("config.zig"); -const builtins = @import("builtins.zig"); -const animation = pardes.animation; -const dump = pardes.dump; - -const Pardes = pardes.Pardes; -const Builtin = builtins.registry.Builtin(); -const Key = pardes.Key; -const Event = pardes.Event; -const Mode = pardes.Mode; -const Surface = pardes.Surface; -const ImagePlace = pardes.ImagePlace; -const PdfFitMode = pdf_pane.FitMode; -const PdfTintMode = pdf_pane.TintMode; -const pdf_enabled = pdf_pane.enabled; -const platform = pardes.platform; -const themes = pardes.themes; -const pdf_raster_policy = pardes.pdf_raster_policy; -const PDF_PAGE_GAP_PX = pardes.PDF_PAGE_GAP_PX; -const BOX_H = pardes.BOX_H; -const sel_slot = @intFromEnum(config.select_button); -const pane_tail = " " ++ config.pane_builtins_str; - -fn hasPdf(pane: *const pardes.Pane) bool { - return if (comptime pdf_enabled) pane.pdf != null else false; -} - -test "PDF feature gates keep argv and builtin behavior coherent" { - const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit"); - const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint"); - const maybe_sections = std.meta.stringToEnum(Builtin, "PdfSections"); - try std.testing.expectEqual(pdf_enabled, maybe_fit != null); - try std.testing.expectEqual(pdf_enabled, maybe_tint != null); - try std.testing.expectEqual(pdf_enabled, maybe_sections != null); - if (pdf_enabled) { - try std.testing.expectEqualStrings("tz", config.leader_path.get(maybe_fit.?).?); - try std.testing.expectEqualStrings("ti", config.leader_path.get(maybe_tint.?).?); - try std.testing.expectEqualStrings("ts", config.leader_path.get(maybe_sections.?).?); - } else if (platform != .web) { - const p = try Pardes.init(std.testing.allocator, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - try std.testing.expect(pane.file != null); - try std.testing.expect(pane.image == null); - try std.testing.expect(!hasPdf(pane)); - } -} - -test "PDF dump fallback remains a byte-preserving file" { - const gpa = std.testing.allocator; - const path = "/definitely/missing/pardes-dump-fallback.PDF"; - const source = "%PDF embedded fallback bytes\x00\xff"; - const encoded = try dump.encodeBytes(gpa, source); - defer gpa.free(encoded); - const ids = [_]usize{0}; - const columns = [_]dump.Column{.{ .panes = &ids }}; - const panes = [_]dump.Pane{.{ - .kind = .image, - .tag = "pdf 3/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del", - .body = "", - .scroll = 2, - .cols = 80, - .rows = 24, - .image = .{ .path = path, .bytes_b64 = encoded }, - }}; - const state = dump.State{ - .screen = .{ .cols = 80, .rows = 24 }, - .columns = &columns, - .panes = &panes, - }; - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); - - const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, out.written()); - defer restored.deinit(); - const pane = restored.panes[0].?; - try std.testing.expect(pane.file != null); - try std.testing.expect(pane.image == null); - try std.testing.expect(!hasPdf(pane)); - try std.testing.expectEqualStrings(path, pane.file.?.path); - try std.testing.expectEqualSlices(u8, source, pane.file.?.content); - try std.testing.expect(pane.tag_init); - try std.testing.expectEqualStrings(" Keep Del", pane.tag_tail[0..pane.tag_tail_len]); -} - -test "PdfSections Look follows the exact owning PDF, not an equal path" { - 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 = 80, .rows = 28 }); - defer p.deinit(); - const first = p.panes[0].?; - // Bare normal-mode `f` on a PDF dispatches the PdfSections builtin. - p.update(.{ .key = .{ .cp = 'f', .text = "f" } }); - const first_output_id = first.search_pane orelse return error.MissingPdfSectionsOutput; - const first_output = p.panes[first_output_id].?; - try std.testing.expect(pdf_pane.isSectionsOutput(first_output)); - try std.testing.expect(first_output.file.?.content.len > 0); - - // A clean result is re-armed in place without rebuilding it. - const first_revision = first_output.file.?.revision; - pdf_pane.openSections(p, 0); - try std.testing.expectEqual(first_output_id, first.search_pane.?); - try std.testing.expectEqual(first_revision, first_output.file.?.revision); - - const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot; - const duplicate = try pdf_pane.openPane(p, duplicate_id, path, 0); - p.placeDoc(0, duplicate_id, duplicate); - p.computeGeom(); - pdf_pane.openSections(p, duplicate_id); - const duplicate_output_id = duplicate.search_pane orelse return error.MissingDuplicatePdfSections; - - const first_row = std.mem.sliceTo(first_output.file.?.content, '\n'); - const target = first_row[0 .. std.mem.indexOfScalar(u8, first_row, ' ') orelse first_row.len]; - p.lookAt(first_output_id, target); - try std.testing.expectEqual(@as(usize, 0), p.active); - p.lookAt(duplicate_output_id, target); - try std.testing.expectEqual(duplicate_id, p.active); - - // Once the original document is gone, its output cannot reinterpret an - // old ordinal against the still-open equal-path duplicate. - try std.testing.expect(pardes.pdf_test.runBuiltin(p, "Del", 0, "", null)); - p.active = first_output_id; - p.lookAt(first_output_id, target); - try std.testing.expectEqual(first_output_id, p.active); -} - -test "PdfSections caches an empty outline output" { - 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.makeNoOutlineTestPdf(gpa); - defer gpa.free(fixture); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path}); - const p = try Pardes.init(gpa, .{ .file = path }); - defer p.deinit(); - pdf_pane.openSections(p, 0); - const pane = p.panes[0].?; - const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections; - try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len); - const serial = p.panes[output_id].?.serial; - pdf_pane.openSections(p, 0); - try std.testing.expectEqual(output_id, pane.search_pane.?); - try std.testing.expectEqual(serial, p.panes[output_id].?.serial); -} - -test "SDL PDF raster policy is materially denser than Kitty" { - try std.testing.expect( - pardes.sdl_pdf_raster_policy.dpi >= pardes.kitty_pdf_raster_policy.dpi * 2, - ); - try std.testing.expect( - pardes.sdl_pdf_raster_policy.max_dimension > - pardes.kitty_pdf_raster_policy.max_dimension * 3, - ); - try std.testing.expect(!pardes.kitty_pdf_raster_policy.match_viewport); - try std.testing.expect(pardes.sdl_pdf_raster_policy.match_viewport); -} - -test "watched PDF reload replaces MuPDF state and preserves the reading view" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const original = try pdf_impl.makeOutlineTestPdf(gpa); - defer gpa.free(original); - const replacement = try pdf_impl.makeNoOutlineTestPdf(gpa); - defer gpa.free(replacement); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint( - &path_buf, - ".zig-cache/tmp/{s}/live.pdf", - .{tmp.sub_path}, - ); - - const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); - defer p.deinit(); - var watched = false; - while (p.nextEffect()) |effect| switch (effect) { - .watch => |watch| if (watch.pane == 0 and watch.on) { - watched = true; - }, - else => {}, - }; - try std.testing.expect(watched); - - const pane = p.panes[0].?; - const state = &pane.pdf.?; - try std.testing.expectEqual(@as(usize, 3), state.page_count); - pdf_pane.openSections(p, 0); - const sections_id = pane.search_pane orelse return error.MissingPdfSectionsOutput; - const sections = p.panes[sections_id].?; - try std.testing.expect(sections.file.?.content.len > 0); - - p.native_images = true; - state.fit = .height; - state.tint = .full; - state.pan_x = 1234; - state.pan_y = 4321; - try state.setSearchQuery(p.pdf_gpa, "needle"); - pdf_pane.setPage(p, pane, 2); - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - _ = try p.render(frame.allocator()); - state.text_scroll = 7; - state.search_hit = 4; - state.search_reveal_pending = false; - const reveal_viewport_w = state.reveal_viewport_w; - const reveal_viewport_h = state.reveal_viewport_h; - const reveal_fit = state.reveal_fit; - const reveal_viewport_valid = state.reveal_viewport_valid; - const old_revision = pdf_pane.rasterForPage(state, 2).?.revision; - const old_revision_counter = state.next_raster_revision; - const anchor_fraction: f64 = 0.375; - state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[2])) + - anchor_fraction * @as(f64, @floatFromInt(state.page_heights[2])); - state.scroll_to_page_pending = false; - state.layout_anchor_pending = false; - pane.cur_row = 9; - pane.cur_col = 8; - - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = replacement }); - try std.testing.expect(p.reloadWatchedFile(0, &.{})); - - try std.testing.expectEqual(@as(usize, 1), state.page_count); - try std.testing.expectEqual(@as(usize, 0), state.page); - try std.testing.expectEqual(PdfFitMode.height, state.fit); - try std.testing.expectEqual(PdfTintMode.full, state.tint); - try std.testing.expectEqual(@as(u16, 1234), state.pan_x); - try std.testing.expectEqual(@as(u16, 4321), state.pan_y); - try std.testing.expectEqual(@as(usize, 7), state.text_scroll); - try std.testing.expect(state.text_scroll_clamp_pending); - try std.testing.expectEqualStrings("needle", state.search_query); - try std.testing.expectEqual(@as(usize, 4), state.search_hit); - try std.testing.expectEqual(reveal_viewport_w, state.reveal_viewport_w); - try std.testing.expectEqual(reveal_viewport_h, state.reveal_viewport_h); - try std.testing.expectEqual(reveal_fit, state.reveal_fit); - try std.testing.expectEqual(reveal_viewport_valid, state.reveal_viewport_valid); - try std.testing.expectEqual(old_revision_counter, state.next_raster_revision); - try std.testing.expectEqual(@as(usize, 0), state.rasters_len); - try std.testing.expect(state.layout_anchor_pending); - try std.testing.expectEqual(@as(usize, 2), state.layout_anchor_page); - try std.testing.expectApproxEqAbs(anchor_fraction, state.layout_anchor_fraction, 0.0001); - try std.testing.expectEqual(@as(i32, 0), pane.cur_row); - try std.testing.expectEqual(@as(i32, 0), pane.cur_col); - _ = state.ensureText(p.pdf_gpa); - const text_lines = std.mem.count(u8, state.text, "\n") + 1; - try std.testing.expect(state.text_scroll < text_lines); - try std.testing.expect(!state.text_scroll_clamp_pending); - - // A clean generated outline is derived data: it refreshes in place and - // remains the remembered output. This replacement deliberately has none. - try std.testing.expectEqual(@as(usize, 0), sections.file.?.content.len); - try std.testing.expectEqual(sections_id, state.sections_output.?.pane); - try std.testing.expectEqual(sections.file.?.revision, state.sections_output.?.revision); - - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - const fresh_raster = pdf_pane.rasterForPage(state, 0).?; - try std.testing.expect(fresh_raster.revision != old_revision); - try std.testing.expect(fresh_raster.revision > old_revision_counter); - const expected_scroll = anchor_fraction * - @as(f64, @floatFromInt(state.page_heights[0])); - const viewport = pdf_pane.paneViewport(p, pane).?; - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - try std.testing.expectApproxEqAbs(@min(expected_scroll, max_scroll), state.document_scroll_y, 0.001); - - // Reopen is transactional: malformed replacement bytes leave the last - // good MuPDF handle and every durable setting untouched. - const good_handle = state.document.handle; - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = "not a PDF" }); - try std.testing.expect(!p.reloadWatchedFile(0, &.{})); - try std.testing.expectEqual(good_handle, state.document.handle); - try std.testing.expectEqual(@as(usize, 1), state.page_count); - try std.testing.expectEqualStrings("needle", state.search_query); - try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PDF reload") != null); - - // A user edit breaks the generated-revision token. A later valid reload - // updates the document but leaves those user-owned output bytes alone. - file_pane.setContent(p, §ions.file.?, try gpa.dupe(u8, "edited sections\n")); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); - try std.testing.expect(p.reloadWatchedFile(0, &.{})); - try std.testing.expectEqual(@as(usize, 3), state.page_count); - try std.testing.expectEqualStrings("edited sections\n", sections.file.?.content); - - // The watch follows the PDF payload's lifetime just like a text file's; - // emit the stop while the slot still identifies the disappearing pane. - p.deinitPane(pane); - p.panes[0] = null; - var unwatched = false; - while (p.nextEffect()) |effect| switch (effect) { - .watch => |watch| if (watch.pane == 0 and !watch.on) { - unwatched = true; - }, - else => {}, - }; - try std.testing.expect(unwatched); -} - -test "PDF reload does not re-center an already revealed matching search" { - if (!pdf_enabled or platform == .web) return; - const gpa = std.testing.allocator; - const fixture = try look.readFile(gpa, "docs/design.pdf"); - defer gpa.free(fixture); - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/search.pdf", .{tmp.sub_path}); - const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); - defer p.deinit(); - p.native_images = true; - const pane = p.panes[0].?; - const state = &pane.pdf.?; - try state.setSearchQuery(p.pdf_gpa, "Pardes"); - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - _ = try p.render(frame.allocator()); - try std.testing.expect(state.search_results != null); - try std.testing.expect(state.search_results.?.hit_count > 0); - try std.testing.expect(state.reveal_viewport_valid); - - // Reading moved on after the original reveal. A reload retains the query - // and rebuilds its quads, but it must not mistake that for a new request to - // jump back to the hit. - const anchor_page = state.page; - const anchor_fraction: f64 = 0.82; - state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[anchor_page])) + - anchor_fraction * @as(f64, @floatFromInt(state.page_heights[anchor_page])); - state.scroll_to_page_pending = false; - state.layout_anchor_pending = false; - state.search_reveal_pending = false; - const reveal_w = state.reveal_viewport_w; - const reveal_h = state.reveal_viewport_h; - const reveal_fit = state.reveal_fit; - - // Same semantic document on a fresh generation keeps the expected anchor - // easy to state while still rebuilding every MuPDF-owned search object. - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture }); - try std.testing.expect(p.reloadWatchedFile(0, &.{})); - try std.testing.expectEqual(reveal_w, state.reveal_viewport_w); - try std.testing.expectEqual(reveal_h, state.reveal_viewport_h); - try std.testing.expectEqual(reveal_fit, state.reveal_fit); - try std.testing.expect(!state.search_reveal_pending); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - - const page = @min(anchor_page, state.page_count - 1); - const anchored = @as(f64, @floatFromInt(state.page_starts[page])) + - anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page])); - const viewport = pdf_pane.paneViewport(p, pane).?; - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - try std.testing.expectApproxEqAbs(@min(anchored, max_scroll), state.document_scroll_y, 0.001); -} - -test "MuPDF pane renders, navigates, searches, and round-trips its page" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - try std.testing.expect(hasPdf(pane)); - try std.testing.expect(pane.pdf.?.page_count > 1); - try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); - try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w); - try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h); - const initial_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null); - try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null); - try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null); - try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null); - try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail)); - - p.native_images = true; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - const first = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(usize, 1), first.nimages); - const first_place = first.images[0].?; - try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4); - try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit); - const request = pdf_pane.renderRequest( - pdf_pane.paneViewport(p, pane) orelse return error.MissingPdfViewport, - pdf_raster_policy, - ); - try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi); - try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension); - try std.testing.expectEqual( - request, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.request, - ); - try std.testing.expect(@max(first_place.iw, first_place.ih) <= request.max_dimension); - if (pdf_raster_policy.match_viewport) { - const viewport = pdf_pane.paneViewport(p, pane) orelse return error.MissingPdfViewport; - try std.testing.expectEqual(viewport.pixel_w, request.minimum_width); - try std.testing.expectEqual(viewport.pixel_h, request.minimum_height); - try std.testing.expect(first_place.iw >= viewport.pixel_w or - @max(first_place.iw, first_place.ih) == request.max_dimension); - try std.testing.expect(first_place.ih >= viewport.pixel_h or - @max(first_place.iw, first_place.ih) == request.max_dimension); - } else { - try std.testing.expectEqual(@as(u32, 0), request.minimum_width); - try std.testing.expectEqual(@as(u32, 0), request.minimum_height); - } - - // SDL's raw dy path retains fractions in the placement itself; it does - // not leave native pixels fixed while only the underlying cells slide. - p.update(.{ .pdf_scroll = .{ .pane = 0, .delta_pixels = 0.25 } }); - try std.testing.expectEqual(@as(f64, 0.25), pane.pdf.?.document_scroll_y); - _ = frame.reset(.retain_capacity); - const fractional = try p.render(frame.allocator()); - try std.testing.expectEqual(first_place.native.revision, fractional.images[0].?.native.revision); - try std.testing.expectEqual(@as(f32, -0.25), fractional.images[0].?.native.pixel_offset_y); - pane.pdf.?.document_scroll_y = 0; - - // The default reading view moves one exact display-cell distance without - // replacing page pixels. Document placement, not texture identity, moves. - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); - try std.testing.expectEqual(@as(f64, p.cell_pixels.h), pane.pdf.?.document_scroll_y); - try std.testing.expectEqual( - first_place.native.revision, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - _ = frame.reset(.retain_capacity); - const panned = try p.render(frame.allocator()); - try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision); - try std.testing.expect(panned.images[0].?.native.geometry.?.src.y > first_place.native.geometry.?.src.y); - - const row_scroll = pane.pdf.?.document_scroll_y; - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '3' } }); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 3), pane.pdf.?.document_scroll_y); - - // Counts survive a shared multi-key prefix. An invalid continuation is - // consumed and clears both prefix and count before the following action. - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 2), pane.pdf.?.document_scroll_y); - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '4' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = '?' } }); - try std.testing.expectEqual(@as(u21, 0), pane.pending); - try std.testing.expectEqual(@as(u32, 0), pane.count); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y); - - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); - const half_scroll = pane.pdf.?.document_scroll_y; - try std.testing.expect(half_scroll > row_scroll); - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); - const counted_half = pane.pdf.?.document_scroll_y; - try std.testing.expectEqual(half_scroll * 2, counted_half); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'u', .ctrl = true } }); - try std.testing.expectEqual(@as(f64, 0), pane.pdf.?.document_scroll_y); - pane.pdf.?.document_scroll_y = @as(f64, p.cell_pixels.h) * 4; - p.update(.{ .key = .{ .cp = '3' } }); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y); - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); - try std.testing.expect(pane.pdf.?.document_scroll_y >= half_scroll); - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); - const key_viewport = pdf_pane.paneViewport(p, pane).?; - const max_key_scroll = @as(f64, @floatFromInt(pane.pdf.?.document_height -| key_viewport.pixel_h)); - const counted_full = @min(@as(f64, @floatFromInt(key_viewport.pixel_h * 2)), max_key_scroll); - try std.testing.expectEqual(counted_full, pane.pdf.?.document_scroll_y); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); - try std.testing.expectEqual(@max(@as(f64, 0), counted_full - @as(f64, @floatFromInt(key_viewport.pixel_h * 2))), pane.pdf.?.document_scroll_y); - try std.testing.expectEqual( - first_place.native.revision, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - - // At a page boundary both page rasters coexist, the gap remains uncovered, - // and a row step crosses it without snapping either page to an edge. - // - // This lands by ASSIGNMENT, which is a jump and not a fling — and the - // travel counter has to say so: the keys above scrolled two screenfuls - // without any frame in between to spend that distance, which no shell does - // (every wheel batch is followed by a draw). Left unspent it would make the - // frame below the first frame of a fling and hand it bands. - const viewport = pdf_pane.paneViewport(p, pane).?; - pane.pdf.?.scroll_travel = 0; - pane.pdf.?.document_scroll_y = @floatFromInt( - pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2, - ); - _ = frame.reset(.retain_capacity); - const boundary = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(usize, 2), boundary.nimages); - try std.testing.expectEqual(@as(u32, 0), boundary.images[0].?.native.page); - try std.testing.expectEqual(@as(u32, 1), boundary.images[1].?.native.page); - const first_bottom = @as(f32, @floatFromInt( - boundary.images[0].?.native.geometry.?.dst.y + - boundary.images[0].?.native.geometry.?.dst.h, - )) + boundary.images[0].?.native.pixel_offset_y; - const second_top = @as(f32, @floatFromInt( - boundary.images[1].?.native.geometry.?.dst.y, - )) + boundary.images[1].?.native.pixel_offset_y; - const visible_gap = second_top - first_bottom; - try std.testing.expect(visible_gap >= @as(f32, PDF_PAGE_GAP_PX)); - try std.testing.expect(visible_gap <= @as(f32, PDF_PAGE_GAP_PX + 1)); - const before_boundary_scroll = pane.pdf.?.document_scroll_y; - const page0_revision = boundary.images[0].?.native.revision; - const page1_revision = boundary.images[1].?.native.revision; - - // A scroll can activate the already-cached neighbor before the shell's - // next draw. Every consumer resolves the active page's resident raster, - // so a queued click cannot accidentally use page zero's dimensions. - pane.pdf.?.document_scroll_y = @as(f64, @floatFromInt(pane.pdf.?.page_starts[1])) - 0.5; - try std.testing.expectEqual( - page0_revision, - pdf_pane.rasterForPage(&pane.pdf.?, 0).?.revision, - ); - try std.testing.expect(pdf_pane.scrollPane(p, pane, 1)); - try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); - try std.testing.expectEqual( - page1_revision, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - pdf_pane.activatePage(p, pane, 0, false); - pane.pdf.?.document_scroll_y = before_boundary_scroll; - - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(before_boundary_scroll + p.cell_pixels.h, pane.pdf.?.document_scroll_y); - _ = frame.reset(.retain_capacity); - const second = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(usize, 2), second.nimages); - try std.testing.expectEqual(page0_revision, second.images[0].?.native.revision); - try std.testing.expectEqual(page1_revision, second.images[1].?.native.revision); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y); - - // Once page zero is wholly outside the viewport, both its owned RGBA and - // backend placement disappear; returning later renders a new raster. What - // does NOT go back is the memory: the departing page's bytes are parked in - // the relay and the arriving page of the same size takes them, so a fling - // never asks the allocator (or the kernel's fault handler) for megabytes it - // just gave up. - pdf_pane.setPage(p, pane, 1); - _ = frame.reset(.retain_capacity); - const away = try p.render(frame.allocator()); - try std.testing.expect(away.nimages > 0); - for (away.images[0..away.nimages]) |maybe| if (maybe) |place| - try std.testing.expect(place.native.page != 0); - try std.testing.expect(pdf_pane.rasterForPage(&pane.pdf.?, 0) == null); - try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len); - const retired = pane.pdf.?.spare[0]; - pdf_pane.setPage(p, pane, 0); - _ = frame.reset(.retain_capacity); - const returned = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(u32, 0), returned.images[0].?.native.page); - try std.testing.expectEqual(retired.ptr, returned.images[0].?.rgba.ptr); - try std.testing.expect(returned.images[0].?.native.revision != page0_revision); - - // PdfFit exists as a real builtin in this build. It resets placement but - // preserves the current page pixels; fit-height j/k remains continuous in - // the same document-pixel coordinate space. - const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse - return error.MissingPdfFitBuiltin; - const revision_before_toggle = pdf_pane.rasterForPage( - &pane.pdf.?, - pane.pdf.?.page, - ).?.revision; - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(fit_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); - try std.testing.expectEqual( - revision_before_toggle, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - const height_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null); - try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null); - try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null); - try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null); - for (p.panes) |slot| { - const other = slot orelse continue; - if (hasPdf(other)) continue; - pdf_pane.toggleFit(other); // pane-scoped and deliberately inert here - try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); - break; - } - - // A fit-height landscape page exposes horizontal overflow to a horizontal - // wheel without rerasterizing. Use synthetic dimensions only for the - // geometry check; no frame is drawn while they differ from the buffer. - const active_raster = pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?; - const saved_iw = active_raster.iw; - const saved_ih = active_raster.ih; - active_raster.iw = 2000; - active_raster.ih = 500; - pdf_pane.horizontalWheel(p, pane, 1); - try std.testing.expect(pane.pdf.?.pan_x > 0); - try std.testing.expectEqual(revision_before_toggle, active_raster.revision); - pane.pdf.?.pan_x = 0; - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'l' } }); - try std.testing.expect(pane.pdf.?.pan_x > 0); - p.update(.{ .key = .{ .cp = '$' } }); - try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'h' } }); - try std.testing.expect(pane.pdf.?.pan_x < std.math.maxInt(u16)); - p.update(.{ .key = .{ .cp = '0' } }); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'l' } }); - try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'h' } }); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); - active_raster.iw = saved_iw; - active_raster.ih = saved_ih; - pane.pdf.?.pan_x = 0; - - const before_height_scroll = pane.pdf.?.document_scroll_y; - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll); - - const revision_before_search = active_raster.revision; - try p.runSearch(0, "Pardes", .text, .top); - try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); - try std.testing.expectEqual(revision_before_search, active_raster.revision); - const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; - const results = p.panes[results_id].?.file.?.content; - try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null); - // n SELECTS the first result row and opens nothing: the walk's only list - // here is the unlooked +Search buffer, so focus lands THERE. Enter is what - // jumps, and the document query survives both. - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(results_id, p.active); - const results_pane = p.panes[results_id].?; - try std.testing.expect(results_pane.vsel.active and results_pane.vsel.explicit); - try std.testing.expectEqual(@as(i32, 0), results_pane.cur_row); - try std.testing.expectEqual(@as(i32, 0), results_pane.cur_col); - p.update(.{ .key = .{ .cp = Key.enter } }); - try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); - - // Search state is owned and untruncated, and changing pages invalidates - // only page-local state while retaining the document query. - const long_query = "a query deliberately longer than any tag display budget: " ++ - "012345678901234567890123456789012345678901234567890123456789" ++ - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; - try pane.pdf.?.setSearchQuery(p.pdf_gpa, long_query); - try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); - const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0; - pdf_pane.setPage(p, pane, next_page); - try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); - - // PDF dumps intentionally retain the existing image-compatible schema: - // page/path and the exact editable tail are restored, while pane-local - // tint starts from the fresh-PDF default rather than being serialized. - pdf_pane.toggleTint(pane); - try std.testing.expectEqual(PdfTintMode.full, pane.pdf.?.tint); - pane.pdf.?.fit = .height; - const custom_tail = " Keep Del"; - @memcpy(pane.tag_tail[0..custom_tail.len], custom_tail); - pane.tag_tail_len = custom_tail.len; - pane.tag_init = true; - try p.dumpState(); - const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?); - defer restored.deinit(); - try std.testing.expect(hasPdf(restored.panes[0].?)); - try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page); - try std.testing.expectEqual(PdfFitMode.width, restored.panes[0].?.pdf.?.fit); - try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint); - const restored_pane = restored.panes[0].?; - try std.testing.expect(restored_pane.tag_init); - try std.testing.expectEqualStrings( - custom_tail, - restored_pane.tag_tail[0..restored_pane.tag_tail_len], - ); -} - -test "a fling's banded pages show the reader exactly what whole pages would" { - if (!pdf_enabled or platform == .web) return; - - // The contract fast scrolling is allowed to change: HOW pixels are carried - // (a strip of a page instead of the page) but never WHICH pixels arrive. So - // the same frame is drawn twice — once at fling speed, once at reading - // speed — and every pixel inside every source rectangle must match, along - // with where on screen it goes. - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 40 }); - defer p.deinit(); - p.native_images = true; - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - _ = try p.render(frame.allocator()); - - const viewport = pdf_pane.paneViewport(p, pane) orelse return error.MissingPdfViewport; - // Land mid-page-boundary so the frame carries TWO pages, each showing a - // fraction of itself — the shape a fling actually produces. - const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3)); - const Shot = struct { - page: u32, - dst: image.PixelRect, - pixels: []u8, - }; - var shots: [8]Shot = undefined; - var shots_len: usize = 0; - defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels); - - // A fling: one frame's worth of wheel travel carrying the viewport more - // than a screenful, delivered through the real scroll path so the distance - // is counted the way a wheel batch counts it. - pv.document_scroll_y = 0; - pv.scroll_travel = 0; - try std.testing.expect(pdf_pane.scrollPane(p, pane, landing)); - _ = frame.reset(.retain_capacity); - const flung = try p.render(frame.allocator()); - try std.testing.expect(flung.nimages >= 2); - var banded = false; - for (flung.images[0..flung.nimages]) |maybe| { - const place = maybe orelse continue; - const geometry = place.native.geometry orelse return error.MissingPdfGeometry; - const raster = pdf_pane.rasterForPage(pv, place.native.page) orelse - return error.MissingPdfRaster; - if (raster.band_h < raster.ih) banded = true; - try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len); - shots[shots_len] = .{ - .page = place.native.page, - .dst = geometry.dst, - .pixels = try copySourceRect(gpa, place, geometry.src), - }; - shots_len += 1; - } - // ...and it really did band, or the comparison below is two identical - // whole-page renders agreeing with each other. - try std.testing.expect(banded); - // ...at reading speed: no travel at all since the frame above, so every - // page is rasterized whole again, and that is the picture the banded frame - // has to have matched. - _ = frame.reset(.retain_capacity); - const rested = try p.render(frame.allocator()); - try std.testing.expectEqual(shots_len, rested.nimages); - for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| { - const place = maybe orelse return error.MissingPdfPlacement; - const geometry = place.native.geometry orelse return error.MissingPdfGeometry; - const raster = pdf_pane.rasterForPage(pv, place.native.page) orelse - return error.MissingPdfRaster; - try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest - try std.testing.expectEqual(shot.page, place.native.page); - try std.testing.expectEqual(shot.dst.x, geometry.dst.x); - try std.testing.expectEqual(shot.dst.y, geometry.dst.y); - try std.testing.expectEqual(shot.dst.w, geometry.dst.w); - try std.testing.expectEqual(shot.dst.h, geometry.dst.h); - const whole = try copySourceRect(gpa, place, geometry.src); - defer gpa.free(whole); - try std.testing.expectEqualSlices(u8, shot.pixels, whole); - } -} - -/// The pixels a backend samples out of one placement: the source rectangle, -/// row by row, at the texture's own stride. Test-only, and the one operation -/// that makes "same picture" mean something when the textures differ in shape. -fn copySourceRect( - gpa: std.mem.Allocator, - place: ImagePlace, - src: image.PixelRect, -) ![]u8 { - const stride = place.iw * 4; - const row_len = @as(usize, src.w) * 4; - const out = try gpa.alloc(u8, row_len * src.h); - errdefer gpa.free(out); - var row: usize = 0; - while (row < src.h) : (row += 1) { - const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4; - @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]); - } - return out; -} - -test "PDF normal adapter consumes unsupported actions and navigates page fallback" { - if (!pdf_enabled or platform == .web) return; - - const p = try Pardes.init(std.testing.allocator, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - try std.testing.expect(pv.page_count > 3); - p.native_images = false; - - // Every vertical vocabulary falls back to counted page changes when the - // shell cannot place native pixels. - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(usize, 2), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(@as(usize, 0), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); - try std.testing.expectEqual(@as(usize, 2), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'u', .ctrl = true } }); - try std.testing.expectEqual(@as(usize, 0), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); - try std.testing.expectEqual(@as(usize, 2), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); - try std.testing.expectEqual(@as(usize, 0), pv.page); - - // Prefix actions and the counted text goto-line action map to pages. - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'e' } }); - try std.testing.expectEqual(pv.page_count - 1, pv.page); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - try std.testing.expectEqual(@as(usize, 0), pv.page); - p.update(.{ .key = .{ .cp = '3' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - try std.testing.expectEqual(@as(usize, 2), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'G' } }); - try std.testing.expectEqual(@as(usize, 1), pv.page); - - // Editing/selection actions are consumed no-ops: parser state clears, - // placeholder cells never acquire a range, and PDF state stays intact. - const page_before_noop = pv.page; - const revision_before_noop = pv.next_raster_revision; - p.update(.{ .key = .{ .cp = '4' } }); - p.update(.{ .key = .{ .cp = 'd' } }); - p.update(.{ .key = .{ .cp = 'v' } }); - p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expectEqual(page_before_noop, pv.page); - try std.testing.expectEqual(revision_before_noop, pv.next_raster_revision); - try std.testing.expectEqual(@as(u32, 0), pane.count); - try std.testing.expectEqual(@as(u21, 0), pane.pending); - try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0); - try std.testing.expect(!pane.tag_edit); - - // Cross-pane BODY-NORMAL actions keep their established shared paths. - p.update(.{ .key = .{ .cp = ':' } }); - try std.testing.expect(pane.tag_edit); - try std.testing.expectEqual(Mode.normal, pane.mode); - p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expect(!pane.tag_edit); - p.update(.{ .key = .{ .cp = ' ' } }); - try std.testing.expect(p.leader_on); - p.update(.{ .key = .{ .cp = '!' } }); - try std.testing.expect(!p.leader_on); - p.update(.{ .key = .{ .cp = '/' } }); - try std.testing.expect(pane.tag_edit); - p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expect(!pane.tag_edit); -} - -test "Escape cancels PDF chrome in place and Shift-Escape leaves the pane" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 28, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - - // A second pane, then focus it and come back: `Last` has somewhere to go - // and the PDF is where the keys land. - pdf_pane.openSections(p, 0); - p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(usize, 1), p.active); - p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(@as(usize, 0), p.active); - - // Everything a reader can see over the page: a search overlay and a live - // word selection. - try pv.setSearchQuery(p.pdf_gpa, "Pardes"); - var found = try pv.document.search(gpa, pv.page, "Pardes"); - defer found.deinit(gpa); - try std.testing.expect(found.quads.len > 0); - const quad = found.quads[0].quad; - try std.testing.expectEqual( - pdf_pane.SelectionUpdate.changed, - pv.setSelection(p.pdf_gpa, quad.ul, quad.lr, false), - ); - p.update(.{ .key = .{ .cp = '3' } }); - const page_before = pv.page; - const scroll_before = pv.document_scroll_y; - - // Escape drops the overlay and the selection, keeps the reading position, - // and does NOT hand the keyboard to another pane. - p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expect(pv.selection == null and pv.selection_text.len == 0); - try std.testing.expectEqual(@as(usize, 0), pv.search_query.len); - try std.testing.expect(pv.search_results == null); - try std.testing.expect(!pv.highlights_dirty and !pv.search_reveal_pending); - try std.testing.expectEqual(page_before, pv.page); - try std.testing.expectEqual(scroll_before, pv.document_scroll_y); - try std.testing.expectEqual(@as(u32, 0), pane.count); - try std.testing.expectEqual(@as(u21, 0), pane.pending); - // A second Escape on a bare document is inert rather than an exit. - p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expectEqual(@as(usize, 0), p.active); - - // Shift-Escape is the way out, and it leaves the document as it found it. - p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); - try std.testing.expectEqual(@as(usize, 1), p.active); - try std.testing.expectEqual(page_before, pv.page); - try std.testing.expectEqual(scroll_before, pv.document_scroll_y); -} - -test "PDF continuous strip renders every intersecting short page" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - try std.testing.expect(pv.page_count > 3); - p.native_images = true; - - // A legal wide MediaBox can make more than three pages intersect one - // viewport. Seed tiny matching rasters so this tests transport/cache - // cardinality without spending the unit test rendering fake page sizes. - for (pv.page_sizes) |*size| size.* = .{ .width = 100_000, .height = 1 }; - pv.layout_valid = false; - pv.scroll_to_page_pending = true; - const viewport = pdf_pane.ensurePaneLayout(p, pane).?; - const visible = pdf_pane.visiblePages(pv, viewport); - try std.testing.expectEqual(pv.page_count, visible.len); - const request = pdf_pane.renderRequest(viewport, pdf_raster_policy); - try std.testing.expect(pv.page_count <= pv.rasters.len); - for (0..pv.page_count) |page| { - const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4); - @memset(rgba, @intCast(page)); - pv.rasters[pv.rasters_len] = .{ - .valid = true, - .page = page, - .rgba = rgba, - .iw = viewport.pixel_w, - .ih = 1, - .request = request, - .request_valid = true, - .tried = true, - .tint_key = .{ .mode = pv.tint, .colors = pdf_pane.tintColors(p) }, - .revision = @intCast(page + 1), - }; - pv.rasters_len += 1; - } - - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - const surface = try p.render(frame.allocator()); - try std.testing.expectEqual(pv.page_count, surface.nimages); - try std.testing.expectEqual(pv.page_count, pv.rasters_len); - for (surface.images[0..surface.nimages], 0..) |maybe, page| { - const place = maybe orelse return error.MissingShortPdfPage; - try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page); - try std.testing.expectEqual(@as(u32, 1), place.native.geometry.?.dst.h); - } -} - -test "PdfTint cycles pane-local state and exposes it in the live PDF tag" { - if (!pdf_enabled or platform == .web) return; - - const p = try Pardes.init(std.testing.allocator, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse - return error.MissingPdfTintBuiltin; - - try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); - const initial_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf( - u8, - initial_tag, - "width PdfFit filtered PdfTint", - ) != null); - - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.full, pv.tint); - const full_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, full_tag, "full PdfTint") != null); - - // Running the same pane-scoped word in a terminal cannot mutate the PDF - // next to it. A FILE boot is one pane now, so that terminal is asked for - // here rather than inherited from init. - _ = try p.newShell(1, ""); - _ = p.layoutSplitColumn(0, 1, false); - pardes.pdf_test.sync(p); // rects for the new column, exactly as the two-pane boot did - try std.testing.expect(!hasPdf(p.panes[1].?)); - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 1, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.full, pv.tint); - - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); - const disabled_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled PdfTint") != null); - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); - try std.testing.expectEqual(PdfFitMode.width, pv.fit); - - // `dark` intentionally leaves page bg/fg null. PDF tint resolves those - // deterministically to its chrome colors rather than host defaults. - const dark_index = for (themes, 0..) |th, i| { - if (std.mem.eql(u8, th.name, "dark")) break i; - } else return error.MissingDarkTheme; - p.setThemeIndex(dark_index); - const colors = pdf_pane.tintColors(p); - try std.testing.expectEqual(p.theme().tag_bg, colors.background); - try std.testing.expectEqual(p.theme().tag_fg, colors.foreground); -} - -test "PDF tint and tinted theme changes rebuild every visible raster only" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - - _ = try p.render(frame.allocator()); - const viewport = pdf_pane.paneViewport(p, pane).?; - pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2); - _ = frame.reset(.retain_capacity); - const default_surface = try p.render(frame.allocator()); - try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); - try std.testing.expectEqual(@as(usize, 2), default_surface.nimages); - try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); - - const Snapshot = struct { - page: u32, - revision: u32, - geometry: image.NativeGeometry, - pixel_offset_y: f32, - iw: usize, - ih: usize, - checksum: u64, - }; - const Capture = struct { - fn get(surface: *const Surface) ![2]Snapshot { - if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount; - var out: [2]Snapshot = undefined; - for (&out, 0..) |*snapshot, i| { - const place = surface.images[i] orelse return error.MissingVisiblePdf; - snapshot.* = .{ - .page = place.native.page, - .revision = place.native.revision, - .geometry = place.native.geometry orelse return error.MissingPdfGeometry, - .pixel_offset_y = place.native.pixel_offset_y, - .iw = place.iw, - .ih = place.ih, - .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba), - }; - } - return out; - } - - fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void { - for (before, after) |old, new| { - try std.testing.expectEqual(old.page, new.page); - try std.testing.expectEqual(old.geometry, new.geometry); - try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y); - try std.testing.expectEqual(old.iw, new.iw); - try std.testing.expectEqual(old.ih, new.ih); - } - } - }; - const default_filtered = try Capture.get(default_surface); - for (pv.rasters[0..pv.rasters_len]) |raster| - try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode); - - const dark_index = for (themes, 0..) |th, i| { - if (std.mem.eql(u8, th.name, "dark")) break i; - } else return error.MissingDarkTheme; - p.setThemeIndex(dark_index); - for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried); - _ = frame.reset(.retain_capacity); - const themed_surface = try p.render(frame.allocator()); - const themed = try Capture.get(themed_surface); - try Capture.expectGeometry(default_filtered, themed); - for (default_filtered, themed) |old, new| { - try std.testing.expect(new.revision > old.revision); - try std.testing.expect(new.checksum != old.checksum); - } - - // Chrome animation reads a separate palette. Its ticks must never disturb - // the target-theme tint key or ask MuPDF for the same pixels again. - try std.testing.expect(p.animationActive()); - for (0..animation.transition_steps) |_| { - p.update(.tick); - _ = frame.reset(.retain_capacity); - const tick_surface = try p.render(frame.allocator()); - const ticked = try Capture.get(tick_surface); - try Capture.expectGeometry(themed, ticked); - for (themed, ticked) |once, after_tick| { - try std.testing.expectEqual(once.revision, after_tick.revision); - try std.testing.expectEqual(once.checksum, after_tick.checksum); - } - } - try std.testing.expect(!p.animationActive()); - - pdf_pane.toggleTint(pane); - try std.testing.expectEqual(PdfTintMode.full, pv.tint); - _ = frame.reset(.retain_capacity); - const full_surface = try p.render(frame.allocator()); - const full = try Capture.get(full_surface); - try Capture.expectGeometry(themed, full); - for (themed, full) |old, new| { - try std.testing.expect(new.revision > old.revision); - } - for (pv.rasters[0..pv.rasters_len]) |raster| - try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode); - - pdf_pane.toggleTint(pane); - try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); - _ = frame.reset(.retain_capacity); - const disabled_surface = try p.render(frame.allocator()); - const disabled = try Capture.get(disabled_surface); - try Capture.expectGeometry(full, disabled); - for (full, disabled) |old, source| { - try std.testing.expect(source.revision > old.revision); - try std.testing.expect(source.checksum != old.checksum); - } - - const acme_index = for (themes, 0..) |th, i| { - if (std.mem.eql(u8, th.name, "acme")) break i; - } else return error.MissingAcmeTheme; - for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); - p.setThemeIndex(acme_index); - // Disabled keys ignore theme colors, so neither explicit invalidation nor - // the per-raster key comparison asks MuPDF for replacement pixels. - for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); - _ = frame.reset(.retain_capacity); - const unchanged_surface = try p.render(frame.allocator()); - const unchanged = try Capture.get(unchanged_surface); - try Capture.expectGeometry(disabled, unchanged); - for (disabled, unchanged) |old, new| { - try std.testing.expectEqual(old.revision, new.revision); - try std.testing.expectEqual(old.checksum, new.checksum); - } - try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); -} - -test "PDF resize preserves page-relative document position" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - - pdf_pane.setPage(p, pane, 1); - _ = try p.render(frame.allocator()); - const fraction: f64 = 0.375; - pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + - fraction * @as(f64, @floatFromInt(pv.page_heights[1])); - p.update(.{ .resize = .{ - .cols = 100, - .rows = 24, - .cell_pixels = p.cell_pixels, - } }); - try std.testing.expect(!pv.layout_valid); - try std.testing.expect(pv.layout_anchor_pending); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - const resized_fraction = (pv.document_scroll_y - - @as(f64, @floatFromInt(pv.page_starts[1]))) / - @as(f64, @floatFromInt(pv.page_heights[1])); - try std.testing.expectApproxEqAbs(fraction, resized_fraction, 0.000_001); - try std.testing.expectEqual(@as(usize, 1), pv.page); - - const held = pv.document_scroll_y; - p.update(.{ .resize = .{ - .cols = 100, - .rows = 24, - .cell_pixels = p.cell_pixels, - } }); - try std.testing.expectEqual(held, pv.document_scroll_y); - try std.testing.expect(pv.layout_valid); -} - -test "PDF pane geometry change preserves its page-relative position" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - // A FILE boot is one pane now and a lone column always fills the window, - // so the divider drag below needs a second column to take the width from. - _ = try p.newShell(1, ""); - _ = p.layoutSplitColumn(0, 1, false); - pardes.pdf_test.sync(p); // rects for the new column, exactly as the two-pane boot did - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - - pdf_pane.setPage(p, pane, 1); - _ = try p.render(frame.allocator()); - const fraction: f64 = 0.625; - pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + - fraction * @as(f64, @floatFromInt(pv.page_heights[1])); - const old_width = pv.layout_viewport_w; - - // A divider/split changes rects through computeGeom without emitting a - // shell resize. pdf_pane.ensurePaneLayout anchors against the old layout - // before rebuilding it for this wider pane. - const sibling_weight = p.col_weight[1]; - p.col_weight[0] = sibling_weight * 6; - p.computeGeom(); - try std.testing.expect(pdf_pane.paneViewport(p, pane).?.pixel_w != old_width); - try std.testing.expect(pv.layout_valid); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - const changed_fraction = (pv.document_scroll_y - - @as(f64, @floatFromInt(pv.page_starts[1]))) / - @as(f64, @floatFromInt(pv.page_heights[1])); - try std.testing.expectApproxEqAbs(fraction, changed_fraction, 0.000_001); - try std.testing.expectEqual(@as(usize, 1), pv.page); - - // An explicit pending reveal wins over an implicit layout anchor. - pv.page = 2; - pv.scroll_to_page_pending = true; - p.col_weight[0] = sibling_weight * 2; - p.computeGeom(); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - try std.testing.expectEqual( - @as(f64, @floatFromInt(pv.page_starts[2])), - pv.document_scroll_y, - ); -} - -test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 120, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - - var page_hits = try pv.document.search(gpa, 0, "Pardes"); - defer page_hits.deinit(gpa); - try std.testing.expect(page_hits.hit_count >= 3); - - try p.runSearch(0, "Pardes", .text, .top); - const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; - const rows = p.panes[results_id].?.file.?.content; - try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null); - try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null); - try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null); - - // Exaggerate the cell aspect only to make the three fixture hits occupy - // distinct fit-width crops. The search/reveal math must use the same - // reported metrics as placement, whatever a backend reports. - p.update(.{ .resize = .{ - .cols = p.screen_w, - .rows = p.screen_h, - .cell_pixels = .{ .w = 16, .h = 4 }, - } }); - - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - try std.testing.expect(pardes.pdf_test.searchStep(p, 0, 1)); - try std.testing.expectEqual(@as(usize, 0), pv.search_hit); - const first = try p.render(frame.allocator()); - const revision = first.images[0].?.native.revision; - const raster = pdf_pane.rasterForPage(pv, pv.page).?; - const first_scroll = pv.document_scroll_y; - const first_results = pv.search_results orelse return error.MissingPdfPageSearch; - const first_quad = for (first_results.quads) |item| { - if (item.hit == 0) break item.quad; - } else return error.MissingFirstPdfHit; - const first_y = pdf_pane.normalizedPixel( - (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4, - raster.ih, - ); - const first_geometry = pdf_pane.paneGeometry(p, pane) orelse return error.MissingPdfGeometry; - try std.testing.expect(first_y >= first_geometry.src.y and - first_y < first_geometry.src.y + first_geometry.src.h); - - try std.testing.expect(pardes.pdf_test.searchStep(p, 0, 1)); - try std.testing.expectEqual(@as(usize, 1), pv.search_hit); - _ = frame.reset(.retain_capacity); - const second = try p.render(frame.allocator()); - try std.testing.expectEqual(revision, second.images[0].?.native.revision); - - try std.testing.expect(pardes.pdf_test.searchStep(p, 0, 1)); - try std.testing.expectEqual(@as(usize, 2), pv.search_hit); - _ = frame.reset(.retain_capacity); - const third = try p.render(frame.allocator()); - try std.testing.expectEqual(revision, third.images[0].?.native.revision); - try std.testing.expect(pv.document_scroll_y != first_scroll); - const third_results = pv.search_results orelse return error.MissingPdfPageSearch; - const third_quad = for (third_results.quads) |item| { - if (item.hit == 2) break item.quad; - } else return error.MissingThirdPdfHit; - const third_y = pdf_pane.normalizedPixel( - (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4, - raster.ih, - ); - const third_geometry = pdf_pane.paneGeometry(p, pane) orelse return error.MissingPdfGeometry; - try std.testing.expect(third_y >= third_geometry.src.y and - third_y < third_geometry.src.y + third_geometry.src.h); -} - -test "PDF native mouse selection, Look, and highlights share page geometry" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - - // A host without Kitty/native pixels keeps the projected-text contract: - // j/k can still change pages and a body drag remains a generic selection. - const fallback = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(usize, 0), fallback.nimages); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); - const fallback_rect = p.rects[0]; - const fallback_col = fallback_rect.x + config.GUTTER + 1; - const fallback_row = fallback_rect.y + BOX_H + 1; - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } }); - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } }); - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } }); - try std.testing.expectEqual(.done, pane.sel[sel_slot].state); - try std.testing.expect(pane.pdf.?.selection == null); - pane.sel[sel_slot].state = .none; - - p.native_images = true; - _ = frame.reset(.retain_capacity); - const plain = try p.render(frame.allocator()); - const plain_revision = plain.images[0].?.native.revision; - - var found = try pane.pdf.?.document.search(gpa, 0, "Pardes"); - defer found.deinit(gpa); - const quad = found.quads[0].quad; - const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4; - const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4; - const pv = &pane.pdf.?; - const raster = pdf_pane.rasterForPage(pv, pv.page).?; - const source_x: u32 = @intCast(@min( - raster.iw - 1, - @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(raster.iw)))), - )); - const source_y: u32 = @intCast(@min( - raster.ih - 1, - @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(raster.ih)))), - )); - - // Bring the known word into the fit-width crop, then invert the shared - // source/destination geometry to the nearest body cell. - var geometry = pdf_pane.paneGeometry(p, pane).?; - if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) { - const overflow = @as(u32, @intCast(raster.ih)) - geometry.src.h; - const wanted = @min(overflow, source_y -| geometry.src.h / 2); - pv.pan_y = if (overflow == 0) 0 else @intCast( - (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, - ); - geometry = pdf_pane.paneGeometry(p, pane).?; - } - const pixel_x = geometry.dst.x + @as(u32, @intCast( - @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w, - )); - const pixel_y = geometry.dst.y + @as(u32, @intCast( - @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h, - )); - const r = p.rects[0]; - const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w); - const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h); - - var selected_col: ?u16 = null; - var selected_row: u16 = 0; - const nearby = [_]i32{ 0, -1, 1, -2, 2 }; - find_word: for (nearby) |dy| for (nearby) |dx| { - const col: u16 = @intCast(std.math.clamp( - base_col + dx, - @as(i32, r.x + config.GUTTER), - @as(i32, r.x + r.w - 1), - )); - const row: u16 = @intCast(std.math.clamp( - base_row + dy, - @as(i32, r.y + BOX_H), - @as(i32, r.y + r.h - 1), - )); - if (!pdf_pane.beginSelection(p, pane, col, row)) continue; - if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) { - selected_col = col; - selected_row = row; - break :find_word; - } - }; - const word_col = selected_col orelse return error.PdfMouseMappingMissedWord; - try std.testing.expect(std.ascii.indexOfIgnoreCase( - pardes.pdf_test.heldSelection(p, 0).?, - "Pardes", - ) != null); - - _ = frame.reset(.retain_capacity); - const selected_frame = try p.render(frame.allocator()); - const selected_revision = selected_frame.images[0].?.native.revision; - try std.testing.expect(selected_revision != plain_revision); - - // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the - // same texture generation instead of retransmitting identical pixels. - try std.testing.expect(pdf_pane.beginSelection(p, pane, word_col, selected_row)); - try std.testing.expect(raster.tried); - _ = frame.reset(.retain_capacity); - const duplicate = try p.render(frame.allocator()); - try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision); - - // Delayed native hover retains independent quads/text and changes only - // presentation state. In particular it must not borrow the click path, - // whose page activation and persistent selection are intentional effects - // of a gesture rather than observation. - if (config.look_preview_delay_frames) |delay| { - const active_before = p.active; - const page_before = pv.page; - const scroll_before = pv.document_scroll_y; - const sels_before = pane.sel; - const cursor_before = .{ pane.cur_row, pane.cur_col }; - const modal_before = .{ pane.msel, pane.vsel, pane.nsel }; - const selection_before = pv.selection orelse return error.MissingPdfSelection; - const selection_quads_before = try gpa.dupe(pdf_impl.Quad, selection_before.quads); - defer gpa.free(selection_quads_before); - const selection_text_before = try gpa.dupe(u8, pv.selection_text); - defer gpa.free(selection_text_before); - const selection_anchor_before = pv.selection_anchor; - const selection_head_before = pv.selection_head; - const query_before = try gpa.dupe(u8, pv.search_query); - defer gpa.free(query_before); - const search_hit_before = pv.search_hit; - const jumps_before = .{ p.njumps, p.jcur, p.n_look_src }; - const effects_before = p.effects_len; - try std.testing.expect(p.drag == .none); - - const hover_motion = Event{ .mouse = .{ - .button = .none, - .kind = .motion, - .col = word_col, - .row = selected_row, - } }; - p.update(hover_motion); - try std.testing.expect(p.look_hover_wait != null); - for (0..delay) |_| p.update(.tick); - const hover = p.pdf_hover_preview orelse return error.MissingPdfHoverPreview; - try std.testing.expectEqual(@as(usize, 0), hover.probe.page); - try std.testing.expect(hover.probe.quads.len > 0); - try std.testing.expect(std.ascii.indexOfIgnoreCase(hover.probe.text, "Pardes") != null); - try std.testing.expect(p.look_hover_preview == null); - try std.testing.expect(p.look_hover_wait == null); - - const decorated = try pdf_pane.buildHighlights( - pv, - p.scratch.allocator(), - pdf_pane.highlightInput(p, 0, pane), - ); - const page_highlights = decorated.forPage(pv.page, pv.page); - try std.testing.expectEqual(pdf_impl.HighlightKind.custom, page_highlights[0].kind); - try std.testing.expectEqual(pdf_impl.HighlightKind.selection, page_highlights[page_highlights.len - 1].kind); - _ = frame.reset(.retain_capacity); - const hovered_frame = try p.render(frame.allocator()); - const hovered_revision = hovered_frame.images[0].?.native.revision; - try std.testing.expect(hovered_revision != selected_revision); - - p.update(.pointer_leave); - try std.testing.expect(p.pdf_hover_preview == null); - _ = frame.reset(.retain_capacity); - const unhovered_frame = try p.render(frame.allocator()); - try std.testing.expect(unhovered_frame.images[0].?.native.revision != hovered_revision); - - try std.testing.expectEqual(active_before, p.active); - try std.testing.expectEqual(page_before, pv.page); - try std.testing.expectEqual(scroll_before, pv.document_scroll_y); - try std.testing.expect(std.meta.eql(sels_before, pane.sel)); - try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col }); - try std.testing.expect(std.meta.eql(modal_before, .{ pane.msel, pane.vsel, pane.nsel })); - const selection_after = pv.selection orelse return error.HoverDroppedPdfSelection; - try std.testing.expectEqualSlices(pdf_impl.Quad, selection_quads_before, selection_after.quads); - try std.testing.expectEqualStrings(selection_text_before, pv.selection_text); - try std.testing.expect(std.meta.eql(selection_anchor_before, pv.selection_anchor)); - try std.testing.expect(std.meta.eql(selection_head_before, pv.selection_head)); - try std.testing.expectEqualStrings(query_before, pv.search_query); - try std.testing.expectEqual(search_hit_before, pv.search_hit); - try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src }); - try std.testing.expectEqual(effects_before, p.effects_len); - try std.testing.expect(p.drag == .none); - } - - // A native right-click resolves the same MuPDF-snapped word and feeds it - // to Look. Search highlights precede selection highlights so the live - // selection remains visually authoritative where they overlap. - p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } }); - p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } }); - try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null); - pv.resolveSearch(p.pdf_gpa); - const highlights = (try pdf_pane.buildHighlights( - pv, - p.scratch.allocator(), - pdf_pane.highlightInput(p, 0, pane), - )).items; - try std.testing.expect(highlights.len > 1); - try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind); - try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind); - const revision_before_reveal = raster.revision; - const max_document_scroll = @as(f64, @floatFromInt( - pv.document_height -| pdf_pane.paneViewport(p, pane).?.pixel_h, - )); - pv.document_scroll_y = max_document_scroll; - pdf_pane.revealSearch(pv, pdf_pane.paneViewport(p, pane).?, pdf_pane.paneGeometry(p, pane)); - const revealed_scroll = pv.document_scroll_y; - try std.testing.expect(revealed_scroll != max_document_scroll); - try std.testing.expectEqual(revision_before_reveal, raster.revision); - pv.document_scroll_y = max_document_scroll; - pdf_pane.revealSearch( - pv, - pdf_pane.paneViewport(p, pane).?, - pdf_pane.paneGeometry(p, pane), - ); // one shot: subsequent manual pan stays put - try std.testing.expectEqual(max_document_scroll, pv.document_scroll_y); - pv.document_scroll_y = revealed_scroll; - _ = frame.reset(.retain_capacity); - const searched = try p.render(frame.allocator()); - try std.testing.expect(searched.images[0].?.native.revision != selected_revision); - - // Fit and viewport changes can crop a hit which was already revealed. - // Fit remains placement-only. SDL's physical-viewport quality request - // changes with a resize and therefore replaces pixels; Kitty's fixed, - // bandwidth-conscious request retains them. - const revision_before_geometry_change = searched.images[0].?.native.revision; - pv.search_reveal_pending = false; - pdf_pane.toggleFit(pane); - try std.testing.expect(pv.search_reveal_pending); - pv.search_reveal_pending = false; - pdf_pane.toggleFit(pane); // restore the reading-width geometry - try std.testing.expect(pv.search_reveal_pending); - pv.search_reveal_pending = false; - const original_cell_pixels = p.cell_pixels; - p.update(.{ .resize = .{ - .cols = p.screen_w, - .rows = p.screen_h, - .cell_pixels = p.cell_pixels, - } }); - try std.testing.expect(!pv.search_reveal_pending); - p.update(.{ .resize = .{ - .cols = p.screen_w, - .rows = p.screen_h, - .cell_pixels = .{ - .w = original_cell_pixels.w, - .h = original_cell_pixels.h + 1, - }, - } }); - try std.testing.expect(pv.search_reveal_pending); - _ = frame.reset(.retain_capacity); - const geometry_changed = try p.render(frame.allocator()); - if (pdf_raster_policy.match_viewport) - try std.testing.expect(geometry_changed.images[0].?.native.revision != - revision_before_geometry_change) - else - try std.testing.expectEqual( - revision_before_geometry_change, - geometry_changed.images[0].?.native.revision, - ); - p.update(.{ .resize = .{ - .cols = p.screen_w, - .rows = p.screen_h, - .cell_pixels = original_cell_pixels, - } }); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - - // Unsupported text-selection actions are consumed by the PDF adapter: - // they neither invent a range over placeholder cells nor disturb the - // native MuPDF selection. The select-button's no-drag click still clears. - p.update(.{ .key = .{ .cp = 'v' } }); - try std.testing.expect(pv.selection != null); - try std.testing.expect(!pane.vsel.active); - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } }); - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } }); - try std.testing.expect(pv.selection == null); - try std.testing.expect(pdf_pane.beginSelection(p, pane, word_col, selected_row)); - - // Native drag events update MuPDF quads and copied text immediately, but - // keep the already transmitted page generation stable until release. - _ = frame.reset(.retain_capacity); - const before_drag = try p.render(frame.allocator()); - const before_drag_revision = before_drag.images[0].?.native.revision; - const drag_col = @min(r.x + r.w - 1, word_col +| 12); - const drag_row = @min(r.y + r.h - 1, selected_row +| 4); - p.update(.{ .mouse = .{ - .button = config.select_button, - .kind = .press, - .col = word_col, - .row = selected_row, - } }); - p.update(.{ .mouse = .{ - .button = config.select_button, - .kind = .drag, - .col = @min(drag_col, word_col +| 4), - .row = @min(drag_row, selected_row +| 2), - } }); - _ = frame.reset(.retain_capacity); - const during_first_drag = try p.render(frame.allocator()); - try std.testing.expectEqual( - before_drag_revision, - during_first_drag.images[0].?.native.revision, - ); - p.update(.{ .mouse = .{ - .button = config.select_button, - .kind = .drag, - .col = drag_col, - .row = drag_row, - } }); - try std.testing.expect(p.drag.select.pdf.selection_changed); - _ = frame.reset(.retain_capacity); - const during_second_drag = try p.render(frame.allocator()); - try std.testing.expectEqual( - before_drag_revision, - during_second_drag.images[0].?.native.revision, - ); - p.update(.{ .mouse = .{ - .button = config.select_button, - .kind = .release, - .col = drag_col, - .row = drag_row, - } }); - try std.testing.expect(!raster.tried); - _ = frame.reset(.retain_capacity); - const committed_drag = try p.render(frame.allocator()); - try std.testing.expectEqual( - before_drag_revision + 1, - committed_drag.images[0].?.native.revision, - ); - _ = frame.reset(.retain_capacity); - const stable_drag = try p.render(frame.allocator()); - try std.testing.expectEqual( - committed_drag.images[0].?.native.revision, - stable_drag.images[0].?.native.revision, - ); - - const saved_query = try gpa.dupe(u8, pv.search_query); - defer gpa.free(saved_query); - pdf_pane.setPage(p, pane, 1); - try std.testing.expect(pv.selection == null); - try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len); - try std.testing.expectEqualStrings(saved_query, pv.search_query); -} - -test "Esc back into a PDF keeps the offset within its page" { - 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 = 80, .rows = 28 }); - defer p.deinit(); - p.update(.{ .resize = .{ .cols = 80, .rows = 28 } }); - const doc = p.active; - const dp = p.panes[doc].?; - try std.testing.expect(dp.pdf != null); - pardes.pdf_test.sync(p); - - // Read a little way DOWN the page you are on, then step away. - const pv = &dp.pdf.?; - pv.document_scroll_y += 137; - const mid = pv.document_scroll_y; - pv.scroll_to_page_pending = false; - p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc - pardes.pdf_test.sync(p); - const shell = p.active; - try std.testing.expect(shell != doc); - - p.update(.{ .key = .{ .cp = Key.escape } }); // Esc: back into the PDF - pardes.pdf_test.sync(p); - try std.testing.expectEqual(doc, p.active); - - // The jumps stack records a PDF as its PAGE, so returning revealed the page - // you were already on — and a reveal sets `document_scroll_y` to that page's - // start, throwing away where you had read to inside it. - try std.testing.expectEqual(mid, pv.document_scroll_y); - // Same reveal, the other half: with no valid layout yet it only ARMS the - // snap, so a test that watched the offset alone would not see it coming. - try std.testing.expect(!pv.scroll_to_page_pending); -} diff --git a/src/petscii.zig b/src/petscii.zig deleted file mode 100644 index 136c4ead..00000000 --- a/src/petscii.zig +++ /dev/null @@ -1,446 +0,0 @@ -// PETSCII image rendering — a fallback when the terminal has no kitty graphics, -// and a per-pane toggle. Ported in spirit from caioluders/petsciinator: split the -// image into character cells and match each cell to the Unicode block/sextant glyph -// + a C64 foreground/background color pair that best reproduces the cell's pixels -// (minimum per-pixel color error). Pure: only std + the raw RGBA bytes (no vaxis), -// so the matcher is unit-testable on its own. -const std = @import("std"); - -// The match palette is supplied by the caller (mode-dependent): the Commodore 64 -// colors below by default, or the terminal's own ANSI palette. Cells store fg/bg as -// palette indices; image.draw paints them as C64 RGB or as indexed colors per mode. -// This is Pepto's canonical C64 palette. -pub const commodore = [16][3]u8{ - .{ 0x00, 0x00, 0x00 }, // 0 black - .{ 0xff, 0xff, 0xff }, // 1 white - .{ 0x68, 0x37, 0x2b }, // 2 red - .{ 0x70, 0xa4, 0xb2 }, // 3 cyan - .{ 0x6f, 0x3d, 0x86 }, // 4 purple - .{ 0x58, 0x8d, 0x43 }, // 5 green - .{ 0x35, 0x28, 0x79 }, // 6 blue - .{ 0xb8, 0xc7, 0x6f }, // 7 yellow - .{ 0x6f, 0x4f, 0x25 }, // 8 orange - .{ 0x43, 0x39, 0x00 }, // 9 brown - .{ 0x9a, 0x67, 0x59 }, // 10 light red - .{ 0x44, 0x44, 0x44 }, // 11 dark grey - .{ 0x6c, 0x6c, 0x6c }, // 12 grey - .{ 0x9a, 0xd2, 0x84 }, // 13 light green - .{ 0x6c, 0x5e, 0xb5 }, // 14 light blue - .{ 0x95, 0x95, 0x95 }, // 15 light grey -}; - -const Palette = [16][3]u8; - -// One rendered character cell: the UTF-8 bytes of the chosen glyph (stored inline -// so the slice handed to the retained vaxis screen outlives the frame) + palette -// fg/bg indices. -pub const Cell = struct { - glyph: [4]u8 = .{ ' ', 0, 0, 0 }, - glen: u3 = 1, - fg: u4 = 1, - bg: u4 = 0, -}; - -pub const Grid = struct { cells: []Cell, gw: usize, gh: usize }; - -fn dist2(a: [3]u8, b: [3]u8) u32 { - const dr = @as(i32, a[0]) - b[0]; - const dg = @as(i32, a[1]) - b[1]; - const db = @as(i32, a[2]) - b[2]; - return @intCast(dr * dr + dg * dg + db * db); -} - -fn nearest(px: [3]u8, pal: Palette) u4 { - var best: u4 = 0; - var bestd: u32 = std.math.maxInt(u32); - for (pal, 0..) |c, i| { - const d = dist2(px, c); - if (d < bestd) { - bestd = d; - best = @intCast(i); - } - } - return best; -} - -// ---- glyph set: each is a codepoint + an 8x8 ink bitmap (bit y*8+x set = fg) ---- -const Glyph = struct { cp: u21, bits: u64 }; - -// The Unicode codepoint for a 2x3 sextant pattern. Bits: 1=upper-left, 2=upper-right, -// 4=mid-left, 8=mid-right, 16=lower-left, 32=lower-right. The four patterns that -// coincide with existing block characters are mapped to those instead. -fn sextantCp(p: u6) u21 { - return switch (p) { - 0 => ' ', - 21 => 0x258C, // left half ▌ - 42 => 0x2590, // right half ▐ - 63 => 0x2588, // full block █ - else => blk: { - var off: u21 = @as(u21, p) - 1; - if (p > 21) off -= 1; - if (p > 42) off -= 1; - break :blk 0x1FB00 + off; // Symbols for Legacy Computing sextants - }, - }; -} - -// the 8x8 ink bitmap for a sextant pattern (2 cols x 3 rows of subcells). -fn sextantBits(p: u6) u64 { - var bits: u64 = 0; - var y: usize = 0; - while (y < 8) : (y += 1) { - const band = (y * 3) / 8; // 0,0,0,1,1,1,2,2 - var x: usize = 0; - while (x < 8) : (x += 1) { - const col: usize = if (x < 4) 0 else 1; - const sub: u6 = @intCast(band * 2 + col); - if ((p >> sub) & 1 != 0) bits |= @as(u64, 1) << @intCast(y * 8 + x); - } - } - return bits; -} - -// pack 8 row-bytes (bit x set, x=0 leftmost) into the 8x8 bitmap. -fn rows(r: [8]u8) u64 { - var b: u64 = 0; - for (r, 0..) |row, y| b |= @as(u64, row) << @intCast(y * 8); - return b; -} - -// the horizontal half blocks and the ten 2x2 quadrants — sextants are 2x3, so they -// can't express an exact 4-row half or a quarter; these fill that gap. -const block_glyphs = [_]Glyph{ - .{ .cp = 0x2580, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0 }) }, // ▀ top half - .{ .cp = 0x2584, .bits = rows(.{ 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff }) }, // ▄ bottom half - .{ .cp = 0x2598, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0, 0, 0, 0 }) }, // ▘ TL - .{ .cp = 0x259d, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0, 0, 0, 0 }) }, // ▝ TR - .{ .cp = 0x2596, .bits = rows(.{ 0, 0, 0, 0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▖ BL - .{ .cp = 0x2597, .bits = rows(.{ 0, 0, 0, 0, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▗ BR - .{ .cp = 0x259a, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▚ TL+BR - .{ .cp = 0x259e, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▞ TR+BL - .{ .cp = 0x259b, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▛ ¬BR - .{ .cp = 0x259c, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▜ ¬BL - .{ .cp = 0x2599, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xff, 0xff, 0xff, 0xff }) }, // ▙ ¬TR - .{ .cp = 0x259f, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0xff, 0xff, 0xff, 0xff }) }, // ▟ ¬TL -}; - -// line/diagonal glyphs add the characteristic PETSCII "drawn" look on edges. -const line_glyphs = [_]Glyph{ - .{ .cp = 0x2500, .bits = rows(.{ 0, 0, 0, 0xff, 0xff, 0, 0, 0 }) }, // ─ - .{ .cp = 0x2502, .bits = rows(.{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18 }) }, // │ - .{ .cp = 0x253c, .bits = rows(.{ 0x18, 0x18, 0x18, 0xff, 0xff, 0x18, 0x18, 0x18 }) }, // ┼ - .{ .cp = 0x2572, .bits = rows(.{ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 }) }, // ╲ - .{ .cp = 0x2571, .bits = rows(.{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }) }, // ╱ - .{ .cp = 0x2573, .bits = rows(.{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }) }, // ╳ -}; - -// Real 8x8 font bitmaps for the printable ASCII range (extracted from the cp850-8x8 -// console font). This is how petsciinator's charset works: the FULL character set is -// matched, each glyph scored against the cell by its actual bitmap (ink = fg), then -// rendered by its codepoint — the terminal draws the letter in its own font. (bit -// y*8+x set = ink; x=0 leftmost. Generated, not hand-drawn.) -const ascii_glyphs = [_]Glyph{ - .{ .cp = 0x21, .bits = 0x00180018183c3c18 }, // ! - .{ .cp = 0x22, .bits = 0x0000000000246666 }, // " - .{ .cp = 0x23, .bits = 0x0036367f367f3636 }, // # - .{ .cp = 0x24, .bits = 0x00183e603c067c18 }, // $ - .{ .cp = 0x25, .bits = 0x0063660c18336300 }, // % - .{ .cp = 0x26, .bits = 0x006e333b6e1c361c }, // & - .{ .cp = 0x27, .bits = 0x00000000000c1818 }, // ' - .{ .cp = 0x28, .bits = 0x0030180c0c0c1830 }, // ( - .{ .cp = 0x29, .bits = 0x000c18303030180c }, // ) - .{ .cp = 0x2a, .bits = 0x0000663cff3c6600 }, // * - .{ .cp = 0x2b, .bits = 0x000018187e181800 }, // + - .{ .cp = 0x2c, .bits = 0x0c18180000000000 }, // , - .{ .cp = 0x2d, .bits = 0x000000007e000000 }, // - - .{ .cp = 0x2e, .bits = 0x0018180000000000 }, // . - .{ .cp = 0x2f, .bits = 0x000103060c183060 }, // / - .{ .cp = 0x30, .bits = 0x001c36636b63361c }, // 0 - .{ .cp = 0x31, .bits = 0x007e181818181c18 }, // 1 - .{ .cp = 0x32, .bits = 0x007f660c3860633e }, // 2 - .{ .cp = 0x33, .bits = 0x003e63603c60633e }, // 3 - .{ .cp = 0x34, .bits = 0x0078307f33363c38 }, // 4 - .{ .cp = 0x35, .bits = 0x003e63603f03037f }, // 5 - .{ .cp = 0x36, .bits = 0x003e63633f03061c }, // 6 - .{ .cp = 0x37, .bits = 0x000c0c0c1830637f }, // 7 - .{ .cp = 0x38, .bits = 0x003e63633e63633e }, // 8 - .{ .cp = 0x39, .bits = 0x001e30607e63633e }, // 9 - .{ .cp = 0x3a, .bits = 0x0018180000181800 }, // : - .{ .cp = 0x3b, .bits = 0x0c18180000181800 }, // ; - .{ .cp = 0x3c, .bits = 0x006030180c183060 }, // < - .{ .cp = 0x3d, .bits = 0x00007e00007e0000 }, // = - .{ .cp = 0x3e, .bits = 0x00060c1830180c06 }, // > - .{ .cp = 0x3f, .bits = 0x001800181830633e }, // ? - .{ .cp = 0x40, .bits = 0x001e037b7b7b633e }, // @ - .{ .cp = 0x41, .bits = 0x006363637f63361c }, // A - .{ .cp = 0x42, .bits = 0x003f66663e66663f }, // B - .{ .cp = 0x43, .bits = 0x003c66030303663c }, // C - .{ .cp = 0x44, .bits = 0x001f36666666361f }, // D - .{ .cp = 0x45, .bits = 0x007f46161e16467f }, // E - .{ .cp = 0x46, .bits = 0x000f06161e16467f }, // F - .{ .cp = 0x47, .bits = 0x005c66730303663c }, // G - .{ .cp = 0x48, .bits = 0x006363637f636363 }, // H - .{ .cp = 0x49, .bits = 0x003c18181818183c }, // I - .{ .cp = 0x4a, .bits = 0x001e333330303078 }, // J - .{ .cp = 0x4b, .bits = 0x006766361e366667 }, // K - .{ .cp = 0x4c, .bits = 0x007f66460606060f }, // L - .{ .cp = 0x4d, .bits = 0x0063636b7f7f7763 }, // M - .{ .cp = 0x4e, .bits = 0x006363737b6f6763 }, // N - .{ .cp = 0x4f, .bits = 0x003e63636363633e }, // O - .{ .cp = 0x50, .bits = 0x000f06063e66663f }, // P - .{ .cp = 0x51, .bits = 0x703e73636363633e }, // Q - .{ .cp = 0x52, .bits = 0x006766363e66663f }, // R - .{ .cp = 0x53, .bits = 0x003c6630180c663c }, // S - .{ .cp = 0x54, .bits = 0x003c1818185a7e7e }, // T - .{ .cp = 0x55, .bits = 0x003e636363636363 }, // U - .{ .cp = 0x56, .bits = 0x001c366363636363 }, // V - .{ .cp = 0x57, .bits = 0x00367f6b6b636363 }, // W - .{ .cp = 0x58, .bits = 0x006363361c366363 }, // X - .{ .cp = 0x59, .bits = 0x003c18183c666666 }, // Y - .{ .cp = 0x5a, .bits = 0x007f664c1831637f }, // Z - .{ .cp = 0x5b, .bits = 0x003c0c0c0c0c0c3c }, // [ - .{ .cp = 0x5c, .bits = 0x00406030180c0603 }, // \\ - .{ .cp = 0x5d, .bits = 0x003c30303030303c }, // ] - .{ .cp = 0x5e, .bits = 0x0000000063361c08 }, // ^ - .{ .cp = 0x5f, .bits = 0xff00000000000000 }, // _ - .{ .cp = 0x60, .bits = 0x000000000030180c }, // ` - .{ .cp = 0x61, .bits = 0x006e333e301e0000 }, // a - .{ .cp = 0x62, .bits = 0x003b6666663e0607 }, // b - .{ .cp = 0x63, .bits = 0x003e6303633e0000 }, // c - .{ .cp = 0x64, .bits = 0x006e3333333e3038 }, // d - .{ .cp = 0x65, .bits = 0x003e037f633e0000 }, // e - .{ .cp = 0x66, .bits = 0x000f06061f06663c }, // f - .{ .cp = 0x67, .bits = 0x1f303e33336e0000 }, // g - .{ .cp = 0x68, .bits = 0x006766666e360607 }, // h - .{ .cp = 0x69, .bits = 0x003c1818181c0018 }, // i - .{ .cp = 0x6a, .bits = 0x3c66666060600060 }, // j - .{ .cp = 0x6b, .bits = 0x0067361e36660607 }, // k - .{ .cp = 0x6c, .bits = 0x003c18181818181c }, // l - .{ .cp = 0x6d, .bits = 0x006b6b6b7f370000 }, // m - .{ .cp = 0x6e, .bits = 0x00666666663b0000 }, // n - .{ .cp = 0x6f, .bits = 0x003e6363633e0000 }, // o - .{ .cp = 0x70, .bits = 0x0f063e66663b0000 }, // p - .{ .cp = 0x71, .bits = 0x78303e33336e0000 }, // q - .{ .cp = 0x72, .bits = 0x000f06066e3b0000 }, // r - .{ .cp = 0x73, .bits = 0x003f603e037e0000 }, // s - .{ .cp = 0x74, .bits = 0x00386c0c0c3f0c0c }, // t - .{ .cp = 0x75, .bits = 0x006e333333330000 }, // u - .{ .cp = 0x76, .bits = 0x001c366363630000 }, // v - .{ .cp = 0x77, .bits = 0x00367f6b6b630000 }, // w - .{ .cp = 0x78, .bits = 0x0063361c36630000 }, // x - .{ .cp = 0x79, .bits = 0x3f607e6363630000 }, // y - .{ .cp = 0x7a, .bits = 0x007e4c18327e0000 }, // z - .{ .cp = 0x7b, .bits = 0x007018180e181870 }, // { - .{ .cp = 0x7c, .bits = 0x0018181818181818 }, // | - .{ .cp = 0x7d, .bits = 0x000e18187018180e }, // } - .{ .cp = 0x7e, .bits = 0x0000000000003b6e }, // ~ -}; - -// the block glyph table (sextants + half/quadrant blocks + line glyphs), built at -// comptime, and the same set extended with the ASCII glyphs. `render(ascii=…)` picks. -const glyphs = blk: { - @setEvalBranchQuota(100000); - var list: [64 + block_glyphs.len + line_glyphs.len]Glyph = undefined; - var p: usize = 0; - while (p < 64) : (p += 1) list[p] = .{ .cp = sextantCp(@intCast(p)), .bits = sextantBits(@intCast(p)) }; - for (block_glyphs, 0..) |bg, i| list[64 + i] = bg; - for (line_glyphs, 0..) |lg, i| list[64 + block_glyphs.len + i] = lg; - break :blk list; -}; -const glyphs_ascii = glyphs ++ ascii_glyphs; - -// match one 8x8 RGB cell to the best (glyph, fg, bg). Candidate colors are the -// most-common palette colors among the 64 pixels; for each ordered pair the glyph -// cost is base(all-bg) + sum over the glyph's ink bits of (dist_fg - dist_bg). -fn matchCell(cell: *const [64][3]u8, pal: Palette, gset: []const Glyph) Cell { - var counts = [_]u16{0} ** 16; - for (cell) |px| counts[nearest(px, pal)] += 1; - var cand: [4]u4 = undefined; - var ncand: usize = 0; - var used = [_]bool{false} ** 16; - while (ncand < 4) : (ncand += 1) { - var best: ?usize = null; - for (counts, 0..) |c, i| { - if (used[i] or c == 0) continue; - if (best == null or c > counts[best.?]) best = i; - } - if (best) |bi| { - cand[ncand] = @intCast(bi); - used[bi] = true; - } else break; - } - if (ncand == 0) return .{}; // can't happen (64 pixels), but keep it total - if (ncand == 1) return encode(' ', cand[0], cand[0]); // solid color - - var best_cost: i64 = std.math.maxInt(i64); - var best = encode(' ', cand[0], cand[0]); - var fi: usize = 0; - while (fi < ncand) : (fi += 1) { - var bi: usize = 0; - while (bi < ncand) : (bi += 1) { - if (fi == bi) continue; - const fg = cand[fi]; - const bg = cand[bi]; - var dfg: [64]u32 = undefined; - var dbg: [64]u32 = undefined; - var base: i64 = 0; - for (cell, 0..) |px, p| { - dfg[p] = dist2(px, pal[fg]); - dbg[p] = dist2(px, pal[bg]); - base += dbg[p]; - } - for (gset) |g| { - var delta: i64 = 0; - var bits = g.bits; - while (bits != 0) : (bits &= bits - 1) { - const p: usize = @ctz(bits); - delta += @as(i64, dfg[p]) - @as(i64, dbg[p]); - } - const cost = base + delta; - if (cost < best_cost) { - best_cost = cost; - best = encode(g.cp, fg, bg); - } - } - } - } - return best; -} - -fn encode(cp: u21, fg: u4, bg: u4) Cell { - var c = Cell{ .fg = fg, .bg = bg }; - const n = std.unicode.utf8Encode(cp, &c.glyph) catch 1; - c.glen = @intCast(n); - return c; -} - -// Render `rgba` (iw x ih, 4 bytes/px) into a grid of at most cols x rows cells, -// preserving the image aspect with the terminal cell aspect (~1:2) corrected. -// Caller owns Grid.cells (gpa). -pub fn render(gpa: std.mem.Allocator, rgba: []const u8, iw: usize, ih: usize, cols: usize, rows_: usize, pal: Palette, ascii: bool) !Grid { - if (iw == 0 or ih == 0 or cols == 0 or rows_ == 0) return .{ .cells = try gpa.alloc(Cell, 0), .gw = 0, .gh = 0 }; - const gset: []const Glyph = if (ascii) &glyphs_ascii else &glyphs; - // contain-fit; cells are ~twice as tall as wide, so a row spans 2 width-units. - var gw = cols; - var gh = (cols * ih) / (2 * iw); - if (gh > rows_) { - gh = rows_; - gw = (rows_ * 2 * iw) / ih; - } - gw = std.math.clamp(gw, 1, cols); - gh = std.math.clamp(gh, 1, rows_); - - const cells = try gpa.alloc(Cell, gw * gh); - var cy: usize = 0; - while (cy < gh) : (cy += 1) { - const ry0 = cy * ih / gh; - const ry1 = @max(ry0 + 1, (cy + 1) * ih / gh); - var cx: usize = 0; - while (cx < gw) : (cx += 1) { - const rx0 = cx * iw / gw; - const rx1 = @max(rx0 + 1, (cx + 1) * iw / gw); - var cell: [64][3]u8 = undefined; - var sy: usize = 0; - while (sy < 8) : (sy += 1) { - const py0 = ry0 + sy * (ry1 - ry0) / 8; - const py1 = @max(py0 + 1, ry0 + (sy + 1) * (ry1 - ry0) / 8); - var sx: usize = 0; - while (sx < 8) : (sx += 1) { - const px0 = rx0 + sx * (rx1 - rx0) / 8; - const px1 = @max(px0 + 1, rx0 + (sx + 1) * (rx1 - rx0) / 8); - var rs: usize = 0; - var gs: usize = 0; - var bs: usize = 0; - var n: usize = 0; - var yy = py0; - while (yy < py1 and yy < ih) : (yy += 1) { - var xx = px0; - while (xx < px1 and xx < iw) : (xx += 1) { - const i = (yy * iw + xx) * 4; - rs += rgba[i]; - gs += rgba[i + 1]; - bs += rgba[i + 2]; - n += 1; - } - } - if (n == 0) n = 1; - cell[sy * 8 + sx] = .{ @intCast(rs / n), @intCast(gs / n), @intCast(bs / n) }; - } - } - cells[cy * gw + cx] = matchCell(&cell, pal, gset); - } - } - return .{ .cells = cells, .gw = gw, .gh = gh }; -} - -test "sextant codepoints: blocks + endpoints" { - try std.testing.expectEqual(@as(u21, ' '), sextantCp(0)); - try std.testing.expectEqual(@as(u21, 0x2588), sextantCp(63)); - try std.testing.expectEqual(@as(u21, 0x258C), sextantCp(21)); - try std.testing.expectEqual(@as(u21, 0x2590), sextantCp(42)); - try std.testing.expectEqual(@as(u21, 0x1FB00), sextantCp(1)); // first sextant - try std.testing.expectEqual(@as(u21, 0x1FB3B), sextantCp(62)); // last sextant -} - -test "matchCell: solid color -> space on that bg" { - var cell: [64][3]u8 = undefined; - for (&cell) |*p| p.* = commodore[5]; // all green - const m = matchCell(&cell, commodore, &glyphs); - try std.testing.expectEqual(@as(u4, 5), m.bg); - try std.testing.expectEqual(@as(u8, ' '), m.glyph[0]); -} - -test "matchCell: clean top/bottom split picks the two colors" { - var cell: [64][3]u8 = undefined; - for (0..64) |p| cell[p] = if (p < 32) commodore[1] else commodore[6]; // white over blue - const m = matchCell(&cell, commodore, &glyphs); - // both palette colors must be chosen (in some fg/bg order) - const a = @as(u4, @min(m.fg, m.bg)); - const b = @as(u4, @max(m.fg, m.bg)); - try std.testing.expectEqual(@as(u4, 1), a); - try std.testing.expectEqual(@as(u4, 6), b); - // a clean top/bottom split must resolve to a real block glyph (the top-4-rows - // half block ▀), never a blank cell. - const cp = std.unicode.utf8Decode(m.glyph[0..m.glen]) catch 0; - try std.testing.expectEqual(@as(u21, 0x2580), cp); -} - -test "ascii option only enables the ascii glyphs" { - // the ascii glyph codepoints must be reachable exactly when ascii is on. - var seen_block = false; - var seen_ascii = false; - for (glyphs) |g| if (g.cp == '#') { - seen_block = true; - }; - for (glyphs_ascii) |g| if (g.cp == '#') { - seen_ascii = true; - }; - try std.testing.expect(!seen_block); // '#' is an ascii-only glyph - try std.testing.expect(seen_ascii); - try std.testing.expectEqual(glyphs.len + ascii_glyphs.len, glyphs_ascii.len); -} - -test "ascii glyph is chosen when a cell has its exact shape" { - // a cell shaped exactly like the font 'S' (ink=white on black) must match 'S' - // with ascii on (cost 0), and fall back to some block glyph with ascii off. - const s_bits: u64 = 0x003c6630180c663c; - var cell: [64][3]u8 = undefined; - for (0..64) |p| cell[p] = if ((s_bits >> @intCast(p)) & 1 != 0) commodore[1] else commodore[0]; - const on = matchCell(&cell, commodore, &glyphs_ascii); - try std.testing.expectEqual(@as(u21, 'S'), std.unicode.utf8Decode(on.glyph[0..on.glen]) catch 0); - const off = matchCell(&cell, commodore, &glyphs); - try std.testing.expect((std.unicode.utf8Decode(off.glyph[0..off.glen]) catch 0) != 'S'); -} - -test "render: tiny image produces a grid within bounds" { - const a = std.testing.allocator; - // 2x2 checker, RGBA - var img = [_]u8{0} ** (2 * 2 * 4); - img[0] = 255; img[1] = 255; img[2] = 255; img[3] = 255; // (0,0) white - img[(3) * 4 + 0] = 255; img[(3) * 4 + 1] = 255; img[(3) * 4 + 2] = 255; img[(3) * 4 + 3] = 255; // (1,1) white - const g = try render(a, &img, 2, 2, 10, 10, commodore, true); - defer a.free(g.cells); - try std.testing.expect(g.gw >= 1 and g.gw <= 10); - try std.testing.expect(g.gh >= 1 and g.gh <= 10); - try std.testing.expectEqual(g.gw * g.gh, g.cells.len); -} diff --git a/src/runtime_config.zig b/src/runtime_config.zig deleted file mode 100644 index 69d2cdfe..00000000 --- a/src/runtime_config.zig +++ /dev/null @@ -1,579 +0,0 @@ -//! Runtime choices in one plain, owned record. -//! -//! `Setting` is compile-time metadata for the builtin registry; `State` is the -//! data it mutates and the Config report reads. Neither contains callbacks. -const std = @import("std"); -const panel_animation = @import("panel_animation.zig"); -const limits = @import("limits.zig"); - -/// Tagline glyphs retain body-cell geometry, so allowing a face larger than -/// the body would clip into neighbouring cells. Zero would make the role -/// invisible. Keep the runtime command on the same 1...100 contract as the -/// build-time default in config.zig. -pub const tagline_percent_min: u8 = 1; -pub const tagline_percent_max: u8 = 100; - -pub fn Text(comptime capacity: usize) type { - return struct { - bytes: [capacity]u8 = @splat(0), - len: std.math.IntFittingRange(0, capacity) = 0, - - pub fn get(value: *const @This()) []const u8 { - return value.bytes[0..value.len]; - } - - pub fn set(value: *@This(), text: []const u8) bool { - if (text.len > capacity) return false; - @memcpy(value.bytes[0..text.len], text); - value.len = @intCast(text.len); - return true; - } - - pub fn clear(value: *@This()) void { - value.len = 0; - } - }; -} - -pub const State = struct { - theme: usize = 0, - colors: bool = true, - wrap: bool = true, - tag_bottom: bool = false, - debug: bool = false, - - /// Empty requested text means config.default_shell. Resolution belongs to - /// the native host (the core deliberately has no filesystem), so retain - /// the executable it actually chose separately and mark a changed request - /// pending until the next terminal spawn acknowledges it. - shell: struct { - requested: Text(255) = .{}, - // One data schema across native, browser and freestanding builds; only - // its one absolute-path field follows `limits.host_path_cap`. - effective: Text(limits.host_path_cap) = .{}, - pending: bool = true, - } = .{}, - - /// The shell acknowledges a font before `effective` changes. A rejected - /// request therefore remains queryable without claiming it is on screen. - font: struct { - requested_path: Text(limits.host_path_cap) = .{}, - requested_name: Text(255) = .{}, - effective_name: Text(255) = .{}, - pending: bool = false, - effective_size_hundredths: u16 = 0, - effective_size_unit: FontSizeUnit = .unknown, - // Pardes.init copies config.gui_tagline_font_percent here. Keeping - // the compiled choice in the live record makes Config truthful while - // avoiding the config -> builtins -> runtime_config import cycle. - tagline_percent: u8 = 100, - } = .{}, - - panel_transition: panel_animation.Transition = .off, - scene_effects: panel_animation.SceneEffect = .{}, - - pub fn toggleTransition(state: *State, effect: panel_animation.Transition) void { - std.debug.assert(effect != .off); - state.panel_transition = if (state.panel_transition == effect) .off else effect; - } -}; - -pub const FontSizeUnit = enum { unknown, pixels, points }; - -/// Replace the requested font tuple atomically. Both fixed strings are -/// preflighted before either changes, so a rejected long name cannot leave a -/// new path paired with stale metadata. -pub fn requestFont(state: *State, path: []const u8, name: []const u8) bool { - if (path.len > state.font.requested_path.bytes.len or - name.len > state.font.requested_name.bytes.len) return false; - std.debug.assert(state.font.requested_path.set(path)); - std.debug.assert(state.font.requested_name.set(name)); - state.font.pending = true; - return true; -} - -/// Backend facilities are plain data supplied once by the platform-facing -/// registry. The same value gates command generation, leader paths, source -/// queries, and the Config report, so "unsupported" cannot mean four subtly -/// different things at those four call sites. -pub const Capabilities = struct { - font_picker: bool, - panel_transitions: bool, - scene_shaders: bool, - /// A smaller tagline face is also supported by the browser, which does - /// not own a native font picker. Keep this fact deliberately independent. - tagline_font_size: bool, -}; - -pub const Capability = std.meta.FieldEnum(Capabilities); - -pub const Toggle = enum { colors, wrap, tag_bottom, debug }; -pub const Scene = std.meta.FieldEnum(panel_animation.SceneEffect); - -pub const Action = union(enum) { - toggle: Toggle, - shell, - theme, - font, - tagline_size, - transition: panel_animation.Transition, - scene: Scene, -}; - -pub const Setting = struct { - word: []const u8, - action: Action, - availability: ?Capability = null, - - pub fn takesArg(setting: Setting) bool { - return switch (setting.action) { - .shell, .theme, .font, .tagline_size => true, - else => false, - }; - } - - pub fn enabled(setting: Setting, capabilities: Capabilities) bool { - const capability = setting.availability orelse return true; - return switch (capability) { - inline else => |field| @field(capabilities, @tagName(field)), - }; - } -}; - -/// This table is both the generated-setting builtin input and the Config -/// report order. Adding mutable config without adding a query row is therefore -/// impossible unless it is deliberately kept out of this user-facing state. -pub const settings = [_]Setting{ - .{ .word = "Colors", .action = .{ .toggle = .colors } }, - .{ .word = "Wrap", .action = .{ .toggle = .wrap } }, - .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } }, - .{ .word = "Debug", .action = .{ .toggle = .debug } }, - .{ .word = "Theme", .action = .theme }, - .{ .word = "Shell", .action = .shell }, - .{ .word = "Font", .action = .font, .availability = .font_picker }, - .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker }, - .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions }, - .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions }, - .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions }, - .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions }, - .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions }, - .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions }, - .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions }, - .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions }, - .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions }, - .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, - .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, - .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, - .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, - .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, -}; - -pub fn find(name: []const u8) ?Setting { - for (settings) |setting| if (std.mem.eql(u8, setting.word, name)) return setting; - return null; -} - -/// The table is also the sole action-to-command vocabulary. Compile-time -/// callers use this for picker rows; the Config report uses it for the active -/// transition. No parallel enum or transition-name switch can drift. -pub fn findAction(action: Action) ?Setting { - for (settings) |setting| if (std.meta.eql(setting.action, action)) return setting; - return null; -} - -fn actionCount(comptime action: Action) comptime_int { - var count = 0; - for (settings) |setting| count += @intFromBool(std.meta.eql(setting.action, action)); - return count; -} - -comptime { - @setEvalBranchQuota(20_000); - for (settings, 0..) |setting, i| { - if (setting.word.len == 0) @compileError("runtime setting has an empty command word"); - for (settings[i + 1 ..]) |later| if (std.mem.eql(u8, setting.word, later.word)) - @compileError("duplicate runtime setting command word: " ++ setting.word); - switch (setting.action) { - .font, .tagline_size => if (setting.availability != .font_picker) - @compileError("native font settings must use the font-picker capability"), - .transition => if (setting.availability != .panel_transitions) - @compileError("panel effects must use the panel-transition capability"), - .scene => if (setting.availability != .scene_shaders) - @compileError("scene effects must use the scene-shader capability"), - else => if (setting.availability != null) - @compileError("unconditional settings cannot carry a backend capability"), - } - } - for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1) - @compileError("runtime toggle must occur exactly once: " ++ @tagName(field)); - if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or - actionCount(.tagline_size) != 1) - @compileError("Shell, Theme, Font, and TaglineSize actions must each occur exactly once"); - for (std.enums.values(panel_animation.Transition)) |effect| { - const expected: comptime_int = @intFromBool(effect != .off); - if (actionCount(.{ .transition = effect }) != expected) - @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect)); - } - for (std.enums.values(Scene)) |effect| { - if (actionCount(.{ .scene = effect }) != 1) - @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); - if (@FieldType(panel_animation.SceneEffect, @tagName(effect)) != bool) - @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); - } -} - -/// Apply settings whose operation is independent of themes, font discovery, -/// or a shell. Those three remain explicit at the generated builtin's edge. -pub fn applySimple(state: *State, setting: Setting, argument: ?[]const u8) bool { - switch (setting.action) { - .toggle => |field| switch (field) { - .colors => state.colors = !state.colors, - .wrap => state.wrap = !state.wrap, - .tag_bottom => state.tag_bottom = !state.tag_bottom, - .debug => state.debug = !state.debug, - }, - .shell => { - const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); - if (value.len == 0 or !state.shell.requested.set(value)) return false; - state.shell.pending = true; - }, - .tagline_size => { - const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); - const percent = std.fmt.parseInt(u16, text, 10) catch return false; - if (percent < tagline_percent_min or percent > tagline_percent_max) return false; - // Parse and validate before the sole write: malformed commands - // cannot partially alter the live configuration. - state.font.tagline_percent = @intCast(percent); - }, - .transition => |effect| state.toggleTransition(effect), - .scene => |effect| switch (effect) { - inline else => |field| { - const value = &@field(state.scene_effects, @tagName(field)); - value.* = !value.*; - }, - }, - .theme, .font => return false, - } - return true; -} - -/// The runtime facts which do not belong to mutable `State`, supplied by the -/// core when it materialises +Config. Slices are borrowed for this one write. -pub const ReportContext = struct { - startup_config_path: ?[]const u8, - platform: []const u8, - theme_name: []const u8, - compiled_default_shell: []const u8, - /// Null outside the SDL GUI. The borrowed label comes from effect_sources, - /// which knows whether its embedded GLSL import is live or paired prebuilt. - gui_shader_source_mode: ?[]const u8 = null, - hover_delay_frames: ?u16, - native_images: bool, - capabilities: Capabilities, - state: *const State, -}; - -fn onOff(value: bool) []const u8 { - return if (value) "on" else "off"; -} - -fn shown(text: []const u8) []const u8 { - return if (text.len == 0) "(none)" else text; -} - -/// Name the setting which selected the one active transition. This keeps the -/// report vocabulary identical to the generated builtin registry. -fn transitionSettingName(transition: panel_animation.Transition) []const u8 { - if (transition == .off) return "off"; - return findAction(.{ .transition = transition }).?.word; -} - -/// Write the complete live Config report without allocation. Setting-backed -/// rows follow `settings` order; host facts follow them as a compact footer. -pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { - const state = context.state; - var wrote_transition = false; - for (settings) |setting| switch (setting.action) { - .toggle => |field| { - const value = switch (field) { - .colors => state.colors, - .wrap => state.wrap, - .tag_bottom => state.tag_bottom, - .debug => state.debug, - }; - try out.print("{s}: {s}\n", .{ setting.word, onOff(value) }); - }, - .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }), - .shell => { - const chosen = state.shell.requested.get(); - try out.print( - "{s} requested (new panes): {s}{s}\n" ++ - "{s} effective (last spawn): {s}\n" ++ - "{s} pending: {s}\n", - .{ - setting.word, - if (chosen.len == 0) context.compiled_default_shell else chosen, - if (chosen.len == 0) " (default)" else "", - setting.word, - shown(state.shell.effective.get()), - setting.word, - onOff(state.shell.pending), - }, - ); - }, - .font => if (!setting.enabled(context.capabilities)) - try out.print("{s}: unsupported\n", .{setting.word}) - else - try out.print( - "{s} requested: {s}\n" ++ - "{s} requested path: {s}\n" ++ - "{s} effective: {s}\n" ++ - "{s} pending: {s}\n" ++ - "{s} effective size: {d}.{d:0>2} {s}\n", - .{ - setting.word, - shown(state.font.requested_name.get()), - setting.word, - shown(state.font.requested_path.get()), - setting.word, - shown(state.font.effective_name.get()), - setting.word, - onOff(state.font.pending), - setting.word, - state.font.effective_size_hundredths / 100, - state.font.effective_size_hundredths % 100, - @tagName(state.font.effective_size_unit), - }, - ), - .tagline_size => if (!context.capabilities.tagline_font_size) - try out.print("{s}: unsupported\n", .{setting.word}) - else if (!setting.enabled(context.capabilities)) - try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent }) - else - try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }), - .transition => { - if (wrote_transition) continue; - wrote_transition = true; - if (!setting.enabled(context.capabilities)) - try out.writeAll("Panel transition: unsupported\n") - else - try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)}); - }, - .scene => |effect| { - if (!setting.enabled(context.capabilities)) { - try out.print("{s}: unsupported\n", .{setting.word}); - continue; - } - const enabled = switch (effect) { - inline else => |field| @field(state.scene_effects, @tagName(field)), - }; - try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) }); - }, - }; - - if (context.startup_config_path) |path| - try out.print("Startup config: {s}\n", .{path}) - else - try out.writeAll("Startup config: no per-user config path\n"); - try out.print( - "Platform: {s}\n" ++ - "Compiled default shell: {s}\n", - .{ context.platform, context.compiled_default_shell }, - ); - if (context.gui_shader_source_mode) |mode| - try out.print("GUI shader source: {s}\n", .{mode}); - if (context.hover_delay_frames) |frames| - try out.print("Look hover delay: {d} frames\n", .{frames}) - else - try out.writeAll("Look hover delay: off\n"); - try out.print("Native images: {s}\n", .{onOff(context.native_images)}); -} - -test "setting names are unique and argument metadata follows actions" { - for (settings, 0..) |setting, i| { - try std.testing.expect(setting.word.len > 0); - for (settings[i + 1 ..]) |later| - try std.testing.expect(!std.mem.eql(u8, setting.word, later.word)); - try std.testing.expectEqual(switch (setting.action) { - .shell, .theme, .font, .tagline_size => true, - else => false, - }, setting.takesArg()); - } -} - -test "simple setting application mutates only its plain field" { - var state: State = .{}; - try std.testing.expect(applySimple(&state, find("Colors").?, null)); - try std.testing.expect(!state.colors); - try std.testing.expect(applySimple(&state, find("Shell").?, " fish\n")); - try std.testing.expectEqualStrings("fish", state.shell.requested.get()); - try std.testing.expect(state.shell.pending); - try std.testing.expect(applySimple(&state, find("PanelAscii").?, null)); - try std.testing.expectEqual(panel_animation.Transition.ascii, state.panel_transition); - try std.testing.expect(applySimple(&state, find("PanelAscii").?, null)); - try std.testing.expectEqual(panel_animation.Transition.off, state.panel_transition); - try std.testing.expect(applySimple(&state, find("Crt").?, null)); - try std.testing.expect(state.scene_effects.crt); -} - -test "tagline size validates before mutating live state" { - const setting = find("TaglineSize").?; - var state: State = .{}; - - for ([_][]const u8{ "1", " 82\n", "100" }) |argument| { - try std.testing.expect(applySimple(&state, setting, argument)); - try std.testing.expectEqual(try std.fmt.parseInt(u8, std.mem.trim(u8, argument, " \t\r\n"), 10), state.font.tagline_percent); - } - - state.font.tagline_percent = 67; - for ([_]?[]const u8{ null, "", "0", "101", "-1", "50%", "999999999999999999999" }) |argument| { - try std.testing.expect(!applySimple(&state, setting, argument)); - try std.testing.expectEqual(@as(u8, 67), state.font.tagline_percent); - } -} - -test "font request tuple rejects atomically" { - var state: State = .{}; - try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old")); - var too_long: [256]u8 = @splat('x'); - try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long)); - try std.testing.expectEqualStrings("/fonts/old.ttf", state.font.requested_path.get()); - try std.testing.expectEqualStrings("Old", state.font.requested_name.get()); -} - -test "Config report observes every simple setting and all live context" { - var state: State = .{}; - var storage: [4096]u8 = undefined; - const context: ReportContext = .{ - .startup_config_path = "/tmp/pardes/init", - .platform = "gui", - .theme_name = "acme", - .compiled_default_shell = "/bin/sh", - .gui_shader_source_mode = "live GLSL compiled during this build", - .hover_delay_frames = 18, - .native_images = true, - .capabilities = .{ - .font_picker = true, - .panel_transitions = true, - .scene_shaders = true, - .tagline_font_size = true, - }, - .state = &state, - }; - - for (settings) |setting| { - switch (setting.action) { - .theme, .font => continue, - else => {}, - } - const argument: ?[]const u8 = switch (setting.action) { - .shell => "fish", - .tagline_size => "73", - else => null, - }; - try std.testing.expect(applySimple(&state, setting, argument)); - - var out: std.Io.Writer = .fixed(&storage); - try writeReport(&out, context); - const report = storage[0..out.end]; - const expected = switch (setting.action) { - .toggle => |field| switch (field) { - .colors => "Colors: off\n", - .wrap => "Wrap: off\n", - .tag_bottom => "Tagbottom: on\n", - .debug => "Debug: on\n", - }, - .shell => "Shell requested (new panes): fish\n", - .tagline_size => "TaglineSize: 73%\n", - .transition => |transition| switch (transition) { - .off => unreachable, - .slide => "Panel transition: PanelSlide\n", - .zoom => "Panel transition: PanelZoom\n", - .dissolve => "Panel transition: PanelDissolve\n", - .ascii => "Panel transition: PanelAscii\n", - .vertical => "Panel transition: PanelVertical\n", - .edges => "Panel transition: PanelEdges\n", - .fall => "Panel transition: PanelFall\n", - .wave => "Panel transition: PanelWave\n", - .curtain => "Panel transition: PanelCurtain\n", - .scramble => "Panel transition: PanelScramble\n", - .typewriter => "Panel transition: PanelType\n", - }, - .scene => |effect| switch (effect) { - .crt => "Crt: on\n", - .ripple => "Ripple: on\n", - .glitch => "Glitch: on\n", - }, - .theme, .font => unreachable, - }; - try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); - } - - try std.testing.expect(state.font.requested_name.set("Wanted Mono")); - try std.testing.expect(state.font.requested_path.set("/fonts/wanted.ttf")); - try std.testing.expect(state.font.effective_name.set("Effective Mono")); - state.font.pending = true; - state.font.effective_size_hundredths = 1375; - state.font.effective_size_unit = .points; - state.font.tagline_percent = 82; - - var out: std.Io.Writer = .fixed(&storage); - try writeReport(&out, context); - const report = storage[0..out.end]; - for ([_][]const u8{ - "Theme: acme\n", - "Font requested: Wanted Mono\n", - "Font requested path: /fonts/wanted.ttf\n", - "Font effective: Effective Mono\n", - "Font pending: on\n", - "Font effective size: 13.75 points\n", - "TaglineSize: 82%\n", - "Startup config: /tmp/pardes/init\n", - "Platform: gui\n", - "Compiled default shell: /bin/sh\n", - "GUI shader source: live GLSL compiled during this build\n", - "Look hover delay: 18 frames\n", - "Native images: on\n", - }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); - - var defaults: State = .{}; - var defaults_context = context; - defaults_context.startup_config_path = null; - defaults_context.platform = "tty"; - defaults_context.gui_shader_source_mode = null; - defaults_context.hover_delay_frames = null; - defaults_context.native_images = false; - defaults_context.capabilities = .{ - .font_picker = false, - .panel_transitions = true, - .scene_shaders = false, - .tagline_font_size = false, - }; - defaults_context.state = &defaults; - out = .fixed(&storage); - try writeReport(&out, defaults_context); - const defaults_report = storage[0..out.end]; - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); - - defaults_context.platform = "web"; - defaults_context.capabilities.panel_transitions = false; - defaults_context.capabilities.tagline_font_size = true; - out = .fixed(&storage); - try writeReport(&out, defaults_context); - const web_report = storage[0..out.end]; - try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null); -} diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig index 8e721751..e1a42962 100644 --- a/src/selection_pipe.zig +++ b/src/selection_pipe.zig @@ -4,6 +4,7 @@ //! `runOne` on that worker. No subprocess or borrowed core memory reaches the //! editor/event-loop thread. const std = @import("std"); +const filesystem = @import("fs.zig"); /// A deliberately finite answer. One selection cannot retain more than 1 MiB /// and a multi-selection command cannot retain more than 4 MiB in total. @@ -43,7 +44,7 @@ pub const Job = struct { .inputs = &.{}, }; errdefer gpa.free(job.command); - job.cwd = try gpa.dupe(u8, request.cwd); + job.cwd = try gpa.dupe(u8, filesystem.localPath(request.cwd) orelse request.cwd); errdefer gpa.free(job.cwd); job.inputs = try gpa.alloc([]u8, request.inputs.len); errdefer gpa.free(job.inputs); @@ -325,6 +326,29 @@ pub fn runJob(gpa: std.mem.Allocator, io: std.Io, job: *const Job) Response { return response; } +test "native selection filters use the physical directory of an explicit OS mount" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(std.testing.io, &directory_buf)]; + var declared_buf: [4102]u8 = undefined; + const declared = try std.fmt.bufPrint(&declared_buf, "/n/os{s}", .{directory}); + const job = try Job.copy(gpa, .{ .id = 1, .command = "pwd", .cwd = declared, .inputs = &.{.{ .bytes = "" }} }); + defer job.deinit(gpa); + try std.testing.expectEqualStrings(directory, job.cwd); + switch (runOne(gpa, std.testing.io, job.command, job.cwd, "")) { + .ok => |bytes| { + defer gpa.free(bytes); + try std.testing.expectEqualStrings(directory, std.mem.trimEnd(u8, bytes, "\n")); + }, + .failed => |failure| { + gpa.free(failure.stderr); + return error.FilterFailed; + }, + } +} + test "native pipe runner preserves stdin/stdout bytes and reports how it failed" { const gpa = std.testing.allocator; const io = std.testing.io; diff --git a/src/shell_bin.zig b/src/shell_bin.zig deleted file mode 100644 index 1090bb2b..00000000 --- a/src/shell_bin.zig +++ /dev/null @@ -1,567 +0,0 @@ -//! Turning the name of a shell into something a freshly forked child can exec, -//! and into the argv that hands that shell its prompt marks. -//! -//! Native-shell side, like temp_file.zig and message.zig, and for the same -//! reason: it touches the filesystem, and the core does not. The core carries -//! only the NAME (Pardes.shellBin, what the Shell builtin was given); which -//! family that is, what to write for it, where to write it and where the -//! binary actually lives all live here, and both frontends call it rather than -//! keeping a copy each. Nothing here imports the core, which is also what lets -//! it be its own std-only test module. -//! -//! Each host owns one `PromptRcs` for its lifetime. Its files are private -//! `mkstemp` names, completely written and closed before resolve can expose -//! them to a child. Concurrent launches therefore share neither a pathname nor -//! an inode, and a shell can never source another user's predictable /tmp file. -//! -//! ALL OF THIS RUNS IN THE PARENT. Between fork and exec a process may not -//! allocate, and a $PATH search does — which is the same reason the exec is -//! `execv` on an absolute path and never `execvp`. So the lookup is a handful -//! of `access` calls over the directories a shell actually lives in, done -//! before the fork, into a caller buffer that the child then inherits through -//! its copy of the stack. -const std = @import("std"); -const builtin = @import("builtin"); - -const libc = std.c; -const X_OK: c_int = 1; - -extern "c" fn mkstemp(template: [*:0]u8) c_int; -extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; - -// ------------------------------------------------- the GUI launch's PATH - -/// Bounded storage for the composed PATH. /etc/paths and /etc/paths.d hold ten -/// directories on a stock machine and a handful more with third-party -/// packages; 4 KiB is not a limit anyone will meet, and a fixed buffer keeps -/// this callable from a host that has not built an allocator yet. -const path_capacity = 4096; -const max_path_files = 64; - -/// macOS: give the PROCESS the PATH a login session would have, but only when -/// it plainly has not got one. -/// -/// A GUI launch — Finder, the Dock, `open(1)` — inherits launchd's -/// environment, and launchd's PATH is `/usr/bin:/bin:/usr/sbin:/sbin`. Nothing -/// else: no /opt/homebrew/bin, no /usr/local/bin. A launch from a terminal -/// inherits the shell's PATH and is fine. That difference is the whole bug, -/// and it is why it reads as intermittent — the same build finds `yazi` when -/// you start it from a terminal and cannot find it when you start it from the -/// Dock. -/// -/// macOS's own answer is /usr/libexec/path_helper, which reads /etc/paths and -/// /etc/paths.d. LOGIN shells run it and non-login shells do not, and pardes -/// spawns non-login shells deliberately (see `resolve`) — so a pane cannot fix -/// this for itself. Nor should it: one environ is inherited by every pty shell -/// pardes forks, every `/bin/sh -c` filter, and every language server the LSP -/// client spawns, and `binOf` searching a launchd PATH is a rust-analyzer that -/// is never found. Fixing the process fixes all of them at once. -/// -/// ONLY when every entry already in PATH is a system directory. That is the -/// test for "nobody configured this". path_helper appends pre-existing entries -/// AFTER the system set, so running it over a real session's PATH would demote -/// a version manager's shims behind /usr/bin and quietly change which `node` -/// runs. A configured PATH is left exactly as it is; the launchd case is -/// unambiguous and is the only one touched. -pub fn adoptSystemPath() void { - if (comptime builtin.os.tag != .macos) return; - var buf: [path_capacity]u8 = undefined; - var len: usize = 0; - collectSystemPath(&buf, &len); - if (len == 0) return; - const system = buf[0..len]; - - const current: []const u8 = if (libc.getenv("PATH")) |p| std.mem.span(p) else ""; - if (!allEntriesWithin(current, system)) return; - if (std.mem.eql(u8, current, system)) return; - - var out: [path_capacity:0]u8 = undefined; - if (len >= out.len) return; - @memcpy(out[0..len], system); - out[len] = 0; - _ = setenv("PATH", out[0..len :0].ptr, 1); -} - -/// Everything a native shell must do TO THE PROCESS before it forks its first -/// pane, in the order it has to happen, handing back the prompt files those -/// forks will borrow. -/// -/// Four hosts performed this ritual by hand and the copies had already -/// diverged. detached/server.zig forks bash through `resolve` exactly like its -/// siblings and never set BASH_SILENCE_DEPRECATION_WARNING, so every pane in a -/// detached session on macOS opened with Apple's zsh-migration banner printed -/// across the top of it — and nobody noticed, because the three hosts anyone -/// looks at daily all had the line. That is the failure mode of a four-line -/// ritual written four times. -/// -/// The ORDER is the content here. `adoptSystemPath` has to precede the fork -/// because the child inherits the environ; the setenv has to precede bash -/// because bash reads it at startup and the rc file is already too late; and -/// the rc files have to be complete on disk before any child can be handed a -/// path to one. -pub fn prepareForFork() PromptRcs { - adoptSystemPath(); - if (comptime builtin.os.tag.isDarwin()) - _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); - return PromptRcs.init(); -} - -/// /etc/paths, then every file in /etc/paths.d in NAME ORDER, which is the -/// order path_helper reads them in and therefore the order the directories -/// take precedence in. -fn collectSystemPath(buf: []u8, len: *usize) void { - var file_buf: [path_capacity]u8 = undefined; - if (readSmall("/etc/paths", &file_buf)) |body| appendLines(buf, len, body); - - const io = std.Io.Threaded.global_single_threaded.io(); - var dir = std.Io.Dir.cwd().openDir(io, "/etc/paths.d", .{ .iterate = true }) catch return; - defer dir.close(io); - - // readdir order is undefined and path_helper's is not, so the names are - // collected and sorted before any of them is read. - var names: [max_path_files][256]u8 = undefined; - var name_lens: [max_path_files]usize = undefined; - var count: usize = 0; - var it = dir.iterate(); - while (count < names.len) { - const entry = (it.next(io) catch break) orelse break; - if (entry.kind == .directory) continue; - if (entry.name.len == 0 or entry.name.len > names[count].len) continue; - @memcpy(names[count][0..entry.name.len], entry.name); - name_lens[count] = entry.name.len; - count += 1; - } - var order: [max_path_files]usize = undefined; - for (0..count) |i| order[i] = i; - std.mem.sort(usize, order[0..count], Names{ .names = &names, .lens = &name_lens }, Names.lessThan); - - var path_buf: [512]u8 = undefined; - for (order[0..count]) |i| { - const name = names[i][0..name_lens[i]]; - const path = std.fmt.bufPrintSentinel(&path_buf, "/etc/paths.d/{s}", .{name}, 0) catch continue; - if (readSmall(path, &file_buf)) |body| appendLines(buf, len, body); - } -} - -const Names = struct { - names: *const [max_path_files][256]u8, - lens: *const [max_path_files]usize, - - fn lessThan(self: Names, a: usize, b: usize) bool { - return std.mem.order(u8, self.names[a][0..self.lens[a]], self.names[b][0..self.lens[b]]) == .lt; - } -}; - -/// One directory per line, blanks and whitespace ignored — the format both -/// files use and the only thing path_helper reads out of them. -fn appendLines(buf: []u8, len: *usize, body: []const u8) void { - var lines = std.mem.splitScalar(u8, body, '\n'); - while (lines.next()) |raw| appendEntry(buf, len, std.mem.trim(u8, raw, " \t\r")); -} - -/// Append `entry` unless it is already present. Dedup preserves the FIRST -/// occurrence, which is what makes the order above mean precedence. -fn appendEntry(buf: []u8, len: *usize, entry: []const u8) void { - if (entry.len == 0) return; - if (hasEntry(buf[0..len.*], entry)) return; - const separator: usize = if (len.* == 0) 0 else 1; - if (len.* + separator + entry.len > buf.len) return; - if (separator == 1) { - buf[len.*] = ':'; - len.* += 1; - } - @memcpy(buf[len.*..][0..entry.len], entry); - len.* += entry.len; -} - -fn hasEntry(list: []const u8, entry: []const u8) bool { - var it = std.mem.tokenizeScalar(u8, list, ':'); - while (it.next()) |have| if (std.mem.eql(u8, have, entry)) return true; - return false; -} - -/// Whether `candidate` holds nothing `list` does not. An empty candidate is -/// within any list: a process with no PATH at all is the launchd case too. -fn allEntriesWithin(candidate: []const u8, list: []const u8) bool { - var it = std.mem.tokenizeScalar(u8, candidate, ':'); - while (it.next()) |entry| if (!hasEntry(list, entry)) return false; - return true; -} - -fn readSmall(path: [:0]const u8, buf: []u8) ?[]const u8 { - const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); - if (fd < 0) return null; - defer _ = libc.close(fd); - var off: usize = 0; - while (off < buf.len) { - const n = libc.read(fd, buf[off..].ptr, buf.len - off); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return null; - } - if (n == 0) break; - off += @intCast(n); - } - return buf[0..off]; -} - -test "the launchd PATH is replaced and a configured one is left alone" { - var buf: [256]u8 = undefined; - var len: usize = 0; - appendEntry(&buf, &len, "/usr/bin"); - appendEntry(&buf, &len, "/bin"); - appendEntry(&buf, &len, "/usr/bin"); // already there: dedup keeps the first - appendEntry(&buf, &len, ""); - try std.testing.expectEqualStrings("/usr/bin:/bin", buf[0..len]); - - // Exactly the launchd default, in any order: nothing here is a choice. - try std.testing.expect(allEntriesWithin("/usr/bin:/bin", "/usr/bin:/bin:/sbin")); - try std.testing.expect(allEntriesWithin("", "/usr/bin")); - // One entry nobody could have inherited by accident, and the whole PATH is - // off limits — reordering it behind /usr/bin is how a version manager stops - // deciding which `node` runs. - try std.testing.expect(!allEntriesWithin("/Users/x/.cargo/bin:/usr/bin", "/usr/bin:/bin")); - try std.testing.expect(!allEntriesWithin("/opt/homebrew/bin", "/usr/bin:/bin")); -} - -test "the composed system path is the real one, in path_helper's order" { - if (comptime builtin.os.tag != .macos) return; - var buf: [path_capacity]u8 = undefined; - var len: usize = 0; - collectSystemPath(&buf, &len); - const composed = buf[0..len]; - // /etc/paths exists on every mac and leads with these. - try std.testing.expect(hasEntry(composed, "/usr/bin")); - try std.testing.expect(hasEntry(composed, "/bin")); - // ...and its entries come before anything /etc/paths.d contributes, which - // is the precedence the order encodes. - try std.testing.expect(std.mem.startsWith(u8, composed, "/usr/local/bin:")); - // No duplicates: /etc/paths.d files routinely repeat a system directory. - var seen = std.mem.tokenizeScalar(u8, composed, ':'); - var index: usize = 0; - while (seen.next()) |entry| : (index += 1) { - var rest = std.mem.tokenizeScalar(u8, composed, ':'); - var matches: usize = 0; - while (rest.next()) |other| if (std.mem.eql(u8, other, entry)) { - matches += 1; - }; - try std.testing.expectEqual(@as(usize, 1), matches); - } -} - -/// Prompt integration, per shell FAMILY rather than per binary: pardes hides -/// prompt rows, moves the cursor by clicking one, and tells a command's output -/// from the line that asked for it, and all three read the OSC 133 marks a -/// shell has to be talked into emitting. Every family needs different words -/// for the same four marks and a different way to be handed them, so the -/// binary a pane is about to exec picks one of these and there is nothing to -/// configure. -pub const ShellRc = enum { bash, fish, none }; - -/// Which family a shell binary belongs to, by the BASENAME's prefix — the -/// whole heuristic. A prefix and not an exact match because a real system -/// spells them `bash`, `/usr/bin/bash`, `bash-5.2`, `fish-3.7`, and pinning -/// exact names would mean a list to maintain against other people's packaging. -/// It costs a false positive on a program called `fishing`, which is a shell -/// nobody has. -/// -/// `none` is not a failure: it execs the binary plain and the pane works, it -/// just has no prompt marks, so prompts are not hidden and a click on one does -/// not move the shell's cursor. Everything else about the pane is unaffected. -/// -/// ponytail: two families and a fallback. zsh is the obvious third and is NOT -/// here because it is shaped differently — it has no `--rcfile`, so it needs a -/// whole ZDOTDIR directory staged with a .zshrc that re-sources the user's, -/// plus an env var set before exec. Add it when someone runs zsh in pardes and -/// misses prompt hiding; the rc text itself is four lines (precmd/preexec). -pub fn shellRc(bin: []const u8) ShellRc { - const slash = std.mem.lastIndexOfScalar(u8, bin, '/'); - const base = if (slash) |s| bin[s + 1 ..] else bin; - if (std.mem.startsWith(u8, base, "bash")) return .bash; - if (std.mem.startsWith(u8, base, "fish")) return .fish; - return .none; -} - -const bash_rc = - \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" - \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' - \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" - \\trap 'printf "\e]133;C\a"' DEBUG - \\ -; - -/// fish is handed this with `-C`, which runs AFTER config.fish — and it has to, -/// because the first thing it does is copy the user's own `fish_prompt` to call -/// it from the middle of ours. Loaded any earlier it would copy the default and -/// silently replace whatever the user actually configured. -/// -/// The other half is why there is no `source ~/.config/fish/config.fish` line -/// the way the bash rc sources .bashrc: bash is being started with `--rcfile`, -/// which REPLACES its startup file, so the rc has to put it back. `-C` adds to -/// fish's startup instead of standing in for it. -/// -/// C and D come off fish's own `fish_preexec`/`fish_postexec` events rather -/// than being spliced into the prompt, which is what bash's DEBUG trap is -/// working around. -const fish_rc = - \\functions -c fish_prompt __pardes_user_prompt - \\function fish_prompt - \\ printf '\e]133;A;cl=line\a' - \\ __pardes_user_prompt - \\ printf '\e]133;B\a' - \\end - \\function __pardes_preexec --on-event fish_preexec - \\ printf '\e]133;C\a' - \\end - \\function __pardes_postexec --on-event fish_postexec - \\ printf '\e]133;D\a' - \\end - \\ -; - -const rc_path_capacity = 64; - -/// The two complete, private prompt files a native host lends to every shell -/// it spawns. No allocation and no global name: moving this value is safe -/// because it stores lengths, never pointers into its own buffers. -pub const PromptRcs = struct { - bash_path: [rc_path_capacity:0]u8 = @splat(0), - bash_len: u8 = 0, - fish_path: [rc_path_capacity:0]u8 = @splat(0), - fish_len: u8 = 0, - fish_command: [rc_path_capacity + "source ".len:0]u8 = @splat(0), - fish_command_len: u8 = 0, - - pub fn init() PromptRcs { - var rcs: PromptRcs = .{}; - rcs.bash_len = stage(&rcs.bash_path, "/tmp/pardes-osc133-bash-XXXXXX", bash_rc); - rcs.fish_len = stage(&rcs.fish_path, "/tmp/pardes-osc133-fish-XXXXXX", fish_rc); - if (rcs.fishPath()) |path| { - const command = std.fmt.bufPrintSentinel(&rcs.fish_command, "source {s}", .{path}, 0) catch { - _ = libc.unlink(path.ptr); - rcs.fish_len = 0; - return rcs; - }; - rcs.fish_command_len = @intCast(command.len); - } - return rcs; - } - - pub fn deinit(rcs: *PromptRcs) void { - if (rcs.bashPath()) |path| _ = libc.unlink(path.ptr); - if (rcs.fishPath()) |path| _ = libc.unlink(path.ptr); - rcs.bash_len = 0; - rcs.fish_len = 0; - rcs.fish_command_len = 0; - } - - fn bashPath(rcs: *const PromptRcs) ?[:0]const u8 { - if (rcs.bash_len == 0) return null; - return rcs.bash_path[0..rcs.bash_len :0]; - } - - fn fishPath(rcs: *const PromptRcs) ?[:0]const u8 { - if (rcs.fish_len == 0) return null; - return rcs.fish_path[0..rcs.fish_len :0]; - } - - fn fishCommand(rcs: *const PromptRcs) ?[:0]const u8 { - if (rcs.fish_command_len == 0) return null; - return rcs.fish_command[0..rcs.fish_command_len :0]; - } -}; - -/// Create one private 0600 file and reveal its length only after the complete -/// write and close. Failure leaves no pathname for resolve to hand to a shell. -fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u8 { - const path = std.fmt.bufPrintSentinel(path_buf, "{s}", .{template}, 0) catch return 0; - const fd = mkstemp(path.ptr); - if (fd < 0) return 0; - var off: usize = 0; - while (off < contents.len) { - const n = libc.write(fd, contents[off..].ptr, contents.len - off); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - _ = libc.close(fd); - _ = libc.unlink(path.ptr); - return 0; - } - if (n == 0) { - _ = libc.close(fd); - _ = libc.unlink(path.ptr); - return 0; - } - off += @intCast(n); - } - if (libc.close(fd) != 0) { - _ = libc.unlink(path.ptr); - return 0; - } - return @intCast(path.len); -} - -test "shell family is the basename's prefix, and anything else runs unadorned" { - try std.testing.expectEqual(ShellRc.fish, shellRc("fish")); - try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/bin/fish")); - try std.testing.expectEqual(ShellRc.fish, shellRc("/opt/homebrew/bin/fish")); - try std.testing.expectEqual(ShellRc.bash, shellRc("bash")); - try std.testing.expectEqual(ShellRc.bash, shellRc("/bin/bash")); - // packaged with a version on the end, which is why this is a prefix - try std.testing.expectEqual(ShellRc.bash, shellRc("/usr/bin/bash-5.2")); - try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/local/bin/fish-3.7")); - // a directory that merely CONTAINS the word is not the shell's name - try std.testing.expectEqual(ShellRc.none, shellRc("/opt/fish/bin/nu")); - // no marks, still a shell - try std.testing.expectEqual(ShellRc.none, shellRc("/usr/bin/zsh")); - try std.testing.expectEqual(ShellRc.none, shellRc("/bin/sh")); - try std.testing.expectEqual(ShellRc.none, shellRc("nu")); - try std.testing.expectEqual(ShellRc.none, shellRc("")); -} - -/// The directories a shell binary is actually installed in. Not $PATH: see the -/// header. `/opt/homebrew` and `/opt/local` are where a mac keeps the shells -/// that did not ship with it, which is every shell anyone chooses on purpose. -const bin_dirs = [_][]const u8{ - "/usr/bin/", - "/bin/", - "/usr/local/bin/", - "/opt/homebrew/bin/", - "/opt/local/bin/", - "/usr/sbin/", -}; - -/// Last resorts, in order, when the configured shell is not installed: the -/// shell pardes used to hardcode, then the one POSIX says exists. A pane that -/// opens with the wrong shell beats a pane whose child dies at exec and shows -/// nothing but an immediate EOF. -const fallbacks = [_][]const u8{ - if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash", - "/bin/sh", -}; - -pub const Spawn = struct { - path: [*:0]const u8, - /// argv for execv. Shorter forms stop at their first null, which is what - /// execv reads anyway, so one width covers all three families. - argv: [4:null]?[*:0]const u8, -}; - -/// `bin` is whatever the Shell builtin was given — a bare name to look up, or -/// a path (anything with a `/`) to take at its word. `buf` holds the resolved -/// path for as long as the returned Spawn is used, which for a caller that is -/// about to fork means: until the child execs. `prompt_rcs` is host-lifetime -/// storage and must likewise remain alive through that exec. -pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8, prompt_rcs: *const PromptRcs) Spawn { - const path = find(bin, buf) orelse fallback(buf); - // the family comes off the path that will ACTUALLY be executed, not the - // name that was asked for — `Shell sh` on a system where that is a symlink - // to bash still has no `--rcfile` promise attached to it, and a resolved - // /usr/bin/fish reads as fish whether it was reached by name or by path - const marks: [2]?[*:0]const u8 = switch (shellRc(std.mem.span(path))) { - .bash => if (prompt_rcs.bashPath()) |rc| .{ "--rcfile", rc.ptr } else .{ null, null }, - // -C runs AFTER config.fish, which is the whole point (see fish_rc) - .fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null }, - .none => .{ null, null }, - }; - return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } }; -} - -fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 { - if (bin.len == 0 or bin.len + 1 > buf.len) return null; - if (std.mem.indexOfScalar(u8, bin, '/') != null) { - @memcpy(buf[0..bin.len], bin); - buf[bin.len] = 0; - const p: [*:0]const u8 = @ptrCast(buf); - return if (libc.access(p, X_OK) == 0) p else null; - } - for (bin_dirs) |dir| { - if (dir.len + bin.len + 1 > buf.len) continue; - @memcpy(buf[0..dir.len], dir); - @memcpy(buf[dir.len..][0..bin.len], bin); - buf[dir.len + bin.len] = 0; - const p: [*:0]const u8 = @ptrCast(buf); - if (libc.access(p, X_OK) == 0) return p; - } - return null; -} - -fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 { - for (fallbacks) |f| { - @memcpy(buf[0..f.len], f); - buf[f.len] = 0; - const p: [*:0]const u8 = @ptrCast(buf); - if (libc.access(p, X_OK) == 0) return p; - } - // nothing executable anywhere we know to look: exec will fail and the pane - // will show an immediate EOF, which is the honest report of that machine. - // buf already holds the last candidate, NUL and all. - return @ptrCast(buf); -} - -test "a path is taken at its word, a name is looked up, and both pick their own marks" { - if (builtin.os.tag == .windows) return; - var buf: [std.fs.max_path_bytes]u8 = undefined; - var prompt_rcs = PromptRcs.init(); - defer prompt_rcs.deinit(); - - // /bin/sh exists on every unix this builds for and is in no family, so it - // pins the resolve-by-path arm AND the unadorned argv - const sh = resolve("/bin/sh", &buf, &prompt_rcs); - try std.testing.expectEqualStrings("/bin/sh", std.mem.span(sh.path)); - try std.testing.expect(sh.argv[1] == null); - - // a name with no slash is searched for; whatever it resolves to, it is a - // bash and so carries --rcfile pointing at the rc the shells write - const bash = resolve("bash", &buf, &prompt_rcs); - try std.testing.expect(shellRc(std.mem.span(bash.path)) == .bash); - try std.testing.expectEqualStrings("--rcfile", std.mem.span(bash.argv[1].?)); - try std.testing.expectEqualStrings(prompt_rcs.bashPath().?, std.mem.span(bash.argv[2].?)); - - // nothing is installed under this name, so the fallback answers — and the - // fallback is a real executable, not the name that failed - const missing = resolve("zznosuchshell", &buf, &prompt_rcs); - try std.testing.expect(!std.mem.eql(u8, "zznosuchshell", std.mem.span(missing.path))); - try std.testing.expect(libc.access(missing.path, X_OK) == 0); - - // an absolute path that does not exist falls back too, rather than being - // handed to exec to fail on - const gone = resolve("/zz/no/such/shell", &buf, &prompt_rcs); - try std.testing.expect(libc.access(gone.path, X_OK) == 0); -} - -test "prompt rc owners have private complete files and clean them up" { - if (builtin.os.tag == .windows) return; - var a = PromptRcs.init(); - defer a.deinit(); - var b = PromptRcs.init(); - defer b.deinit(); - const a_bash = a.bashPath() orelse return error.TempCreateFailed; - const b_bash = b.bashPath() orelse return error.TempCreateFailed; - const a_fish = a.fishPath() orelse return error.TempCreateFailed; - try std.testing.expect(!std.mem.eql(u8, a_bash, b_bash)); - const fish_command = a.fishCommand() orelse return error.MissingFishCommand; - try std.testing.expectEqualStrings("source ", fish_command[0.."source ".len]); - try std.testing.expectEqualStrings(a_fish, fish_command["source ".len..]); - - var buf: [bash_rc.len]u8 = undefined; - const fd = libc.open(a_bash.ptr, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return error.OpenFailed; - defer _ = libc.close(fd); - var len: usize = 0; - while (len < buf.len) { - const n = libc.read(fd, buf[len..].ptr, buf.len - len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; - } - if (n == 0) break; - len += @intCast(n); - } - try std.testing.expectEqualStrings(bash_rc, buf[0..len]); - - var removed: [rc_path_capacity:0]u8 = @splat(0); - @memcpy(removed[0..a_bash.len], a_bash); - removed[a_bash.len] = 0; - a.deinit(); - try std.testing.expect(libc.access(&removed, 0) < 0); -} diff --git a/src/source_manifest.zig b/src/source_manifest.zig deleted file mode 100644 index c3bfde88..00000000 --- a/src/source_manifest.zig +++ /dev/null @@ -1,50 +0,0 @@ -//! The virtual filesystem: pardes's own source, embedded, as an ALLOWLIST. -//! -//! This is the default filesystem — what a host that implements no file method -//! reads and writes, and the only one the browser has ever had. It replaced a -//! build-time generator that embedded every tracked `.zig` file: 64 files and -//! 2.4 MB of generated Zig, rediscovered on every consumer build because the -//! tracked set can change without any known input changing. -//! -//! An allowlist instead, for the reason the generator's own comment gave away: -//! nothing needs all of them. What earns a place here is a file that explains -//! how pardes works to someone reading it inside pardes — the core, the host -//! seam, the keymap, the build. `src/pardes.zig` alone is 722 KB of the total, -//! and it is the one file worth that: it IS the program. -//! -//! Paths are as a user would type them, repo-root-relative, which is what -//! `look` resolves a click against. - -const limits = @import("limits.zig"); - -pub const Source = struct { path: []const u8, contents: []const u8 }; - -/// A slice, not an array: the P4 table is empty (see `limits.embedded_sources` -/// for why) and every consumer only ever iterates or takes `.len`. -pub const all: []const Source = if (limits.embedded_sources) &allowlist else &.{}; - -const allowlist = [_]Source{ - .{ .path = "build.zig", .contents = @embedFile("root-build.zig") }, - .{ .path = "build.zig.zon", .contents = @embedFile("root-build.zig.zon") }, - .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, - .{ .path = "src/host.zig", .contents = @embedFile("host.zig") }, - .{ .path = "src/config.zig", .contents = @embedFile("config.zig") }, - .{ .path = "src/main.zig", .contents = @embedFile("main.zig") }, - .{ .path = "src/builtins.zig", .contents = @embedFile("builtins.zig") }, - .{ .path = "src/grammar_manifest.zig", .contents = @embedFile("grammar_manifest.zig") }, - .{ .path = "src/source_manifest.zig", .contents = @embedFile("source_manifest.zig") }, - .{ .path = "src/CHANGELOG.md", .contents = @embedFile("CHANGELOG.md") }, -}; - -/// The written-file override wins, then the allowlist. Callers own no memory -/// here: every byte is static or lives in the Fallback that answered. -pub fn find(path: []const u8) ?[]const u8 { - for (all) |s| if (eql(s.path, path)) return s.contents; - return null; -} - -fn eql(a: []const u8, b: []const u8) bool { - if (a.len != b.len) return false; - for (a, b) |x, y| if (x != y) return false; - return true; -} diff --git a/src/syntax.zig b/src/syntax.zig index f6e7cae2..77ef80d7 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -1,7 +1,3 @@ -//! Tree-sitter syntax highlighting: one style byte per content byte, filled by -//! running each grammar's highlights.scm query (slurped at build time into the -//! ts_queries options module). Grammar set is tiered: `zig`; `minimal` (c, cpp, -//! zig); and `full`, which adds ~24 languages lazily on first use. const std = @import("std"); const config = @import("pardes_config"); const tracy = @import("tracy.zig"); @@ -16,6 +12,8 @@ const full_grammars = config.syntax_full_grammars; const ts = if (enabled) @import("tree-sitter") else struct { pub const Language = opaque {}; pub const Query = opaque {}; + pub const Parser = opaque {}; + pub const QueryCursor = opaque {}; }; const ts_queries = if (enabled) @import("ts_queries") else struct {}; @@ -28,7 +26,8 @@ const Spec = struct { exts: []const []const u8, language: *const fn () callconv(.c) *const ts.Language, query_src: []const u8, - compiled_query: ?*ts.Query = null, + selected: ?Selected = null, + capture_styles: [256]u8 = undefined, }; fn grammarSelected(comptime g: grammar_manifest.Grammar) bool { @@ -46,21 +45,6 @@ fn specCount() comptime_int { } return count; } - -// Upstream's typst highlights.scm names its markup honestly — -// @markup.heading.*, @markup.bold, @markup.italic, @markup.raw.block — and -// `synFor` now understands that vocabulary, so headings, bold, italics and raw -// blocks paint on their own. What is left here is exactly the two captures we -// refuse to map globally: -// -// - upstream tags call callees @function/@function.method; mapping "function" -// in `synFor` would recolour every function call in every language. -// - upstream tags the code sigil "#" @operator, and mapping "operator" would -// likewise light up every +, -, == in the codebase. -// -// Both are worth colouring *in typst specifically*: the sigil in front of every -// #let/#if/#import/#call is the visual anchor of the code/markup split, and -// upstream leaves it uncoloured even though the keyword behind it is not. const typst_supplement = \\ \\(call item: (ident) @keyword) @@ -71,8 +55,69 @@ const typst_supplement = fn querySrc(comptime g: grammar_manifest.Grammar) []const u8 { const base = @field(ts_queries, g.name ++ "_highlights"); - if (comptime std.mem.eql(u8, g.name, "typst")) return base ++ typst_supplement; - return base; + const source = if (comptime std.mem.eql(u8, g.name, "typst")) base ++ typst_supplement else base; + return comptime colorQuery(source); +} + +fn colorQuery(comptime source: []const u8) []const u8 { + @setEvalBranchQuota(2_000_000); + var result: [source.len]u8 = undefined; + var written: usize = 0; + var at: usize = 0; + while (at < source.len) { + while (at < source.len) { + if (std.ascii.isWhitespace(source[at])) { + at += 1; + } else if (source[at] == ';') { + while (at < source.len and source[at] != '\n') at += 1; + } else break; + } + const begin = at; + var depth: usize = 0; + var colored = false; + var complete = false; + while (at < source.len) { + const byte = source[at]; + if (complete and (byte == '(' or byte == '[' or byte == '"')) break; + if (byte == ';') { + while (at < source.len and source[at] != '\n') at += 1; + continue; + } + if (byte == '"') { + at += 1; + while (at < source.len) : (at += 1) { + if (source[at] == '\\') { + at += 1; + } else if (source[at] == '"') { + at += 1; + break; + } + } + if (depth == 0) complete = true; + continue; + } + if (byte == '(' or byte == '[') depth += 1; + if (byte == ')' or byte == ']') { + depth -= 1; + if (depth == 0) complete = true; + } + if (byte == '@') { + const name = at + 1; + at = name; + while (at < source.len and (std.ascii.isAlphanumeric(source[at]) or + source[at] == '_' or source[at] == '.' or source[at] == '-')) at += 1; + colored = colored or synFor(source[name..at]) != .none; + continue; + } + at += 1; + } + if (colored) { + @memcpy(result[written..][0 .. at - begin], source[begin..at]); + written += at - begin; + } + } + const filtered = result[0..written].*; + return &filtered; } fn initSpecs() [specCount()]Spec { @@ -151,6 +196,7 @@ fn syntaxFree(ptr: ?*anyopaque) callconv(.c) void { pub fn start(gpa: std.mem.Allocator) void { if (comptime enabled) { std.debug.assert(!syntax_started); + stop(); syntax_allocator = gpa; syntax_started = true; ts_set_allocator(syntaxAlloc, syntaxCalloc, syntaxRealloc, syntaxFree); @@ -159,10 +205,13 @@ pub fn start(gpa: std.mem.Allocator) void { pub fn stop() void { if (comptime enabled) { - std.debug.assert(syntax_started); for (&specs) |*spec| { - if (spec.compiled_query) |query| query.destroy(); - spec.compiled_query = null; + if (spec.selected) |selected| { + selected.cursor.destroy(); + selected.parser.destroy(); + selected.query.destroy(); + } + spec.selected = null; } ts_set_allocator(null, null, null, null); syntax_started = false; @@ -170,17 +219,41 @@ pub fn stop() void { } } -const Selected = struct { name: []const u8, lang: *const ts.Language, query: *ts.Query }; +const Selected = struct { + name: []const u8, + lang: *const ts.Language, + query: *ts.Query, + parser: *ts.Parser, + cursor: *ts.QueryCursor, + capture_styles: []u8, +}; -// NOTE: don't lang.destroy() — the tree_sitter_*() languages are static -// singletons reused on every open; destroying one use-after-frees the next. fn ensure(spec: *Spec) !Selected { + if (spec.selected) |selected| return selected; const lang = spec.language(); - if (spec.compiled_query) |query| return .{ .name = spec.name, .lang = lang, .query = query }; var error_offset: u32 = 0; const query = try ts.Query.create(lang, spec.query_src, &error_offset); - spec.compiled_query = query; - return .{ .name = spec.name, .lang = lang, .query = query }; + errdefer query.destroy(); + if (query.captureCount() > spec.capture_styles.len) return error.TooManyCaptures; + const capture_styles = spec.capture_styles[0..query.captureCount()]; + for (capture_styles, 0..) |*style, id| { + const name = query.captureNameForId(@intCast(id)) orelse ""; + style.* = @intFromEnum(synFor(name)); + if (style.* == 0) query.disableCapture(name); + } + const parser = ts.Parser.create(); + errdefer parser.destroy(); + try parser.setLanguage(lang); + const selected: Selected = .{ + .name = spec.name, + .lang = lang, + .query = query, + .parser = parser, + .cursor = ts.QueryCursor.create(), + .capture_styles = capture_styles, + }; + spec.selected = selected; + return selected; } fn forExt(ext: []const u8) !?Selected { @@ -226,30 +299,20 @@ fn forLang(name: []const u8) !?Selected { } return null; } - -/// The caller owns the cursor: `ts_query_cursor_exec` fully resets its state, -/// so one cursor serves any number of trees, and the per-row pass would -/// otherwise create and destroy one — three allocations against the shared -/// tree-sitter arena — for every row of a results buffer. -fn runQuery(styles: []u8, sel: Selected, tree: *ts.Tree, base: usize, cursor: *ts.QueryCursor) void { +fn runQuery(styles: []u8, sel: Selected, tree: *ts.Tree, base: usize) void { + const cursor = sel.cursor; cursor.exec(sel.query, tree.rootNode()); while (cursor.nextMatch()) |match| { for (match.captures) |cap| { - const syn = synFor(sel.query.captureNameForId(cap.index) orelse ""); - if (syn == .none) continue; + const style = sel.capture_styles[cap.index]; + if (style == 0) continue; const b = @min(base + cap.node.startByte(), styles.len); const end = @min(base + @as(usize, cap.node.endByte()), styles.len); - if (end > b) @memset(styles[b..end], @intFromEnum(syn)); + if (end > b) @memset(styles[b..end], style); } } } -// Queries compile on first use (`ensure`), never at startup. Pre-compiling the -// compact tier in Pardes.init cost EVERY boot ~120ms of ts_query__perform_analysis -// (55% of a Debug startup) to save ~40ms on the first .zig/.c/.cpp open — a pane -// of prose or a shell paid for a language it never opened. Grammar availability -// is unchanged; only the timing moved. - fn synFor(name: []const u8) Syn { for ([_]struct { []const u8, Syn }{ .{ "comment", .comment }, @@ -266,19 +329,6 @@ fn synFor(name: []const u8) Syn { .{ "title", .keyword }, .{ "uri", .string }, .{ "reference", .number }, - - // Markup grammars (markdown, typst) name prose constructs in their own - // vocabulary rather than the code vocabulary above, so none of the - // needles so far reach them. These are appended, and first-match-wins - // makes that strictly additive; the needles below were audited across - // all 27 shipped queries and occur only in the markdown and typst ones, - // so no other language is recoloured. - // - // The slot assignment is forced by the palette being four wide and by - // `synStyle` attaching the real BOLD attribute to exactly two of them, - // `keyword` and `comment`: headings take `keyword`, so bold spans have - // to land on `comment` to render actually bold. Nothing is left that - // renders italic, so emphasis can only get a colour shift (`number`). .{ "heading", .keyword }, .{ "strong", .comment }, .{ "bold", .comment }, @@ -291,75 +341,31 @@ fn synFor(name: []const u8) Syn { } return .none; } - -/// One Syn byte per content byte in [start, end). Caller frees. -pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { - const tz = tracy.zone(@src(), "highlightFileRange"); - defer tz.end(); +pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_raw: usize, end_raw: usize) ![]u8 { + const zone = tracy.zone(@src(), "highlightFileRange"); + defer zone.end(); if (!enabled) return &.{}; - const ext = std.fs.path.extension(path); - const selected = (forExt(ext) catch return &.{}) orelse return &.{}; - - const start_byte = @min(start_byte_raw, content.len); - const end_byte = @max(start_byte, @min(end_byte_raw, content.len)); + const selected = (try forExt(std.fs.path.extension(path))) orelse return &.{}; + const start_byte = @min(start_raw, content.len); + const end_byte = @max(start_byte, @min(end_raw, content.len)); const source = content[start_byte..end_byte]; const styles = try gpa.alloc(u8, source.len); - errdefer gpa.free(styles); @memset(styles, 0); - - const parser = ts.Parser.create(); - defer parser.destroy(); - parser.setLanguage(selected.lang) catch return styles; - const cursor = ts.QueryCursor.create(); - defer cursor.destroy(); - paintWith(styles, source, selected, parser, cursor, true); + paint(styles, source, selected); return styles; } -/// Parse `source` and write its style bytes into `styles`, with a parser and a -/// query cursor the caller owns, so the per-row pass below can run a whole -/// results buffer through one of each. -/// -/// `inject` is off for a single row. Both injection passes build a SECOND -/// parser of their own — per fenced block, per `inline` node — which is -/// amortised over a document and absurd over one truncated grep row that -/// almost never contains a fenced block to begin with. -fn paintWith( - styles: []u8, - source: []const u8, - selected: Selected, - parser: *ts.Parser, - cursor: *ts.QueryCursor, - inject: bool, -) void { - const tree = parser.parseString(source, null) orelse return; +fn paint(styles: []u8, source: []const u8, selected: Selected) void { + const tree = selected.parser.parseString(source, null) orelse return; defer tree.destroy(); + runQuery(styles, selected, tree, 0); + if (std.mem.eql(u8, selected.name, "markdown")) { + inject(styles, source, tree.rootNode(), true); + } else if (std.mem.eql(u8, selected.name, "typst")) { + inject(styles, source, tree.rootNode(), false); + } +} - runQuery(styles, selected, tree, 0, cursor); - if (!inject) return; - - if (InjectSite.forGrammar(selected.name)) |site| injectCodeBlocks(styles, source, tree.rootNode(), site); - // Disjoint from the fenced-block pass above: `code_fence_content` is never - // an `inline` node, so the two never write the same byte. - if (std.mem.eql(u8, selected.name, "markdown")) injectMarkdownInline(styles, source, tree.rootNode()); -} - -/// A results buffer — every search, grep and language answer in this program — -/// coloured as the CODE it is quoting. -/// -/// The rows look like `src/look.zig:718:12-16 fn grepText(path: []const u8...`: -/// a location, a space, and a piece of some file. The location names the file, -/// the file names the grammar, and the rest of the row is a fragment of that -/// language — so a +Grep over Zig reads as Zig and one over Markdown does not -/// pretend to. `look.parsePathLine` decides what counts as a location, which is -/// the same primitive n/N walks these buffers with, so the two agree by -/// construction about which rows are locations. -/// -/// ONE PARSER AND ONE CURSOR for the whole buffer, and the buffer is coloured -/// once when it is filled rather than per visible window (file_pane -/// `refreshHighlights`) — the rows are independent, so a window pass buys no -/// fidelity and pays a burst of parses, cursors and first-time query compiles -/// on every scroll that outran the covered range. pub fn highlightLocations(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { const tz = tracy.zone(@src(), "highlightLocations"); defer tz.end(); @@ -370,19 +376,6 @@ pub fn highlightLocations(gpa: std.mem.Allocator, content: []const u8, start_byt const styles = try gpa.alloc(u8, source.len); errdefer gpa.free(styles); @memset(styles, 0); - - // Both are created on the first row that needs them and kept for the rest; - // `held` is the language the parser is currently set to. - var parser: ?*ts.Parser = null; - defer if (parser) |ptr| ptr.destroy(); - var cursor: ?*ts.QueryCursor = null; - defer if (cursor) |ptr| ptr.destroy(); - var held: ?Selected = null; - // Consecutive rows of a results buffer are overwhelmingly the same file, - // and `forExt` is a linear walk of 29 specs and their extension lists. One - // remembered answer collapses that to a string compare — including for the - // rows that match NOTHING (a jumplist `@p3:10`, a `.lock`, a `.txt`), - // which otherwise pay the whole failing scan every time. var memo_ext: []const u8 = "\x00"; var memo: ?Selected = null; var painted = false; @@ -398,100 +391,61 @@ pub fn highlightLocations(gpa: std.mem.Allocator, content: []const u8, start_byt memo = forExt(ext) catch null; } const selected = memo orelse continue; - if (parser == null) parser = ts.Parser.create(); - if (cursor == null) cursor = ts.QueryCursor.create(); - if (held == null or held.?.lang != selected.lang) { - // `held` is cleared FIRST: a failed `setLanguage` has already set - // the parser's language to null, so leaving `held` on the previous - // grammar makes every later row of it skip the call and parse - // against nothing — the rest of the buffer silently loses colour. - held = null; - parser.?.setLanguage(selected.lang) catch continue; - held = selected; - } - paintWith(styles[offset + code.at ..][0..code.text.len], code.text, selected, parser.?, cursor.?, false); + paint(styles[offset + code.at ..][0..code.text.len], code.text, selected); painted = true; } - // NOTHING TO PAINT IS NOTHING TO KEEP. `recolorSyntax` skips a pane whose - // highlights are empty, and every output buffer without locations in it — - // +Help, +Config, +Messages, +Errors — would otherwise hand the renderer a - // full-length run of zeroes and make it walk every visible grapheme, every - // frame, to paint nothing. if (!painted) { gpa.free(styles); return &.{}; } return styles; } - -/// The ` ` split of one results row, or null when the row is not -/// one. A row qualifies when its FIRST whitespace-delimited token is entirely a -/// look target — the whole token, so `see:` in prose does not count — and -/// something follows it. fn codeAfterLocation(line: []const u8) ?struct { path: []const u8, at: usize, text: []const u8 } { const token_end = std.mem.indexOfAny(u8, line, " \t") orelse return null; if (token_end == 0) return null; const token = line[0..token_end]; const target = look.parsePathLine(token); if (target.end != token.len) return null; - // A bare word is not a location: `main.zig` alone is a filename, but a - // results row is `main.zig:12:3`, and without that a prose line whose - // first word happens to end in `.md` would colour the rest of a sentence. if (target.at.line == 0) return null; - var at = token_end; - while (at < line.len and (line[at] == ' ' or line[at] == '\t')) at += 1; + const at = token_end + 1; if (at >= line.len) return null; return .{ .path = target.path, .at = at, .text = line[at..] }; } - -// Markdown fenced blocks and Typst raw blocks are the same construct — a -// language tag plus a literal payload — under different node shapes, so one -// walker drives both and only the (lang, content) extraction differs. -const InjectSite = enum { - markdown_fence, - typst_raw, - - fn forGrammar(name: []const u8) ?InjectSite { - if (std.mem.eql(u8, name, "markdown")) return .markdown_fence; - if (std.mem.eql(u8, name, "typst")) return .typst_raw; - return null; - } - - fn blockKind(self: InjectSite) []const u8 { - return switch (self) { - .markdown_fence => "fenced_code_block", - .typst_raw => "raw_blck", - }; - } - - /// null when the block carries no language tag (an untagged fence, or a - /// Typst raw block written without one) — nothing to inject, leave it alone. - fn parts(self: InjectSite, block: ts.Node) ?struct { lang: ts.Node, content: ts.Node } { - switch (self) { - .markdown_fence => { - const info = childOfKind(block, "info_string") orelse return null; - return .{ - .lang = childOfKind(info, "language") orelse return null, - .content = childOfKind(block, "code_fence_content") orelse return null, - }; - }, - .typst_raw => return .{ - .lang = block.childByFieldName("lang") orelse return null, - .content = childOfKind(block, "blob") orelse return null, - }, - } +fn inject(styles: []u8, source: []const u8, node: ts.Node, markdown: bool) void { + const kind = node.kind(); + if (markdown and std.mem.eql(u8, kind, "inline")) { + const begin: usize = node.startByte(); + const end: usize = node.endByte(); + if (begin >= end or end > source.len) return; + const text = source[begin..end]; + // Every colored inline capture requires one of these delimiters. + if (std.mem.indexOfAny(u8, text, "*_`[<\\\r\n") == null) return; + const selected = (forLang("markdown_inline") catch return) orelse return; + const tree = selected.parser.parseString(text, null) orelse return; + defer tree.destroy(); + runQuery(styles, selected, tree, begin); + return; } -}; - -fn injectCodeBlocks(styles: []u8, source: []const u8, node: ts.Node, site: InjectSite) void { - if (std.mem.eql(u8, node.kind(), site.blockKind())) { - highlightCodeBlock(styles, source, node, site); + if (std.mem.eql(u8, kind, if (markdown) "fenced_code_block" else "raw_blck")) { + const lang = if (markdown) blk: { + const info = childOfKind(node, "info_string") orelse return; + break :blk childOfKind(info, "language") orelse return; + } else node.childByFieldName("lang") orelse return; + const content = childOfKind(node, if (markdown) "code_fence_content" else "blob") orelse return; + const selected = (forLang(source[lang.startByte()..lang.endByte()]) catch return) orelse return; + const begin: usize = content.startByte(); + const end: usize = content.endByte(); + if (begin > end or end > source.len) return; + const tree = selected.parser.parseString(source[begin..end], null) orelse return; + defer tree.destroy(); + @memset(styles[begin..end], 0); + runQuery(styles, selected, tree, begin); return; } var i: u32 = 0; const count = node.childCount(); while (i < count) : (i += 1) { - if (node.child(i)) |c| injectCodeBlocks(styles, source, c, site); + if (node.child(i)) |child| inject(styles, source, child, markdown); } } @@ -506,79 +460,6 @@ fn childOfKind(node: ts.Node, kind: []const u8) ?ts.Node { return null; } -fn highlightCodeBlock(styles: []u8, source: []const u8, block: ts.Node, site: InjectSite) void { - const p = site.parts(block) orelse return; - const langtext = source[p.lang.startByte()..p.lang.endByte()]; - const sub_sel = (forLang(langtext) catch return) orelse return; - const cs: usize = p.content.startByte(); - const ce: usize = p.content.endByte(); - if (ce > source.len or cs > ce) return; - - const parser = ts.Parser.create(); - defer parser.destroy(); - parser.setLanguage(sub_sel.lang) catch return; - const tree = parser.parseString(source[cs..ce], null) orelse return; - defer tree.destroy(); - // The outer grammar already painted these bytes (Typst blankets the whole - // raw block `string`), and runQuery only writes bytes it captures, so the - // outer colour would survive as a wash behind the injected code. The sub - // grammar owns the payload outright: clear it first. - @memset(styles[cs..ce], @intFromEnum(Syn.none)); - const cursor = ts.QueryCursor.create(); - defer cursor.destroy(); - runQuery(styles, sub_sel, tree, cs, cursor); -} - -// tree-sitter-markdown is a split grammar: the block parser bottoms out at named -// `inline` nodes whose bytes it never looks inside, and emphasis / -// strong_emphasis / code_span exist only in the companion inline parser. So -// every `inline` node is re-parsed with `markdown_inline`, which is what -// upstream's injections.scm, helix and nvim all do (per node, uncombined — the -// stray block_continuation markers inside a multi-line paragraph's range are -// just plain text to the inline parser). -// -// The grammar is resolved and the parser built once per file, not once per node: -// only the parse is inherently per node. -fn injectMarkdownInline(styles: []u8, source: []const u8, root: ts.Node) void { - // Absent in builds below the `full` tier — nothing to inject, leave the - // block grammar's colours alone. - const sub_sel = (forLang("markdown_inline") catch return) orelse return; - const parser = ts.Parser.create(); - defer parser.destroy(); - parser.setLanguage(sub_sel.lang) catch return; - inlineNodes(styles, source, root, sub_sel, parser); -} - -fn inlineNodes(styles: []u8, source: []const u8, node: ts.Node, sel: Selected, parser: *ts.Parser) void { - if (std.mem.eql(u8, node.kind(), "inline")) { - highlightInline(styles, source, node, sel, parser); - return; // `inline` nodes never nest - } - var i: u32 = 0; - const count = node.childCount(); - while (i < count) : (i += 1) { - if (node.child(i)) |c| inlineNodes(styles, source, c, sel, parser); - } -} - -fn highlightInline(styles: []u8, source: []const u8, node: ts.Node, sel: Selected, parser: *ts.Parser) void { - const s: usize = node.startByte(); - const e: usize = node.endByte(); - if (s >= e or e > source.len) return; // an empty atx heading has a zero-length inline - const tree = parser.parseString(source[s..e], null) orelse return; - defer tree.destroy(); - // Deliberately NOT clearing the range first, unlike highlightCodeBlock: the - // block query already painted a heading's inline text `keyword`, and that is - // the colour the heading must keep wherever the inline pass captures - // nothing. Painting over instead of resetting is what makes *italic* inside - // a heading recolour while the rest of the heading stays heading-coloured. - const cursor = ts.QueryCursor.create(); - defer cursor.destroy(); - runQuery(styles, sel, tree, s, cursor); -} - -/// One Syn byte per byte of content[start, end) for unified diffs/patches. -/// Pure byte scan; independent of tree-sitter and the `enabled` flag. Caller frees. pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { const start_byte = @min(start_byte_raw, content.len); const end_byte = @max(start_byte, @min(end_byte_raw, content.len)); @@ -610,14 +491,11 @@ fn diffLineSyn(line: []const u8) Syn { return .none; } -test "a results row is coloured by the file its location names" { +test "syntax a results row is coloured by the file its location names" { if (!enabled) return; start(std.testing.allocator); defer stop(); const gpa = std.testing.allocator; - - // Two rows quoting two languages, plus a row that is not a location and a - // location with nothing after it. const content = "src/a.zig:1:1 const S = struct {};\n" ++ "src/b.md:2:1 # heading\n" ++ @@ -626,53 +504,31 @@ test "a results row is coloured by the file its location names" { const styles = try highlightLocations(gpa, content, 0, content.len); defer gpa.free(styles); try std.testing.expectEqual(content.len, styles.len); - - // The LOCATION itself is left alone — it is not code, and colouring it as - // code is how a path starts looking like a keyword. for (styles[0.."src/a.zig:1:1".len]) |b| try std.testing.expectEqual(@as(u8, 0), b); - - // ...and `struct` in the Zig row is a keyword, which is only true if the - // grammar was chosen from `a.zig` rather than from the buffer's own name. - // - // `struct` and not `const`: tree-sitter-zig captures `const` as - // `@type.qualifier`, which `synFor` maps to nothing — a real property of - // the shipped query rather than of this pass, and the reason the first - // version of this test failed. const zig_kw = std.mem.indexOf(u8, content, "struct").?; try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[zig_kw]); try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[zig_kw + 5]); - - // A row with no location contributes nothing... const prose = std.mem.indexOf(u8, content, "just some prose").?; for (styles[prose .. prose + 14]) |b| try std.testing.expectEqual(@as(u8, 0), b); - // ...and neither does a location with no code after it. const bare = std.mem.indexOf(u8, content, "src/c.zig").?; for (styles[bare..]) |b| try std.testing.expectEqual(@as(u8, 0), b); } -test "a buffer with no locations in it keeps no highlights at all" { +test "syntax a buffer with no locations in it keeps no highlights at all" { if (!enabled) return; start(std.testing.allocator); defer stop(); - // +Help, +Config, +Messages: prose. An all-zero run of styles is not the - // same as none — `recolorSyntax` skips a pane whose highlights are EMPTY, - // and returning a full-length run of zeroes made it walk every visible - // grapheme every frame to paint nothing. const content = "nothing has been said yet\n0: save: AccessDenied (x2)\n"; const styles = try highlightLocations(std.testing.allocator, content, 0, content.len); defer std.testing.allocator.free(styles); try std.testing.expectEqual(@as(usize, 0), styles.len); } -test "codeAfterLocation takes whole-token locations and nothing else" { - // A grep row: path, line, column range, then the quoted source. +test "syntax codeAfterLocation takes whole-token locations and nothing else" { const got = codeAfterLocation("src/x.zig:7:2-9 fn main() void {") orelse return error.ShouldBeALocation; try std.testing.expectEqualStrings("src/x.zig", got.path); try std.testing.expectEqualStrings("fn main() void {", got.text); - - // Not locations: a bare filename (no line), prose with a colon, a token - // that only PARTLY parses, and a location with nothing after it. try std.testing.expect(codeAfterLocation("main.zig some words") == null); try std.testing.expect(codeAfterLocation("note: this is prose") == null); try std.testing.expect(codeAfterLocation("src/x.zig:7:2x rest") == null); @@ -681,7 +537,7 @@ test "codeAfterLocation takes whole-token locations and nothing else" { try std.testing.expect(codeAfterLocation(" leading space") == null); } -test "tree-sitter allocator callbacks preserve and free exact allocations" { +test "syntax tree-sitter allocator callbacks preserve and free exact allocations" { syntax_allocator = std.testing.allocator; defer syntax_allocator = undefined; @@ -700,7 +556,7 @@ test "tree-sitter allocator callbacks preserve and free exact allocations" { live = null; } -test "default full grammar set highlights Typst source" { +test "syntax default full grammar set highlights Typst source" { if (!enabled or !full_grammars) return; start(std.testing.allocator); defer stop(); @@ -723,7 +579,7 @@ test "default full grammar set highlights Typst source" { try std.testing.expectEqual(Syn.keyword, @as(Syn, @enumFromInt(short_ext[keyword_at]))); } -test "Typst markup constructs paint and raw blocks inject their language" { +test "syntax Typst markup constructs paint and raw blocks inject their language" { if (!enabled or !full_grammars) return; start(std.testing.allocator); defer stop(); @@ -748,25 +604,14 @@ test "Typst markup constructs paint and raw blocks inject their language" { return @enumFromInt(s[std.mem.indexOf(u8, src, needle).? + offset]); } }.f; - - // marker and text of the heading both take the bold accent try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "= Heading", 0)); try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "Heading", 0)); - // *bold* is @markup.bold, which lands on the one slot that still renders - // with the real bold attribute now that headings own `keyword`. try std.testing.expectEqual(Syn.comment, synAt(styles, source, "bold", 0)); try std.testing.expectEqual(Syn.string, synAt(styles, source, "raw` inline", 0)); - // the callee and the code sigil in front of it try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "emit", 0)); try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "#emit", 0)); - - // fence and lang tag keep the literal colour of the raw block... try std.testing.expectEqual(Syn.string, synAt(styles, source, "```zig", 0)); try std.testing.expectEqual(Syn.string, synAt(styles, source, "```zig", 3)); - // ...while the blob is reset and re-painted by the injected zig grammar. Its - // `fn` and `99` prove the injection ran; `widget` proves the reset, since the - // zig query names it @function (Syn.none here) and without clearing the blob - // first the raw block's `string` would still be washing over it. try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "fn widget", 0)); try std.testing.expectEqual(Syn.number, synAt(styles, source, "99", 0)); try std.testing.expectEqual(Syn.none, synAt(styles, source, "widget", 0)); @@ -778,7 +623,7 @@ test "Typst markup constructs paint and raw blocks inject their language" { try std.testing.expectEqual(Syn.string, synAt(styles, source, "\"arg\"", 0)); } -test "markdown highlights markup, injects inline spans and fenced code blocks" { +test "syntax markdown highlights markup, injects inline spans and fenced code blocks" { if (!enabled or !full_grammars) return; start(std.testing.allocator); defer stop(); @@ -799,31 +644,80 @@ test "markdown highlights markup, injects inline spans and fenced code blocks" { return @enumFromInt(s[std.mem.indexOf(u8, src, needle).? + offset]); } }.f; - - // The block grammar washes the whole heading `keyword`; the inline pass then - // paints the emphasis over it without resetting, so the heading keeps its - // colour everywhere the emphasis is not. try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "Title", 0)); try std.testing.expectEqual(Syn.number, synAt(styles, source, "slant", 0)); - - // bold/italic/code-span live only in the inline grammar, so all three prove - // the inline injection ran. try std.testing.expectEqual(Syn.comment, synAt(styles, source, "stout", 0)); try std.testing.expectEqual(Syn.number, synAt(styles, source, "lean", 0)); try std.testing.expectEqual(Syn.string, synAt(styles, source, "snippet", 0)); - - // the fence is inside the block query's @text.literal wash... try std.testing.expectEqual(Syn.string, synAt(styles, source, "```zig", 0)); - // ...while the payload is cleared and re-painted by the injected zig - // grammar: `fn` and `77` prove the injection ran, and `gadget` proves the - // clear, since the zig query names it @function (Syn.none here) and without - // clearing first the fence's `string` would still be washing over it. try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "fn gadget", 0)); try std.testing.expectEqual(Syn.number, synAt(styles, source, "77", 0)); try std.testing.expectEqual(Syn.none, synAt(styles, source, "gadget", 0)); } -test "highlightDiff colors unified diff lines by prefix" { +test "syntax inline fast path agrees with full Markdown query" { + if (!enabled or !full_grammars) return; + start(std.testing.allocator); + defer stop(); + const selected = (try forLang("markdown_inline")).?; + const check = struct { + fn compare(sel: Selected, source: []const u8) !void { + const tree = sel.parser.parseString(source, null) orelse return error.ParseFailed; + defer tree.destroy(); + const expected = try std.testing.allocator.alloc(u8, source.len); + defer std.testing.allocator.free(expected); + const actual = try std.testing.allocator.alloc(u8, source.len); + defer std.testing.allocator.free(actual); + for ([_]Syn{ .none, .keyword }) |background| { + @memset(expected, @intFromEnum(background)); + @memset(actual, @intFromEnum(background)); + runQuery(expected, sel, tree, 0); + inject(actual, source, tree.rootNode(), true); + if (!std.mem.eql(u8, expected, actual)) std.debug.print("inline mismatch: {s}\n", .{source}); + try std.testing.expectEqualSlices(u8, expected, actual); + } + } + }.compare; + for ([_][]const u8{ + "", "plain prose", + "ação Ελληνικά 日本語 🙂", + "123 456", "tabs\tand spaces", + "'quoted' (parentheses) \"double quotes\"", "https://example.org a@b.org", + "& ", "~~struck~~ $formula$", + "*emphasis* __strong__", "**bold** _emphasis_", + "`code` and ``a`b``", "[text](target \"title\")", + "![description](image)", "[shortcut] [reference][label]", + "[[wiki|text]]", " ", + "text", "\\*escaped\\*", + "soft\nline", "hard \nline", + "hard\\\nline", "tab\t\nline", + "hard \r\nline", "hard \rline", + "**broken", "[broken](", + "`broken", + }) |source| try check(selected, source); + for (0..128) |byte| { + const char: u8 = @intCast(byte); + const source = [_]u8{ char, 'a', 'b', char, ' ', char, char, 'c', char, char }; + try check(selected, &source); + } +} + +test "syntax plain Markdown keeps block styles without starting the inline parser" { + if (!enabled or !full_grammars) return; + start(std.testing.allocator); + defer stop(); + const source = "# Heading\n\nPlain prose.\n\n indented code\n"; + const styles = try highlightFileRange(std.testing.allocator, "a.md", source, 0, source.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[2]); + try std.testing.expectEqual(@intFromEnum(Syn.none), styles[std.mem.indexOf(u8, source, "Plain").?]); + try std.testing.expectEqual(@intFromEnum(Syn.string), styles[std.mem.indexOf(u8, source, "indented").?]); + for (specs) |spec| { + if (std.mem.eql(u8, spec.name, "markdown_inline")) try std.testing.expect(spec.selected == null); + } +} + +test "syntax highlightDiff colors unified diff lines by prefix" { const diff = "diff --git a/x b/x\n" ++ "--- a/x\n" ++ @@ -850,3 +744,98 @@ test "highlightDiff colors unified diff lines by prefix" { try std.testing.expectEqual(Syn.number, byteSyn(styles, diff, "-old line")); try std.testing.expectEqual(Syn.string, byteSyn(styles, diff, "+new line")); } + +test "syntax result fragments preserve source indentation and inline markup" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const fixtures = [_]struct { path: []const u8, source: []const u8 }{ + .{ .path = "a.zig", .source = " const number = 42; // note" }, + .{ .path = "a.md", .source = "# Heading *slant*" }, + .{ .path = "a.md", .source = "**bold** and `code`" }, + .{ .path = "a.md", .source = " # this is indented code" }, + .{ .path = "a.md", .source = "\t# tab-indented code" }, + .{ .path = "a.py", .source = " return \"hello\"" }, + }; + for (fixtures) |fixture| { + const expected = try highlightFileRange(std.testing.allocator, fixture.path, fixture.source, 0, fixture.source.len); + defer std.testing.allocator.free(expected); + const row = try std.fmt.allocPrint(std.testing.allocator, "{s}:12:3-9 {s}", .{ fixture.path, fixture.source }); + defer std.testing.allocator.free(row); + const actual = try highlightLocations(std.testing.allocator, row, 0, row.len); + defer std.testing.allocator.free(actual); + if (expected.len == 0) { + try std.testing.expectEqual(@as(usize, 0), actual.len); + continue; + } + const code_at = row.len - fixture.source.len; + try std.testing.expectEqualSlices(u8, expected, actual[code_at..]); + for (actual[0..code_at]) |style| try std.testing.expectEqual(@as(u8, 0), style); + } +} + +test "syntax query filtering preserves upstream colors" { + if (!enabled) return; + var allocator: std.heap.DebugAllocator(.{ .stack_trace_frames = 0, .safety = true }) = .init; + defer if (allocator.deinit() != .ok) @panic("leaked syntax query allocations"); + start(allocator.allocator()); + defer stop(); + const source = "// comment\n# Heading *inline*\nconst value = 42;\nif (true) { return \"quoted\"; }\n/* multi\nline */\n"; + inline for (grammar_manifest.all) |grammar| { + if (comptime grammarSelected(grammar)) { + const selected = (try forLang(grammar.name)).?; + const raw_source = @field(ts_queries, grammar.name ++ "_highlights") ++ + (if (comptime std.mem.eql(u8, grammar.name, "typst")) typst_supplement else ""); + var error_offset: u32 = 0; + const raw_query = try ts.Query.create(selected.lang, raw_source, &error_offset); + defer raw_query.destroy(); + var reference = selected; + reference.query = raw_query; + reference.capture_styles = try std.testing.allocator.alloc(u8, raw_query.captureCount()); + defer std.testing.allocator.free(reference.capture_styles); + for (reference.capture_styles, 0..) |*style, id| style.* = @intFromEnum(synFor(raw_query.captureNameForId(@intCast(id)) orelse "")); + const tree = selected.parser.parseString(source, null) orelse return error.ParseFailed; + defer tree.destroy(); + var expected: [source.len]u8 = @splat(0); + var actual: [source.len]u8 = @splat(0); + runQuery(&expected, reference, tree, 0); + runQuery(&actual, selected, tree, 0); + if (!std.mem.eql(u8, &expected, &actual)) std.debug.print("query mismatch: {s}\n", .{grammar.name}); + try std.testing.expectEqualSlices(u8, &expected, &actual); + } + } +} + +test "syntax Zig keyword captures cover both bytes beyond line ten thousand" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const code = "pub fn main() void {\n if (true) return;\n}\n"; + const source = try std.testing.allocator.alloc(u8, 10_001 + code.len); + defer std.testing.allocator.free(source); + @memset(source[0..10_001], '\n'); + @memcpy(source[10_001..], code); + const styles = try highlightFileRange(std.testing.allocator, "a.zig", source, 10_001, source.len); + defer std.testing.allocator.free(styles); + for ([_][]const u8{ "fn", "if" }) |keyword| { + const at = std.mem.indexOf(u8, code, keyword).?; + try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[at]); + try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[at + 1]); + } +} + +test "syntax allocator switching clears default-runtime caches" { + if (!enabled) return; + const source = "fn main() void {}"; + const initial = try highlightFileRange(std.testing.allocator, "a.zig", source, 0, source.len); + std.testing.allocator.free(initial); + start(std.testing.allocator); + const custom = try highlightFileRange(std.testing.allocator, "a.zig", source, 0, source.len); + std.testing.allocator.free(custom); + stop(); + const restored = try highlightFileRange(std.testing.allocator, "a.zig", source, 0, source.len); + defer std.testing.allocator.free(restored); + defer stop(); + try std.testing.expectEqual(@intFromEnum(Syn.keyword), restored[0]); + try std.testing.expectEqual(@intFromEnum(Syn.keyword), restored[1]); +} diff --git a/src/temp_file.zig b/src/temp_file.zig deleted file mode 100644 index a6174efd..00000000 --- a/src/temp_file.zig +++ /dev/null @@ -1,84 +0,0 @@ -//! Native-shell ownership of `New`'s one filesystem operation. -//! -//! The core asks for a temporary file by effect and receives only its path. -//! `mkstemp` creates and opens the name atomically with mode 0600, so there is -//! no name-then-open race and repeated requests cannot collide. A file the -//! core declines is unlinked here immediately; an adopted file becomes an -//! ordinary Pardes document and is deliberately left on disk when its pane is -//! closed, just like every other document (and so a dump remains restorable). -const std = @import("std"); -const libc = std.c; - -extern "c" fn mkstemp(template: [*:0]u8) c_int; -extern "c" fn lseek(fd: c_int, offset: libc.off_t, whence: c_int) libc.off_t; - -pub const Created = struct { - fd: c_int, - path: [:0]u8, - - /// The core copied the path and now owns the document. Close our creation - /// handle; Save and watching reopen/use the pathname through their normal - /// seams. - pub fn adopt(f: Created) void { - _ = libc.close(f.fd); - } - - /// Creation succeeded but the request became stale or the core could not - /// allocate a pane. Nothing user-visible owns this name, so remove it. - pub fn discard(f: Created) void { - _ = libc.close(f.fd); - _ = libc.unlink(f.path); - } -}; - -/// Create in the platform's conventional temporary directory. TMPDIR is a -/// shell concern (environment + filesystem), intentionally outside the core. -pub fn create(buf: *[4096:0]u8) ?Created { - const env = libc.getenv("TMPDIR"); - const dir = if (env) |p| std.mem.span(p) else "/tmp"; - return createIn(buf, if (dir.len > 0) dir else "/tmp"); -} - -/// Split out for a hermetic failure test and to keep template construction -/// independently checkable. The six Xs are consumed by mkstemp itself. -pub fn createIn(buf: *[4096:0]u8, dir_arg: []const u8) ?Created { - const dir = std.mem.trimEnd(u8, dir_arg, "/"); - const path = std.fmt.bufPrintSentinel( - buf, - "{s}{s}pardes-XXXXXX", - .{ if (dir.len == 0) "/" else dir, if (dir.len == 0) "" else "/" }, - 0, - ) catch return null; - const fd = mkstemp(path.ptr); - if (fd < 0) return null; - return .{ .fd = fd, .path = path }; -} - -test "mkstemp creates distinct empty files and rejected files are removable" { - var abuf: [4096:0]u8 = undefined; - var bbuf: [4096:0]u8 = undefined; - const a = createIn(&abuf, "/tmp") orelse return error.TempCreateFailed; - const b = createIn(&bbuf, "/tmp") orelse return error.TempCreateFailed; - defer b.discard(); - - try std.testing.expect(!std.mem.eql(u8, a.path, b.path)); - try std.testing.expectEqual(@as(libc.off_t, 0), lseek(a.fd, 0, 2)); // SEEK_END - try std.testing.expectEqual(@as(libc.off_t, 0), lseek(b.fd, 0, 2)); - - a.discard(); - try std.testing.expect(libc.unlink(a.path) < 0); // already removed -} - -test "an adopted tempfile remains named for the document" { - var buf: [4096:0]u8 = undefined; - const made = createIn(&buf, "/tmp") orelse return error.TempCreateFailed; - made.adopt(); - // Adoption closes only the creation handle. The ordinary file document - // keeps this path for Save, watch, Del and dump/restore. - try std.testing.expectEqual(@as(c_int, 0), libc.unlink(made.path)); -} - -test "mkstemp failure creates no candidate file" { - var buf: [4096:0]u8 = undefined; - try std.testing.expect(createIn(&buf, "/definitely/not/a/pardes/temp/directory") == null); -} diff --git a/src/term_pane.zig b/src/term_pane.zig deleted file mode 100644 index 17e50c88..00000000 --- a/src/term_pane.zig +++ /dev/null @@ -1,3525 +0,0 @@ -//! Terminal panes: everything a Pane does BECAUSE it owns a ghostty-vt -//! emulator — constructing and replaying the emulator, reading its grid back -//! out as text (for motions and for the body), translating its cell styles -//! into ours, handling the pty replies it hands back through a callback, and -//! keeping the edit-buffer undo snapshots that only exist because a terminal's -//! "content" is a live grid rather than a []u8. -//! -//! Shared modal edit semantics and the pane-wide screen/grid coordinate -//! invariants remain on Pane in pardes.zig. Terminal-only projection, history -//! snapshots, and cell styling live here, so the core does not need to know -//! how a live terminal becomes an editable text surface. -//! -//! ...and because it does not, this is the only CORE file that ever holds a -//! ghostty-vt VALUE: pardes.zig no longer imports the emulator at all, and -//! image.zig's import exists solely to comptime-check a colour table against -//! it. (src/gui/gui.zig and the test/ snapshot harness import it too — both -//! are backends, and neither is in the esp32p4 graph.) `pardes.terminal_panes` says -//! whether a build has an emulator at all; the two Pane slots and the -//! accessors under "the emulator, as the core is allowed to see it" are the -//! whole seam, and `!enabled` answers every one of them with the empty grid. -//! See pardes.terminal_panes for why the P4 firmware has none. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const Key = pardes.Key; -const EditText = pardes.EditText; -const modal = @import("modal.zig"); -const config = @import("config.zig"); -const dump = @import("dump.zig"); -const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout - -/// `pardes.terminal_panes`, re-exported so every gate in this file reads one -/// local name. When false the import below is a DEAD comptime branch, so -/// build.zig need not resolve the ghostty dependency at all. -pub const enabled = pardes.terminal_panes; -const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {}; - -/// EDIT-BUFFER BOUNDARIES REMEMBERED PER PANE. Snapshots copy the whole edit -/// buffer, so keep this tighter than files. -/// -/// A CAPACITY, not a presence: without an emulator `pane.ovl` is not a typed -/// overlay on a live grid, it is the pane's ENTIRE content (see `create` and -/// `restore` below), so undo on it matters more here, not less. But each entry -/// is a gpa copy of that content, and 64 of them is 2.5 KiB of `Pane` plus 64 -/// heap copies — on a board with a 384 KiB heap the ring would run out of -/// memory long before it ran out of slots. `pushHistory` evicts and frees the -/// oldest once full, so the shorter ring loses only the deepest undo steps. -pub const history_max = if (enabled) 64 else 8; - -/// The emulator and its VT parser as PANE FIELDS — the `PdfSlot` pattern from -/// pardes.zig, zero-sized where there are no terminal panes. Declared here -/// rather than there so the emulator's type never has to be named by the core. -pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; -pub const StreamSlot = if (enabled) ghostty_vt.TerminalStream else void; - -const GColor = ghostty_vt.color; - -/// The inputs which completely determine a filtered terminal palette. Theme -/// names and indices are intentionally absent: ThemeFile may replace a theme -/// in place under the same name, while equal colour values need no rebuild. -const FilterPaletteKey = struct { - bg: GColor.RGB, - fg: GColor.RGB, - base: [16]GColor.RGB, -}; - -/// Cached 256-colour projection of the current Pardes theme. Ghostty owns the -/// interpolation: its CIELAB cube and greyscale ramp give every xterm key a -/// theme-derived RGB value while retaining the conventional dark-to-light -/// index orientation on light themes (`harmonious = false`). -/// -/// Zero-sized without an emulator — there are no ANSI cells to reproject, so -/// `Pardes.tty_filter_palette` costs the core nothing but keeps its `.{}`. -pub const FilterPalette = if (enabled) LivePalette else struct {}; - -const LivePalette = struct { - key: ?FilterPaletteKey = null, - colors: GColor.Palette = GColor.default, - - fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette { - const bg = asGhostRgb(theme.bg orelse theme.tag_bg); - const fg = asGhostRgb(theme.fg orelse theme.tag_fg); - var base: [16]GColor.RGB = undefined; - if (theme.palette) |palette| { - for (&base, palette) |*dst, src| dst.* = asGhostRgb(src); - } else { - // A theme without an ANSI table still supplies every key. The - // first eight keep the usual semantic families; their bright - // partners use the same accents plus the theme's lighter chrome. - const synthesized = [16][3]u8{ - theme.bg orelse theme.tag_bg, - theme.kw, - theme.str, - theme.num, - theme.box, - theme.sel_bg, - theme.comment, - theme.fg orelse theme.tag_fg, - theme.lineno, - theme.kw, - theme.str, - theme.num, - theme.scroll_thumb, - theme.sel_fg, - theme.tag_fg, - theme.fg orelse theme.tag_fg, - }; - for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src); - } - - const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base }; - if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors; - - var seed = GColor.default; - for (base, 0..) |rgb, i| seed[i] = rgb; - self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false); - self.key = key; - return &self.colors; - } -}; - -fn asGhostRgb(rgb: [3]u8) GColor.RGB { - return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] }; -} - -fn asPardesColor(rgb: GColor.RGB) pardes.Color { - return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; -} - -/// The owned text standing in for `rows` live terminal rows, beginning at -/// absolute surface row `row`. The emulator grid remains untouched underneath. -pub const EditBuffer = struct { - row: i32 = 0, - rows: i32 = 1, - text: []u8 = &.{}, -}; - -/// One whole-state terminal edit boundary. Null means the pane has not yet -/// materialized an edit buffer; non-null snapshots own their text. -pub const Snapshot = struct { - ovl: ?EditBuffer, - cur_row: i32, - cur_col: i32, - vsel: pardes.CharSel, -}; - -/// Command bytes aimed at a shell whose prompt does not exist yet. The host -/// resolves the actual executable after the core has already queued `.spawn`, -/// so `spawn` is deliberately an UNKNOWN phase: shells with prompt integration -/// advance to `input` and wait for OSC 133 B; unadorned/unsupported shells are -/// opened immediately by `shellSpawned` and let the pty buffer input until the -/// child reads it. -/// -/// This is pane state, not a Pardes-wide job table. A reused pane slot can -/// therefore never inherit a command intended for the shell it replaced. -pub const PendingCommand = struct { - bytes: []u8 = &.{}, - wait: enum { none, spawn, input } = .none, -}; - -/// A terminal constructed by `newShell` cannot safely receive a command until -/// the native host has at least completed forkpty. Dump-replay terminals do -/// not call this: they are dead grids, not half-spawned children. -pub fn armShellSpawn(pane: *Pane) void { - // With no emulator there is no fork to wait on and no OSC 133 that could - // ever arrive, so the gate stays open: `queuePendingCommand` declines and - // the command leaves as an ordinary write, rather than waiting forever. - if (comptime !enabled) return; - std.debug.assert(pane.pending_command.bytes.len == 0); - pane.pending_command.wait = .spawn; -} - -/// Own `command` until this pane's new child can accept it. False means the -/// gate is already open and the caller should emit in the ordinary way. -/// Multiple gestures before the prompt appears retain their byte order in one -/// flat allocation; each command gets exactly the CR execute normally emits. -pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { - if (pane.pending_command.wait == .none) return false; - const old_len = pane.pending_command.bytes.len; - const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); - const bytes = if (old_len == 0) - try pane.gpa.alloc(u8, new_len) - else - try pane.gpa.realloc(pane.pending_command.bytes, new_len); - @memcpy(bytes[old_len..][0..command.len], command); - bytes[new_len - 1] = '\r'; - pane.pending_command.bytes = bytes; - // An explicit command replaces the automatic greeting. Otherwise both - // would be released by the same first prompt and `ls` would follow what - // the user actually asked to run. - pane.greet = false; - return true; -} - -/// The host successfully forked `pane`. `prompt_marks` describes the argv it -/// ACTUALLY used, not the configured shell name: a staged-rc failure or an -/// unsupported family is an honest unmarked shell and must not wait forever -/// for an OSC sequence it cannot produce. -pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { - const pane = p.panes[id] orelse return; - if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; - if (prompt_marks) { - pane.pending_command.wait = .input; - releasePendingCommand(p, id, pane, false); - } else { - // There is no semantic event on which an automatic greeting can be - // safely based. Explicit commands still release below (the pty owns - // their buffering); silently omit the cosmetic `ls` rather than race - // an unknown shell's startup and possibly type into its rc program. - pane.greet = false; - releasePendingCommand(p, id, pane, true); - } -} - -/// Called from the ordinary sync after terminal output has been parsed. The -/// semantic cursor is ghostty-vt's parsed OSC state, so this and the greeting -/// share the exact same definition of "readline owns input". -pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { - if (pane.pending_command.wait == .input) - releasePendingCommand(p, id, pane, promptInputReady(pane)); -} - -fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { - if (!ready) return; - const bytes = pane.pending_command.bytes; - pane.pending_command = .{}; - if (bytes.len > 0) { - p.emitWrite(id, bytes); - pane.gpa.free(bytes); - } -} - -pub fn deinitPendingCommand(pane: *Pane) void { - if (pane.pending_command.bytes.len > 0) - pane.gpa.free(pane.pending_command.bytes); - pane.pending_command = .{}; -} - -/// The emulator stores an Io value for optional kitty-image work. The browser -/// has no host IO and must not instantiate std.Io.Threaded's POSIX backend -/// merely to construct a replay-only terminal. -pub fn terminalIo() std.Io { - return if (comptime !pardes.hosted) - std.Io.failing - else - std.Io.Threaded.global_single_threaded.io(); -} - -// ---- the emulator, as the core is allowed to see it ---- -// -// Every question pardes.zig used to answer by walking `pane.vt.screens.active` -// for itself, named. That is the boundary this file's header always claimed, -// and naming them is what lets a build with no emulator answer ALL of them at -// comptime with the empty grid, instead of scattering one platform test -// through the core's scroll, cursor, mouse, resize and render paths. - -/// The three numbers ghostty's scrollbar reports; all zero without an emulator. -pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 }; - -pub fn scrollbar(pane: *const Pane) Scrollbar { - if (comptime !enabled) return .{}; - const sb = pane.vt.screens.active.pages.scrollbar(); - return .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; -} - -/// The emulator's viewport offset, in SHELL rows: the top of what it shows. -pub fn gridOffset(pane: *const Pane) i32 { - return @intCast(scrollbar(pane).offset); -} - -/// Where the emulator itself puts the cursor, in viewport cells — the origin -/// without one, which is where an empty pane's cursor belongs anyway. -pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 }; - -pub fn gridCursor(pane: *const Pane) GridCursor { - if (comptime !enabled) return .{}; - const cur = pane.vt.screens.active.cursor; - return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; -} - -/// Move the emulator's viewport by `delta` shell rows (negative scrolls back). -pub fn scrollGrid(pane: *Pane, delta: i32) void { - if (comptime !enabled) return; - pane.vt.screens.active.scroll(.{ .delta_row = delta }); -} - -/// Snap the viewport back onto live output. -pub fn followOutput(pane: *Pane) void { - if (comptime !enabled) return; - pane.vt.screens.active.scroll(.active); -} - -/// Reflow the grid. A failed reflow keeps the grid it had rather than dropping -/// a scrollback; the next resize retries with the same numbers. -pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void { - if (comptime !enabled) return; - pane.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {}; -} - -/// DECSET 2004: the program wants its pastes bracketed. -pub fn bracketedPaste(pane: *const Pane) bool { - if (comptime !enabled) return false; - return pane.vt.modes.get(.bracketed_paste); -} - -/// The program tracks the mouse itself, so a click in its body is its event. -pub fn reportsMouse(pane: *const Pane) bool { - if (comptime !enabled) return false; - const m = &pane.vt.modes; - return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); -} - -/// ...and wants them in SGR (1006) rather than the legacy X10 bytes. -pub fn mouseFormatSgr(pane: *const Pane) bool { - if (comptime !enabled) return false; - return pane.vt.modes.get(.mouse_format_sgr); -} - -/// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out. -pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 { - if (comptime !enabled) return &.{}; - return pane.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} }); -} - -/// Release the emulator's heap. The Pane allocation itself is the core's. -pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void { - if (comptime !enabled) return; - pane.stream.deinit(); - pane.vt.deinit(gpa); -} - -/// Allocate the live emulator half of a terminal pane. Slot ownership, serial -/// assignment, and spawn effects remain core lifecycle invariants. -pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { - const pane = try gpa.create(Pane); - errdefer gpa.destroy(pane); - if (comptime !enabled) { - // No emulator: the pane is a plain text surface whose whole content is - // its edit buffer. `tty_filter` stays off — there are no ANSI cells to - // reproject and `recolorAnsi` is compiled out entirely. - pane.* = .{ .vt = {}, .stream = {}, .gpa = gpa, .cols = cols, .rows = rows }; - return pane; - } - pane.* = .{ - .vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ - .cols = cols, - .rows = rows, - .max_scrollback = 16 * 1024 * 1024, - }), - .stream = undefined, - .gpa = gpa, - .cols = cols, - .rows = rows, - // Real terminals start theme-keyed. Document panes use the separate - // 1x1 stub constructor and retain Pane's inert false default. - .tty_filter = true, - }; - pane.stream = pane.vt.vtStream(); - // Answer terminal queries (DSR/DA/kitty) back into the pty, else - // crossterm apps (nushell, helix, fzf) block on the reply forever. - pane.stream.handler.effects.write_pty = ptyReport; - pane.stream.handler.effects.device_attributes = ptyDeviceAttrs; - return pane; -} - -/// A doc pane (file/image/PDF): no pty and no spawn, and a stub 1x1 emulator -/// only because the shared pane machinery touches its allocator-owned bits. -/// Slot registration stays with the core, as for `create`. -pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { - const pane = try gpa.create(Pane); - errdefer gpa.destroy(pane); - pane.* = .{ - .vt = if (comptime enabled) - try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ .cols = 1, .rows = 1 }) - else {}, - .stream = undefined, - .gpa = gpa, - .cols = cols, - .rows = rows, - }; - if (comptime enabled) pane.stream = pane.vt.vtStream(); - return pane; -} - -/// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy -/// stay with the core; raw VT replay and viewport restoration belong here. -pub fn restore(p: *Pardes, src: dump.Pane) !*Pane { - const terminal = src.terminal.?; - if (comptime !enabled) { - // Nothing to replay the recorded VT bytes INTO. The dump also carries - // the rendered text of that grid, so it becomes the pane's edit buffer - // — the one content a build with no emulator can show at all. - const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - errdefer p.gpa.destroy(pane); - if (terminal.stream.len > 0) - pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; - return pane; - } - const bytes = if (terminal.stream_b64.len > 0) - try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) - else - &.{}; - const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - if (bytes.len > 0) { - ingest(pane, bytes); - followOutput(pane); - if (src.scroll > 0) - scrollGrid(pane, -@as(i32, @intCast(src.scroll))); - } - return pane; -} - -/// Feed the emulator and retain the bounded suffix a dump can replay. Live -/// output and restoration share this byte path, then apply different views. -fn ingest(pane: *Pane, bytes: []const u8) void { - if (bytes.len >= pane.tty_stream.len) { - const kept = bytes[bytes.len - pane.tty_stream.len ..]; - @memcpy(pane.tty_stream[0..], kept); - pane.tty_stream_head = 0; - pane.tty_stream_len = pane.tty_stream.len; - } else { - const overflow = bytes.len -| (pane.tty_stream.len - pane.tty_stream_len); - pane.tty_stream_head = (pane.tty_stream_head + overflow) % pane.tty_stream.len; - pane.tty_stream_len -= overflow; - const tail = (pane.tty_stream_head + pane.tty_stream_len) % pane.tty_stream.len; - const first = @min(bytes.len, pane.tty_stream.len - tail); - @memcpy(pane.tty_stream[tail..][0..first], bytes[0..first]); - @memcpy(pane.tty_stream[0 .. bytes.len - first], bytes[first..]); - pane.tty_stream_len += bytes.len; - } - pane.stream.nextSlice(bytes); -} - -/// Return the replay ring in chronological order. Wrapped records are copied -/// into `allocator`; contiguous records remain a borrowed slice of the pane. -fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { - if (pane.tty_stream_len == 0) return &.{}; - if (pane.tty_stream_head + pane.tty_stream_len <= pane.tty_stream.len) - return pane.tty_stream[pane.tty_stream_head..][0..pane.tty_stream_len]; - const out = try allocator.alloc(u8, pane.tty_stream_len); - const first = pane.tty_stream.len - pane.tty_stream_head; - @memcpy(out[0..first], pane.tty_stream[pane.tty_stream_head..]); - @memcpy(out[first..], pane.tty_stream[0 .. pane.tty_stream_len - first]); - return out; -} - -/// Record and parse one live pty read, invalidate its motion surface, and -/// follow it only when the body (possibly parked under a tag edit) is raw. -pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { - // There are no pty reads at all without an emulator to parse them into. - if (comptime !enabled) return; - ingest(pane, bytes); - p.shell_rows.markStale(pane); - const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; - if (body_mode == .tty) followOutput(pane); -} - -/// True only after OSC 133 B ended the prompt and handed the cursor to shell -/// input. `cursorIsAtPrompt` deliberately also accepts OSC A's `.prompt` -/// phase; that is right for navigation but too early to inject a greeting — -/// readline may not own echo yet and would leave `ls` on an unmarked row. -pub fn promptInputReady(pane: *const Pane) bool { - if (comptime !enabled) return false; - return pane.vt.screens.active_key != .alternate and - pane.vt.screens.active.cursor.semantic_content == .input; -} - -test "fresh-shell greeting waits for OSC 133 B input phase" { - if (pardes.platform == .web) return; - const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); - defer p.deinit(); - const pane = p.panes[0].?; - while (p.nextEffect()) |_| {} // initial spawn - - p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.GreetedBeforeOutput, - else => {}, - }; - p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } }); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.GreetedBeforePrompt, - else => {}, - }; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } }); - try std.testing.expect(!promptInputReady(pane)); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.GreetedDuringPrompt, - else => {}, - }; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); - try std.testing.expect(promptInputReady(pane)); - var greeted = false; - while (p.nextEffect()) |effect| switch (effect) { - .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"), - else => {}, - }; - try std.testing.expect(greeted); - try std.testing.expect(!pane.greet); -} - -test "fresh-shell commands preserve order and wait for OSC 133 B" { - if (pardes.platform == .web) return; - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet - - try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first")); - try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second")); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.CommandEscapedBeforeFork, - else => {}, - }; - - p.acknowledgeShell(0, "/bin/bash", true); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.CommandEscapedBeforePrompt, - else => {}, - }; - p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } }); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.CommandEscapedDuringPrompt, - else => {}, - }; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); - var sent: [64]u8 = undefined; - var sent_len: usize = 0; - while (p.nextEffect()) |effect| switch (effect) { - .write => |write| { - const bytes = write.bytes.slice(); - @memcpy(sent[sent_len..][0..bytes.len], bytes); - sent_len += bytes.len; - }, - else => {}, - }; - try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]); - try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait); -} - -test "unmarked fresh shells omit the automatic greeting" { - if (pardes.platform == .web) return; - const p = try Pardes.init(std.testing.allocator, .{}); - defer p.deinit(); - const pane = p.panes[0].?; - while (p.nextEffect()) |_| {} - try std.testing.expect(pane.greet); - - p.acknowledgeShell(0, "/bin/sh", false); - try std.testing.expect(!pane.greet); - try std.testing.expectEqual(.none, pane.pending_command.wait); - p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } }); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.UnmarkedGreetingEscaped, - else => {}, - }; -} - -/// Encode one key for the program that owns a raw terminal and queue its pty -/// write. Global chords and mode routing have already been handled by core. -pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { - var control: [1]u8 = undefined; - const bytes: ?[]const u8 = blk: { - if (key.ctrl) { - if (key.cp >= 'a' and key.cp <= 'z') { - control[0] = @intCast(key.cp - 0x60); - break :blk control[0..1]; - } - // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. - if (key.cp >= '@' and key.cp <= '_') { - control[0] = @intCast(key.cp - 0x40); - break :blk control[0..1]; - } - } - if (key.text.len > 0) break :blk key.text; - break :blk switch (key.cp) { - Key.enter => "\r", - Key.backspace => "\x7f", - Key.tab => "\t", - Key.escape => "\x1b", - Key.up => "\x1b[A", - Key.down => "\x1b[B", - Key.right => "\x1b[C", - Key.left => "\x1b[D", - Key.delete => "\x1b[3~", - else => null, - }; - }; - if (bytes) |encoded| - p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); -} - -/// Enter raw tty, handing a pinned modal cursor back to the shell prompt when -/// OSC 133 marks one. The visible prompt row is left-hugged outside tty mode, -/// so translate its column through the hidden prompt before asking ghostty for -/// the arrow-key movement the child understands. -pub fn enterTty(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - // Only the PROMPT HANDOFF needs the emulator; the mode switch below is - // plain pane state, so a build without one still has a raw mode — it just - // has no prompt to translate a pinned cursor back onto. - if (comptime enabled) if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) handoff: { - const screen = pane.vt.screens.active; - const goff: i32 = @intCast(screen.pages.scrollbar().offset); - const vp_row = pane.gridRow(pane.cur_row) - goff; - if (vp_row < 0) break :handoff; - - var grid_col: i32 = @max(0, pane.cur_col); - if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| { - if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) { - .cut => |cols| grid_col += @intCast(cols), - .keep, .blank => {}, - }; - } - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) }, - }) orelse break :handoff; - const cursor_pin = screen.cursor.page_pin.*; - var prompts = cursor_pin.promptIterator(.left_up, null); - const prompt_pin = prompts.next() orelse break :handoff; - if (click_pin.before(prompt_pin)) break :handoff; - const moves = screen.promptClickMove(click_pin); - for (0..moves.left) |_| p.emitWrite(id, "\x1b[D"); - for (0..moves.right) |_| p.emitWrite(id, "\x1b[C"); - }; - - pane.mode = .tty; - pane.msel.active = false; - pane.vsel.active = false; - pane.nsel = 0; - // A pinned row scrolls away. Raw mode must follow the program's live - // cursor, and Last must not restore a stale modal spot on the way back. - pane.cur_pinned = false; - pane.select = false; - pane.append_at = null; - pane.sticky_col = -1; - pane.pending = 0; -} - -test "raw terminal keys encode text controls and special sequences" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - - const Case = struct { key: Key, expected: ?[]const u8 }; - const cases = [_]Case{ - .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" }, - .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" }, - .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" }, - .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" }, - .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" }, - .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" }, - .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" }, - .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" }, - .{ .key = .{ .cp = Key.home }, .expected = null }, - }; - for (cases) |case| { - forwardKey(p, 0, case.key); - const expected = case.expected orelse { - try std.testing.expect(p.nextEffect() == null); - continue; - }; - const effect = p.nextEffect() orelse return error.MissingWriteEffect; - switch (effect) { - .write => |write| { - try std.testing.expectEqual(@as(u8, 0), write.pane); - try std.testing.expectEqualStrings(expected, write.bytes.slice()); - }, - else => return error.UnexpectedEffect, - } - try std.testing.expect(p.nextEffect() == null); - } -} - -/// The memo behind `shellRows`. ONE entry for the editor, because the motion -/// surface is built for the pane the cursor is in and a second pane asking -/// would only double a multi-megabyte buffer for a slot it is about to lose -/// again. gpa-owned rather than scratch-arena: the whole point is to outlive -/// the update that built it. -/// -/// LIFETIME, the part that would rot silently: `rows` is handed out to -/// callers, so the buffers are freed in exactly two places — `sweep`, at the -/// TOP of an update before any handler can be holding them, and `reset` when -/// the editor goes away. Everything that notices the entry has gone bad -/// (output arrived, the grid reflowed, the pane died, another pane wants the -/// slot) only marks it `stale`; nothing frees mid-update. That is the same -/// guarantee the scratch arena gave, spelled out. -pub const RowsCache = struct { - /// whose grid this describes; null = the slot is free - pane: ?*const Pane = null, - /// the rows joined by '\n' — `flatSurface` hands this back verbatim - /// instead of rebuilding the join on every keystroke - text: []const u8 = &.{}, - /// slices INTO `text`, absolute grid rows from 0 - rows: [][]const u8 = &.{}, - /// `text` is a prefix of this: blanking a prompt row shortens the join, - /// and the slack is not worth a second allocation to reclaim - text_alloc: []u8 = &.{}, - stale: bool = false, - - pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void { - if (c.text_alloc.len > 0) gpa.free(c.text_alloc); - if (c.rows.len > 0) gpa.free(c.rows); - c.* = .{}; - } - - /// Free a stale entry. Called at the top of `update`, and nowhere else. - pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void { - if (c.stale) c.reset(gpa); - } - - /// `pane`'s grid moved: the entry no longer describes it. - pub fn markStale(c: *RowsCache, pane: *const Pane) void { - if (c.pane == pane) c.stale = true; - } - - /// `pane` is being destroyed. Drop the pointer now — a freed pane's - /// address can come back from the allocator as a different pane, and an - /// entry still naming it would answer for the wrong grid — but leave the - /// buffers to the next sweep, as ever. - pub fn dropPane(c: *RowsCache, pane: *const Pane) void { - if (c.pane != pane) return; - c.pane = null; - c.stale = true; - } -}; - -const Rows = struct { - text_alloc: []u8, - text: []const u8, - rows: [][]const u8, -}; - -/// The motion surface of a pane with no emulator behind it: exactly the one -/// blank row `buildRows` retains from a real grid, so surface row 0 exists and -/// every motion, edit and undo path measures the same thing it always did. -const empty_grid = [1][]const u8{""}; - -/// What LEAVING raw tty mode does to one prompt row, decided from its cells -/// alone. See config.tty_blank for why any of this happens. -const PromptCut = union(enum) { - /// show the row exactly as ghostty dumped it - keep, - /// show nothing at all - blank, - /// drop this many leading COLUMNS — the prompt — and keep the rest, which - /// is what was typed at it - cut: usize, -}; - -/// The prompt and the command typed at it share a grid row, and OSC 133 marks -/// them apart CELL by cell (`Cell.semantic_content` is output / input / -/// prompt). The row flag every caller tests first is only ghostty's "some cell -/// in here is a prompt cell" index; taking the row on that flag alone is what -/// used to throw the command away with the prompt. -fn promptCut(pin: ghostty_vt.Pin) PromptCut { - if (config.tty_blank == .prompt_and_input) return .blank; - const cells = pin.cells(.all); - var cols: usize = 0; - while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1; - // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or - // a repaint that has moved on. Nothing here is the prompt, so hide nothing. - if (cols == 0) return .keep; - // ...and all prompt, nothing typed yet: the row is chrome end to end. - if (cols >= cells.len) return .blank; - return .{ .cut = cols }; -} - -/// That decision applied to `raw`, the line ghostty dumped for `pin`'s row. -/// Always a slice OF `raw` — dropping the prompt is a left-hug, so the command -/// starts at column 0 with no run of blanks in front of it where the prompt -/// used to be, and there is nothing to allocate or copy anywhere. -/// -/// Walking the dump rather than rebuilding the row out of cells keeps ghostty -/// the single authority on how a cell spells itself — wide glyphs, combining -/// marks and all. One non-spacer cell is one dumped grapheme, and that is what -/// makes the cell walk and the byte walk stay in step. -fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { - const cols = switch (promptCut(pin)) { - .keep => return raw, - .blank => return "", - .cut => |n| n, - }; - const cells = pin.cells(.all); - var at: usize = 0; - var col: usize = 0; - while (col < cols and at < raw.len) { - const cell = &cells[col]; - // Step the dump by exactly what THIS CELL contributed to it. The - // tempting walk — one `modal.nextGrapheme` per cell — assumes the two - // sides agree on where a cluster ends, and they do not: ghostty keeps a - // ZWJ family emoji in three cells and spells each one separately, while - // pardes' iterator joins the whole sequence into one grapheme. That walk - // then consumed three graphemes for one cell's worth of bytes and ate - // the first characters of what was typed at the prompt. - at = @min(raw.len, at + dumpedBytes(pin, cell)); - // the tail cell of a wide glyph spells nothing of its own - col += if (cell.wide == .wide) @as(usize, 2) else 1; - } - return std.mem.trimEnd(u8, raw[at..], " \t"); -} - -/// How many bytes `cell` contributed to `pin`'s dumped row. -/// -/// `ScreenFormatter` writes a cell's codepoint followed by the grapheme -/// codepoints stored with it, and writes NOTHING for either spacer, so this is -/// the dump's own arithmetic rather than a guess about clustering. -fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize { - switch (cell.wide) { - .spacer_head, .spacer_tail => return 0, - .narrow, .wide => {}, - } - var n: usize = switch (cell.content_tag) { - .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength( - cell.codepoint(), - ) catch 1, - // A cell carrying only a colour still spells one blank in the dump. - else => 1, - }; - if (cell.content_tag == .codepoint_grapheme) { - if (pin.grapheme(cell)) |extra| for (extra) |cp| { - n += std.unicode.utf8CodepointSequenceLength(cp) catch 1; - }; - } - return n; -} - -/// A terminal's shell rows as the surface sees them: the WHOLE -/// history+active grid, prompt rows blanked (OSC 133), absolute grid rows -/// from 0. The raw material the motion surface is composed from — the -/// edit buffer is NOT applied here, so it is also what seeding the buffer -/// reads. -/// ghostty's dump trims the grid's trailing blank rows; ONE of them is -/// kept back, the row the cursor sits on below the last line of output. -/// That row is a file's final newline: without it the surface would have -/// one line fewer than the same text in a document, and every motion and -/// linewise edit at the bottom would diverge. -/// -/// Building it is O(scrollback) — a dump of the whole history — and a -/// keystroke asks for it once or twice, so the result is memoized against the -/// pane until its grid changes. A pane sitting on 16 MiB of agent transcript -/// paid that dump per press of `j` before the cache; now it pays it once per -/// chunk of output. -pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { - // With no emulator there is no history to dump, and no cache to keep it - // in: one empty row, which is the same row `buildRows` keeps back from - // ghostty's trimmed dump — a file's final newline. Everything above the - // grid (the edit overlay, its undo stacks, every motion) works unchanged - // over it, so a pane on the board is an ordinary scratch buffer. - if (comptime !enabled) return &empty_grid; - const c = &p.shell_rows; - if (!c.stale and c.pane == pane) return c.rows; - if (c.pane != null) { - // Another pane holds the slot. Take it for the NEXT update (the sweep - // frees what is there) and answer this one from scratch: whoever owns - // the live entry may still be holding the rows it handed out. - c.stale = true; - return (try buildRows(p.scratch.allocator(), p, pane)).rows; - } - const built = try buildRows(p.gpa, p, pane); - c.* = .{ - .pane = pane, - .text = built.text, - .rows = built.rows, - .text_alloc = built.text_alloc, - }; - return c.rows; -} - -/// The full modal motion surface: shell history with the live edit overlay -/// spliced into the rows it covers. -pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { - const rows = try shellRows(p, pane); - if (pane.ovl == null) return rows; - var last = rows.len; - if (pane.ovl) |overlay| - last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows)))); - var count = last; - if (pane.ovl) |overlay| { - if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last) - count = count - @min( - @as(usize, @intCast(overlay.rows)), - last - @as(usize, @intCast(overlay.row)), - ) + @max(1, modal.lineCount(overlay.text)); - } - const lines = try p.scratch.allocator().alloc([]const u8, count); - var n: usize = 0; - var grid_row: usize = 0; - while (grid_row < last) : (grid_row += 1) { - if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) { - var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n'); - while (overlay_lines.next()) |line| : (n += 1) lines[n] = line; - grid_row += @intCast(overlay.rows - 1); - continue; - }; - lines[n] = if (grid_row < rows.len) rows[grid_row] else ""; - n += 1; - } - return lines[0..n]; -} - -/// Flatten `cursorLines` without rebuilding the common cached/no-overlay -/// case. Scratch-owned when a join is required. -pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 { - const cache = &p.shell_rows; - if (!cache.stale and cache.pane == pane and - lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text; - var total: usize = if (lines.len > 0) lines.len - 1 else 0; - for (lines) |line| total += line.len; - const text = try p.scratch.allocator().alloc(u8, total); - var at: usize = 0; - for (lines, 0..) |line, i| { - if (i > 0) { - text[at] = '\n'; - at += 1; - } - @memcpy(text[at..][0..line.len], line); - at += line.len; - } - return text; -} - -/// Materialize or extend the terminal edit overlay with one exact allocation. -/// Row slices are scratch-owned/borrowed; only the joined text is installed. -pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { - const want_lo = @max(0, @min(lo, hi)); - const want_hi = @max(want_lo, @max(lo, hi)); - const fresh = pane.ovl == null; - const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; - const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); - const up = old.row - want_lo; - const down = want_hi - (old.row + lines - 1); - const extending = fresh or up > 0 or down > 0; - var row0 = old.row; - var covered = old.rows; - var text = old.text; - var owned = false; - if (extending) { - const rows = shellRows(p, pane) catch return null; - row0 = old.row - @max(0, up); - covered = old.rows + @max(0, up) + @max(0, down); - const up_len: usize = @intCast(@max(0, up)); - const down_len: usize = @intCast(@max(0, down)); - const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null; - for (parts[0..up_len], 0..) |*part, i| { - const src = @as(usize, @intCast(row0)) + i; - part.* = if (src < rows.len) rows[src] else ""; - } - const middle: usize = @intCast(old.row); - parts[up_len] = if (fresh) - (if (middle < rows.len) rows[middle] else "") - else - old.text; - for (parts[up_len + 1 ..], 0..) |*part, i| { - const src = @as(usize, @intCast(old.row + old.rows)) + i; - part.* = if (src < rows.len) rows[src] else ""; - } - text = std.mem.join(p.gpa, "\n", parts) catch return null; - owned = true; - } - - const row: usize = @intCast(@max(0, want_lo - row0)); - const line_len: i32 = @intCast(modal.lineSlice(text, row).len); - if (col > line_len) { - const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch { - if (owned) p.gpa.free(text); - return null; - }; - @memset(spaces, ' '); - const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch { - if (owned) p.gpa.free(text); - return null; - }; - if (owned) p.gpa.free(text); - text = padded; - owned = true; - } - if (owned) { - if (pane.ovl) |overlay| p.gpa.free(overlay.text); - pane.ovl = .{ .row = row0, .rows = covered, .text = text }; - } - return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row }; -} - -/// Consume a rewritten overlay, freeing the terminal edit text it replaces. -pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); - p.gpa.free(overlay.text); - overlay.text = new; -} - -/// Serialize terminal-only state; the core supplies shared pane metadata. -pub fn dumpPane( - pane: *Pane, - arena: std.mem.Allocator, - tag: []const u8, - body: []const u8, - scroll: usize, -) !dump.Pane { - if (comptime !enabled) { - // A pane with no emulator has no grid to serialize and no VT bytes to - // record — its edit buffer IS its content, so that is what the dump - // carries, and `restore` reads it straight back into a fresh buffer. - const text = if (pane.ovl) |overlay| overlay.text else ""; - return .{ - .kind = .terminal, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .terminal = .{ - .cwd = try arena.dupe(u8, pane.cwdSlice()), - .stream = try arena.dupe(u8, text), - .stream_b64 = &.{}, - .cursor = .{ .col = 0, .row = 0 }, - }, - }; - } - const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); - const extra = if (pane.ovl) |overlay| overlay.text.len else 0; - const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra)); - var len: usize = 0; - var lines = std.mem.splitAny(u8, full, "\n"); - var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var row: i32 = 0; - var skip: i32 = 0; - while (lines.next()) |raw| : (row += 1) { - // Hidden overlay rows still consume prompt pins to keep them aligned. - const prompt = if (pane.mode != .tty) prompts.next() else null; - if (skip > 0) { - skip -= 1; - continue; - } - if (row > 0) { - stream[len] = '\n'; - len += 1; - } - if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { - @memcpy(stream[len..][0..overlay.text.len], overlay.text); - len += overlay.text.len; - skip = overlay.rows - 1; - continue; - }; - const shown = if (prompt) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw - else - raw; - @memcpy(stream[len..][0..shown.len], shown); - len += shown.len; - } - return .{ - .kind = .terminal, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .terminal = .{ - .cwd = try arena.dupe(u8, pane.cwdSlice()), - .stream = stream[0..len], - .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)), - .cursor = .{ - .col = pane.vt.screens.active.cursor.x, - .row = pane.vt.screens.active.cursor.y, - }, - }, - }; -} - -fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { - const full = try pane.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); - var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - // split yields one more item than delimiters; the extra final slot is the - // cursor row retained below. - const n_rows = std.mem.count(u8, full, "\n") + 2; - const rows = try alloc.alloc([]const u8, n_rows); - errdefer alloc.free(rows); - // Blanking a prompt row only ever SHORTENS it and the retained cursor row - // adds one separator, so the dump's length plus one bounds the join. - const text = try alloc.alloc(u8, full.len + 1); - errdefer alloc.free(text); - var at: usize = 0; - var n: usize = 0; - var it = std.mem.splitScalar(u8, full, '\n'); - while (it.next()) |raw| { - const shown = if (pit.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw - else - raw; - if (n > 0) { - text[at] = '\n'; - at += 1; - } - @memcpy(text[at..][0..shown.len], shown); - rows[n] = text[at..][0..shown.len]; - at += shown.len; - n += 1; - } - text[at] = '\n'; - at += 1; - rows[n] = text[at..][0..0]; - n += 1; - std.debug.assert(n == n_rows); - return .{ .text_alloc = text, .text = text[0..at], .rows = rows }; -} - -/// The body a terminal renders: the viewport's shell rows (prompt rows blanked -/// outside tty mode) with the edit buffer's lines standing in for the rows it -/// covers, so what you see is what the motions move over. -pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { - // The VIEWPORT half is the emulator's; the row walk below is the edit - // buffer's and is shared. With no emulator the viewport is simply empty, - // and `fillBody` renders the overlay against blank rows. - const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: { - const screen = pane.vt.screens.active; - // The dump has to start at COLUMN ZERO of the viewport's first row. - // `Terminal.plainString` cannot: it goes through `getTopLeft(.viewport)`, - // which hands back the viewport pin verbatim, x and all, while - // `PageList.pin` — how the colour pass finds that same row — forces x to - // 0. Reflow can leave a tracked viewport pin in the MIDDLE of a row - // (narrow the pane until a line wraps, scroll back onto the - // continuation, widen it again): from then on this pass dumped row 0 - // from that column while the colour pass paired the fragment with the - // row's first cells, so the row lost its left half and wore the wrong - // colours — every frame, until the pane snapped back to live output. - // Ghostty's own renderer walks rows and ignores that x, so column zero - // is also what the terminal itself draws. - var tl = screen.pages.getTopLeft(.viewport); - tl.x = 0; - const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint; - var rows_out: std.Io.Writer.Allocating = .init(arena); - try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false }); - const raw = try rows_out.toOwnedSlice(); - var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); - const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1); - var lines = std.mem.splitScalar(u8, raw, '\n'); - var n: usize = 0; - while (lines.next()) |ln| { - vp[n] = if (pane.mode != .tty) - if (prompts.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln - else - ln - else - ln; - n += 1; - } - std.debug.assert(n == vp.len); - break :vp vp; - }; - - const len = fillBody(null, pane, vp); - const out = try arena.alloc(u8, len); - const filled = fillBody(out, pane, vp); - std.debug.assert(filled == out.len); - return out; -} - -/// Where ONE body row's content comes from. The text pass copies bytes for it -/// and the colour pass projects the emulator's styles onto it, so handing both -/// the same answer is what keeps a colour on the row its text landed on. -pub const BodyRow = union(enum) { - /// A shell row, as a VIEWPORT index. Out-of-range values are yielded rather - /// than filtered: each consumer knows its own bound (the text pass has the - /// dumped rows, the colour pass has the live viewport) and a row nobody can - /// source is a blank row, not a skipped one. - grid: i32, - /// One line of the edit buffer, and WHICH line it is. A line the user never - /// changed still stands over the shell row it was seeded from, so the index - /// is what lets the colour pass find that row again (see `EditAnchors`). - edit: struct { line: []const u8, idx: usize }, -}; - -/// THE body row walk, shared. Both passes stepping the same iterator is what -/// makes them agree by CONSTRUCTION rather than by two copies of the same -/// arithmetic agreeing: `Pane.gridRow` and this walk disagree whenever -/// `modal.lineCount` and `splitScalar` disagree about how many rows a buffer -/// occupies (they do, for empty text: 0 against 1), and re-deriving a row's -/// anchor from `gridRow` per row instead of stepping it here put colours one -/// row off below an emptied edit buffer. -const BodyWalk = struct { - pane: *Pane, - goff: i32, - g: i32, - /// the buffer can start above the viewport: drop the lines scrolled past - skip: usize, - n: usize = 0, - lines: ?std.mem.SplitIterator(u8, .scalar) = null, - covered: i32 = 0, - line_idx: usize = 0, - - fn init(pane: *Pane) BodyWalk { - const off = pane.scroll(); - const goff = gridOffset(pane); - return .{ - .pane = pane, - .goff = goff, - // tty mode does not apply the edit buffer, so it must not be moved - // by one either. `Pane.gridRow` and `Pane.surfRow` are NOT inverses - // for a row strictly inside the buffer's covered span (surfRow - // clamps to the buffer's last line, gridRow collapses the whole - // span onto its first shell row), so a buffer left behind by - // `enterTty` — which clears every other modal remnant but not this - // one — straddling the viewport top used to start this walk ABOVE - // the viewport and slide the entire body down. - .g = if (pane.mode == .tty) goff else pane.gridRow(off), - .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0, - }; - } - - fn next(w: *BodyWalk) ?BodyRow { - while (w.n < w.pane.rows) { - if (w.lines) |*it| { - if (it.next()) |line| { - const idx = w.line_idx; - w.line_idx += 1; - // Lines scrolled off the top still count: the index names a - // line of the BUFFER, not of the visible body. - if (w.skip > 0) { - w.skip -= 1; - continue; - } - w.n += 1; - return .{ .edit = .{ .line = line, .idx = idx } }; - } - // The buffer stands in for `rows` shell rows however many lines - // it actually spelled, which is the whole slide. - w.g += w.covered; - w.skip = 0; - w.lines = null; - continue; - } - if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) { - w.lines = std.mem.splitScalar(u8, o.text, '\n'); - w.covered = o.rows; - w.line_idx = 0; - continue; - }; - const vi = w.g - w.goff; - w.g += 1; - w.n += 1; - return .{ .grid = vi }; - } - return null; - } -}; - -/// Run the terminal body row walk. A null destination counts bytes; a slice -/// fills the exact allocation made from that count. -fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { - var walk: BodyWalk = .init(pane); - var written: usize = 0; - var first = true; - while (walk.next()) |row| { - if (!first) { - if (dst) |out| out[written] = '\n'; - written += 1; - } - first = false; - const bytes = switch (row) { - .edit => |e| e.line, - .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) - viewport[@intCast(vi)] - else - "", - }; - if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes); - written += bytes.len; - } - return written; -} - -/// WHICH edit-buffer lines still stand over a shell row. -/// -/// The buffer only ever GROWS: it starts at the row first typed on and stretches -/// to cover every row an edit since has touched, so after a few edits it spans -/// rows the user never altered. Those lines are still byte-identical to the -/// shell rows they were seeded from, and their anchor is therefore still known — -/// so they keep their colours, and only lines that actually differ go plain. -/// -/// The buffer's text is DERIVED from the rows it covers, so the untouched lines -/// appear in the same ORDER as the rows they came from. The answer is therefore -/// a MONOTONE MATCHING, and that is what this streams: one shell-row cursor -/// which only ever moves forward, advanced once per buffer line. A line claims -/// the first row at or after the cursor that its bytes equal; matching bytes is -/// the whole proof. A line that matches nothing was typed by the user, so it -/// claims no row and leaves the rows beneath it to the lines below. -/// -/// Two ALIGNED guesses — the Nth line over the Nth covered row, and the same -/// counted from the bottom — are not enough, and the counterexample is one -/// keystroke. Join two rows (backspace at column 0): the buffer loses a line -/// and gains covered rows, the two counts cancel at `lines == covered`, and both -/// guesses resolve to the SAME row, one short of where the lines below actually -/// live. Every untouched row under the join went plain. Nor is a leading and a -/// trailing RUN enough: a run stops at the first divergence, so two separate -/// edits drained the colour of every untouched line BETWEEN them. -/// -/// Cost is linear in the buffer, which the quadratic version this replaced was -/// not (walking to the Nth line per line: 35 ms a frame at a few thousand -/// lines). Every successful claim moves the cursor, so all of them together -/// scan the covered span once; only a typed line can scan without moving it, -/// and `budget` is what stops a buffer full of typed lines from paying that -/// scan per line. Exhausting it costs colour on rows further down, never -/// correctness. -const EditAnchors = struct { - /// the buffer's own text, walked in order: a line the VIEWPORT skipped still - /// consumes the row it came from, so the lines below it stay aligned - text: []const u8 = &.{}, - at: usize = 0, - shell: []const []const u8 = &.{}, - /// the covered span, absolute grid rows, as `[first, end)` - first: usize = 0, - end: usize = 0, - lines: usize = 0, - /// the line `at` names, and the first row still unclaimed - idx: usize = 0, - cursor: usize = 0, - budget: usize = 0, - active: bool = false, - - fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors { - if (o.rows <= 0 or o.row < 0) return .{}; - const shell = shellRows(p, pane) catch return .{}; - const first: usize = @intCast(o.row); - if (first >= shell.len) return .{}; - const covered: usize = @intCast(o.rows); - const lines = std.mem.count(u8, o.text, "\n") + 1; - return .{ - .text = o.text, - .shell = shell, - .first = first, - .end = @min(first + covered, shell.len), - .lines = lines, - .cursor = first, - .budget = covered + 4 * lines, - .active = true, - }; - } - - /// Where buffer line `idx` still stands over the grid, if anywhere. `idx` - /// only ever grows — both passes step `BodyWalk` from the top — so catching - /// up to it is amortised O(1) per visible row. - fn shellRow(a: *EditAnchors, idx: usize) ?Anchor { - if (!a.active or idx >= a.lines) return null; - var found: ?Anchor = null; - while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null); - return found; - } - - fn nextLine(a: *EditAnchors) ?[]const u8 { - if (a.at > a.text.len) return null; - const rest = a.text[a.at..]; - if (std.mem.indexOfScalar(u8, rest, '\n')) |n| { - a.at += n + 1; - return rest[0..n]; - } - // The last line has no terminator; one past the end ends the walk. - a.at = a.text.len + 1; - return rest; - } - - /// Where this line still stands over the grid, if anywhere. - fn claim(a: *EditAnchors, line: []const u8) ?Anchor { - // An EXACT row is the best evidence there is, so look for one first and - // look anywhere ahead: a line that merely RESEMBLES the row alignment - // offers is often the row two below, unchanged and unedited. - // - // Scanning past the cursor crosses rows that were deleted or joined - // away, and the line's bytes are what justify the crossing — so an - // EMPTY line may not do it. Empty is not evidence: it equals every - // blank row in the span, and splitting a row makes exactly that. Two - // keystrokes (Home, Enter) used to hand the blank row below the last - // output to the new empty line and take every coloured row in between - // out of reach of the lines that owned them. - const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end; - var k = a.cursor; - while (k < end) : (k += 1) { - if (a.budget == 0) return null; - a.budget -= 1; - if (!std.mem.eql(u8, line, a.shell[k])) continue; - a.cursor = k + 1; - return .{ .row = @intCast(k) }; - } - // No row spells this line, so it is either the row the alignment offers - // WITH AN EDIT IN IT, or text typed from nothing. The bytes shared at - // the two ends decide which — and, when it is an edit, exactly how much - // of the row's colour the line still has a right to. - if (a.cursor >= a.end) return null; - const shell = a.shell[a.cursor]; - var p: usize = 0; - while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1; - var s: usize = 0; - const room = @min(line.len, shell.len) - p; - while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1; - if (p + s == 0) return null; - // Accept when the row accounts for the whole LINE (nothing was typed; - // the line is a piece of the row, which is the top half of a split), or - // when most of the ROW survived in it (an ordinary edit). Otherwise this - // is new text that happens to share an edge with its neighbour, and - // colouring it would hand it a colour that was never its own. - if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null; - const row = a.cursor; - // A line that stopped short of the row's END leaves the rest of that row - // to the NEXT line. Splitting a row in two is exactly that, and it is - // why the bottom half can still find its colours: they are in the tail - // of the row the top half only partly covered. - if (s > 0 or p >= shell.len) a.cursor += 1; - return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len }; - } -}; - -/// WHERE a body row's colours come from, and HOW MUCH of the row they cover. -/// -/// A row whose text is the grid's own takes the grid's colours end to end. A -/// row the user has EDITED still holds the row's own bytes at its two ends — -/// they are the same bytes, provably — and those keep their colours; only what -/// was typed between them has no cell under it and so takes none. Dropping the -/// whole row instead was the loudest colour bug in the editor: one keystroke -/// that changed one character's case turned every column of a coloured row -/// grey. -const Anchor = struct { - /// the row, absolute while it comes from `EditAnchors`, viewport once - /// `recolorAnsi` has subtracted the walk's offset - row: i32, - /// bytes at the START of the line that are still the row's own, and bytes at - /// its END. The default says ALL of it: an exact match, or a `.grid` row, - /// which is the grid's text by construction. - prefix: usize = std.math.maxInt(usize), - suffix: usize = 0, - /// the row's own dumped length — what the suffix is measured from on the - /// GRID side, where the edit may have changed the byte count - shell_len: usize = 0, - - fn whole(an: Anchor) bool { - return an.prefix == std.math.maxInt(usize); - } -}; - -/// tty colors: recolor each visible body cell from the emulator's own style so -/// raw output keeps its ansi colors — in EVERY mode, not just `.tty`, because a -/// body row's colour has the same origin its text does and `BodyWalk` already -/// knows it. -/// -/// Editing moves shell rows around: the edit buffer's lines stand in for the -/// rows it covers, so everything below slides, and `promptRow` left-hugs a -/// prompt row so what was typed starts at column 0. A colour therefore needs -/// exactly two translations, and takes each from the pass that made it: -/// -/// * ROW — step `BodyWalk`, the same iterator `fillBody` steps. A `.grid` row -/// names the viewport row whose bytes were drawn; an `.edit` row is the -/// user's own text with no shell row underneath, so it keeps the body style. -/// Sharing the walk is load-bearing: deriving the anchor independently (from -/// `Pane.gridRow`) put colours one row off wherever that arithmetic and this -/// walk disagreed about a buffer's height. -/// * COLUMN — pair the PRINTED graphemes with the grid cells that spelled them, -/// starting at the cell `promptCut` says the hug dropped to. Not `cut + c`: -/// the two sides disagree about how many columns a cluster is worth (ghostty -/// splits `👨‍👩‍👧` across three wide cells and spells it once; this surface -/// prints that one grapheme two columns wide), so column arithmetic walks off -/// the glyph it means and every cell after it wears a neighbour's colour. -/// `body` is the very text the caller just printed, which is what makes the -/// pairing exact rather than a second guess at clustering. -/// -/// In tty mode the buffer is not applied and no prompt is hugged, so the row -/// anchor collapses to the viewport row. That is not quite "as it always did": -/// the walk starts at `Pane.gridRow(pane.scroll())` like `fillBody`, so where a -/// stale buffer skews that start, the colours now follow the text instead of -/// silently disagreeing with it. -pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { - // No emulator, no ANSI cells: the whole pass — and the 256-colour theme - // projection behind it — is compiled out. - if (comptime !enabled) return; - const s = &p.surface; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - var filtered_storage: FilteredColors = undefined; - const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { - // Enumerating the indexed answers is the whole cost of the filter that - // is NOT per cell, so it gets to be visible on its own: this is the - // number that says whether the tables should be cached across frames - // rather than rebuilt per pass. Measured at 2.9 us warm against a - // 117 us `paneRecolor`, which is why they are not. - const tz_filter = tracy.zone(@src(), "filterInit"); - defer tz_filter.end(); - filtered_storage = FilteredColors.init(p, pane); - break :blk &filtered_storage; - } else null; - // DECSCNM, read once: the filtered palette folds it in itself, the raw - // path needs it per cell. - const scnm = pane.vt.modes.get(.reverse_colors); - const pages = &pane.vt.screens.active.pages; - // The text pass bounds its rows by the dump it was handed; this one has the - // live viewport, so it bounds by the viewport's own height. Both bounds - // exist for the same reason and NEITHER is `pin`: `PageList.pin` resolves a - // viewport row by walking DOWN the pagelist, so a viewport scrolled back - // answers happily for rows below its bottom edge — which painted the - // scrollback's colours onto rows the text pass had left blank. - const vp_rows: i32 = @intCast(scrollbar(pane).len); - // Which buffer lines the user has not actually changed, so a row swallowed - // by a growing buffer keeps the colour it still stands over. - var anchors: EditAnchors = if (pane.mode != .tty) - if (pane.ovl) |o| .init(p, pane, o) else .{} - else - .{}; - var walk: BodyWalk = .init(pane); - // The printed body, one line per body row, stepped ONCE per row alongside - // the walk. Asking for the Nth line per row instead re-scanned the whole - // body every time, which made a tall pane's render superlinear. - var lines = std.mem.splitScalar(u8, body, '\n'); - var vr: u16 = 0; - while (walk.next()) |row| : (vr += 1) { - if (vr >= body_h) break; - // Before any early exit below, or the lines fall out of step with rows. - const text = lines.next() orelse ""; - const anchor: Anchor = switch (row) { - // A line the user typed from nothing has no cell under it; one they - // only had swallowed, or edited a piece of, still names the row its - // bytes came from and how much of it is still that row's. - .edit => |e| blk: { - var an = anchors.shellRow(e.idx) orelse continue; - an.row -= walk.goff; - break :blk an; - }, - .grid => |v| .{ .row = v }, - }; - const vi = anchor.row; - if (vi < 0 or vi >= vp_rows) continue; - const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue; - // The prompt the text pass dropped, added back as a starting CELL. - // Gated on the ROW FLAG first, exactly as `bodyText` gates `promptRow`: - // `promptCut` answers for the whole row under - // `config.tty_blank == .prompt_and_input`, so asking it about a row the - // text pass never asked about would blank colours nobody hid. - const cut: u16 = if (pane.mode == .tty) 0 else cut: { - if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0; - break :cut switch (promptCut(row_pin)) { - .keep => 0, - // Blanked end to end: the row shows nothing of the grid, so - // projecting the prompt's own colours onto it would be a lie. - .blank => continue, - .cut => |n| std.math.cast(u16, n) orelse continue, - }; - }; - // The text this row printed is walked grapheme by grapheme alongside the - // cells that spelled it. Both walks are driven by real data — the - // printed bytes and the cells' own dumped byte counts — so neither has - // to guess how many columns the other gives a cluster. - // The hug can empty a row outright: a prompt whose command did not fit - // leaves ghostty a styled spacer and nothing printable. The row DRAWS - // nothing, so nothing on it may take the grid's colour — the same - // reasoning as `.blank` above, reached by a different route. - if (cut > 0 and text.len == 0) continue; - var at: usize = 0; - var sc: u16 = 0; - var gc: u16 = cut; - // Shell bytes crossed so far, which is how the row's TAIL is found again - // after an edit: the printed text and the grid agree byte for byte over - // `prefix` and over `suffix`, and nowhere in between. - var sb: usize = 0; - const mine_from = @min(anchor.prefix, text.len); - const mine_to = text.len - @min(anchor.suffix, text.len); - var crossed = false; - while (at < text.len and sc < tw) { - const stop = modal.nextGrapheme(text, at); - if (stop <= at) break; - // What the glyph occupies HERE: `print` leaves an empty cell under a - // double-width one, and `fill` writes a space, so a zero-length cell - // is a spacer and nothing else. It is a property of the SURFACE, so - // it is known before any cell is consumed — which is what lets the - // user's own text spend its columns without spending the row's. - // - // This rule assumes the printed text holds no `\t` and no `\r`: - // `Surface.print` expands a tab into `config.tab_width` cells and - // draws nothing at all for a carriage return, either of which would - // slide every later colour on the row. The assumption is ghostty's, - // not ours — its row dump expands tabs to real spaces and replaces - // undecodable bytes with U+FFFD — so it holds for anything sourced - // from the grid, and an anchor only ever covers bytes that ARE such - // a row's. Feed this text from anywhere else and the span rule is - // the thing that breaks first. - const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1; - // Between the row's own two ends lie the bytes the user typed. No - // cell spelled them, so they take no colour and spend no grid - // column: the row's tail then still lines up with the line's tail. - if (at >= mine_from and at < mine_to) { - sc += span; - at = stop; - continue; - } - // Crossing back into the row's own bytes: step over the cells whose - // bytes the edit replaced. `shell_len - suffix` is where the row's - // own tail starts on the GRID side, which is not where it starts in - // the line whenever the edit changed the byte count. - if (at >= mine_to and !crossed) { - crossed = true; - const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len); - while (sb < upto) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - sb += dumpedBytes(row_pin, ci.cell); - gc = std.math.add(u16, gc, 1) catch break; - } - } - const want = stop - at; - // Consume every cell that contributed to this grapheme. A cluster - // ghostty split across several cells is still ONE printed glyph. - var covered: usize = 0; - var style: ?pardes.CellStyle = null; - while (covered < want) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head) - style = cellStyle(p, ci, filtered, scnm); - covered += dumpedBytes(row_pin, ci.cell); - gc = std.math.add(u16, gc, 1) catch break; - // A spacer contributes no bytes; without this the loop would - // spin on a row that ends in one. - if (covered == 0 and gc >= pane.cols) break; - } - // A wide cell's tail contributes NO bytes, so the loop above stops - // on it rather than past it. Step over any tail now: leaving `gc` on - // one pairs the next surface column with the cell before it, which - // left an unpainted hole beside a row-final CJK glyph and pushed - // every colour after it one column right. - while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| { - if (t.cell.wide != .spacer_tail) break; - gc = std.math.add(u16, gc, 1) catch break; - } - if (style) |st| for (0..span) |k| { - const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr); - // sparse projection: bodyText already painted every glyph, so - // only a filter (which theme-keys blank/default cells too) - // touches these. - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = st; - }; - sb += covered; - sc += span; - at = stop; - } - // Past the text: the row's remaining cells carry colour but no glyph - // (an erase-to-end-of-line under a background). One cell, one column - // from here, with two exceptions on the grid side. - // - // Only a row that ENDS in the row's own bytes may ask what lies past - // them. Where the user's own text runs to the end of the line, the next - // cells still spell bytes the edit removed, and painting the line's - // margin from those would dress it in the colours of text that is no - // longer there. - var tail: ?pardes.CellStyle = null; - if (anchor.whole() or anchor.suffix > 0) { - while (sc < tw) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - gc = std.math.add(u16, gc, 1) catch break; - // A TAIL spells nothing and owns no column of its own, so it - // moves the grid on without spending a surface column. A HEAD - // does own its column — it is the gap ghostty leaves where a - // wide glyph would not fit, and it carries the row's background - // — so it is painted like any other cell. Skipping it left the - // last column of a coloured row bare, because a head is by - // construction that row's final cell. - if (ci.cell.wide == .spacer_tail) continue; - const cell = s.at(tx + sc, body_y + vr); - sc += 1; - const style = cellStyle(p, ci, filtered, scnm); - tail = style; - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = style; - } - // The grid can run out before the surface does: a cluster ghostty - // spends four cells on may print in two columns here, so a row - // ending in one has columns with no cell left to ask. The row's - // background does reach its edge on the grid, so carry the last - // cell's answer across rather than leaving a notch of pane colour at - // the margin. - if (tail) |style| while (sc < tw) : (sc += 1) { - const cell = s.at(tx + sc, body_y + vr); - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = style; - }; - } - } -} - -/// `scnm` is DECSCNM (`\x1b[?5h`), which swaps only the terminal's DEFAULT -/// colour roles — explicit SGR colours stay explicit. `FilteredColors` applies -/// it by swapping the theme's two defaults; the raw path resolves a `.none` -/// colour through `ghostColor`, whose `is_bg` argument chooses which default it -/// means, so flipping that argument is the same swap. Without it reverse video -/// simply vanished whenever `tty_filter` was off. -fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle { - const style = ci.style(); - var cs: pardes.CellStyle = .{ - .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm), - .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm), - .bold = style.flags.bold, - .dim = style.flags.faint, - .italic = style.flags.italic, - .blink = style.flags.blink, - .reverse = style.flags.inverse, - .invisible = style.flags.invisible, - .strikethrough = style.flags.strikethrough, - .ul = switch (style.flags.underline) { - .none => .off, - .single => .single, - .double => .double, - .curly => .curly, - .dotted => .dotted, - .dashed => .dashed, - }, - }; - // Style.bg above already resolves Ghostty's color-only cell variants for - // the filtered path. Preserve the established direct translation outside - // it, where indexed colours are intentionally allowed to reach the host. - if (filtered == null) switch (ci.cell.content_tag) { - .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data), - .bg_color_rgb => { - const rgb = ci.cell.content.color_rgb; - cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; - }, - else => {}, - }; - return cs; -} - -/// Per-render resolver, in two stages. The source palette is materialized -/// through Ghostty's public xterm API, so OSC 4 changes participate without -/// reaching into the emulator's private state. -/// -/// STAGE ONE is the default foreground and background roles, because they are -/// the anchors: Ghostty generates the whole 256-colour projection from them, -/// and `default_bg` below is the page every other colour is judged against. -/// -/// STAGE TWO is everything else. Truecolour and visually overridden entries are -/// reduced to the nearest canonical Ghostty palette key; the key then indexes -/// the theme palette. Repeated RGBs pay that search only once per frame. A -/// FOREGROUND additionally has to clear `config.tty_filter_min_contrast` -/// against `default_bg` — the reduction is an RGB distance and knows nothing -/// about the page, and the projection's cube corners are the anchors -/// themselves, so without the gate the nearest key to a truecolour extreme is -/// the background and the text is painted in the colour of the page. -const FilteredColors = struct { - source: GColor.Palette, - target: *const GColor.Palette, - theme_bg: GColor.RGB, - theme_fg: GColor.RGB, - dynamic_bg: ?GColor.RGB, - dynamic_fg: ?GColor.RGB, - /// Stage one's background, mapped: exactly what `bg` answers for a cell - /// that names no colour of its own, and therefore the page a foreground - /// has to stay legible against. - default_bg: GColor.RGB, - /// What a foreground too near `default_bg` becomes instead. - fallback_fg: GColor.RGB, - /// `default_bg`'s luminance, computed once. `legible` runs per CELL and - /// asks for the contrast ratio against this same colour every time; the - /// half of the ratio that belongs to the background never changes. - default_bg_luminance: f64, - /// Every answer the INDEXED path can give, resolved before the first - /// cell is read. - /// - /// A cell that names a palette colour has 256 possible inputs, and this - /// filter is a pure function of them: the OSC 4 comparison, the theme - /// projection and the contrast gate all depend only on the index and on - /// state that is fixed for the whole pass. So the per-cell chain - /// collapses to one array read, and `legible` - six libm `pow` calls - /// through `RGB.contrast`, which profiling put at 12 of 43 draw samples - /// - stops being a per-cell cost entirely. - /// - /// Only TRUECOLOUR still searches: it carries arbitrary RGB, so its - /// answers cannot be enumerated and the direct-mapped cache below is - /// what keeps it cheap. - fg_for_palette: [256]pardes.Color = undefined, - bg_for_palette: [256]pardes.Color = undefined, - /// The two answers for a cell that names no colour of its own. - fg_default: pardes.Color = undefined, - bg_default: pardes.Color = undefined, - // Direct-mapped rather than append-only: a frame which encounters more - // than the cache's capacity must not strand every later (and repeated) - // colour on the 256-entry nearest-key scan. The RGB hash spreads the - // common 6x6x6 cube values instead of keying on their low bits. - cache_rgb: [256]GColor.RGB = undefined, - cache_key: [256]u8 = undefined, - cache_valid: [256]bool = @splat(false), - - fn init(p: *Pardes, pane: *const Pane) FilteredColors { - var source = GColor.default; - for (&source, 0..) |*rgb, i| - rgb.* = pane.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*; - const theme = p.theme(); - var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg); - var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg); - var dynamic_bg = pane.vt.colorForXterm(.{ .dynamic = .background }); - var dynamic_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground }); - // DECSCNM swaps only the terminal's default color roles; explicit SGR - // colors stay explicit. Reuse Ghostty's parsed mode instead of trying - // to infer the escape from cells, just as its renderer does. - if (pane.vt.modes.get(.reverse_colors)) { - std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); - std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); - } - var self: FilteredColors = .{ - .source = source, - .target = p.tty_filter_palette.get(theme), - .theme_bg = theme_bg, - .theme_fg = theme_fg, - .dynamic_bg = dynamic_bg, - .dynamic_fg = dynamic_fg, - .default_bg = theme_bg, - .fallback_fg = theme_fg, - .default_bg_luminance = luminanceOf(theme_bg), - }; - // Stage one, finished before a single other colour is mapped. OSC 11 - // moves the page, so the floor moves with it; the anchor that survives - // as the fallback is then whichever of the theme's own pair can still - // be seen on it, which on an untouched terminal is always the theme's - // foreground (a background has no contrast with itself). - if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb); - self.default_bg_luminance = luminanceOf(self.default_bg); - if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg)) - self.fallback_fg = self.theme_bg; - - // Stage two, ENUMERATED rather than answered per cell. Everything the - // indexed path needs is now fixed, and its input is a u8, so every - // answer it can ever give is computed here - once for the pass, not - // once for each of the tens of thousands of cells that will ask. - self.fg_default = asPardesColor(self.legible( - if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg, - )); - self.bg_default = asPardesColor(self.default_bg); - for (&self.source, 0..) |current, i| { - const idx: u8 = @intCast(i); - const mapped = self.paletteRgb(idx, current); - self.fg_for_palette[idx] = asPardesColor(self.legible(mapped)); - self.bg_for_palette[idx] = asPardesColor(mapped); - } - return self; - } - - /// One array read for every colour a cell can NAME. Only truecolour, - /// whose 16.7M inputs cannot be enumerated, reaches the reduction - and - /// `style.fg` is now asked only on that path, because the other two - /// answers no longer depend on it. - fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { - return switch (style.fg_color) { - .none => self.fg_default, - .palette => |idx| self.fg_for_palette[idx], - .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{ - .default = self.dynamic_fg orelse self.theme_fg, - .palette = &self.source, - .bold = null, - })))), - }; - } - - fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { - switch (cell.content_tag) { - .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data], - .bg_color_rgb => {}, - else => switch (style.bg_color) { - .none => return self.bg_default, - .palette => |idx| return self.bg_for_palette[idx], - .rgb => {}, - }, - } - // Truecolour, from either the cell or its style. - return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?)); - } - - /// Stage two's only rule, and a FOREGROUND rule: a background IS the page - /// for whatever is drawn over it, so holding one away from itself would be - /// meaningless. An ANSI black on a dark theme and a truecolour white on a - /// light one both reduce to the key whose projected value is the page — - /// ratio 1.000, invisible text — and both land here instead. - fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB { - if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >= - config.tty_filter_min_contrast) return rgb; - return self.fallback_fg; - } - - /// Preserve an ordinary indexed colour's semantic key. A value changed by - /// OSC 4 instead carries arbitrary RGB intent, so key that RGB the same way - /// as truecolour. Setting an entry to its exact original value is visually - /// indistinguishable and correctly takes this fast path. - fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB { - if (current.eql(GColor.default[idx])) return self.target[idx]; - return self.keyedRgb(current); - } - - fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB { - return self.target[self.nearestKey(rgb)]; - } - - fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 { - const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b; - const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24); - if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb)) - return self.cache_key[slot]; - - var best: u8 = 0; - var best_distance: u32 = std.math.maxInt(u32); - for (GColor.default, 0..) |candidate, i| { - const distance = colorDistance(rgb, candidate); - // Strict comparison makes duplicate-colour ties stable at the - // lowest canonical xterm key. - if (distance < best_distance) { - best_distance = distance; - best = @intCast(i); - } - } - self.cache_rgb[slot] = rgb; - self.cache_key[slot] = best; - self.cache_valid[slot] = true; - return best; - } -}; - -/// W3C relative luminance per 8-bit channel, precomputed. -/// -/// ghostty's `RGB.componentLuminance` ends in `std.math.pow(f64, x, 2.4)` -/// (color.zig:474), `luminance` calls it three times, and `contrast` calls -/// `luminance` for BOTH colours — so `legible`'s single `rgb.contrast(bg)` is -/// up to six libm `pow` calls, per cell, per frame. Profiling the AppKit shell -/// put `cellStyle -> FilteredColors.legible -> RGB.contrast` at 12 of 43 draw -/// samples; the whole rest of `recolorAnsi` was 3. -/// -/// The input is a `u8`. There are 256 possible answers. This is the table. -/// -/// Bit-identical to ghostty's function by construction — same expression, -/// evaluated at comptime — so the filter's decisions do not move. The -/// equivalence test below pins that. -const channel_luminance: [256]f64 = blk: { - @setEvalBranchQuota(20000); - var table: [256]f64 = undefined; - for (&table, 0..) |*slot, c| { - const normalized: f64 = @as(f64, @floatFromInt(c)) / 255; - slot.* = if (normalized <= 0.03928) - normalized / 12.92 - else - std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); - } - break :blk table; -}; - -fn luminanceOf(rgb: GColor.RGB) f64 { - return 0.2126 * channel_luminance[rgb.r] + - 0.7152 * channel_luminance[rgb.g] + - 0.0722 * channel_luminance[rgb.b]; -} - -/// ghostty's `RGB.contrast` with both luminances already in hand. -fn contrastOf(a_luminance: f64, b_luminance: f64) f64 { - const lighter = @max(a_luminance, b_luminance); - const darker = @min(a_luminance, b_luminance); - return (lighter + 0.05) / (darker + 0.05); -} - -fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { - const dr = @as(i32, a.r) - @as(i32, b.r); - const dg = @as(i32, a.g) - @as(i32, b.g); - const db = @as(i32, a.b) - @as(i32, b.b); - return @intCast(dr * dr + dg * dg + db * db); -} - -test "the luminance table answers exactly what ghostty computes" { - // The filter's decisions are a threshold comparison on these numbers, so - // "close enough" is not enough: one ULP either side of - // `tty_filter_min_contrast` is a different colour on screen. Every - // channel value, and the pairs a real pass actually asks about. - for (0..256) |i| { - const c: u8 = @intCast(i); - const grey: GColor.RGB = .{ .r = c, .g = c, .b = c }; - try std.testing.expectEqual(grey.luminance(), luminanceOf(grey)); - } - // Channel weights are asymmetric, so a grey ramp alone would not catch a - // transposed coefficient. The palette is what the tables enumerate. - for (GColor.default) |candidate| { - try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate)); - for (GColor.default) |page| { - try std.testing.expectEqual( - candidate.contrast(page), - contrastOf(luminanceOf(candidate), luminanceOf(page)), - ); - } - } -} - -test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - pane.tty_filter = false; - - // 1: ANSI base key; 196: extended key; true red exactly matches canonical - // key 196. The two backgrounds repeat key 25 as indexed and truecolour. - // Erase-to-EOL under that background makes Ghostty color-only cells. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++ - "\x1b[38;5;196mB" ++ - "\x1b[38;2;255;0;0mC" ++ - "\x1b[0;48;5;25mD" ++ - "\x1b[0;48;2;0;95;175mE" ++ - "\x1b[0;1;2;3;4;5;7;8;9mF" ++ - "\x1b[0;48;5;25m\x1b[K" ++ - "\r\n\x1b[0;38;5;2m界" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - const raw = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg); - - pane.tty_filter = true; - _ = frame.reset(.retain_capacity); - const filtered = try p.render(frame.allocator()); - var expected_cache: FilterPalette = .{}; - const expected = expected_cache.get(p.theme()); - try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg); - try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg); - try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg); - try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg); - try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg); - - const attrs = filtered.at(tx + 5, body_y).style; - try testing.expect(attrs.bold); - try testing.expect(attrs.dim); - try testing.expect(attrs.italic); - try testing.expect(attrs.blink); - try testing.expect(attrs.reverse); - try testing.expect(attrs.invisible); - try testing.expect(attrs.strikethrough); - try testing.expectEqual(.single, attrs.ul); - - const erased = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?; - try testing.expectEqual(.bg_color_palette, erased.cell.content_tag); - try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg); - try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg); - try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style); - // A filtered terminal never delegates either colour to a backend palette, - // including cells which were empty/default before the pass. - for (0..r.w - config.GUTTER) |col| { - const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y); - try testing.expect(!cell.default); - switch (cell.style.fg) { - .rgb => {}, - else => return error.FilteredForegroundWasNotRgb, - } - switch (cell.style.bg) { - .rgb => {}, - else => return error.FilteredBackgroundWasNotRgb, - } - } - - // Colors remains the global master gate. The pane remembers Filter while - // ANSI projection is dormant, and resumes it without replaying VT bytes. - p.settings.colors = false; - _ = frame.reset(.retain_capacity); - const plain = try p.render(frame.allocator()); - try testing.expect(pane.tty_filter); - try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg); - p.settings.colors = true; - - // OSC 4 changes the value behind an existing indexed cell. Filter treats - // that arbitrary value like truecolour, while toggling remains purely a - // presentation operation and cannot alter Ghostty's query answer. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } }); - const osc_red = pane.vt.colorForXterm(.{ .palette = 1 }).?; - try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 })); - pane.tty_filter = false; - pane.tty_filter = true; - try testing.expect(osc_red.eql(pane.vt.colorForXterm(.{ .palette = 1 }).?)); - _ = frame.reset(.retain_capacity); - const osc_palette = try p.render(frame.allocator()); - try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg); - - // Dynamic default foreground/background colours key every default cell, - // including the otherwise blank end of the row. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++ - "\x1b]11;#5f5f5f\x1b\\" } }); - const dyn_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground }).?; - const dyn_bg = pane.vt.colorForXterm(.{ .dynamic = .background }).?; - try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 })); - try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 })); - _ = frame.reset(.retain_capacity); - const dynamic = try p.render(frame.allocator()); - const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[196]), blank.fg); - try testing.expectEqual(asPardesColor(expected[59]), blank.bg); - - // Ghostty owns DEC reverse-screen parsing. Filter follows that mode for - // the dynamic/default roles, and here the swap turns this cell into a - // COLLISION: its explicit ANSI foreground is the OSC 4 red keyed to 196, - // and reverse video has just made that same red the page. Stage two - // refuses the mapping rather than painting red on red, so the ink becomes - // the anchor still visible on it — under the swap, the theme's own - // background colour. Unreversed, the very same cell keeps key 196. - const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; - try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse); - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - _ = frame.reset(.retain_capacity); - const reversed = try p.render(frame.allocator()); - const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg); - try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg); - const reversed_explicit = reversed.at(tx, body_y).style; - try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg); - try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg); - try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg)); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } }); - _ = frame.reset(.retain_capacity); - const unreversed = try p.render(frame.allocator()); - const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg); - try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg); - - // The cache is keyed by values, not a theme name. Replacing a custom - // theme in place immediately recolours already-rendered indexed cells. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } }); - var custom = p.theme().*; - custom.name = try p.gpa.dupe(u8, "same-name"); - custom.palette = null; - custom.kw = .{ 1, 2, 3 }; - p.custom_theme = custom; - p.custom_theme_active = true; - _ = frame.reset(.retain_capacity); - const custom_first = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg); - if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 }; - _ = frame.reset(.retain_capacity); - const custom_second = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg); -} - -test "terminal Filter keeps extended keys dark-to-light on a light theme" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - // Curated order is a public theme contract: helix, dark, acme. - p.settings.theme = 2; - try std.testing.expectEqualStrings("acme", p.theme().name); - var cache: FilterPalette = .{}; - const palette = cache.get(p.theme()); - try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?))); - try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?))); -} - -test "terminal Filter preserves exact palette-null light theme default roles" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - - var light = p.theme().*; - light.name = try p.gpa.dupe(u8, "filter-light-defaults"); - light.bg = .{ 0xf8, 0xf8, 0xf8 }; - light.fg = .{ 0x38, 0x38, 0x38 }; - light.palette = null; - p.custom_theme = light; - p.custom_theme_active = true; - - const pane = p.panes[0].?; - try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .foreground })); - try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .background })); - pane.tty_filter = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const ordinary = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - _ = frame.reset(.retain_capacity); - const reversed = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg); -} - -test "terminal Filter maps the default roles before it maps anything else" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - - // Stage one is the two anchors, and they are taken from the theme WHOLE: - // a cell that names no colour of its own is not routed through the - // projection at all, so the page and the ink are exactly the theme's. - for (0..3) |t| { - p.settings.theme = @intCast(t); - const stage_one = FilteredColors.init(p, pane); - // `dark` declares no background of its own, which is exactly why the - // resolver reads the tag colours as the fallback rather than `.?`. - const theme = p.theme(); - try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg))); - try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg))); - // With no OSC 11 in play the mapped page IS that anchor, and the - // fallback is the other one: a background never contrasts with itself. - try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg)); - try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg)); - } - - // OSC 11 moves the page, and stage one moves with it: the reference the - // floor is measured against becomes the PROJECTED dynamic background, not - // the theme's, because that is what `bg` paints behind a default cell. - p.settings.theme = 0; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } }); - var moved = FilteredColors.init(p, pane); - try testing.expect(!moved.default_bg.eql(moved.theme_bg)); - try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f }))); -} - -test "terminal Filter refuses a foreground that would collapse onto the page" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - - // Two ways to land on the page, one per theme orientation. On the light - // theme the projection's white corner IS the paper, so a truecolour white - // reduces to it; on a dark theme the same is true of ANSI black, which a - // shell reaches for with a bare `\x1b[30m` and which takes the semantic - // fast path rather than the nearest-key scan. Both used to render text in - // the colour of the page under it. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++ - "\x1b[0;30mB" ++ - "\x1b[0;31mR" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - for (0..3) |t| { - p.settings.theme = @intCast(t); - var fc = FilteredColors.init(p, pane); - const page = asPardesColor(fc.default_bg); - const rescued = asPardesColor(fc.fallback_fg); - _ = frame.reset(.retain_capacity); - const g = try p.render(frame.allocator()); - - // The colour each of the three would have been given with no floor. - const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 }); - const raw_black = fc.paletteRgb(0, GColor.default[0]); - const raw_red = fc.paletteRgb(1, GColor.default[1]); - - for ([_]struct { at: u16, raw: GColor.RGB }{ - .{ .at = 0, .raw = raw_white }, - .{ .at = 1, .raw = raw_black }, - .{ .at = 2, .raw = raw_red }, - }) |case| { - const cell = g.at(tx + case.at, body_y).style; - try testing.expectEqual(page, cell.bg); - if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) { - // Refused: the projection's answer is not painted, the anchor is. - try testing.expectEqual(rescued, cell.fg); - try testing.expect(!std.meta.eql(cell.fg, cell.bg)); - } else { - // Cleared the floor, so stage two leaves it exactly alone. - try testing.expectEqual(asPardesColor(case.raw), cell.fg); - } - // Either way a filtered cell delegates neither colour to a backend. - switch (cell.fg) { - .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >= - config.tty_filter_min_contrast), - else => return error.FilteredForegroundWasNotRgb, - } - } - - // At least one of the three has to have been a real collapse, or this - // theme proved nothing: white on the light theme, black on the dark. - try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or - raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast); - // A saturated red is never the page on any curated theme. - try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast); - } -} - -test "terminal Filter holds every projected foreground off the page" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - - // The invariant over the WHOLE projection rather than a sampled colour: - // whatever key a foreground reduces to, what stage two hands back clears - // the floor. A background is exempt by construction and must stay so — - // `bg` is what the floor is measured against. - for (0..3) |t| { - p.settings.theme = @intCast(t); - var fc = FilteredColors.init(p, pane); - var refused: usize = 0; - for (fc.target, 0..) |projected, key| { - const ink = fc.legible(projected); - try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast); - if (!ink.eql(projected)) { - refused += 1; - try testing.expect(ink.eql(fc.fallback_fg)); - // Only ever refused for being too near the page. - try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast); - } - // The key a background asks for is handed back untouched, including - // the one whose value is the page itself. - try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])])); - } - // Every curated theme owns at least one collapsing key — that is why - // the floor exists — and the floor must not be flattening the palette. - try testing.expect(refused > 0); - try testing.expect(refused < fc.target.len / 8); - } -} - -test "tty ansi colors follow the prompt hug into normal mode" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const red: pardes.Color = .{ .index = 1 }; - const blue: pardes.Color = .{ .index = 4 }; - - // tty mode projects the emulator's ansi colours cell for cell. - pane.mode = .tty; - p.shell_rows.stale = true; - const tty = try p.render(frame.allocator()); - try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg); - try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg); - - // Normal mode hugs the prompt away, so `R` starts at column 0 — and its - // colour comes with it. The two cells the prompt occupied are the COLUMN - // anchor `promptCut` hands back, which is the only reason the red lands on - // the R the user can see instead of two cells to the right of it. - pane.mode = .normal; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const norm = try p.render(frame.allocator()); - try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme()); - try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme()); - try testing.expectEqual(red, norm.at(tx, body_y).style.fg); - try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg); -} - -test "an edit buffer slides shell rows and their colors together" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const red: pardes.Color = .{ .index = 1 }; - const green: pardes.Color = .{ .index = 2 }; - const blue: pardes.Color = .{ .index = 4 }; - - p.shell_rows.stale = true; - const before = try p.render(frame.allocator()); - try testing.expectEqual(red, before.at(tx, body_y).style.fg); - try testing.expectEqual(green, before.at(tx, body_y + 1).style.fg); - try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); - - // Four lines of typed text standing in for the ONE shell row `AAA` was: - // every row below slides down by three, and `surfRow` is the arithmetic - // that says so. The colours have to take the same three rows, or `BBB` - // would be painted green three rows above where it is now drawn. - pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const after = try p.render(frame.allocator()); - - try testing.expectEqualStrings("B", after.at(tx, body_y + 4).grapheme()); - try testing.expectEqualStrings("C", after.at(tx, body_y + 5).grapheme()); - try testing.expectEqual(green, after.at(tx, body_y + 4).style.fg); - try testing.expectEqual(blue, after.at(tx, body_y + 5).style.fg); - - // ...and the rows the user typed are the user's own text: no shell row - // sits under them, so nothing projects a colour onto them. - for (0..4) |i| { - const cell = after.at(tx, body_y + @as(u16, @intCast(i))); - try testing.expect(!std.meta.eql(red, cell.style.fg)); - try testing.expect(!std.meta.eql(green, cell.style.fg)); - try testing.expect(!std.meta.eql(blue, cell.style.fg)); - } -} - -test "a combining mark in the prompt keeps the command and its colors aligned" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - // A ONE-cell prompt carrying a combining mark — an NFD `e` — then `ABC` - // typed at it. The cell walk that finds the prompt's end must step ONE - // grapheme for that cell, not one per stored codepoint: stepping twice ate - // the `A`, and left every colour a cell to the left of its glyph with the - // last one stranded on a blank. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32me\u{301}\x1b]133;B\x1b\\\x1b[31mA\x1b[34mB\x1b[35mC" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme()); - try testing.expectEqualStrings("B", s.at(tx + 1, body_y).grapheme()); - try testing.expectEqualStrings("C", s.at(tx + 2, body_y).grapheme()); - try testing.expectEqual(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx + 1, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx + 2, body_y).style.fg); - // ...and no colour past the end of what the row actually says - try testing.expect(!std.meta.eql(pardes.Color{ .index = 5 }, s.at(tx + 3, body_y).style.fg)); -} - -test "colors are never taken from shell rows below the viewport" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - // Raw palette, so a leaked background reads back as `.index` — the theme - // filter would repaint every blank cell and hide the evidence. - pane.tty_filter = false; - pane.mode = .normal; - - // Sixty rows, each a distinct background, so a leaked colour names the row - // it leaked from. - for (0..60) |i| { - var buf: [32]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[4{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - p.shell_rows.stale = true; - scrollGrid(pane, -20); - - // ONE buffer line standing in for SIX shell rows: everything below slides - // UP five, so the last rows of the body resolve past the viewport's bottom - // edge. `PageList.pin` answers for those rows anyway — it walks down the - // pagelist, not the viewport — so without a bound of its own this pass - // painted the scrollback's colours onto rows the text pass left blank. - const anchor = gridOffset(pane); - pane.ovl = .{ .row = anchor, .rows = 6, .text = try p.gpa.dupe(u8, "one") }; - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const body_h = r.h - pardes.BOX_H; - - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - // A body row the text pass left blank has no shell row under it, so no - // ANSI background may have reached it. Every colour in the payload above is - // an indexed one, so a leak is exactly an `.index` background on a blank row. - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - var blank = true; - var c: u16 = 0; - while (c < r.w -| config.GUTTER) : (c += 1) { - if (!std.mem.eql(u8, " ", s.at(tx + c, body_y + vr).grapheme())) blank = false; - } - if (!blank) continue; - c = 0; - while (c < r.w -| config.GUTTER) : (c += 1) { - const bg = s.at(tx + c, body_y + vr).style.bg; - try testing.expect(std.meta.activeTag(bg) != .index); - } - } -} - -test "a row the edit buffer only swallowed keeps its color" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const red: pardes.Color = .{ .index = 1 }; - const green: pardes.Color = .{ .index = 2 }; - const blue: pardes.Color = .{ .index = 4 }; - - // The buffer only ever grows, so after a few edits it covers rows nobody - // touched. Here it spans all three and only the MIDDLE line differs: the - // first and last are still byte-identical to the shell rows they were - // seeded from, so they still stand over them and keep their colours. - pane.ovl = .{ .row = 0, .rows = 3, .text = try p.gpa.dupe(u8, "AAA\nXXX\nCCC") }; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme()); - try testing.expectEqualStrings("X", s.at(tx, body_y + 1).grapheme()); - try testing.expectEqualStrings("C", s.at(tx, body_y + 2).grapheme()); - try testing.expectEqual(red, s.at(tx, body_y).style.fg); - try testing.expectEqual(blue, s.at(tx, body_y + 2).style.fg); - // ...and the line that actually changed is the user's own text now - try testing.expect(!std.meta.eql(green, s.at(tx, body_y + 1).style.fg)); -} - -test "an edit buffer reaching past the dumped rows colors nothing from row zero" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const red: pardes.Color = .{ .index = 1 }; - - // Covers far more rows than the grid was ever dumped for, so the anchor - // table cannot be built and answers "no shell row" for every line. The - // zeroed table must not read as "the last line sits on the buffer's first - // row", which claimed row zero's colour and underflowed on every line after. - pane.ovl = .{ .row = 1, .rows = 50, .text = try p.gpa.dupe(u8, "p\nq\nr") }; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings("p", s.at(tx, body_y + 1).grapheme()); - var i: u16 = 1; - while (i <= 3) : (i += 1) { - try testing.expect(!std.meta.eql(red, s.at(tx, body_y + i).style.fg)); - } -} - -/// TTY MODE IS THE ORACLE. It paints the viewport row for row and column for -/// column, so whatever it shows on a glyph is what that glyph's colour IS. -/// Normal mode may move a glyph LEFT (the prompt hug) but must never change its -/// colour, so the comparison aligns by glyph rather than by column: for each -/// row the shift is recovered by finding where normal mode's glyph run sits in -/// tty mode's, without asking the code under test what it did. -/// -/// Returns the number of cells whose style disagrees; `note` labels the report. -fn modeStyleDiffs(p: *Pardes, pane: *Pane, gpa: std.mem.Allocator, note: []const u8) !usize { - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const rows: usize = r.h - pardes.BOX_H; - const cols: usize = r.w -| config.GUTTER; - - const Snap = struct { text: [][7]u8, len: []u8, style: []pardes.CellStyle }; - const glyphAt = struct { - fn f(sn: Snap, i: usize) []const u8 { - return sn.text[i][0..sn.len[i]]; - } - }.f; - var shot: [2]Snap = undefined; - for (&shot) |*sn| { - sn.text = try gpa.alloc([7]u8, rows * cols); - sn.len = try gpa.alloc(u8, rows * cols); - sn.style = try gpa.alloc(pardes.CellStyle, rows * cols); - } - defer for (&shot) |*sn| { - gpa.free(sn.text); - gpa.free(sn.len); - gpa.free(sn.style); - }; - - for ([_]pardes.Mode{ .tty, .normal }, 0..) |mode, i| { - pane.mode = mode; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const s = try p.render(frame.allocator()); - for (0..rows) |row| for (0..cols) |col| { - const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); - shot[i].text[row * cols + col] = cell.text; - shot[i].len[row * cols + col] = cell.len; - shot[i].style[row * cols + col] = cell.style; - }; - } - - var diffs: usize = 0; - for (0..rows) |row| { - const base = row * cols; - // The glyph run normal mode shows, and where it ends. - var last: ?usize = null; - for (0..cols) |col| { - if (!std.mem.eql(u8, glyphAt(shot[1], base + col), " ")) last = col; - } - const end = last orelse continue; // blank row: nothing to align - - // Recover the shift: the first offset at which tty mode spells the same - // run. Zero for every row no prompt was hugged out of. - const shift = shift: { - var s: usize = 0; - while (s + end < cols) : (s += 1) { - var all = true; - for (0..end + 1) |col| { - if (!std.mem.eql(u8, glyphAt(shot[1], base + col), glyphAt(shot[0], base + col + s))) { - all = false; - break; - } - } - if (all) break :shift s; - } - var tty_row: [256]u8 = undefined; - var nrm_row: [256]u8 = undefined; - var tn: usize = 0; - var nn: usize = 0; - for (0..cols) |col| { - const tg = glyphAt(shot[0], base + col); - const ng = glyphAt(shot[1], base + col); - if (tn + tg.len < tty_row.len) { - @memcpy(tty_row[tn..][0..tg.len], tg); - tn += tg.len; - } - if (nn + ng.len < nrm_row.len) { - @memcpy(nrm_row[nn..][0..ng.len], ng); - nn += ng.len; - } - } - std.debug.print("\n[{s}] row {d} unalignable\n tty: '{s}'\nnormal: '{s}'\n", .{ note, row, tty_row[0..tn], nrm_row[0..nn] }); - diffs += 1; - break :shift null; - } orelse continue; - - // Every column the shift can reach, not just the ones holding a glyph: - // a cell with a background and no text (`\x1b[41m\x1b[K`, a padded - // table cell) carries colour too, and is exactly what a shell paints - // most of. - for (0..cols - shift) |col| { - const want = shot[0].style[base + col + shift]; - const got = shot[1].style[base + col]; - if (std.meta.eql(want, got)) continue; - if (diffs < 6) std.debug.print( - "\n[{s}] row {d} col {d} (shift {d}) glyph '{s}': tty fg={any} bg={any} rev={} ul={any} | normal fg={any} bg={any} rev={} ul={any}", - .{ note, row, col, shift, glyphAt(shot[1], base + col), want.fg, want.bg, want.reverse, want.ul, got.fg, got.bg, got.reverse, got.ul }, - ); - diffs += 1; - } - } - if (diffs > 0) std.debug.print("\n[{s}] {d} style mismatches\n", .{ note, diffs }); - return diffs; -} - -test "a prompted session keeps every glyph's color in normal mode" { - const testing = std.testing; - const payload = - "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mls \x1b[33m-la\x1b[0m\r\n" ++ - "\x1b[34mdir1\x1b[0m \x1b[32mexec\x1b[0m plain.txt\r\n" ++ - "\x1b[31merror: nope\x1b[0m\r\n" ++ - "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mecho \x1b[1;37mhi\x1b[0m\r\n" ++ - "\x1b[38;5;208mhi\x1b[0m\r\n"; - - for ([_]bool{ false, true }) |filter| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 44, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - p.update(.{ .output = .{ .pane = 0, .bytes = payload } }); - const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "session filter=on" else "session filter=off"); - try testing.expectEqual(@as(usize, 0), diffs); - } -} - -test "an emoji prompt neither eats the command nor slides its colors" { - const testing = std.testing; - // ABSOLUTE assertions, not a tty/normal comparison: ghostty and this - // surface can BOTH be wrong about a cluster's width, and then a differential - // agrees with itself while the user sees the wrong thing. What is typed at - // the prompt is what must appear, each character wearing its own colour. - // - // Ghostty splits these clusters across cells and spells each one in the row - // dump, so the cell walk and the byte walk only agree if the byte walk is - // driven by what each CELL contributed. `👨‍💻` is two wide cells, `👨‍👩‍👧` - // three, `🇺🇸` two, `👍🏽` two, while all of them print as one glyph here. - const prompts = [_][]const u8{ - "plain", - "\u{1F468}\u{200D}\u{1F4BB}", // technologist - "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", // family - "\u{1F1FA}\u{1F1F8}", // flag - "\u{1F44D}\u{1F3FD}", // thumbs up, skin tone - "\u{2764}\u{FE0F}", // heart, VS16 - "\u{0031}\u{FE0F}\u{20E3}", // keycap - "\u{754C}", // CJK wide - "e\u{301}", // NFD - }; - for (prompts) |prompt| { - for ([_]bool{ false, true }) |filter| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - pane.mode = .normal; - - var buf: [256]u8 = undefined; - const bytes = try std.fmt.bufPrint( - &buf, - "\x1b]133;A\x1b\\\x1b[32m{s}$ \x1b]133;B\x1b\\\x1b[31mab\x1b[34mcd\x1b[0m", - .{prompt}, - ); - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - for ([_][]const u8{ "a", "b", "c", "d" }, 0..) |want, i| { - const cell = s.at(tx + @as(u16, @intCast(i)), body_y); - testing.expectEqualStrings(want, cell.grapheme()) catch |err| { - std.debug.print("\nprompt '{s}' filter={}: col {d}\n", .{ prompt, filter, i }); - return err; - }; - } - // `ab` was printed red and `cd` blue, so whatever the theme does - // with those two runs, the pair boundary has to fall between `b` - // and `c`. A prompt that cost the row a character shows up here as - // the boundary sliding onto the wrong glyph. - const fg = [_]pardes.Color{ - s.at(tx, body_y).style.fg, - s.at(tx + 1, body_y).style.fg, - s.at(tx + 2, body_y).style.fg, - s.at(tx + 3, body_y).style.fg, - }; - errdefer std.debug.print("\nprompt '{s}' filter={}: fg {any}\n", .{ prompt, filter, fg }); - try testing.expect(std.meta.eql(fg[0], fg[1])); - try testing.expect(std.meta.eql(fg[2], fg[3])); - try testing.expect(!std.meta.eql(fg[1], fg[2])); - if (!filter) { - try testing.expectEqual(pardes.Color{ .index = 1 }, fg[0]); - try testing.expectEqual(pardes.Color{ .index = 4 }, fg[2]); - } - } - } -} - -test "background-only cells keep their color through the prompt hug" { - const testing = std.testing; - // Backgrounds with no glyph under them are most of what a shell paints: - // erase-to-end-of-line after a colour is set, padded table cells, and a - // selected row. They have no text to align on, so they are the cells a - // column translation is most likely to lose. - const payload = - "\x1b]133;A\x1b\\\x1b[32mp\x1b[0m$ \x1b]133;B\x1b\\cmd\x1b[41m\x1b[K\r\n" ++ - "\x1b[44mblue-bg\x1b[K\x1b[0m\r\n" ++ - "a\x1b[42m \x1b[0mb\r\n" ++ - "\x1b[100;97mbright-on-grey\x1b[0m\r\n" ++ - "\x1b]133;A\x1b\\\x1b[35m>>\x1b[0m \x1b]133;B\x1b\\\x1b[48;5;19mrun\x1b[K\x1b[0m\r\n" ++ - "\x1b[48;2;90;10;10mtruecolor-bg\x1b[K\x1b[0m\r\n"; - - for ([_]bool{ false, true }) |filter| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - p.update(.{ .output = .{ .pane = 0, .bytes = payload } }); - const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "bg filter=on" else "bg filter=off"); - try testing.expectEqual(@as(usize, 0), diffs); - } -} - -test "a leftover edit buffer does not move what tty mode shows" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .tty; - - for (0..60) |i| { - var buf: [40]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[3{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const rows: usize = r.h - pardes.BOX_H; - const cols: usize = r.w -| config.GUTTER; - - // What tty mode shows with nothing left behind: the reference. - p.shell_rows.stale = true; - const clean = try p.render(frame.allocator()); - const want_text = try testing.allocator.alloc([7]u8, rows * cols); - defer testing.allocator.free(want_text); - const want_fg = try testing.allocator.alloc(pardes.Color, rows * cols); - defer testing.allocator.free(want_fg); - for (0..rows) |row| for (0..cols) |col| { - const cell = clean.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); - want_text[row * cols + col] = cell.text; - want_fg[row * cols + col] = cell.style.fg; - }; - - // `enterTty` clears every other modal remnant but leaves the edit buffer, so - // a buffer whose covered span STRADDLES the viewport top is an ordinary - // state. tty mode does not apply the buffer, so it must not be moved by one - // either — and `surfRow`/`gridRow` are not inverses across that span. - const anchor = gridOffset(pane); - pane.ovl = .{ .row = anchor - 1, .rows = 4, .text = try p.gpa.dupe(u8, "one\ntwo") }; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const after = try p.render(frame.allocator()); - - for (0..rows) |row| for (0..cols) |col| { - const cell = after.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); - try testing.expectEqualStrings( - std.mem.sliceTo(&want_text[row * cols + col], 0), - std.mem.sliceTo(&cell.text, 0), - ); - try testing.expectEqual(want_fg[row * cols + col], cell.style.fg); - }; -} - -test "a background after a row-final wide glyph lands on the right columns" { - const testing = std.testing; - // A CJK glyph then a coloured erase-to-end-of-line, with a second colour - // partway. The glyph's grid tail spells no bytes, so the pairing walk used - // to stop ON it and pair every later column with the cell before it: an - // unpainted hole beside the glyph and every boundary one column right. - // - // ABSOLUTE assertions: both modes were wrong identically here, so a - // tty/normal differential says nothing. - for ([_]bool{ false, true }) |filter| { - for ([_]pardes.Mode{ .tty, .normal }) |mode| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - pane.mode = mode; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32m\u{754C}\x1b[41m\x1b[K\x1b[7G\x1b[44m\x1b[K\r\n" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings("\u{754C}", s.at(tx, body_y).grapheme()); - // The glyph covers columns 0-1; red runs from 2 up to the second - // erase at column 6 (1-based 7), blue from there to the edge. - const red = s.at(tx + 3, body_y).style.bg; - const blue = s.at(tx + 9, body_y).style.bg; - errdefer std.debug.print("\nmode={any} filter={}: red={any} blue={any} col2={any}\n", .{ mode, filter, red, blue, s.at(tx + 2, body_y).style.bg }); - try testing.expect(!std.meta.eql(red, blue)); - for (2..6) |c| try testing.expectEqual(red, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg); - for (6..10) |c| try testing.expectEqual(blue, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg); - } - } -} - -test "a colored row reaches its last column when a wide glyph did not fit" { - const testing = std.testing; - // Thirteen cells of red background, then a wide glyph with one column left: - // ghostty leaves a `spacer_head` in that last column, carrying the row's - // background, and wraps the glyph to the next row. A head OWNS its column, - // so skipping it the way a tail is skipped left the row's final column bare. - for ([_]pardes.Mode{ .tty, .normal }) |mode| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 16, .rows = 8 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = mode; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[41mzzzzzzzzzzzzz\u{754C}\x1b[0m\r\n" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - const red: pardes.Color = .{ .index = 1 }; - var c: u16 = 0; - while (c < r.w -| config.GUTTER) : (c += 1) { - errdefer std.debug.print("\nmode={any} col {d} bg={any}\n", .{ mode, c, s.at(tx + c, body_y).style.bg }); - try testing.expectEqual(red, s.at(tx + c, body_y).style.bg); - } - } -} - -test "tty colours survive a scrollback deeper than the pane" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .tty; - for (0..40) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mline-{d:0>2}\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const s = try p.render(frame.allocator()); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - var bad: usize = 0; - for (0..r.h -| pardes.BOX_H) |vr| { - var buf: [16]u8 = undefined; - var n: usize = 0; - for (0..10) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - if (g.len != 1) break; - buf[n] = g[0]; - n += 1; - } - const txt = buf[0..n]; - if (!std.mem.startsWith(u8, txt, "line-")) continue; - const num = std.fmt.parseInt(usize, std.mem.trim(u8, txt[5..], " "), 10) catch continue; - const want = pardes.Color{ .index = @intCast(20 + num) }; - const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg; - if (!std.meta.eql(want, got)) { - bad += 1; - std.debug.print("row {d}: text {s} want {any} got {any}\n", .{ vr, txt, want, got }); - } - } - try testing.expectEqual(@as(usize, 0), bad); -} - -test "reverse video swaps the default colors with the filter off too" { - const testing = std.testing; - // DECSCNM is a property of the terminal, not of a cell's SGR, so it has to - // be honoured on BOTH colour paths. The theme filter folds it into its own - // palette; the raw path resolves a `.none` colour by role, and simply - // dropped reverse video altogether. - for ([_]bool{ false, true }) |filter| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 20, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - pane.mode = .normal; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - p.update(.{ .output = .{ .pane = 0, .bytes = "plain text\r\n" } }); - p.shell_rows.stale = true; - const before = try p.render(frame.allocator()); - const plain = before.at(tx, body_y).style; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const after = try p.render(frame.allocator()); - const reversed = after.at(tx, body_y).style; - - errdefer std.debug.print("\nfilter={}: plain fg={any} bg={any} | reversed fg={any} bg={any}\n", .{ filter, plain.fg, plain.bg, reversed.fg, reversed.bg }); - try testing.expectEqual(plain.fg, reversed.bg); - try testing.expectEqual(plain.bg, reversed.fg); - } -} - -test "untouched lines between two edits keep their colors" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - for (0..6) |i| { - var buf: [40]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}\x1b[0m\r\n", .{ 16 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - // The state two ordinary edits reach: one at the bottom, one that split a - // line further up. The buffer now spans rows 2..6 and diverges at BOTH - // ends, with three untouched lines in the middle. Matching a leading and a - // trailing run stops at the first divergence and drains exactly those three; - // each line carries its own evidence, so each is anchored on its own. - pane.ovl = .{ .row = 2, .rows = 5, .text = try p.gpa.dupe(u8, "r\now-02\nrow-03\nrow-04\nrow-05\nZ") }; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - // body row 2+k shows buffer line k; lines 2..4 are `row-03`..`row-05` - for (0..3) |k| { - const vr = @as(u16, @intCast(4 + k)); - var buf: [8]u8 = undefined; - const want_text = std.fmt.bufPrint(&buf, "row-{d:0>2}", .{3 + k}) catch unreachable; - const cell = s.at(tx, body_y + vr); - errdefer std.debug.print("\nbody row {d}: glyph '{s}' fg {any}\n", .{ vr, cell.grapheme(), cell.style.fg }); - try testing.expectEqualStrings(want_text[0..1], cell.grapheme()); - try testing.expectEqual(pardes.Color{ .index = @intCast(19 + k) }, cell.style.fg); - } -} - -test "a prompt row hidden end to end paints nothing at all" { - const testing = std.testing; - // The command's first glyph is wide with one column left, so ghostty leaves - // a spacer_head carrying the command's background and wraps the glyph to - // the next row. `promptRow` renders this row EMPTY, so no cell of it may - // take a colour — a spacer owns no column of its own. - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme()); - try testing.expect(!std.meta.eql(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.bg)); -} - -test "an emptied edit buffer does not shift the colors below it" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31m000\x1b[0m\r\n\x1b[32m111\x1b[0m\r\n\r\n\x1b[34m333\x1b[0m\r\n\x1b[35m444\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - // The state three keystrokes reach on any blank shell row: type a character - // and delete it, and the buffer holds NO text while still standing in for - // the row. `modal.lineCount("")` is 0 while `splitScalar("")` yields one - // line, so anything deriving the slide from the former puts every colour - // below here one row too far down — and drops the bottom row's entirely. - pane.ovl = .{ .row = 2, .rows = 1, .text = try p.gpa.dupe(u8, "") }; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings("3", s.at(tx, body_y + 3).grapheme()); - try testing.expectEqualStrings("4", s.at(tx, body_y + 4).grapheme()); - try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx, body_y + 3).style.fg); - try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx, body_y + 4).style.fg); - // ...and the user's own empty line takes no colour from the row beneath it - try testing.expect(!std.meta.eql(pardes.Color{ .index = 4 }, s.at(tx, body_y + 2).style.fg)); -} - -test "an edit overlay never changes tty-mode ansi colors" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .tty; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const blue: pardes.Color = .{ .index = 4 }; - - p.shell_rows.stale = true; - const before = try p.render(frame.allocator()); - try testing.expectEqualStrings("C", before.at(tx, body_y + 2).grapheme()); - try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); - - // A lingering multi-line edit overlay must not move any shell row's colour. - pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const after = try p.render(frame.allocator()); - try testing.expectEqualStrings("C", after.at(tx, body_y + 2).grapheme()); - try testing.expectEqual(blue, after.at(tx, body_y + 2).style.fg); -} - -pub fn palColor(p: *Pardes, idx: u8) pardes.Color { - if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; - return .{ .index = idx }; -} - -pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color { - return switch (color) { - .none => blk: { - const t = if (is_bg) p.theme().bg else p.theme().fg; - break :blk if (t) |c| .{ .rgb = c } else .default; - }, - .palette => |idx| palColor(p, idx), - .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, - }; -} - -/// executing at a prompt with typed text below it: pad the output area -/// with newlines so the command's output doesn't overwrite the buffer -pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { - // Nothing to pad away from: with no emulator there is no prompt and no - // child whose output could land on top of the edit buffer. - if (comptime !enabled) return; - const pane = p.panes[id] orelse return; - const o = pane.ovl orelse return; - if (!pane.isTerminal()) return; - if (!pane.vt.cursorIsAtPrompt()) return; - // the buffer's LAST surface row: its lines may outnumber the shell - // rows it covers, and it is the bottom one output must clear - const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; - const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); - const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.vt.screens.active.cursor.y))); - const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); - var i: i32 = 0; - while (i < pad) : (i += 1) p.emitWrite(id, "\r"); -} - -/// the snapshot takes ownership of a COPY of the edit buffer's text -pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot { - var ovl: ?EditBuffer = null; - if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; - return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; -} - -/// undo/redo restores the selection recorded with the snapshot (helix -/// keeps selections in its history transactions) -pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void { - if (pane.ovl) |o| p.gpa.free(o.text); - pane.ovl = s.ovl; - pane.cur_row = s.cur_row; - pane.cur_col = s.cur_col; - pane.cur_pinned = true; - pane.vsel = s.vsel; - pane.msel.active = false; - pane.ensureCursorVisible(); -} - -fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void { - if (len.* == slots.len) { - if (slots[0].ovl) |overlay| gpa.free(overlay.text); - std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); - len.* -= 1; - } - slots[len.*] = value; - len.* += 1; -} - -pub fn pushUndo(p: *Pardes, pane: *Pane) void { - const current = pane.ovl orelse EditBuffer{ .rows = 0 }; - if (pane.ed_undo_len > 0) { - const top = pane.ed_undo[pane.ed_undo_len - 1]; - const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and - overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null; - if (same) return; - } - const value = snap(p, pane) orelse return; - pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value); - for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text); - pane.ed_redo_len = 0; -} - -pub fn undo(p: *Pardes, pane: *Pane) void { - if (pane.ed_undo_len == 0) return; - const current = snap(p, pane) orelse return; - pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current); - pane.ed_undo_len -= 1; - restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]); -} - -pub fn redo(p: *Pardes, pane: *Pane) void { - if (pane.ed_redo_len == 0) return; - const current = snap(p, pane) orelse return; - pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current); - pane.ed_redo_len -= 1; - restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]); -} - -// ghostty calls this with a reply (cursor-position report, DA, ...) to send -// back to the child as if it typed it. The handler's `terminal` is our Pane.vt -// field; recover the Pane and stash the bytes — sync() drains them into write -// effects (the callback has no path to the effect queue). -pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { - const pane: *Pane = @alignCast(@fieldParentPtr("vt", handler.terminal)); - const room = pane.reply.len - pane.reply_len; - const n = @min(room, data.len); - @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]); - pane.reply_len += @intCast(n); -} - -const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( - @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), -).optional.child).pointer.child).@"fn".return_type.?; -pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { - return .{}; -} - -test "an edited row keeps the colours of the bytes the edit did not touch" { - const testing = std.testing; - // The loudest colour bug this editor had: one keystroke anywhere in a - // coloured row turned EVERY column of it grey, because an anchor was all or - // nothing. The row's own bytes survive at both ends of what was typed, and - // being the same bytes they keep the same colours; only the typed character - // has no cell under it and so takes none. - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - for (0..6) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d}-abcdefgh\x1b[0m\r\n", .{ 30 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - // One `Z` typed into the middle of row 3's own text. - pane.ovl = .{ .row = 3, .rows = 1, .text = try p.gpa.dupe(u8, "row-3-abcZdefgh") }; - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - const s = try p.render(frame.allocator()); - const want = pardes.Color{ .index = 33 }; - var seen = false; - for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { - var buf: [15]u8 = undefined; - for (0..15) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - buf[c] = if (g.len == 1) g[0] else '?'; - } - if (!std.mem.eql(u8, &buf, "row-3-abcZdefgh")) continue; - seen = true; - for (0..15) |c| { - const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; - errdefer std.debug.print("\nedited row col {d} ('{c}') fg={any}\n", .{ c, buf[c], got }); - // Column 9 is the typed `Z`; every other column is row 3's own. - if (c == 9) try testing.expect(!std.meta.eql(want, got)) else try testing.expectEqual(want, got); - } - } - try testing.expect(seen); -} - -test "joining two rows leaves the rows below them their colours" { - const testing = std.testing; - // A join removes a buffer line while the buffer's covered span GROWS, so the - // two counts cancel at `lines == covered`. Anchoring that only counts down - // from the buffer's top and up from its bottom then resolves both ways to - // the SAME row, one short of where the lines below live, and every untouched - // row under the join went plain. This is the state four keystrokes reach - // (Enter, then a backspace two rows up), taken from the fuzzer that found it. - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - for (0..26) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - pane.ovl = .{ - .row = 23, - .rows = 4, - .text = try p.gpa.dupe(u8, "row-23-xyzzyrow-24-xyzzy\nrow-25-xyzzy\n\n"), - }; - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - const s = try p.render(frame.allocator()); - var seen = false; - for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { - var buf: [12]u8 = undefined; - for (0..12) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - buf[c] = if (g.len == 1) g[0] else '?'; - } - if (!std.mem.eql(u8, &buf, "row-25-xyzzy")) continue; - seen = true; - // The join is above it and its own text is untouched, so every column - // still carries row 25's own colour. - for (0..12) |c| { - const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; - errdefer std.debug.print("\nrow-25 col {d} fg={any}\n", .{ c, got }); - try testing.expectEqual(pardes.Color{ .index = 45 }, got); - } - } - try testing.expect(seen); -} - -test "an untouched row always carries the colour its own text names" { - const testing = std.testing; - // Random editing, absolute oracle: every row's own text names the colour it - // must have, so no sequence of keystrokes may leave an UNTOUCHED row wearing - // anything else. This is what found the join above, and the empty line that - // claimed a blank row far below it and took every coloured row in between - // out of reach of the lines that owned them. - var seed: u64 = 0; - while (seed < 40) : (seed += 1) { - var prng = std.Random.DefaultPrng.init(seed); - const rand = prng.random(); - - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - // The raw palette, so a row's text names its exact colour instead of one - // this test would have to re-derive from the theme. - pane.tty_filter = false; - pane.mode = .normal; - for (0..26) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const body_h = r.h -| pardes.BOX_H; - - var step: usize = 0; - while (step < 12) : (step += 1) { - _ = frame.reset(.retain_capacity); - const s = try p.render(frame.allocator()); - for (0..body_h) |vr| { - var buf: [24]u8 = undefined; - for (0..24) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - buf[c] = if (g.len == 1) g[0] else '?'; - } - const txt = std.mem.trimEnd(u8, buf[0..24], " "); - if (txt.len != 12) continue; - if (!std.mem.startsWith(u8, txt, "row-") or !std.mem.endsWith(u8, txt, "-xyzzy")) continue; - const num = std.fmt.parseInt(usize, txt[4..6], 10) catch continue; - const want = pardes.Color{ .index = @intCast(20 + num) }; - for (0..txt.len) |c| { - const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; - errdefer std.debug.print("\nseed {d} step {d}: untouched '{s}' col {d} fg={any}\n", .{ seed, step, txt, c, got }); - try testing.expectEqual(want, got); - } - } - - switch (rand.intRangeAtMost(u8, 0, 10)) { - 0 => p.update(.{ .key = .{ .cp = pardes.Key.up } }), - 1 => p.update(.{ .key = .{ .cp = pardes.Key.down } }), - 2 => p.update(.{ .key = .{ .cp = pardes.Key.left } }), - 3 => p.update(.{ .key = .{ .cp = pardes.Key.right } }), - 4 => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = 'Q', .text = "Q" } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - 5 => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = pardes.Key.enter } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - 6 => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = pardes.Key.backspace } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - 7 => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = 'W', .text = "W" } }); - p.update(.{ .key = .{ .cp = 'W', .text = "W" } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - 8 => p.update(.{ .key = .{ .cp = pardes.Key.home } }), - 9 => p.update(.{ .key = .{ .cp = pardes.Key.end } }), - else => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = pardes.Key.delete } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - } - while (p.nextEffect()) |_| {} - } - } -} - -test "a new empty line does not take the colours of the rows below it" { - const testing = std.testing; - // Splitting a row makes an EMPTY buffer line, and empty equals every blank - // row in the buffer's span - including the one under the last output. Left - // free to look ahead for a row spelling the same bytes, that line claimed - // the blank row far below and put every coloured row in between out of - // reach of the lines that owned them. Two keystrokes (Home, Enter) got here. - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - for (0..26) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - // A newline typed at column 0 of row 24, and `WW` typed on the blank row - // below the output: the span covers rows 24, 25 and that blank row. - pane.ovl = .{ - .row = 24, - .rows = 3, - .text = try p.gpa.dupe(u8, "\nrow-24-xyzzy\nrow-25-xyzzy\nWW"), - }; - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - const s = try p.render(frame.allocator()); - var seen: usize = 0; - for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { - var buf: [12]u8 = undefined; - for (0..12) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - buf[c] = if (g.len == 1) g[0] else '?'; - } - if (!std.mem.startsWith(u8, &buf, "row-2")) continue; - const num = std.fmt.parseInt(usize, buf[4..6], 10) catch continue; - if (num != 24 and num != 25) continue; - seen += 1; - const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg; - errdefer std.debug.print("\nrow-{d} fg={any}\n", .{ num, got }); - try testing.expectEqual(pardes.Color{ .index = @intCast(20 + num) }, got); - } - try testing.expectEqual(@as(usize, 2), seen); -} diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig index 2ca0263e..9363841a 100644 --- a/src/tty/panel_compositor.zig +++ b/src/tty/panel_compositor.zig @@ -7,10 +7,10 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); -const panel_animation = @import("../panel_animation.zig"); +const layout = @import("../layout.zig"); -const Box = panel_animation.Box; -const Track = panel_animation.Track; +const Box = layout.Box; +const Track = layout.Track; /// Kitty placements cannot be resampled through the character-grid transform. /// Keep their transmitted pixels cached, but omit the placement while its pane @@ -77,7 +77,7 @@ pub fn compose( // Stable layout motion first, newly opening panels above it, and closing // tombstones last. A closing pane no longer owns input or canonical cells, // but its frozen old content remains the top visual until it slides out. - for ([_]panel_animation.Phase{ .moving, .opening, .closing }) |phase| { + for ([_]layout.Phase{ .moving, .opening, .closing }) |phase| { for (tracks) |track| { if (!drawable(source, track) or track.phase != phase) continue; switch (track.effect) { @@ -139,7 +139,7 @@ fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) v var x = area.x0; while (x < area.x1) : (x += 1) { if (!source.panelCellChanged(x, y)) continue; - if (panel_animation.dissolveRevealed( + if (layout.dissolveRevealed( track.serial, x - area.x0, y - area.y0, @@ -339,7 +339,7 @@ test "TTY content effects never touch cells outside the published diff" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - for ([_]panel_animation.Transition{ .ascii, .dissolve }) |effect| { + for ([_]layout.Transition{ .ascii, .dissolve }) |effect| { const track: Track = .{ .serial = 17, .effect = effect, @@ -366,7 +366,7 @@ test "content transition without a diff snaps to canonical surface" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - for ([_]panel_animation.Transition{ .dissolve, .ascii, .vertical }) |effect| { + for ([_]layout.Transition{ .dissolve, .ascii, .vertical }) |effect| { const track: Track = .{ .effect = effect, .phase = .opening, @@ -391,7 +391,7 @@ test "exact transition endpoint preserves the canonical cursor" { }; const track: Track = .{ .effect = .ascii, - .frame = panel_animation.Transition.ascii.frames() - 1, + .frame = layout.Transition.ascii.frames() - 1, .from = .{ .w = 1, .h = 1 }, .to = .{ .w = 1, .h = 1 }, }; @@ -431,7 +431,7 @@ test "vertical opening rises through a fixed old-grid clip" { .serial = 5, .phase = .opening, .effect = .vertical, - .from = panel_animation.openingBox(.vertical, target, 3), + .from = layout.openingBox(.vertical, target, 3), .to = target, }; @@ -485,7 +485,7 @@ test "vertical closing drops frozen content over canonical cells" { .phase = .closing, .effect = .vertical, .from = old_box, - .to = panel_animation.closingBox(.vertical, old_box), + .to = layout.closingBox(.vertical, old_box), }; const first = try compose(arena.allocator(), &surface, &.{track}, null); @@ -530,13 +530,13 @@ test "closing content paints after opening content regardless of track order" { .phase = .closing, .effect = .vertical, .from = box, - .to = panel_animation.closingBox(.vertical, box), + .to = layout.closingBox(.vertical, box), }; const opening: Track = .{ .serial = 2, .phase = .opening, .effect = .ascii, - .frame = panel_animation.Transition.ascii.frames() - 1, + .frame = layout.Transition.ascii.frames() - 1, .from = box, .to = box, }; @@ -691,8 +691,8 @@ test "active panel transition hides only its own native attachment" { const tracks = [_]Track{ .{ .serial = 41, .effect = .slide, .frame = 0 }, .{ .serial = 42, .effect = .ascii, .frame = 0 }, - .{ .serial = 43, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() - 1 }, - .{ .serial = 44, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() }, + .{ .serial = 43, .effect = .zoom, .frame = layout.Transition.zoom.frames() - 1 }, + .{ .serial = 44, .effect = .zoom, .frame = layout.Transition.zoom.frames() }, .{ .serial = 45, .phase = .opening, .effect = .vertical, .frame = 0 }, }; diff --git a/src/tty/tty.zig b/src/tty/tty.zig index fb0a5b8e..1f11c535 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1,12 +1,4 @@ -//! The terminal shell: owns the event loop and all IO. Translates vaxis -//! events into core events, performs the core's effects (fork ptys, write -//! them, resize them), and hands the core's Surface to vaxis cell-for-cell — -//! the canonical interface rendered with no interpretation. -//! -//! It also holds the OTHER loop a terminal can run: an attached frontend, -//! which has a socket where its core would be and performs no machine-local -//! effect whatsoever (see `Attach`). The `Attach` builtin turns the first into -//! the second in place, without giving up the terminal. +const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; @@ -15,19 +7,11 @@ const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); -const shell_bin = @import("../shell_bin.zig"); -const message = @import("../message.zig"); +const message = pardes.Pardes.Message; const file_watch = @import("../file_watch.zig"); -const user_config = @import("../user_config.zig"); const selection_pipe = @import("../selection_pipe.zig"); -const nested = @import("../nested.zig"); -const fuse = @import("../fuse.zig"); -const fs_service = @import("../fs_service.zig"); +const ninep_io = @import("../9p_io.zig"); const panel_compositor = @import("panel_compositor.zig"); -const host_api = @import("../host.zig"); -// The other half of `--detach`, and the reason this file has an attached loop -// at all: the frontend side of a detached session is a terminal and a socket, -// and this file is already the one that owns a terminal. const detached_client = @import("../detached/client.zig"); const detached_server = @import("../detached/server.zig"); const wire = @import("../detached/wire.zig"); @@ -35,7 +19,6 @@ const host_io = @import("../host_io.zig"); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); pub const Command = struct { @@ -44,54 +27,231 @@ pub const Command = struct { quit, tick, key_press: vaxis.Key, - pty_read: struct { id: usize, bytes: []u8 }, + pty_read: struct { id: usize, gen: u32, bytes: []u8 }, pty_eof: struct { id: usize, gen: u32 }, winsize: vaxis.Winsize, mouse: vaxis.Mouse, - /// Focus reporting is part of vaxis's mouse mode (DEC 1004). A TTY - /// cannot report a literal pointer crossing its character grid, so - /// losing terminal focus is its only reliable pointer-leave signal. focus_in, focus_out, paste: []const u8, - /// The bracketed-paste brackets. vaxis posts them ONLY because this - /// union declares fields with these exact names — its Loop gates every - /// event on `@hasField` — and the pasted bytes themselves arrive - /// BETWEEN them as ordinary key presses, which the loop accumulates - /// into one `.paste` above instead of running as commands. paste_start, paste_end, - /// a language query finished on a worker; rows are lsp-domain-owned - lsp_done: struct { id: u32, rows: []u8 }, - /// a language SERVER changed state (spawned, indexing, exited) — the - /// client's reader thread narrates and this lands it on the message - /// row; text is lsp-domain-owned + lsp_done: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, - /// a selection-filter worker finished; every stdout is gpa-owned pipe_done: selection_pipe.Response, - /// something happened in a watched directory (see watchFiles) files_changed, - /// a pardes launched inside this one sent us a builtin command line - /// (see lookServer); gpa-owned, like pty_read - command: []u8, - /// `--fs`: the /dev/fuse descriptor has requests on it. Carries - /// nothing and is applied as a no-op — its whole job is to end the - /// blocking `nextEvent`, because the drain itself lives in pollFrame - /// beside the file-watch reload. Same shape and same reason as - /// `files_changed` above, and posted from two places: the poll thread - /// when the kernel makes the descriptor readable, and pollFrame itself - /// when a batch hit its cap with requests still pending. fs_ready, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); -/// The shared snapshot/worker pair every native shell uses. This file used to -/// carry its own `LspJob` and gui.zig carried a copy of it; the copies said so. -const lsp_host = @import("../lsp_host.zig"); +fn startInput(loop: *Loop, cache: *vaxis.GraphemeCache) !void { + if (comptime builtin.os.tag == .windows) return loop.start(); + if (loop.thread != null) return; + loop.thread = try loop.io.concurrent(inputThread, .{ loop, cache }); +} + +fn stopInput(loop: *Loop) void { + if (comptime builtin.os.tag == .windows) return loop.stop(); + if (loop.thread) |*thread| { + thread.cancel(loop.io); + loop.thread = null; + } +} + +fn inputThread(loop: *Loop, cache: *vaxis.GraphemeCache) void { + inputReader(loop, loop.tty, cache); +} + +fn inputReader(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) void { + readInput(loop, tty, cache) catch |err| { + if (err == error.Canceled) return; + std.log.err("terminal input: {s}", .{@errorName(err)}); + }; + loop.postEvent(.quit) catch {}; +} + +fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { + try loop.postEvent(.{ .winsize = try tty.getWinsize() }); + var parser: vaxis.Parser = .{}; + var buf: [1024]u8 = undefined; + var carried: usize = 0; + while (!loop.should_quit) { + if (carried == buf.len) return error.InputSequenceTooLong; + const received = try tty.read(buf[carried..]); + if (received == 0) return; + const end = carried + received; + var parse_end = end; + var lead = end; + while (lead > 0 and buf[lead - 1] & 0xc0 == 0x80) lead -= 1; + if (lead > 0) { + const scalar_len = std.unicode.utf8ByteSequenceLength(buf[lead - 1]) catch 1; + if (scalar_len > end - (lead - 1)) parse_end = lead - 1; + } + var consumed: usize = 0; + while (consumed < parse_end) { + const result = try parser.parse(buf[consumed..parse_end], loop.vaxis.opts.system_clipboard_allocator); + if (result.n == 0) break; + consumed += result.n; + if (result.event) |event| + vaxis.loop.handleEventGeneric(loop, loop.vaxis, cache, @TypeOf(Command.value), event, loop.vaxis.opts.system_clipboard_allocator) catch |err| { + if (event == .paste) if (loop.vaxis.opts.system_clipboard_allocator) |gpa| gpa.free(@constCast(event.paste)); + return err; + }; + } + carried = end - consumed; + std.mem.copyForwards(u8, buf[0..carried], buf[consumed..end]); + } +} + +test "terminal input preserves fragmented keys queries paste and text after EOF" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{ .system_clipboard_allocator = gpa }); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + vx.queries_done.store(false, .unordered); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: []const []const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn read(self: *@This(), buf: []u8) !usize { + if (self.next == self.parts.len) return 0; + const part = self.parts[self.next]; + self.next += 1; + @memcpy(buf[0..part.len], part); + return part.len; + } + }; + var reader: Reader = .{ .parts = &.{ + "plain \x1b[?62;", "4c\x1b[", "A\x1b[200", "~caf\xc3", "\xa9 \xe7", "\x95", + "\x8c \xf0\x9f", "\x98\x80", "\x1b[201", "~\x1b]52;c;Y2", "xpcA==\x07tail", "\x1b", + } }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(vx.queries_done.load(.unordered)); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + var resized = false; + var up = false; + var in_paste = false; + var pasted = false; + var clipboard = false; + var escape = false; + while (try loop.tryEvent()) |event| switch (event) { + .winsize => |size| { + try std.testing.expect(!resized); + resized = true; + try std.testing.expectEqual(@as(u16, 80), size.cols); + }, + .key_press => |key| { + try std.testing.expect(resized); + if (key.codepoint == vaxis.Key.up) { + up = true; + } else if (key.codepoint == vaxis.Key.escape) { + escape = true; + } else if (key.text) |bytes| { + if (in_paste) try std.testing.expect(up); + try text.appendSlice(gpa, bytes); + } else return error.UnexpectedInputKey; + }, + .paste_start => { + try std.testing.expect(up and !in_paste); + in_paste = true; + }, + .paste_end => { + try std.testing.expect(in_paste); + in_paste = false; + pasted = true; + }, + .paste => |bytes| { + defer gpa.free(@constCast(bytes)); + try std.testing.expect(pasted and !in_paste); + try std.testing.expectEqualStrings("clip", bytes); + clipboard = true; + }, + else => return error.UnexpectedInputEvent, + }; + try std.testing.expectEqualStrings("plain café 界 😀tail", text.items); + try std.testing.expect(resized and up and pasted and clipboard and escape); +} + +test "terminal input cancellation joins blocked reads and queued EOF" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{}); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + const Reader = struct { + file: std.Io.File, + eof: bool, + entered: std.Io.Event = .unset, + release: std.Io.Event = .unset, + returned: std.Io.Event = .unset, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn read(self: *@This(), buf: []u8) !usize { + self.entered.set(std.testing.io); + if (self.eof) { + try self.release.wait(std.testing.io); + self.returned.set(std.testing.io); + return 0; + } + return self.file.readStreaming(std.testing.io, &.{buf}); + } + fn run(loop: *Loop, reader: *@This(), cache: *vaxis.GraphemeCache) void { + inputReader(loop, reader, cache); + } + }; + const Case = enum { blocked_read, cancel_eof, deliver_eof }; + for (std.enums.values(Case)) |case| { + const eof = case != .blocked_read; + var fds: [2]c_int = undefined; + if (libc.pipe(&fds) != 0) return error.PipeFailed; + defer _ = libc.close(fds[0]); + defer _ = libc.close(fds[1]); + var reader: Reader = .{ .file = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }, .eof = eof }; + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + loop.thread = try io.concurrent(Reader.run, .{ &loop, &reader, &cache }); + defer stopInput(&loop); + try std.testing.expectEqual(.winsize, std.meta.activeTag(try loop.nextEvent())); + try reader.entered.wait(io); + if (eof) { + for (0..512) |_| try loop.postEvent(.nop); + reader.release.set(io); + try reader.returned.wait(io); + } + if (case == .deliver_eof) { + try std.testing.expectEqual(.nop, std.meta.activeTag(try loop.nextEvent())); + loop.thread.?.await(io); + } + stopInput(&loop); + try std.testing.expect(loop.thread == null); + var count: usize = 0; + while (try loop.tryEvent()) |event| { + const expected: std.meta.Tag(@TypeOf(Command.value)) = if (case == .deliver_eof and count == 511) .quit else .nop; + try std.testing.expectEqual(expected, std.meta.activeTag(event)); + count += 1; + } + try std.testing.expectEqual(@as(usize, if (eof) 512 else 0), count); + } +} -/// The pipe in-flight set moved to `selection_pipe.Tasks`, beside the Job it -/// tracks: gui.zig carried this same table verbatim. const PipeTask = selection_pipe.Tasks.Task; const PipeTasks = selection_pipe.Tasks; @@ -125,9 +285,6 @@ fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u } }); } -/// Translate backend-neutral source/destination pixels into Kitty's source -/// crop plus cell-sized placement. Kitty can specify only one scaled axis -/// without distorting the image; the terminal derives the other axis. fn kittyPlacement( place: pardes.ImagePlace, screen_cols: u16, @@ -151,13 +308,6 @@ fn kittyPlacement( place.native.pan_y, ) orelse return null; - // Kitty has a top-left pixel offset but no destination bottom clip. - // Its missing c/r axis is rounded up to whole terminal cells, so a - // clipped fragment shorter than one row cannot be represented without - // painting the following theme gap. Conservatively keep only whole - // rows contained by geometry.dst and trim the source crop to the same - // scale. Cached page pixels remain unchanged; an unrepresentable tail - // is simply left as theme background. const safe_rows_u32 = geometry.dst.h / cell_h; if (safe_rows_u32 == 0) return null; const safe_pixel_h = safe_rows_u32 * cell_h; @@ -202,8 +352,6 @@ fn kittyPlacement( } if (declared_rows == 0 or declared_rows > safe_rows_u32) return null; - // Every Kitty protocol field is u16. Reject an attachment which the - // wire format cannot represent instead of truncating it. const src_x = std.math.cast(u16, geometry.src.x) orelse return null; const src_y = std.math.cast(u16, geometry.src.y) orelse return null; const src_w = std.math.cast(u16, geometry.src.w) orelse return null; @@ -287,8 +435,6 @@ test "Kitty PDF fragments never declare pixels beyond their clipped bottom" { try std.testing.expect(@as(u32, height_fragment.options.size.?.rows.?) * 16 <= height.native.geometry.?.dst.h); - // There is no honest APC for less than one physical row: omitting it is - // preferable to painting three pixels of the following theme gap. height.native.geometry.?.dst.h = 13; try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null); } @@ -304,8 +450,6 @@ test "host watch closes initial race and reloads rename-over PDF while idle" { const replacement = try pardes.pdf.makeNoOutlineTestPdf(gpa); defer gpa.free(replacement); try tmp.dir.writeFile(io, .{ .sub_path = "live.pdf", .data = original }); - // Prepare both editor-style temporary inodes before marking the directory - // so the only post-arm wake below is the second rename. try tmp.dir.writeFile(io, .{ .sub_path = "initial.pdf", .data = replacement }); try tmp.dir.writeFile(io, .{ .sub_path = "live-replacement.pdf", .data = original }); var path_buf: [256]u8 = undefined; @@ -328,15 +472,12 @@ test "host watch closes initial race and reloads rename-over PDF while idle" { defer core.deinit(); try std.testing.expectEqual(@as(usize, 3), core.panes[0].?.pdf.?.page_count); - // The core opened the three-page inode, but the host has not drained its - // watch effect yet. Replace it now: install-then-reconcile must discover - // the one-page document even though no source existed for this first edge. try tmp.dir.rename("initial.pdf", tmp.dir, "live.pdf", io); var armed = false; while (core.nextEffect()) |effect| switch (effect) { .watch => |watch| if (watch.pane == 0 and watch.on) { armed = true; - try std.testing.expect(!file_watch.applyEffect(core, io, gpa, fd, &watches, 0, true)); + try std.testing.expect(!file_watch.applyEffect(core, io, fd, &watches, 0, true, watch.mode)); }, else => {}, }; @@ -346,8 +487,6 @@ test "host watch closes initial race and reloads rename-over PDF while idle" { try tmp.dir.rename("live-replacement.pdf", tmp.dir, "live.pdf", io); try std.testing.expect(file_watch.drain(fd)); - // This is the same pass the watcher thread schedules; no key, mouse, or - // synthetic core file_changed event participates in the transaction. try std.testing.expect(!file_watch.reloadChanged(core, io, gpa, &watches)); const pane = core.panes[0].?; try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count); @@ -382,11 +521,6 @@ fn surfaceHasKittyKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) const PdfWheelTarget = struct { pane: usize, page: usize }; -/// A native PDF's page geometry is invalid between `setPdfPage` and the next -/// render. Remember the page under a vertical wheel press so the input batch -/// can stop exactly when that press crosses a page boundary. The following -/// queued wheel report then sees the newly rastered page instead of treating -/// missing geometry as another page-wise fallback. fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWheelTarget { if (comptime !pardes.pdf_enabled) return null; if (!core.native_images or mouse.type != .press or @@ -405,37 +539,17 @@ fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWhee return null; } -/// `attach` is `--attach[=]`: empty means "the session there is" (see -/// `detached_client.resolve`). It is a parameter rather than an `Options` field -/// because it says nothing to the core — this process does not have one when -/// it is set. pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void { const io = init.io; const gpa = init.gpa; - // `--attach` only, and registered HERE — before the terminal is opened — - // for the LIFO: it must run after every deferred restore below. Why a - // frontend stopped is discovered deep inside the loop while the alt screen - // is still up, and anything written there is erased by the switch back to - // the main screen, which is the one screen a user would look at. var attach_end: AttachEnd = .none; defer attach_end.report(io); - // ...and resolved before the terminal too, for the same reason turned the - // other way: "no session called work" is a launch that never started, and - // flashing the alt screen up and straight back down to say so is worse - // than never entering it. Only the QUESTION is asked here, and asked - // through client.zig because the SDL frontend asks the identical one: - // `detached_client.attempt` asks it again with the socket in hand, and a - // session that ends between the two answers is a `.no_session` from there - // rather than a disagreement between two spellings of the same scan. var name_buf: [detached_server.path_max]u8 = undefined; var attach_name: []const u8 = &.{}; if (attach) |requested| switch (detached_client.resolve(&name_buf, requested)) { .name => |resolved| attach_name = resolved, - // Two ends for the union's one arm, because the advice differs: a name - // that resolved to nothing is a typo to correct, and no name at all is - // a session to start. .none => { attach_end = if (requested.len != 0) .{ .no_session = requested } else .nothing_detached; return; @@ -446,16 +560,6 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v }, }; - // SIGWINCH must never run vaxis's signal handler: it posts the winsize - // event through std.Io.Mutex/Condition, and when the signal lands on a - // thread blocked inside an Io.Threaded syscall region (pty readers in - // read(2), the main thread parked in queue.pop) a contended lock re-enters - // the Io machinery and Syscall.start hits `unreachable` — panic, then the - // panic-time terminal restore used to write through the same Io and - // recurse until stack overflow. Reproduced by resizing the outer terminal - // (e.g. a font-size change) while shells run. Block it here, before any - // thread exists (threads inherit the mask, so vaxis's handler never - // fires), and take it synchronously on the sigwait thread below instead. var winch_set = posix.sigemptyset(); posix.sigaddset(&winch_set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.BLOCK, &winch_set, null); @@ -467,37 +571,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v defer vx.deinit(gpa, tty.writer()); try vx.enterAltScreen(tty.writer()); defer vx.exitAltScreen(tty.writer()) catch {}; - // requests 1002;1003;1004;1006 (cell-coordinate SGR; called pre-query, so - // vaxis never upgrades to 1016 pixel mode). Note: ghostty's GTK apprt drops - // middle press+release BEFORE mouse reporting when the desktop sets - // gtk-enable-primary-paste=false — no mode we request can surface middle - // clicks there (see test/snapshots/ghostty-mid.snap). try vx.setMouseMode(tty.writer(), true); - // Bracketed paste. Without it a paste into pardes-in-a-terminal is just a - // flood of key presses: plausible-looking in insert mode, and in normal - // mode every pasted character runs as a command. With it the terminal - // wraps the bytes in \x1b[200~ / \x1b[201~ and the loop coalesces them. - // No defer to switch it back off, for the same reason the mouse modes - // above have none: setBracketedPaste records state.bracketed_paste, and - // vaxis's resetState — reached from the `defer vx.deinit` above, while the - // tty is still open — sends the disable off that flag. try vx.setBracketedPaste(tty.writer(), true); - // `--attach`: this process has a terminal and NO core. Everything above is - // the terminal, which an attached frontend needs exactly as much as a whole - // session does; everything below is the core, which lives in the detached - // process. The branch is here so both leave by the same door — an attach - // has to restore cooked mode, the main screen and the mouse the way an - // ordinary exit does, and sharing the deferred teardown is the only way to - // guarantee that instead of asserting it. if (attach != null) { attach_end = attachSession(init, attach_name, &tty, &vx); return; } - // Everything below is the TERMINAL's, shared by the two loops that can draw - // on it: the local session's and, after an `Attach`, an attached one's. The - // core and everything that only a core needs is `localSession`'s. var kitty_handles = std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image).init(gpa); defer { clearNativeImages(&kitty_handles, &vx, &tty); @@ -506,30 +587,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v var paste_buf: std.Io.Writer.Allocating = .init(gpa); defer paste_buf.deinit(); var loop: Loop = .init(io, &tty, &vx); + var input_cache: vaxis.GraphemeCache = .{}; - // How a connected client leaves `localSession`, and the whole of the - // handover: it is set only once `detached_client.attempt` has come back - // GREETED, so every way of failing to attach leaves the local session - // running with this still null. By the time `localSession` returns non-null - // its scope has ended, which means every pane shell, watch, worker and - // mount of the local session is already away — the teardown is a scope - // exit rather than a second copy of the same defers. var attached: ?detached_client.Client = null; - // The loop is STARTED inside `localSession`, because the initial forkpty - // has to happen before any thread of ours exists, and stopped by whichever - // loop was the last to use it: `localSession` itself when it is exiting for - // good, and this defer when it handed the terminal on. Registered before - // the call so LIFO puts the drain after `loop.stop()` — vaxis's reader must - // be joined before the queue is emptied, or a late post lands in a queue - // nobody drains again and its bytes leak. defer if (attached != null) { - loop.stop(); + stopInput(&loop); drainAttachedQueue(&loop, gpa); }; - try localSession(init, opts, &tty, &vx, &loop, &kitty_handles, &paste_buf, &attached); + try localSession(init, opts, &tty, &vx, &loop, &input_cache, &kitty_handles, &paste_buf, &attached); if (attached) |*client| { - // `detach` and not `deinit`: seven bytes that turn "the peer vanished" - // into "the peer left" in the session's log. defer client.detach(); var a: Attach = .{ .gpa = gpa, @@ -537,43 +603,19 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v .loop = &loop, .vx = &vx, .tty = &tty, - // The `Shell`'s own paste buffer. Nothing is in flight in it: a - // bracketed burst cannot span the switch, because the builtin that - // caused the switch was a keystroke, and every `paste_start` clears - // it before it fills. .paste_buf = &paste_buf, - // `caps_pending` deliberately keeps its default rather than - // inheriting the local session's: `enableDetectedFeatures` is - // idempotent mode-setting, and the `queueRefresh` it pairs with is - // wanted anyway on a screen that just changed which core draws it. - // `session` keeps its empty default for a reason worth stating: the - // name the `Attach` word carried lived in the core's own - // `attach_buf`, and that core is deinited by the time this runs. A - // later `Detach` therefore says "that session" rather than naming - // it, which is also all a bare `Attach` ever said. }; attach_end = attachLoop(&a); } } -/// The session that lives in THIS process: the core, its pane shells, its -/// watches, its acme filesystem, its workers and the loop that pumps them. A -/// function of its own rather than the tail of `run` because that makes its -/// teardown a SCOPE EXIT instead of a second copy of the same nine defers — -/// and the `Attach` builtin needs exactly that teardown, in exactly that LIFO -/// order, before an attached loop may draw on the same terminal. The hand-copy -/// it replaces had 29 lines identical to these defers and stated its ordering -/// contract in prose, so nothing but a reader could enforce it. -/// -/// The terminal itself is NOT here: `tty`, `vx`, the alt screen, the `Loop`, -/// the paste buffer and the kitty placements outlive this scope because the -/// attached loop keeps drawing on them. fn localSession( init: std.process.Init, opts: pardes.Options, tty: *vaxis.Tty, vx: *vaxis.Vaxis, loop: *Loop, + input_cache: *vaxis.GraphemeCache, kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), paste_buf: *std.Io.Writer.Allocating, attached: *?detached_client.Client, @@ -581,13 +623,12 @@ fn localSession( const io = init.io; const gpa = init.gpa; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.deinit(); var options = opts; options.image_allocator = allocs.image; options.pdf_allocator = allocs.pdf; options.tree_sitter_allocator = allocs.tree_sitter; - // the 16 MiB static buffer behind every per-frame Surface options.frame_allocator = allocs.frame; pardes.image.start(io, allocs.image); @@ -600,37 +641,20 @@ fn localSession( } var core = if (options.load_path) |lp| blk: { - const bytes = try look.readFile(gpa, lp); + const bytes = try filesystem.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes); } else try pardes.Pardes.init(allocs.pardes, options); defer core.deinit(); - // PATH, the bash banner and the prompt rc files, in the one order that - // works. Children borrow only these stable in-struct path buffers. - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); defer frame_arena.deinit(); - // `--fs`: mount before the initial spawns, because those shells are the - // ones that need PARDES_FS in their environment, and before the first - // frame, because a script racing startup must find panes that are already - // there. Also before any thread of ours exists — the mount forks the - // setuid fusermount3 helper, and forking from a multithreaded process is - // the hazard this whole region is ordered around. Null covers both "no - // --fs" and "--fs but the mount failed"; the second is reported on a - // message row inside `start` and the session runs on regardless. - // - // The teardown answers every held request, aborts the connection, - // unmounts and removes `/`. The PARENT (`.../pardes`) stays, - // like nested.zig's socket directory: another session may be living in it, - // and rmdir of a shared directory is not ours to attempt. - var fs = fs_service.start(gpa, core); - // Covers the error paths and the handover; the ordinary exit unmounts at - // the END OF THE LOOP instead, see there. - defer if (fs) |f| f.deinit(); + var fs = ninep_io.start(gpa, core); + defer if (fs) |f| f.deinit(gpa); var sh: Shell = .{ .io = io, @@ -644,57 +668,26 @@ fn localSession( .kitty = kitty_handles, .frame = &frame_arena, .paste_buf = paste_buf, - // One watcher for every watched pane, opened here — before any thread - // exists — so the pre-loop effect drain below can already mark the file - // a positional path argument opened. `false`: this host parks a thread - // in it rather than polling it. -1 where there is no watcher to make: - // watchPane goes quiet and the core simply never gets a file_changed. .inotify_fd = file_watch.init(false), .fs = fs, }; - // The protocol client's reader threads narrate server state through this - // sink from the moment it is set; posting is safe because the loop queue - // outlives them all — and it is UNSET first thing in the defer below, - // under the sink's own lock, so no reader can be mid-post when the queue - // starts draining for teardown. pardes.lsp.setStatusSink(&sh, lspStatusSink); defer { pardes.lsp.setStatusSink(null, null); - // reap the reader tasks (cancel interrupts a blocked read) before - // closing the masters — the runtime joins those threads on exit and a - // reader stuck in read(2) would hang the process — then drain the - // queue: leftover events own gpa bytes and would dump as leaks. for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); - // THE ONE DELTA BETWEEN THE TWO WAYS OUT OF THIS SCOPE, and the - // reason it is a condition rather than a comment: an exit leaves - // the closed master's SIGHUP to kill the shell and the kernel to - // collect it, which server.zig's `harvest` calls "the one - // bookkeeping cost a long-lived process pays that a frontend, - // which exits, never did". A handover does not exit — this process - // goes on drawing somebody else's session for hours — so a skipped - // `waitpid` is a zombie per pane held for all of it. SIGKILL and - // not the hangup alone because the wait has to be BOUNDED: the - // master is gone, so there is nothing left for the shell to print - // and no graceful exit left to give it, and SIGHUP is a signal it - // may decline while SIGKILL is not. if (attached.* != null) { _ = libc.kill(pt.pid, posix.SIG.KILL); _ = libc.waitpid(pt.pid, null, 0); } slot.* = null; }; - // join the query worker BEFORE the drain below, or its late post - // lands in a queue nobody empties again and the rows leak if (sh.lsp_task) |*t| { - t.cancel(io) catch {}; + t.future.cancel(io) catch {}; sh.lsp_task = null; } sh.pipe_tasks.cancelAll(io); - // same contract as the pty readers: the watcher has to be off the - // descriptor before it is closed. `stop` is what releases a kqueue wait - // (macos); `cancel` is what interrupts the blocking read (linux). file_watch.stop(sh.inotify_fd); if (sh.watch_task) |*t| { t.cancel(io) catch {}; @@ -706,9 +699,8 @@ fn localSession( } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), - .command => |line| gpa.free(line), .paste => |b| gpa.free(@constCast(b)), - .lsp_done => |d| allocs.lsp.free(d.rows), + .lsp_done => |d| if (d.rows) |rows| allocs.lsp.free(rows), .lsp_status => |text| allocs.lsp.free(text), .pipe_done => |response_value| { var response = response_value; @@ -719,114 +711,44 @@ fn localSession( } const host = sh.host(); - // The socket a pardes launched inside this one connects to (nested.zig). - // Declared AFTER the drain above so its teardown runs BEFORE it — the - // listener thread must be out of the way before the queue is emptied. - // --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. - const sock_fd: c_int = if (options.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. `pump` installs - // the host on every pass; this drain runs outside it, so install it here. core.host = host; while (core.nextEffect()) |effect| core.perform(effect); - try loop.start(); - // ...and stopped here only when this scope is the last user of the - // terminal. A handover leaves vaxis's reader running for the attached loop, - // which is drawing on the same tty a moment later; `run` stops it then. - defer if (attached.* == null) loop.stop(); - // resize watcher: plain detached thread (not io.concurrent — teardown - // joins those, and sigwait never returns); dies with the process + try startInput(loop, input_cache); + defer if (attached.* == null) stopInput(loop); (try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach(); - // ...and the nested-instance listener, detached for the same reason: a - // blocking accept(2) never returns either, so an io.concurrent task would - // hang the teardown that joins it. - if (sock_fd >= 0) (try std.Thread.spawn(.{}, lookServer, .{ gpa, sock_fd, loop })).detach(); - // ...and the /dev/fuse poller, which is the same kind of thread again: it - // waits for POLLIN and posts, never touching the core or the descriptor's - // data. Joined by `Fs.deinit` rather than detached, because unlike accept4 - // it CAN be woken — fuse.zig gives it a control pipe for exactly that. - fs_service.wake(fs, loop, wakeFs); - // Capability handshake — SEND the probes, do not wait on them. This was - // queryTerminal(2ms), which blocks on a futex until DA1 comes back. The - // number has to beat one terminal round trip: a local terminal answers in - // microseconds, `ssh localhost` in under 1ms, and any real link never. - // Measured over sshd on :22 with the replies delayed to model the wire, - // 2ms already loses at 5ms RTT and everything above. - // - // Losing it is worse than never probing, because vaxis splits detect from - // enable and only detect respects the deadline. queryTerminal sets - // queries_done the moment the futex times out, and the two replies vaxis - // gates on that flag — explicit width and scaled text, both answered as a - // cursor-position report — stop being recognised as probe replies and are - // handed to US as shift-F3/alt-F3 keypresses. The replies it does NOT - // gate (mode 2027, kitty keyboard/graphics, sgr-pixels) keep landing and - // keep mutating vx.caps from the reader thread, long after - // enableDetectedFeatures ran and declined to switch those modes on. So - // over ssh the terminal sat in its default modes while caps claimed - // otherwise — kitty keyboard was never actually pushed, ever. Raising the - // timeout only moves the link speed at which that happens. - // - // Resolve it on the loop instead. DA1 is last in the probe string and - // terminals answer in order, so when vaxis's reader flips queries_done - // every earlier reply is already applied — no window left to miss at any - // latency, and the enable lands before the next frame (see caps_pending - // in pollFrame). A terminal that never answers keeps the defaults, which - // is what the 2ms timeout produced anyway, and with no caps - // enableDetectedFeatures writes no bytes — the snapshot goldens, where - // nothing ever answers, do not move. Startup gets 2ms faster, not slower. - // - // ponytail: nothing wakes the loop for DA1 alone. In practice the reply - // burst carries the mode-2048 size report too, which posts a winsize and - // turns the loop; a terminal idle from boot that lands DA1 between two - // parks keeps the defaults until the user's first keystroke (decoded - // legacy, which vaxis handles). Ceiling accepted because nothing in the - // render path reads the missing caps: pardes writes one codepoint per - // cell plus an explicit blank spacer under a wide glyph, and vaxis's - // Cell.width defaults to 1, so gwidth — the only consumer of - // caps.unicode — is never called. If that ever changes, wake the loop on - // vx.query_futex from a one-shot thread instead. + if (fs) |f| try f.wakeThread(loop, wakeFs); try vx.queryTerminalSend(tty.writer()); - // now threads are fine: start a reader task per pty sh.threads_ok = true; for (&sh.ptys, 0..) |*slot, id| if (slot.*) |*pt| { pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, sh.gens[id], loop }); }; - // ...and the one file watcher. Started here rather than lazily on the - // first watched pane because the fd already exists and an unwatched - // inotify instance just parks in read(2) — one thread for the process, - // however many panes come and go. if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, loop }) catch null; - // The core owns the loop ORDER (see Pardes.pump); the outer `while` stays - // here rather than being `core.run` for one reason: Restore swaps the - // whole core, and a core cannot replace itself from inside its own frame. frames: while (!core.quit) { try core.pump(host); - // Restore builtin: swap in a core rebuilt from the dump; the live - // shells die with their masters (readers canceled, gens bumped so - // their late eofs never touch the replay panes) if (core.takeRestore()) |rp| blk: { - const bytes = look.readFile(gpa, rp) catch break :blk; + const bytes = filesystem.readFile(gpa, rp) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; + }; defer gpa.free(bytes); - var o = core.opts; - o.cols = core.screen_w; - o.rows = core.screen_h; // pre-size: dump panes never greet - const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk; - for (&sh.ptys, 0..) |*slot, pid| if (slot.*) |*pt| { + const nc = core.restore(bytes) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; + }; + if (sh.lsp_task) |*task| { + task.future.cancel(io) catch {}; + sh.lsp_task = null; + } + sh.pipe_tasks.cancelAll(io); + for (&sh.gens) |*generation| generation.* +%= 1; + for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; - sh.gens[pid] +%= 1; }; - // the replay core's pane ids mean new things, and the dying core's - // `watch off` effects go into a queue nobody drains — drop the lot - // here. The new core emits its own `on`s as it builds its panes. for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( sh.inotify_fd, &sh.watches, @@ -838,33 +760,14 @@ fn localSession( _ = file_watch.applyThemeEffect(core, gpa, sh.inotify_fd, &sh.watches, 0, false, false); clearNativeImages(kitty_handles, vx, tty); nc.native_images = vx.caps.kitty_graphics; + if (fs) |f| f.reset(core); core.deinit(); core = nc; sh.core = nc; if (comptime pardes.pdf_enabled) { - // A restored core did not receive the terminal's earlier - // winsize event. Reapply both the grid and physical cells - // before its first PDF frame so pointer/pan geometry stays - // identical to placement. updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix); } } - // `Attach [name]`: hand this terminal to a detached session and stop - // being a session at all. CONNECTING IS NOT BEING ATTACHED, which is - // why this asks `detached_client.attempt` for a GREETED client and not - // for a socket: `Client.open` writes a hello and returns, and every way - // a session says no — `refuse .version` for a session built from other - // bytes, `.full`, `.quitting`, or a plain `quit` from one that ended in - // the same round — arrives after a successful `connect(2)`. A swap that - // trusted the connect would already have SIGKILLed every pane shell, - // unmounted the filesystem and freed every undo history by the time it - // decoded the refusal. - // - // So `attached` is set only with the welcome in hand, and until it is, - // NOTHING here has been touched: a failed `Attach` costs one message - // row and leaves every pane, every shell and every undo history where - // it was. The teardown that follows is this function's own defers, - // reached by leaving its scope. if (core.takeAttach()) |req| { var attempt = detached_client.attempt(gpa, req.name, core.screen_w, core.screen_h); switch (attempt) { @@ -879,27 +782,13 @@ fn localSession( } } } - // THE FILESYSTEM GOES FIRST, ahead of every deferred teardown below. - // `loop.stop()` joins a reader parked in `read(2)` on the tty, so it does - // not return until the next keystroke — and a session that has decided to - // exit must not spend that wait holding a mount nobody is serving. A - // client blocked on `/event` when the last pane is deleted through - // `ctl` then gets ENOTCONN at once instead of hanging until somebody - // touches the keyboard. A handover skips this and lets the deferred - // unmount do it, because it does not stop the loop and so never waits. if (fs) |f| { - f.deinit(); + f.deinit(gpa); fs = null; sh.fs = null; } } -/// Free every kitty placement this session put on the terminal and forget them. -/// THREE callers and one reason: the pixels live in the TERMINAL, not in the -/// core, so a core that is replaced (`Restore`), handed away (`Attach`) or -/// simply gone (the exit) leaves placements the next frames know nothing about -/// and would paint text around. The exit's own caller follows it with `deinit`; -/// the two mid-session ones keep the map's capacity for the frames after. fn clearNativeImages( kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), vx: *vaxis.Vaxis, @@ -910,119 +799,79 @@ fn clearNativeImages( kitty_handles.clearRetainingCapacity(); } -/// Empty the event queue an attached loop leaves behind, AFTER `loop.stop()` -/// has joined vaxis's reader. Two of its events own gpa bytes — a decoded paste -/// (a bracketed burst, or an OSC 52 reply to the session's `read_clipboard`) -/// and a nested-instance command line — and the thread that posts the second -/// cannot be joined at all, so the drain is not optional on either path out. fn drainAttachedQueue(loop: *Loop, gpa: std.mem.Allocator) void { while (loop.tryEvent() catch null) |ev| switch (ev) { .paste => |b| gpa.free(@constCast(b)), - .command => |line| gpa.free(line), else => {}, }; } -/// Everything the terminal shell owns and the core cannot: the ptys, the -/// inotify table, the worker futures, and the one thread allowed to touch -/// `tty.writer()`. This struct IS the `Host.ctx`. const Shell = struct { io: std.Io, gpa: std.mem.Allocator, lsp_gpa: std.mem.Allocator, - /// live core; Restore replaces it (see run) core: *pardes.Pardes, - prompt_rcs: *const shell_bin.PromptRcs, + prompt_rcs: *const host_io.Shell.PromptFiles, loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty, kitty: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), frame: *std.heap.ArenaAllocator, - /// Where a bracketed paste is assembled. It has to outlive one drain pass: - /// the burst arrives over as many passes as the terminal takes to write - /// it, and the markers are the only thing that says where it ends. paste_buf: *std.Io.Writer.Allocating, inotify_fd: c_int, - /// acme's control filesystem for this session, or null when `--fs` was not - /// given (or its mount failed). Owned by `run`, which mounts it before the - /// first fork and tears it down on every path out. - fs: ?*fuse.Fs = null, + fs: ?*ninep_io.Listener = null, ptys: [pardes.MAX_PANES]?Pty = @splat(null), - /// per-slot spawn generation: a reused pane id ignores the old shell's - /// late pty_eof (which would otherwise close the NEW pty on that slot) gens: [pardes.MAX_PANES]u32 = @splat(0), watches: file_watch.Table = @splat(null), watch_task: ?std.Io.Future(anyerror!void) = null, - /// the single in-flight language query (see the lsp method) - lsp_task: ?std.Io.Future(anyerror!void) = null, - /// Selection filters may overlap: a second submit supersedes the first in - /// core without synchronously canceling a possibly slow shell command. + lsp_task: ?host_io.Lsp.Task = null, pipe_tasks: PipeTasks = .{}, - /// false during the pre-loop drain: no worker exists to answer to yet threads_ok: bool = false, caps_pending: bool = true, check_files: bool = false, in_paste: bool = false, - /// the tracks the frame in flight was composed from - tracks: []const pardes.panel_animation.Track = &.{}, + tracks: []const pardes.layout.Track = &.{}, fn of(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } - fn host(s: *Shell) host_api.Host { + fn host(s: *Shell) host_io.Host { return .{ .ctx = s, .vtable = if (comptime pardes.isolated) &isolated_vtable else &vtable }; } - /// An ISOLATED build (`zig build run-isolated`): the terminal this draws on - /// and the keys it reads, and nothing else. Every other method stays null, - /// so the core answers it itself — the embedded source filesystem, the - /// in-process clipboard, silent ptys. Nothing is forked, nothing on disk is - /// opened or written, and no clipboard leaves the process. The option is - /// comptime, so the other vtable is not even built into that binary. - const isolated_vtable: host_api.Host.VTable = .{ - .pull_wait_input = waitInput, - .push_present = present, - .push_post_present = postPresent, + const isolated_vtable: host_io.Host.VTable = .{ + .wait_input = waitInput, + .present = present, + .post_present = postPresent, }; - const vtable: host_api.Host.VTable = .{ - .pull_wait_input = waitInput, - .push_present = present, - .push_post_present = postPresent, - .push_poll_frame = pollFrame, - .push_spawn = spawn, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .pull_lsp = lsp, - .pull_pipe = pipe, - .push_fs_reply = fsReply, + const vtable: host_io.Host.VTable = .{ + .wait_input = waitInput, + .present = present, + .post_present = postPresent, + .poll_frame = pollFrame, + .spawn = spawn, + .pty_write = ptyWrite, + .pty_resize = ptyResize, + .pty_signal = ptySignal, + .tty_taken = ttyTaken, + .write_file = writeFile, + .write_dump = writeDump, + .watch_file = watchFile, + .watch_theme = watchTheme, + .dump_themes = dumpThemes, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, + .lsp = lsp, + .pipe = pipe, }; - // ---- input ------------------------------------------------------------ - - /// Block for one event, then apply the whole pending batch. Everything - /// goes through `core.update` rather than `postEvent`: a pty chunk borrows - /// its bytes for the call, and mixing queued with immediate delivery would - /// reorder a keystroke against the output it caused. fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); var batch: usize = 0; if (timeout_ms == 0) { - // A failed read is a DEAD event source — the reader is gone and no - // event can ever arrive again, so nothing could set `quit` and the - // outer `while (!core.quit)` would spin at full speed. End the - // session, exactly as the pre-vtable `try loop.nextEvent()` did. const first = s.loop.nextEvent() catch { s.core.quit = true; return s.reloadWatched(); @@ -1030,20 +879,10 @@ const Shell = struct { batch = 1; if (s.apply(first)) return s.reloadWatched(); } else { - // Animating: the frame clock IS the wait, and the `.tick` that - // spends it is ours to post — `pump` cannot, because only this - // host knows when a real frame interval has passed. Anything that - // queues during the short sleep is drained below, in this pass. std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) catch {}; _ = s.apply(.tick); batch = 1; } - // Apply every queued INPUT event, then render ONCE — the gui shell - // drains SDL's queue the same way. Without this a wheel flick is fifty - // full render+repaint (and re-highlight) cycles instead of one. A - // paste in flight keeps draining WITHOUT rendering: a hundred thousand - // pasted characters are one edit. That cannot spin — the drain still - // ends the moment the queue runs dry. while (s.in_paste or batch < 64) { const ev = (s.loop.tryEvent() catch null) orelse break; batch += 1; @@ -1052,10 +891,6 @@ const Shell = struct { s.reloadWatched(); } - /// Apply one vaxis event. Returns true when the batch must end here: the - /// session is over, a pty chunk wants its own frame so progress paints as - /// it arrives, or a native PDF page crossing needs geometry this render - /// has not produced yet. fn apply(s: *Shell, event: @TypeOf(Command.value)) bool { const core = s.core; const tz_event = tracy.zone(@src(), "event"); @@ -1077,7 +912,8 @@ const Shell = struct { core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } }); }, .pty_read => |pr| { - core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); + if (s.gens[pr.id] == pr.gen) + core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); s.gpa.free(pr.bytes); return true; }, @@ -1115,40 +951,20 @@ const Shell = struct { }, .paste_end => { s.in_paste = false; - // ONE event for the whole paste — the core borrows the - // bytes for the call, exactly like the OSC 52 arm above. const pasted = s.paste_buf.written(); if (pasted.len > 0) core.update(.{ .paste = pasted }); s.paste_buf.clearRetainingCapacity(); }, - .command => |line| { - core.update(.{ .command = line }); - s.gpa.free(line); - }, - // Coalesced on purpose: a burst of writes (a formatter, a build, a - // `git checkout`) collapses into ONE pass below, so it cannot - // queue a reload — or an undo entry — per write. .files_changed => s.check_files = true, - // A wake and nothing more. The requests behind it are drained in - // pollFrame, where the file-watch reload also happens: both want to - // run once per frame with the whole batch already in, not once per - // event. So this arm has nothing to do — which is the point, since - // its only job was ending the blocking wait above. .fs_ready => {}, .lsp_done => |d| { core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); - s.lsp_gpa.free(d.rows); - // the worker is finished; join it so its future does not - // leak (same contract as pty_eof above) - if (s.lsp_task) |*t| { - t.await(s.io) catch {}; + if (d.rows) |rows| s.lsp_gpa.free(rows); + if (s.lsp_task) |*t| if (t.id == d.id) { + t.future.await(s.io) catch {}; s.lsp_task = null; - } + }; }, - // Server state on the transient message row — the same row, the - // same `message.stamp` clock, and the same shell-side ownership a - // completed save uses. The ACTIVE pane, because the state of a - // server is session news, not a fact about the pane that asked. .lsp_status => |text| { var mbuf: [256]u8 = undefined; core.setStatus(core.active, message.stamp(&mbuf, "lsp", text)); @@ -1176,58 +992,29 @@ const Shell = struct { s.loop.postEvent(.files_changed) catch {}; } - // ---- the frame --------------------------------------------------------- - fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); - // acme's filesystem, answered here for the same reason the file-watch - // reload is (see reloadWatched): one batch per frame, not one frame per - // request. First in the pass, so an edit a script just made through - // `body` is in the surface this frame composes rather than the next. - if (s.fs) |f| if (fs_service.drain(f.transport(), s.core).pending) { - // The batch hit its cap with requests still pending, and no ack has - // gone to the poll thread — so nothing else will wake us. Re-arm - // the loop ourselves. tryPostEvent, not postEvent: this runs on the - // only thread that drains the queue, so blocking on a full one - // would deadlock, and a full queue already holds a wake. + if (s.fs) |f| if (f.tick(s.core).pending) { _ = s.loop.tryPostEvent(.fs_ready) catch {}; }; - // live cwd for tags/look: cheap per-pane lookup, per frame. Whether the - // pane's tty still belongs to the prompt we forked is NOT polled here — - // it is a walk through /proc and nothing draws it, so the core pulls it - // through tty_taken instead, at the Exec that cares. for (&s.ptys, 0..) |*slot, id| if (slot.*) |pt| { - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd); + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd); }; - // The handshake landed (vaxis's reader flips queries_done on DA1, the - // last probe answered): put the terminal into the modes the caps now - // claim, before anything is drawn under them. Polled here rather than - // done where the replies arrive because that is the reader thread, and - // this is the only thread allowed to touch the tty writer. The repaint - // matters as much as the enable: earlier frames were drawn under the - // pre-handshake caps, and vaxis's shadow grid has to be re-established - // under the new ones or it keeps skipping cells it thinks are current. if (s.caps_pending and s.vx.queries_done.load(.unordered)) { s.caps_pending = false; s.vx.enableDetectedFeatures(s.tty.writer()) catch {}; - // The core was constructed before the asynchronous handshake. - // Advertise native pixels only now, after every reply preceding - // DA1 has updated the capability set. s.core.native_images = s.vx.caps.kitty_graphics; s.vx.queueRefresh(); } } - /// The canonical surface -> vaxis, cell for cell, with no interpretation. fn present(ctx: ?*anyopaque, canonical: *const pardes.Surface) void { const s = of(ctx); const vx = s.vx; s.tracks = canonical.panelTracks(); _ = s.frame.reset(.retain_capacity); - // compose only READS the canonical surface; the mutable pointer is so - // it can hand it straight back when no panel is mid-transition. const surface = panel_compositor.compose( s.frame.allocator(), @constCast(canonical), @@ -1238,16 +1025,8 @@ const Shell = struct { const win = vx.window(); paintCells(win, surface.cells, surface.cols, surface.rows); tz_cells.end(); - // Pixel attachments (kitty graphics): transmit once per pixel - // generation, then re-place every frame (placements aren't - // persistent). The map is keyed by pane lifetime + PDF page + pixel - // revision, so any number of short visible pages can coexist without - // aliasing a fixed terminal cache slot. for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; - // Keep the placement in Surface so the terminal-side cache stays - // live, but do not pin native pixels over a panel whose cells are - // currently moving through the TTY grid. if (panel_compositor.hidesAttachment(surface.panelTracks(), place.serial)) continue; if (comptime pardes.pdf_enabled) { if (!kittyImageRepresentable(place)) continue; @@ -1288,9 +1067,6 @@ const Shell = struct { } } } - // Toggling PETSCII or closing a pane removes its attachment from the - // Surface. Release the terminal-side image then, not merely when that - // numeric pane slot happens to be reused. while (true) { var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; var stale_len: usize = 0; @@ -1317,33 +1093,18 @@ const Shell = struct { tracy.frameMark(); } - // ---- pseudo-terminals --------------------------------------------------- - fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = of(ctx); - // the core reuses pane ids and there is no close effect: a deleted - // pane's shell lives in its slot until a respawn lands here — reap - // it (cancel joins the reader; its late eof is ignored by gen) if (s.ptys[pane]) |*old| { old.reader.cancel(s.io) catch {}; _ = libc.close(old.file.handle); s.ptys[pane] = null; } s.gens[pane] +%= 1; - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - if (cwd.len > 0 and cwd.len < cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs); + const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err); s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; - // report the pane's starting directory back to the core (tags). The - // slot needs no occupancy reset: nothing is remembered, and the next - // Exec asks about the shell that is there now. - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); if (s.threads_ok) { if (s.ptys[pane]) |*pt| { pt.reader = s.io.concurrent(readPty, .{ s.io, s.gpa, pt.file, @as(usize, pane), s.gens[pane], s.loop }) catch pt.reader; @@ -1364,47 +1125,27 @@ const Shell = struct { } } - /// `pty/ctl`'s `sig`. A pane with no pty of ours has nothing to signal, - /// which is the same silence `ptyWrite` above gives it. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = of(ctx); - if (s.ptys[pane]) |pt| look.signalTty(pt.pid, pt.file.handle, sig); + if (s.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig); } - /// Is a pane's tty still the prompt we forked? Lazy by construction — it - /// runs only where the core is about to type a command line, so the /proc - /// walk costs nothing on an ordinary frame. A pane with no pty of ours (a - /// document, a slot whose shell already died) is not a terminal a program - /// can be holding. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = of(ctx); const pt = s.ptys[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.file.handle); + return host_io.ttyTaken(pt.pid, pt.file.handle); } - // ---- the filesystem ----------------------------------------------------- - fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); - // SAY WHY, and tell the core it did not happen. A save that cannot be - // done is the one failure this program must never swallow: `saveFailed` - // puts the reason on the pane's message row and leaves the pane dirty, - // so the ` *` stays and `Del` cannot quietly take the edits. - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, "save", err); - // our own write is about to come back as a watch event: restamp from - // the bytes we just put there so it reads as "no change". Only when - // this IS the pane's watched file — a `Save ` must not - // silence a real change to the file the pane has open. if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, .pdf => {}, }; }; - // ...and say so on the pane's message row. AFTER the write, not beside - // it: every early return above is a save that did not happen and must - // not be reported as one. var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -1413,13 +1154,13 @@ const Shell = struct { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return s.core.reportError(0, "dump", err); + filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } - fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void { + fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { const s = of(ctx); - if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, &s.watches, pane, on)) + if (file_watch.applyEffect(s.core, s.io, s.inotify_fd, &s.watches, pane, on, mode)) s.loop.postEvent(.files_changed) catch {}; } @@ -1429,21 +1170,10 @@ const Shell = struct { s.loop.postEvent(.files_changed) catch {}; } - /// The core's answer to one filesystem request, handed straight back to the - /// transport that is holding it. `bytes` was resolved by `pardes.fsPayload` - /// inside `perform` and is borrowed only for this call — a body read is a - /// window onto the pane's live text, so there is nothing to copy and - /// nothing to free. `.again` needs no special case here: `Fs.reply` reads - /// the status and re-parks the request itself. - fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void { - const s = of(ctx); - if (s.fs) |f| f.reply(reply, bytes); - } - fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); const config_dir = s.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = pardes.config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { s.core.reportError(pane, "dump themes", err); return; }; @@ -1452,16 +1182,6 @@ const Shell = struct { s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } - // ---- the desktop -------------------------------------------------------- - // - // The three effects a detached session still puts on the wire - // (`wire.ServerTag` 0x10..), because each of them needs the display a - // human is actually looking at rather than the machine the core runs on. - // These are the `Host.ctx` shims and nothing else: the terminal work is - // `copyToClipboard`/`requestClipboard` below this struct, so an attached - // frontend serving `set_clipboard`/`read_clipboard` writes the same escape - // sequences from the same lines. - fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { const s = of(ctx); copyToClipboard(s.vx, s.tty, s.gpa, text); @@ -1476,96 +1196,171 @@ const Shell = struct { look.openLink(url); // desktop browser; no terminal in it, so Attach calls this one directly } - // ---- work that must leave the loop -------------------------------------- - - fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void { + fn lsp(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const s = of(ctx); - if (!s.threads_ok) return; // pre-loop drain: nothing to answer to yet - const job = lsp_host.snapshot(s.lsp_gpa, s.core, req) orelse return; - // ponytail: ONE query in flight, so one future slot. Replacing it - // cancels-then-joins the previous worker, which for a backend that - // ignores cancellation means waiting out a query the user already - // abandoned. Queries are milliseconds; make this a real pool the day a - // backend takes long enough to notice. + if (!s.threads_ok) { + s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return s.core.reportError(req.pane, "lsp", error.WorkersUnavailable); + } + const job = host_io.Lsp.snapshot(s.lsp_gpa, s.core, req) catch |err| { + s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return s.core.reportError(req.pane, "lsp", err); + }; if (s.lsp_task) |*old| { - old.cancel(s.io) catch {}; + old.future.cancel(s.io) catch {}; s.lsp_task = null; } - s.lsp_task = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch { + const future = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch |err| { job.free(s.lsp_gpa); - return; + s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return s.core.reportError(req.pane, "lsp", err); }; + s.lsp_task = .{ .id = req.id, .future = future }; } fn pipe(ctx: ?*anyopaque, id: u32) void { const s = of(ctx); - if (!s.threads_ok) return; + if (!s.threads_ok) { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", error.WorkersUnavailable); + } if (s.pipe_tasks.full()) { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } const view = s.core.pipeRequest(id) orelse return; - const job = selection_pipe.Job.copy(s.gpa, view) catch return; - const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch { + const job = selection_pipe.Job.copy(s.gpa, view) catch |err| { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", err); + }; + const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch |err| { job.deinit(s.gpa); - return; + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", err); }; - // `full()` was checked above, so this cannot fail; assert rather than - // discard, because a silently dropped task is a future nobody joins. std.debug.assert(s.pipe_tasks.add(.{ .id = id, .future = future })); } }; -/// Mirror a yank register out via OSC 52. Free functions over the terminal -/// they write to rather than `Shell` methods, because the clipboard is the one -/// piece of host work that survived the move into the daemon and BOTH loops in -/// this file perform it: `Shell` for its own core's `Effect.set_clipboard`, and -/// `Attach` for a detached session's `wire.ServerMsg.set_clipboard`. One -/// escape sequence written in two places is one that drifts. +test "TTY queued results reject old PTY generations and LSP request IDs" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer core.deinit(); + var shell: Shell = .{ + .io = std.testing.io, + .gpa = gpa, + .lsp_gpa = gpa, + .core = core, + .prompt_rcs = undefined, + .loop = undefined, + .vx = undefined, + .tty = undefined, + .kitty = undefined, + .frame = undefined, + .paste_buf = undefined, + .inotify_fd = -1, + }; + shell.gens[0] = 2; + _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 1, .bytes = try gpa.dupe(u8, "old session\n") } }); + _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 2, .bytes = try gpa.dupe(u8, "current session\n") } }); + const text = try pardes.panes.Terminal.screenTextAlloc(core.panes[0].?, gpa); + defer gpa.free(text); + try std.testing.expect(std.mem.indexOf(u8, text, "old session") == null); + try std.testing.expect(std.mem.indexOf(u8, text, "current session") != null); + + core.lspRequest(0, .status, ""); + const old_id = core.lsp_wait.?.id; + core.lspRequest(0, .status, ""); + const current_id = core.lsp_wait.?.id; + shell.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } }; + _ = shell.apply(.{ .lsp_done = .{ .id = old_id, .rows = try gpa.dupe(u8, "stale result\n") } }); + try std.testing.expect(shell.lsp_task != null); + try std.testing.expectEqual(current_id, shell.lsp_task.?.id); + try std.testing.expectEqual(current_id, core.lsp_wait.?.id); + _ = shell.apply(.{ .lsp_done = .{ .id = current_id, .rows = try gpa.dupe(u8, "") } }); + try std.testing.expect(shell.lsp_task == null); + try std.testing.expect(core.lsp_wait == null); +} + +test "TTY worker setup failures finish matching LSP and pipe requests" { + const gpa = std.testing.allocator; + var failing_vtable = std.testing.io.vtable.*; + failing_vtable.concurrent = std.Io.failingConcurrent; + const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; + for (0..2) |failure| { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + var failing = std.testing.FailingAllocator.init(gpa, .{ + .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), + }); + var shell: Shell = .{ + .io = failing_io, + .gpa = failing.allocator(), + .lsp_gpa = failing.allocator(), + .core = core, + .prompt_rcs = undefined, + .loop = undefined, + .vx = undefined, + .tty = undefined, + .kitty = undefined, + .frame = undefined, + .paste_buf = undefined, + .inotify_fd = -1, + .threads_ok = true, + }; + core.lspRequest(core.active, .status, ""); + const req: host_io.Lsp.Request = .{ + .id = core.lsp_wait.?.id, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }; + Shell.lsp(&shell, req); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + core.update(.{ .key = .{ .cp = '|' } }); + core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = core.pipe_wait.?.id; + Shell.pipe(&shell, pipe_id); + try std.testing.expect(core.pipe_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expect(shell.lsp_task == null); + try std.testing.expectEqual(@as(usize, 0), shell.pipe_tasks.len); + } +} + fn copyToClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty, gpa: std.mem.Allocator, text: []const u8) void { if (text.len == 0) return; vx.copyToSystemClipboard(tty.writer(), text, gpa) catch {}; } -/// ...and the other direction, OSC 52 read. The answer arrives on vaxis's -/// reader thread as an ordinary `.paste` event and reaches whoever asked — -/// the local core through `Shell`, the session through `ClientTag.event` — -/// by the same path an outer bracketed paste takes; this request is the only -/// wiring it needs. "The answer arrives" is the optimistic reading: a -/// clipboard READ is an exfiltration primitive and terminals treat it as one -/// (ghostty prompts by default, xterm ships it off, a multiplexer or ssh link -/// may eat it), and a refusal looks exactly like silence. So the core's -/// pending request is dropped by the next keystroke rather than pasting -/// minutes late, and `SPC p` in a locked-down terminal honestly does nothing. fn requestClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { vx.requestSystemClipboard(tty.writer()) catch {}; } -/// Answer a language query off the event loop and post the rows back. The -/// snapshot and the query body are `lsp_host`'s; the only part that is this -/// shell's is the vaxis event the rows travel home on. -fn lspWorker(allocator: std.mem.Allocator, job: *lsp_host.Job, loop: *Loop) anyerror!void { +fn lspWorker(allocator: std.mem.Allocator, job: *host_io.Lsp.Job, loop: *Loop) anyerror!void { var sink: LspRowSink = .{ .allocator = allocator, .loop = loop }; - lsp_host.work(allocator, job, &sink, LspRowSink.take); + host_io.Lsp.work(allocator, job, &sink, LspRowSink.take); } const LspRowSink = struct { allocator: std.mem.Allocator, loop: *Loop, - fn take(ctx: ?*anyopaque, id: u32, rows: []u8) void { + fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const s: *LspRowSink = @ptrCast(@alignCast(ctx orelse return)); - s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch s.allocator.free(rows); + s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch { + if (rows) |owned| s.allocator.free(owned); + }; } }; -/// The registered `lsp.setStatusSink` target, called from the protocol -/// client's READER threads. Only thread-safe, NON-BLOCKING things happen -/// here: a dupe with the concurrent lsp allocator and a TRY-post onto the -/// loop's queue. Never the blocking post — the sink lock is held around this -/// call, and a full queue plus a teardown spinning on that lock would be a -/// deadlock; server state is periodic news, so a dropped line is repriced -/// by the next one. fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const s: *Shell = @ptrCast(@alignCast(ctx orelse return)); const copy = s.lsp_gpa.dupe(u8, text) catch return; @@ -1584,22 +1379,7 @@ fn pipeWorker( loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa); } -/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the -/// events: the loop re-reads every watched pane anyway, so the only thing an -/// event carries that we need is THAT something happened, and parsing would -/// mean sharing the watch table with the thread that mutates it. Same shape as -/// readPty — block off the loop, hand the loop an event, keep the core a state -/// machine that never blocks. Going through std.Io.File rather than a raw -/// read(2) is what lets the teardown `cancel` interrupt it. -/// -/// ponytail: churn in a watched directory that never touches the watched file -/// still costs a wake and a re-read per event. The ceiling is one directory -/// per open file pane; filter by basename here if it ever shows up in a -/// profile. fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { - // A kqueue cannot be read, so there is no std.Io.File to wrap and no - // `cancel` to interrupt: this arm parks in kevent(2) and the teardown's - // `file_watch.stop` is what releases it. See file_watch.wait. if (comptime builtin.os.tag != .linux) { while (file_watch.wait(fd)) loop.postEvent(.files_changed) catch break; return; @@ -1616,32 +1396,6 @@ fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { } } -/// Block on the nested-instance socket and hand the loop each command line a -/// pardes started inside this one sends. Same shape as winchWatch: a plain -/// detached thread around a call that never returns, posting into the vaxis -/// loop from ordinary thread context. -/// -/// Nothing here is woken by teardown — close(2) does NOT release a thread -/// parked in accept4 on linux — so this dies with the process, exactly as -/// winchWatch dies inside sigwait. The window that leaves is one connection -/// accepted between the last drain and process exit posting into a queue whose -/// owner has returned; same shape and same bound as every other detached -/// worker here, and a self-pipe to close it would be more machinery than the -/// window is worth. -fn lookServer(gpa: std.mem.Allocator, fd: c_int, loop: *Loop) void { - var buf: [nested.max_line]u8 = undefined; - while (nested.acceptLine(fd, &buf)) |line| { - const owned = gpa.dupe(u8, line) catch continue; - loop.postEvent(.{ .command = owned }) catch { - gpa.free(owned); - break; - }; - } -} - -/// Consume SIGWINCH synchronously (it is blocked in every thread) and post -/// the new size as a winsize event from normal thread context — the one place -/// vaxis's Io-backed queue is safe to touch on a resize. fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { var set = posix.sigemptyset(); posix.sigaddset(&set, posix.SIG.WINCH); @@ -1654,11 +1408,6 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } -/// The /dev/fuse poller's wake, and deliberately nothing else — the thread that -/// calls this has no business in the core, so all it does is end the blocking -/// `nextEvent`. tryPostEvent rather than postEvent for the same reason readPty's -/// final post uses it: this can fire after the loop has already been left, and a -/// blocking push into a full queue nobody is draining would never return. fn wakeFs(ctx: ?*anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(ctx.?)); _ = loop.tryPostEvent(.fs_ready) catch {}; @@ -1673,18 +1422,14 @@ fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, gen: const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; const bytes = try gpa.dupe(u8, buf[0..n]); - loop.postEvent(.{ .pty_read = .{ .id = id, .bytes = bytes } }) catch { + loop.postEvent(.{ .pty_read = .{ .id = id, .gen = gen, .bytes = bytes } }) catch { gpa.free(bytes); break; }; } - // non-blocking: a teardown cancel only unblocks one wait, so a blocking - // post into a full queue here could hang the exit _ = loop.tryPostEvent(.{ .pty_eof = .{ .id = id, .gen = gen } }) catch {}; } -/// The effective codepoint the way vaxis Key.matches sees it: a single-char -/// text wins (shift resolved by the terminal), else the shifted codepoint. fn effCp(key: vaxis.Key) u21 { if (key.text) |t| { const view = std.unicode.Utf8View.init(t) catch return key.codepoint; @@ -1696,8 +1441,6 @@ fn effCp(key: vaxis.Key) u21 { return key.shifted_codepoint orelse key.codepoint; } -/// vaxis functional-key codepoints -> core Key constants (ASCII ones already -/// coincide: enter/tab/escape/backspace pass through). fn mapKey(cp: u21) u21 { return switch (cp) { vaxis.Key.up => pardes.Key.up, @@ -1713,37 +1456,17 @@ fn mapKey(cp: u21) u21 { }; } -/// 4 MiB ceiling, past which the tail is dropped rather than grown into. A -/// paste that large is a mis-click on a file, not an edit, and the core would -/// have to hold the whole of it as one undo entry. File scope rather than a -/// `Shell` decl because the `--attach` frontend accumulates the same bursts -/// against the same ceiling, and wire.zig cites this name as THE cap. const max_paste_bytes: usize = 4 << 20; -/// One key press between the bracketed-paste markers, as the bytes it means. -/// A key in there is DATA, never a command, and the two callers — the -/// in-process host and the `--attach` frontend — must agree exactly, because -/// what they produce is compared against what a terminal's own paste would -/// have delivered. fn pasteBytes(key: vaxis.Key) []const u8 { const text = key.text orelse ""; if (text.len > 0) return text; const cp = mapKey(effCp(key)); if (cp == pardes.Key.tab) return "\t"; - // vaxis gives control bytes no text at all: a line break inside a paste - // reaches the ground parser as a bare CR (-> Key.enter) or, from a - // terminal that does not translate them, a bare LF — which that parser - // reports as ctrl+j. Nothing in here is a real keypress, so both of them - // are just a newline. if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) return "\n"; - // arrows, F-keys, a stray escape: noise a paste has no business carrying, - // dropped rather than smuggled in. return ""; } -/// ...and one ordinary key press as the core's event. Shared for the reason -/// above: a keystroke must mean the same thing whether the core is in this -/// process or on the other end of a socket. fn keyEvent(key: vaxis.Key) pardes.Event { return .{ .key = .{ .cp = mapKey(effCp(key)), @@ -1754,9 +1477,6 @@ fn keyEvent(key: vaxis.Key) pardes.Event { } }; } -/// ...and one mouse report. Null for a button this vocabulary has no name for -/// (vaxis reports more of them than the core has), which is a report to drop -/// rather than a press to invent. fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { const button: pardes.Mouse.Button = switch (m.button) { .left => .left, @@ -1783,14 +1503,6 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { } }; } -/// THE cell walk: canonical cells -> vaxis, cell for cell, with no -/// interpretation. One walk and two callers, because a `frame` off the -/// detached wire IS a `Surface`'s cells — wire.zig carries the grid and -/// deliberately does not carry the two halves `Shell.present` adds around this -/// (the kitty attachments, which have no encoding, and the panel transition, -/// which the session composes before it sends). A second walk here would be -/// two renderers for one canonical interface, and the interface is the thing -/// this editor is. fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { win.clear(); var y: u16 = 0; @@ -1809,7 +1521,6 @@ fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u1 fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void { win.showCursor(x, y); - // insert = beam, everything else = the terminal's default shape win.setCursorShape(if (bar) .beam else .default); } @@ -1843,60 +1554,19 @@ fn vaxisColor(c: pardes.Color) vaxis.Color { }; } -// --------------------------------------------------------------------------- -// Attached: a terminal, a socket, and no core -// -// Reached two ways — `--attach[=]` on the command line, and the `Attach` -// builtin handing a running local session's terminal to a detached one — and -// identical past the connect. NOTHING below this line forks a shell, writes a -// file or watches a path: the daemon owns every machine-local effect now -// (src/detached/server.zig), and the three that are still on the wire -// (`wire.ServerTag` 0x10..) are there because the clipboard and the browser -// are the human's, not the machine's. -// --------------------------------------------------------------------------- - -/// Why an `--attach` frontend stopped, and where its exit status comes from. -/// A VALUE rather than a message printed where it is discovered: at that point -/// the alt screen is still up and everything written to it is erased by the -/// restore a moment later. `run` registers `report` BEFORE it opens the -/// terminal, so LIFO runs it last — on a cooked main screen, which is the one -/// screen a person would go looking at. const AttachEnd = union(enum) { - /// not an `--attach` run at all, or the session said `quit`: exit 0 none, - /// `--attach=` and nothing is listening under it no_session: []const u8, - /// bare `--attach` with no session to mean... nothing_detached, - /// ...or more than one, which is a choice and not ours to make ambiguous: usize, - /// the session hung up on the connect and said why refused: wire.Refusal, - /// the link died, or the stream stopped making sense lost: anyerror, - /// the connect landed and the session hung up before its reason arrived: a - /// refusal whose six bytes raced the close (server.zig `refuseFd`) rejected, - /// ...and the other silence: it accepted, kept the slot, and never greeted - /// us at all inside client.zig's `attempt` deadline silent, - /// `Detach` in an attached frontend: THIS frontend leaves and the session - /// does not, which is the whole difference between it and `.none`. The name - /// is what to come back to, and empty when this frontend never knew it (an - /// `Attach` builtin's bare form: the core that held the word is gone by the - /// time the attached loop runs). detached: []const u8, - /// The nonzero exit lives HERE for the same reason the message does: every - /// teardown this process owes is a defer registered after this one, so by - /// the time this runs they have all run and there is nothing left to skip. fn report(e: AttachEnd, io: std.Io) void { var buf: [512]u8 = undefined; - // Set by the one ending that is not a failure. `Detach` is a word the - // user typed, so leaving is success: the line goes to STDOUT and the - // exit status is untouched, because there is still a session there to - // come back to. tmux's `[detached]` line is the same sentence for the - // same reason. var ok = false; const text: []const u8 = switch (e) { .none => return, @@ -1934,17 +1604,8 @@ const AttachEnd = union(enum) { if (!ok) std.process.exit(1); } - /// The same reason on ONE pane message row, for the `Attach` builtin. It - /// exists because that path does not exit: `report` writes to a cooked main - /// screen on the way out of the process and can spend a clause on advice, - /// while this shares a row with a filename in a session that goes on - /// running. Same vocabulary, no `pardes:` prefix and no newline. fn row(e: AttachEnd, buf: []u8) []const u8 { return switch (e) { - // `report` reads `.none` as "exit 0, say nothing", and a message - // row only ever shows a failure — but a session that says `quit` - // before it greets is the one way this arm could be reached, and - // that is what it says. .none => "attach: that session ended", .no_session => |name| std.fmt.bufPrint(buf, "attach: no session '{s}'", .{name}) catch "attach: no session under that name", @@ -1959,39 +1620,11 @@ const AttachEnd = union(enum) { .lost => |err| std.fmt.bufPrint(buf, "attach: {t}", .{err}) catch "attach: link lost", .rejected => "attach: that session hung up on the connect", .silent => "attach: that session accepted and never greeted", - // Never asked for: `attemptEnd` is the only caller and a connect - // that has not happened yet cannot have been detached from. Worded - // rather than left to an `else`, so the arm somebody adds next - // still has to be thought about. .detached => "attach: detached from that session", }; } }; -/// `--attach[=]` and the `Attach` builtin: the frontend half of a -/// detached session. This process owns a terminal and a socket; the `Pardes` -/// is in the session process (src/detached/). The whole job is client.zig's -/// two sentences — send the input it collects, draw the frames it is sent — -/// and since the daemon took its own IO back there is nothing else in it. -/// -/// THAT DELETION IS THE POINT. A frontend used to serve `spawn`, `pty_write`, -/// `pty_resize`, `write_file`, `write_dump`, `watch_file` and `dump_themes` -/// off the wire, which put every pane's shell in whichever frontend happened -/// to fork it and stopped that pane's output the moment that frontend left — -/// a daemon whose whole promise is outliving frontends killed your shells. A -/// unix socket means the two ends share a machine, so the daemon forks and -/// writes and watches for itself (src/host_io.zig, src/file_watch.zig) and -/// `wire.ServerTag` keeps exactly three effects, 0x10..: the clipboard both -/// ways and the browser, because each of those needs the display a human is -/// actually looking at. -/// -/// It is NOT a `Shell`, and the difference is not size. `Shell` IS the -/// `Host.ctx` of a core in THIS process, and its methods reach into that core -/// on nearly every line — a pane's message row, `acknowledgeShell`, `setCwd`, -/// a watch generation taken off the pane's live text. With no core those are -/// not cheaper versions of the same work, they are absent. What the two -/// genuinely share is shared: the cell walk, the key and mouse vocabularies, -/// the paste ceiling, `copyToClipboard`, `requestClipboard`. const Attach = struct { gpa: std.mem.Allocator, client: *detached_client.Client, @@ -1999,97 +1632,41 @@ const Attach = struct { vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_buf: *std.Io.Writer.Allocating, - /// The session this frontend asked for, borrowed for the loop's lifetime - /// and only so `Detach` can name what to come back to. Empty when it is not - /// knowable here — see `AttachEnd.detached`. session: []const u8 = &.{}, caps_pending: bool = true, in_paste: bool = false, - /// A frame landed. Painted once at the end of the round rather than where - /// it arrives: several can be decoded out of one poll and only the last of - /// them is on the screen. dirty: bool = false, - /// One event onto the wire. Non-null ends this frontend. fn send(a: *Attach, ev: pardes.Event) ?AttachEnd { a.client.send(.{ .event = ev }) catch |err| switch (err) { - // A message this protocol cannot carry, which is not a link that - // has died: `putSlice32` refuses past `wire.max_payload` (16 MiB). - // One event still reaches it now that the watched files are the - // daemon's — an OSC 52 clipboard reply, whose size is whatever the - // terminal handed vaxis and which nothing in this file bounds - // (`max_paste_bytes` bounds the bracketed-paste assembly, not a - // decoded reply). Dropping it costs one paste; treating it as a - // hangup would cost the session. error.Overlong, error.NoSpace => return null, else => return .{ .lost = err }, }; return null; } - /// One decoded message from the session. `wire.ServerMsg` has eight arms - /// and so has this switch — no catch-all, so a protocol that grows a ninth - /// stops compiling here rather than quietly ignoring it. fn handle(a: *Attach, msg: wire.ServerMsg) ?AttachEnd { switch (msg) { - // Already applied to the client's slot and geometry; the full frame - // the session promises a fresh attach is the next thing to arrive. .welcome => {}, .refuse => |why| return .{ .refused = why }, - // Applied too — `grid` and `cursor` are current by the time this - // returns, so all that is left is to say the screen moved. .frame => a.dirty = true, .quit => return .none, - // ...and its sibling, which is the same exit for the opposite - // reason: `quit` is the session ending under every frontend, and - // `Detach` is THIS frontend leaving one that carries on. The - // session keeps its panes, its shells and its other frontends, so - // there is nothing to report as a failure and something to come - // back to — see `AttachEnd.detached`. .detach => return .{ .detached = a.session }, - // The three that are left, served by the same lines the local - // `Shell` runs for its own core's effects: this terminal's OSC 52 - // pair and this desktop's browser. .set_clipboard => |text| copyToClipboard(a.vx, a.tty, a.gpa, text), - // The answer is not a reply message: it comes back as an ordinary - // `Event.paste` through the `.paste` arm of `apply`, like any other - // input, which is the same asynchronous shape `pull_read_clipboard` - // has in-process. .read_clipboard => requestClipboard(a.vx, a.tty), .open_link => |url| look.openLink(url), } return null; } - /// One vaxis event, translated onto the wire. Non-null ends the loop. fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd { switch (event) { - // Nothing posts these here, and each absence has a reason. `tick` - // is `Shell.waitInput`'s animation clock, and an animation runs - // where the core is — the session sleeps on its own frame interval - // (server.zig `nap`) and the frames simply arrive. `fs_ready` - // belongs to `--fs`, which lives with the core. `lsp_done` and - // `pipe_done` answer work the core dispatches, and it dispatches it - // there; `lsp_status` narrates servers whose sink the local loop - // UNSET in its teardown before this loop started, and the queue - // was drained after that, so none is in flight. `pty_read`, - // `pty_eof` and `files_changed` are the ones that MOVED: the - // daemon forks the pane shells and holds the inotify instance - // now, so the only descriptors this process reads are its - // terminal and one socket. An in-place switch (`Attach` in a - // local session) cancels its readers and its watcher and drains - // this queue before the attached loop starts, so not even a late - // post from the session it just left arrives here. .nop, .tick, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {}, .quit => return .none, .focus_in => {}, .focus_out => return a.send(.pointer_leave), .winsize => |ws| { a.vx.resize(a.gpa, a.tty.writer(), ws) catch {}; - // Repaint from the frame already in hand: vaxis has just thrown - // its shadow grid away, and the SESSION grid may not move at - // all — it is the smallest common one and another frontend may - // be the small one (client.zig GEOMETRY). a.dirty = true; a.client.resize(ws.cols, ws.rows) catch |err| return .{ .lost = err }; }, @@ -2110,39 +1687,17 @@ const Attach = struct { .paste_end => { a.in_paste = false; defer a.paste_buf.clearRetainingCapacity(); - // ONE message for the whole paste, exactly as the in-process - // host makes it one `update`. const pasted = a.paste_buf.written(); if (pasted.len > 0) return a.send(.{ .paste = pasted }); }, - // A pardes launched inside a pane shell hands its file to the - // nearest pardes ANCESTOR (nested.zig `outer`), and now that the - // daemon forks those shells that ancestor is the daemon — which is - // why `attachSession` binds no listener at all. The one line that - // still reaches this arm is a switch racing itself: a local session - // whose own child wrote to `localSession`'s listener in the moment - // before `Attach` gave the terminal away, on a thread that is - // detached and so cannot be joined ahead of the queue drain. It - // goes over the wire, which is what `ClientTag.command` is for. - .command => |line| { - defer a.gpa.free(line); - return a.send(.{ .command = line }); - }, } return null; } - /// The capability handshake, resolved on the loop exactly as - /// `Shell.pollFrame` resolves it and for its reason: the replies land on - /// vaxis's reader thread, and this is the only thread allowed to write to - /// the tty. No `native_images` here — this wire carries no attachments. fn enableCaps(a: *Attach) void { if (!a.caps_pending or !a.vx.queries_done.load(.unordered)) return; a.caps_pending = false; a.vx.enableDetectedFeatures(a.tty.writer()) catch {}; - // Earlier frames were drawn under the pre-handshake caps, and vaxis's - // shadow grid has to be re-established under the new ones or it keeps - // skipping cells it thinks are current. a.vx.queueRefresh(); a.dirty = true; } @@ -2150,72 +1705,33 @@ const Attach = struct { fn paint(a: *Attach) void { a.dirty = false; const win = a.vx.window(); - // The session grid can be smaller than this window; `paintCells` clears - // first, so the surplus is the terminal's own default cell rather than - // whatever was there a frame ago. paintCells(win, a.client.grid.items, a.client.cols, a.client.rows); if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); a.vx.render(a.tty.writer()) catch {}; } }; -/// One `detached_client.Attempt` that did NOT come back with a client, in this -/// file's own vocabulary. `requested` is what the user actually typed, because -/// the union has a single `no_session` where this file has two ends for it: a -/// name that resolved to nothing is a typo to correct, and no name at all is a -/// session to start. fn attemptEnd(a: *const detached_client.Attempt, requested: []const u8) AttachEnd { return switch (a.*) { - // Both callers take the client out of the `.greeted` arm themselves, so - // this is only ever asked about a failure; `.none` is what "nothing to - // report" is spelled as everywhere else in this union. .greeted => .none, .no_session => if (requested.len != 0) .{ .no_session = requested } else .nothing_detached, .ambiguous => |found| .{ .ambiguous = found }, .refused => |why| .{ .refused = why }, .silent => .silent, - // A hangup with no reason decoded is what `rejected` was written for: - // server.zig's `refuseFd` writes six bytes and closes in the same pass, - // so the close can beat the reason onto the socket. client.zig's `give` - // already prefers a refusal it did decode, so an `error.Closed` that - // reaches here is that race and nothing else. .lost => |err| if (err == error.Closed) .rejected else .{ .lost = err }, }; } -/// The attached loop: two event sources, one screen, no core. A function of its -/// own because there are two ways to become attached and only one loop — -/// `--attach` on the command line (`attachSession`, which opens the terminal -/// for it) and the `Attach` builtin (see `localSession`, whose terminal already -/// had one) — and past the connect the two are indistinguishable. Every thread -/// it needs (vaxis's reader, the SIGWINCH sigwait) is the caller's to have -/// started, which is the whole difference between the two entries. fn attachLoop(a: *Attach) AttachEnd { while (true) { const link = a.client.wait(detached_client.poll_ms); - // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in - // the same call that read the last bytes, and the last bytes are the - // session's `quit`: `fill` appends every chunk and only then sees the - // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly - // this reason, and honouring that means draining what arrived before - // deciding the link is what ended us — otherwise an ordinary `Kill` - // exits one frontend 0 (it got the quit alone) and whichever frontend - // was in the same poll round nonzero, which is what the first run of - // this loop actually did. while (true) { const msg = (a.client.next() catch |err| return .{ .lost = err }) orelse break; if (a.handle(msg)) |end| return end; } link catch |err| return .{ .lost = err }; - // The terminal, drained the way `Shell.waitInput` drains it and for its - // reason: a wheel flick is one batch rather than fifty round trips, and - // a paste in flight keeps draining without a message per character. var batch: usize = 0; while (a.in_paste or batch < 64) { - // Propagated rather than swallowed, unlike `Shell.waitInput`'s - // identical drain: there the blocking `nextEvent` above it is what - // notices a dead event source, and here there is no blocking read - // to notice with. const ev = (a.loop.tryEvent() catch |err| return .{ .lost = err }) orelse break; batch += 1; if (a.apply(ev)) |end| return end; @@ -2225,48 +1741,23 @@ fn attachLoop(a: *Attach) AttachEnd { } } -/// The whole `--attach` run: connect, then `attachLoop` until the session, the -/// link or the terminal ends it. The terminal is already raw, on the alt screen -/// and reporting the mouse — `run` did that, and `run`'s defers undo it, which -/// is what makes an attach leave a terminal in exactly the state an ordinary -/// exit does. -/// -/// No `Options` reaches here any more. Every field of it describes a core, and -/// the last two this frontend read went with the work that read them: the -/// config directory served a `dump_themes` the daemon now does itself, and -/// `nested` gated a listener for children this process no longer has. fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx: *vaxis.Vaxis) AttachEnd { const io = init.io; const gpa = init.gpa; - // The hello carries this window, so the size has to be real before it goes - // out: a session told 80x24 by a 200x50 terminal reflows every pane twice, - // once now and once on the first SIGWINCH. `vx.resize` here rather than - // waiting for the loop's first event for the same reason — the first frame - // may arrive before any terminal event does, and it has to have somewhere - // to be painted. const ws = tty.getWinsize() catch |err| return .{ .lost = err }; if (ws.cols == 0 or ws.rows == 0) return .{ .lost = error.NoWinsize }; vx.resize(gpa, tty.writer(), ws) catch |err| return .{ .lost = err }; - // The SAME three steps the `Attach` builtin takes, through the same - // function, because the two entries drifted apart the last time they were - // written separately: `--attach` resolved a bare name and the builtin did - // not, so the documented `SPC s a` answered `NoSessionPath` at a session - // that was listening. `attempt` resolves, connects, and waits to be - // GREETED. This path could afford to meet a refusal inside the loop below - // — it has no local session to lose — but there is no second sequence to - // maintain, so it does not have its own. var attempt = detached_client.attempt(gpa, name, ws.cols, ws.rows); var client = switch (attempt) { .greeted => |c| c, else => return attemptEnd(&attempt, name), }; - // `detach` and not `deinit`: seven bytes that turn "the peer vanished" into - // "the peer left" in the session's log. defer client.detach(); var loop: Loop = .init(io, tty, vx); + var input_cache: vaxis.GraphemeCache = .{}; var paste_buf: std.Io.Writer.Allocating = .init(gpa); defer paste_buf.deinit(); @@ -2277,26 +1768,13 @@ fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx: .vx = vx, .tty = tty, .paste_buf = &paste_buf, - // Concrete here, unlike the builtin's path: `run` resolved a bare - // `--attach` to one name before it opened the terminal, and it outlives - // this call. So a `Detach` from a `--attach` frontend can say what to - // come back to. .session = name, }; - // The whole teardown this frontend owes, which is now one queue drain: no - // ptys, no watches, no workers, nothing forked. Registered BEFORE - // `loop.stop()` below so LIFO runs it after — vaxis's reader has to be - // joined before the queue is emptied, or a late post lands in a queue - // nobody drains again and its bytes leak. defer drainAttachedQueue(&loop, gpa); - loop.start() catch |err| return .{ .lost = err }; - defer loop.stop(); - // Detached rather than an `io.concurrent` task, for `run`'s reason: sigwait - // never returns, so a task around it would hang the teardown that joins it. + startInput(&loop, &input_cache) catch |err| return .{ .lost = err }; + defer stopInput(&loop); (std.Thread.spawn(.{}, winchWatch, .{ &loop, vx, tty }) catch |err| return .{ .lost = err }).detach(); - // Send the capability probes and do not wait on them; `Attach.enableCaps` - // resolves them on the loop. run() has the long version of why. vx.queryTerminalSend(tty.writer()) catch {}; return attachLoop(&a); diff --git a/src/tutor.txt b/src/tutor.txt index f712ab85..b2110dd8 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -230,11 +230,9 @@ WHAT A DAEMON CAN ALSO DO A detached session serves acme's control filesystem like any other: - pardes --detach=work --fs the tree under $XDG_RUNTIME_DIR - pardes --detach=work --fs9 the same tree, as 9P on a unix socket + pardes --detach=work 9P on the session's default unix socket - Either, both or neither. That is what makes a daemon scriptable while - nobody is looking at it — see part 6. + Every native session is scriptable over 9P — see part 6. The socket lives in $XDG_RUNTIME_DIR (else ~/.local/state/pardes), created 0700, never /tmp: it carries keystrokes into a live editor. @@ -368,7 +366,7 @@ typed abandons it, and so does any key that leads nowhere. SPC ? list every path - SPC k Kill SPC d Del + SPC d Del Kill remains available in the topbar. SPC f s Save SPC f f Find SPC f n New SPC y Y p P R the system clipboard SPC w h/j/k/l focus a neighbouring pane @@ -397,46 +395,43 @@ typed plugin API, no interpreter and no rebuild. A program that opens files IS an extension. - pardes --fs acme's control files over FUSE - pardes --fs9 the same tree as 9P on a unix socket + pardes the 9P socket opens by default A directory per pane holding `body`, `tag`, `ctl`, `addr`, `data`, `event`, `pty/` and the rest, plus `index`, `cons` and `new/` at the - top. Every pane shell is told `$PARDES_FS` and `$PARDES_PANE`, so a - script in a pane addresses its own window with no arguments: + top under /self. Every pane shell receives `$PARDES_9P` (the socket) + and `$PARDES_PANE` (its serial). Use a 9P client to read and write: - echo hello >> $PARDES_FS/$PARDES_PANE/body - cat $PARDES_FS/index - echo hi > $PARDES_FS/new/body + /self/pane//body + /self/index + /self/new/ctl A terminal pane also has `pty/`: - echo 'winsize 100 30' >> $PARDES_FS/3/pty/ctl - echo 'sig INT' >> $PARDES_FS/3/pty/ctl - echo 'make -j8' >> $PARDES_FS/3/pty/data + /self/pane/3/pty/ctl winsize, sig + /self/pane/3/pty/data terminal input/output Over 9P the same tree answers plan9port, from anywhere: 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock ls / - 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock read /index + 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock read /self/index ...and pardes is a 9P CLIENT too, so one session can read another's: - 9p work.sock /1/body the `9p` word: opens it in a pane + pardes --mount=peer=work + /n/peer/self/pane/1/body Look opens the other session's body TWO THINGS WORTH KNOWING. While a program holds a pane's `event` file open, MIDDLE AND RIGHT CLICKS IN THAT PANE BELONG TO IT: pardes reports them and performs nothing, so that pane's tag can carry the program's - own words (examples/acmefs/life.py puts Step/Run/Clear there). The - keyboard is never suppressed, and the clicks come back when it exits. + own words. The keyboard is never suppressed, and the clicks come back + when it exits. And writing an event record back — `origin type q0 q1` — makes pardes perform the Look or Exec it names. That is arbitrary command execution - by design, the same door acme has always had, which is why the mount - sits under $XDG_RUNTIME_DIR at 0700 and why both flags are opt-in. + by design. The socket sits in the user's private runtime directory. - examples/README.md is the client's guide; docs/acme-fs.md compares this - implementation with acme's file by file; docs/9p.typ is the 9P note. + docs/fs.md describes the filesystem and mount paths. ================================================================= @@ -466,7 +461,7 @@ typed Attach / Detach the same, as words (SPC s a / s D) the pane shells belong to the SESSION and never stop N frontends share ONE screen at the smallest common grid - --fs and --fs9 work in a daemon too + the default 9P socket works in a daemon too KEYS h j k l w b e 0 $ ^ gg ge f F t T v x d c y p i a I A o O u undo U redo @@ -480,8 +475,8 @@ typed / is a case-insensitive SUBSTRING search, not a regex SPC is the leader (SPC ? lists every path) - SCRIPTING --fs acme's files over FUSE; --fs9 the same over 9P - $PARDES_FS//{body,tag,ctl,addr,data,event,pty/} + SCRIPTING 9P is served by default on $PARDES_9P + /self/pane//{body,tag,ctl,addr,data,event,pty/} a script holding `event` owns that pane's middle and right clicks; writing a record back runs it diff --git a/src/user_config.zig b/src/user_config.zig deleted file mode 100644 index 15253ab2..00000000 --- a/src/user_config.zig +++ /dev/null @@ -1,188 +0,0 @@ -//! Native startup configuration discovery and loading. -//! -//! `pardes` is a directory inside the platform's per-user configuration -//! directory; its startup command file is `pardes/init`. Discovery is -//! deliberately launcher-owned: native -//! launchers opt in by putting the bytes in `Options.startup_config`, while -//! tests and the browser keep the default `null`. - -const std = @import("std"); -const builtin = @import("builtin"); - -const max_bytes = 1024 * 1024; - -pub const init_name = "init"; -pub const builtin_themes_subdir = "themes/builtin"; - -/// Resolve the native per-user config directory with stdlib environment and path -/// APIs. XDG_CONFIG_HOME is accepted only when absolute, as required by the -/// XDG base-directory specification; an empty/relative value falls back. -pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8 { - if (builtin.os.tag == .windows) { - if (nonEmpty(env.get("LOCALAPPDATA"))) |base| - return try std.fs.path.join(gpa, &.{ base, "pardes" }); - if (nonEmpty(env.get("USERPROFILE"))) |home| - return try std.fs.path.join(gpa, &.{ home, "AppData", "Local", "pardes" }); - return null; - } - - if (xdgBase(env)) |base| - return try std.fs.path.join(gpa, &.{ base, "pardes" }); - - const home = nonEmpty(env.get("HOME")) orelse return null; - if (builtin.os.tag == .macos) - return try std.fs.path.join(gpa, &.{ home, "Library", "Application Support", "pardes" }); - return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" }); -} - -/// The config directory and its init file: WHERE they were looked for, and -/// what was there. Missing, -/// unreadable, oversized, or otherwise unusable config is simply no config — -/// but the path resolves either way, because "nothing is there yet" is the -/// answer the Config builtin exists to give and a null would erase it. The -/// arena passed by the launcher owns every returned slice for the process. -pub const Found = struct { - dir: ?[]const u8 = null, - path: ?[]const u8 = null, - bytes: ?[]const u8 = null, -}; - -pub fn load( - io: std.Io, - gpa: std.mem.Allocator, - env: *const std.process.Environ.Map, -) Found { - const config_dir = (path(gpa, env) catch return .{}) orelse return .{}; - const config_path = std.fs.path.join(gpa, &.{ config_dir, init_name }) catch return .{ .dir = config_dir }; - return .{ - .dir = config_dir, - .path = config_path, - .bytes = std.Io.Dir.cwd().readFileAlloc(io, config_path, gpa, .limited(max_bytes)) catch null, - }; -} - -fn nonEmpty(value: ?[]const u8) ?[]const u8 { - const v = value orelse return null; - return if (v.len == 0) null else v; -} - -fn xdgBase(env: *const std.process.Environ.Map) ?[]const u8 { - const value = nonEmpty(env.get("XDG_CONFIG_HOME")) orelse return null; - return if (std.fs.path.isAbsolute(value)) value else null; -} - -test "config path honors XDG and rejects a relative XDG directory" { - if (builtin.os.tag == .windows) return; - - var env: std.process.Environ.Map = .init(std.testing.allocator); - defer env.deinit(); - try env.put("HOME", "/home/pardes-test"); - try env.put("XDG_CONFIG_HOME", "/var/tmp/pardes-xdg"); - - const xdg = (try path(std.testing.allocator, &env)).?; - defer std.testing.allocator.free(xdg); - try std.testing.expectEqualStrings("/var/tmp/pardes-xdg/pardes", xdg); - - try env.put("XDG_CONFIG_HOME", "relative/config"); - const fallback = (try path(std.testing.allocator, &env)).?; - defer std.testing.allocator.free(fallback); - const expected = if (builtin.os.tag == .macos) - "/home/pardes-test/Library/Application Support/pardes" - else - "/home/pardes-test/.config/pardes"; - try std.testing.expectEqualStrings(expected, fallback); -} - -test "config loader reads init inside the config directory" { - if (builtin.os.tag == .windows) return; - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var base_buf: [std.fs.max_path_bytes]u8 = undefined; - const base_len = try tmp.dir.realPath(std.testing.io, &base_buf); - - var env: std.process.Environ.Map = .init(std.testing.allocator); - defer env.deinit(); - try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]); - - const missing = load(std.testing.io, std.testing.allocator, &env); - defer std.testing.allocator.free(missing.dir.?); - defer std.testing.allocator.free(missing.path.?); - const expected_dir = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" }); - defer std.testing.allocator.free(expected_dir); - const expected = try std.fs.path.join(std.testing.allocator, &.{ expected_dir, init_name }); - defer std.testing.allocator.free(expected); - try std.testing.expectEqualStrings(expected_dir, missing.dir.?); - try std.testing.expectEqualStrings(expected, missing.path.?); - try std.testing.expect(missing.bytes == null); - const source = "Theme dark\nUnknown command\nTheme acme\n"; - try tmp.dir.createDir(std.testing.io, "pardes", .default_dir); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes/init", .data = source }); - const found = load(std.testing.io, std.testing.allocator, &env); - defer std.testing.allocator.free(found.dir.?); - defer std.testing.allocator.free(found.path.?); - defer std.testing.allocator.free(found.bytes.?); - try std.testing.expectEqualStrings(expected_dir, found.dir.?); - try std.testing.expectEqualStrings(source, found.bytes.?); -} - -/// Write one editable `.zon` file per compiled theme. The caller supplies the -/// ring so this filesystem-only module does not import the core. Existing -/// generated copies are replaced; unrelated files in the directory are left -/// alone, which lets a user keep custom themes beside the reference set. -pub fn dumpThemes( - io: std.Io, - gpa: std.mem.Allocator, - config_dir: []const u8, - theme_values: anytype, -) ![]u8 { - const out_dir = try std.fs.path.join(gpa, &.{ config_dir, builtin_themes_subdir }); - errdefer gpa.free(out_dir); - try std.Io.Dir.cwd().createDirPath(io, out_dir); - - for (theme_values) |theme_value| { - var encoded: std.Io.Writer.Allocating = .init(gpa); - defer encoded.deinit(); - try std.zon.stringify.serialize(theme_value, .{ .whitespace = true }, &encoded.writer); - - const filename = try std.fmt.allocPrint(gpa, "{s}.zon", .{theme_value.name}); - defer gpa.free(filename); - const output_path = try std.fs.path.join(gpa, &.{ out_dir, filename }); - defer gpa.free(output_path); - try std.Io.Dir.cwd().writeFile(io, .{ - .sub_path = output_path, - .data = encoded.written(), - }); - } - return out_dir; -} - -test "theme dump creates the builtin subdirectory and ZON files" { - const io = std.testing.io; - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var base_buf: [std.fs.max_path_bytes]u8 = undefined; - const base_len = try tmp.dir.realPath(io, &base_buf); - const Sample = struct { name: []const u8, rgb: [3]u8 }; - const samples = [_]Sample{ - .{ .name = "one", .rgb = .{ 1, 2, 3 } }, - .{ .name = "two", .rgb = .{ 4, 5, 6 } }, - }; - const output = try dumpThemes(io, gpa, base_buf[0..base_len], &samples); - defer gpa.free(output); - const expected = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], builtin_themes_subdir }); - defer gpa.free(expected); - try std.testing.expectEqualStrings(expected, output); - - const one_path = try std.fs.path.join(gpa, &.{ output, "one.zon" }); - defer gpa.free(one_path); - const bytes = try std.Io.Dir.cwd().readFileAlloc(io, one_path, gpa, .limited(4096)); - defer gpa.free(bytes); - const source = try gpa.dupeZ(u8, bytes); - defer gpa.free(source); - const parsed = try std.zon.parse.fromSliceAlloc(Sample, gpa, source, null, .{}); - defer std.zon.parse.free(gpa, parsed); - try std.testing.expectEqualStrings("one", parsed.name); - try std.testing.expectEqual([3]u8{ 1, 2, 3 }, parsed.rgb); -} diff --git a/src/web.zig b/src/web.zig index bfef38a7..3a9446b6 100644 --- a/src/web.zig +++ b/src/web.zig @@ -105,8 +105,8 @@ const State = struct { frame_rows: u16 = 0, fn init(cols: u16, rows: u16) !State { - const allocs = pardes.allocators.init(gpa); - errdefer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + errdefer pardes.memory.deinit(); pardes.image.start(std.Io.failing, allocs.image); errdefer pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); @@ -131,7 +131,7 @@ const State = struct { pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); - pardes.allocators.deinit(); + pardes.memory.deinit(); } }; @@ -340,12 +340,12 @@ extern "pardes" fn host_download( ) callconv(.c) void; const vtable: pardes.Host.VTable = .{ - .push_present = present, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, + .present = present, + .write_file = writeFile, + .write_dump = writeDump, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, }; fn host(s: *State) pardes.Host { -- cgit v1.3