summaryrefslogtreecommitdiff
path: root/src/panel_animation.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/panel_animation.zig')
-rw-r--r--src/panel_animation.zig326
1 files changed, 317 insertions, 9 deletions
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));