diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-20 17:10:22 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 09:42:06 -0300 |
| commit | 70bde600793ea70bd68832018a154671c6bf1512 (patch) | |
| tree | af477fd09881b85bf3ed735ede1afa254779b14f /src | |
| parent | 7d2ade5ed706c67f30f54462684bced6a0978413 (diff) | |
| download | pardes-70bde600793ea70bd68832018a154671c6bf1512.tar.gz pardes-70bde600793ea70bd68832018a154671c6bf1512.zip | |
animation: six character-motion panel transitions, composed in the core so backends agree
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.zig | 6 | ||||
| -rw-r--r-- | src/macos.zig | 6 | ||||
| -rw-r--r-- | src/macos/pardes.h | 8 | ||||
| -rw-r--r-- | src/panel_animation.zig | 326 | ||||
| -rw-r--r-- | src/pardes.zig | 249 | ||||
| -rw-r--r-- | src/runtime_config.zig | 12 | ||||
| -rw-r--r-- | src/tty/panel_compositor.zig | 7 | ||||
| -rw-r--r-- | src/tutor.txt | 4 |
8 files changed, 554 insertions, 64 deletions
diff --git a/src/config.zig b/src/config.zig index 83cd2a99..a01db954 100644 --- a/src/config.zig +++ b/src/config.zig @@ -202,6 +202,12 @@ pub const leader_path = paths: { table.set(.PanelDissolve, "ad"); table.set(.PanelAscii, "aa"); table.set(.PanelVertical, "av"); + table.set(.PanelEdges, "ae"); + table.set(.PanelFall, "af"); + table.set(.PanelWave, "aw"); + table.set(.PanelCurtain, "ac"); + table.set(.PanelScramble, "ar"); + table.set(.PanelType, "at"); } if (builtins.capabilities.scene_shaders) { table.set(.Crt, "tr"); diff --git a/src/macos.zig b/src/macos.zig index 38d2c961..2569e6b3 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -2191,6 +2191,12 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(panel_animation.Transition.dissolve)); try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(panel_animation.Transition.ascii)); try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(panel_animation.Transition.vertical)); + try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(panel_animation.Transition.edges)); + try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(panel_animation.Transition.fall)); + try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(panel_animation.Transition.wave)); + try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(panel_animation.Transition.curtain)); + try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(panel_animation.Transition.scramble)); + try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(panel_animation.Transition.typewriter)); // Every key the host has a name for must be the codepoint the core reads. try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter); diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 33bedc9e..e9d7bb41 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -123,6 +123,14 @@ typedef struct { #define PARDES_PANEL_DISSOLVE 3u #define PARDES_PANEL_ASCII 4u #define PARDES_PANEL_VERTICAL 5u +// Character effects composed in the core: the host receives finished glyphs +// and only clips the panel batch for them. +#define PARDES_PANEL_EDGES 6u +#define PARDES_PANEL_FALL 7u +#define PARDES_PANEL_WAVE 8u +#define PARDES_PANEL_CURTAIN 9u +#define PARDES_PANEL_SCRAMBLE 10u +#define PARDES_PANEL_TYPEWRITER 11u // ---------------------------------------------------------------- input diff --git a/src/panel_animation.zig b/src/panel_animation.zig index 0451213d..e0cb32d3 100644 --- a/src/panel_animation.zig +++ b/src/panel_animation.zig @@ -11,6 +11,9 @@ pub const ascii_max_movement_frames: u16 = 12; pub const Easing = enum(u8) { linear, smooth, + /// Quintic ease-in-out. It creeps at both ends and crosses the middle of + /// the distance fast, inside the same frame count a linear walk would use. + smoother, in_cubic, out_cubic, out_back, @@ -26,13 +29,27 @@ pub const Transition = enum(u8) { dissolve = 3, ascii = 4, vertical = 5, + // Character effects the core composes into Surface cells (see + // `composedByCore`). A backend never evaluates them: it receives finished + // glyphs, so these ids reach a shader only as "draw this panel batch". + edges = 6, + fall = 7, + wave = 8, + curtain = 9, + scramble = 10, + typewriter = 11, pub fn easing(effect: Transition) Easing { return switch (effect) { - .off, .dissolve => .smooth, - .slide, .vertical => .out_cubic, + .off, .dissolve, .wave => .smooth, + .slide, .vertical, .edges => .out_cubic, .zoom => .out_back, - .ascii => .linear, + // Character walks and per-cell locks read best with a slow start, + // a fast middle, and a slow settle over their fixed frame count. + .ascii, .fall, .scramble => .smoother, + // A sweep and a typewriter are constant-rate by definition: easing + // their head would make the pass visibly hesitate mid-pane. + .curtain, .typewriter => .linear, }; } @@ -47,11 +64,25 @@ pub const Transition = enum(u8) { // destination; nearby bytes still move one value at a time. .ascii => ascii_max_movement_frames + 1, .vertical => 12, + .edges, .curtain, .scramble => 12, + // Travelling motion needs a couple more samples than a lock or a + // rigid slide before it stops reading as a jump. + .fall, .wave, .typewriter => 14, + }; + } + + /// Character effects whose glyphs the core writes into the published + /// Surface. Every backend rasterizes the same finished cells, which is why + /// none of them owns a byte walk, a stagger, or a noise threshold. + pub fn composedByCore(effect: Transition) bool { + return switch (effect) { + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true, + .off, .slide, .zoom, .dissolve, .vertical => false, }; } pub fn needsPreviousGrid(effect: Transition) bool { - return effect == .dissolve or effect == .ascii or effect == .vertical; + return effect == .dissolve or effect == .vertical or effect.composedByCore(); } pub fn lifecycleOnly(effect: Transition) bool { @@ -124,11 +155,14 @@ pub const Track = extern struct { } /// Geometry actually painted by every backend. Content transitions reveal - /// cells in place; slide, zoom, and vertical transform the panel rectangle. + /// or move cells inside the final rectangle; slide, zoom, and vertical + /// transform the panel rectangle itself. pub fn visualBox(track: Track) Box { return switch (track.effect) { .slide, .zoom, .vertical => track.presented(), - .off, .dissolve, .ascii => track.to, + .off, .dissolve => track.to, + // Every character effect stays inside the pane's final rectangle. + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to, }; } @@ -140,8 +174,9 @@ pub const Track = extern struct { } }; -/// Preset starting geometry for a newly-visible panel. Dissolve and ASCII -/// animate content in place; slide, zoom, and vertical animate its rectangle. +/// Preset starting geometry for a newly-visible panel. Dissolve and the +/// character effects animate content in place; slide, zoom, and vertical +/// animate its rectangle. pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { return switch (effect) { .slide => blk: { @@ -167,7 +202,10 @@ pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { from.y += target.h; break :blk from; }, - .off, .dissolve, .ascii => target, + .off, .dissolve => target, + // Character effects own the glyphs inside a fixed rectangle, so their + // panel opens at exactly its final geometry. + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target, }; } @@ -191,6 +229,7 @@ pub fn sample(easing: Easing, raw: f32) f32 { return switch (easing) { .linear => t, .smooth => t * t * (3.0 - 2.0 * t), + .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0), .in_cubic => t * t * t, .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), // Robert Penner's ease-out-back polynomial. It intentionally travels @@ -250,6 +289,129 @@ pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool return cellNoise(serial, col, row) < t; } +/// The pane-local cell grid a core-composed character effect walks. Origin and +/// size use the same floor/ceil convention as the GUI cell-coordinate upload, +/// so the core's composition and any backend port index the same glyph. +pub const CellArea = struct { + x0: u16 = 0, + y0: u16 = 0, + cols: u16 = 1, + rows: u16 = 1, + + pub fn of(box: Box) CellArea { + return .{ + .x0 = floorCell(box.x), + .y0 = floorCell(box.y), + .cols = ceilCell(box.w), + .rows = ceilCell(box.h), + }; + } +}; + +fn floorCell(value: f32) u16 { + return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16)))); +} + +fn ceilCell(value: f32) u16 { + return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16)))); +} + +/// What one pane cell shows this frame under a core-composed character +/// effect. `at` offsets are in cells and relative to the destination cell, so +/// an all-zero offset is exactly the canonical glyph and every effect ends on +/// the untouched final frame. +pub const CharSource = union(enum) { + /// Nothing has arrived here yet: keep the frozen old cell. + old, + at: Offset, + /// Paint this printable byte in the destination cell's own style, whatever + /// that cell holds — a caret marching over empty space is still a caret. + byte: u8, + /// Paint this printable byte only where there is a glyph to churn. Noise + /// over blank cells would fill a pane with junk instead of letting its + /// text resolve out of noise. + churn: u8, + + pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 }; + + pub const settled: CharSource = .{ .at = .{} }; +}; + +/// One cell of one core-composed character effect. Offsets travel with the +/// glyph rather than blending it: a cell either holds a real glyph from the +/// new grid, the frozen old glyph, or a churning byte, never a mix. A source +/// outside the pane is the caller's cue to keep the old cell. +pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource { + const t = track.amount(); + if (t >= 1.0) return .settled; + const w: f32 = @floatFromInt(area.cols); + const h: f32 = @floatFromInt(area.rows); + const c: f32 = @floatFromInt(col); + const r: f32 = @floatFromInt(row); + const remaining = 1.0 - t; + return switch (track.effect) { + // Whole rows arrive from the left and right screen edges, alternating. + // Sliding rigid rows is what keeps the glyphs crisp: one row is one + // rigid translation, so no cell ever samples two source glyphs. + .edges => blk: { + const travel = cellsOf(remaining * (w + 1.0)); + break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } }; + }, + // Columns rain down, each with its own stable head start, so the pane + // fills from the top and the last glyphs land at the bottom. + .fall => blk: { + const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4); + if (local <= 0.0) break :blk .old; + break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } }; + }, + // A vertical ripple travels left to right and its amplitude decays, so + // the pane settles out of a wave instead of a fade. + .wave => .{ .at = .{ + .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)), + } }, + // A curtain of glyphs marches in from the right, column by column, left + // to right; each column still has a short slide of its own. + .curtain => blk: { + const lead = t * (w + 1.0) - c; + if (lead <= 0.0) break :blk .old; + break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } }; + }, + // Every cell churns through printable ASCII and locks onto its final + // glyph at its own stable threshold: the pane resolves out of noise. + .scramble => blk: { + if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled; + const churn = cellNoise( + track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f), + col, + row, + ); + break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) }; + }, + // Reading-order reveal with a caret sitting on the write head. + .typewriter => blk: { + const head = t * w * h; + const index = r * w + c; + if (index + 1.0 <= head) break :blk .settled; + if (index <= head) break :blk .{ .byte = '_' }; + break :blk .old; + }, + // PanelAscii walks its own byte distance per cell, and the geometry + // effects never reach this path at all. + .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled, + }; +} + +fn cellsOf(distance: f32) i32 { + return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0))); +} + +/// Remap track progress into one cell's own window. A stagger delays a glyph +/// without making the effect as a whole end after its last frame. +fn staggered(t: f32, delay: f32) f32 { + if (delay >= 1.0) return t; + return (t - delay) / (1.0 - delay); +} + test "easing presets have exact endpoints and intended shapes" { inline for (std.enums.values(Easing)) |easing| { try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); @@ -259,6 +421,13 @@ test "easing presets have exact endpoints and intended shapes" { try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); try std.testing.expect(sample(.out_back, 0.8) > 1.0); + // Slow at both ends, fast through the middle, and symmetric about the + // halfway point: the same curve the integer byte walk reproduces. + try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5)); + try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15)); + try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85)); + try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) > + sample(.linear, 0.6) - sample(.linear, 0.4)); } test "transition progress completes exactly" { @@ -278,6 +447,145 @@ test "transition progress completes exactly" { try std.testing.expect(opening.h > 40); } +test "character effects are core-composed and settle on the canonical glyph" { + const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 }; + const area: CellArea = .of(box); + try std.testing.expectEqual(@as(u16, 4), area.x0); + try std.testing.expectEqual(@as(u16, 2), area.y0); + try std.testing.expectEqual(@as(u16, 20), area.cols); + try std.testing.expectEqual(@as(u16, 6), area.rows); + + inline for (std.enums.values(Transition)) |effect| { + if (comptime !effect.composedByCore()) continue; + // Core composition needs the frozen old grid for every glyph which has + // not arrived, so no character effect may animate without it. + try std.testing.expect(effect.needsPreviousGrid()); + if (comptime effect == .ascii) continue; // owns its own per-cell byte walk + + const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box }; + const first: Track = .{ .effect = effect, .frame = 0, .to = box }; + var moving = false; + var row: u16 = 0; + while (row < area.rows) : (row += 1) { + var col: u16 = 0; + while (col < area.cols) : (col += 1) { + // The last active sample is the exact canonical grid: no cell + // is displaced, churning, or still frozen. + try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area)); + if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area))) + moving = true; + } + } + try std.testing.expect(moving); + } +} + +test "each character effect moves glyphs along its own axis" { + const box: Box = .{ .w = 30, .h = 8 }; + const area: CellArea = .of(box); + + // Rows alternate which screen edge they come from, and every glyph in a row + // travels as one rigid slide: one offset, no vertical component. + var edges: Track = .{ .effect = .edges, .frame = 2, .to = box }; + const even = charSource(edges, 5, 0, area).at; + const odd = charSource(edges, 5, 1, area).at; + try std.testing.expect(even.cols > 0); + try std.testing.expectEqual(-even.cols, odd.cols); + try std.testing.expectEqual(@as(i32, 0), even.rows); + try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at); + edges.frame = 5; + try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols); + + // Falling columns are vertical only, staggered, and sample from below the + // destination because the new text is still above the pane. + const fall: Track = .{ .effect = .fall, .frame = 4, .to = box }; + var falling = false; + var col: u16 = 0; + while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) { + .old => {}, + .byte, .churn => return error.FallShouldNotChurn, + .at => |offset| { + try std.testing.expectEqual(@as(i32, 0), offset.cols); + try std.testing.expect(offset.rows >= 0); + if (offset.rows > 0) falling = true; + }, + }; + try std.testing.expect(falling); + + // The wave displaces rows both ways as it travels, and only rows. + const wave: Track = .{ .effect = .wave, .frame = 1, .to = box }; + var above = false; + var below = false; + col = 0; + while (col < area.cols) : (col += 1) { + const offset = charSource(wave, col, 3, area).at; + try std.testing.expectEqual(@as(i32, 0), offset.cols); + if (offset.rows < 0) above = true; + if (offset.rows > 0) below = true; + } + try std.testing.expect(above and below); + + // The curtain has a head: columns behind it hold the old grid, columns the + // head has passed are settled, and the head itself is still sliding. + const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box }; + try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area)); + try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area)); + var sliding = false; + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) { + .at => |offset| if (offset.cols < 0) { + sliding = true; + }, + .old, .byte, .churn => {}, + }; + try std.testing.expect(sliding); + + // Scramble churns printable ASCII per cell and per frame, then locks. It + // asks for churn rather than an unconditional byte, so the compositor can + // keep the pane's blank space blank. + var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box }; + var churning: usize = 0; + var locked: usize = 0; + var changed = false; + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) { + .churn => |byte| { + try std.testing.expect(byte >= ' ' and byte <= '~'); + churning += 1; + scramble.frame = 4; + switch (charSource(scramble, col, 0, area)) { + .churn => |next| changed = changed or next != byte, + .old, .at, .byte => {}, + } + scramble.frame = 3; + }, + .at => locked += 1, + .old, .byte => return error.ScrambleShouldNotFreeze, + }; + try std.testing.expect(churning > 0 and locked > 0 and changed); + + // The typewriter writes in reading order with a caret on its head. + const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box }; + try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area)); + try std.testing.expectEqual( + CharSource{ .old = {} }, + charSource(typewriter, area.cols - 1, area.rows - 1, area), + ); + var carets: usize = 0; + var row: u16 = 0; + while (row < area.rows) : (row += 1) { + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) { + .byte => |byte| { + try std.testing.expectEqual(@as(u8, '_'), byte); + carets += 1; + }, + .old, .at, .churn => {}, + }; + } + try std.testing.expectEqual(@as(usize, 1), carets); +} + test "opening presets separate geometry and content transitions" { const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 }; try std.testing.expectEqual(target, openingBox(.ascii, target, 80)); 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 diff --git a/src/runtime_config.zig b/src/runtime_config.zig index edfd8652..19964a48 100644 --- a/src/runtime_config.zig +++ b/src/runtime_config.zig @@ -158,6 +158,12 @@ pub const settings = [_]Setting{ .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions }, .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions }, .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions }, + .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions }, + .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions }, + .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions }, + .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions }, + .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, + .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, @@ -487,6 +493,12 @@ test "Config report observes every simple setting and all live context" { .dissolve => "Panel transition: PanelDissolve\n", .ascii => "Panel transition: PanelAscii\n", .vertical => "Panel transition: PanelVertical\n", + .edges => "Panel transition: PanelEdges\n", + .fall => "Panel transition: PanelFall\n", + .wave => "Panel transition: PanelWave\n", + .curtain => "Panel transition: PanelCurtain\n", + .scramble => "Panel transition: PanelScramble\n", + .typewriter => "Panel transition: PanelType\n", }, .scene => |effect| switch (effect) { .crt => "Crt: on\n", diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig index 1891c623..2ca0263e 100644 --- a/src/tty/panel_compositor.zig +++ b/src/tty/panel_compositor.zig @@ -84,9 +84,10 @@ pub fn compose( .off => {}, .slide, .zoom => transform(out, source, track), .dissolve => dissolve(out, source, track), - // Pardes core has already composed the byte-wise ASCII diff - // into source.cells. TTY only owns cursor suppression here. - .ascii => {}, + // 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), } } diff --git a/src/tutor.txt b/src/tutor.txt index 193ef29c..d6b82713 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -880,6 +880,10 @@ abc SPC t f FontSel (gui and macOS only) SPC a s/z/d/a/v PanelSlide / PanelZoom / PanelDissolve / PanelAscii / PanelVertical + SPC a e/f/w PanelEdges / PanelFall / PanelWave + SPC a c/r/t PanelCurtain / PanelScramble / PanelType — glyphs + marching in, churning into place, and typed in + reading order (native tty, SDL GUI, and macOS; press again to turn off) SPC t w Wrap: soft line breaks in a file pane, ON to start with — so this is the switch that lets a long line |
