summaryrefslogtreecommitdiff
path: root/src/layout.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /src/layout.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/layout.zig')
-rw-r--r--src/layout.zig1706
1 files changed, 1706 insertions, 0 deletions
diff --git a/src/layout.zig b/src/layout.zig
new file mode 100644
index 00000000..51944856
--- /dev/null
+++ b/src/layout.zig
@@ -0,0 +1,1706 @@
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const config = @import("config.zig");
+const panes = @import("panes.zig");
+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 TOPBAR_H = pardes.TOPBAR_H;
+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_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);
+ 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;
+ }
+ @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 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.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;
+
+pub const MovePlacement = struct {
+ preview_col: usize,
+ above_id: usize,
+ row: u16,
+ above_y: u16,
+ above_h: u16,
+};
+
+pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void {
+ const a = p.rects[from];
+ var best: ?usize = null;
+ var best_d: i32 = 0;
+ for (p.panes, 0..) |slot, i| {
+ if (slot == null or i == from) continue;
+ const r = p.rects[i];
+ const vov = a.y < r.y + r.h and r.y < a.y + a.h;
+ const hov = a.x < r.x + r.w and r.x < a.x + a.w;
+ const ok = switch (dir) {
+ .left => r.x + r.w <= a.x and vov,
+ .right => r.x >= a.x + a.w and vov,
+ .up => r.y + r.h <= a.y and hov,
+ .down => r.y >= a.y + a.h and hov,
+ };
+ if (!ok) continue;
+ const d: i32 = switch (dir) {
+ .left => @as(i32, a.x) - @as(i32, r.x + r.w),
+ .right => @as(i32, r.x) - @as(i32, a.x + a.w),
+ .up => @as(i32, a.y) - @as(i32, r.y + r.h),
+ .down => @as(i32, r.y) - @as(i32, a.y + a.h),
+ };
+ if (best == null or d < best_d) {
+ best = i;
+ best_d = d;
+ }
+ }
+ if (best) |b| {
+ p.active = b;
+ // a count typed before the hop was meant for the pane you left
+ p.panes[b].?.normal.clear();
+ }
+}
+
+pub fn targetColumn(p: *Pardes, cur_x: u16) usize {
+ var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0;
+ for (0..p.ncol) |c| {
+ if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) {
+ tc = c;
+ break;
+ }
+ }
+ return tc;
+}
+
+pub fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 {
+ if (h < 2) return null;
+ const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1;
+ const lo = y +| min_each;
+ const hi = y + h - min_each;
+ if (lo > hi) return y + h / 2;
+ return std.math.clamp(cur_y, lo, hi);
+}
+
+pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement {
+ const src = findPane(p, id) orelse return null;
+ const tc = targetColumn(p, cur_x);
+ if (tc == src.col and p.col_n[src.col] == 1) return null;
+ if (tc == src.col) {
+ const sr = p.rects[id];
+ if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null;
+ }
+ var heights: [MAX_PANES]u16 = @splat(0);
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ const pid = p.col_panes[c][k];
+ heights[pid] = p.rects[pid].h;
+ }
+ }
+ if (p.col_n[src.col] > 1) {
+ const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx + 1];
+ heights[sib] +|= p.rects[id].h;
+ }
+ var y: u16 = TOPBAR_H;
+ var last: ?MovePlacement = null;
+ for (0..p.col_n[tc]) |k| {
+ const pid = p.col_panes[tc][k];
+ if (pid == id) continue;
+ const h = heights[pid];
+ const row = splitRowForExtent(y, h, cur_y) orelse {
+ y +|= h;
+ continue;
+ };
+ const placement: MovePlacement = .{
+ .preview_col = tc,
+ .above_id = pid,
+ .row = row,
+ .above_y = y,
+ .above_h = h,
+ };
+ last = placement;
+ if (cur_y < y + h) return placement;
+ y +|= h;
+ }
+ return last;
+}
+
+pub fn movePane(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void {
+ const placement = movePlacement(p, id, cur_x, cur_y) orelse return;
+ const src = findPane(p, id) orelse return;
+ const source_multi = p.col_n[src.col] > 1;
+ var heights: [MAX_PANES]u16 = @splat(0);
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ const pid = p.col_panes[c][k];
+ heights[pid] = p.rects[pid].h;
+ }
+ }
+ removePane(p, id);
+ if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) {
+ const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx];
+ heights[sib] +|= p.rects[id].h;
+ }
+ const af = findPane(p, placement.above_id) orelse return;
+ const upper_h = @max(1, placement.row -| placement.above_y);
+ const lower_h = @max(1, placement.above_h -| upper_h);
+ heights[placement.above_id] = upper_h;
+ heights[id] = lower_h;
+ insert(p, af.col, af.idx + 1, id);
+ setColumnWeights(p, af.col, &heights);
+ if (source_multi and src.col < p.ncol and src.col != af.col) setColumnWeights(p, src.col, &heights);
+}
+
+pub fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void {
+ if (col >= p.ncol) return;
+ for (0..p.col_n[col]) |k| {
+ const pid = p.col_panes[col][k];
+ if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid]));
+ }
+}
+
+pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void {
+ if (k + 1 >= p.col_n[cc]) return;
+ const a = p.panes[p.col_panes[cc][k]] orelse return;
+ const b = p.panes[p.col_panes[cc][k + 1]] orelse return;
+ const ra = p.rects[p.col_panes[cc][k]];
+ const rb = p.rects[p.col_panes[cc][k + 1]];
+ const combined: f32 = @floatFromInt(ra.h + rb.h);
+ var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y);
+ nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H));
+ const pair = a.vweight + b.vweight;
+ a.vweight = pair * (nt / combined);
+ b.vweight = pair - a.vweight;
+}
+
+pub fn findPane(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } {
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ if (p.col_panes[c][k] == id) return .{ .col = c, .idx = k };
+ }
+ }
+ return null;
+}
+
+pub fn insert(p: *Pardes, c: usize, idx: usize, id: usize) void {
+ var k = p.col_n[c];
+ while (k > idx) : (k -= 1) p.col_panes[c][k] = p.col_panes[c][k - 1];
+ p.col_panes[c][idx] = id;
+ p.col_n[c] += 1;
+}
+
+pub fn removePane(p: *Pardes, id: usize) void {
+ const f = findPane(p, id) orelse return;
+ const c = f.col;
+ var k = f.idx;
+ while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1];
+ p.col_n[c] -= 1;
+ if (p.col_n[c] == 0) {
+ if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
+ var j = c;
+ while (j + 1 < p.ncol) : (j += 1) {
+ p.col_panes[j] = p.col_panes[j + 1];
+ p.col_n[j] = p.col_n[j + 1];
+ p.col_weight[j] = p.col_weight[j + 1];
+ }
+ p.ncol -= 1;
+ }
+}
+
+pub fn joinCol(p: *Pardes) void {
+ const f = findPane(p, p.active) orelse return;
+ const c = f.col;
+ if (c + 1 >= p.ncol) return;
+ const dst = c + 1;
+ p.col_weight[dst] +|= p.col_weight[c];
+ for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k];
+ p.col_n[dst] += p.col_n[c];
+ var j = c;
+ while (j + 1 < p.ncol) : (j += 1) {
+ p.col_panes[j] = p.col_panes[j + 1];
+ p.col_n[j] = p.col_n[j + 1];
+ p.col_weight[j] = p.col_weight[j + 1];
+ }
+ p.ncol -= 1;
+}
+
+pub fn canSplitColumn(p: *Pardes, source_id: usize) bool {
+ if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false;
+ const source = findPane(p, source_id) orelse return false;
+ // Refresh derived widths: public layout surgery may be chained between
+ // syncs, and a cached width must never admit a now-too-narrow split.
+ compute(p);
+ if (p.col_w[source.col] < config.MINW * 2) return false;
+
+ const weight = p.col_weight[source.col];
+ if (weight >= 2 and weight % 2 == 0) return true;
+ for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2)
+ return false;
+ return weight > 0;
+}
+
+pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
+ if (id >= MAX_PANES or p.panes[id] == null) return false;
+ if (!canSplitColumn(p, source_id)) return false;
+ const source = findPane(p, source_id) orelse return false;
+ const source_col = source.col;
+ var old_weight = p.col_weight[source_col];
+ const needs_rebase = old_weight < 2 or old_weight % 2 != 0;
+ if (needs_rebase) old_weight *= 2;
+ if (id == source_id) {
+ if (p.col_n[source_col] <= 1) return false;
+ absorbVWeight(p, id);
+ removePane(p, id);
+ } else if (findPane(p, id) != null) return false;
+
+ if (needs_rebase) {
+ for (0..p.ncol) |column| p.col_weight[column] *= 2;
+ }
+ const source_weight = old_weight / 2;
+ const new_weight = old_weight - source_weight;
+ p.col_weight[source_col] = source_weight;
+ const c = source_col + @intFromBool(!before);
+ var j = p.ncol;
+ while (j > c) : (j -= 1) {
+ p.col_panes[j] = p.col_panes[j - 1];
+ p.col_n[j] = p.col_n[j - 1];
+ p.col_weight[j] = p.col_weight[j - 1];
+ }
+ p.col_weight[c] = new_weight;
+ p.col_panes[c][0] = id;
+ p.col_n[c] = 1;
+ p.ncol += 1;
+ return true;
+}
+
+pub fn snapColWeights(p: *Pardes, c: usize) void {
+ for (0..p.col_n[c]) |k| {
+ const pid = p.col_panes[c][k];
+ if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h));
+ }
+}
+
+pub fn absorbVWeight(p: *Pardes, id: usize) void {
+ const f = findPane(p, id) orelse return;
+ if (p.col_n[f.col] <= 1) return;
+ snapColWeights(p, f.col);
+ var sib = if (f.idx > 0) p.col_panes[f.col][f.idx - 1] else p.col_panes[f.col][f.idx + 1];
+ var k = f.idx;
+ while (k > 0) : (k -= 1) {
+ sib = p.col_panes[f.col][k - 1];
+ if (p.panes[sib]) |pp| if (if (pp.file) |ff| panes.Output.fileTraits(ff.output).doc else true) break;
+ }
+ if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h)));
+}
+
+pub fn splitParent(p: *Pardes, want: usize) usize {
+ const need = 2 * BOX_H + 3;
+ if (p.rects[want].h >= need) return want;
+ if (findPane(p, want)) |f| for (0..p.col_n[f.col]) |k| {
+ if (p.rects[p.col_panes[f.col][k]].h >= need) return p.col_panes[f.col][k];
+ };
+ var tallest = want;
+ for (0..p.ncol) |c| for (0..p.col_n[c]) |k| {
+ const pid = p.col_panes[c][k];
+ if (p.rects[pid].h >= need) return pid;
+ if (p.rects[pid].h > p.rects[tallest].h) tallest = pid;
+ };
+ return tallest;
+}
+
+pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void {
+ const src = p.panes[src_id] orelse return;
+ const src_h = p.rects[src_id].h;
+ const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
+ const cur: u16 = if (!src.isTerminal()) body / 2 else panes.Terminal.gridCursor(src).y + 1;
+ // cap keep so a content-full source still leaves the new pane a tag +
+ // a few body rows (an Alt-n from a full shell was born 0 rows tall)
+ const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3)));
+ if (findPane(p, src_id)) |f| for (0..p.col_n[f.col]) |k| {
+ const pid = p.col_panes[f.col][k];
+ if (p.panes[pid]) |pp| if (pp != nw) {
+ pp.vweight = @floatFromInt(@max(1, p.rects[pid].h));
+ };
+ };
+ src.vweight = @floatFromInt(BOX_H + keep);
+ nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep)));
+ if (nw.file) |f| if (!panes.Output.fileTraits(f.output).doc) {
+ // trimmed: every row ends in a newline, and the empty line after
+ // the last one is not a result
+ const want: f32 = @floatFromInt(BOX_H + panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n")));
+ if (want < nw.vweight) {
+ src.vweight += nw.vweight - want;
+ nw.vweight = want;
+ }
+ };
+}
+
+pub fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool {
+ const f = findPane(p, source_id) orelse return false;
+ compute(p);
+ return p.col_w[f.col] >= min_cells * 2;
+}
+
+pub fn panelBox(rect: Rect) Box {
+ return .{
+ .x = @floatFromInt(rect.x),
+ .y = @floatFromInt(rect.y),
+ .w = @floatFromInt(rect.w),
+ .h = @floatFromInt(rect.h),
+ };
+}
+
+pub fn columnBoundary(width: u16, prefix: u128, total: u128) u16 {
+ if (total == 0) return 0;
+ const pixels = (@as(u128, width) * prefix + total / 2) / total;
+ return @intCast(@min(@as(u128, width), pixels));
+}
+
+pub fn compute(p: *Pardes) void {
+ p.rects = @splat(.{});
+ if (p.ncol == 0) return;
+ var wsum: u128 = 0;
+ for (0..p.ncol) |c| wsum += p.col_weight[c];
+ if (wsum == 0) wsum = 1;
+
+ // Round cumulative boundaries so widths still sum to the available screen.
+ var prefix: u128 = 0;
+ for (0..p.ncol) |c| {
+ const last = c + 1 == p.ncol;
+ const x = columnBoundary(p.screen_w, prefix, wsum);
+ prefix += p.col_weight[c];
+ const end: u16 = if (last)
+ p.screen_w
+ else
+ columnBoundary(p.screen_w, prefix, wsum);
+ const cw = end -| x;
+ p.col_x[c] = x;
+ p.col_w[c] = cw;
+
+ var vsum: f32 = 0;
+ for (0..p.col_n[c]) |k| {
+ if (p.panes[p.col_panes[c][k]]) |pane| vsum += pane.vweight;
+ }
+ if (vsum <= 0) vsum = 1;
+
+ var y: u16 = TOPBAR_H;
+ const avail_h = p.screen_h -| TOPBAR_H;
+ for (0..p.col_n[c]) |k| {
+ const id = p.col_panes[c][k];
+ const pane = p.panes[id] orelse continue;
+ const lastk = k + 1 == p.col_n[c];
+ const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum;
+ const room = p.screen_h -| y;
+ const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh)))));
+ p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
+ y +|= ch;
+ }
+ }
+}
+
+pub const ascii_max_movement_frames: u16 = 12;
+
+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);
+ }
+};