From 31fece62f56aa2311e2325de83659edbc9e641db Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 17 Aug 2026 16:12:56 -0300 Subject: animation: core publishes transition records; gui evaluates via shaders, tty over grid cells --- src/panel_animation.zig | 101 ++++++++++++++++++++---------------------------- 1 file changed, 41 insertions(+), 60 deletions(-) (limited to 'src/panel_animation.zig') diff --git a/src/panel_animation.zig b/src/panel_animation.zig index 045d2a87..0451213d 100644 --- a/src/panel_animation.zig +++ b/src/panel_animation.zig @@ -1,13 +1,17 @@ //! 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. +//! 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, }; @@ -38,7 +42,10 @@ pub const Transition = enum(u8) { .slide => 12, .zoom => 14, .dissolve => 10, - .ascii => 16, + // 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, }; } @@ -89,16 +96,27 @@ pub const Track = extern struct { effect: Transition = .off, _padding: u8 = 0, frame: u16 = 0, - _frame_padding: 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.effect.frames(); + 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 { - return progress(track.effect, track.frame); + // 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 { @@ -153,14 +171,15 @@ pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { }; } -/// 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. +/// 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; + to.y += source.h; break :blk to; }, else => source, @@ -172,6 +191,7 @@ pub fn sample(easing: Easing, raw: f32) f32 { 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. @@ -185,12 +205,15 @@ pub fn sample(easing: Easing, raw: f32) f32 { } pub fn progress(effect: Transition, frame: u16) f32 { - const frames = effect.frames(); + 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(effect.easing(), @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); + return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); } pub fn lerpBox(from: Box, to: Box, t: f32) Box { @@ -227,39 +250,13 @@ pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool 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(.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); } @@ -287,7 +284,7 @@ test "opening presets separate geometry and content transitions" { 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); + 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()); @@ -297,31 +294,15 @@ test "opening presets separate geometry and content transitions" { track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; try std.testing.expectEqual(target, track.visualBox()); - const closing: Track = .{ + var 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); + closing.frame = 2; + try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); } test "dissolve has exact stable endpoints" { -- cgit v1.3