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.zig662
1 files changed, 0 insertions, 662 deletions
diff --git a/src/panel_animation.zig b/src/panel_animation.zig
deleted file mode 100644
index a77e5a34..00000000
--- a/src/panel_animation.zig
+++ /dev/null
@@ -1,662 +0,0 @@
-//! Backend-neutral vocabulary and math for pane transitions.
-//!
-//! The core publishes plain transition data and composes semantic PanelAscii
-//! bytes itself. GUI shells evaluate geometry/dissolve data in shaders; the
-//! TTY shell evaluates the same records over cells in its grid. There are
-//! deliberately no callbacks or backend objects here.
-const std = @import("std");
-
-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,
-};
-
-pub const Transition = enum(u8) {
- // These values cross both GUI shader ABIs. GLSL receives the enum in the
- // instance uvec4 and the Core Image kernel receives it as a float, so
- // spelling the numbers here keeps a source reorder from changing pixels.
- off = 0,
- slide = 1,
- zoom = 2,
- 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, .wave => .smooth,
- .slide, .vertical, .edges => .out_cubic,
- .zoom => .out_back,
- // 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,
- };
- }
-
- pub fn frames(effect: Transition) u16 {
- return switch (effect) {
- .off => 0,
- .slide => 12,
- .zoom => 14,
- .dissolve => 10,
- // Frame zero is the exact old byte. Core's AsciiDiff caps a long
- // byte walk at twelve eased movement samples, including the exact
- // 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 == .vertical or effect.composedByCore();
- }
-
- pub fn lifecycleOnly(effect: Transition) bool {
- return effect == .vertical;
- }
-};
-
-/// Full-scene shader effects. CRT is one effect in this vocabulary rather
-/// than a separate renderer switch; only one scene pass is needed even when
-/// more than one bit is enabled.
-pub const SceneEffect = struct {
- crt: bool = false,
- ripple: bool = false,
- glitch: bool = false,
-};
-
-pub const Phase = enum(u8) {
- opening = 0,
- moving = 1,
- /// Presentation-only content whose pane lifetime has already ended.
- /// It is never a valid input target.
- closing = 2,
-};
-
-pub const Box = extern struct {
- x: f32 = 0,
- y: f32 = 0,
- w: f32 = 0,
- h: f32 = 0,
-
- pub fn eql(a: Box, b: Box) bool {
- return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h;
- }
-
- /// Whether a grid cell falls inside this box. Cells are whole, boxes are
- /// fractional mid-animation, so the test is the cell's ORIGIN against a
- /// half-open range: a box straddling a column owns it once its origin is
- /// covered, and never owns it twice.
- pub fn contains(box: Box, col: u16, row: u16) bool {
- const x: f32 = @floatFromInt(col);
- const y: f32 = @floatFromInt(row);
- return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
- }
-};
-
-/// The order every backend composites tracks in: moving panes first, then new
-/// panes, then inert closing tombstones on top. Returns how many were written.
-///
-/// A function rather than a loop inside `Pardes.render` because it is a RULE
-/// three hosts used to re-derive — macos.zig re-sorted the already-sorted list
-/// and tty/panel_compositor.zig walked the phases again — and a second sort
-/// that happens to agree is the one that silently stops agreeing. `render`
-/// calls this and every host receives the result verbatim.
-///
-/// Inactive tracks are dropped here, so a host never has to ask.
-pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize {
- var len: usize = 0;
- for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| {
- const track = maybe orelse continue;
- if (!track.active() or track.phase != phase) continue;
- if (len == out.len) return len;
- out[len] = track;
- len += 1;
- };
- for (closing) |track| {
- if (!track.active()) continue;
- if (len == out.len) return len;
- out[len] = track;
- len += 1;
- }
- return len;
-}
-
-/// One POD record is enough for every backend. `from` and `to` are logical
-/// cell boxes; frontends convert them to pixels only at their render edge.
-pub const Track = extern struct {
- serial: u32 = 0,
- pane: u8 = 0,
- phase: Phase = .moving,
- effect: Transition = .off,
- _padding: u8 = 0,
- frame: u16 = 0,
- /// Core-computed duration for data-dependent effects. Zero selects the
- /// effect preset; PanelAscii fills this from the longest eased byte walk
- /// in the pane's semantic cell diff.
- frame_count: u16 = 0,
- from: Box = .{},
- to: Box = .{},
-
- pub fn active(track: Track) bool {
- return track.effect != .off and track.frame < track.frames();
- }
-
- pub fn frames(track: Track) u16 {
- return if (track.frame_count != 0) track.frame_count else track.effect.frames();
- }
-
- pub fn amount(track: Track) f32 {
- // Opening rises quickly and settles; closing reverses that motion and
- // accelerates down out of the fixed clip.
- if (track.phase == .closing and track.effect == .vertical)
- return progressEased(.in_cubic, track.frames(), track.frame);
- return progressEased(track.effect.easing(), track.frames(), track.frame);
- }
-
- pub fn presented(track: Track) Box {
- return lerpBox(track.from, track.to, track.amount());
- }
-
- /// Geometry actually painted by every backend. Content transitions reveal
- /// 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 => track.to,
- // Every character effect stays inside the pane's final rectangle.
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to,
- };
- }
-
- /// The canonical box whose cells a backend samples. Opening and moving
- /// tracks sample the new frame at `to`; a closing tombstone samples the
- /// frozen old frame at `from` because its pane no longer exists.
- pub fn contentBox(track: Track) Box {
- return if (track.phase == .closing) track.from else track.to;
- }
-};
-
-/// 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: {
- var from = target;
- const middle = target.x + target.w * 0.5;
- from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5)
- -target.w
- else
- @floatFromInt(screen_width);
- break :blk from;
- },
- .zoom => .{
- .x = target.x + target.w * 0.5,
- .y = target.y + target.h * 0.5,
- .w = 0,
- .h = 0,
- },
- // Slide upward into a fixed clip equal to the new pane's own box.
- // Starting one panel-height below that box keeps the translated
- // content from travelling across any surviving pane.
- .vertical => blk: {
- var from = target;
- from.y += target.h;
- break :blk from;
- },
- .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,
- };
-}
-
-/// Destination for a lifecycle-only closing track. The old panel drops out
-/// through its own fixed clip, reversing the opening path; surviving panes
-/// are already at canonical geometry underneath it and never receive tracks
-/// for this effect.
-pub fn closingBox(effect: Transition, source: Box) Box {
- return switch (effect) {
- .vertical => blk: {
- var to = source;
- to.y += source.h;
- break :blk to;
- },
- else => source,
- };
-}
-
-pub fn sample(easing: Easing, raw: f32) f32 {
- const t = std.math.clamp(raw, 0.0, 1.0);
- 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
- // a little past one before settling exactly on the endpoint.
- .out_back => blk: {
- const c1: f32 = 1.70158;
- const c3 = c1 + 1.0;
- const u = t - 1.0;
- break :blk 1.0 + c3 * u * u * u + c1 * u * u;
- },
- };
-}
-
-pub fn progress(effect: Transition, frame: u16) f32 {
- return progressEased(effect.easing(), effect.frames(), frame);
-}
-
-fn progressEased(easing: Easing, frames: u16, frame: u16) f32 {
- if (frames <= 1 or frame >= frames - 1) return 1.0;
- // `frames` is the number of presented samples, including both exact
- // endpoints. This makes the last active frame the real final image rather
- // than 15/16 followed by an unrendered snap to canonical content.
- return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1)));
-}
-
-pub fn lerpBox(from: Box, to: Box, t: f32) Box {
- // Keep easing overshoot for opening/moving geometry—the whole visual
- // distinction of out-back—while preventing any shrinking dimension from
- // becoming negative and flipping its quad.
- const u = @max(0.0, t);
- return .{
- .x = from.x + (to.x - from.x) * u,
- .y = from.y + (to.y - from.y) * u,
- .w = @max(0.0, from.w + (to.w - from.w) * u),
- .h = @max(0.0, from.h + (to.h - from.h) * u),
- };
-}
-
-/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only
-/// hashing means resizing or repainting a frame does not make cells flicker.
-pub fn cellNoise(serial: u32, col: u16, row: u16) f32 {
- var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b);
- x ^= x >> 16;
- x *%= 0x7feb_352d;
- x ^= x >> 15;
- x *%= 0x846c_a68b;
- x ^= x >> 16;
- return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0;
-}
-
-/// Whether a changed dissolve cell has crossed from the frozen old grid to
-/// the new one. Exact endpoints are part of the presentation contract.
-pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool {
- const t = std.math.clamp(raw_progress, 0.0, 1.0);
- if (t <= 0) return false;
- if (t >= 1) return true;
- 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));
- try std.testing.expectEqual(@as(f32, 1), sample(easing, 1));
- }
- try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5));
- 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" {
- inline for (std.enums.values(Transition)) |effect| {
- try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames()));
- if (effect.frames() > 0)
- try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1));
- try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16)));
- }
- try std.testing.expectEqual(@as(f32, 1), progress(.off, 0));
-
- const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8));
- try std.testing.expectEqual(@as(f32, 0), shrinking.w);
- try std.testing.expectEqual(@as(f32, 0), shrinking.h);
- const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8));
- try std.testing.expect(opening.w > 100);
- 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));
- try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w);
- try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x);
- try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y);
- try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y);
-
- var track: Track = .{ .effect = .slide, .from = target, .to = target };
- try std.testing.expect(track.active());
- track.frame = track.effect.frames();
- try std.testing.expect(!track.active());
-
- track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target };
- try std.testing.expectEqual(target, track.visualBox());
-
- var closing: Track = .{
- .phase = .closing,
- .effect = .vertical,
- .from = target,
- .to = closingBox(.vertical, target),
- };
- try std.testing.expectEqual(target, closing.contentBox());
- closing.frame = 2;
- try std.testing.expect(closing.amount() < progress(.vertical, closing.frame));
-}
-
-test "dissolve has exact stable endpoints" {
- for (0..64) |col| {
- const x: u16 = @intCast(col);
- try std.testing.expect(!dissolveRevealed(42, x, 7, 0));
- try std.testing.expect(dissolveRevealed(42, x, 7, 1));
- if (dissolveRevealed(42, x, 7, 0.25))
- try std.testing.expect(dissolveRevealed(42, x, 7, 0.75));
- }
-}