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/panes.zig | 484 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 462 insertions(+), 22 deletions(-) (limited to 'src/panes.zig') diff --git a/src/panes.zig b/src/panes.zig index b3424385..8d4330e9 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -183,6 +183,20 @@ pub const Pane = struct { tty_filter: bool = false, msg: [256]u8 = undefined, msg_len: u16 = 0, + /// Where the newest message line is in its life. + msg_life: MessageLife = .{}, + /// The row (among the notice rows) the newest line holds for its life. + msg_slot: u8 = 0, + /// Earlier message lines still on screen, oldest first, each running its + /// own life: a new message stacks under them instead of replacing them. + msg_older: [message_stack]OlderMessage = undefined, + msg_older_len: u8 = 0, + /// The newest line is a builtin announcing itself in this step: what + /// the step says next replaces it rather than stacking under it. + msg_announcement: bool = false, + /// The newest line is a status (progress, a server's state): the next + /// line replaces it rather than stacking under it. + msg_status: bool = false, /// The transient lines this pane shows between its body and its tagline: /// a builtin's message, the pending leader chord, a prompt waiting for /// input. Rebuilt every frame from the state that owns each one, so a @@ -192,30 +206,102 @@ pub const Pane = struct { /// reserves rows for sticky context headers. notices: Notices = .{}, + pub const MessagePhase = enum { entering, shown, lingering, leaving }; + /// One message line's animation: falling in, shown, lingering after the + /// key or click that dismissed it, dissolving. `frame` counts animation + /// frames within the phase. + pub const MessageLife = struct { + phase: MessagePhase = .shown, + frame: u16 = 0, + /// Dismissed while still falling: it lingers once it has landed. + dismissed: bool = false, + }; + /// Older message lines kept on screen under a new one. + pub const message_stack = 3; + pub const OlderMessage = struct { + text: [256]u8 = undefined, + len: u16 = 0, + life: MessageLife = .{}, + slot: u8 = 0, + + pub fn slice(line: *const OlderMessage) []const u8 { + return line.text[0..line.len]; + } + }; + + /// Move the newest line onto the stack of older ones, dropping the oldest + /// when it is full; the newest slot is left empty for what arrives. + pub fn pushOlderMessage(pane: *Pane) void { + if (pane.msg_older_len == message_stack) { + std.mem.copyForwards(OlderMessage, pane.msg_older[0 .. message_stack - 1], pane.msg_older[1..]); + pane.msg_older_len -= 1; + } + pane.msg_older[pane.msg_older_len] = .{ .text = pane.msg, .len = pane.msg_len, .life = pane.msg_life, .slot = pane.msg_slot }; + pane.msg_older_len += 1; + pane.msg_len = 0; + pane.msg_life = .{}; + } + + /// The first row no older line holds. A line keeps its row until it + /// leaves, and a new one fills the first gap: nothing on screen moves. + pub fn freeMessageSlot(pane: *const Pane) u8 { + var used: u8 = 0; + for (pane.msg_older[0..pane.msg_older_len]) |line| used |= @as(u8, 1) << @intCast(line.slot); + var slot: u8 = 0; + while (used & (@as(u8, 1) << @intCast(slot)) != 0) slot += 1; + return slot; + } + + pub fn removeOlderMessage(pane: *Pane, i: usize) void { + std.mem.copyForwards(OlderMessage, pane.msg_older[i .. pane.msg_older_len - 1], pane.msg_older[i + 1 .. pane.msg_older_len]); + pane.msg_older_len -= 1; + } + pub const Notices = struct { /// Painted bottom-up in this order, so a prompt keeps the row nearest /// the tagline that it has always had, and its cursor with it. - pub const Kind = enum { message, leader, prompt }; - pub const max = @typeInfo(Kind).@"enum".fields.len; + /// `older` is an earlier message line still on screen; `index` says + /// which (`Pane.msg_older`). + pub const Kind = enum { older, message, leader, prompt }; + pub const max = @typeInfo(Kind).@"enum".fields.len - 1 + message_stack; kinds: [max]Kind = undefined, + index: [max]u8 = @splat(0), + /// Each band's row below the body's top: message lines keep theirs, + /// so a band that leaves leaves a gap rather than moving the rest. + row: [max]u8 = @splat(0), /// Where each band's chip starts, as an absolute grid column. A chip /// is only as wide as its own text, so the rest of the row underneath /// is ordinary body text and a click there has to reach it. left: [max]u16 = @splat(std.math.maxInt(u16)), len: u8 = 0, + /// A band on the next row after every band so far. pub fn push(n: *Notices, kind: Kind) void { + n.pushAt(kind, 0, n.span()); + } + + pub fn pushAt(n: *Notices, kind: Kind, index: u8, row: u8) void { if (n.len >= max) return; n.kinds[n.len] = kind; + n.index[n.len] = index; + n.row[n.len] = row; n.left[n.len] = std.math.maxInt(u16); n.len += 1; } /// Is this cell inside the chip drawn for band `i`? Nothing else on /// that row belongs to the notice. - pub fn covers(n: *const Notices, i: usize, col: u16) bool { - return i < n.len and col >= n.left[i]; + pub fn covers(n: *const Notices, row: usize, col: u16) bool { + for (0..n.len) |i| if (n.row[i] == row) return col >= n.left[i]; + return false; + } + + /// Rows from the body's top down to the last band's. + pub fn span(n: *const Notices) u8 { + var rows: u8 = 0; + for (n.row[0..n.len]) |row| rows = @max(rows, row + 1); + return rows; } pub fn slice(n: *const Notices) []const Kind { @@ -1327,7 +1413,15 @@ pub const File = struct { const file = &pane.file.?; for ([_][]const u8{ "", "one", "one\n", "a\r\nλ界\nlast", "\n\n", "\tλ e\u{301}\n\r\n" }) |content| { setContent(p, file, try p.gpa.dupe(u8, content)); - try std.testing.expectEqual(@as(usize, 0), file.line_starts.len); + // An edit carries a built index forward; it must equal a fresh one. + if (file.line_starts.len > 0) { + const carried = file.line_starts; + defer p.gpa.free(carried); + file.line_starts = &.{}; + try std.testing.expectEqualSlices(usize, try lineIndex(p.gpa, file), carried); + p.gpa.free(file.line_starts); + file.line_starts = &.{}; + } const rows = lineCount(content) + 2; for ([_]bool{ false, true }) |indexed| { if (indexed) _ = try lineIndex(p.gpa, file); @@ -1546,6 +1640,80 @@ pub const File = struct { return starts; } + /// The line index of `new` from `old`'s: an edit replaces one span, so + /// line starts before it stand, starts after it shift, and only the span + /// is scanned. Rebuilding scanned the whole file on every keystroke. + fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8) ![]usize { + const n = @min(old.len, new.len); + var head: usize = 0; + while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64; + while (head < n and old[head] == new[head]) head += 1; + var tail: usize = 0; + while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64; + while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1; + // A start s follows the newline at s-1: kept while that newline is in + // the common head, shifted while it is in the common tail. + const kept = std.sort.upperBound(usize, starts, head, struct { + fn order(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.order); + const old_tail_at = old.len - tail; + const moved = starts.len - std.sort.upperBound(usize, starts, old_tail_at, struct { + fn order(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.order); + const middle = new[head .. new.len - tail]; + const out = try gpa.alloc(usize, kept + std.mem.count(u8, middle, "\n") + moved); + @memcpy(out[0..kept], starts[0..kept]); + var i = kept; + var off: usize = 0; + while (std.mem.indexOfScalarPos(u8, middle, off, '\n')) |nl| { + off = nl + 1; + out[i] = head + off; + i += 1; + } + for (starts[starts.len - moved ..]) |start| { + out[i] = start + new.len - old.len; + i += 1; + } + return out; + } + + test "edited line index equals a rebuilt one" { + const gpa = std.testing.allocator; + var prng = std.Random.DefaultPrng.init(7); + const r = prng.random(); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + for (0..300) |_| try text.append(gpa, "ab\n"[r.uintLessThan(usize, 3)]); + for (0..2000) |_| { + var old_state: State = undefined; + old_state.content = text.items; + old_state.line_starts = &.{}; + const starts = try lineIndex(gpa, &old_state); + defer gpa.free(starts); + // one random replace, like any edit through setContent + const at = r.uintAtMost(usize, text.items.len); + const del = r.uintAtMost(usize, @min(8, text.items.len - at)); + var ins: [8]u8 = undefined; + const ins_len = r.uintAtMost(usize, 8); + for (ins[0..ins_len]) |*c| c.* = "ab\n"[r.uintLessThan(usize, 3)]; + const old = try gpa.dupe(u8, text.items); + defer gpa.free(old); + try text.replaceRange(gpa, at, del, ins[0..ins_len]); + const edited = try editedLineStarts(gpa, starts, old, text.items); + defer gpa.free(edited); + var fresh_state: State = undefined; + fresh_state.content = text.items; + fresh_state.line_starts = &.{}; + const fresh = try lineIndex(gpa, &fresh_state); + defer gpa.free(fresh); + try std.testing.expectEqualSlices(usize, fresh, edited); + } + } + /// line count, O(1) once the index is warm pub fn nlines(gpa: std.mem.Allocator, f: *State) usize { const idx = lineIndex(gpa, f) catch return lineCount(f.content); @@ -1760,12 +1928,13 @@ pub const File = struct { reportEdit(p, f, new); if (f.mini) |*mini| mini.deinit(p.gpa); f.mini = null; + const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{}; p.gpa.free(f.content); f.content = new; f.revision +%= 1; f.mtime = pardes.ctlfs.events.now(); if (f.line_starts.len > 0) p.gpa.free(f.line_starts); - f.line_starts = &.{}; + f.line_starts = starts; if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); f.highlights = &.{}; f.highlight_start = 0; @@ -2864,7 +3033,10 @@ pub const Output = struct { pub fn openConfig(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); + var dir_buf: [1024]u8 = undefined; + const host_fs = comptime (pardes.platform != .web and pardes.platform != .esp32p4); try config.Runtime.writeReport(&out.writer, .{ + .default_dump_dir = if (host_fs) (pardes.dump.defaultDirectory(&dir_buf) orelse "") else "", .startup_config_path = p.opts.startup_config_path, .platform = @tagName(pardes.platform), .theme_name = p.theme().name, @@ -3599,6 +3771,9 @@ pub const Pdf = struct { const Page = struct { page: usize, items: []const Highlight }; pages: [3]Page = undefined, len: usize = 0, + /// Repaint whenever the set changes, not only when a page's raster was + /// invalidated (`HighlightInput.live`). + live: bool = false, pub fn forPage(highlights: @This(), page: usize) []const Highlight { for (highlights.pages[0..highlights.len]) |group| @@ -3612,6 +3787,7 @@ pub const Pdf = struct { hover_page: ?usize = null, hover_color: [3]u8, selection_color: [3]u8, + live: bool = false, } else void; pub fn buildHighlights( @@ -3623,7 +3799,7 @@ pub const Pdf = struct { const search_len = if (state.search_results) |results| results.quads.len else 0; const selection_len = if (state.selection) |selection| selection.quads.len else 0; const items = try arena.alloc(Highlight, input.hover_quads.len + search_len + selection_len); - var result: Highlights = .{}; + var result: Highlights = .{ .live = input.live }; var n: usize = 0; // Hover, search and selection can belong to three different pages. // Group them once so raster rendering can borrow a contiguous slice. @@ -3721,6 +3897,19 @@ pub const Pdf = struct { decorated: bool = false, tint_key: ?TintKey = null, revision: u32 = 0, + /// `rgba` before its highlights and tint, kept once the page has been + /// highlighted: a highlight change repaints rows from it instead of + /// rendering the page again. + clean: []u8 = &.{}, + /// The highlights baked into `rgba` (gpa-owned), so a change repaints + /// only the rows of the quads that differ. + baked: []Highlight = &.{}, + /// `baked` is a true record (false after a render could not copy it). + baked_valid: bool = false, + /// Page rows changed since revision `patch_from`, the one last handed + /// to a shell; 0 when the pixels were replaced wholesale since. + patch_from: u32 = 0, + patch_rows: pdf.Document.Rows = .{ .start = 0, .end = 0 }, } else void; pub const SectionsOutput = if (enabled) struct { @@ -3894,6 +4083,8 @@ pub const Pdf = struct { } fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void { + if (raster.clean.len > 0) gpa.free(raster.clean); + gpa.free(raster.baked); state.retireRgba(gpa, raster.rgba); raster.* = .{}; } @@ -4138,8 +4329,11 @@ pub const Pdf = struct { pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void { gpa.free(state.path); - for (state.rasters[0..state.rasters_len]) |raster| + for (state.rasters[0..state.rasters_len]) |raster| { if (raster.rgba.len > 0) gpa.free(raster.rgba); + if (raster.clean.len > 0) gpa.free(raster.clean); + gpa.free(raster.baked); + } for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba); gpa.free(state.page_sizes); gpa.free(state.page_starts); @@ -4614,16 +4808,27 @@ pub const Pdf = struct { const slot = raster.?; const page_highlights = highlights.forPage(page); const decorated = page_highlights.len > 0; - const stale = !slot.tried or !slot.request_valid or - !slot.request.eql(request) or slot.decorated != decorated or + const base_stale = !slot.request_valid or !slot.request.eql(request) or slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or slot.rgba.len == 0 or slot.band_h == 0; - const uncovered = !stale and uncovered: { + const uncovered = !base_stale and uncovered: { const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging); break :uncovered slot.band_y > want.y or slot.band_y + slot.band_h < want.y + want.height; }; - if (!stale and !uncovered) continue; + if (!base_stale and !uncovered) { + if (slot.tried and slot.decorated == decorated and + !(highlights.live and !sameHighlights(slot.baked, page_highlights))) continue; + // Invalidated, but it already shows exactly these highlights + // (a preview withdrawn that was never drawn, on every pointer + // move over a page): nothing to render. + if (slot.baked_valid and sameHighlights(slot.baked, page_highlights)) { + slot.tried = true; + continue; + } + // Only the highlights moved: repaint their rows in place. + if (repaintHighlights(state, gpa, slot, request, tint_key, page_highlights)) continue; + } slot.tried = true; slot.request = request; @@ -4640,15 +4845,27 @@ pub const Pdf = struct { { const tz_render = tracy.zone(@src(), "pdf.render_into"); defer tz_render.end(); + // A highlighted page keeps its clean rows for repaints. + const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0); state.document.renderIntoAtWithPaper( page, request, shape, want, - page_highlights, + if (keep_clean) &.{} else page_highlights, fresh, pardes.platform == .macos, ) catch break :filled false; + if (slot.clean.len != 0 and (!keep_clean or slot.clean.len != fresh.len)) { + gpa.free(slot.clean); + slot.clean = &.{}; + } + if (keep_clean) { + if (slot.clean.len == 0) slot.clean = gpa.alloc(u8, fresh.len) catch &.{}; + if (slot.clean.len == fresh.len) @memcpy(slot.clean, fresh); + state.document.paintHighlightsAt(page, request, shape, want, page_highlights, fresh) catch + break :filled false; + } } const tz_tint = tracy.zone(@src(), "pdf.tint"); defer tz_tint.end(); @@ -4658,6 +4875,9 @@ pub const Pdf = struct { }; if (!filled) { state.retireRgba(gpa, fresh); + // `clean` may already hold the band that failed. + if (slot.clean.len > 0) gpa.free(slot.clean); + slot.clean = &.{}; continue; } @@ -4669,6 +4889,18 @@ pub const Pdf = struct { slot.band_h = want.height; slot.decorated = decorated; slot.tint_key = tint_key; + if (gpa.dupe(Highlight, page_highlights)) |copy| { + gpa.free(slot.baked); + slot.baked = copy; + slot.baked_valid = true; + } else |_| { + // Unrecorded highlights cannot be compared or diffed. + slot.baked_valid = false; + if (slot.clean.len > 0) gpa.free(slot.clean); + slot.clean = &.{}; + } + slot.patch_from = 0; + slot.patch_rows = .{ .start = 0, .end = 0 }; state.next_raster_revision +%= 1; if (state.next_raster_revision == 0) state.next_raster_revision = 1; slot.revision = state.next_raster_revision; @@ -4676,6 +4908,114 @@ pub const Pdf = struct { state.trimSpares(gpa); } + /// Repaint only the rows where the old and new highlights differ: the + /// clean rows kept from the page's last render, MuPDF's highlight pass + /// (the whole new set) over them, the per-pixel tint — the operations a + /// full render performs, in its order, on fewer rows and without running + /// the page again. False leaves the slot for the full path. + fn repaintHighlights( + state: *State, + gpa: std.mem.Allocator, + slot: *Raster, + request: RenderRequest, + tint_key: TintKey, + page_highlights: []const Highlight, + ) bool { + const tz = tracy.zone(@src(), "pdf.repaint_highlights"); + defer tz.end(); + if (slot.clean.len != slot.rgba.len or !slot.baked_valid) return false; + if (sameHighlights(slot.baked, page_highlights)) { + slot.tried = true; + return true; + } + const baked = gpa.dupe(Highlight, page_highlights) catch return false; + const changed = changedHighlights(gpa, slot.baked, page_highlights) catch { + gpa.free(baked); + return false; + }; + defer gpa.free(changed); + var dirty = state.document.highlightRows(slot.page, request, changed) catch { + gpa.free(baked); + return false; + }; + slot.tried = true; + dirty.start = @max(dirty.start, slot.band_y); + dirty.end = @min(dirty.end, slot.band_y + slot.band_h); + if (dirty.start < dirty.end) { + const shape = slotShape(slot); + const band = shape.band(dirty.start, dirty.end - dirty.start); + const offset = (dirty.start - slot.band_y) * shape.stride; + const out = slot.rgba[offset..][0..band.len]; + @memcpy(out, slot.clean[offset..][0..band.len]); + // On failure the rows are half-made: let the full path redo them. + state.document.paintHighlightsAt(slot.page, request, shape, band, page_highlights, out) catch { + gpa.free(baked); + slot.tried = false; + return false; + }; + pdf.tintRgba(out, tint_key.mode, tint_key.colors) catch { + gpa.free(baked); + slot.tried = false; + return false; + }; + slot.patch_rows = unionRows(slot.patch_rows, dirty); + state.next_raster_revision +%= 1; + if (state.next_raster_revision == 0) state.next_raster_revision = 1; + slot.revision = state.next_raster_revision; + } + gpa.free(slot.baked); + slot.baked = baked; + slot.decorated = page_highlights.len > 0; + return true; + } + + fn sameHighlights(a: []const Highlight, b: []const Highlight) bool { + return std.mem.eql(u8, std.mem.sliceAsBytes(a), std.mem.sliceAsBytes(b)); + } + + /// The highlights in one set and not the other, as multisets (a quad + /// painted twice blends twice). When only the order changed, blending + /// order did, so everything counts. + // ponytail: quadratic match; past 64k comparisons every quad counts. + fn changedHighlights(gpa: std.mem.Allocator, old: []const Highlight, new: []const Highlight) ![]Highlight { + const out = try gpa.alloc(Highlight, old.len + new.len); + errdefer gpa.free(out); + var n: usize = 0; + if (old.len * new.len <= 1 << 16) { + const matched = try gpa.alloc(bool, new.len); + defer gpa.free(matched); + @memset(matched, false); + for (old) |h| { + for (new, matched) |candidate, *used| { + if (!used.* and std.mem.eql(u8, std.mem.asBytes(&h), std.mem.asBytes(&candidate))) { + used.* = true; + break; + } + } else { + out[n] = h; + n += 1; + } + } + for (new, matched) |h, used| if (!used) { + out[n] = h; + n += 1; + }; + } + if (n == 0) { + @memcpy(out[0..old.len], old); + @memcpy(out[old.len..], new); + return out; + } + // Freed by length: hand back exactly what the caller will free. + return gpa.realloc(out, n); + } + + fn unionRows(a: pdf.Document.Rows, b: pdf.Document.Rows) pdf.Document.Rows { + if (a.start >= a.end) return b; + if (b.start >= b.end) return a; + return .{ .start = @min(a.start, b.start), .end = @max(a.end, b.end) }; + } + pub fn renderFrame( state: *State, gpa: std.mem.Allocator, @@ -5837,6 +6177,7 @@ pub const Pdf = struct { .hover_page = hover_page, .hover_color = if (link_hover) core.theme().box else selection_color, .selection_color = selection_color, + .live = core.native_image_patches, }; } @@ -5907,6 +6248,16 @@ pub const Pdf = struct { const visible_end = visible.first + visible.len; while (page < visible_end) : (page += 1) { const placed = placedRaster(state, view, page) orelse continue; + const slot = rasterForPage(state, page).?; + const patch: ?pardes.ImagePatch = if (slot.patch_from != 0 and + slot.patch_rows.start < slot.patch_rows.end) + .{ + .from = slot.patch_from, + .y = @intCast(slot.patch_rows.start - slot.band_y), + .h = @intCast(slot.patch_rows.end - slot.patch_rows.start), + } + else + null; if (!core.appendImagePlace(.{ .pane = @intCast(pane_id), .serial = pane.serial, @@ -5928,14 +6279,18 @@ pub const Pdf = struct { .x = text_x, // Below the notice chips, like an image: a placed page is // drawn after the cells and would paint a chip out. - .y = core.bodyTop(rect) + pane.notices.len, + .y = core.bodyTop(rect) + pane.notices.span(), .w = text_width, - .h = (rect.h -| pardes.BOX_H) -| pane.notices.len, + .h = (rect.h -| pardes.BOX_H) -| pane.notices.span(), .rgba = placed.rgba, .iw = placed.width, // The texture contains the retained band, not the full page. .ih = placed.band_height, + .patch = patch, })) break; + // A shell now holds (or will fetch whole) this revision. + slot.patch_from = slot.revision; + slot.patch_rows = .{ .start = 0, .end = 0 }; placed_any = true; } if (!placed_any) return false; @@ -6439,9 +6794,15 @@ pub const Terminal = struct { return pane; } - /// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy - /// stay with the core; raw VT replay and viewport restoration belong here. - pub fn restore(p: *Pardes, src: dump.Pane) !*Pane { + /// Where a restored terminal's history ends and its new shell begins. + pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n"; + + /// Rebuild a dump's terminal emulator from its recorded output. `live`: a + /// shell will be spawned into it, so the history is marked off from what + /// that shell prints and the pane waits for it like any new terminal. + /// Registration and tag/cwd policy stay with the core; raw VT replay and + /// viewport restoration belong here. + pub fn restore(p: *Pardes, src: dump.Pane, live: bool) !*Pane { const terminal = src.terminal.?; if (comptime !enabled) { const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); @@ -6450,12 +6811,28 @@ pub const Terminal = struct { pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; return pane; } - const bytes = if (terminal.stream_b64.len > 0) + // An older dump's raw output tail cannot come back live: cut off mid + // redraw, or written at sizes the pane no longer has, it replays as + // garbage. Its rendered text can. + const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0; + const bytes = if (legacy_text) + try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n") + else if (terminal.stream_b64.len > 0) try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) else &.{}; const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - if (bytes.len > 0) { + if (live) { + if (bytes.len > 0) { + ingest(pane, bytes); + ingest(pane, restored_banner); + } + followOutput(pane); + // A view left scrolled back stays on what it was showing: the + // banner's line breaks scrolled a full screen by that many more. + if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n")))); + armShellSpawn(pane); + } else if (bytes.len > 0) { ingest(pane, bytes); followOutput(pane); if (src.scroll > 0) @@ -6613,7 +6990,7 @@ pub const Terminal = struct { .cols = pane.cols, .rows = pane.rows, .terminal = .{ .stream_b64 = encoded }, - }); + }, false); defer gpa.destroy(restored); defer deinitEmulator(restored, gpa); const before = try screenTextAlloc(pane, gpa); @@ -6624,6 +7001,58 @@ pub const Terminal = struct { try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa)); } + test "a restored terminal keeps its history above a marker and gets a shell where it was" { + if (comptime !enabled or pardes.isolated) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + feedOutput(p, pane, "old output\r\n$ ls\r\n"); + try pane.setOwnedCwd("/tmp"); + try p.dumpState(); + const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?); + defer restored.deinit(); + var spawned = false; + while (restored.nextEffect()) |effect| switch (effect) { + .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"), + else => {}, + }; + try std.testing.expect(spawned); + const again = restored.panes[0].?; + try std.testing.expectEqual(.spawn, again.pending_command.wait); + const text = try screenTextAlloc(again, gpa); + defer gpa.free(text); + const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost; + const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker; + try std.testing.expect(old_at < mark_at); + } + + test "an older dump comes back live from its rendered text, not its raw output tail" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + // A tail cut mid-redraw: relative moves from a cursor it never set. + const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore"); + defer gpa.free(tail); + const restored = try restore(p, .{ + .kind = .terminal, + .tag = "", + .body = "", + .cols = 40, + .rows = 12, + .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail }, + }, true); + defer gpa.destroy(restored); + defer deinitEmulator(restored, gpa); + defer deinitPendingCommand(restored); + const text = try screenTextAlloc(restored, gpa); + defer gpa.free(text); + try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null); + } + /// Record and parse one live pty read, invalidate its motion surface, and /// follow it only when the body (possibly parked under a tag edit) is raw. pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { @@ -7247,6 +7676,16 @@ pub const Terminal = struct { overlay.text = new; } + /// The shell's screen and scrollback (the primary screen, even under a + /// full-screen program) as VT that replays to what was shown: styled + /// text, soft-wrapped lines rejoined so they reflow at another width. + fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 { + const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{}; + var out: std.Io.Writer.Allocating = .init(arena); + try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer); + return out.written(); + } + /// Serialize terminal-only state; the core supplies shared pane metadata. pub fn dumpPane( pane: *Pane, @@ -7318,7 +7757,8 @@ pub const Terminal = struct { .terminal = .{ .cwd = try arena.dupe(u8, pane.cwdSlice()), .stream = stream[0..len], - .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)), + .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)), + .stream_is_screen = true, .cursor = .{ .col = pane.terminal.?.vt.screens.active.cursor.x, .row = pane.terminal.?.vt.screens.active.cursor.y, -- cgit v1.3