diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 12:57:47 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 30c2c8a154e770b3920bcfd7d553825fe9182bf4 (patch) | |
| tree | ecdce274f1cb25095c72f4da62b7c35e23aee04c /src/layout.zig | |
| parent | 8f31fc6bfaae2d29b2bb5cc2149fcd7033cd9724 (diff) | |
| download | pardes-30c2c8a154e770b3920bcfd7d553825fe9182bf4.tar.gz pardes-30c2c8a154e770b3920bcfd7d553825fe9182bf4.zip | |
Move the frame into draw.zig and the transitions out of layout.zig
Pure moves, no behaviour change. draw.zig holds the whole core frame in
order: render, the pane, tag, header and notice painters it calls, and the
character-effect composition (Pardes.render stays a declaration alias).
Presentation.zig is the panel presentation state as a file struct, and
animation.zig the easing curves, transition kinds, tracks and boxes, the
character effects' sources and the generic displayed-value transition, all
of which lived in layout.zig. layout.zig keeps only layout.
Diffstat (limited to 'src/layout.zig')
| -rw-r--r-- | src/layout.zig | 1439 |
1 files changed, 2 insertions, 1437 deletions
diff --git a/src/layout.zig b/src/layout.zig index 8e44f1d9..b642c52c 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; @@ -1250,8 +600,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(); @@ -1457,789 +805,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(); |
