summaryrefslogtreecommitdiff
path: root/src/layout.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/layout.zig')
-rw-r--r--src/layout.zig1445
1 files changed, 5 insertions, 1440 deletions
diff --git a/src/layout.zig b/src/layout.zig
index 855a3eb9..f0e6fd16 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -4,666 +4,16 @@ const tagline = @import("tagline.zig");
const dump = @import("dump.zig");
const config = @import("config.zig");
const panes = @import("panes.zig");
+const animation = @import("animation.zig");
+const Box = animation.Box;
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
pub const Rect = struct { x: u16 = 0, y: u16 = 0, w: u16 = 0, h: u16 = 0 };
-pub const Snapshot = struct { serial: u32, box: Box };
const MAX_COLS = pardes.MAX_COLS;
const MAX_PANES = pardes.MAX_PANES;
const BOX_H = pardes.BOX_H;
-pub const Presentation = struct {
- // Only acknowledged draws advance shown geometry; committed and submitted may be ahead.
- committed: [MAX_PANES]?Snapshot = @splat(null),
- initialized: bool = false,
- enabled: bool = false,
- snap_once: bool = false,
- tracks: [MAX_PANES]?Track = @splat(null),
- closing: [MAX_PANES]Track = undefined,
- closing_len: usize = 0,
- submitted: [MAX_PANES]?Snapshot = @splat(null),
- submitted_ready: bool = false,
- shown: [MAX_PANES]?Snapshot = @splat(null),
- shown_tracks: [MAX_PANES]?Track = @splat(null),
- shown_closing: [MAX_PANES]Track = undefined,
- shown_closing_len: usize = 0,
- acknowledged: bool = false,
- pending: bool = false,
- previous_cells: []pardes.Cell = &.{},
- previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}),
- previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}),
- previous_cols: u16 = 0,
- previous_rows: u16 = 0,
- previous_valid: bool = false,
- previous_layout: [MAX_PANES]?Snapshot = @splat(null),
- diffs: []pardes.PanelCellDiff = &.{},
- diff_state: enum { none, requested, ready } = .none,
-
- pub const CellPosition = struct { col: u16, row: u16 };
-
- pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void {
- if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
- for (&self.previous_body_layers) |*layer| gpa.free(layer.cells);
- for (&self.previous_tag_layers) |*layer| gpa.free(layer.cells);
- if (self.diffs.len > 0) gpa.free(self.diffs);
- }
-
- pub fn sync(self: *Presentation, p: *Pardes) void {
- const initializing = !self.initialized or !self.enabled;
- if (initializing or self.snap_once) {
- const had_layout = self.initialized;
- self.committed = @splat(null);
- self.tracks = @splat(null);
- self.closing_len = 0;
- self.diff_state = .none;
- if (initializing) {
- self.shown_tracks = @splat(null);
- self.shown_closing_len = 0;
- }
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
- }
- self.initialized = true;
- self.snap_once = false;
- if (had_layout and self.acknowledged)
- self.pending = true;
- return;
- }
-
- const effect = p.settings.panel_transition;
- if (effect.needsPreviousGrid() and self.diff_state != .none) {
- var second_change = false;
- for (p.panes, self.committed, 0..) |slot, snapshot, id| {
- const pane = slot orelse {
- second_change = second_change or snapshot != null;
- continue;
- };
- const target = panelBox(p.rects[id]);
- second_change = second_change or snapshot == null or
- snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target);
- }
- if (second_change) {
- p.abandonPanelAnimations();
- self.committed = @splat(null);
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
- }
- return;
- }
- }
- var changed = false;
- var animated_change = false;
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse {
- if (self.committed[id]) |old| {
- changed = true;
- if (effect.lifecycleOnly()) {
- if (self.appendClosing(.{
- .serial = old.serial,
- .pane = @intCast(id),
- .phase = .closing,
- .effect = effect,
- .from = old.box,
- .to = closingBox(effect, old.box),
- })) animated_change = true;
- }
- }
- self.committed[id] = null;
- self.tracks[id] = null;
- // Never let pixels from a dead pane address a reused slot.
- self.shown_tracks[id] = null;
- continue;
- };
- const target = panelBox(p.rects[id]);
- const previous = self.committed[id];
- self.committed[id] = .{ .serial = pane.serial, .box = target };
-
- if (effect == .off) {
- changed = changed or previous == null or previous.?.serial != pane.serial or
- !previous.?.box.eql(target);
- self.tracks[id] = null;
- continue;
- }
- if (previous) |old| {
- if (old.serial == pane.serial and old.box.eql(target)) continue;
- const same_lifetime = old.serial == pane.serial;
- const prior = if (self.tracks[id]) |track|
- if (track.serial == pane.serial and track.active()) track else null
- else
- null;
- if (effect.lifecycleOnly() and same_lifetime) {
- if (prior) |active| if (active.phase == .opening) {
- var next = active;
- next.from = openingBox(effect, target, p.screen_w);
- next.to = target;
- self.tracks[id] = next;
- changed = true;
- animated_change = true;
- continue;
- };
- self.tracks[id] = null;
- changed = true;
- continue;
- }
- if (effect.lifecycleOnly() and !same_lifetime) {
- _ = self.appendClosing(.{
- .serial = old.serial,
- .pane = @intCast(id),
- .phase = .closing,
- .effect = effect,
- .from = old.box,
- .to = closingBox(effect, old.box),
- });
- }
- const shown = if (self.shown[id]) |snapshot|
- if (snapshot.serial == pane.serial) snapshot.box else null
- else
- null;
- const from = if (!same_lifetime)
- openingBox(effect, target, p.screen_w)
- else if (shown) |box|
- box
- else if (self.acknowledged and prior != null)
- prior.?.from
- else
- old.box;
- const next: Track = .{
- .serial = pane.serial,
- .pane = @intCast(id),
- .phase = if (!same_lifetime or
- (prior != null and prior.?.phase == .opening)) .opening else .moving,
- .effect = effect,
- .from = from,
- .to = target,
- };
- self.tracks[id] = next;
- changed = true;
- animated_change = true;
- } else {
- const next: Track = .{
- .serial = pane.serial,
- .pane = @intCast(id),
- .phase = .opening,
- .effect = effect,
- .from = openingBox(effect, target, p.screen_w),
- .to = target,
- };
- self.tracks[id] = next;
- changed = true;
- animated_change = true;
- }
- }
- if (animated_change and effect.needsPreviousGrid()) {
- self.diff_state = .requested;
- }
- if (changed and self.acknowledged) self.pending = true;
- }
-
- fn appendClosing(self: *Presentation, track: Track) bool {
- std.debug.assert(track.phase == .closing);
- const baseline = self.previous_layout[track.pane] orelse return false;
- if (!self.previous_valid or baseline.serial != track.serial or
- !baseline.box.eql(track.from)) return false;
- if (self.closing_len == self.closing.len) {
- std.mem.copyForwards(
- Track,
- self.closing[0 .. self.closing.len - 1],
- self.closing[1..],
- );
- self.closing_len -= 1;
- }
- self.closing[self.closing_len] = track;
- self.closing_len += 1;
- return true;
- }
-
- pub fn advance(self: *Presentation) void {
- for (&self.tracks) |*slot| {
- const track = if (slot.*) |*track| track else continue;
- track.frame +|= 1;
- if (!track.active()) slot.* = null;
- }
- var out: usize = 0;
- for (self.closing[0..self.closing_len]) |value| {
- var track = value;
- track.frame +|= 1;
- if (!track.active()) continue;
- self.closing[out] = track;
- out += 1;
- }
- self.closing_len = out;
- }
-
- pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void {
- var presented: [MAX_PANES]?Track = @splat(null);
- var presented_closing: [MAX_PANES]Track = undefined;
- var nclosing: usize = 0;
- var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready)
- self.submitted
- else
- @splat(null);
- if (!self.submitted_ready) for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
- };
- for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| {
- const pane = p.panes[id] orelse {
- snapshot.* = null;
- continue;
- };
- if (pane.serial != saved.serial) snapshot.* = null;
- };
- for (tracks) |track| {
- if (track.phase == .closing) {
- const current = for (self.closing[0..self.closing_len]) |candidate| {
- if (candidate.serial == track.serial and candidate.pane == track.pane and
- candidate.effect == track.effect) break true;
- } else false;
- if (!current or !track.active() or nclosing == presented_closing.len) continue;
- presented_closing[nclosing] = track;
- nclosing += 1;
- continue;
- }
- const id: usize = track.pane;
- if (id >= p.panes.len or !track.active()) continue;
- const pane = p.panes[id] orelse continue;
- if (pane.serial != track.serial) continue;
- presented[id] = track;
- presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() };
- }
- self.shown_tracks = presented;
- self.shown_closing = presented_closing;
- self.shown_closing_len = nclosing;
- self.shown = presented_layout;
- self.acknowledged = true;
- self.pending = false;
- if (tracks.len == 0) {
- self.tracks = @splat(null);
- self.closing_len = 0;
- self.diff_state = .none;
- self.capturePrevious(p.gpa, &p.surface);
- }
- }
-
- fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
- self.diff_state = .none;
- const cells = surface.cells;
- if (cells.len == 0) {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- }
- if (self.previous_cells.len != cells.len) {
- const next = gpa.alloc(pardes.Cell, cells.len) catch {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- };
- if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
- self.previous_cells = next;
- }
- for (&self.previous_body_layers, surface.bodyLayers()) |*old, *current| {
- const count = if (current.rows > 0) current.cells.len else 0;
- const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- };
- if (count > 0) @memcpy(storage, current.cells);
- old.* = current.*;
- old.cells = storage;
- }
- for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| {
- const count = if (current.cols > 0) current.cells.len else 0;
- const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- };
- if (count > 0) @memcpy(storage, current.cells);
- old.* = current.*;
- old.cells = storage;
- }
- @memcpy(self.previous_cells, cells);
- self.previous_cols = surface.cols;
- self.previous_rows = surface.rows;
- self.previous_valid = true;
- self.previous_layout = if (self.submitted_ready)
- self.submitted
- else
- @splat(null);
- }
-
- pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff {
- if (self.diff_state != .ready or col >= cols or row >= rows or
- self.diffs.len != @as(usize, cols) * rows) return .unchanged;
- return self.diffs[@as(usize, row) * cols + col];
- }
-
- pub const Point = struct { x: f32, y: f32 };
-
- /// Coordinates stay fractional until the host resolves its body font grid.
- pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32, y: f32) ?Point {
- if (self.pending or !std.math.isFinite(x) or !std.math.isFinite(y) or
- x < 0 or y < 0 or x >= @as(f32, @floatFromInt(cols)) or y >= @as(f32, @floatFromInt(rows))) return null;
- var closing = self.shown_closing_len;
- while (closing > 0) {
- closing -= 1;
- const track = self.shown_closing[closing];
- if (track.active() and boxContainsPoint(track.contentBox(), x, y) and
- boxContainsPoint(track.presented(), x, y)) return null;
- }
- for ([_]Phase{ .opening, .moving }) |phase| {
- var index = self.shown_tracks.len;
- while (index > 0) {
- index -= 1;
- const track = self.shown_tracks[index] orelse continue;
- if (!track.active() or track.phase != phase) continue;
- switch (track.effect) {
- .slide, .zoom, .vertical => {
- const target = track.contentBox();
- if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue;
- const shown = track.presented();
- if (!boxContainsPoint(shown, x, y)) {
- if (track.effect == .vertical) return null;
- continue;
- }
- return .{
- .x = target.x + (x - shown.x) / shown.w * target.w,
- .y = target.y + (y - shown.y) / shown.h * target.h,
- };
- },
- else => {
- if (!boxContainsPoint(track.to, x, y)) continue;
- _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
- return .{ .x = x, .y = y };
- },
- }
- }
- }
- _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
- return .{ .x = x, .y = y };
- }
-
- fn boxContainsPoint(box: Box, x: f32, y: f32) bool {
- return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h;
- }
-
- pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition {
- if (self.pending) return null;
- var closing = self.shown_closing_len;
- while (closing > 0) {
- closing -= 1;
- const track = self.shown_closing[closing];
- if (!track.active()) continue;
- if (boxContainsCell(track.contentBox(), col, row) and
- boxContainsCell(track.presented(), col, row)) return null;
- }
- const phases = [_]Phase{ .opening, .moving };
- for (phases) |phase| {
- // Backends paint pane slots forward within a phase. Probe them in
- // reverse so overlapping transition quads address the top pixel.
- var index = self.shown_tracks.len;
- while (index > 0) {
- index -= 1;
- const track = self.shown_tracks[index] orelse continue;
- if (!track.active() or track.phase != phase) continue;
- switch (track.effect) {
- .slide, .zoom => {
- const shown = track.presented();
- if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue;
- const x = @as(f32, @floatFromInt(col)) + 0.5;
- const y = @as(f32, @floatFromInt(row)) + 0.5;
- const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
- const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
- const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w));
- const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h));
- return .{
- .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))),
- .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))),
- };
- },
- .vertical => {
- const clip = track.contentBox();
- if (!boxContainsCell(clip, col, row)) continue;
- const shown = track.presented();
- if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row))
- return null;
- const x = @as(f32, @floatFromInt(col)) + 0.5;
- const y = @as(f32, @floatFromInt(row)) + 0.5;
- const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
- const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
- return .{
- .col = @intFromFloat(@floor(clip.x + u * clip.w)),
- .row = @intFromFloat(@floor(clip.y + v * clip.h)),
- };
- },
- .dissolve, .ascii => {
- if (!boxContainsCell(track.to, col, row)) continue;
- const cell_diff = self.cellDiff(cols, rows, col, row);
- if (!cell_diff.changed())
- return .{ .col = col, .row = row };
- const relative_col: u16 = @intFromFloat(@floor(
- @as(f32, @floatFromInt(col)) + 0.5 - track.to.x,
- ));
- const relative_row: u16 = @intFromFloat(@floor(
- @as(f32, @floatFromInt(row)) + 0.5 - track.to.y,
- ));
- const visible = switch (track.effect) {
- .dissolve => dissolveRevealed(
- track.serial,
- relative_col,
- relative_row,
- track.amount(),
- ),
- .ascii => switch (cell_diff) {
- .ascii => |diff| diff.complete(track.frame),
- .unchanged, .visual => true,
- },
- else => unreachable,
- };
- return if (visible) .{ .col = col, .row = row } else null;
- },
- .edges, .fall, .wave, .curtain, .scramble, .typewriter => {
- if (!boxContainsCell(track.to, col, row)) continue;
- const area = CellArea.of(track.to);
- const settled = std.meta.eql(
- charSource(
- track,
- col -| area.x0,
- row -| area.y0,
- area,
- ),
- CharSource.settled,
- );
- return if (settled) .{ .col = col, .row = row } else null;
- },
- .off => {},
- }
- }
- }
- for (self.shown_tracks) |maybe| {
- const track = maybe orelse continue;
- if (!track.active() or (track.effect != .slide and track.effect != .zoom and
- track.effect != .vertical)) continue;
- if (boxContainsCell(track.to, col, row)) return null;
- }
- return .{ .col = col, .row = row };
- }
-
- fn boxContainsCell(box: Box, col: u16, row: u16) bool {
- const x = @as(f32, @floatFromInt(col)) + 0.5;
- const y = @as(f32, @floatFromInt(row)) + 0.5;
- return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
- }
-
- pub fn cancel(self: *Presentation) void {
- self.tracks = @splat(null);
- self.closing_len = 0;
- self.shown_tracks = @splat(null);
- self.shown_closing_len = 0;
- self.diff_state = .none;
- self.pending = self.acknowledged;
- }
-
- fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool {
- const count = surface.cells.len;
- if (!self.previous_valid or
- self.previous_cols != surface.cols or
- self.previous_rows != surface.rows or
- self.previous_cells.len != count) return false;
- if (self.diffs.len != count) {
- const next = try gpa.alloc(pardes.PanelCellDiff, count);
- if (self.diffs.len > 0) gpa.free(self.diffs);
- self.diffs = next;
- }
- for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new|
- diff.* = pardes.PanelCellDiff.between(old, new);
- for (&self.tracks) |*slot| {
- const track = if (slot.*) |*track| track else continue;
- if (track.effect != .ascii) continue;
- var longest: u16 = 1;
- var row: u16 = 0;
- while (row < surface.rows) : (row += 1) {
- var col: u16 = 0;
- while (col < surface.cols) : (col += 1) {
- if (!boxContainsCell(track.to, col, row)) continue;
- const index = @as(usize, row) * surface.cols + col;
- switch (self.diffs[index]) {
- .ascii => |diff| longest = @max(longest, diff.frameCount()),
- .unchanged, .visual => {},
- }
- }
- }
- track.frame_count = @max(track.frame_count, longest);
- }
- self.diff_state = .ready;
- return true;
- }
-
- pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void {
- self.submitted = @splat(null);
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- self.submitted[id] = .{
- .serial = pane.serial,
- .box = panelBox(p.rects[id]),
- };
- }
- self.submitted_ready = true;
- if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) {
- for (&self.tracks) |*slot| {
- const track = slot.* orelse continue;
- if (track.effect.needsPreviousGrid()) slot.* = null;
- }
- self.closing_len = 0;
- self.diff_state = .none;
- }
- if (self.diff_state == .ready) {
- s.previous_cells = self.previous_cells;
- s.previous_body_layers = &self.previous_body_layers;
- s.previous_tag_layers = &self.previous_tag_layers;
- s.cell_diffs = self.diffs;
- }
- s.npanel_tracks = paintOrder(
- &self.tracks,
- self.closing[0..self.closing_len],
- &s.panel_tracks,
- );
- }
-
- pub fn animating(self: *const Presentation) bool {
- for (self.tracks) |track| if (track != null and track.?.active()) return true;
- for (self.closing[0..self.closing_len]) |track|
- if (track.active()) return true;
- return false;
- }
-
- test "presentation cache allocation failures retain ownership and allow retry" {
- var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
- const gpa = allocator.allocator();
- var state: Presentation = .{};
- defer state.deinit(gpa);
- var cells: [3]pardes.Cell = @splat(.{});
- cells[0].default = false;
- cells[0].text[0] = 'a';
- var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] };
- state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } };
- state.submitted_ready = true;
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(state.previous_valid);
- try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial);
- const previous = state.previous_cells.ptr;
- const allocated = allocator.allocations;
- allocator.fail_index = allocator.alloc_index;
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(state.previous_valid);
- try std.testing.expectEqual(allocated, allocator.allocations);
- try std.testing.expect(!allocator.has_induced_failure);
-
- surface.cols = 3;
- surface.cells = &cells;
- state.submitted[0].?.box.w = 3;
- state.diff_state = .ready;
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(allocator.has_induced_failure);
- try std.testing.expect(!state.previous_valid);
- try std.testing.expectEqual(previous, state.previous_cells.ptr);
- try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len);
- try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]);
- try std.testing.expect(state.previous_layout[0] == null);
- try std.testing.expectEqual(.none, state.diff_state);
-
- allocator.fail_index = std.math.maxInt(usize);
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(state.previous_valid);
- try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len);
- try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w);
- state.diff_state = .requested;
- allocator.fail_index = allocator.alloc_index;
- try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface));
- try std.testing.expectEqual(.requested, state.diff_state);
- try std.testing.expectEqual(@as(usize, 0), state.diffs.len);
- allocator.fail_index = std.math.maxInt(usize);
- try std.testing.expect(try state.prepareDiff(gpa, &surface));
- try std.testing.expectEqual(.ready, state.diff_state);
- try std.testing.expectEqual(@as(usize, 3), state.diffs.len);
- for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff);
- }
-
- test "presentation submission failure preserves shown geometry until retry is acknowledged" {
- var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
- const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
- p.acknowledgePanelPresentation(&.{});
- const shown = p.presentation.shown;
- const previous = p.presentation.previous_cells.ptr;
- p.presentation.diff_state = .requested;
- p.presentation.pending = true;
- allocator.fail_index = allocator.alloc_index;
- try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface));
- try std.testing.expectEqualDeep(shown, p.presentation.shown);
- try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr);
- try std.testing.expectEqual(.requested, p.presentation.diff_state);
- try std.testing.expect(p.presentation.pending);
- try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null);
-
- allocator.fail_index = std.math.maxInt(usize);
- try p.presentation.submit(p, &p.surface);
- try std.testing.expectEqualDeep(shown, p.presentation.shown);
- try std.testing.expect(p.presentation.pending);
- p.acknowledgePanelPresentation(&.{});
- try std.testing.expect(!p.presentation.pending);
- try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?);
- }
-};
-
pub const column_weight_unit: u64 = 1 << 32;
pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS;
@@ -947,7 +297,7 @@ pub fn removePane(p: *Pardes, id: usize) void {
p.col_serial[j] = p.col_serial[j + 1];
}
p.ncol -= 1;
- p.col_tags[p.ncol] = .{ .what = .column };
+ p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[p.ncol] = 0;
}
}
@@ -971,7 +321,7 @@ pub fn joinCol(p: *Pardes) void {
p.col_serial[j] = p.col_serial[j + 1];
}
p.ncol -= 1;
- p.col_tags[p.ncol] = .{ .what = .column };
+ p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[p.ncol] = 0;
}
@@ -1021,7 +371,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
p.col_serial[j] = p.col_serial[j - 1];
}
p.col_weight[c] = new_weight;
- p.col_tags[c] = .{ .what = .column };
+ p.col_tags[c] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[c] = 0;
p.col_panes[c][0] = id;
p.col_n[c] = 1;
@@ -1254,8 +604,6 @@ pub fn compute(p: *Pardes) void {
}
}
-pub const ascii_max_movement_frames: u16 = 12;
-
test "collapse transfers height upward and restores the original layout" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 });
defer p.deinit();
@@ -1461,789 +809,6 @@ test "collapse dump restores folded state and retained expansion weight" {
try std.testing.expectEqual(restored.screen_h - restored.rects[0].y, restored.rects[0].h);
}
-pub const Easing = enum(u8) {
- linear,
- smooth,
- /// Quintic ease-in-out. It creeps at both ends and crosses the middle of
- /// the distance fast, inside the same frame count a linear walk would use.
- smoother,
- in_cubic,
- out_cubic,
- out_back,
-};
-
-pub const Transition = enum(u8) {
- // Numeric values are shared with the GUI shader ABI.
- off = 0,
- slide = 1,
- zoom = 2,
- dissolve = 3,
- ascii = 4,
- vertical = 5,
- edges = 6,
- fall = 7,
- wave = 8,
- curtain = 9,
- scramble = 10,
- typewriter = 11,
-
- pub fn easing(effect: Transition) Easing {
- return switch (effect) {
- .off, .dissolve, .wave => .smooth,
- .slide, .vertical, .edges => .out_cubic,
- .zoom => .out_back,
- // Character walks and per-cell locks read best with a slow start,
- // a fast middle, and a slow settle over their fixed frame count.
- .ascii, .fall, .scramble => .smoother,
- // A sweep and a typewriter are constant-rate by definition: easing
- // their head would make the pass visibly hesitate mid-pane.
- .curtain, .typewriter => .linear,
- };
- }
-
- pub fn frames(effect: Transition) u16 {
- return switch (effect) {
- .off => 0,
- .slide => 12,
- .zoom => 14,
- .dissolve => 10,
- .ascii => ascii_max_movement_frames + 1,
- .vertical => 12,
- .edges, .curtain, .scramble => 12,
- // Travelling motion needs a couple more samples than a lock or a
- // rigid slide before it stops reading as a jump.
- .fall, .wave, .typewriter => 14,
- };
- }
-
- pub fn composedByCore(effect: Transition) bool {
- return switch (effect) {
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true,
- .off, .slide, .zoom, .dissolve, .vertical => false,
- };
- }
-
- pub fn needsPreviousGrid(effect: Transition) bool {
- return effect == .dissolve or effect == .vertical or effect.composedByCore();
- }
-
- pub fn lifecycleOnly(effect: Transition) bool {
- return effect == .vertical;
- }
-};
-
-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;
- }
-
- // A cell belongs to a fractional box when its center lies inside.
- pub fn contains(box: Box, col: u16, row: u16) bool {
- const x: f32 = @floatFromInt(col);
- const y: f32 = @floatFromInt(row);
- return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
- }
-};
-
-// Drawing and hit testing share this moving/opening/closing order.
-pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize {
- var len: usize = 0;
- for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| {
- const track = maybe orelse continue;
- if (!track.active() or track.phase != phase) continue;
- if (len == out.len) return len;
- out[len] = track;
- len += 1;
- };
- for (closing) |track| {
- if (!track.active()) continue;
- if (len == out.len) return len;
- out[len] = track;
- len += 1;
- }
- return len;
-}
-
-/// 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_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());
- }
-
- pub fn visualBox(track: Track) Box {
- return switch (track.effect) {
- .slide, .zoom, .vertical => track.presented(),
- .off, .dissolve => track.to,
- // Every character effect stays inside the pane's final rectangle.
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to,
- };
- }
-
- pub fn contentBox(track: Track) Box {
- return if (track.phase == .closing) track.from else track.to;
- }
-};
-
-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,
- },
- .vertical => blk: {
- var from = target;
- from.y += target.h;
- break :blk from;
- },
- .off, .dissolve => target,
- // Character effects own the glyphs inside a fixed rectangle, so their
- // panel opens at exactly its final geometry.
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target,
- };
-}
-
-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),
- .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0),
- .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;
- return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1)));
-}
-
-pub fn lerpBox(from: Box, to: Box, t: f32) Box {
- 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 CellArea = struct {
- x0: u16 = 0,
- y0: u16 = 0,
- cols: u16 = 1,
- rows: u16 = 1,
-
- pub fn of(box: Box) CellArea {
- return .{
- .x0 = floorCell(box.x),
- .y0 = floorCell(box.y),
- .cols = ceilCell(box.w),
- .rows = ceilCell(box.h),
- };
- }
-};
-
-fn floorCell(value: f32) u16 {
- return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16))));
-}
-
-fn ceilCell(value: f32) u16 {
- return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16))));
-}
-
-pub const CharSource = union(enum) {
- /// Nothing has arrived here yet: keep the frozen old cell.
- old,
- at: Offset,
- /// Paint this printable byte in the destination cell's own style, whatever
- /// that cell holds — a caret marching over empty space is still a caret.
- byte: u8,
- churn: u8,
-
- pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 };
-
- pub const settled: CharSource = .{ .at = .{} };
-};
-
-pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource {
- const t = track.amount();
- if (t >= 1.0) return .settled;
- const w: f32 = @floatFromInt(area.cols);
- const h: f32 = @floatFromInt(area.rows);
- const c: f32 = @floatFromInt(col);
- const r: f32 = @floatFromInt(row);
- const remaining = 1.0 - t;
- return switch (track.effect) {
- .edges => blk: {
- const travel = cellsOf(remaining * (w + 1.0));
- break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } };
- },
- // Columns rain down, each with its own stable head start, so the pane
- // fills from the top and the last glyphs land at the bottom.
- .fall => blk: {
- const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4);
- if (local <= 0.0) break :blk .old;
- break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } };
- },
- // A vertical ripple travels left to right and its amplitude decays, so
- // the pane settles out of a wave instead of a fade.
- .wave => .{ .at = .{
- .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)),
- } },
- // A curtain of glyphs marches in from the right, column by column, left
- // to right; each column still has a short slide of its own.
- .curtain => blk: {
- const lead = t * (w + 1.0) - c;
- if (lead <= 0.0) break :blk .old;
- break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } };
- },
- // Every cell churns through printable ASCII and locks onto its final
- // glyph at its own stable threshold: the pane resolves out of noise.
- .scramble => blk: {
- if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled;
- const churn = cellNoise(
- track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f),
- col,
- row,
- );
- break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) };
- },
- // Reading-order reveal with a caret sitting on the write head.
- .typewriter => blk: {
- const head = t * w * h;
- const index = r * w + c;
- if (index + 1.0 <= head) break :blk .settled;
- if (index <= head) break :blk .{ .byte = '_' };
- break :blk .old;
- },
- // PanelAscii walks its own byte distance per cell, and the geometry
- // effects never reach this path at all.
- .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled,
- };
-}
-
-fn cellsOf(distance: f32) i32 {
- return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0)));
-}
-
-/// Remap track progress into one cell's own window. A stagger delays a glyph
-/// without making the effect as a whole end after its last frame.
-fn staggered(t: f32, delay: f32) f32 {
- if (delay >= 1.0) return t;
- return (t - delay) / (1.0 - delay);
-}
-
-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);
- // Slow at both ends, fast through the middle, and symmetric about the
- // halfway point: the same curve the integer byte walk reproduces.
- try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5));
- try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15));
- try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85));
- try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) >
- sample(.linear, 0.6) - sample(.linear, 0.4));
-}
-
-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 "character effects are core-composed and settle on the canonical glyph" {
- const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 };
- const area: CellArea = .of(box);
- try std.testing.expectEqual(@as(u16, 4), area.x0);
- try std.testing.expectEqual(@as(u16, 2), area.y0);
- try std.testing.expectEqual(@as(u16, 20), area.cols);
- try std.testing.expectEqual(@as(u16, 6), area.rows);
-
- inline for (std.enums.values(Transition)) |effect| {
- if (comptime !effect.composedByCore()) continue;
- // Core composition needs the frozen old grid for every glyph which has
- // not arrived, so no character effect may animate without it.
- try std.testing.expect(effect.needsPreviousGrid());
- if (comptime effect == .ascii) continue; // owns its own per-cell byte walk
-
- const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box };
- const first: Track = .{ .effect = effect, .frame = 0, .to = box };
- var moving = false;
- var row: u16 = 0;
- while (row < area.rows) : (row += 1) {
- var col: u16 = 0;
- while (col < area.cols) : (col += 1) {
- // The last active sample is the exact canonical grid: no cell
- // is displaced, churning, or still frozen.
- try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area));
- if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area)))
- moving = true;
- }
- }
- try std.testing.expect(moving);
- }
-}
-
-test "each character effect moves glyphs along its own axis" {
- const box: Box = .{ .w = 30, .h = 8 };
- const area: CellArea = .of(box);
-
- // Rows alternate which screen edge they come from, and every glyph in a row
- // travels as one rigid slide: one offset, no vertical component.
- var edges: Track = .{ .effect = .edges, .frame = 2, .to = box };
- const even = charSource(edges, 5, 0, area).at;
- const odd = charSource(edges, 5, 1, area).at;
- try std.testing.expect(even.cols > 0);
- try std.testing.expectEqual(-even.cols, odd.cols);
- try std.testing.expectEqual(@as(i32, 0), even.rows);
- try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at);
- edges.frame = 5;
- try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols);
-
- // Falling columns are vertical only, staggered, and sample from below the
- // destination because the new text is still above the pane.
- const fall: Track = .{ .effect = .fall, .frame = 4, .to = box };
- var falling = false;
- var col: u16 = 0;
- while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) {
- .old => {},
- .byte, .churn => return error.FallShouldNotChurn,
- .at => |offset| {
- try std.testing.expectEqual(@as(i32, 0), offset.cols);
- try std.testing.expect(offset.rows >= 0);
- if (offset.rows > 0) falling = true;
- },
- };
- try std.testing.expect(falling);
-
- // The wave displaces rows both ways as it travels, and only rows.
- const wave: Track = .{ .effect = .wave, .frame = 1, .to = box };
- var above = false;
- var below = false;
- col = 0;
- while (col < area.cols) : (col += 1) {
- const offset = charSource(wave, col, 3, area).at;
- try std.testing.expectEqual(@as(i32, 0), offset.cols);
- if (offset.rows < 0) above = true;
- if (offset.rows > 0) below = true;
- }
- try std.testing.expect(above and below);
-
- // The curtain has a head: columns behind it hold the old grid, columns the
- // head has passed are settled, and the head itself is still sliding.
- const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box };
- try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area));
- try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area));
- var sliding = false;
- col = 0;
- while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) {
- .at => |offset| if (offset.cols < 0) {
- sliding = true;
- },
- .old, .byte, .churn => {},
- };
- try std.testing.expect(sliding);
-
- var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box };
- var churning: usize = 0;
- var locked: usize = 0;
- var changed = false;
- col = 0;
- while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) {
- .churn => |byte| {
- try std.testing.expect(byte >= ' ' and byte <= '~');
- churning += 1;
- scramble.frame = 4;
- switch (charSource(scramble, col, 0, area)) {
- .churn => |next| changed = changed or next != byte,
- .old, .at, .byte => {},
- }
- scramble.frame = 3;
- },
- .at => locked += 1,
- .old, .byte => return error.ScrambleShouldNotFreeze,
- };
- try std.testing.expect(churning > 0 and locked > 0 and changed);
-
- // The typewriter writes in reading order with a caret on its head.
- const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box };
- try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area));
- try std.testing.expectEqual(
- CharSource{ .old = {} },
- charSource(typewriter, area.cols - 1, area.rows - 1, area),
- );
- var carets: usize = 0;
- var row: u16 = 0;
- while (row < area.rows) : (row += 1) {
- col = 0;
- while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) {
- .byte => |byte| {
- try std.testing.expectEqual(@as(u8, '_'), byte);
- carets += 1;
- },
- .old, .at, .churn => {},
- };
- }
- try std.testing.expectEqual(@as(usize, 1), carets);
-}
-
-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));
- }
-}
-
-pub const Animation = struct {
- pub const frame_ms: u32 = 16;
- pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms;
- pub const transition_steps: u16 = 10;
-
- pub fn Transition(comptime Value: type) type {
- return struct {
- const Self = @This();
-
- from: Value,
- to: Value,
- displayed: Value,
- step: u16 = transition_steps,
-
- pub fn init(value: Value) Self {
- return .{ .from = value, .to = value, .displayed = value };
- }
-
- pub fn isActive(a: *const Self) bool {
- return a.step < transition_steps;
- }
-
- pub fn retarget(a: *Self, target: Value) void {
- a.from = a.displayed;
- a.to = target;
- a.step = if (std.meta.eql(a.from, target)) transition_steps else 0;
- if (a.step == transition_steps) a.displayed = target;
- }
-
- pub fn advance(a: *Self) void {
- if (!a.isActive()) return;
- a.step += 1;
- a.displayed = if (a.step == transition_steps)
- a.to
- else
- Value.interpolate(a.from, a.to, a.step, transition_steps);
- }
-
- pub fn snap(a: *Self, value: Value) void {
- a.* = init(value);
- }
- };
- }
-
- pub fn Immediate(comptime Value: type) type {
- return struct {
- const Self = @This();
-
- displayed: Value,
-
- pub fn init(value: Value) Self {
- return .{ .displayed = value };
- }
-
- pub fn isActive(_: *const Self) bool {
- return false;
- }
-
- pub fn retarget(a: *Self, target: Value) void {
- a.displayed = target;
- }
-
- pub fn advance(_: *Self) void {}
-
- pub fn snap(a: *Self, value: Value) void {
- a.displayed = value;
- }
- };
- }
-
- pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 {
- if (step == 0) return from;
- if (step >= steps) return to;
- var out: [3]u8 = undefined;
- for (&out, from, to) |*dst, a, b| {
- const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step;
- dst.* = @intCast((numerator + steps / 2) / steps);
- }
- return out;
- }
-
- const TestColor = struct {
- rgb: [3]u8,
-
- pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor {
- return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) };
- }
- };
-
- test "Immediate lands where a completed Transition lands" {
- const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
- const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
-
- var faded = Animation.Transition(TestColor).init(from);
- faded.retarget(to);
- for (0..transition_steps) |_| faded.advance();
-
- var instant = Immediate(TestColor).init(from);
- try std.testing.expect(!instant.isActive());
- instant.retarget(to);
- try std.testing.expectEqual(faded.displayed, instant.displayed);
-
- // Never active, so a frontend that renders only while something is animating stops immediately
- // rather than spending ten frames discovering there is nothing to draw.
- try std.testing.expect(!instant.isActive());
- instant.advance();
- try std.testing.expectEqual(to, instant.displayed);
-
- instant.snap(from);
- try std.testing.expectEqual(from, instant.displayed);
- }
-
- test "fixed-step interpolation has exact monotonic endpoints" {
- const Tween = Animation.Transition(TestColor);
- const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
- const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
- var tween = Tween.init(from);
- tween.retarget(to);
- try std.testing.expectEqual(from, tween.displayed);
-
- var previous = tween.displayed;
- for (0..transition_steps) |_| {
- tween.advance();
- try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]);
- try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]);
- try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]);
- previous = tween.displayed;
- }
- try std.testing.expect(!tween.isActive());
- try std.testing.expectEqual(to, tween.displayed);
- tween.advance();
- try std.testing.expectEqual(to, tween.displayed);
- }
-
- test "retarget starts at the currently displayed value" {
- const Tween = Animation.Transition(TestColor);
- const first: TestColor = .{ .rgb = .{ 0, 40, 200 } };
- const second: TestColor = .{ .rgb = .{ 200, 140, 0 } };
- const third: TestColor = .{ .rgb = .{ 20, 10, 250 } };
- var tween = Tween.init(first);
- tween.retarget(second);
- tween.advance();
- tween.advance();
- tween.advance();
- const on_screen = tween.displayed;
-
- tween.retarget(third);
- try std.testing.expectEqual(on_screen, tween.from);
- try std.testing.expectEqual(on_screen, tween.displayed);
- try std.testing.expect(tween.isActive());
- for (0..transition_steps) |_| tween.advance();
- try std.testing.expectEqual(third, tween.displayed);
- }
-};
-
-test "fractional panel inverse preserves compact header rows through slide and zoom" {
- const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 };
- for ([_]Transition{ .slide, .zoom, .vertical }) |effect| {
- var presentation: Presentation = .{};
- const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } };
- presentation.shown_tracks[0] = track;
- const shown = track.presented();
- for (0..4) |index| {
- const row: u16 = @intCast(index);
- const source_x: f32 = 5.3;
- const source_y = (layer.rowTop(row, 20, 10) + 5) / 20;
- const x = shown.x + (source_x - track.to.x) / track.to.w * shown.w;
- const y = shown.y + (source_y - track.to.y) / track.to.h * shown.h;
- const point = presentation.pointerFractional(80, 30, x, y).?;
- try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001);
- try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001);
- const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?;
- try std.testing.expectEqual(row, body_hit.row);
- }
- }
-}
-
-test "presentation snapshots own compact body and tag rows across later edits" {
- var state: Presentation = .{};
- defer state.deinit(std.testing.allocator);
- var cells: [1]pardes.Cell = .{.{}};
- var body: [2]pardes.Cell = .{ .{}, .{} };
- body[0].text[0] = 'A';
- var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells };
- surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body };
- var tag: [1]pardes.Cell = .{.{}};
- tag[0].text[0] = 'T';
- surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag };
- state.capturePrevious(std.testing.allocator, &surface);
- try std.testing.expect(state.previous_valid);
- tag[0].text[0] = 'U';
- try std.testing.expectEqual(@as(u8, 'T'), state.previous_tag_layers[0].cells[0].text[0]);
- body[0].text[0] = 'B';
- surface.body_layers[0].context_rows = 0;
- try std.testing.expectEqual(@as(u8, 'A'), state.previous_body_layers[0].cells[0].text[0]);
- try std.testing.expectEqual(@as(u16, 1), state.previous_body_layers[0].context_rows);
- state.capturePrevious(std.testing.allocator, &surface);
- try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]);
- try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]);
-}
-
test "column reorder moves only crossed state and preserves weights" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 173, .rows = 20 });
defer p.deinit();