//! 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, 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, 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, // 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, }; } 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, /// 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 /// 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 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), .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; } 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); } 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()); 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)); } }