From 9695cb098c784792af79c22d46a17f7bc7247d33 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 22 Sep 2026 17:43:57 -0300 Subject: Repaint PDF highlights by row, send rasters by shared memory, and animate messages PDF highlights (hover preview, search, selection) are baked into page rasters, and any change re-rendered the whole page with MuPDF; the TTY then re-sent it as base64 (4.7 MB a page), the GUI as a new texture. Worse, a pointer motion over a PDF invalidated the page even when no preview was shown, so every motion paid that. Now: - A raster whose baked highlight set equals the wanted one is left alone. - A highlighted page keeps its clean rows (before highlights and tint); a change repaints only the rows of quads that differ, running MuPDF's highlight pass (pardes_pdf_paint_highlights) over those clean rows and tinting them: the operations a full render performs, so the pixels are identical. MuPDF band renders are NOT bit-identical to a whole page (edge rows, resampled images), so they are never used to patch; the comment claiming otherwise is corrected. - ImagePlace.patch hands shells the changed rows; the GUI uploads just those rows into the texture it holds. - The TTY probes kitty shared memory (t=s) with an id vaxis never reaches and sends rasters as a /dev/shm name when the terminal reads it; direct base64 otherwise (ssh). - Shells that take row patches (GUI, TTY with shm) repaint a selection while it is dragged instead of only on release. Latency elsewhere: - TTY: an animating frame no longer sleeps 16 ms blind; a tick thread posts into the input queue, so input inside the frame is handled at once. - TTY and GUI: queued pointer motions coalesce to the last. - GUI: a skipped swapchain image re-arms the frame (3 retries); animations still tick while nothing presents. - Editing: the line index is carried across an edit instead of rebuilt from a scan of the whole file per keystroke. Messages fall into their row (ease-in; the GUI slides the band out from under the tagline, a terminal fades it), stay until the next input as before, linger MessageLinger ms (default 800), and dissolve (ease-out). MessageAnimation toggles it; both are settings, in Config and startup files. The snapshot harness pins the old behaviour. The detached server now ticks animations. A restored terminal comes back live: the old screen and scrollback (dumped as clean VT by ghostty's formatter, replayed at the new size; older dumps fall back to their rendered text), a dim "restored history" marker, then a new shell in the directory it was in. Right-click on a line number in a file pane looks at that line (a sticky context header's number included). Measured with an external pty driver (TTY), an in-process fence trace (GUI, PARDES_TEST_LATENCY), and test/pdf_pointer_bench.zig (pixel identity against the baseline and a whole-page oracle); balanced A/A/B rounds, paired per-round statistics. Messages stack: each event gets its own row and its own fall, linger and dissolve; a line keeps its row until it leaves and a new one fills the first free row. Announcements and statuses are replaced in place, not stacked. MessageFall, MessageDissolve and DumpDir are settings Config reports. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tty/tty.zig | 148 +++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 126 insertions(+), 22 deletions(-) (limited to 'src/tty') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 03ccd3e2..5976852f 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -21,6 +21,56 @@ extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: 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, @@ -81,6 +131,13 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { const received = try tty.read(buf[carried..]); if (received == 0) return; const end = carried + received; + 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; @@ -717,6 +774,7 @@ fn localSession( try startInput(loop, input_cache); defer if (attached.* == null) stopInput(loop); (try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach(); + (try std.Thread.spawn(.{}, tickWatch, .{ io, loop })).detach(); if (fs) |f| try f.watch(loop, wakeFs); try vx.queryTerminalSend(tty.writer()); @@ -726,11 +784,20 @@ fn localSession( }; 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) catch |err| { + const bytes = filesystem.readRestore(gpa, rp, core.settings.dump_dir.get()) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; @@ -872,29 +939,39 @@ const Shell = struct { 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. - if (timeout_ms == 0) { - pardes.turn.rest(); - const first = s.loop.nextEvent() catch { - pardes.turn.wake(); - s.core.quit = true; - return s.reloadWatched(); - }; - pardes.turn.wake(); - batch = 1; - if (s.apply(first)) return s.reloadWatched(); - } else { - pardes.turn.rest(); - std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) catch {}; + // The wait is the 9P connections' turn with the core. Animating, the + // frame's tick arrives through the same queue (`tickWatch`). + if (timeout_ms != 0) tick_request.set(s.io); + pardes.turn.rest(); + const first = s.loop.nextEvent() catch { pardes.turn.wake(); - _ = s.apply(.tick); - batch = 1; - } - while (s.in_paste or batch < 64) { - const ev = (s.loop.tryEvent() catch null) orelse break; + 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 (s.apply(ev)) break; } + if (motion) |m| _ = s.apply(m); s.reloadWatched(); } @@ -1001,6 +1078,7 @@ const Shell = struct { 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 {}; }; @@ -1013,6 +1091,7 @@ const Shell = struct { 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(); } } @@ -1039,6 +1118,16 @@ const Shell = struct { 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| { @@ -1160,7 +1249,7 @@ const Shell = struct { fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf) orelse return; + const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return; filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } @@ -1415,6 +1504,21 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } +/// Set by an animating frame's wait: post one `.tick` a frame from now. The +/// tick joins the input queue, so a key or a click that lands inside the +/// frame is handled at once instead of after a blind sleep. +var tick_request: std.Io.Event = .unset; + +fn tickWatch(io: std.Io, loop: *Loop) void { + while (true) { + tick_request.waitUncancelable(io); + tick_request.reset(); + var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.layout.Animation.frame_ms) * std.time.ns_per_ms }; + _ = libc.nanosleep(&ts, null); + loop.postEvent(.tick) catch {}; + } +} + fn wakeFs(ctx: ?*anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(ctx.?)); _ = loop.tryPostEvent(.fs_ready) catch {}; -- cgit v1.3