summaryrefslogtreecommitdiff
path: root/src/tty/panel_compositor.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /src/tty/panel_compositor.zig
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz
pardes-1551e409c31992437cb2fa864f576d45c8433801.zip
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/tty/panel_compositor.zig')
-rw-r--r--src/tty/panel_compositor.zig730
1 files changed, 730 insertions, 0 deletions
diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig
new file mode 100644
index 00000000..647c36ff
--- /dev/null
+++ b/src/tty/panel_compositor.zig
@@ -0,0 +1,730 @@
+//! TTY implementation of panel transitions.
+//!
+//! The core publishes final cells plus POD tracks. This backend copies that
+//! grid and performs the transition in cell space; no terminal escape trick
+//! or pixel assumption leaks into the core.
+
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const panel_animation = @import("../panel_animation.zig");
+
+const Box = panel_animation.Box;
+const Track = panel_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.effect.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 ([_]panel_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),
+ .ascii => ascii(out, source, track),
+ .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 (panel_animation.dissolveRevealed(
+ track.serial,
+ x - area.x0,
+ y - area.y0,
+ amount,
+ )) continue;
+ out.at(x, y).* = previousCell(source, x, y).*;
+ }
+ }
+}
+
+fn ascii(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;
+ const material = panel_animation.asciiCell(track.serial, x - area.x0, y - area.y0, amount);
+ if (material.reveal) continue;
+ const cell = out.at(x, y);
+ if (material.glyph == ' ') {
+ cell.* = previousCell(source, x, y).*;
+ continue;
+ }
+ // The wave itself wears the new cell's colors, then yields to its
+ // real glyph. Before the wave arrives the complete old cell—not a
+ // synthetic blank—remains visible above.
+ cell.* = source.cells[@as(usize, y) * source.cols + x];
+ cell.text[0] = material.glyph;
+ cell.len = 1;
+ cell.default = false;
+ cell.style.dim = true;
+ }
+ }
+}
+
+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 "ASCII materialization happens in copied TTY cells" {
+ var cells: [8]pardes.Cell = @splat(.{});
+ var previous: [8]pardes.Cell = @splat(.{});
+ const changed: [8]u8 = @splat(1);
+ 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,
+ .changed_cells = &changed,
+ };
+ 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());
+ var differs = false;
+ for (animated.cells) |cell| differs = differs or !std.mem.eql(u8, cell.grapheme(), "X");
+ try std.testing.expect(differs);
+}
+
+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 changed = [_]u8{ 1, 0 };
+ var surface: pardes.Surface = .{
+ .cols = 2,
+ .rows = 1,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ 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 "ASCII diff travels old through punctuation to new" {
+ 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 changed = [_]u8{1};
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ var track: Track = .{
+ .serial = 9,
+ .effect = .ascii,
+ .from = .{ .w = 1, .h = 1 },
+ .to = .{ .w = 1, .h = 1 },
+ };
+
+ const old_frame = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("O", old_frame.at(0, 0).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = 7;
+ const wave = panel_animation.asciiCell(track.serial, 0, 0, track.amount());
+ try std.testing.expect(!wave.reveal and wave.glyph != ' ');
+ const punctuation = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualSlices(u8, &.{wave.glyph}, punctuation.at(0, 0).grapheme());
+ try std.testing.expect(punctuation.at(0, 0).style.dim);
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = track.effect.frames() - 1;
+ const new_frame = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(cells[0], new_frame.at(0, 0).*);
+}
+
+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 ([_]panel_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 changed = [_]u8{1};
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .cursor = .{ .x = 0, .y = 0 },
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ const track: Track = .{
+ .effect = .ascii,
+ .frame = panel_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 changed: [12]u8 = @splat(1);
+ var surface: pardes.Surface = .{
+ .cols = 3,
+ .rows = 4,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ 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 = panel_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 slides frozen content upward 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 changed: [12]u8 = @splat(1);
+ var surface: pardes.Surface = .{
+ .cols = 3,
+ .rows = 4,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ 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 = panel_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 = 2;
+ const leaving = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("2", leaving.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("c", 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 changed = [_]u8{1};
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ 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 = panel_animation.closingBox(.vertical, box),
+ };
+ const opening: Track = .{
+ .serial = 2,
+ .phase = .opening,
+ .effect = .ascii,
+ .frame = panel_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 = panel_animation.Transition.zoom.frames() - 1 },
+ .{ .serial = 44, .effect = .zoom, .frame = panel_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));
+}