From 31fece62f56aa2311e2325de83659edbc9e641db Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 17 Aug 2026 16:12:56 -0300 Subject: animation: core publishes transition records; gui evaluates via shaders, tty over grid cells --- src/tty/panel_compositor.zig | 140 ++++++++++++++++++------------------------- 1 file changed, 57 insertions(+), 83 deletions(-) (limited to 'src/tty') diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig index 647c36ff..1891c623 100644 --- a/src/tty/panel_compositor.zig +++ b/src/tty/panel_compositor.zig @@ -1,8 +1,9 @@ //! 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. +//! 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"); @@ -22,7 +23,7 @@ pub fn hidesAttachment(tracks: []const Track, serial: u32) bool { 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; + if (track.frame + 1 < track.frames()) return true; } return false; } @@ -83,7 +84,9 @@ pub fn compose( .off => {}, .slide, .zoom => transform(out, source, track), .dissolve => dissolve(out, source, track), - .ascii => ascii(out, source, track), + // Pardes core has already composed the byte-wise ASCII diff + // into source.cells. TTY only owns cursor suppression here. + .ascii => {}, .vertical => vertical(out, source, track), } } @@ -146,33 +149,6 @@ fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) v } } -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]; @@ -258,10 +234,10 @@ fn clipped(box: Box, cols: u16, rows: u16) ?GridBox { return if (area.x0 < area.x1 and area.y0 < area.y1) area else null; } -test "ASCII materialization happens in copied TTY cells" { +test "TTY consumes core-composed ASCII cells without reinterpreting glyphs" { var cells: [8]pardes.Cell = @splat(.{}); var previous: [8]pardes.Cell = @splat(.{}); - const changed: [8]u8 = @splat(1); + const diffs: [8]pardes.PanelCellDiff = @splat(.visual); for (&cells) |*cell| { cell.text[0] = 'X'; cell.len = 1; @@ -277,7 +253,7 @@ test "ASCII materialization happens in copied TTY cells" { .rows = 2, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -291,9 +267,7 @@ test "ASCII materialization happens in copied TTY cells" { 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); + for (animated.cells) |cell| try std.testing.expectEqualStrings("X", cell.grapheme()); } test "dissolve changes only diff cells from old to new" { @@ -309,13 +283,13 @@ test "dissolve changes only diff cells from old to new" { cell.len = 1; cell.default = false; } - const changed = [_]u8{ 1, 0 }; + const diffs = [_]pardes.PanelCellDiff{ .{ .ascii = .{ .from = 'O', .to = 'N' } }, .unchanged }; var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -337,45 +311,45 @@ test "dissolve changes only diff cells from old to new" { 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}; +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 = 1, + .cols = 4, .rows = 1, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; 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).*); + for ([_]panel_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" { @@ -405,14 +379,14 @@ test "content transition without a diff snaps to canonical surface" { test "exact transition endpoint preserves the canonical cursor" { var cells = [_]pardes.Cell{.{}}; var previous = cells; - const changed = [_]u8{1}; + const diffs = [_]pardes.PanelCellDiff{.visual}; var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells, .cursor = .{ .x = 0, .y = 0 }, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; const track: Track = .{ .effect = .ascii, @@ -441,13 +415,13 @@ test "vertical opening rises through a fixed old-grid clip" { cell.len = 1; cell.default = false; } - const changed: [12]u8 = @splat(1); + const diffs: [12]pardes.PanelCellDiff = @splat(.visual); var surface: pardes.Surface = .{ .cols = 3, .rows = 4, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -481,7 +455,7 @@ test "vertical opening rises through a fixed old-grid clip" { try std.testing.expectEqualStrings("c", final.at(0, 2).grapheme()); } -test "vertical closing slides frozen content upward over canonical cells" { +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| { @@ -494,13 +468,13 @@ test "vertical closing slides frozen content upward over canonical cells" { cell.len = 1; cell.default = false; } - const changed: [12]u8 = @splat(1); + const diffs: [12]pardes.PanelCellDiff = @splat(.visual); var surface: pardes.Surface = .{ .cols = 3, .rows = 4, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -518,10 +492,10 @@ test "vertical closing slides frozen content upward over canonical cells" { try std.testing.expectEqualStrings("2", first.at(0, 2).grapheme()); _ = arena.reset(.retain_capacity); - track.frame = 2; + track.frame = 8; 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("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()); @@ -539,13 +513,13 @@ test "closing content paints after opening content regardless of track order" { cells[0].default = false; previous[0].text[0] = 'O'; previous[0].default = false; - const changed = [_]u8{1}; + const diffs = [_]pardes.PanelCellDiff{.visual}; var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells, .previous_cells = &previous, - .changed_cells = &changed, + .cell_diffs = &diffs, }; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); -- cgit v1.3