summaryrefslogtreecommitdiff
path: root/src/Presentation.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 12:57:47 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit30c2c8a154e770b3920bcfd7d553825fe9182bf4 (patch)
treeecdce274f1cb25095c72f4da62b7c35e23aee04c /src/Presentation.zig
parent8f31fc6bfaae2d29b2bb5cc2149fcd7033cd9724 (diff)
downloadpardes-30c2c8a154e770b3920bcfd7d553825fe9182bf4.tar.gz
pardes-30c2c8a154e770b3920bcfd7d553825fe9182bf4.zip
Move the frame into draw.zig and the transitions out of layout.zig
Pure moves, no behaviour change. draw.zig holds the whole core frame in order: render, the pane, tag, header and notice painters it calls, and the character-effect composition (Pardes.render stays a declaration alias). Presentation.zig is the panel presentation state as a file struct, and animation.zig the easing curves, transition kinds, tracks and boxes, the character effects' sources and the generic displayed-value transition, all of which lived in layout.zig. layout.zig keeps only layout.
Diffstat (limited to 'src/Presentation.zig')
-rw-r--r--src/Presentation.zig721
1 files changed, 721 insertions, 0 deletions
diff --git a/src/Presentation.zig b/src/Presentation.zig
new file mode 100644
index 00000000..fe8317dd
--- /dev/null
+++ b/src/Presentation.zig
@@ -0,0 +1,721 @@
+//! Panel presentation: which pane geometry the shells have been shown, the
+//! transition tracks that move it there, and the frozen previous grid the
+//! character and dissolve effects read. Only acknowledged draws advance the
+//! shown geometry; committed and submitted may be ahead of it.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const layout = @import("layout.zig");
+const animation = @import("animation.zig");
+const Pardes = pardes.Pardes;
+const MAX_PANES = pardes.MAX_PANES;
+const Box = animation.Box;
+const Track = animation.Track;
+const Phase = animation.Phase;
+const Transition = animation.Transition;
+const CellArea = animation.CellArea;
+const CharSource = animation.CharSource;
+const charSource = animation.charSource;
+const dissolveRevealed = animation.dissolveRevealed;
+const openingBox = animation.openingBox;
+const closingBox = animation.closingBox;
+const paintOrder = animation.paintOrder;
+const panelBox = layout.panelBox;
+
+const Presentation = @This();
+
+pub const Snapshot = struct { serial: u32, box: Box };
+
+// Only acknowledged draws advance shown geometry; committed and submitted may be ahead.
+committed: [MAX_PANES]?Snapshot = @splat(null),
+initialized: bool = false,
+enabled: bool = false,
+snap_once: bool = false,
+tracks: [MAX_PANES]?Track = @splat(null),
+closing: [MAX_PANES]Track = undefined,
+closing_len: usize = 0,
+submitted: [MAX_PANES]?Snapshot = @splat(null),
+submitted_ready: bool = false,
+shown: [MAX_PANES]?Snapshot = @splat(null),
+shown_tracks: [MAX_PANES]?Track = @splat(null),
+shown_closing: [MAX_PANES]Track = undefined,
+shown_closing_len: usize = 0,
+acknowledged: bool = false,
+pending: bool = false,
+previous_cells: []pardes.Cell = &.{},
+previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}),
+previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}),
+previous_cols: u16 = 0,
+previous_rows: u16 = 0,
+previous_valid: bool = false,
+previous_layout: [MAX_PANES]?Snapshot = @splat(null),
+diffs: []pardes.PanelCellDiff = &.{},
+diff_state: enum { none, requested, ready } = .none,
+
+pub const CellPosition = struct { col: u16, row: u16 };
+
+pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void {
+ if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
+ for (&self.previous_body_layers) |*layer| gpa.free(layer.cells);
+ for (&self.previous_tag_layers) |*layer| gpa.free(layer.cells);
+ if (self.diffs.len > 0) gpa.free(self.diffs);
+}
+
+pub fn sync(self: *Presentation, p: *Pardes) void {
+ const initializing = !self.initialized or !self.enabled;
+ if (initializing or self.snap_once) {
+ const had_layout = self.initialized;
+ self.committed = @splat(null);
+ self.tracks = @splat(null);
+ self.closing_len = 0;
+ self.diff_state = .none;
+ if (initializing) {
+ self.shown_tracks = @splat(null);
+ self.shown_closing_len = 0;
+ }
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ }
+ self.initialized = true;
+ self.snap_once = false;
+ if (had_layout and self.acknowledged)
+ self.pending = true;
+ return;
+ }
+
+ const effect = p.settings.panel_transition;
+ if (effect.needsPreviousGrid() and self.diff_state != .none) {
+ var second_change = false;
+ for (p.panes, self.committed, 0..) |slot, snapshot, id| {
+ const pane = slot orelse {
+ second_change = second_change or snapshot != null;
+ continue;
+ };
+ const target = panelBox(p.rects[id]);
+ second_change = second_change or snapshot == null or
+ snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target);
+ }
+ if (second_change) {
+ p.abandonPanelAnimations();
+ self.committed = @splat(null);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ }
+ return;
+ }
+ }
+ var changed = false;
+ var animated_change = false;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse {
+ if (self.committed[id]) |old| {
+ changed = true;
+ if (effect.lifecycleOnly()) {
+ if (self.appendClosing(.{
+ .serial = old.serial,
+ .pane = @intCast(id),
+ .phase = .closing,
+ .effect = effect,
+ .from = old.box,
+ .to = closingBox(effect, old.box),
+ })) animated_change = true;
+ }
+ }
+ self.committed[id] = null;
+ self.tracks[id] = null;
+ // Never let pixels from a dead pane address a reused slot.
+ self.shown_tracks[id] = null;
+ continue;
+ };
+ const target = panelBox(p.rects[id]);
+ const previous = self.committed[id];
+ self.committed[id] = .{ .serial = pane.serial, .box = target };
+
+ if (effect == .off) {
+ changed = changed or previous == null or previous.?.serial != pane.serial or
+ !previous.?.box.eql(target);
+ self.tracks[id] = null;
+ continue;
+ }
+ if (previous) |old| {
+ if (old.serial == pane.serial and old.box.eql(target)) continue;
+ const same_lifetime = old.serial == pane.serial;
+ const prior = if (self.tracks[id]) |track|
+ if (track.serial == pane.serial and track.active()) track else null
+ else
+ null;
+ if (effect.lifecycleOnly() and same_lifetime) {
+ if (prior) |active| if (active.phase == .opening) {
+ var next = active;
+ next.from = openingBox(effect, target, p.screen_w);
+ next.to = target;
+ self.tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ continue;
+ };
+ self.tracks[id] = null;
+ changed = true;
+ continue;
+ }
+ if (effect.lifecycleOnly() and !same_lifetime) {
+ _ = self.appendClosing(.{
+ .serial = old.serial,
+ .pane = @intCast(id),
+ .phase = .closing,
+ .effect = effect,
+ .from = old.box,
+ .to = closingBox(effect, old.box),
+ });
+ }
+ const shown = if (self.shown[id]) |snapshot|
+ if (snapshot.serial == pane.serial) snapshot.box else null
+ else
+ null;
+ const from = if (!same_lifetime)
+ openingBox(effect, target, p.screen_w)
+ else if (shown) |box|
+ box
+ else if (self.acknowledged and prior != null)
+ prior.?.from
+ else
+ old.box;
+ const next: Track = .{
+ .serial = pane.serial,
+ .pane = @intCast(id),
+ .phase = if (!same_lifetime or
+ (prior != null and prior.?.phase == .opening)) .opening else .moving,
+ .effect = effect,
+ .from = from,
+ .to = target,
+ };
+ self.tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ } else {
+ const next: Track = .{
+ .serial = pane.serial,
+ .pane = @intCast(id),
+ .phase = .opening,
+ .effect = effect,
+ .from = openingBox(effect, target, p.screen_w),
+ .to = target,
+ };
+ self.tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ }
+ }
+ if (animated_change and effect.needsPreviousGrid()) {
+ self.diff_state = .requested;
+ }
+ if (changed and self.acknowledged) self.pending = true;
+}
+
+fn appendClosing(self: *Presentation, track: Track) bool {
+ std.debug.assert(track.phase == .closing);
+ const baseline = self.previous_layout[track.pane] orelse return false;
+ if (!self.previous_valid or baseline.serial != track.serial or
+ !baseline.box.eql(track.from)) return false;
+ if (self.closing_len == self.closing.len) {
+ std.mem.copyForwards(
+ Track,
+ self.closing[0 .. self.closing.len - 1],
+ self.closing[1..],
+ );
+ self.closing_len -= 1;
+ }
+ self.closing[self.closing_len] = track;
+ self.closing_len += 1;
+ return true;
+}
+
+pub fn advance(self: *Presentation) void {
+ for (&self.tracks) |*slot| {
+ const track = if (slot.*) |*track| track else continue;
+ track.frame +|= 1;
+ if (!track.active()) slot.* = null;
+ }
+ var out: usize = 0;
+ for (self.closing[0..self.closing_len]) |value| {
+ var track = value;
+ track.frame +|= 1;
+ if (!track.active()) continue;
+ self.closing[out] = track;
+ out += 1;
+ }
+ self.closing_len = out;
+}
+
+pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void {
+ var presented: [MAX_PANES]?Track = @splat(null);
+ var presented_closing: [MAX_PANES]Track = undefined;
+ var nclosing: usize = 0;
+ var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready)
+ self.submitted
+ else
+ @splat(null);
+ if (!self.submitted_ready) for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ };
+ for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| {
+ const pane = p.panes[id] orelse {
+ snapshot.* = null;
+ continue;
+ };
+ if (pane.serial != saved.serial) snapshot.* = null;
+ };
+ for (tracks) |track| {
+ if (track.phase == .closing) {
+ const current = for (self.closing[0..self.closing_len]) |candidate| {
+ if (candidate.serial == track.serial and candidate.pane == track.pane and
+ candidate.effect == track.effect) break true;
+ } else false;
+ if (!current or !track.active() or nclosing == presented_closing.len) continue;
+ presented_closing[nclosing] = track;
+ nclosing += 1;
+ continue;
+ }
+ const id: usize = track.pane;
+ if (id >= p.panes.len or !track.active()) continue;
+ const pane = p.panes[id] orelse continue;
+ if (pane.serial != track.serial) continue;
+ presented[id] = track;
+ presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() };
+ }
+ self.shown_tracks = presented;
+ self.shown_closing = presented_closing;
+ self.shown_closing_len = nclosing;
+ self.shown = presented_layout;
+ self.acknowledged = true;
+ self.pending = false;
+ if (tracks.len == 0) {
+ self.tracks = @splat(null);
+ self.closing_len = 0;
+ self.diff_state = .none;
+ self.capturePrevious(p.gpa, &p.surface);
+ }
+}
+
+fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
+ self.diff_state = .none;
+ const cells = surface.cells;
+ if (cells.len == 0) {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ return;
+ }
+ if (self.previous_cells.len != cells.len) {
+ const next = gpa.alloc(pardes.Cell, cells.len) catch {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ return;
+ };
+ if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
+ self.previous_cells = next;
+ }
+ for (&self.previous_body_layers, surface.bodyLayers()) |*old, *current| {
+ const count = if (current.rows > 0) current.cells.len else 0;
+ const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ return;
+ };
+ if (count > 0) @memcpy(storage, current.cells);
+ old.* = current.*;
+ old.cells = storage;
+ }
+ for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| {
+ const count = if (current.cols > 0) current.cells.len else 0;
+ const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ return;
+ };
+ if (count > 0) @memcpy(storage, current.cells);
+ old.* = current.*;
+ old.cells = storage;
+ }
+ @memcpy(self.previous_cells, cells);
+ self.previous_cols = surface.cols;
+ self.previous_rows = surface.rows;
+ self.previous_valid = true;
+ self.previous_layout = if (self.submitted_ready)
+ self.submitted
+ else
+ @splat(null);
+}
+
+pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff {
+ if (self.diff_state != .ready or col >= cols or row >= rows or
+ self.diffs.len != @as(usize, cols) * rows) return .unchanged;
+ return self.diffs[@as(usize, row) * cols + col];
+}
+
+pub const Point = struct { x: f32, y: f32 };
+
+/// Coordinates stay fractional until the host resolves its body font grid.
+pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32, y: f32) ?Point {
+ if (self.pending or !std.math.isFinite(x) or !std.math.isFinite(y) or
+ x < 0 or y < 0 or x >= @as(f32, @floatFromInt(cols)) or y >= @as(f32, @floatFromInt(rows))) return null;
+ var closing = self.shown_closing_len;
+ while (closing > 0) {
+ closing -= 1;
+ const track = self.shown_closing[closing];
+ if (track.active() and boxContainsPoint(track.contentBox(), x, y) and
+ boxContainsPoint(track.presented(), x, y)) return null;
+ }
+ for ([_]Phase{ .opening, .moving }) |phase| {
+ var index = self.shown_tracks.len;
+ while (index > 0) {
+ index -= 1;
+ const track = self.shown_tracks[index] orelse continue;
+ if (!track.active() or track.phase != phase) continue;
+ switch (track.effect) {
+ .slide, .zoom, .vertical => {
+ const target = track.contentBox();
+ if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue;
+ const shown = track.presented();
+ if (!boxContainsPoint(shown, x, y)) {
+ if (track.effect == .vertical) return null;
+ continue;
+ }
+ return .{
+ .x = target.x + (x - shown.x) / shown.w * target.w,
+ .y = target.y + (y - shown.y) / shown.h * target.h,
+ };
+ },
+ else => {
+ if (!boxContainsPoint(track.to, x, y)) continue;
+ _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
+ return .{ .x = x, .y = y };
+ },
+ }
+ }
+ }
+ _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
+ return .{ .x = x, .y = y };
+}
+
+fn boxContainsPoint(box: Box, x: f32, y: f32) bool {
+ return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h;
+}
+
+pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition {
+ if (self.pending) return null;
+ var closing = self.shown_closing_len;
+ while (closing > 0) {
+ closing -= 1;
+ const track = self.shown_closing[closing];
+ if (!track.active()) continue;
+ if (boxContainsCell(track.contentBox(), col, row) and
+ boxContainsCell(track.presented(), col, row)) return null;
+ }
+ const phases = [_]Phase{ .opening, .moving };
+ for (phases) |phase| {
+ // Backends paint pane slots forward within a phase. Probe them in
+ // reverse so overlapping transition quads address the top pixel.
+ var index = self.shown_tracks.len;
+ while (index > 0) {
+ index -= 1;
+ const track = self.shown_tracks[index] orelse continue;
+ if (!track.active() or track.phase != phase) continue;
+ switch (track.effect) {
+ .slide, .zoom => {
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue;
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
+ const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w));
+ const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h));
+ return .{
+ .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))),
+ .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))),
+ };
+ },
+ .vertical => {
+ const clip = track.contentBox();
+ if (!boxContainsCell(clip, col, row)) continue;
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row))
+ return null;
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
+ return .{
+ .col = @intFromFloat(@floor(clip.x + u * clip.w)),
+ .row = @intFromFloat(@floor(clip.y + v * clip.h)),
+ };
+ },
+ .dissolve, .ascii => {
+ if (!boxContainsCell(track.to, col, row)) continue;
+ const cell_diff = self.cellDiff(cols, rows, col, row);
+ if (!cell_diff.changed())
+ return .{ .col = col, .row = row };
+ const relative_col: u16 = @intFromFloat(@floor(
+ @as(f32, @floatFromInt(col)) + 0.5 - track.to.x,
+ ));
+ const relative_row: u16 = @intFromFloat(@floor(
+ @as(f32, @floatFromInt(row)) + 0.5 - track.to.y,
+ ));
+ const visible = switch (track.effect) {
+ .dissolve => dissolveRevealed(
+ track.serial,
+ relative_col,
+ relative_row,
+ track.amount(),
+ ),
+ .ascii => switch (cell_diff) {
+ .ascii => |diff| diff.complete(track.frame),
+ .unchanged, .visual => true,
+ },
+ else => unreachable,
+ };
+ return if (visible) .{ .col = col, .row = row } else null;
+ },
+ .edges, .fall, .wave, .curtain, .scramble, .typewriter => {
+ if (!boxContainsCell(track.to, col, row)) continue;
+ const area = CellArea.of(track.to);
+ const settled = std.meta.eql(
+ charSource(
+ track,
+ col -| area.x0,
+ row -| area.y0,
+ area,
+ ),
+ CharSource.settled,
+ );
+ return if (settled) .{ .col = col, .row = row } else null;
+ },
+ .off => {},
+ }
+ }
+ }
+ for (self.shown_tracks) |maybe| {
+ const track = maybe orelse continue;
+ if (!track.active() or (track.effect != .slide and track.effect != .zoom and
+ track.effect != .vertical)) continue;
+ if (boxContainsCell(track.to, col, row)) return null;
+ }
+ return .{ .col = col, .row = row };
+}
+
+fn boxContainsCell(box: Box, col: u16, row: u16) bool {
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
+}
+
+pub fn cancel(self: *Presentation) void {
+ self.tracks = @splat(null);
+ self.closing_len = 0;
+ self.shown_tracks = @splat(null);
+ self.shown_closing_len = 0;
+ self.diff_state = .none;
+ self.pending = self.acknowledged;
+}
+
+fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool {
+ const count = surface.cells.len;
+ if (!self.previous_valid or
+ self.previous_cols != surface.cols or
+ self.previous_rows != surface.rows or
+ self.previous_cells.len != count) return false;
+ if (self.diffs.len != count) {
+ const next = try gpa.alloc(pardes.PanelCellDiff, count);
+ if (self.diffs.len > 0) gpa.free(self.diffs);
+ self.diffs = next;
+ }
+ for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new|
+ diff.* = pardes.PanelCellDiff.between(old, new);
+ for (&self.tracks) |*slot| {
+ const track = if (slot.*) |*track| track else continue;
+ if (track.effect != .ascii) continue;
+ var longest: u16 = 1;
+ var row: u16 = 0;
+ while (row < surface.rows) : (row += 1) {
+ var col: u16 = 0;
+ while (col < surface.cols) : (col += 1) {
+ if (!boxContainsCell(track.to, col, row)) continue;
+ const index = @as(usize, row) * surface.cols + col;
+ switch (self.diffs[index]) {
+ .ascii => |diff| longest = @max(longest, diff.frameCount()),
+ .unchanged, .visual => {},
+ }
+ }
+ }
+ track.frame_count = @max(track.frame_count, longest);
+ }
+ self.diff_state = .ready;
+ return true;
+}
+
+pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void {
+ self.submitted = @splat(null);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ self.submitted[id] = .{
+ .serial = pane.serial,
+ .box = panelBox(p.rects[id]),
+ };
+ }
+ self.submitted_ready = true;
+ if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) {
+ for (&self.tracks) |*slot| {
+ const track = slot.* orelse continue;
+ if (track.effect.needsPreviousGrid()) slot.* = null;
+ }
+ self.closing_len = 0;
+ self.diff_state = .none;
+ }
+ if (self.diff_state == .ready) {
+ s.previous_cells = self.previous_cells;
+ s.previous_body_layers = &self.previous_body_layers;
+ s.previous_tag_layers = &self.previous_tag_layers;
+ s.cell_diffs = self.diffs;
+ }
+ s.npanel_tracks = paintOrder(
+ &self.tracks,
+ self.closing[0..self.closing_len],
+ &s.panel_tracks,
+ );
+}
+
+pub fn animating(self: *const Presentation) bool {
+ for (self.tracks) |track| if (track != null and track.?.active()) return true;
+ for (self.closing[0..self.closing_len]) |track|
+ if (track.active()) return true;
+ return false;
+}
+
+test "presentation cache allocation failures retain ownership and allow retry" {
+ var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
+ const gpa = allocator.allocator();
+ var state: Presentation = .{};
+ defer state.deinit(gpa);
+ var cells: [3]pardes.Cell = @splat(.{});
+ cells[0].default = false;
+ cells[0].text[0] = 'a';
+ var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] };
+ state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } };
+ state.submitted_ready = true;
+ state.capturePrevious(gpa, &surface);
+ try std.testing.expect(state.previous_valid);
+ try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial);
+ const previous = state.previous_cells.ptr;
+ const allocated = allocator.allocations;
+ allocator.fail_index = allocator.alloc_index;
+ state.capturePrevious(gpa, &surface);
+ try std.testing.expect(state.previous_valid);
+ try std.testing.expectEqual(allocated, allocator.allocations);
+ try std.testing.expect(!allocator.has_induced_failure);
+
+ surface.cols = 3;
+ surface.cells = &cells;
+ state.submitted[0].?.box.w = 3;
+ state.diff_state = .ready;
+ state.capturePrevious(gpa, &surface);
+ try std.testing.expect(allocator.has_induced_failure);
+ try std.testing.expect(!state.previous_valid);
+ try std.testing.expectEqual(previous, state.previous_cells.ptr);
+ try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len);
+ try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]);
+ try std.testing.expect(state.previous_layout[0] == null);
+ try std.testing.expectEqual(.none, state.diff_state);
+
+ allocator.fail_index = std.math.maxInt(usize);
+ state.capturePrevious(gpa, &surface);
+ try std.testing.expect(state.previous_valid);
+ try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len);
+ try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w);
+ state.diff_state = .requested;
+ allocator.fail_index = allocator.alloc_index;
+ try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface));
+ try std.testing.expectEqual(.requested, state.diff_state);
+ try std.testing.expectEqual(@as(usize, 0), state.diffs.len);
+ allocator.fail_index = std.math.maxInt(usize);
+ try std.testing.expect(try state.prepareDiff(gpa, &surface));
+ try std.testing.expectEqual(.ready, state.diff_state);
+ try std.testing.expectEqual(@as(usize, 3), state.diffs.len);
+ for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff);
+}
+
+test "presentation submission failure preserves shown geometry until retry is acknowledged" {
+ var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
+ const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(&.{});
+ const shown = p.presentation.shown;
+ const previous = p.presentation.previous_cells.ptr;
+ p.presentation.diff_state = .requested;
+ p.presentation.pending = true;
+ allocator.fail_index = allocator.alloc_index;
+ try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface));
+ try std.testing.expectEqualDeep(shown, p.presentation.shown);
+ try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr);
+ try std.testing.expectEqual(.requested, p.presentation.diff_state);
+ try std.testing.expect(p.presentation.pending);
+ try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null);
+
+ allocator.fail_index = std.math.maxInt(usize);
+ try p.presentation.submit(p, &p.surface);
+ try std.testing.expectEqualDeep(shown, p.presentation.shown);
+ try std.testing.expect(p.presentation.pending);
+ p.acknowledgePanelPresentation(&.{});
+ try std.testing.expect(!p.presentation.pending);
+ try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?);
+}
+
+test "fractional panel inverse preserves compact header rows through slide and zoom" {
+ const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 };
+ for ([_]Transition{ .slide, .zoom, .vertical }) |effect| {
+ var presentation: Presentation = .{};
+ const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } };
+ presentation.shown_tracks[0] = track;
+ const shown = track.presented();
+ for (0..4) |index| {
+ const row: u16 = @intCast(index);
+ const source_x: f32 = 5.3;
+ const source_y = (layer.rowTop(row, 20, 10) + 5) / 20;
+ const x = shown.x + (source_x - track.to.x) / track.to.w * shown.w;
+ const y = shown.y + (source_y - track.to.y) / track.to.h * shown.h;
+ const point = presentation.pointerFractional(80, 30, x, y).?;
+ try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001);
+ try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001);
+ const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?;
+ try std.testing.expectEqual(row, body_hit.row);
+ }
+ }
+}
+
+test "presentation snapshots own compact body and tag rows across later edits" {
+ var state: Presentation = .{};
+ defer state.deinit(std.testing.allocator);
+ var cells: [1]pardes.Cell = .{.{}};
+ var body: [2]pardes.Cell = .{ .{}, .{} };
+ body[0].text[0] = 'A';
+ var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells };
+ surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body };
+ var tag: [1]pardes.Cell = .{.{}};
+ tag[0].text[0] = 'T';
+ surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag };
+ state.capturePrevious(std.testing.allocator, &surface);
+ try std.testing.expect(state.previous_valid);
+ tag[0].text[0] = 'U';
+ try std.testing.expectEqual(@as(u8, 'T'), state.previous_tag_layers[0].cells[0].text[0]);
+ body[0].text[0] = 'B';
+ surface.body_layers[0].context_rows = 0;
+ try std.testing.expectEqual(@as(u8, 'A'), state.previous_body_layers[0].cells[0].text[0]);
+ try std.testing.expectEqual(@as(u16, 1), state.previous_body_layers[0].context_rows);
+ state.capturePrevious(std.testing.allocator, &surface);
+ try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]);
+ try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]);
+}