diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-16 15:49:12 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-18 23:44:42 -0300 |
| commit | 1551e409c31992437cb2fa864f576d45c8433801 (patch) | |
| tree | e2fae8451f87b735a1360c7c2e383fdc40165789 /src/panel_animation.zig | |
| parent | be2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff) | |
| download | pardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz pardes-1551e409c31992437cb2fa864f576d45c8433801.zip | |
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/panel_animation.zig')
| -rw-r--r-- | src/panel_animation.zig | 335 |
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)); + } +} |
