//! 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; // The frozen grid exists for transitions alone: with them off, an // idle frame copies nothing. if (p.settings.panel_transition != .off) { self.capturePrevious(p.gpa, &p.surface); } else { self.previous_valid = false; self.previous_layout = @splat(null); } } } pub 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(); // The frozen grid is kept only while a transition is chosen. p.settings.panel_transition = .slide; _ = 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]); }