summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig249
1 files changed, 197 insertions, 52 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 73b431e6..968b73fb 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1978,28 +1978,35 @@ pub const AsciiDiff = struct {
return diff.movementFrames() + 1;
}
- /// Move through the u8 range with integer ease-out. Every frame advances
- /// at least one byte; distances beyond max_movement_frames are distributed
- /// as larger skips near the start and taper to an exact final step. Keeping
- /// this integer-only makes every backend receive the same character.
+ /// Move through the u8 range with integer ease-in-out over exactly
+ /// `movementFrames` samples. A byte creeps at both ends and crosses the
+ /// middle of its distance in a few large skips, inside the same frame
+ /// count the old constant one-byte-per-frame walk took. Keeping this
+ /// integer-only makes every backend receive the same character.
pub fn byteAt(diff: AsciiDiff, frame: u16) u8 {
- const byte_distance: u32 = diff.distance();
const movements: u32 = diff.movementFrames();
const at: u32 = @min(@as(u32, frame), movements);
- const extra = byte_distance - movements;
- const remaining = movements - at;
- const denominator = movements * movements;
- const eased_numerator = denominator - remaining * remaining;
- // Ceiling division consumes all skipped distance before the last
- // movement, so a long walk always settles onto its target by one.
- const skipped = if (eased_numerator == 0)
- 0
- else
- (extra * eased_numerator + denominator - 1) / denominator;
- const delta: u8 = @intCast(at + skipped);
+ const delta: u8 = @intCast(easedDistance(diff.distance(), movements, at));
return if (diff.from < diff.to) diff.from + delta else diff.from - delta;
}
+ /// `distance * smootherstep(at / movements)`, rounded, without touching
+ /// floating point. Endpoints are exact — zero at frame zero, the whole
+ /// distance at the last movement — and the curve is monotonic, so a byte
+ /// never walks backwards between samples.
+ fn easedDistance(span: u8, movements: u32, at: u32) u32 {
+ if (movements == 0 or at >= movements) return span;
+ const n: u64 = at;
+ const d: u64 = movements;
+ // Quintic smootherstep as one exact fraction: n^3 (10 d^2 + 6 n^2 -
+ // 15 d n) over d^5. The positive terms are summed first because
+ // 10 d^2 + 6 n^2 >= 15 d n for every n <= d: unsigned arithmetic must
+ // never see the intermediate go below zero.
+ const shape = n * n * n * (10 * d * d + 6 * n * n - 15 * d * n);
+ const denominator = d * d * d * d * d;
+ return @intCast((@as(u64, span) * shape + denominator / 2) / denominator);
+ }
+
pub fn complete(diff: AsciiDiff, frame: u16) bool {
return diff.byteAt(frame) == diff.to;
}
@@ -2047,29 +2054,51 @@ test "ASCII cell diffs ease long byte walks in both directions" {
var high: Cell = .{ .default = false };
high.text[0] = 'F';
+ // Ease-in-out inside exactly the five movement frames the old constant
+ // walk used: the first sample holds, the middle crosses two values at a
+ // time, and the endpoint is exact.
const rising = AsciiDiff.between(&low, &high).?;
+ try std.testing.expectEqual(@as(u16, 6), rising.frameCount());
try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(0));
- try std.testing.expectEqual(@as(u8, 'B'), rising.byteAt(1));
- try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(5));
+ try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(1));
+ try std.testing.expectEqual(@as(u8, 'C'), rising.byteAt(2));
+ try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(4));
try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(500));
const falling = AsciiDiff.between(&high, &low).?;
- try std.testing.expectEqual(@as(u8, 'E'), falling.byteAt(1));
- try std.testing.expectEqual(@as(u8, 'A'), falling.byteAt(5));
+ try std.testing.expectEqual(@as(u8, 'D'), falling.byteAt(2));
+ try std.testing.expectEqual(@as(u8, 'A'), falling.byteAt(4));
low.text[0] = ' ';
high.text[0] = '~';
const long_rising = AsciiDiff.between(&low, &high).?;
try std.testing.expectEqual(@as(u16, 13), long_rising.frameCount());
try std.testing.expectEqual(@as(u8, ' '), long_rising.byteAt(0));
- try std.testing.expectEqual(@as(u8, '/'), long_rising.byteAt(1));
- try std.testing.expectEqual(@as(u8, '}'), long_rising.byteAt(11));
+ try std.testing.expectEqual(@as(u8, '#'), long_rising.byteAt(2));
+ try std.testing.expectEqual(@as(u8, 'O'), long_rising.byteAt(6));
+ try std.testing.expectEqual(@as(u8, '{'), long_rising.byteAt(10));
try std.testing.expectEqual(@as(u8, '~'), long_rising.byteAt(12));
const long_falling = AsciiDiff.between(&high, &low).?;
- try std.testing.expectEqual(@as(u8, 'o'), long_falling.byteAt(1));
- try std.testing.expectEqual(@as(u8, '!'), long_falling.byteAt(11));
+ try std.testing.expectEqual(@as(u8, 'O'), long_falling.byteAt(6));
try std.testing.expectEqual(@as(u8, ' '), long_falling.byteAt(12));
+
+ // The shape itself is the contract: never backwards, and the widest step
+ // is in the middle of the walk rather than at either end.
+ var previous = long_rising.byteAt(0);
+ var first_step: u8 = 0;
+ var middle_step: u8 = 0;
+ var frame: u16 = 1;
+ while (frame <= long_rising.movementFrames()) : (frame += 1) {
+ const byte = long_rising.byteAt(frame);
+ try std.testing.expect(byte >= previous);
+ const step = byte - previous;
+ if (frame == 1) first_step = step;
+ if (frame == 6) middle_step = step;
+ previous = byte;
+ }
+ try std.testing.expect(middle_step > first_step);
+ try std.testing.expect(middle_step > long_rising.byteAt(12) - long_rising.byteAt(11));
}
test "ASCII diff classification skips stable and non-ASCII glyphs" {
@@ -4320,6 +4349,47 @@ test "core composes ASCII bytes while visual-only cells pass through" {
try std.testing.expectEqualStrings("D", surface.at(0, 0).grapheme());
}
+test "core composes character motion out of the new grid, not a fade" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 4, .tty_only = true });
+ defer p.deinit();
+ var current: [8]Cell = @splat(.{ .default = false });
+ var previous = current;
+ for (&current, "abcdefgh") |*cell, byte| cell.text[0] = byte;
+ for (&previous, "........") |*cell, byte| cell.text[0] = byte;
+ // Motion effects carry every glyph in the pane, so a cell's semantic
+ // classification is irrelevant to them; only the frozen grid matters.
+ const diffs: [8]PanelCellDiff = @splat(.visual);
+ var surface: Surface = .{
+ .cols = 8,
+ .rows = 1,
+ .cells = &current,
+ .previous_cells = &previous,
+ .cell_diffs = &diffs,
+ };
+ surface.panel_tracks[0] = .{
+ .effect = .edges,
+ .frame = 4,
+ .to = .{ .w = 8, .h = 1 },
+ };
+ surface.npanel_tracks = 1;
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ // Row zero is sliding in from the left screen edge: it holds real glyphs
+ // from two columns further right, and the cells its text has not reached
+ // yet keep the frozen old grid rather than a blend or a blank.
+ const presented = try p.composeAsciiTransitions(arena.allocator(), &surface);
+ try std.testing.expect(presented != &surface);
+ var seen: [8]u8 = undefined;
+ for (&seen, 0..) |*byte, col| byte.* = presented.at(@intCast(col), 0).text[0];
+ try std.testing.expectEqualStrings("cdefgh..", &seen);
+ try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme());
+
+ // The last active sample is the untouched canonical grid, with no copy.
+ surface.panel_tracks[0].frame = panel_animation.Transition.edges.frames() - 1;
+ try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface);
+}
+
test "pointer rejects panel content cells which have not materialized" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true });
defer p.deinit();
@@ -10632,6 +10702,23 @@ pub const Pardes = struct {
};
return if (visible) .{ .col = col, .row = row } else null;
},
+ // Every glyph in a motion effect's pane is in flight,
+ // changed or not, so a cell becomes a truthful input target
+ // only once its own glyph has settled on the canonical one.
+ .edges, .fall, .wave, .curtain, .scramble, .typewriter => {
+ if (!boxContainsCell(track.to, col, row)) continue;
+ const area = panel_animation.CellArea.of(track.to);
+ const settled = std.meta.eql(
+ panel_animation.charSource(
+ track,
+ col -| area.x0,
+ row -| area.y0,
+ area,
+ ),
+ panel_animation.CharSource.settled,
+ );
+ return if (settled) .{ .col = col, .row = row } else null;
+ },
.off => {},
}
}
@@ -13397,52 +13484,110 @@ pub const Pardes = struct {
}
// EFFECT_CODE_ASCII_COMPOSITOR_BEGIN
- /// Lazily copy the canonical grid only when an active PanelAscii track has
- /// a character which has not reached its target yet. The returned Surface
- /// is the sole backend boundary, so every shell rasterizes the exact same
- /// intermediate bytes and style-only/non-ASCII changes pass through once.
+ /// Lazily copy the canonical grid only when an active core-composed
+ /// character track still shows something other than its final glyph. The
+ /// returned Surface is the sole backend boundary, so every shell rasterizes
+ /// the exact same intermediate characters and style-only/non-ASCII changes
+ /// pass through once.
fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface {
_ = p;
if (!canonical.hasPanelDiff()) return canonical;
var presented: ?*Surface = null;
for (canonical.panelTracks()) |track| {
- if (track.effect != .ascii or track.phase == .closing) continue;
- var row: u16 = 0;
- while (row < canonical.rows) : (row += 1) {
- var col: u16 = 0;
- while (col < canonical.cols) : (col += 1) {
- if (!boxContainsCell(track.to, col, row)) continue;
+ if (!track.effect.composedByCore() or track.phase == .closing) continue;
+ const area = panel_animation.CellArea.of(track.to);
+ const col_end = @min(canonical.cols, area.x0 +| area.cols);
+ const row_end = @min(canonical.rows, area.y0 +| area.rows);
+ var row: u16 = area.y0;
+ while (row < row_end) : (row += 1) {
+ var col: u16 = area.x0;
+ while (col < col_end) : (col += 1) {
const index = @as(usize, row) * canonical.cols + col;
- const diff = switch (canonical.cell_diffs[index]) {
- .ascii => |diff| diff,
- .unchanged, .visual => continue,
- };
- const byte = diff.byteAt(track.frame);
- if (byte == diff.to) continue;
+ const composed = composedCell(canonical, track, area, col, row, index) orelse
+ continue;
+ if (composed.visuallyEqual(&canonical.cells[index])) continue;
if (presented == null) {
const next = try arena.create(Surface);
next.* = canonical.*;
next.cells = try arena.dupe(Cell, canonical.cells);
presented = next;
}
- const cell = &presented.?.cells[index];
- if (track.frame == 0) {
- cell.* = canonical.previous_cells[index];
- } else {
- // The semantic destination owns presentation style as
- // soon as its byte begins walking. At the endpoint the
- // untouched canonical cell wins bit-for-bit instead.
- cell.* = canonical.cells[index];
- cell.text[0] = byte;
- cell.len = 1;
- cell.default = false;
- }
+ presented.?.cells[index] = composed;
}
}
}
return presented orelse canonical;
}
+ /// The character one cell presents this frame, or null when the canonical
+ /// cell is already the honest answer. PanelAscii walks the semantic byte
+ /// distance of a *changed* cell; the motion effects carry every glyph in
+ /// the pane, because text flying in from a screen edge has to bring its
+ /// unchanged glyphs along with it.
+ fn composedCell(
+ canonical: *const Surface,
+ track: panel_animation.Track,
+ area: panel_animation.CellArea,
+ col: u16,
+ row: u16,
+ index: usize,
+ ) ?Cell {
+ if (track.effect == .ascii) {
+ const diff = switch (canonical.cell_diffs[index]) {
+ .ascii => |diff| diff,
+ .unchanged, .visual => return null,
+ };
+ const byte = diff.byteAt(track.frame);
+ if (byte == diff.to) return null;
+ // Frame zero is the exact old cell. Once a byte is walking, the
+ // semantic destination owns presentation style, and at the endpoint
+ // the untouched canonical cell wins bit-for-bit instead.
+ if (track.frame == 0) return canonical.previous_cells[index];
+ return withByte(canonical.cells[index], byte);
+ }
+ return switch (panel_animation.charSource(track, col - area.x0, row - area.y0, area)) {
+ .old => canonical.previous_cells[index],
+ .byte => |byte| withByte(canonical.cells[index], byte),
+ // Churn belongs on the glyph, not on the pane's empty space, and it
+ // borrows whichever side of the diff actually has one.
+ .churn => |byte| blk: {
+ const new_cell = &canonical.cells[index];
+ if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte);
+ const old_cell = &canonical.previous_cells[index];
+ if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte);
+ break :blk old_cell.*;
+ },
+ .at => |offset| blk: {
+ const source_col = @as(i32, col) + offset.cols;
+ const source_row = @as(i32, row) + offset.rows;
+ // A glyph whose source lies outside the pane has not arrived
+ // yet: the frozen old cell is the only truthful thing under it.
+ if (source_col < area.x0 or source_row < area.y0 or
+ source_col >= @as(i32, area.x0) + area.cols or
+ source_row >= @as(i32, area.y0) + area.rows or
+ source_col >= canonical.cols or source_row >= canonical.rows)
+ break :blk canonical.previous_cells[index];
+ const source = @as(usize, @intCast(source_row)) * canonical.cols +
+ @as(usize, @intCast(source_col));
+ break :blk canonical.cells[source];
+ },
+ };
+ }
+
+ fn withByte(cell: Cell, byte: u8) Cell {
+ var out = cell;
+ out.text[0] = byte;
+ out.len = 1;
+ out.default = false;
+ return out;
+ }
+
+ /// Whether a cell shows a glyph a character effect can work on. An unpainted
+ /// cell and a space are both blank space, whatever style they carry.
+ fn hasGlyph(cell: *const Cell) bool {
+ return !cell.default and !(cell.len == 1 and cell.text[0] == ' ');
+ }
+
fn preparePanelDiff(p: *Pardes) !bool {
const count = p.surface.cells.len;
if (!p.presented_cells_valid or