summaryrefslogtreecommitdiff
path: root/src/panes.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/panes.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/panes.zig')
-rw-r--r--src/panes.zig484
1 files changed, 462 insertions, 22 deletions
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,