From 70bde600793ea70bd68832018a154671c6bf1512 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 20 Aug 2026 17:10:22 -0300 Subject: animation: six character-motion panel transitions, composed in the core so backends agree --- src/pardes.zig | 249 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 197 insertions(+), 52 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index 73b431e6..968b73fb 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1978,28 +1978,35 @@ pub const AsciiDiff = struct { return diff.movementFrames() + 1; } - /// Move through the u8 range with integer ease-out. Every frame advances - /// at least one byte; distances beyond max_movement_frames are distributed - /// as larger skips near the start and taper to an exact final step. Keeping - /// this integer-only makes every backend receive the same character. + /// Move through the u8 range with integer ease-in-out over exactly + /// `movementFrames` samples. A byte creeps at both ends and crosses the + /// middle of its distance in a few large skips, inside the same frame + /// count the old constant one-byte-per-frame walk took. Keeping this + /// integer-only makes every backend receive the same character. pub fn byteAt(diff: AsciiDiff, frame: u16) u8 { - const byte_distance: u32 = diff.distance(); const movements: u32 = diff.movementFrames(); const at: u32 = @min(@as(u32, frame), movements); - const extra = byte_distance - movements; - const remaining = movements - at; - const denominator = movements * movements; - const eased_numerator = denominator - remaining * remaining; - // Ceiling division consumes all skipped distance before the last - // movement, so a long walk always settles onto its target by one. - const skipped = if (eased_numerator == 0) - 0 - else - (extra * eased_numerator + denominator - 1) / denominator; - const delta: u8 = @intCast(at + skipped); + const delta: u8 = @intCast(easedDistance(diff.distance(), movements, at)); return if (diff.from < diff.to) diff.from + delta else diff.from - delta; } + /// `distance * smootherstep(at / movements)`, rounded, without touching + /// floating point. Endpoints are exact — zero at frame zero, the whole + /// distance at the last movement — and the curve is monotonic, so a byte + /// never walks backwards between samples. + fn easedDistance(span: u8, movements: u32, at: u32) u32 { + if (movements == 0 or at >= movements) return span; + const n: u64 = at; + const d: u64 = movements; + // Quintic smootherstep as one exact fraction: n^3 (10 d^2 + 6 n^2 - + // 15 d n) over d^5. The positive terms are summed first because + // 10 d^2 + 6 n^2 >= 15 d n for every n <= d: unsigned arithmetic must + // never see the intermediate go below zero. + const shape = n * n * n * (10 * d * d + 6 * n * n - 15 * d * n); + const denominator = d * d * d * d * d; + return @intCast((@as(u64, span) * shape + denominator / 2) / denominator); + } + pub fn complete(diff: AsciiDiff, frame: u16) bool { return diff.byteAt(frame) == diff.to; } @@ -2047,29 +2054,51 @@ test "ASCII cell diffs ease long byte walks in both directions" { var high: Cell = .{ .default = false }; high.text[0] = 'F'; + // Ease-in-out inside exactly the five movement frames the old constant + // walk used: the first sample holds, the middle crosses two values at a + // time, and the endpoint is exact. const rising = AsciiDiff.between(&low, &high).?; + try std.testing.expectEqual(@as(u16, 6), rising.frameCount()); try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(0)); - try std.testing.expectEqual(@as(u8, 'B'), rising.byteAt(1)); - try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(5)); + try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(1)); + try std.testing.expectEqual(@as(u8, 'C'), rising.byteAt(2)); + try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(4)); try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(500)); const falling = AsciiDiff.between(&high, &low).?; - try std.testing.expectEqual(@as(u8, 'E'), falling.byteAt(1)); - try std.testing.expectEqual(@as(u8, 'A'), falling.byteAt(5)); + try std.testing.expectEqual(@as(u8, 'D'), falling.byteAt(2)); + try std.testing.expectEqual(@as(u8, 'A'), falling.byteAt(4)); low.text[0] = ' '; high.text[0] = '~'; const long_rising = AsciiDiff.between(&low, &high).?; try std.testing.expectEqual(@as(u16, 13), long_rising.frameCount()); try std.testing.expectEqual(@as(u8, ' '), long_rising.byteAt(0)); - try std.testing.expectEqual(@as(u8, '/'), long_rising.byteAt(1)); - try std.testing.expectEqual(@as(u8, '}'), long_rising.byteAt(11)); + try std.testing.expectEqual(@as(u8, '#'), long_rising.byteAt(2)); + try std.testing.expectEqual(@as(u8, 'O'), long_rising.byteAt(6)); + try std.testing.expectEqual(@as(u8, '{'), long_rising.byteAt(10)); try std.testing.expectEqual(@as(u8, '~'), long_rising.byteAt(12)); const long_falling = AsciiDiff.between(&high, &low).?; - try std.testing.expectEqual(@as(u8, 'o'), long_falling.byteAt(1)); - try std.testing.expectEqual(@as(u8, '!'), long_falling.byteAt(11)); + try std.testing.expectEqual(@as(u8, 'O'), long_falling.byteAt(6)); try std.testing.expectEqual(@as(u8, ' '), long_falling.byteAt(12)); + + // The shape itself is the contract: never backwards, and the widest step + // is in the middle of the walk rather than at either end. + var previous = long_rising.byteAt(0); + var first_step: u8 = 0; + var middle_step: u8 = 0; + var frame: u16 = 1; + while (frame <= long_rising.movementFrames()) : (frame += 1) { + const byte = long_rising.byteAt(frame); + try std.testing.expect(byte >= previous); + const step = byte - previous; + if (frame == 1) first_step = step; + if (frame == 6) middle_step = step; + previous = byte; + } + try std.testing.expect(middle_step > first_step); + try std.testing.expect(middle_step > long_rising.byteAt(12) - long_rising.byteAt(11)); } test "ASCII diff classification skips stable and non-ASCII glyphs" { @@ -4320,6 +4349,47 @@ test "core composes ASCII bytes while visual-only cells pass through" { try std.testing.expectEqualStrings("D", surface.at(0, 0).grapheme()); } +test "core composes character motion out of the new grid, not a fade" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 4, .tty_only = true }); + defer p.deinit(); + var current: [8]Cell = @splat(.{ .default = false }); + var previous = current; + for (¤t, "abcdefgh") |*cell, byte| cell.text[0] = byte; + for (&previous, "........") |*cell, byte| cell.text[0] = byte; + // Motion effects carry every glyph in the pane, so a cell's semantic + // classification is irrelevant to them; only the frozen grid matters. + const diffs: [8]PanelCellDiff = @splat(.visual); + var surface: Surface = .{ + .cols = 8, + .rows = 1, + .cells = ¤t, + .previous_cells = &previous, + .cell_diffs = &diffs, + }; + surface.panel_tracks[0] = .{ + .effect = .edges, + .frame = 4, + .to = .{ .w = 8, .h = 1 }, + }; + surface.npanel_tracks = 1; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + // Row zero is sliding in from the left screen edge: it holds real glyphs + // from two columns further right, and the cells its text has not reached + // yet keep the frozen old grid rather than a blend or a blank. + const presented = try p.composeAsciiTransitions(arena.allocator(), &surface); + try std.testing.expect(presented != &surface); + var seen: [8]u8 = undefined; + for (&seen, 0..) |*byte, col| byte.* = presented.at(@intCast(col), 0).text[0]; + try std.testing.expectEqualStrings("cdefgh..", &seen); + try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme()); + + // The last active sample is the untouched canonical grid, with no copy. + surface.panel_tracks[0].frame = panel_animation.Transition.edges.frames() - 1; + try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface); +} + test "pointer rejects panel content cells which have not materialized" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true }); defer p.deinit(); @@ -10632,6 +10702,23 @@ pub const Pardes = struct { }; return if (visible) .{ .col = col, .row = row } else null; }, + // Every glyph in a motion effect's pane is in flight, + // changed or not, so a cell becomes a truthful input target + // only once its own glyph has settled on the canonical one. + .edges, .fall, .wave, .curtain, .scramble, .typewriter => { + if (!boxContainsCell(track.to, col, row)) continue; + const area = panel_animation.CellArea.of(track.to); + const settled = std.meta.eql( + panel_animation.charSource( + track, + col -| area.x0, + row -| area.y0, + area, + ), + panel_animation.CharSource.settled, + ); + return if (settled) .{ .col = col, .row = row } else null; + }, .off => {}, } } @@ -13397,52 +13484,110 @@ pub const Pardes = struct { } // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN - /// Lazily copy the canonical grid only when an active PanelAscii track has - /// a character which has not reached its target yet. The returned Surface - /// is the sole backend boundary, so every shell rasterizes the exact same - /// intermediate bytes and style-only/non-ASCII changes pass through once. + /// Lazily copy the canonical grid only when an active core-composed + /// character track still shows something other than its final glyph. The + /// returned Surface is the sole backend boundary, so every shell rasterizes + /// the exact same intermediate characters and style-only/non-ASCII changes + /// pass through once. fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { _ = p; if (!canonical.hasPanelDiff()) return canonical; var presented: ?*Surface = null; for (canonical.panelTracks()) |track| { - if (track.effect != .ascii or track.phase == .closing) continue; - var row: u16 = 0; - while (row < canonical.rows) : (row += 1) { - var col: u16 = 0; - while (col < canonical.cols) : (col += 1) { - if (!boxContainsCell(track.to, col, row)) continue; + if (!track.effect.composedByCore() or track.phase == .closing) continue; + const area = panel_animation.CellArea.of(track.to); + const col_end = @min(canonical.cols, area.x0 +| area.cols); + const row_end = @min(canonical.rows, area.y0 +| area.rows); + var row: u16 = area.y0; + while (row < row_end) : (row += 1) { + var col: u16 = area.x0; + while (col < col_end) : (col += 1) { const index = @as(usize, row) * canonical.cols + col; - const diff = switch (canonical.cell_diffs[index]) { - .ascii => |diff| diff, - .unchanged, .visual => continue, - }; - const byte = diff.byteAt(track.frame); - if (byte == diff.to) continue; + const composed = composedCell(canonical, track, area, col, row, index) orelse + continue; + if (composed.visuallyEqual(&canonical.cells[index])) continue; if (presented == null) { const next = try arena.create(Surface); next.* = canonical.*; next.cells = try arena.dupe(Cell, canonical.cells); presented = next; } - const cell = &presented.?.cells[index]; - if (track.frame == 0) { - cell.* = canonical.previous_cells[index]; - } else { - // The semantic destination owns presentation style as - // soon as its byte begins walking. At the endpoint the - // untouched canonical cell wins bit-for-bit instead. - cell.* = canonical.cells[index]; - cell.text[0] = byte; - cell.len = 1; - cell.default = false; - } + presented.?.cells[index] = composed; } } } return presented orelse canonical; } + /// The character one cell presents this frame, or null when the canonical + /// cell is already the honest answer. PanelAscii walks the semantic byte + /// distance of a *changed* cell; the motion effects carry every glyph in + /// the pane, because text flying in from a screen edge has to bring its + /// unchanged glyphs along with it. + fn composedCell( + canonical: *const Surface, + track: panel_animation.Track, + area: panel_animation.CellArea, + col: u16, + row: u16, + index: usize, + ) ?Cell { + if (track.effect == .ascii) { + const diff = switch (canonical.cell_diffs[index]) { + .ascii => |diff| diff, + .unchanged, .visual => return null, + }; + const byte = diff.byteAt(track.frame); + if (byte == diff.to) return null; + // Frame zero is the exact old cell. Once a byte is walking, the + // semantic destination owns presentation style, and at the endpoint + // the untouched canonical cell wins bit-for-bit instead. + if (track.frame == 0) return canonical.previous_cells[index]; + return withByte(canonical.cells[index], byte); + } + return switch (panel_animation.charSource(track, col - area.x0, row - area.y0, area)) { + .old => canonical.previous_cells[index], + .byte => |byte| withByte(canonical.cells[index], byte), + // Churn belongs on the glyph, not on the pane's empty space, and it + // borrows whichever side of the diff actually has one. + .churn => |byte| blk: { + const new_cell = &canonical.cells[index]; + if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte); + const old_cell = &canonical.previous_cells[index]; + if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte); + break :blk old_cell.*; + }, + .at => |offset| blk: { + const source_col = @as(i32, col) + offset.cols; + const source_row = @as(i32, row) + offset.rows; + // A glyph whose source lies outside the pane has not arrived + // yet: the frozen old cell is the only truthful thing under it. + if (source_col < area.x0 or source_row < area.y0 or + source_col >= @as(i32, area.x0) + area.cols or + source_row >= @as(i32, area.y0) + area.rows or + source_col >= canonical.cols or source_row >= canonical.rows) + break :blk canonical.previous_cells[index]; + const source = @as(usize, @intCast(source_row)) * canonical.cols + + @as(usize, @intCast(source_col)); + break :blk canonical.cells[source]; + }, + }; + } + + fn withByte(cell: Cell, byte: u8) Cell { + var out = cell; + out.text[0] = byte; + out.len = 1; + out.default = false; + return out; + } + + /// Whether a cell shows a glyph a character effect can work on. An unpainted + /// cell and a space are both blank space, whatever style they carry. + fn hasGlyph(cell: *const Cell) bool { + return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); + } + fn preparePanelDiff(p: *Pardes) !bool { const count = p.surface.cells.len; if (!p.presented_cells_valid or -- cgit v1.3