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