//! TTY implementation of panel transitions. //! //! The core publishes presentation cells plus POD tracks. It has already //! composed PanelAscii because byte stepping is semantic, while this backend //! copies the grid for geometry and dissolve operations. No terminal escape //! trick or pixel assumption leaks into the core. const std = @import("std"); const pardes = @import("../pardes.zig"); const layout = @import("../layout.zig"); const animation = @import("../animation.zig"); const Box = animation.Box; const Track = animation.Track; /// Kitty placements cannot be resampled through the character-grid transform. /// Keep their transmitted pixels cached, but omit the placement while its pane /// geometry moves. Data effects animate changed grid cells in place and have /// no honest old Kitty bitmap, so leaving an unchanged attachment canonical is /// better than making it disappear for the whole reveal and pop back. pub fn hidesAttachment(tracks: []const Track, serial: u32) bool { for (tracks) |track| { if (track.serial != serial or !track.active()) continue; if (track.effect != .slide and track.effect != .zoom and track.effect != .vertical) continue; // The last active sample is already the exact canonical frame. Place // the image there too, rather than making it pop in one tick later. if (track.frame + 1 < track.frames()) return true; } return false; } /// Return `source` unchanged when idle, otherwise an arena-owned Surface whose /// cells contain this frame's TTY presentation. Geometry exposed by a moving /// panel uses the theme's page background when it has one; null deliberately /// leaves the host terminal's default background showing. pub fn compose( arena: std.mem.Allocator, source: *pardes.Surface, tracks: []const Track, theme_bg: ?[3]u8, ) !*pardes.Surface { var active = false; var noncanonical = false; for (tracks) |track| { if (!drawable(source, track)) continue; active = true; noncanonical = noncanonical or track.amount() < 1.0; } if (!active) return source; // Every effect's last active sample is its exact canonical endpoint. Keep // the real cursor and attachments in that sample instead of suppressing // them for one extra frame that is otherwise pixel-identical. if (!noncanonical) return source; const out = try arena.create(pardes.Surface); out.* = source.*; out.cells = try arena.dupe(pardes.Cell, source.cells); // A cursor has no pane/phase record of its own. All native backends hide // it for the short transition rather than disagreeing about whether it // should move, stay static, or cover an overlapping opening pane. out.cursor = null; // Clear every final geometry before drawing any presented geometry. If // clear and draw are interleaved, a new panel can erase the old full-width // panel that was deliberately drawn underneath it at frame zero. for (tracks) |track| if (drawable(source, track) and (track.effect == .slide or track.effect == .zoom)) clearBox(out, track.to, theme_bg); // A vertical opener starts over the exact old pixels in its fixed target // clip. Its new content then rises into that clip from below. Restore all // bases before painting anything so an opener cannot erase an earlier // moving panel merely because its pane slot sorts later. for (tracks) |track| if (drawable(source, track) and track.effect == .vertical and track.phase == .opening) restorePrevious(out, source, track.contentBox()); // Stable layout motion first, newly opening panels above it, and closing // tombstones last. A closing pane no longer owns input or canonical cells, // but its frozen old content remains the top visual until it slides out. for ([_]animation.Phase{ .moving, .opening, .closing }) |phase| { for (tracks) |track| { if (!drawable(source, track) or track.phase != phase) continue; switch (track.effect) { .off => {}, .slide, .zoom => transform(out, source, track), .dissolve => dissolve(out, source, track), // Pardes core has already composed every character effect // into source.cells — byte walks, glyph motion, and per-cell // locks are semantic. TTY only owns cursor suppression here. .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => {}, .vertical => vertical(out, source, track), } } } return out; } /// Content effects have no honest frame without the frozen old grid. In /// production the core drops such tracks before publishing Surface; retain /// this guard at the backend boundary so a partial/custom caller also snaps /// to canonical content instead of manufacturing blanks. fn drawable(source: *const pardes.Surface, track: Track) bool { return track.active() and (!track.effect.needsPreviousGrid() or source.hasPanelDiff()); } fn transform(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void { const shown = track.presented(); if (shown.w <= 0 or shown.h <= 0) return; const dst = clipped(shown, out.cols, out.rows) orelse return; var y = dst.y0; while (y < dst.y1) : (y += 1) { var x = dst.x0; while (x < dst.x1) : (x += 1) { // The core hit-tests cell CENTRES against this same half-open box. // floor/ceil clipping includes boundary cells for iteration, but // they are not visible unless their centre is actually inside. // Test that containment before division: at large u16 grids f32 // can round an inside right-edge ratio to exactly 1.0. const cx = @as(f32, @floatFromInt(x)) + 0.5; const cy = @as(f32, @floatFromInt(y)) + 0.5; if (cx < shown.x or cx >= shown.x + shown.w or cy < shown.y or cy >= shown.y + shown.h) continue; const u = std.math.clamp((cx - shown.x) / shown.w, 0, 0.999_999); const v = std.math.clamp((cy - shown.y) / shown.h, 0, 0.999_999); const sx: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w)); const sy: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h)); if (sx < 0 or sy < 0 or sx >= source.cols or sy >= source.rows) continue; out.at(x, y).* = source.cells[@as(usize, @intCast(sy)) * source.cols + @as(usize, @intCast(sx))]; } } } fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void { const area = clipped(track.to, out.cols, out.rows) orelse return; const amount = track.amount(); var y = area.y0; while (y < area.y1) : (y += 1) { var x = area.x0; while (x < area.x1) : (x += 1) { if (!source.panelCellChanged(x, y)) continue; if (animation.dissolveRevealed( track.serial, x - area.x0, y - area.y0, amount, )) continue; out.at(x, y).* = previousCell(source, x, y).*; } } } fn previousCell(source: *const pardes.Surface, x: u16, y: u16) *const pardes.Cell { std.debug.assert(source.hasPanelDiff()); return &source.previous_cells[@as(usize, y) * source.cols + x]; } fn restorePrevious(out: *pardes.Surface, source: *const pardes.Surface, box: Box) void { const area = clipped(box, out.cols, out.rows) orelse return; var y = area.y0; while (y < area.y1) : (y += 1) { var x = area.x0; while (x < area.x1) : (x += 1) out.at(x, y).* = previousCell(source, x, y).*; } } fn vertical(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void { const cells = if (track.phase == .closing) source.previous_cells else source.cells; transformClipped(out, source.cols, source.rows, cells, track, track.contentBox()); } /// Translate one old/new canonical panel through its fixed lifecycle clip. /// Sampling is always relative to contentBox: an opener reads its new target, /// while a closer reads the old box retained after that pane was destroyed. fn transformClipped( out: *pardes.Surface, source_cols: u16, source_rows: u16, cells: []const pardes.Cell, track: Track, clip: Box, ) void { const shown = track.presented(); if (shown.w <= 0 or shown.h <= 0) return; const dst = clipped(shown, out.cols, out.rows) orelse return; const content = track.contentBox(); var y = dst.y0; while (y < dst.y1) : (y += 1) { var x = dst.x0; while (x < dst.x1) : (x += 1) { const cx = @as(f32, @floatFromInt(x)) + 0.5; const cy = @as(f32, @floatFromInt(y)) + 0.5; if (cx < shown.x or cx >= shown.x + shown.w or cy < shown.y or cy >= shown.y + shown.h or cx < clip.x or cx >= clip.x + clip.w or cy < clip.y or cy >= clip.y + clip.h) continue; const u = std.math.clamp((cx - shown.x) / shown.w, 0, 0.999_999); const v = std.math.clamp((cy - shown.y) / shown.h, 0, 0.999_999); const sx: i32 = @intFromFloat(@floor(content.x + u * content.w)); const sy: i32 = @intFromFloat(@floor(content.y + v * content.h)); if (sx < 0 or sy < 0 or sx >= source_cols or sy >= source_rows) continue; out.at(x, y).* = cells[@as(usize, @intCast(sy)) * source_cols + @as(usize, @intCast(sx))]; } } } fn clearBox(surface: *pardes.Surface, box: Box, theme_bg: ?[3]u8) void { const area = clipped(box, surface.cols, surface.rows) orelse return; var blank: pardes.Cell = .{}; if (theme_bg) |rgb| { blank.default = false; blank.style.bg = .{ .rgb = rgb }; } var y = area.y0; while (y < area.y1) : (y += 1) { var x = area.x0; while (x < area.x1) : (x += 1) surface.at(x, y).* = blank; } } const GridBox = struct { x0: u16, y0: u16, x1: u16, y1: u16 }; fn clipped(box: Box, cols: u16, rows: u16) ?GridBox { const x0: i32 = @intFromFloat(@floor(box.x)); const y0: i32 = @intFromFloat(@floor(box.y)); const x1: i32 = @intFromFloat(@ceil(box.x + box.w)); const y1: i32 = @intFromFloat(@ceil(box.y + box.h)); const area: GridBox = .{ .x0 = @intCast(std.math.clamp(x0, 0, @as(i32, cols))), .y0 = @intCast(std.math.clamp(y0, 0, @as(i32, rows))), .x1 = @intCast(std.math.clamp(x1, 0, @as(i32, cols))), .y1 = @intCast(std.math.clamp(y1, 0, @as(i32, rows))), }; return if (area.x0 < area.x1 and area.y0 < area.y1) area else null; } test "TTY consumes core-composed ASCII cells without reinterpreting glyphs" { var cells: [8]pardes.Cell = @splat(.{}); var previous: [8]pardes.Cell = @splat(.{}); const diffs: [8]pardes.PanelCellDiff = @splat(.visual); for (&cells) |*cell| { cell.text[0] = 'X'; cell.len = 1; cell.default = false; } for (&previous) |*cell| { cell.text[0] = 'O'; cell.len = 1; cell.default = false; } var surface: pardes.Surface = .{ .cols = 4, .rows = 2, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const track: Track = .{ .serial = 9, .effect = .ascii, .frame = 0, .from = .{ .w = 4, .h = 2 }, .to = .{ .w = 4, .h = 2 }, }; const animated = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expect(animated != &surface); try std.testing.expectEqualStrings("X", surface.at(0, 0).grapheme()); for (animated.cells) |cell| try std.testing.expectEqualStrings("X", cell.grapheme()); } test "dissolve changes only diff cells from old to new" { var cells: [2]pardes.Cell = @splat(.{}); var previous: [2]pardes.Cell = @splat(.{}); for (&cells, "NU") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } for (&previous, "OU") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } const diffs = [_]pardes.PanelCellDiff{ .{ .ascii = .{ .from = 'O', .to = 'N' } }, .unchanged }; var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); var track: Track = .{ .serial = 1, .effect = .dissolve, .from = .{ .w = 2, .h = 1 }, .to = .{ .w = 2, .h = 1 }, }; const first = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqualStrings("O", first.at(0, 0).grapheme()); try std.testing.expectEqual(cells[1], first.at(1, 0).*); _ = arena.reset(.retain_capacity); track.frame = track.effect.frames() - 1; const final = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqual(cells[0], final.at(0, 0).*); try std.testing.expectEqual(cells[1], final.at(1, 0).*); } test "TTY content effects never touch cells outside the published diff" { var cells: [4]pardes.Cell = @splat(.{}); var previous: [4]pardes.Cell = @splat(.{}); for (&cells, "NUQV") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } // Deliberately disagree even where the mask says unchanged. This makes // the test prove that the compositor obeys the core cell diff rather than // merely appearing correct because old and new happened to match there. for (&previous, "OXRY") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } const diffs = [_]pardes.PanelCellDiff{ .visual, .unchanged, .visual, .unchanged }; var surface: pardes.Surface = .{ .cols = 4, .rows = 1, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); for ([_]animation.Transition{ .ascii, .dissolve }) |effect| { const track: Track = .{ .serial = 17, .effect = effect, .from = .{ .w = 4, .h = 1 }, .to = .{ .w = 4, .h = 1 }, }; const animated = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqual(cells[1], animated.at(1, 0).*); try std.testing.expectEqual(cells[3], animated.at(3, 0).*); _ = arena.reset(.retain_capacity); } } test "content transition without a diff snaps to canonical surface" { var cells = [_]pardes.Cell{.{}}; cells[0].text[0] = 'N'; cells[0].default = false; var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells, .cursor = .{ .x = 0, .y = 0 }, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); for ([_]animation.Transition{ .dissolve, .ascii, .vertical }) |effect| { const track: Track = .{ .effect = effect, .phase = .opening, .from = .{ .y = 1, .w = 1, .h = 1 }, .to = .{ .w = 1, .h = 1 }, }; try std.testing.expectEqual(&surface, try compose(arena.allocator(), &surface, &.{track}, null)); } } test "exact transition endpoint preserves the canonical cursor" { var cells = [_]pardes.Cell{.{}}; var previous = cells; const diffs = [_]pardes.PanelCellDiff{.visual}; var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells, .cursor = .{ .x = 0, .y = 0 }, .previous_cells = &previous, .cell_diffs = &diffs, }; const track: Track = .{ .effect = .ascii, .frame = animation.Transition.ascii.frames() - 1, .from = .{ .w = 1, .h = 1 }, .to = .{ .w = 1, .h = 1 }, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const result = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqual(&surface, result); try std.testing.expect(result.cursor != null); } test "vertical opening rises through a fixed old-grid clip" { var cells: [12]pardes.Cell = @splat(.{}); var previous: [12]pardes.Cell = @splat(.{}); for (&cells, "aaabbbcccddd") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } for (&previous, "000111222333") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } const diffs: [12]pardes.PanelCellDiff = @splat(.visual); var surface: pardes.Surface = .{ .cols = 3, .rows = 4, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const target: Box = .{ .y = 1, .w = 3, .h = 2 }; var track: Track = .{ .serial = 5, .phase = .opening, .effect = .vertical, .from = animation.openingBox(.vertical, target, 3), .to = target, }; const first = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqualStrings("1", first.at(0, 1).grapheme()); try std.testing.expectEqualStrings("2", first.at(0, 2).grapheme()); try std.testing.expectEqualStrings("a", first.at(0, 0).grapheme()); try std.testing.expectEqualStrings("d", first.at(0, 3).grapheme()); _ = arena.reset(.retain_capacity); track.frame = 2; const entering = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqualStrings("1", entering.at(0, 1).grapheme()); try std.testing.expectEqualStrings("b", entering.at(0, 2).grapheme()); try std.testing.expectEqualStrings("a", entering.at(0, 0).grapheme()); try std.testing.expectEqualStrings("d", entering.at(0, 3).grapheme()); _ = arena.reset(.retain_capacity); track.frame = track.effect.frames() - 1; const final = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqualStrings("b", final.at(0, 1).grapheme()); try std.testing.expectEqualStrings("c", final.at(0, 2).grapheme()); } test "vertical closing drops frozen content over canonical cells" { var cells: [12]pardes.Cell = @splat(.{}); var previous: [12]pardes.Cell = @splat(.{}); for (&cells, "aaabbbcccddd") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } for (&previous, "000111222333") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } const diffs: [12]pardes.PanelCellDiff = @splat(.visual); var surface: pardes.Surface = .{ .cols = 3, .rows = 4, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const old_box: Box = .{ .y = 1, .w = 3, .h = 2 }; var track: Track = .{ .serial = 5, .phase = .closing, .effect = .vertical, .from = old_box, .to = animation.closingBox(.vertical, old_box), }; const first = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqualStrings("1", first.at(0, 1).grapheme()); try std.testing.expectEqualStrings("2", first.at(0, 2).grapheme()); _ = arena.reset(.retain_capacity); track.frame = 8; const leaving = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqualStrings("b", leaving.at(0, 1).grapheme()); try std.testing.expectEqualStrings("1", leaving.at(0, 2).grapheme()); try std.testing.expectEqualStrings("a", leaving.at(0, 0).grapheme()); try std.testing.expectEqualStrings("d", leaving.at(0, 3).grapheme()); _ = arena.reset(.retain_capacity); track.frame = track.effect.frames() - 1; const final = try compose(arena.allocator(), &surface, &.{track}, null); try std.testing.expectEqual(cells[3], final.at(0, 1).*); try std.testing.expectEqual(cells[6], final.at(0, 2).*); } test "closing content paints after opening content regardless of track order" { var cells = [_]pardes.Cell{.{}}; var previous = [_]pardes.Cell{.{}}; cells[0].text[0] = 'N'; cells[0].default = false; previous[0].text[0] = 'O'; previous[0].default = false; const diffs = [_]pardes.PanelCellDiff{.visual}; var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const box: Box = .{ .w = 1, .h = 1 }; const closing: Track = .{ .serial = 1, .phase = .closing, .effect = .vertical, .from = box, .to = animation.closingBox(.vertical, box), }; const opening: Track = .{ .serial = 2, .phase = .opening, .effect = .ascii, .frame = animation.Transition.ascii.frames() - 1, .from = box, .to = box, }; const animated = try compose(arena.allocator(), &surface, &.{ closing, opening }, null); try std.testing.expectEqualStrings("O", animated.at(0, 0).grapheme()); } test "opening panel cannot erase an existing panel drawn underneath" { var cells: [8]pardes.Cell = @splat(.{}); for (&cells, 0..) |*cell, x| { cell.text[0] = if (x < 4) 'A' else 'B'; cell.len = 1; cell.default = false; } var surface: pardes.Surface = .{ .cols = 8, .rows = 1, .cells = &cells }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const tracks = [_]Track{ .{ .serial = 1, .phase = .moving, .effect = .slide, .from = .{ .w = 8, .h = 1 }, .to = .{ .w = 4, .h = 1 }, }, .{ .serial = 2, .phase = .opening, .effect = .slide, .from = .{ .x = 8, .w = 4, .h = 1 }, .to = .{ .x = 4, .w = 4, .h = 1 }, }, }; const animated = try compose(arena.allocator(), &surface, &tracks, null); try std.testing.expectEqualStrings("A", animated.at(7, 0).grapheme()); } test "geometry gap is a blank painted in an explicit theme background" { var cells: [4]pardes.Cell = @splat(.{}); for (&cells, "ABCD") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; cell.style = .{ .fg = .{ .rgb = .{ 220, 210, 200 } }, .bg = .{ .rgb = .{ 30, 20, 10 } }, .bold = true, .font_role = .tagline, }; } var surface: pardes.Surface = .{ .cols = 4, .rows = 1, .cells = &cells }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const track: Track = .{ .serial = 1, .effect = .slide, .from = .{ .x = 2, .w = 2, .h = 1 }, .to = .{ .w = 4, .h = 1 }, }; const page_bg = [3]u8{ 248, 244, 232 }; const animated = try compose(arena.allocator(), &surface, &.{track}, page_bg); const gap = animated.at(0, 0); try std.testing.expect(!gap.default); try std.testing.expectEqualStrings(" ", gap.grapheme()); try std.testing.expectEqual(pardes.Color{ .rgb = page_bg }, gap.style.bg); try std.testing.expectEqual(pardes.Color.default, gap.style.fg); try std.testing.expect(!gap.style.bold); try std.testing.expectEqual(pardes.FontRole.body, gap.style.font_role); // The clear supplies only exposed geometry. Cells sampled from the moving // panel retain all canonical content and style fields byte-for-byte. try std.testing.expectEqual(cells[1], animated.at(2, 0).*); } test "geometry gap stays terminal-default for a transparent theme" { var cells: [2]pardes.Cell = @splat(.{}); for (&cells, "AB") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = &cells }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const track: Track = .{ .serial = 1, .effect = .slide, .from = .{ .x = 1, .w = 1, .h = 1 }, .to = .{ .w = 2, .h = 1 }, }; const animated = try compose(arena.allocator(), &surface, &.{track}, null); const gap = animated.at(0, 0); try std.testing.expect(gap.default); try std.testing.expectEqualStrings(" ", gap.grapheme()); try std.testing.expectEqual(pardes.Color.default, gap.style.bg); } test "fractional geometry draws exactly the cell centres core can hit" { var cells: [3]pardes.Cell = @splat(.{}); for (&cells, "ABC") |*cell, glyph| { cell.text[0] = glyph; cell.len = 1; cell.default = false; } var surface: pardes.Surface = .{ .cols = 3, .rows = 1, .cells = &cells }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const track: Track = .{ .serial = 1, .effect = .slide, .from = .{ .x = 0.9, .w = 1, .h = 1 }, .to = .{ .x = 0, .w = 1, .h = 1 }, }; const animated = try compose(arena.allocator(), &surface, &.{track}, null); // Cell 0 lies in floor/ceil iteration bounds, but its centre (0.5) is // outside [0.9, 1.9); cell 1 is the only presented/hit-testable cell. try std.testing.expectEqualStrings(" ", animated.at(0, 0).grapheme()); try std.testing.expectEqualStrings("A", animated.at(1, 0).grapheme()); } test "large-grid right edge uses containment before rounded ratio" { const cols = std.math.maxInt(u16); const source_cells = try std.testing.allocator.alloc(pardes.Cell, cols); defer std.testing.allocator.free(source_cells); const output_cells = try std.testing.allocator.alloc(pardes.Cell, cols); defer std.testing.allocator.free(output_cells); @memset(source_cells, .{}); @memset(output_cells, .{}); for (source_cells) |*cell| { cell.text[0] = 'X'; cell.len = 1; cell.default = false; } var source: pardes.Surface = .{ .cols = cols, .rows = 1, .cells = source_cells }; var output: pardes.Surface = .{ .cols = cols, .rows = 1, .cells = output_cells }; const track: Track = .{ .serial = 1, .effect = .zoom, .frame = 8, .from = .{ .x = 56_796, .w = 8_299, .h = 1 }, .to = .{ .x = 556, .w = 63_483, .h = 1 }, }; const shown = track.presented(); try std.testing.expect(@as(f32, 63_935.5) < shown.x + shown.w); transform(&output, &source, track); try std.testing.expectEqualStrings("X", output.at(63_935, 0).grapheme()); } test "active panel transition hides only its own native attachment" { const tracks = [_]Track{ .{ .serial = 41, .effect = .slide, .frame = 0 }, .{ .serial = 42, .effect = .ascii, .frame = 0 }, .{ .serial = 43, .effect = .zoom, .frame = animation.Transition.zoom.frames() - 1 }, .{ .serial = 44, .effect = .zoom, .frame = animation.Transition.zoom.frames() }, .{ .serial = 45, .phase = .opening, .effect = .vertical, .frame = 0 }, }; try std.testing.expect(hidesAttachment(&tracks, 41)); try std.testing.expect(!hidesAttachment(&tracks, 42)); try std.testing.expect(!hidesAttachment(&tracks, 43)); try std.testing.expect(!hidesAttachment(&tracks, 44)); try std.testing.expect(hidesAttachment(&tracks, 45)); try std.testing.expect(!hidesAttachment(&tracks, 46)); }