summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-20 17:10:22 -0300
committerGabriel Schneider <[email protected]>2026-08-25 09:42:06 -0300
commit70bde600793ea70bd68832018a154671c6bf1512 (patch)
treeaf477fd09881b85bf3ed735ede1afa254779b14f /src
parent7d2ade5ed706c67f30f54462684bced6a0978413 (diff)
downloadpardes-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.zig6
-rw-r--r--src/macos.zig6
-rw-r--r--src/macos/pardes.h8
-rw-r--r--src/panel_animation.zig326
-rw-r--r--src/pardes.zig249
-rw-r--r--src/runtime_config.zig12
-rw-r--r--src/tty/panel_compositor.zig7
-rw-r--r--src/tutor.txt4
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 (&current, "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 = &current,
+ .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