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.zig335
1 files changed, 335 insertions, 0 deletions
diff --git a/src/panel_animation.zig b/src/panel_animation.zig
new file mode 100644
index 00000000..045d2a87
--- /dev/null
+++ b/src/panel_animation.zig
@@ -0,0 +1,335 @@
+//! Backend-neutral vocabulary and math for pane transitions.
+//!
+//! The core publishes plain transition data. GUI shells evaluate that data in
+//! shaders; the TTY shell evaluates the same progress over cells in its grid.
+//! There are deliberately no callbacks or backend objects here.
+const std = @import("std");
+
+pub const Easing = enum(u8) {
+ linear,
+ smooth,
+ 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,
+
+ pub fn easing(effect: Transition) Easing {
+ return switch (effect) {
+ .off, .dissolve => .smooth,
+ .slide, .vertical => .out_cubic,
+ .zoom => .out_back,
+ .ascii => .linear,
+ };
+ }
+
+ pub fn frames(effect: Transition) u16 {
+ return switch (effect) {
+ .off => 0,
+ .slide => 12,
+ .zoom => 14,
+ .dissolve => 10,
+ .ascii => 16,
+ .vertical => 12,
+ };
+ }
+
+ pub fn needsPreviousGrid(effect: Transition) bool {
+ return effect == .dissolve or effect == .ascii or effect == .vertical;
+ }
+
+ 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;
+ }
+};
+
+/// 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,
+ _frame_padding: u16 = 0,
+ from: Box = .{},
+ to: Box = .{},
+
+ pub fn active(track: Track) bool {
+ return track.effect != .off and track.frame < track.effect.frames();
+ }
+
+ pub fn amount(track: Track) f32 {
+ return progress(track.effect, 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
+ /// cells in place; slide, zoom, and vertical transform the panel rectangle.
+ pub fn visualBox(track: Track) Box {
+ return switch (track.effect) {
+ .slide, .zoom, .vertical => track.presented(),
+ .off, .dissolve, .ascii => 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 ASCII
+/// 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, .ascii => target,
+ };
+}
+
+/// Destination for a lifecycle-only closing track. The old panel rises out
+/// through its own fixed clip; 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),
+ .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 {
+ const frames = effect.frames();
+ 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(effect.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;
+}
+
+pub const AsciiCell = struct {
+ /// When true the pane's real cell has arrived.
+ reveal: bool,
+ /// A transient one-byte glyph while `reveal` is false. Space means the
+ /// cell has not entered the animation yet.
+ glyph: u8,
+};
+
+/// ASCII-materialization preset: a diagonal wave perturbed by stable noise.
+/// It is content animation rather than geometry; TTY replaces the cell glyph,
+/// while GUI shaders use the same threshold to dissolve glyph coverage.
+pub fn asciiCell(serial: u32, col: u16, row: u16, raw_progress: f32) AsciiCell {
+ const t = std.math.clamp(raw_progress, 0.0, 1.0);
+ // These endpoints are real presented frames: even a cell whose stable
+ // threshold is zero starts as old data, and every cell ends as new data.
+ if (t <= 0) return .{ .reveal = false, .glyph = ' ' };
+ if (t >= 1) return .{ .reveal = true, .glyph = ' ' };
+ const noise = cellNoise(serial, col, row);
+ const diagonal = @as(f32, @floatFromInt((@as(u32, col) + @as(u32, row) * 2) % 17)) / 17.0;
+ const threshold = noise * 0.72 + diagonal * 0.28;
+ if (t >= threshold) return .{ .reveal = true, .glyph = ' ' };
+ if (t + 0.22 < threshold) return .{ .reveal = false, .glyph = ' ' };
+ const glyphs = ".:+*#%@";
+ const index: usize = @intFromFloat(@floor(noise * @as(f32, @floatFromInt(glyphs.len))));
+ return .{ .reveal = false, .glyph = glyphs[@min(index, glyphs.len - 1)] };
+}
+
+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(.out_cubic, 0.5) > sample(.linear, 0.5));
+ try std.testing.expect(sample(.out_back, 0.8) > 1.0);
+}
+
+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 "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());
+
+ const closing: Track = .{
+ .phase = .closing,
+ .effect = .vertical,
+ .from = target,
+ .to = closingBox(.vertical, target),
+ };
+ try std.testing.expectEqual(target, closing.contentBox());
+}
+
+test "ASCII materialization is stable and finishes as real content" {
+ const a = asciiCell(42, 11, 7, 0.4);
+ const b = asciiCell(42, 11, 7, 0.4);
+ try std.testing.expectEqual(a, b);
+ try std.testing.expect(!asciiCell(42, 11, 7, 0).reveal);
+ try std.testing.expectEqual(@as(u8, ' '), asciiCell(42, 11, 7, 0).glyph);
+ try std.testing.expect(asciiCell(42, 11, 7, 1).reveal);
+
+ var changed = false;
+ for (0..32) |col| {
+ if (!std.meta.eql(asciiCell(42, @intCast(col), 3, 0.35), asciiCell(43, @intCast(col), 3, 0.35))) {
+ changed = true;
+ break;
+ }
+ }
+ try std.testing.expect(changed);
+}
+
+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));
+ }
+}