const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const dump = @import("../dump.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); const message = pardes.Messages.Message; const file_watch = @import("../file_watch.zig"); const selection_pipe = @import("../selection_pipe.zig"); const ninep_io = @import("../9p_io.zig"); const panel_compositor = @import("panel_compositor.zig"); const detached_client = @import("../detached/client.zig"); const detached_server = @import("../detached/server.zig"); const wire = @import("../detached/wire.zig"); const host_io = @import("../host_io.zig"); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); /// The terminal answered the shared-memory probe (`kittyShmProbe`): it can /// read a raster from a POSIX shm object, so a page costs a name on the wire /// instead of its whole base64 body. Set by the input reader, which sees the /// reply before vaxis' parser swallows every graphics APC alike. var kitty_shm = std.atomic.Value(bool).init(false); /// Only a reply that arrives while the probe is out counts. The id is one /// vaxis' own image numbering (1, 2, ...) never reaches, because a terminal /// answers every transmission `i=;OK` and one of those must not pass. var kitty_shm_probing = std.atomic.Value(bool).init(false); const kitty_shm_probe_id = 0x7fff_fff1; var kitty_shm_serial: u32 = 0; fn kittyShmName(buf: []u8, n: u32) ![:0]const u8 { return std.fmt.bufPrintSentinel(buf, "/pardes-{d}-{d}", .{ libc.getpid(), n }, 0); } /// Write `bytes` to a fresh shm object; the terminal unlinks it after reading. fn kittyShmWrite(name: [:0]const u8, bytes: []const u8) bool { const fd = libc.shm_open(name, @bitCast(libc.O{ .ACCMODE = .RDWR, .CREAT = true, .EXCL = true }), @as(c_uint, 0o600)); if (fd < 0) return false; defer _ = libc.close(fd); if (libc.ftruncate(fd, @intCast(bytes.len)) != 0) { _ = libc.shm_unlink(name); return false; } var off: usize = 0; while (off < bytes.len) { const n = libc.write(fd, bytes[off..].ptr, bytes.len - off); if (n <= 0) { _ = libc.shm_unlink(name); return false; } off += @intCast(n); } return true; } /// Ask with a 1x1 RGB image in shm whether the terminal reads that medium. A /// remote terminal cannot see our shm and answers an error (or nothing). fn kittyShmProbe(w: *std.Io.Writer) void { var buf: [64]u8 = undefined; const name = kittyShmName(&buf, 0) catch return; if (!kittyShmWrite(name, &.{ 0, 0, 0 })) return; var b64: [64]u8 = undefined; const enc = std.base64.standard.Encoder.encode(&b64, name); kitty_shm_probing.store(true, .release); w.print("\x1b_Gi={d},s=1,v=1,a=q,t=s,f=24;{s}\x1b\\", .{ kitty_shm_probe_id, enc }) catch return; w.flush() catch {}; } pub const Command = struct { pub var value: union(enum) { nop, quit, /// A timed wait ran out: the core's next wake has come. wake, key_press: vaxis.Key, pty_read: struct { id: usize, gen: u32, bytes: []u8 }, pty_eof: struct { id: usize, gen: u32 }, winsize: vaxis.Winsize, mouse: vaxis.Mouse, focus_in, focus_out, paste: []const u8, paste_start, paste_end, lsp_done: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, pipe_done: selection_pipe.Response, files_changed, fs_ready, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); 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; // The terminal's answers to frames (FrameAck) come out first, // wherever they sit: never events, and never glued to a key (an // Escape pressed just before one would read as Alt on it). const end = FrameAck.take(buf[0 .. carried + received], loop); if (end == 0) { carried = 0; continue; } if (kitty_shm_probing.load(.acquire)) if (std.mem.indexOf(u8, buf[0..end], std.fmt.comptimePrint("\x1b_Gi={d};", .{kitty_shm_probe_id}))) |at| { kitty_shm_probing.store(false, .release); kitty_shm.store(std.mem.startsWith(u8, buf[at..end], std.fmt.comptimePrint("\x1b_Gi={d};OK", .{kitty_shm_probe_id})), .release); // Answered either way: a terminal that could not read it left it. var name_buf: [64]u8 = undefined; if (kittyShmName(&name_buf, 0)) |name| _ = libc.shm_unlink(name) else |_| {} }; 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; // A read that is one ESC and nothing else is the Escape key: a // terminal writes each key's sequence whole and ssh carries each of // its writes whole, so no sequence arrives cut right after its ESC. // Waiting on it cost every Escape press 50 ms on a legacy terminal // (tmux, most ssh sessions), and 1 s before kitty's flags were on. // (A terminal's answer to a frame is one write too: FrameAck.) var waited = carried == 0 and end == 1; while (consumed < parse_end) { // A lone ESC that ends a longer read is the Escape key, or the // first byte of a sequence whose rest the read boundary held // back (an ssh packet, a busy link), which parsed now left the // rest to be typed as text. Wait a moment for more. Under the // kitty keyboard protocol Escape is `CSI 27 u`, so there the ESC // can only start a sequence and the wait can be long. const rest = buf[consumed..parse_end]; // A frame's answer cut short by the read waits for its rest. if (parse_end == end and rest.len > 1 and rest.len < FrameAck.answer.len and std.mem.startsWith(u8, FrameAck.answer, rest)) break; if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) { waited = true; if (try inputFollows(tty, loop.io, if (loop.vaxis.caps.kitty_keyboard) 1000 else 50)) break; } const result = try parser.parse(buf[consumed..parse_end], loop.vaxis.opts.system_clipboard_allocator); if (result.n == 0) break; consumed += result.n; // A modifier pressed on its own, which the kitty keyboard // protocol reports: pardes binds none, it would break a pending // chord, and a shell pane was typed its private-use code. if (result.event) |event| if (event == .key_press and event.key_press.isModifier()) continue; 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; }; // The last answer to the startup queries is no event of its // own: wake the loop so it takes the capabilities up (pollFrame) // now. Left to the first key, that key paid for the full redraw // they cost, and kitty's keys came in the legacy way till then. if (result.event) |event| if (event == .cap_da1) { _ = loop.tryPostEvent(.nop) catch {}; }; } carried = end - consumed; std.mem.copyForwards(u8, buf[0..carried], buf[consumed..end]); } } /// One frame in flight at most. Each frame the tty draws ends with a device /// status request (`CSI 5 n`), and no other frame is drawn until the /// terminal's `CSI 0 n` comes back: over ssh the frames a burst of scrolling /// or typing would queue behind each other (sshd's window holds megabytes) /// are never drawn, and the burst ends one frame and one round trip after /// its last input. On a local terminal the answer is back in well under a /// millisecond. A terminal that never answers turns this off for good after /// `timeout_ns`. const FrameAck = struct { const request = "\x1b[5n"; const answer = "\x1b[0n"; const timeout_ns: u64 = std.time.ns_per_s; var waiting = std.atomic.Value(bool).init(false); var off = std.atomic.Value(bool).init(false); /// When the request went out (the loop's thread only). var sent_ns: u64 = 0; /// Take every answer out of `bytes`, closing up the rest; the new /// length. The loop is woken for the frame that waited on one. fn take(bytes: []u8, loop: *Loop) usize { var len = bytes.len; var found = false; while (std.mem.indexOf(u8, bytes[0..len], answer)) |at| { std.mem.copyForwards(u8, bytes[at..], bytes[at + answer.len .. len]); len -= answer.len; found = true; } if (found and waiting.swap(false, .acq_rel)) loop.postEvent(.nop) catch {}; return len; } /// Whether a new frame must wait (and the loop has a wake at the /// timeout); past the timeout the terminal is taken to never answer. fn holds(now_ns: u64, io: std.Io) bool { if (off.load(.acquire) or !waiting.load(.acquire)) return false; const waited_ns = now_ns -| sent_ns; if (waited_ns >= timeout_ns) { off.store(true, .release); waiting.store(false, .release); return false; } requestWake(io, @intCast((timeout_ns - waited_ns + std.time.ns_per_ms - 1) / std.time.ns_per_ms)); return true; } /// After a frame that wrote something: ask for the answer. fn ask(w: *std.Io.Writer, now_ns: u64) void { if (off.load(.acquire)) return; // Waiting before the request goes: a local terminal can answer // before the write returns, and an answer nobody waited for is lost. sent_ns = now_ns; waiting.store(true, .release); w.writeAll(request) catch return waiting.store(false, .release); w.flush() catch return waiting.store(false, .release); } }; /// Whether more input is readable within `ms`, polled in short slices so /// that a cancel of the input thread is still seen. A test reader answers /// for itself. fn inputFollows(tty: anytype, io: std.Io, ms: u32) !bool { if (@hasDecl(@TypeOf(tty.*), "inputFollows")) return tty.inputFollows(ms); const handle = if (@TypeOf(tty.fd) == std.Io.File) tty.fd.handle else tty.fd; var fds = [_]posix.pollfd{.{ .fd = handle, .events = posix.POLL.IN, .revents = 0 }}; var left = ms; while (left > 0) : (left -|= 10) { try io.checkCancel(); if (try posix.poll(&fds, @intCast(@min(left, 10))) > 0) return true; } return false; } 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 inputFollows(self: *@This(), _: u32) bool { return self.next < self.parts.len; } 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; }, // The device attributes' wake (a capability answer). .nop => try std.testing.expect(vx.queries_done.load(.unordered)), 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 replies and malformed sequences never become typed text" { 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 inputFollows(self: *@This(), _: u32) bool { return self.next < self.parts.len; } 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 = &.{ // The startup width queries' answers where the terminal has no // explicit width: both used to arrive as a bare F3, typed as text. "a\x1b[1;1R\x1b[1;1R", // A late report somewhere else on the screen, and DECXCPR. "\x1b[12;40R\x1b[?3;7;1R", // rxvt's Alt+Up: ESC before the sequence, not Alt+Esc then `[A`. "b\x1b\x1b[A", // A kitty graphics reply cut inside its ST sliced past its input. "\x1b_Gi=1;OK\x1b", "\\", // An X10 mouse report cut after `CSI M` left three bytes as text. "\x1b[M", " !!", // Malformed color and clipboard replies failed the parse, which // ended input altogether; and a reply ended by BEL ran on to the // next sequence's ESC, leaving what lay between as text. "\x1b]11;rgb:zz/zz/zz\x1b\\\x1b]52;c;!!!!\x07\x1b]52\x07", // Device reports whose fields end early. "\x1b[?1;y\x1b[?997;n\x1b[c", "\x1b[?62;c", "c", }, }; try readInput(&loop, &reader, &cache); try std.testing.expect(vx.queries_done.load(.unordered)); try std.testing.expect(vx.caps.kitty_graphics); var text: std.ArrayList(u8) = .empty; defer text.deinit(gpa); var alt_up = false; var clicked = false; while (try loop.tryEvent()) |event| switch (event) { .winsize => {}, .key_press => |key| { if (key.codepoint == vaxis.Key.up and key.mods.alt) { alt_up = true; } else if (key.text) |bytes| { try text.appendSlice(gpa, bytes); } else return error.UnexpectedInputKey; }, .mouse => |mouse| { try std.testing.expectEqual(@as(i16, 0), mouse.col); clicked = true; }, .nop => {}, // the device attributes' wake else => return error.UnexpectedInputEvent, }; try std.testing.expectEqualStrings("abc", text.items); try std.testing.expect(alt_up and clicked); } test "terminal input split at every byte with a gap reads as it does whole" { 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); var tty: vaxis.Tty = undefined; var loop: Loop = .init(io, &tty, &vx); var cache: vaxis.GraphemeCache = .{}; // Two reads with `gap_ms` between them, as an ssh link delivers a // sequence cut across two packets. const Reader = struct { parts: [2][]const u8, gap_ms: u32, next: usize = 0, fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } fn inputFollows(self: *@This(), ms: u32) bool { return self.next < self.parts.len and self.gap_ms <= ms; } fn read(self: *@This(), buf: []u8) !usize { while (self.next < self.parts.len) { const part = self.parts[self.next]; self.next += 1; if (part.len == 0) continue; @memcpy(buf[0..part.len], part); return part.len; } return 0; } /// What the loop was handed, one line per event. fn events(l: *Loop, out: *std.ArrayList(u8)) !void { out.clearRetainingCapacity(); while (try l.tryEvent()) |event| switch (event) { .winsize => {}, .key_press => |k| try out.print(std.testing.allocator, "key {d} {d} {?s}\n", .{ k.codepoint, @as(u8, @bitCast(k.mods)), k.text }), .mouse => |m| try out.print(std.testing.allocator, "mouse {d} {d} {t} {t}\n", .{ m.col, m.row, m.button, m.type }), .paste => |bytes| { try out.print(std.testing.allocator, "paste {s}\n", .{bytes}); std.testing.allocator.free(@constCast(bytes)); }, else => |other| try out.print(std.testing.allocator, "{t}\n", .{other}), }; } }; const Case = struct { bytes: []const u8, kitty: bool = false }; const cases = [_]Case{ .{ .bytes = "\x1b[118;5u", .kitty = true }, // Ctrl-V .{ .bytes = "\x1b[97;;97u", .kitty = true }, // a, with its text .{ .bytes = "\x1b[57442;5u", .kitty = true }, // Ctrl on its own .{ .bytes = "\x1b[27u", .kitty = true }, // Escape .{ .bytes = "\x1b[A" }, .{ .bytes = "\x1bOA" }, .{ .bytes = "\x1b[3;5~" }, .{ .bytes = "\x1b\x1b[A" }, // Four wheel notches: cut at a `;` with 60 ms between the halves, // ssh's framing lost all four. .{ .bytes = "\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M" }, .{ .bytes = "\x1b[200~pasted\x1b[201~" }, .{ .bytes = "\x1b]52;c;Y2xpcA==\x1b\\" }, .{ .bytes = "\x1b_Gi=1;OK\x1b\\" }, .{ .bytes = "\x1bP>|kitty(0.40)\x1b\\" }, }; var whole: std.ArrayList(u8) = .empty; defer whole.deinit(gpa); var split: std.ArrayList(u8) = .empty; defer split.deinit(gpa); for (cases) |case| { vx.caps.kitty_keyboard = case.kitty; const all = try std.mem.concat(gpa, u8, &.{ "x", case.bytes, "y" }); defer gpa.free(all); var one: Reader = .{ .parts = .{ all, "" }, .gap_ms = 0 }; try readInput(&loop, &one, &cache); try Reader.events(&loop, &whole); for (1..all.len) |cut| { // A cut just after an ESC is the one that has to wait, and a // legacy terminal's Escape waits only 50 ms: 40 ms there. A cut // anywhere else is carried however long the gap. const after_esc = all[cut - 1] == 0x1b; var two: Reader = .{ .parts = .{ all[0..cut], all[cut..] }, .gap_ms = if (after_esc and !case.kitty) 40 else 60 }; try readInput(&loop, &two, &cache); try Reader.events(&loop, &split); std.testing.expectEqualStrings(whole.items, split.items) catch |err| { std.debug.print("case {f} cut at {d}\n", .{ std.ascii.hexEscape(case.bytes, .lower), cut }); return err; }; } } vx.caps.kitty_keyboard = false; } test "an ESC read by itself is the Escape key at once" { 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); var tty: vaxis.Tty = undefined; var loop: Loop = .init(io, &tty, &vx); var cache: vaxis.GraphemeCache = .{}; // More always follows at once: a reader that waited on the ESC would // take `j` for the rest of its sequence (Alt-j). const Reader = struct { parts: []const []const u8, next: usize = 0, asked: bool = false, fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } fn inputFollows(self: *@This(), _: u32) bool { self.asked = true; return self.next < self.parts.len; } 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; } }; for ([_]bool{ false, true }) |kitty| { vx.caps.kitty_keyboard = kitty; var reader: Reader = .{ .parts = &.{ "\x1b", "j" } }; try readInput(&loop, &reader, &cache); try std.testing.expect(!reader.asked); try std.testing.expectEqual(.winsize, std.meta.activeTag((try loop.tryEvent()).?)); const escape = (try loop.tryEvent()).?.key_press; try std.testing.expectEqual(vaxis.Key.escape, escape.codepoint); try std.testing.expect(!escape.mods.alt); const j = (try loop.tryEvent()).?.key_press; try std.testing.expectEqual(@as(u21, 'j'), j.codepoint); try std.testing.expect(!j.mods.alt); try std.testing.expect(try loop.tryEvent() == null); } vx.caps.kitty_keyboard = false; } test "a frame's answer is taken wherever the reads cut it, and never typed" { 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); var tty: vaxis.Tty = undefined; var loop: Loop = .init(io, &tty, &vx); var cache: vaxis.GraphemeCache = .{}; const Reader = struct { parts: [2][]const u8, next: usize = 0, fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } fn inputFollows(self: *@This(), _: u32) bool { return self.next < self.parts.len; } fn read(self: *@This(), buf: []u8) !usize { while (self.next < self.parts.len) { const part = self.parts[self.next]; self.next += 1; if (part.len == 0) continue; @memcpy(buf[0..part.len], part); return part.len; } return 0; } }; const all = "x" ++ FrameAck.answer ++ "y"; defer FrameAck.waiting.store(false, .release); for (0..all.len) |cut| { FrameAck.waiting.store(true, .release); var reader: Reader = .{ .parts = .{ all[0..cut], all[cut..] } }; try readInput(&loop, &reader, &cache); try std.testing.expect(!FrameAck.waiting.load(.acquire)); var typed: std.ArrayList(u8) = .empty; defer typed.deinit(gpa); var woke = false; while (try loop.tryEvent()) |event| switch (event) { .winsize => {}, .nop => woke = true, .key_press => |key| try typed.appendSlice(gpa, key.text orelse return error.UnexpectedInputKey), else => return error.UnexpectedInputEvent, }; std.testing.expectEqualStrings("xy", typed.items) catch |err| { std.debug.print("cut at {d}\n", .{cut}); return err; }; try std.testing.expect(woke); } // An Escape pressed just before an answer, in the same read, is the // Escape key, not Alt on the answer. FrameAck.waiting.store(true, .release); var glued: Reader = .{ .parts = .{ "\x1b" ++ FrameAck.answer, "j" } }; try readInput(&loop, &glued, &cache); try std.testing.expectEqual(.winsize, std.meta.activeTag((try loop.tryEvent()).?)); try std.testing.expectEqual(.nop, std.meta.activeTag((try loop.tryEvent()).?)); const escape = (try loop.tryEvent()).?.key_press; try std.testing.expectEqual(vaxis.Key.escape, escape.codepoint); try std.testing.expect(!escape.mods.alt); try std.testing.expectEqual(@as(u21, 'j'), (try loop.tryEvent()).?.key_press.codepoint); try std.testing.expect(try loop.tryEvent() == null); } test "a terminal that never answers a frame stops being asked after one timeout" { const io = std.testing.io; defer { FrameAck.off.store(false, .release); FrameAck.waiting.store(false, .release); FrameAck.sent_ns = 0; } var out: std.Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); const t0: u64 = 5 * std.time.ns_per_s; FrameAck.ask(&out.writer, t0); try std.testing.expectEqualStrings(FrameAck.request, out.written()); // Unanswered: the next frame waits, until the timeout. try std.testing.expect(FrameAck.holds(t0 + 10 * std.time.ns_per_ms, io)); try std.testing.expect(FrameAck.holds(t0 + 999 * std.time.ns_per_ms, io)); try std.testing.expect(!FrameAck.holds(t0 + FrameAck.timeout_ns, io)); // Then never again: no request, no wait. out.clearRetainingCapacity(); FrameAck.ask(&out.writer, t0 + 2 * std.time.ns_per_s); try std.testing.expectEqual(@as(usize, 0), out.written().len); try std.testing.expect(!FrameAck.holds(t0 + 2 * std.time.ns_per_s, io)); } 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 inputFollows(_: *@This(), _: u32) bool { return false; } 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); } } const PipeTask = selection_pipe.Tasks.Task; const PipeTasks = selection_pipe.Tasks; const Pty = struct { file: std.Io.File, pid: posix.pid_t, reader: std.Io.Future(anyerror!void), /// A command pane's child, watched to its exit (host_io.watchExit); /// the pty stays open until both its end of file and its exit. cmd: host_io.CommandWatch = .{}, }; /// A watched child exited: wake the loop, which takes it (`takeExits`). fn wakeForExit(ctx: ?*anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(ctx.?)); _ = loop.tryPostEvent(.nop) catch {}; } const KittyPlacement = struct { cell_x: u16, cell_y: u16, cell_cols: u16, cell_rows: u16, options: vaxis.Image.DrawOptions, }; fn terminalCellPixels(total_pixels: u16, cells: u16, fallback: u32) u32 { if (total_pixels == 0 or cells == 0) return fallback; return @max(1, @as(u32, total_pixels) / cells); } fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u16, pixel_h: u16) void { core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = .{ .w = @intCast(terminalCellPixels(pixel_w, cols, 8)), .h = @intCast(terminalCellPixels(pixel_h, rows, 16)), }, } }); } fn kittyPlacement( place: pardes.ImagePlace, screen_cols: u16, screen_rows: u16, screen_pixel_w: u16, screen_pixel_h: u16, ) ?KittyPlacement { if (comptime pardes.pdf_enabled) { if (place.native.fit == .contain) return null; const cell_w = terminalCellPixels(screen_pixel_w, screen_cols, 8); const cell_h = terminalCellPixels(screen_pixel_h, screen_rows, 16); const body_w = std.math.mul(u32, place.w, cell_w) catch return null; const body_h = std.math.mul(u32, place.h, cell_h) catch return null; const geometry = place.native.geometry orelse pardes.image.nativeGeometry( place.iw, place.ih, body_w, body_h, place.native.fit, place.native.pan_x, place.native.pan_y, ) orelse return null; 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; var safe_src_h: u32 = geometry.src.h; var declared_cols: u32 = 0; var declared_rows: u32 = 0; switch (place.native.fit) { .width => { declared_cols = @intCast(@max( @as(u64, 1), (@as(u64, geometry.dst.w) + cell_w - 1) / cell_w, )); const declared_pixel_w = @as(u64, declared_cols) * cell_w; const max_src_h: u32 = @intCast( @as(u64, safe_pixel_h) * geometry.src.w / declared_pixel_w, ); if (max_src_h == 0) return null; safe_src_h = @min(safe_src_h, max_src_h); const aspect_pixel_h: u32 = @intCast( (@as(u64, safe_src_h) * declared_pixel_w + geometry.src.w - 1) / geometry.src.w, ); declared_rows = @intCast( (@as(u64, aspect_pixel_h) + cell_h - 1) / cell_h, ); }, .height => { declared_rows = safe_rows_u32; safe_src_h = @max(@as(u32, 1), @as(u32, @intCast( @as(u64, safe_pixel_h) * geometry.src.h / geometry.dst.h, ))); safe_src_h = @min(safe_src_h, geometry.src.h); const aspect_pixel_w: u32 = @intCast( (@as(u64, geometry.src.w) * safe_pixel_h + safe_src_h - 1) / safe_src_h, ); declared_cols = @intCast( (@as(u64, aspect_pixel_w) + cell_w - 1) / cell_w, ); }, .contain => unreachable, } if (declared_rows == 0 or declared_rows > safe_rows_u32) return null; 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; const src_h = std.math.cast(u16, safe_src_h) orelse return null; const cell_x = std.math.cast(u16, geometry.dst.x / cell_w) orelse return null; const cell_y = std.math.cast(u16, geometry.dst.y / cell_h) orelse return null; if (cell_x >= place.w or cell_y >= place.h) return null; const pixel_x = std.math.cast(u16, geometry.dst.x % cell_w) orelse return null; const pixel_y = std.math.cast(u16, geometry.dst.y % cell_h) orelse return null; const cell_cols = std.math.cast(u16, @min( @as(u32, place.w - cell_x), (@as(u64, pixel_x) + @as(u64, declared_cols) * cell_w + cell_w - 1) / cell_w, )) orelse return null; const cell_rows = std.math.cast(u16, @min( @as(u32, place.h - cell_y), (@as(u64, pixel_y) + declared_rows * cell_h + cell_h - 1) / cell_h, )) orelse return null; if (cell_cols == 0 or cell_rows == 0) return null; return .{ .cell_x = cell_x, .cell_y = cell_y, .cell_cols = cell_cols, .cell_rows = cell_rows, .options = .{ .clip_region = .{ .x = src_x, .y = src_y, .width = src_w, .height = src_h }, .pixel_offset = if (pixel_x != 0 or pixel_y != 0) .{ .x = pixel_x, .y = pixel_y } else null, .size = switch (place.native.fit) { .width => .{ .cols = std.math.cast(u16, declared_cols) orelse return null }, .height => .{ .rows = std.math.cast(u16, declared_rows) orelse return null }, .contain => unreachable, }, }, }; } else { return null; } } test "Kitty PDF fragments never declare pixels beyond their clipped bottom" { if (comptime !pardes.pdf_enabled) return; const base = pardes.ImagePlace{ .pane = 0, .serial = 1, .native = .{ .revision = 1, .page = 0, .fit = .width, .geometry = .{ .src = .{ .w = 96, .h = 10 }, .dst = .{ .y = 195, .w = 304, .h = 29 }, }, }, .x = 0, .y = 0, .w = 38, .h = 14, .rgba = &.{}, .iw = 96, .ih = 64, }; const width = kittyPlacement(base, 80, 16, 640, 256) orelse return error.MissingSafeKittyWidthFragment; const width_clip = width.options.clip_region.?; const width_size = width.options.size.?; try std.testing.expectEqual(@as(u16, 5), width_clip.height.?); try std.testing.expectEqual(@as(u16, 38), width_size.cols.?); try std.testing.expectEqual(@as(u16, 3), width.options.pixel_offset.?.y); const aspect_pixels = (@as(u32, width_size.cols.?) * 8 * width_clip.height.? + width_clip.width.? - 1) / width_clip.width.?; const inferred_rows = (aspect_pixels + 15) / 16; try std.testing.expect(inferred_rows * 16 <= base.native.geometry.?.dst.h); var height = base; height.native.fit = .height; height.native.geometry.?.src.h = 9; const height_fragment = kittyPlacement(height, 80, 16, 640, 256) orelse return error.MissingSafeKittyHeightFragment; try std.testing.expectEqual(@as(u16, 1), height_fragment.options.size.?.rows.?); try std.testing.expect(@as(u32, height_fragment.options.size.?.rows.?) * 16 <= height.native.geometry.?.dst.h); height.native.geometry.?.dst.h = 13; try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null); } test "host watch closes initial race and reloads rename-over PDF while idle" { if (comptime !file_watch.supported or !pardes.pdf_enabled) return; const io = std.testing.io; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const original = try pardes.pdf.makeOutlineTestPdf(gpa); defer gpa.free(original); const replacement = try pardes.pdf.makeNoOutlineTestPdf(gpa); defer gpa.free(replacement); try tmp.dir.writeFile(io, .{ .sub_path = "live.pdf", .data = original }); 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; const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/live.pdf", .{tmp.sub_path}); const fd = file_watch.init(true); if (fd < 0) return error.WatchInitFailed; defer _ = libc.close(fd); var watches: file_watch.Table = @splat(null); defer for (0..pardes.MAX_PANES) |id| file_watch.watchPane( fd, &watches, @intCast(id), null, 0, .{ .text = 0 }, ); const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); defer core.deinit(); try std.testing.expectEqual(@as(usize, 3), core.panes[0].?.pdf.?.page_count); 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, fd, &watches, 0, true, watch.mode)); }, else => {}, }; try std.testing.expect(armed and watches[0] != null); try std.testing.expectEqual(@as(usize, 1), core.panes[0].?.pdf.?.page_count); try tmp.dir.rename("live-replacement.pdf", tmp.dir, "live.pdf", io); try std.testing.expect(file_watch.drain(fd)); 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); const disk_identity = try file_watch.identify(io, path); switch (watches[0].?.generation) { .pdf => |accepted| try std.testing.expect(accepted != null and accepted.?.eql(disk_identity)), .text => return error.PdfWatchStoredTextGeneration, } try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "reloaded") != null); core.native_images = true; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const surface = try core.render(frame.allocator()); try std.testing.expect(surface.nimages > 0); try std.testing.expectEqual(@as(u32, 0), surface.images[0].?.native.page); } fn kittyImageRepresentable(place: pardes.ImagePlace) bool { return place.iw > 0 and place.ih > 0 and place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16); } fn surfaceHasKittyKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) bool { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (comptime pardes.pdf_enabled) if (!kittyImageRepresentable(place)) continue; if (place.cacheKey().eql(key)) return true; } return false; } const PdfWheelTarget = struct { pane: usize, page: usize }; 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 (mouse.button != .wheel_up and mouse.button != .wheel_down) or mouse.col < 0 or mouse.row < 0) return null; const col: u16 = @intCast(mouse.col); const row: u16 = @intCast(mouse.row); for (core.panes, 0..) |slot, id| { const pane = slot orelse continue; const rect = core.rects[id]; if (col < rect.x or col >= rect.x + rect.w or row < rect.y or row >= rect.y + rect.h) continue; const page = pane.pdfPage() orelse return null; return .{ .pane = id, .page = page }; } return null; } pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void { const io = init.io; const gpa = init.gpa; var attach_end: AttachEnd = .none; defer attach_end.report(io); 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, .none => { attach_end = if (requested.len != 0) .{ .no_session = requested } else .nothing_detached; return; }, .ambiguous => |found| { attach_end = .{ .ambiguous = found }; return; }, }; var winch_set = posix.sigemptyset(); posix.sigaddset(&winch_set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.BLOCK, &winch_set, null); var tty_buf: [0x10000]u8 = undefined; var tty = try vaxis.Tty.init(io, &tty_buf); defer tty.deinit(); // restore cooked termios LAST, after vx flushed its resets var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa }); defer vx.deinit(gpa, tty.writer()); try vx.enterAltScreen(tty.writer()); defer vx.exitAltScreen(tty.writer()) catch {}; try vx.setMouseMode(tty.writer(), true); try vx.setBracketedPaste(tty.writer(), true); if (attach != null) { attach_end = attachSession(init, attach_name, &tty, &vx); return; } var kitty_handles = std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image).init(gpa); defer { clearNativeImages(&kitty_handles, &vx, &tty); kitty_handles.deinit(); } 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 = .{}; var attached: ?detached_client.Client = null; defer if (attached != null) { stopInput(&loop); drainAttachedQueue(&loop, gpa); }; try localSession(init, opts, &tty, &vx, &loop, &input_cache, &kitty_handles, &paste_buf, &attached); if (attached) |*client| { defer client.detach(); var a: Attach = .{ .gpa = gpa, .client = client, .loop = &loop, .vx = &vx, .tty = &tty, .paste_buf = &paste_buf, }; attach_end = attachLoop(&a); } } 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, ) !void { const io = init.io; const gpa = init.gpa; 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; pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); defer { pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); } var core = if (options.load_path) |lp| blk: { const bytes = try filesystem.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.dump.initFromDump(allocs.pardes, options, bytes); } else try pardes.Pardes.init(allocs.pardes, options); defer core.deinit(); var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); defer frame_arena.deinit(); var fs = ninep_io.start(io, gpa, core); defer if (fs) |f| f.deinit(gpa); var sh: Shell = .{ .io = io, .gpa = gpa, .lsp_gpa = allocs.lsp, .core = core, .prompt_rcs = &prompt_rcs, .loop = loop, .vx = vx, .tty = tty, .kitty = kitty_handles, .frame = &frame_arena, .paste_buf = paste_buf, .inotify_fd = file_watch.init(false), .fs = fs, .test_clock = host_io.testClock(), }; pardes.lsp.setStatusSink(&sh, lspStatusSink); host_io.setExitWake(loop, wakeForExit); defer { host_io.setExitWake(null, null); pardes.lsp.setStatusSink(null, null); for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); if (attached.* != null) { _ = libc.kill(pt.pid, posix.SIG.KILL); _ = libc.waitpid(pt.pid, null, 0); } slot.* = null; }; if (sh.lsp_task) |*t| { t.future.cancel(io) catch {}; sh.lsp_task = null; } sh.pipe_tasks.cancelAll(io); file_watch.stop(sh.inotify_fd); if (sh.watch_task) |*t| { t.cancel(io) catch {}; sh.watch_task = null; } if (sh.inotify_fd >= 0) { _ = libc.close(sh.inotify_fd); sh.inotify_fd = -1; } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), .paste => |b| gpa.free(@constCast(b)), .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; response.deinit(gpa); }, else => {}, }; } const host = sh.host(); core.host = host; while (core.nextEffect()) |effect| core.perform(effect); try startInput(loop, input_cache); defer if (attached.* == null) stopInput(loop); (try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach(); (try std.Thread.spawn(.{}, wakeWatch, .{ io, loop })).detach(); if (fs) |f| try f.watch(loop, wakeFs); try vx.queryTerminalSend(tty.writer()); 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 }); }; if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, loop }) catch null; // A terminal unlinks what it reads; whatever it never read (the probe // unanswered, a transmission lost with the terminal) is ours to remove. defer { var name_buf: [64]u8 = undefined; var n: u32 = 0; while (n <= kitty_shm_serial) : (n += 1) { if (kittyShmName(&name_buf, n)) |name| _ = libc.shm_unlink(name) else |_| {} } } frames: while (!core.quit) { pardes.turn.restoreSettled(); try core.pump(host); if (core.takeRestore()) |rp| blk: { const bytes = filesystem.readRestore(gpa, rp, core.settings.dump_dir.get()) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; defer gpa.free(bytes); const nc = dump.restore(core, bytes, rp) 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); host_io.retireShell(pt.pid); slot.* = null; }; for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( sh.inotify_fd, &sh.watches, @intCast(wid), null, 0, .{ .text = 0 }, ); _ = 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(nc); core.deinit(); core = nc; sh.core = nc; if (comptime pardes.pdf_enabled) { updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix); } } if (core.takeAttach()) |req| { var attempt = detached_client.attempt(gpa, req.name, core.screen_w, core.screen_h); switch (attempt) { .greeted => |client| { attached.* = client; break :frames; }, else => { var mbuf: [256]u8 = undefined; core.setMessage(req.pane, attemptEnd(&attempt, req.name).row(&mbuf)); }, } } } if (fs) |f| { f.deinit(gpa); fs = null; sh.fs = null; } } fn clearNativeImages( kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), vx: *vaxis.Vaxis, tty: *vaxis.Tty, ) void { var iterator = kitty_handles.valueIterator(); while (iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); kitty_handles.clearRetainingCapacity(); } fn drainAttachedQueue(loop: *Loop, gpa: std.mem.Allocator) void { while (loop.tryEvent() catch null) |ev| switch (ev) { .paste => |b| gpa.free(@constCast(b)), else => {}, }; } const Shell = struct { io: std.Io, gpa: std.mem.Allocator, lsp_gpa: std.mem.Allocator, core: *pardes.Pardes, 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, paste_buf: *std.Io.Writer.Allocating, inotify_fd: c_int, fs: ?*ninep_io.Listener = null, ptys: [pardes.MAX_PANES]?Pty = @splat(null), gens: [pardes.MAX_PANES]u32 = @splat(0), watches: file_watch.Table = @splat(null), watch_task: ?std.Io.Future(anyerror!void) = null, lsp_task: ?host_io.Lsp.Task = null, pipe_tasks: PipeTasks = .{}, threads_ok: bool = false, caps_pending: bool = true, check_files: bool = false, in_paste: bool = false, tracks: []const pardes.animation.Track = &.{}, /// T2: the cursor's jump trail (JumpTrail). trail: JumpTrail = .{}, /// T7: when each pane's view last moved (ThumbFlash). flash: ThumbFlash = .{}, /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), and where the /// running-out wait moves it: the core's next wake. test_clock: ?u64 = null, wake_to: u64 = 0, fn of(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } fn host(s: *Shell) host_io.Host { return .{ .ctx = s, .vtable = if (comptime pardes.isolated) &isolated_vtable else &vtable }; } const isolated_vtable: host_io.Host.VTable = .{ .wait_input = waitInput, .now = clockNow, .present = present, .post_present = postPresent, }; const vtable: host_io.Host.VTable = .{ .wait_input = waitInput, .now = clockNow, .present = present, .post_present = postPresent, .poll_frame = pollFrame, .spawn = spawn, .pty_write = ptyWrite, .pty_resize = ptyResize, .pty_signal = ptySignal, .close_pty = closePty, .tty_taken = ttyTaken, .fg_name = fgName, .kill_job = killJob, .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, }; fn clockNow(ctx: ?*anyopaque) u64 { const s = of(ctx); return s.test_clock orelse host_io.monotonicNs(); } fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); var batch: usize = 0; // The wait is the 9P connections' turn with the core. A timeout (the // core's next wake) arrives through the same queue (`wakeWatch`). if (timeout_ms != 0) { s.wake_to = s.core.nextWake() orelse 0; requestWake(s.io, timeout_ms); } pardes.turn.rest(); const first = s.loop.nextEvent() catch { pardes.turn.wake(); s.core.quit = true; return s.reloadWatched(); }; pardes.turn.wake(); batch = 1; // A run of queued motions is one move to its last cell: each would // otherwise redo hover and selection work the next one undoes. var motion: ?@TypeOf(Command.value) = null; var ev = first; while (true) { if (ev == .mouse and (ev.mouse.type == .motion or ev.mouse.type == .drag)) { if (motion) |m| if (m.mouse.button != ev.mouse.button or m.mouse.type != ev.mouse.type or @as(u3, @bitCast(m.mouse.mods)) != @as(u3, @bitCast(ev.mouse.mods))) { _ = s.apply(m); }; motion = ev; } else { if (motion) |m| _ = s.apply(m); motion = null; if (s.apply(ev)) break; } if (!s.in_paste and batch >= 64) break; ev = (s.loop.tryEvent() catch null) orelse break; batch += 1; } if (motion) |m| _ = s.apply(m); // An exit's wake (a .nop) or output an exit waited behind: told // here, inside the step, so the frame after it shows it. s.takeExits(); s.reloadWatched(); } fn apply(s: *Shell, event: @TypeOf(Command.value)) bool { const core = s.core; const tz_event = tracy.zone(@src(), "event"); defer tz_event.end(); switch (event) { .nop => {}, .wake => if (s.test_clock) |*virtual| { virtual.* = @max(virtual.*, s.wake_to); }, .quit => { core.quit = true; return true; }, .focus_in => {}, .focus_out => core.update(.pointer_leave), .winsize => |ws| { s.vx.resize(s.gpa, s.tty.writer(), ws) catch {}; if (comptime pardes.pdf_enabled) updateCoreTerminalSize(core, ws.cols, ws.rows, ws.x_pixel, ws.y_pixel) else core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } }); }, .pty_read => |pr| { if (s.gens[pr.id] == pr.gen) core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); s.gpa.free(pr.bytes); // An exit waiting behind this output may be told now. s.takeExits(); return true; }, .pty_eof => |e| if (s.gens[e.id] == e.gen) { if (s.ptys[e.id]) |*pt| { pt.reader.await(s.io) catch {}; // reader just finished; join it or its future leaks // A command's pty stays open until its child has exited: // closing it would hang up one that runs on without it. // Its exit, if it came first, is told now its output is in. if (pt.cmd.watched) { host_io.commandEof(core, &s.ptys, e.id, s, closeWatched); return true; } _ = libc.close(pt.file.handle); // Unwatched, a command's exit is read here, as its end, // and a shell's, for a run waiting on it and the log. const command = if (core.panes[e.id]) |pn| pn.command != null else false; const status = host_io.exitStatus(pt.pid, 100); if (status == null) host_io.retireShell(pt.pid); s.ptys[e.id] = null; if (command or status != null) core.update(.{ .exited = .{ .pane = @intCast(e.id), .status = status } }); } core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); }, .key_press => |key| if (s.in_paste) { const bytes = pasteBytes(key); const room = max_paste_bytes -| s.paste_buf.written().len; s.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; } else core.update(keyEvent(key)), .mouse => |m| { const pdf_before = nativePdfWheelTarget(core, m); if (mouseEvent(m)) |ev| core.update(ev); if (pdf_before) |before| { if (core.panes[before.pane]) |pane| { if (pane.pdfPage()) |page| { if (page != before.page) return true; } } } }, .paste => |bytes| { core.update(.{ .paste = bytes }); s.gpa.free(@constCast(bytes)); }, .paste_start => { s.in_paste = true; s.paste_buf.clearRetainingCapacity(); }, .paste_end => { s.in_paste = false; const pasted = s.paste_buf.written(); if (pasted.len > 0) core.update(.{ .paste = pasted }); s.paste_buf.clearRetainingCapacity(); }, .files_changed => s.check_files = true, .fs_ready => {}, .lsp_done => |d| { core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); 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; }; }, .lsp_status => |text| { var mbuf: [256]u8 = undefined; core.setStatus(core.active, message.stamp(&mbuf, "lsp", text)); s.lsp_gpa.free(text); }, .pipe_done => |response_value| { var response = response_value; core.update(.{ .pipe_resp = .{ .id = response.id, .success = response.success, .outputs = response.outputs, .failure = response.failure, } }); response.deinit(s.gpa); s.pipe_tasks.finish(s.io, response_value.id); }, } return false; } fn reloadWatched(s: *Shell) void { if (!s.check_files) return; s.check_files = false; if (file_watch.reloadChanged(s.core, s.io, s.gpa, &s.watches)) s.loop.postEvent(.files_changed) catch {}; } fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); s.core.native_image_patches = kitty_shm.load(.acquire); if (s.fs) |f| if (f.tick().pending) { _ = s.loop.tryPostEvent(.fs_ready) catch {}; }; for (&s.ptys, 0..) |*slot, id| if (slot.*) |pt| { var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd); }; if (s.caps_pending and s.vx.queries_done.load(.unordered)) { s.caps_pending = false; s.vx.enableDetectedFeatures(s.tty.writer()) catch {}; s.core.native_images = s.vx.caps.kitty_graphics; if (s.vx.caps.kitty_graphics) kittyShmProbe(s.tty.writer()); s.vx.queueRefresh(); s.core.needs_frame = true; } } fn present(ctx: ?*anyopaque, canonical: *const pardes.Surface) void { const s = of(ctx); const vx = s.vx; s.tracks = canonical.panelTracks(); // The last frame is not answered yet: this one stays owed, and the // answer's wake draws the state as it is then. if (FrameAck.holds(host_io.monotonicNs(), s.io)) { s.core.present_skipped = true; return; } _ = s.frame.reset(.retain_capacity); const surface = panel_compositor.compose( s.frame.allocator(), @constCast(canonical), s.tracks, s.core.theme().bg, ) catch return; const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); const truecolor = vx.caps.rgb or colortermTruecolor(); // Each of the shell's own motions below asks for frames while it runs. s.core.shell_continuous = false; const shadowed = chipShadows(s.frame.allocator(), surface, s.core, truecolor) catch surface; const flashed = s.flash.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed; const trailed = s.trail.apply(s.frame.allocator(), flashed, s.core, truecolor) catch flashed; paintCells(win, trailed.cells, trailed.cols, trailed.rows); tz_cells.end(); for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (panel_compositor.hidesAttachment(surface.panelTracks(), place.serial)) continue; if (comptime pardes.pdf_enabled) { if (!kittyImageRepresentable(place)) continue; } const key = place.cacheKey(); if (!s.kitty.contains(key) and vx.caps.kitty_graphics and kitty_shm.load(.acquire)) shm: { var buf: [64]u8 = undefined; kitty_shm_serial +%= 1; const name = kittyShmName(&buf, kitty_shm_serial) catch break :shm; if (!kittyShmWrite(name, place.rgba)) break :shm; if (vx.transmitLocalImagePath(s.gpa, s.tty.writer(), name, @intCast(place.iw), @intCast(place.ih), .shared_mem, .rgba) catch null) |handle| s.kitty.put(key, handle) catch vx.freeImage(s.tty.writer(), handle.id) else _ = libc.shm_unlink(name); } if (!s.kitty.contains(key) and vx.caps.kitty_graphics) { const enc = std.base64.standard.Encoder; if (s.gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { defer s.gpa.free(b64); _ = enc.encode(b64, place.rgba); if (vx.transmitPreEncodedImage(s.tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle| s.kitty.put(key, handle) catch vx.freeImage(s.tty.writer(), handle.id); } else |_| {} } if (s.kitty.get(key)) |cached| { if (comptime !pardes.pdf_enabled) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); cached.draw(child, .{ .scale = .contain }) catch {}; } else { if (place.native.fit == .contain) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); cached.draw(child, .{ .scale = .contain }) catch {}; } else if (kittyPlacement( place, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix, )) |placement| { const child = win.child(.{ .x_off = @as(i17, place.x) + placement.cell_x, .y_off = @as(i17, place.y) + placement.cell_y, .width = placement.cell_cols, .height = placement.cell_rows, }); cached.draw(child, placement.options) catch {}; } } } } while (true) { var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; var stale_len: usize = 0; var image_iterator = s.kitty.iterator(); while (image_iterator.next()) |entry| { if (surfaceHasKittyKey(surface, entry.key_ptr.*)) continue; stale[stale_len] = entry.key_ptr.*; stale_len += 1; if (stale_len == stale.len) break; } for (stale[0..stale_len]) |key| if (s.kitty.fetchRemove(key)) |removed| vx.freeImage(s.tty.writer(), removed.value.id); if (stale_len < stale.len) break; } if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); const tz_render = tracy.zone(@src(), "vx.render"); const before = s.tty.tty_writer.pos + s.tty.writer().end; vx.render(s.tty.writer()) catch {}; if (s.tty.tty_writer.pos + s.tty.writer().end != before) FrameAck.ask(s.tty.writer(), host_io.monotonicNs()); tz_render.end(); } fn postPresent(ctx: ?*anyopaque) void { const s = of(ctx); s.core.acknowledgePanelPresentation(s.tracks); tracy.frameMark(); } fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = of(ctx); // The new shell starts before a running one goes: one that cannot // start leaves the running one be (pty/ctl's exec). const running = s.ptys[pane] != null; 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| { if (running) return s.core.restartFailed(pane, err); return s.core.shellFailed(pane, err); }; closePty(ctx, pane); // a shell still in the slot goes, reaped s.gens[pane] +%= 1; const command = if (s.core.panes[pane]) |pn| pn.command != null else false; s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} }, .cmd = .{ .watched = command and host_io.watchExit(child.pid), .fd = child.file.handle } }; var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; // Only a shell given no directory is asked where it is: one given a // directory is named by it already (newShell), and asked now, before // its chdir, it would answer this process's, a rename to undo. if (cwd.len == 0) 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; } } } fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = of(ctx); if (s.ptys[pane]) |pt| _ = host_io.writeFd(pt.file.handle, bytes); } fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = of(ctx); if (s.ptys[pane]) |pt| { const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } } fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = of(ctx); // A command whose exit is recorded is reaped: its group may be // another's by now. if (s.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig, pt.cmd.watched); } /// The pane is gone: hang its pty up, which the kernel passes on to the /// shell as SIGHUP, and retire the shell. Output still in flight /// carries the old generation and is dropped. fn closePty(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); var pt = s.ptys[pane] orelse return; s.ptys[pane] = null; s.gens[pane] +%= 1; pt.reader.cancel(s.io) catch {}; _ = libc.close(pt.file.handle); host_io.retireShell(pt.pid); } fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = of(ctx); const pt = s.ptys[pane] orelse return false; return host_io.ttyTaken(pt.pid, pt.file.handle); } fn fgName(ctx: ?*anyopaque, pane: u8, buf: []u8) ?[]const u8 { const s = of(ctx); const pt = s.ptys[pane] orelse return null; return host_io.foregroundName(pt.pid, pt.file.handle, buf); } /// The command panes' exits, told once their output is in (host_io). fn takeExits(s: *@This()) void { host_io.takeExits(s.core, &s.ptys, s, closeWatched); } fn closeWatched(s: *@This(), id: usize) void { const pt = &(s.ptys[id] orelse return); _ = libc.close(pt.file.handle); s.ptys[id] = null; } fn killJob(ctx: ?*anyopaque, pane: u8) bool { const s = of(ctx); const pt = s.ptys[pane] orelse return false; return host_io.killJob(pt.pid, pt.file.handle); } fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, path, err); 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 => {}, }; }; var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory); filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err); s.core.setLastDump(path); } 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.inotify_fd, &s.watches, pane, on, mode)) s.loop.postEvent(.files_changed) catch {}; } fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { const s = of(ctx); if (file_watch.applyThemeEffect(s.core, s.gpa, s.inotify_fd, &s.watches, generation, on, s.threads_ok)) s.loop.postEvent(.files_changed) catch {}; } fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); const config_dir = s.core.opts.config_dir orelse return; 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; }; defer s.gpa.free(out_dir); var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { const s = of(ctx); copyToClipboard(s.vx, s.tty, s.gpa, text); } fn readClipboard(ctx: ?*anyopaque) void { const s = of(ctx); requestClipboard(s.vx, s.tty); } fn openLink(_: ?*anyopaque, url: []const u8) void { look.openLink(url); // desktop browser; no terminal in it, so Attach calls this one directly } fn lsp(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const s = of(ctx); 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.future.cancel(s.io) catch {}; s.lsp_task = null; } const future = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch |err| { job.free(s.lsp_gpa); 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) { 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.pipe.pipeRequest(id) orelse return; 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); 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 })); } }; 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.body.cur_col = 2; pane.body.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 {}; } fn requestClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { vx.requestSystemClipboard(tty.writer()) catch {}; } fn lspWorker(allocator: std.mem.Allocator, job: *host_io.Lsp.Job, loop: *Loop) anyerror!void { var sink: LspRowSink = .{ .allocator = allocator, .loop = loop }; 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 { const s: *LspRowSink = @ptrCast(@alignCast(ctx orelse return)); s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch { if (rows) |owned| s.allocator.free(owned); }; } }; 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; const posted = s.loop.tryPostEvent(.{ .lsp_status = copy }) catch false; if (!posted) s.lsp_gpa.free(copy); } fn pipeWorker( io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, loop: *Loop, ) anyerror!void { defer job.deinit(gpa); var response = selection_pipe.runJob(gpa, io, job); loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa); } fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { if (comptime builtin.os.tag != .linux) { while (file_watch.wait(fd)) loop.postEvent(.files_changed) catch break; return; } const file: std.Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; var read_buf: [4096]u8 = undefined; var reader = file.readerStreaming(io, &read_buf); while (true) { var buf: [4096]u8 = undefined; var vec = [_][]u8{&buf}; const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; loop.postEvent(.files_changed) catch break; } } fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { var set = posix.sigemptyset(); posix.sigaddset(&set, posix.SIG.WINCH); while (true) { var sig: c_int = 0; if (libc.sigwait(&set, &sig) != 0) continue; if (vx.state.in_band_resize) continue; // terminal reports via CSI 48 const ws = tty.getWinsize() catch continue; loop.postEvent(.{ .winsize = ws }) catch {}; } } /// A wait with a timeout: post one `.wake` when it runs out. The wake joins /// the input queue, so a key or a click that lands first is handled at once /// instead of after a blind sleep; vaxis's queue has no timed wait. Each /// request bumps `wake_gen`, which cuts short a sleep still timing an /// earlier, longer request (an 800 ms linger interrupted by a key that /// starts a 16 ms fall) and times the new one instead. var wake_gen: std.atomic.Value(u32) = .init(0); var wake_ms: std.atomic.Value(u32) = .init(0); fn requestWake(io: std.Io, ms: u32) void { wake_ms.store(ms, .release); _ = wake_gen.fetchAdd(1, .acq_rel); io.futexWake(u32, &wake_gen.raw, 1); } fn wakeWatch(io: std.Io, loop: *Loop) void { var seen = wake_gen.load(.acquire); while (true) { // Asleep until a request. while (wake_gen.load(.acquire) == seen) io.futexWaitUncancelable(u32, &wake_gen.raw, seen); seen = wake_gen.load(.acquire); if (timeWake(io, &seen)) loop.postEvent(.wake) catch {}; } } /// Sleep out the latest request; true when it ran its course, false when /// none is pending. A newer request met on the way restarts the timing. fn timeWake(io: std.Io, seen: *u32) bool { while (true) { const ms = wake_ms.load(.acquire); const deadline = host_io.monotonicNs() + @as(u64, ms) * std.time.ns_per_ms; while (true) { const now = host_io.monotonicNs(); if (now >= deadline) return true; io.futexWaitTimeout(u32, &wake_gen.raw, seen.*, .{ .duration = .{ .raw = .fromNanoseconds(@intCast(deadline - now)), .clock = .awake, } }) catch {}; const gen = wake_gen.load(.acquire); if (gen != seen.*) { seen.* = gen; break; } } } } test "a short wake request cuts short a long one still sleeping" { const io = std.testing.io; var seen = wake_gen.load(.acquire); requestWake(io, 800); seen = wake_gen.load(.acquire); const Timer = struct { fn run(timer_io: std.Io, start_seen: u32, done: *std.atomic.Value(u64)) void { var local = start_seen; _ = timeWake(timer_io, &local); done.store(host_io.monotonicNs(), .release); } }; var done: std.atomic.Value(u64) = .init(0); const start = host_io.monotonicNs(); const thread = try std.Thread.spawn(.{}, Timer.run, .{ io, seen, &done }); var pause: std.c.timespec = .{ .sec = 0, .nsec = 50 * std.time.ns_per_ms }; _ = std.c.nanosleep(&pause, null); requestWake(io, 16); thread.join(); // The 16 ms request lands about 66 ms in: well before the 800 ms one // would have, however loaded the machine. try std.testing.expect(done.load(.acquire) - start < 700 * std.time.ns_per_ms); } fn wakeFs(ctx: ?*anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(ctx.?)); _ = loop.tryPostEvent(.fs_ready) catch {}; } fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, gen: u32, loop: *Loop) anyerror!void { var read_buf: [0x10000]u8 = undefined; var reader = pty.readerStreaming(io, &read_buf); while (true) { var buf: [0x10000]u8 = undefined; var vec = [_][]u8{&buf}; 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, .gen = gen, .bytes = bytes } }) catch { gpa.free(bytes); break; }; } _ = loop.tryPostEvent(.{ .pty_eof = .{ .id = id, .gen = gen } }) catch {}; } fn effCp(key: vaxis.Key) u21 { if (key.text) |t| { const view = std.unicode.Utf8View.init(t) catch return key.codepoint; var it = view.iterator(); if (it.nextCodepoint()) |c| { if (it.nextCodepoint() == null) return c; } } return key.shifted_codepoint orelse key.codepoint; } fn mapKey(cp: u21) u21 { return switch (cp) { vaxis.Key.kp_enter => pardes.Key.enter, vaxis.Key.up => pardes.Key.up, vaxis.Key.down => pardes.Key.down, vaxis.Key.left => pardes.Key.left, vaxis.Key.right => pardes.Key.right, vaxis.Key.home => pardes.Key.home, vaxis.Key.end => pardes.Key.end, vaxis.Key.page_up => pardes.Key.page_up, vaxis.Key.page_down => pardes.Key.page_down, vaxis.Key.delete => pardes.Key.delete, vaxis.Key.insert => pardes.Key.insert, vaxis.Key.print_screen => pardes.Key.print_screen, vaxis.Key.scroll_lock => pardes.Key.scroll_lock, vaxis.Key.pause => pardes.Key.pause, vaxis.Key.menu => pardes.Key.menu, vaxis.Key.f1...vaxis.Key.f25 => pardes.Key.f1 + (cp - vaxis.Key.f1), // The keypad's own keys, which the kitty keyboard protocol tells // apart, do what the keys they are named for do. vaxis.Key.kp_left => pardes.Key.left, vaxis.Key.kp_right => pardes.Key.right, vaxis.Key.kp_up => pardes.Key.up, vaxis.Key.kp_down => pardes.Key.down, vaxis.Key.kp_page_up => pardes.Key.page_up, vaxis.Key.kp_page_down => pardes.Key.page_down, vaxis.Key.kp_home => pardes.Key.home, vaxis.Key.kp_end => pardes.Key.end, vaxis.Key.kp_insert => pardes.Key.insert, vaxis.Key.kp_delete => pardes.Key.delete, else => cp, }; } const max_paste_bytes: usize = 4 << 20; 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"; if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) return "\n"; return ""; } fn keyEvent(key: vaxis.Key) pardes.Event { return .{ .key = .{ .cp = mapKey(effCp(key)), .text = key.text orelse "", .ctrl = key.mods.ctrl, .alt = key.mods.alt, .shift = key.mods.shift, } }; } test "a terminal pane gets function keys as their sequences and lone modifiers not at all" { 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); 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 inputFollows(self: *@This(), _: u32) bool { return self.next < self.parts.len; } 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 = &.{ // The startup width queries' answers from a terminal without // explicit width (foot, ghostty, tmux): two F3s, typed at the prompt. "\x1b[1;1R\x1b[1;1R\x1b[?62;c", // Kitty reports Ctrl and Shift pressed on their own: the Ctrl of a // Ctrl-V typed `\x1b[57442;5u` into the shell. "\x1b[57442;5u\x1b[57441;2u\x1b[57358u", // F3, F5 and Insert the kitty way, F3 the legacy way and the // keypad's Up: once typed as private-use junk, now what htop, // mc and vim read them as. "\x1b[13~\x1b[15~\x1b[2~\x1bOR\x1b[57419u", "\x1b[104;;104u", }, }; try readInput(&loop, &reader, &cache); const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); core.panes[0].?.body.mode = .tty; while (core.nextEffect()) |_| {} while (try loop.tryEvent()) |event| switch (event) { .key_press => |key| core.update(keyEvent(key)), else => {}, }; var written: std.Io.Writer.Allocating = .init(gpa); defer written.deinit(); while (core.nextEffect()) |effect| { if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice()); } try std.testing.expectEqualStrings("\x1bOR\x1b[15~\x1b[2~\x1bOR\x1b[Ah", written.written()); } test "keypad Enter matches Return for editor input terminal writes and paste" { for ([_]u21{ vaxis.Key.enter, vaxis.Key.kp_enter }) |codepoint| { const key: vaxis.Key = .{ .codepoint = codepoint }; const event = keyEvent(key); try std.testing.expectEqual(pardes.Key.enter, event.key.cp); try std.testing.expectEqualStrings("\n", pasteBytes(key)); { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); const pane = try core.setTestFile("ab"); core.update(.{ .key = .{ .cp = 'i' } }); core.update(event); try std.testing.expectEqualStrings("\nab", pane.file.?.content); } { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); core.panes[0].?.body.mode = .tty; while (core.nextEffect()) |_| {} core.update(event); var written: std.Io.Writer.Allocating = .init(std.testing.allocator); defer written.deinit(); while (core.nextEffect()) |effect| { if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice()); } try std.testing.expectEqualStrings("\r", written.written()); } } } fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { const button: pardes.Mouse.Button = switch (m.button) { .left => .left, .middle => .middle, .right => .right, .wheel_up => .wheel_up, .wheel_down => .wheel_down, .wheel_left => .wheel_left, .wheel_right => .wheel_right, .none => .none, // button-less motion: hover tracking else => return null, }; return .{ .mouse = .{ .button = button, .kind = switch (m.type) { .press => .press, .release => .release, .motion => .motion, .drag => .drag, }, .col = @intCast(m.col), .row = @intCast(m.row), .ctrl = m.mods.ctrl, } }; } /// A blank whose ink alone changed is not sent: vaxis's render skips it /// (pardes's patch to Vaxis.zig in build.zig), since a blank's ink shows /// nowhere. Deciding that here cost every cell of every frame a look at the /// frame before; vaxis already makes that comparison. fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { win.clear(); var y: u16 = 0; while (y < rows) : (y += 1) { var x: u16 = 0; while (x < cols) : (x += 1) { const cell = &cells[@as(usize, y) * cols + x]; if (cell.default) continue; win.writeCell(x, y, .{ .char = .{ .grapheme = cell.grapheme() }, .style = vaxisStyle(cell.style), }); } } } test "a fade sends the text it dims, not the blanks beside it" { const gpa = std.testing.allocator; var env = try std.testing.environ.createMap(gpa); defer env.deinit(); var vx = try vaxis.init(std.testing.io, gpa, &env, .{}); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); defer vx.deinit(gpa, &out.writer); try vx.resize(gpa, &out.writer, .{ .rows = 1, .cols = 32, .x_pixel = 0, .y_pixel = 0 }); // `a`, a gap, `b`, a two-cell gap, `c`, a gap, an underlined blank // (which shows its ink) and one more blank. var cells: [32]pardes.Cell = @splat(.{ .default = false }); cells[0].text[0] = 'a'; cells[12].text[0] = 'b'; cells[15].text[0] = 'c'; cells[30].style.ul = .single; var sent: [2][]u8 = .{ &.{}, &.{} }; defer for (sent) |bytes| gpa.free(bytes); for ([_][3]u8{ .{ 200, 200, 200 }, .{ 90, 90, 90 } }, 0..) |ink, frame| { for (&cells) |*cell| cell.style.fg = .{ .rgb = ink }; out.clearRetainingCapacity(); paintCells(vx.window(), &cells, 32, 1); try vx.render(&out.writer); sent[frame] = try out.toOwnedSlice(); } // Against a cleared screen every cell goes. After the fade the letters // go and the underlined blank; every plain blank is jumped. try std.testing.expectEqual(@as(usize, 29), std.mem.count(u8, sent[0], " ")); try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, sent[1], " ")); try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'b') != null); try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'c') != null); try std.testing.expect(std.mem.indexOf(u8, sent[1], "90:90:90") != null); try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'a') != null); } /// T1 (Β§9.2): each notice chip casts a shadow one cell right and one down, /// inside its own pane's body: its ground 35% toward black in OKLab on a /// cell with text; on a blank one a half block (β–Œ on the right, β–€ below, /// β–˜ at the corner) in the darker colour, so the shadow is half a cell /// thick and reads as one. Never the cursor's cell, a selection, a tag or /// another chip. Truecolor terminals only. fn chipShadows(arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface { if (!core.settings.chip_shadow or !truecolor) return surface; const regions = surface.regionList(); const any = for (regions) |region| { if (region.kind == .notice) break true; } else false; if (!any) return surface; const out = try arena.create(pardes.Surface); out.* = surface.*; out.cells = try arena.dupe(pardes.Cell, surface.cells); const theme = core.theme(); const page = theme.bg orelse pardes.colors.null_page; for (regions) |chip| { if (chip.kind != .notice or chip.rect.w == 0) continue; const body = for (regions) |region| { if (region.kind == .body and region.owner == chip.owner) break region.rect; } else continue; const right = chip.rect.x + chip.rect.w; const below = chip.rect.y + chip.rect.h; // The column right of it (from its second row), the row below (from // its second column), and the corner. var y = chip.rect.y; while (y <= below) : (y += 1) { var x = chip.rect.x; while (x <= right) : (x += 1) { const side = x == right and y > chip.rect.y and y < below; const foot = y == below and x > chip.rect.x and x < right; const corner = x == right and y == below; if (!side and !foot and !corner) continue; if (x < body.x or y < body.y or x >= body.x + body.w or y >= body.y + body.h or x >= out.cols or y >= out.rows) continue; if (surface.cursor) |cu| if (cu.x == x and cu.y == y) continue; const covered = for (regions) |region| { if ((region.kind == .notice or region.kind == .tag or region.kind == .grip) and x >= region.rect.x and x < region.rect.x + region.rect.w and y >= region.rect.y and y < region.rect.y + region.rect.h) break true; } else false; if (covered) continue; const cell = &out.cells[@as(usize, y) * out.cols + x]; const ground: [3]u8 = if (cell.default) page else switch (cell.style.bg) { .rgb => |rgb| rgb, else => page, }; if (std.mem.eql(u8, &ground, &theme.sel_bg)) continue; const dark = pardes.colors.mixOklab(ground, .{ 0, 0, 0 }, 0.35); const blank = cell.default or cell.len == 0 or (cell.len == 1 and cell.text[0] == ' '); if (blank) { const glyph: []const u8 = if (corner) "β–˜" else if (side) "β–Œ" else "β–€"; cell.* = .{ .default = false, .style = .{ .fg = .{ .rgb = dark }, .bg = .{ .rgb = ground } } }; @memcpy(cell.text[0..glyph.len], glyph); cell.len = @intCast(glyph.len); } else { cell.default = false; cell.style.bg = .{ .rgb = dark }; } } } } return out; } /// T7 (Β§9.2): a pane whose view moved (Region.line) has its scroll thumb /// brighten, drawn most of the way to the text's colour, and fade back /// over 250 ms with an ease-out: where you are in the file, as you move. /// The rail is chrome, not a focus indicator. Truecolor terminals only. const ThumbFlash = struct { lines: [pardes.MAX_PANES]i32 = @splat(std.math.minInt(i32)), since: [pardes.MAX_PANES]u64 = @splat(0), const life_ns: u64 = 250 * std.time.ns_per_ms; fn apply(flash: *ThumbFlash, arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface { const on = core.settings.thumb_flash and truecolor and core.settings.motion != .off; var out: ?*pardes.Surface = null; const theme = core.theme(); const ink = theme.fg orelse .{ 0xcc, 0xcc, 0xcc }; for (surface.regionList()) |body| { if (body.kind != .body or body.owner >= flash.lines.len) continue; const id = body.owner; if (flash.lines[id] != std.math.minInt(i32) and flash.lines[id] != body.line) flash.since[id] = core.now_ns; flash.lines[id] = body.line; const age = core.now_ns -| flash.since[id]; if (!on or flash.since[id] == 0 or age >= life_ns) continue; const rail = for (surface.regionList()) |region| { if (region.kind == .rail and region.owner == id) break region; } else continue; if (rail.thumb_h == 0) continue; const t = @as(f32, @floatFromInt(age)) / @as(f32, @floatFromInt(life_ns)); const strength = 0.6 * (1 - t) * (1 - t); if (out == null) { const copy = try arena.create(pardes.Surface); copy.* = surface.*; copy.cells = try arena.dupe(pardes.Cell, surface.cells); out = copy; } const o = out.?; var y = rail.rect.y + rail.thumb_y; while (y < rail.rect.y + rail.thumb_y + rail.thumb_h and y < o.rows) : (y += 1) { var x = rail.rect.x; while (x < rail.rect.x + rail.rect.w and x < o.cols) : (x += 1) { const cell = &o.cells[@as(usize, y) * o.cols + x]; const ground: [3]u8 = switch (cell.style.bg) { .rgb => |rgb| rgb, else => continue, }; if (!std.mem.eql(u8, &ground, &core.chromeTheme().scroll_thumb)) continue; cell.style.bg = .{ .rgb = pardes.colors.mixOklab(ground, ink, strength) }; } } core.shell_continuous = true; } return out orelse surface; } }; /// Truecolor by COLORTERM (truecolor or 24bit), for a terminal that does /// not answer vaxis's query (Β§9.2: vaxis caps or COLORTERM). fn colortermTruecolor() bool { const value = std.mem.span(std.c.getenv("COLORTERM") orelse return false); return std.mem.eql(u8, value, "truecolor") or std.mem.eql(u8, value, "24bit"); } /// T2 (docs/render-pipeline.md Β§9.2): the cursor jumping three cells or /// more leaves a trail of three to five cells along its path, just behind /// it, their ground drawn toward the text's colour, most beside the /// cursor, all fading out over 120 ms. Truecolor terminals only; a cell a /// frame changes, a few bytes. Never the cursor's own cell, a selection or /// anything outside the focused pane's body; off with Motion off. const JumpTrail = struct { last: ?[2]u16 = null, from: [2]u16 = .{ 0, 0 }, to: [2]u16 = .{ 0, 0 }, start_ns: u64 = 0, active: bool = false, const life_ns: u64 = 120 * std.time.ns_per_ms; fn apply(trail: *JumpTrail, arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface { defer core.shell_continuous = trail.active or core.shell_continuous; const on = core.settings.jump_trail and truecolor and core.settings.motion != .off; const cursor = surface.cursor orelse { trail.active = false; return surface; }; const here: [2]u16 = .{ cursor.x, cursor.y }; if (trail.last) |was| { const dx = @as(i32, here[0]) - was[0]; const dy = @as(i32, here[1]) - was[1]; if (on and @max(@abs(dx), @abs(dy)) >= 3) { trail.* = .{ .last = here, .from = was, .to = here, .start_ns = core.now_ns, .active = true }; } } trail.last = here; if (!on or !trail.active) { trail.active = false; return surface; } const age = core.now_ns -| trail.start_ns; if (age >= life_ns or !std.meta.eql(trail.to, here)) { trail.active = false; return surface; } const out = try arena.create(pardes.Surface); out.* = surface.*; out.cells = try arena.dupe(pardes.Cell, surface.cells); const theme = core.theme(); const ink = theme.fg orelse .{ 0xcc, 0xcc, 0xcc }; const page = theme.bg orelse pardes.colors.null_page; const body = for (surface.regionList()) |region| { if (region.kind == .body and region.owner == core.active) break region.rect; } else return out; const t = @as(f32, @floatFromInt(age)) / @as(f32, @floatFromInt(life_ns)); const fade = (1 - t) * (1 - t); const dx = @as(f32, @floatFromInt(@as(i32, trail.from[0]) - trail.to[0])); const dy = @as(f32, @floatFromInt(@as(i32, trail.from[1]) - trail.to[1])); const span = @max(@abs(dx), @abs(dy)); const n: usize = @intFromFloat(std.math.clamp(@round(span / 4), 3, 5)); var k: usize = 1; var last_cell: ?[2]i32 = null; var step: f32 = 1; while (k <= n and step <= span) : (step += 1) { const x: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(trail.to[0])) + dx * step / span)); const y: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(trail.to[1])) + dy * step / span)); if (last_cell) |seen| if (seen[0] == x and seen[1] == y) continue; last_cell = .{ x, y }; defer k += 1; if (x < body.x or y < body.y or x >= @as(i32, body.x) + body.w or y >= @as(i32, body.y) + body.h) continue; const cell = &out.cells[@as(usize, @intCast(y)) * out.cols + @as(usize, @intCast(x))]; const ground: [3]u8 = if (cell.default) page else switch (cell.style.bg) { .rgb => |rgb| rgb, else => page, }; if (std.mem.eql(u8, &ground, &theme.sel_bg)) continue; const strength = 0.55 * fade * (1 - @as(f32, @floatFromInt(k - 1)) / @as(f32, @floatFromInt(n))); if (strength < 0.02) continue; const was_default = cell.default; cell.default = false; if (was_default) cell.style = .{}; cell.style.bg = .{ .rgb = pardes.colors.mixOklab(ground, ink, strength) }; } return out; } }; fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void { win.showCursor(x, y); win.setCursorShape(if (bar) .beam else .default); } fn vaxisStyle(s: pardes.CellStyle) vaxis.Style { return .{ .fg = vaxisColor(s.fg), .bg = vaxisColor(s.bg), .bold = s.bold, .dim = s.dim, .italic = s.italic, .blink = s.blink, .reverse = s.reverse, .invisible = s.invisible, .strikethrough = s.strikethrough, .ul_style = switch (s.ul) { .off => .off, .single => .single, .double => .double, .curly => .curly, .dotted => .dotted, .dashed => .dashed, }, }; } fn vaxisColor(c: pardes.Color) vaxis.Color { return switch (c) { .default => .default, .index => |i| .{ .index = i }, .rgb => |rgb| .{ .rgb = rgb }, }; } const AttachEnd = union(enum) { none, no_session: []const u8, nothing_detached, ambiguous: usize, refused: wire.Refusal, lost: anyerror, rejected, silent, detached: []const u8, fn report(e: AttachEnd, io: std.Io) void { var buf: [512]u8 = undefined; var ok = false; const text: []const u8 = switch (e) { .none => return, .detached => |name| blk: { ok = true; break :blk if (name.len != 0) std.fmt.bufPrint( &buf, "pardes: detached from '{s}', which is still running β€” come back with `pardes --attach={s}`\n", .{ name, name }, ) catch "pardes: detached; that session is still running\n" else "pardes: detached; that session is still running β€” come back with `pardes --attach`\n"; }, .no_session => |name| std.fmt.bufPrint( &buf, "pardes: no detached session called '{s}' (start one with `pardes --detach={s}`)\n", .{ name, name }, ) catch "pardes: no detached session under that name\n", .nothing_detached => "pardes: no detached session is running (start one with `pardes --detach`)\n", .ambiguous => |n| std.fmt.bufPrint( &buf, "pardes: {d} detached sessions are running; say which with --attach=\n", .{n}, ) catch "pardes: several detached sessions are running; say which with --attach=\n", .refused => |why| switch (why) { .version => "pardes: that session speaks a different wire version β€” it is another build of pardes\n", .full => "pardes: that session already has every frontend slot taken\n", .quitting => "pardes: that session is ending\n", }, .lost => |err| std.fmt.bufPrint(&buf, "pardes: detached session lost: {t}\n", .{err}) catch "pardes: detached session lost\n", .rejected => "pardes: that session hung up on the connect β€” every frontend slot is taken (32), or it is shutting down. `PARDES_LOG=1` on the session names which\n", .silent => "pardes: that session accepted the connection and then never greeted it β€” it is wedged inside its own loop, or something else is listening at that path. `PARDES_LOG=1` on the session says which\n", }; const out = if (ok) std.Io.File.stdout() else std.Io.File.stderr(); out.writeStreamingAll(io, text) catch {}; if (!ok) std.process.exit(1); } fn row(e: AttachEnd, buf: []u8) []const u8 { return switch (e) { .none => "attach: that session ended", .no_session => |name| std.fmt.bufPrint(buf, "attach: no session '{s}'", .{name}) catch "attach: no session under that name", .nothing_detached => "attach: no detached session is running", .ambiguous => |n| std.fmt.bufPrint(buf, "attach: {d} sessions running, name one", .{n}) catch "attach: several sessions running, name one", .refused => |why| switch (why) { .version => "attach: that session is another build of pardes", .full => "attach: that session has every frontend slot taken", .quitting => "attach: that session is ending", }, .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", .detached => "attach: detached from that session", }; } }; const Attach = struct { gpa: std.mem.Allocator, client: *detached_client.Client, loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_buf: *std.Io.Writer.Allocating, session: []const u8 = &.{}, caps_pending: bool = true, in_paste: bool = false, dirty: bool = false, fn send(a: *Attach, ev: pardes.Event) ?AttachEnd { a.client.send(.{ .event = ev }) catch |err| switch (err) { error.Overlong, error.NoSpace => return null, else => return .{ .lost = err }, }; return null; } fn handle(a: *Attach, msg: wire.ServerMsg) ?AttachEnd { switch (msg) { .welcome => {}, .refuse => |why| return .{ .refused = why }, .frame => a.dirty = true, .quit => return .none, .detach => return .{ .detached = a.session }, .set_clipboard => |text| copyToClipboard(a.vx, a.tty, a.gpa, text), .read_clipboard => requestClipboard(a.vx, a.tty), .open_link => |url| look.openLink(url), // A terminal has no post chain to draw through. .post => {}, } return null; } fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd { switch (event) { .nop, .wake, .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 {}; a.dirty = true; a.client.resize(ws.cols, ws.rows) catch |err| return .{ .lost = err }; }, .key_press => |key| if (a.in_paste) { const bytes = pasteBytes(key); const room = max_paste_bytes -| a.paste_buf.written().len; a.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; } else return a.send(keyEvent(key)), .mouse => |m| if (mouseEvent(m)) |ev| return a.send(ev), .paste => |bytes| { defer a.gpa.free(@constCast(bytes)); return a.send(.{ .paste = bytes }); }, .paste_start => { a.in_paste = true; a.paste_buf.clearRetainingCapacity(); }, .paste_end => { a.in_paste = false; defer a.paste_buf.clearRetainingCapacity(); const pasted = a.paste_buf.written(); if (pasted.len > 0) return a.send(.{ .paste = pasted }); }, } return null; } 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 {}; a.vx.queueRefresh(); a.dirty = true; } fn paint(a: *Attach) void { a.dirty = false; const win = a.vx.window(); 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 {}; } }; fn attemptEnd(a: *const detached_client.Attempt, requested: []const u8) AttachEnd { return switch (a.*) { .greeted => .none, .no_session => if (requested.len != 0) .{ .no_session = requested } else .nothing_detached, .ambiguous => |found| .{ .ambiguous = found }, .refused => |why| .{ .refused = why }, .silent => .silent, .lost => |err| if (err == error.Closed) .rejected else .{ .lost = err }, }; } fn attachLoop(a: *Attach) AttachEnd { while (true) { const link = a.client.wait(detached_client.poll_ms); 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 }; var batch: usize = 0; while (a.in_paste or batch < 64) { const ev = (a.loop.tryEvent() catch |err| return .{ .lost = err }) orelse break; batch += 1; if (a.apply(ev)) |end| return end; } a.enableCaps(); if (a.dirty) a.paint(); } } fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx: *vaxis.Vaxis) AttachEnd { const io = init.io; const gpa = init.gpa; 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 }; var attempt = detached_client.attempt(gpa, name, ws.cols, ws.rows); var client = switch (attempt) { .greeted => |c| c, else => return attemptEnd(&attempt, name), }; 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(); var a: Attach = .{ .gpa = gpa, .client = &client, .loop = &loop, .vx = vx, .tty = tty, .paste_buf = &paste_buf, .session = name, }; defer drainAttachedQueue(&loop, gpa); 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(); vx.queryTerminalSend(tty.writer()) catch {}; return attachLoop(&a); } test "a jump leaves a trail of a few cells behind the cursor that fades in 120 ms" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer core.deinit(); _ = try core.setTestFile("some text on a line\n" ** 20); try std.testing.expect(core.executeBuiltinLine(0, "Theme forge")); core.settings.jump_trail = true; for (0..40) |_| core.update(.tick); core.sync(); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const s = try core.render(arena.allocator()); const body = for (s.regionList()) |region| { if (region.kind == .body) break region.rect; } else return error.NoBody; var trail: JumpTrail = .{}; core.now_ns = std.time.ns_per_s; s.cursor = .{ .x = body.x + 2, .y = body.y + 1 }; _ = try trail.apply(arena.allocator(), s, core, true); // Ten cells right: a trail back toward where it was. s.cursor = .{ .x = body.x + 12, .y = body.y + 1 }; core.now_ns += 16 * std.time.ns_per_ms; const lit = try trail.apply(arena.allocator(), s, core, true); try std.testing.expect(trail.active and core.shell_continuous); var changed: usize = 0; for (lit.cells, s.cells, 0..) |a, b, i| { if (std.meta.eql(a.style.bg, b.style.bg)) continue; changed += 1; // On the cursor's row, behind it, never its own cell. try std.testing.expectEqual(@as(usize, body.y + 1), i / s.cols); try std.testing.expect(i % s.cols < body.x + 12 and i % s.cols > body.x + 2); } try std.testing.expect(changed >= 3 and changed <= 5); // Gone by 120 ms; a step of a cell leaves none; nor a terminal without // truecolor. core.now_ns += 130 * std.time.ns_per_ms; core.shell_continuous = false; const faded = try trail.apply(arena.allocator(), s, core, true); try std.testing.expect(faded == s and !trail.active and !core.shell_continuous); s.cursor = .{ .x = body.x + 13, .y = body.y + 1 }; try std.testing.expect(try trail.apply(arena.allocator(), s, core, true) == s); s.cursor = .{ .x = body.x + 2, .y = body.y + 1 }; try std.testing.expect(try trail.apply(arena.allocator(), s, core, false) == s); } test "a notice chip casts a cell's shadow right and down, inside its body, off the cursor" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer core.deinit(); _ = try core.setTestFile("some text on a line\n" ** 20); try std.testing.expect(core.executeBuiltinLine(0, "Theme forge")); core.settings.chip_shadow = true; try std.testing.expect(core.executeBuiltinLine(0, "Msg a notice")); for (0..60) |_| core.update(.tick); core.sync(); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const s = try core.render(arena.allocator()); const chip = for (s.regionList()) |region| { if (region.kind == .notice) break region.rect; } else return error.NoNotice; s.cursor = null; const out = try chipShadows(arena.allocator(), s, core, true); try std.testing.expect(out != s); var changed: usize = 0; for (out.cells, s.cells, 0..) |a, b, i| { if (std.meta.eql(a, b)) continue; changed += 1; const x = i % s.cols; const y = i / s.cols; // Right of the chip or below it, never inside it. try std.testing.expect(x == chip.x + chip.w or y == chip.y + chip.h); try std.testing.expect(!(x >= chip.x and x < chip.x + chip.w and y >= chip.y and y < chip.y + chip.h)); } try std.testing.expect(changed >= chip.w - 1); // Off, or without truecolor: the frame as it was. try std.testing.expect(try chipShadows(arena.allocator(), s, core, false) == s); core.settings.chip_shadow = false; try std.testing.expect(try chipShadows(arena.allocator(), s, core, true) == s); } test "a scrolled pane's thumb brightens and fades back in 250 ms" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer core.deinit(); const pane = try core.setTestFile("some text on a line\n" ** 200); try std.testing.expect(core.executeBuiltinLine(0, "Theme forge")); core.settings.thumb_flash = true; for (0..40) |_| core.update(.tick); core.sync(); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); var flash: ThumbFlash = .{}; core.now_ns = std.time.ns_per_s; const first = try core.render(arena.allocator()); try std.testing.expect(try flash.apply(arena.allocator(), first, core, true) == first); pane.scrollBy(20); core.now_ns += 16 * std.time.ns_per_ms; const moved = try core.render(arena.allocator()); const lit = try flash.apply(arena.allocator(), moved, core, true); try std.testing.expect(lit != moved and core.shell_continuous); var brighter: usize = 0; for (lit.cells, moved.cells) |a, b| { if (!std.meta.eql(a.style.bg, b.style.bg)) brighter += 1; } try std.testing.expect(brighter >= 1); core.now_ns += 300 * std.time.ns_per_ms; core.shell_continuous = false; const later = try core.render(arena.allocator()); try std.testing.expect(try flash.apply(arena.allocator(), later, core, true) == later); try std.testing.expect(!core.shell_continuous); }