diff options
Diffstat (limited to 'src/tty/panel_compositor.zig')
| -rw-r--r-- | src/tty/panel_compositor.zig | 730 |
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)); +} |
