summaryrefslogtreecommitdiff
path: root/src/tty/tty.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-22 17:43:57 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit9695cb098c784792af79c22d46a17f7bc7247d33 (patch)
treefa446f3cb588b3184b7914b3f50c36cd3e5ece7b /src/tty/tty.zig
parentf962de52df4b3d33438150920ae95b8708b0dde0 (diff)
downloadpardes-9695cb098c784792af79c22d46a17f7bc7247d33.tar.gz
pardes-9695cb098c784792af79c22d46a17f7bc7247d33.zip
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) <[email protected]>
Diffstat (limited to 'src/tty/tty.zig')
-rw-r--r--src/tty/tty.zig148
1 files changed, 126 insertions, 22 deletions
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=<id>;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();
- };
+ // 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();
- batch = 1;
- if (s.apply(first)) return s.reloadWatched();
- } else {
- pardes.turn.rest();
- std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) 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 {};