1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
|
//! 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));
}
}
|