diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /src/layout.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-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.zig | 1706 |
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); + } +}; |
