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/gui/gui.zig | 110 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 19 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 17bdb46f..7f28124c 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2111,8 +2111,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u } var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; - // A latency run renders to the capture texture; keep it off the desktop. - if (latency_fd >= 0) win_flags |= c.SDL_WINDOW_HIDDEN; + // A test run renders to the capture texture; keep it off the desktop. + if (test_mode) win_flags |= c.SDL_WINDOW_HIDDEN; const native_wayland = !test_mode and std.mem.eql(u8, std.mem.span(c.SDL_GetCurrentVideoDriver()), "wayland"); // Background-only opacity needs per-pixel alpha; whole-window compositor // opacity would dim the text as well. SDL requires this flag at creation. @@ -2353,6 +2353,7 @@ fn localSession( } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); core.native_images = true; + core.native_image_patches = true; observeGuiFont(g, core); syncTaglineFont(g, core); @@ -2448,7 +2449,7 @@ fn localSession( } } 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; }; @@ -2473,6 +2474,7 @@ fn localSession( g.presented_images.clearRetainingCapacity(); g.prepared_images.clearRetainingCapacity(); nc.native_images = true; + nc.native_image_patches = true; nc.host = host; if (fs) |f| f.reset(nc); core.deinit(); @@ -3591,6 +3593,7 @@ const Shell = struct { test_mode: bool = false, feed: StdinFeed = .{}, presented: bool = false, + skipped_presents: u8 = 0, surface: ?*pardes.Surface = null, animation_clock: AnimationClock = .{}, saw_event: bool = false, @@ -3774,14 +3777,29 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { var in: Input = .{ .core = core }; if (s.gui) |g| { var sev = std.mem.zeroes(c.SDL_Event); - const ms: c_int = if (timeout_ms != 0) @intCast(timeout_ms) else 16; + // A latency trace is fed on stdin, whose poll below paces the loop: + // waiting here too would make every tick cost two frames. + const ms: c_int = if (latency_fd >= 0) 0 else if (timeout_ms != 0) @intCast(timeout_ms) else 16; // The wait is the 9P connections' turn with the core. pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); pardes.turn.wake(); if (got) { - dispatch(g, &in, &sev); - while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev); + // A run of queued motions is one move to its last position (the + // handler reads only absolute x/y and held buttons): acting on + // each would repeat hover and selection work the next undoes. + var motion: ?c.SDL_Event = null; + while (true) { + if (sev.type == c.SDL_EVENT_MOUSE_MOTION) { + motion = sev; + } else { + if (motion) |*m| dispatch(g, &in, m); + motion = null; + dispatch(g, &in, &sev); + } + if (!c.SDL_PollEvent(&sev)) break; + } + if (motion) |*m| dispatch(g, &in, m); } } if (s.test_mode) { @@ -3913,6 +3931,10 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { log.err("render: {t}", .{err}); break :blk false; }; + // A skipped swapchain image would otherwise hide this input's frame until + // the next event. A few retries only: a minimized window never gets one. + s.skipped_presents = if (s.presented) 0 else s.skipped_presents +| 1; + if (!s.presented and s.skipped_presents <= 3) core.present_skipped = true; if (g.scene_target_failed) { g.scene_failures +|= 1; log.err("scene target unavailable (attempt {d}/3)", .{g.scene_failures}); @@ -3926,7 +3948,12 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { fn postPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); const g = s.gui orelse return; - if (!s.presented) return; + if (!s.presented) { + // No image (a minimized window): time still passes, so a lingering + // message or a hover wait runs out instead of redrawing forever. + if (s.animation_clock.due(s.core.animationActive(), c.SDL_GetTicksNS())) s.core.update(.tick); + return; + } const frame = s.surface orelse return; refreshPresentedPointer(g, s.core); finishPresentedAnimationFrame( @@ -4030,7 +4057,7 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = shellOf(ctx); var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf) orelse return; + 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); } @@ -4533,6 +4560,23 @@ fn uploadNativeTexture( const byte_len = validImageBytes(place) orelse return error.BadImage; const key = place.cacheKey(); if (g.native_images.contains(key)) return; + if (comptime pardes.pdf_enabled) if (place.patch) |patch| patch: { + // The core changed only these rows since a revision we still hold: + // write them into that texture rather than sending the whole page. + var base = key; + base.revision = patch.from; + if (patch.y + patch.h > place.ih) break :patch; + const texture = g.native_images.get(base) orelse break :patch; + const row = place.iw * 4; + try uploadTextureRows(g, cmd, texture, place.rgba[patch.y * row ..][0 .. patch.h * row], place.iw, patch.y, patch.h); + try g.native_images.put(gpa, key, texture); + _ = g.native_images.remove(base); + // A transition snapshot of the last frame follows its texture. + for (g.presented_images.items) |*saved| if (saved.key.eql(base)) { + saved.key = key; + }; + return; + }; var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; @@ -4545,39 +4589,51 @@ fn uploadNativeTexture( tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const texture = c.SDL_CreateGPUTexture(g.device, &tex_info) orelse return error.GpuCreate; errdefer c.SDL_ReleaseGPUTexture(g.device, texture); + try uploadTextureRows(g, cmd, texture, place.rgba[0..byte_len], place.iw, 0, place.ih); + try g.native_images.put(gpa, key, texture); +} +/// Copy `rows` rows of RGBA, `width` pixels each, into `texture` at row `y`. +fn uploadTextureRows( + g: *Gui, + cmd: *c.SDL_GPUCommandBuffer, + texture: *c.SDL_GPUTexture, + rgba: []const u8, + width: usize, + y: usize, + rows: usize, +) !void { var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, - .size = byte_len, + .size = @intCast(rgba.len), .props = 0, }; const transfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; defer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); const mapped: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, transfer, false) orelse return error.GpuMap); - @memcpy(mapped[0..byte_len], place.rgba); + @memcpy(mapped[0..rgba.len], rgba); c.SDL_UnmapGPUTransferBuffer(g.device, transfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = transfer, .offset = 0, - .pixels_per_row = @intCast(place.iw), - .rows_per_layer = @intCast(place.ih), + .pixels_per_row = @intCast(width), + .rows_per_layer = @intCast(rows), }; const dst = c.SDL_GPUTextureRegion{ .texture = texture, .mip_level = 0, .layer = 0, .x = 0, - .y = 0, + .y = @intCast(y), .z = 0, - .w = @intCast(place.iw), - .h = @intCast(place.ih), + .w = @intCast(width), + .h = @intCast(rows), .d = 1, }; c.SDL_UploadToGPUTexture(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); - try g.native_images.put(gpa, key, texture); } fn appendPreparedImage( @@ -5442,16 +5498,30 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * bw; const background: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = layer.bg } } }; - emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = right - left, .h = bh, .x_off = left, .y_off = 0 }, win_w, win_h, track, .body, &background, old_layer, false, page); + // A band sliding into place is drawn offset and cut at its row's top, so + // it comes out from under whatever is above it. + const slide = layer.slide * bh; + const top = @as(f32, @floatFromInt(layer.viewport.y)) * bh; + emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, &background, old_layer, false, page); + if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; for (0..tagLayerCellCount(g, layer)) |col| { const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false; - emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = 0 }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page); + emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page); clipInstanceRight(&instances[next.*], right, win_w); + if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; } } +fn clipInstanceTop(instance: *CellInstance, top: f32, win_h: f32) void { + const limit = 1 - top / win_h * 2; + if (instance.y0 <= limit) return; + const fraction = std.math.clamp((limit - instance.y1) / (instance.y0 - instance.y1), 0, 1); + instance.v0 = instance.v1 + (instance.v0 - instance.v1) * fraction; + instance.y0 = @max(instance.y1, limit); +} + fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.BodyLayer { return bodyLayerIn(surface.bodyLayers(), col, row); } @@ -6436,7 +6506,9 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t } fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { - if (g.capture_dir.len == 0) return; + // A latency trace times frames, and an 8 MB file write per frame would + // stall the loop it is timing. + if (g.capture_dir.len == 0 or latency_fd >= 0) return; const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); if (bpp != 4) return error.UnsupportedCaptureFormat; const bgr = switch (g.swapchain_format) { -- cgit v1.3