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 BOX_H = pardes.BOX_H; pub const Presentation = struct { // Only acknowledged draws advance shown geometry; committed and submitted may be ahead. committed: [MAX_PANES]?Snapshot = @splat(null), initialized: bool = false, enabled: bool = false, snap_once: bool = false, tracks: [MAX_PANES]?Track = @splat(null), closing: [MAX_PANES]Track = undefined, closing_len: usize = 0, submitted: [MAX_PANES]?Snapshot = @splat(null), submitted_ready: bool = false, shown: [MAX_PANES]?Snapshot = @splat(null), shown_tracks: [MAX_PANES]?Track = @splat(null), shown_closing: [MAX_PANES]Track = undefined, shown_closing_len: usize = 0, acknowledged: bool = false, pending: bool = false, previous_cells: []pardes.Cell = &.{}, previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}), previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), previous_cols: u16 = 0, previous_rows: u16 = 0, previous_valid: bool = false, previous_layout: [MAX_PANES]?Snapshot = @splat(null), diffs: []pardes.PanelCellDiff = &.{}, diff_state: enum { none, requested, ready } = .none, pub const CellPosition = struct { col: u16, row: u16 }; pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void { if (self.previous_cells.len > 0) gpa.free(self.previous_cells); for (&self.previous_body_layers) |*layer| gpa.free(layer.cells); for (&self.previous_tag_layers) |*layer| gpa.free(layer.cells); if (self.diffs.len > 0) gpa.free(self.diffs); } pub fn sync(self: *Presentation, p: *Pardes) void { const initializing = !self.initialized or !self.enabled; if (initializing or self.snap_once) { const had_layout = self.initialized; self.committed = @splat(null); self.tracks = @splat(null); self.closing_len = 0; self.diff_state = .none; if (initializing) { self.shown_tracks = @splat(null); self.shown_closing_len = 0; } for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; } self.initialized = true; self.snap_once = false; if (had_layout and self.acknowledged) self.pending = true; return; } const effect = p.settings.panel_transition; if (effect.needsPreviousGrid() and self.diff_state != .none) { var second_change = false; for (p.panes, self.committed, 0..) |slot, snapshot, id| { const pane = slot orelse { second_change = second_change or snapshot != null; continue; }; const target = panelBox(p.rects[id]); second_change = second_change or snapshot == null or snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target); } if (second_change) { p.abandonPanelAnimations(); self.committed = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; } return; } } var changed = false; var animated_change = false; for (p.panes, 0..) |slot, id| { const pane = slot orelse { if (self.committed[id]) |old| { changed = true; if (effect.lifecycleOnly()) { if (self.appendClosing(.{ .serial = old.serial, .pane = @intCast(id), .phase = .closing, .effect = effect, .from = old.box, .to = closingBox(effect, old.box), })) animated_change = true; } } self.committed[id] = null; self.tracks[id] = null; // Never let pixels from a dead pane address a reused slot. self.shown_tracks[id] = null; continue; }; const target = panelBox(p.rects[id]); const previous = self.committed[id]; self.committed[id] = .{ .serial = pane.serial, .box = target }; if (effect == .off) { changed = changed or previous == null or previous.?.serial != pane.serial or !previous.?.box.eql(target); self.tracks[id] = null; continue; } if (previous) |old| { if (old.serial == pane.serial and old.box.eql(target)) continue; const same_lifetime = old.serial == pane.serial; const prior = if (self.tracks[id]) |track| if (track.serial == pane.serial and track.active()) track else null else null; if (effect.lifecycleOnly() and same_lifetime) { if (prior) |active| if (active.phase == .opening) { var next = active; next.from = openingBox(effect, target, p.screen_w); next.to = target; self.tracks[id] = next; changed = true; animated_change = true; continue; }; self.tracks[id] = null; changed = true; continue; } if (effect.lifecycleOnly() and !same_lifetime) { _ = self.appendClosing(.{ .serial = old.serial, .pane = @intCast(id), .phase = .closing, .effect = effect, .from = old.box, .to = closingBox(effect, old.box), }); } const shown = if (self.shown[id]) |snapshot| if (snapshot.serial == pane.serial) snapshot.box else null else null; const from = if (!same_lifetime) openingBox(effect, target, p.screen_w) else if (shown) |box| box else if (self.acknowledged and prior != null) prior.?.from else old.box; const next: Track = .{ .serial = pane.serial, .pane = @intCast(id), .phase = if (!same_lifetime or (prior != null and prior.?.phase == .opening)) .opening else .moving, .effect = effect, .from = from, .to = target, }; self.tracks[id] = next; changed = true; animated_change = true; } else { const next: Track = .{ .serial = pane.serial, .pane = @intCast(id), .phase = .opening, .effect = effect, .from = openingBox(effect, target, p.screen_w), .to = target, }; self.tracks[id] = next; changed = true; animated_change = true; } } if (animated_change and effect.needsPreviousGrid()) { self.diff_state = .requested; } if (changed and self.acknowledged) self.pending = true; } fn appendClosing(self: *Presentation, track: Track) bool { std.debug.assert(track.phase == .closing); const baseline = self.previous_layout[track.pane] orelse return false; if (!self.previous_valid or baseline.serial != track.serial or !baseline.box.eql(track.from)) return false; if (self.closing_len == self.closing.len) { std.mem.copyForwards( Track, self.closing[0 .. self.closing.len - 1], self.closing[1..], ); self.closing_len -= 1; } self.closing[self.closing_len] = track; self.closing_len += 1; return true; } pub fn advance(self: *Presentation) void { for (&self.tracks) |*slot| { const track = if (slot.*) |*track| track else continue; track.frame +|= 1; if (!track.active()) slot.* = null; } var out: usize = 0; for (self.closing[0..self.closing_len]) |value| { var track = value; track.frame +|= 1; if (!track.active()) continue; self.closing[out] = track; out += 1; } self.closing_len = out; } pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void { var presented: [MAX_PANES]?Track = @splat(null); var presented_closing: [MAX_PANES]Track = undefined; var nclosing: usize = 0; var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready) self.submitted else @splat(null); if (!self.submitted_ready) for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; }; for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| { const pane = p.panes[id] orelse { snapshot.* = null; continue; }; if (pane.serial != saved.serial) snapshot.* = null; }; for (tracks) |track| { if (track.phase == .closing) { const current = for (self.closing[0..self.closing_len]) |candidate| { if (candidate.serial == track.serial and candidate.pane == track.pane and candidate.effect == track.effect) break true; } else false; if (!current or !track.active() or nclosing == presented_closing.len) continue; presented_closing[nclosing] = track; nclosing += 1; continue; } const id: usize = track.pane; if (id >= p.panes.len or !track.active()) continue; const pane = p.panes[id] orelse continue; if (pane.serial != track.serial) continue; presented[id] = track; presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() }; } self.shown_tracks = presented; self.shown_closing = presented_closing; self.shown_closing_len = nclosing; self.shown = presented_layout; self.acknowledged = true; self.pending = false; if (tracks.len == 0) { self.tracks = @splat(null); self.closing_len = 0; self.diff_state = .none; self.capturePrevious(p.gpa, &p.surface); } } fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { self.diff_state = .none; const cells = surface.cells; if (cells.len == 0) { self.previous_valid = false; self.previous_layout = @splat(null); return; } if (self.previous_cells.len != cells.len) { const next = gpa.alloc(pardes.Cell, cells.len) catch { self.previous_valid = false; self.previous_layout = @splat(null); return; }; if (self.previous_cells.len > 0) gpa.free(self.previous_cells); self.previous_cells = next; } for (&self.previous_body_layers, surface.bodyLayers()) |*old, *current| { const count = if (current.rows > 0) current.cells.len else 0; const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { self.previous_valid = false; self.previous_layout = @splat(null); return; }; if (count > 0) @memcpy(storage, current.cells); old.* = current.*; old.cells = storage; } for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| { const count = if (current.cols > 0) current.cells.len else 0; const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { self.previous_valid = false; self.previous_layout = @splat(null); return; }; if (count > 0) @memcpy(storage, current.cells); old.* = current.*; old.cells = storage; } @memcpy(self.previous_cells, cells); self.previous_cols = surface.cols; self.previous_rows = surface.rows; self.previous_valid = true; self.previous_layout = if (self.submitted_ready) self.submitted else @splat(null); } pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff { if (self.diff_state != .ready or col >= cols or row >= rows or self.diffs.len != @as(usize, cols) * rows) return .unchanged; return self.diffs[@as(usize, row) * cols + col]; } pub const Point = struct { x: f32, y: f32 }; /// Coordinates stay fractional until the host resolves its body font grid. pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32, y: f32) ?Point { if (self.pending or !std.math.isFinite(x) or !std.math.isFinite(y) or x < 0 or y < 0 or x >= @as(f32, @floatFromInt(cols)) or y >= @as(f32, @floatFromInt(rows))) return null; var closing = self.shown_closing_len; while (closing > 0) { closing -= 1; const track = self.shown_closing[closing]; if (track.active() and boxContainsPoint(track.contentBox(), x, y) and boxContainsPoint(track.presented(), x, y)) return null; } for ([_]Phase{ .opening, .moving }) |phase| { var index = self.shown_tracks.len; while (index > 0) { index -= 1; const track = self.shown_tracks[index] orelse continue; if (!track.active() or track.phase != phase) continue; switch (track.effect) { .slide, .zoom, .vertical => { const target = track.contentBox(); if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue; const shown = track.presented(); if (!boxContainsPoint(shown, x, y)) { if (track.effect == .vertical) return null; continue; } return .{ .x = target.x + (x - shown.x) / shown.w * target.w, .y = target.y + (y - shown.y) / shown.h * target.h, }; }, else => { if (!boxContainsPoint(track.to, x, y)) continue; _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null; return .{ .x = x, .y = y }; }, } } } _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null; return .{ .x = x, .y = y }; } fn boxContainsPoint(box: Box, x: f32, y: f32) bool { return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h; } pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition { if (self.pending) return null; var closing = self.shown_closing_len; while (closing > 0) { closing -= 1; const track = self.shown_closing[closing]; if (!track.active()) continue; if (boxContainsCell(track.contentBox(), col, row) and boxContainsCell(track.presented(), col, row)) return null; } const phases = [_]Phase{ .opening, .moving }; for (phases) |phase| { // Backends paint pane slots forward within a phase. Probe them in // reverse so overlapping transition quads address the top pixel. var index = self.shown_tracks.len; while (index > 0) { index -= 1; const track = self.shown_tracks[index] orelse continue; if (!track.active() or track.phase != phase) continue; switch (track.effect) { .slide, .zoom => { const shown = track.presented(); if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; const x = @as(f32, @floatFromInt(col)) + 0.5; const y = @as(f32, @floatFromInt(row)) + 0.5; const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w)); const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h)); return .{ .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))), .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))), }; }, .vertical => { const clip = track.contentBox(); if (!boxContainsCell(clip, col, row)) continue; const shown = track.presented(); if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) return null; const x = @as(f32, @floatFromInt(col)) + 0.5; const y = @as(f32, @floatFromInt(row)) + 0.5; const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); return .{ .col = @intFromFloat(@floor(clip.x + u * clip.w)), .row = @intFromFloat(@floor(clip.y + v * clip.h)), }; }, .dissolve, .ascii => { if (!boxContainsCell(track.to, col, row)) continue; const cell_diff = self.cellDiff(cols, rows, col, row); if (!cell_diff.changed()) return .{ .col = col, .row = row }; const relative_col: u16 = @intFromFloat(@floor( @as(f32, @floatFromInt(col)) + 0.5 - track.to.x, )); const relative_row: u16 = @intFromFloat(@floor( @as(f32, @floatFromInt(row)) + 0.5 - track.to.y, )); const visible = switch (track.effect) { .dissolve => dissolveRevealed( track.serial, relative_col, relative_row, track.amount(), ), .ascii => switch (cell_diff) { .ascii => |diff| diff.complete(track.frame), .unchanged, .visual => true, }, else => unreachable, }; return if (visible) .{ .col = col, .row = row } else null; }, .edges, .fall, .wave, .curtain, .scramble, .typewriter => { if (!boxContainsCell(track.to, col, row)) continue; const area = CellArea.of(track.to); const settled = std.meta.eql( charSource( track, col -| area.x0, row -| area.y0, area, ), CharSource.settled, ); return if (settled) .{ .col = col, .row = row } else null; }, .off => {}, } } } for (self.shown_tracks) |maybe| { const track = maybe orelse continue; if (!track.active() or (track.effect != .slide and track.effect != .zoom and track.effect != .vertical)) continue; if (boxContainsCell(track.to, col, row)) return null; } return .{ .col = col, .row = row }; } fn boxContainsCell(box: Box, col: u16, row: u16) bool { const x = @as(f32, @floatFromInt(col)) + 0.5; const y = @as(f32, @floatFromInt(row)) + 0.5; return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; } pub fn cancel(self: *Presentation) void { self.tracks = @splat(null); self.closing_len = 0; self.shown_tracks = @splat(null); self.shown_closing_len = 0; self.diff_state = .none; self.pending = self.acknowledged; } fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool { const count = surface.cells.len; if (!self.previous_valid or self.previous_cols != surface.cols or self.previous_rows != surface.rows or self.previous_cells.len != count) return false; if (self.diffs.len != count) { const next = try gpa.alloc(pardes.PanelCellDiff, count); if (self.diffs.len > 0) gpa.free(self.diffs); self.diffs = next; } for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new| diff.* = pardes.PanelCellDiff.between(old, new); for (&self.tracks) |*slot| { const track = if (slot.*) |*track| track else continue; if (track.effect != .ascii) continue; var longest: u16 = 1; var row: u16 = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContainsCell(track.to, col, row)) continue; const index = @as(usize, row) * surface.cols + col; switch (self.diffs[index]) { .ascii => |diff| longest = @max(longest, diff.frameCount()), .unchanged, .visual => {}, } } } track.frame_count = @max(track.frame_count, longest); } self.diff_state = .ready; return true; } pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void { self.submitted = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; self.submitted[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]), }; } self.submitted_ready = true; if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) { for (&self.tracks) |*slot| { const track = slot.* orelse continue; if (track.effect.needsPreviousGrid()) slot.* = null; } self.closing_len = 0; self.diff_state = .none; } if (self.diff_state == .ready) { s.previous_cells = self.previous_cells; s.previous_body_layers = &self.previous_body_layers; s.previous_tag_layers = &self.previous_tag_layers; s.cell_diffs = self.diffs; } s.npanel_tracks = paintOrder( &self.tracks, self.closing[0..self.closing_len], &s.panel_tracks, ); } pub fn animating(self: *const Presentation) bool { for (self.tracks) |track| if (track != null and track.?.active()) return true; for (self.closing[0..self.closing_len]) |track| if (track.active()) return true; return false; } test "presentation cache allocation failures retain ownership and allow retry" { var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); const gpa = allocator.allocator(); var state: Presentation = .{}; defer state.deinit(gpa); var cells: [3]pardes.Cell = @splat(.{}); cells[0].default = false; cells[0].text[0] = 'a'; var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] }; state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }; state.submitted_ready = true; state.capturePrevious(gpa, &surface); try std.testing.expect(state.previous_valid); try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial); const previous = state.previous_cells.ptr; const allocated = allocator.allocations; allocator.fail_index = allocator.alloc_index; state.capturePrevious(gpa, &surface); try std.testing.expect(state.previous_valid); try std.testing.expectEqual(allocated, allocator.allocations); try std.testing.expect(!allocator.has_induced_failure); surface.cols = 3; surface.cells = &cells; state.submitted[0].?.box.w = 3; state.diff_state = .ready; state.capturePrevious(gpa, &surface); try std.testing.expect(allocator.has_induced_failure); try std.testing.expect(!state.previous_valid); try std.testing.expectEqual(previous, state.previous_cells.ptr); try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len); try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]); try std.testing.expect(state.previous_layout[0] == null); try std.testing.expectEqual(.none, state.diff_state); allocator.fail_index = std.math.maxInt(usize); state.capturePrevious(gpa, &surface); try std.testing.expect(state.previous_valid); try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len); try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w); state.diff_state = .requested; allocator.fail_index = allocator.alloc_index; try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface)); try std.testing.expectEqual(.requested, state.diff_state); try std.testing.expectEqual(@as(usize, 0), state.diffs.len); allocator.fail_index = std.math.maxInt(usize); try std.testing.expect(try state.prepareDiff(gpa, &surface)); try std.testing.expectEqual(.ready, state.diff_state); try std.testing.expectEqual(@as(usize, 3), state.diffs.len); for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff); } test "presentation submission failure preserves shown geometry until retry is acknowledged" { var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); defer frame.deinit(); _ = try p.render(frame.allocator()); p.acknowledgePanelPresentation(&.{}); const shown = p.presentation.shown; const previous = p.presentation.previous_cells.ptr; p.presentation.diff_state = .requested; p.presentation.pending = true; allocator.fail_index = allocator.alloc_index; try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface)); try std.testing.expectEqualDeep(shown, p.presentation.shown); try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr); try std.testing.expectEqual(.requested, p.presentation.diff_state); try std.testing.expect(p.presentation.pending); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null); allocator.fail_index = std.math.maxInt(usize); try p.presentation.submit(p, &p.surface); try std.testing.expectEqualDeep(shown, p.presentation.shown); try std.testing.expect(p.presentation.pending); p.acknowledgePanelPresentation(&.{}); try std.testing.expect(!p.presentation.pending); try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?); } }; pub const column_weight_unit: u64 = 1 << 32; pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; 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 columnBySerial(p: *const Pardes, serial: u32) ?usize { if (serial == 0) return null; for (0..p.ncol) |column| if (p.col_serial[column] == serial) return column; return null; } /// What releasing column `source`'s grip at `x` does: past a neighbour's /// middle it takes that column's place; short of it, as in acme, the /// column's left edge moves to `x - grab`, clamped as setColumnPairWidth /// clamps it. Still on the grip, or at the leftmost column, nothing moves. /// The drag preview and the release both ask here, so they cannot disagree. pub const ColumnDrop = union(enum) { stay, reorder: usize, edge: u16 }; pub fn columnDrop(p: *const Pardes, source: usize, x: u16, grab: u16) ColumnDrop { if (source >= p.ncol) return .stay; var insertion: usize = 0; for (0..p.ncol) |column| { if (column == source) continue; if (x >= p.col_x[column] + p.col_w[column] / 2) insertion += 1; } if (insertion != source) return .{ .reorder = insertion }; if (source == 0 or (x >= p.col_x[source] and x < p.col_x[source] + config.GUTTER)) return .stay; if (p.col_w[source - 1] + p.col_w[source] < 2 * config.MINW) return .stay; const lo = p.col_x[source - 1] + config.MINW; const hi = (p.col_x[source] + p.col_w[source]) -| config.MINW; return .{ .edge = std.math.clamp(x -| grab, lo, @max(lo, hi)) }; } /// Share out the width of columns `left` and `left + 1` so the left one is /// `left_width` cells, neither below MINW. A border drag and a column grip /// dropped inside its own place both end here. pub fn setColumnPairWidth(p: *Pardes, left: usize, left_width: u64) void { if (left + 1 >= p.ncol) return; const combined: u64 = p.col_w[left] + p.col_w[left + 1]; // Too narrow for both to keep MINW: any split would squeeze one away. if (combined < 2 * @as(u64, config.MINW)) return; const width = std.math.clamp(left_width, config.MINW, @max(@as(u64, config.MINW), combined -| config.MINW)); const pair = p.col_weight[left] + p.col_weight[left + 1]; if (pair <= 1 or combined == 0) return; const numerator = @as(u128, pair) * width + combined / 2; const share = std.math.clamp(@as(u64, @intCast(numerator / combined)), 1, pair - 1); p.col_weight[left] = share; p.col_weight[left + 1] = pair - share; } pub fn reorderColumn(p: *Pardes, from: usize, to: usize) void { if (from >= p.ncol or to >= p.ncol or from == to) return; const ids = p.col_panes[from]; const count = p.col_n[from]; const weight = p.col_weight[from]; const tag = p.col_tags[from]; const serial = p.col_serial[from]; var at = from; while (at != to) { const next = if (at < to) at + 1 else at - 1; p.col_panes[at] = p.col_panes[next]; p.col_n[at] = p.col_n[next]; p.col_weight[at] = p.col_weight[next]; p.col_tags[at] = p.col_tags[next]; p.col_serial[at] = p.col_serial[next]; at = next; } p.col_panes[to] = ids; p.col_n[to] = count; p.col_weight[to] = weight; p.col_tags[to] = tag; p.col_serial[to] = serial; if (p.header_column) |column| { if (column == from) p.header_column = to else if (from < to and column > from and column <= to) { p.header_column = column - 1; } else if (from > to and column >= to and column < from) p.header_column = column + 1; } p.pointer_tag_hit = null; p.look_hover_wait = null; p.look_hover_preview = null; compute(p); } 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 = p.topBarHeight() + p.columnBarHeight(); 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| if (!pane.collapsed) { 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)); if (combined <= 0) return; // Dragging a folded tag open is an explicit resize, so switch this pair // back to visible-height weights without consuming other folded weights. if ((a.collapsed and nt > BOX_H) or (b.collapsed and combined - nt > BOX_H)) { snapColWeights(p, cc); a.vweight = @floatFromInt(@max(1, ra.h)); b.vweight = @floatFromInt(@max(1, rb.h)); if (nt > BOX_H) a.collapsed = false; if (combined - nt > BOX_H) b.collapsed = false; } if (a.collapsed or b.collapsed) return; 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) { p.exitHeader(); 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.col_tags[j] = p.col_tags[j + 1]; p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; p.col_tags[p.ncol] = .{}; p.col_serial[p.ncol] = 0; } } pub fn joinCol(p: *Pardes) void { const f = findPane(p, p.active) orelse return; const c = f.col; if (c + 1 >= p.ncol) return; p.exitHeader(); 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.col_tags[j] = p.col_tags[j + 1]; p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; p.col_tags[p.ncol] = .{}; p.col_serial[p.ncol] = 0; } 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, null); 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); p.exitHeader(); 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_tags[j] = p.col_tags[j - 1]; p.col_serial[j] = p.col_serial[j - 1]; } p.col_weight[c] = new_weight; p.col_tags[c] = .{}; p.col_serial[c] = 0; 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| if (!pp.collapsed) { pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); }; } } /// Hand a closing pane's rows to `to`. With none named they go up: past any /// results listing to the nearest pane above that is not one, or to the /// pane below when the closing one is first in its column. pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) void { const f = findPane(p, id) orelse return; if (p.col_n[f.col] <= 1) return; snapColWeights(p, f.col); var sib = to orelse 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 (to == null and 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 and !pp.collapsed) { pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); }; }; if (!src.collapsed) 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 toggleCollapse(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const at = findPane(p, id) orelse return; compute(p); const was_collapsed = pane.collapsed; const old_height = p.rects[id].h; const remembered_height = pane.vweight; // Convert only visible panes to row weights. The toggle then changes one // pair, rather than letting the column redistribute the released space. snapColWeights(p, at.col); const neighbor = expandedNeighbor(p, at.col, at.idx, .above) orelse expandedNeighbor(p, at.col, at.idx, .below); if (was_collapsed) { if (neighbor) |other| { const wanted: u16 = @intFromFloat(@min(@as(f32, @floatFromInt(std.math.maxInt(u16))), @max(1, @round(remembered_height)))); const give = @min(wanted -| old_height, p.rects[other].h -| BOX_H); p.panes[other].?.vweight -= @floatFromInt(give); pane.vweight = @floatFromInt(@max(1, old_height + give)); } pane.collapsed = false; } else { // A folded pane's weight stores its former height, including its tag, // so Dump/Restore and a later expansion retain a useful target size. pane.vweight = @floatFromInt(@max(1, old_height)); pane.collapsed = true; if (neighbor) |other| p.panes[other].?.vweight += @floatFromInt(old_height -| BOX_H); } compute(p); } /// The nearest pane on one side of `index` in its column that is not folded /// to its tag: the one Collapse lends rows to and Del k / Del j gives them /// to. A folded pane only keeps its weight for later, so rows handed to it /// would spread over the whole column instead. pub fn expandedNeighbor(p: *const Pardes, col: usize, index: usize, side: enum { above, below }) ?usize { var k = index; while (if (side == .above) k > 0 else k + 1 < p.col_n[col]) { k = if (side == .above) k - 1 else k + 1; const id = p.col_panes[col][k]; if (p.panes[id]) |pane| if (!pane.collapsed) return id; } return null; } fn computeCollapsedColumn(p: *Pardes, c: usize, x: u16, width: u16, folded: usize) void { var y: u16 = p.topBarHeight() + p.columnBarHeight(); const available = p.screen_h -| y; var expanded_left: usize = 0; var weight: f32 = 0; for (p.col_panes[c][0..p.col_n[c]]) |id| if (p.panes[id]) |pane| { if (!pane.collapsed) { expanded_left += 1; weight += pane.vweight; } }; const expanded_height = available -| @as(u16, @intCast(folded)); var budget = expanded_height; var folded_left = folded; for (p.col_panes[c][0..p.col_n[c]]) |id| { const pane = p.panes[id] orelse continue; const room = p.screen_h -| y; const height: u16 = if (pane.collapsed) blk: { folded_left -= 1; break :blk @min(room, BOX_H); } else blk: { expanded_left -= 1; const limit = @min(budget, room -| @as(u16, @intCast(folded_left))); const share = @as(f32, @floatFromInt(expanded_height)) * pane.vweight / (if (weight > 0) weight else 1); const h = if (expanded_left == 0) limit else @min(limit -| @as(u16, @intCast(expanded_left)), @max(1, @as(u16, @intFromFloat(@round(share))))); budget -|= h; break :blk h; }; p.rects[id] = .{ .x = x, .y = y, .w = width, .h = height }; y +|= height; } } pub fn compute(p: *Pardes) void { p.rects = @splat(.{}); if (p.ncol == 0) return; for (0..p.ncol) |column| if (p.col_serial[column] == 0) { p.col_serial[column] = p.next_column_serial; p.next_column_serial +%= 1; if (p.next_column_serial == 0) p.next_column_serial = 1; }; 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 folded: usize = 0; for (p.col_panes[c][0..p.col_n[c]]) |id| if (p.panes[id]) |pane| { if (pane.collapsed) folded += 1; }; if (folded > 0) { computeCollapsedColumn(p, c, x, cw, folded); continue; } 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 = p.topBarHeight() + p.columnBarHeight(); const avail_h = p.screen_h -| y; 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; test "collapse transfers height upward and restores the original layout" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); _ = try p.newShell(1, ""); _ = try p.newShell(2, ""); insert(p, 0, 1, 1); insert(p, 0, 2, 2); p.panes[0].?.vweight = 3; p.panes[1].?.vweight = 2; compute(p); const original = p.rects; toggleCollapse(p, 1); try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); try std.testing.expectEqual(@as(f32, @floatFromInt(original[1].h)), p.panes[1].?.vweight); try std.testing.expectEqual(p.screen_h, p.rects[2].y + p.rects[2].h); try std.testing.expect(p.rects[0].h > original[0].h); try std.testing.expectEqualDeep(original[2], p.rects[2]); toggleCollapse(p, 1); try std.testing.expectEqualDeep(original, p.rects); toggleCollapse(p, 2); try std.testing.expectEqual(@as(u16, 1), p.rects[2].h); try std.testing.expectEqual(p.screen_h, p.rects[2].y + p.rects[2].h); } test "collapse changes only one neighbor at the top middle and bottom" { for (0..4) |target| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 58 }); defer p.deinit(); for (1..4) |id| { _ = try p.newShell(id, ""); insert(p, 0, id, id); } for ([_]f32{ 11, 7, 5, 3 }, 0..) |weight, id| p.panes[id].?.vweight = weight; compute(p); const original = p.rects; const recipient = if (target > 0) target - 1 else 1; for (0..5) |_| { toggleCollapse(p, target); try std.testing.expectEqual(@as(u16, BOX_H), p.rects[target].h); try std.testing.expectEqual(original[recipient].h + original[target].h - BOX_H, p.rects[recipient].h); for (0..4) |id| if (id != target and id != recipient) try std.testing.expectEqualDeep(original[id], p.rects[id]); toggleCollapse(p, target); try std.testing.expectEqualDeep(original, p.rects); } } } test "collapse skips folded neighbors and expansion reclaims only its neighbor" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 62 }); defer p.deinit(); for (1..5) |id| { _ = try p.newShell(id, ""); insert(p, 0, id, id); } compute(p); toggleCollapse(p, 1); toggleCollapse(p, 2); const original = p.rects; toggleCollapse(p, 3); try std.testing.expectEqual(original[0].h + original[3].h - BOX_H, p.rects[0].h); try std.testing.expectEqualDeep(original[4], p.rects[4]); for (1..4) |id| try std.testing.expectEqual(@as(u16, BOX_H), p.rects[id].h); toggleCollapse(p, 3); try std.testing.expectEqualDeep(original, p.rects); // With no expanded pane above, give the space to the first one below. toggleCollapse(p, 0); const with_folded_above = p.rects; toggleCollapse(p, 3); try std.testing.expectEqual(with_folded_above[4].h + with_folded_above[3].h - BOX_H, p.rects[4].h); for (0..3) |id| try std.testing.expectEqualDeep(with_folded_above[id], p.rects[id]); toggleCollapse(p, 3); try std.testing.expectEqualDeep(with_folded_above, p.rects); } test "collapse local transfers remain bounded through repeated tiny window resizes" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 58 }); defer p.deinit(); for (1..5) |id| { _ = try p.newShell(id, ""); insert(p, 0, id, id); } for (0..180) |step| { p.screen_h = @intCast((step * 17) % 81); p.settings.column_tags = step % 2 == 0; p.settings.tag_bottom = step % 3 == 0; compute(p); toggleCollapse(p, step % 5); var used: u16 = 0; for (0..5) |id| { const pane = p.panes[id].?; const rect = p.rects[id]; try std.testing.expect(std.math.isFinite(pane.vweight) and pane.vweight > 0); try std.testing.expect(rect.h <= p.screen_h -| rect.y); if (pane.collapsed) try std.testing.expect(rect.h <= BOX_H); used += rect.h; } try std.testing.expect(used <= p.screen_h); } } test "collapse expansion is limited to space available in one neighbor" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 42 }); defer p.deinit(); for (1..4) |id| { _ = try p.newShell(id, ""); insert(p, 0, id, id); } compute(p); toggleCollapse(p, 2); snapColWeights(p, 0); // An intervening resize gave most of the donor's space to another pane. p.panes[0].?.vweight += p.panes[1].?.vweight - 2; p.panes[1].?.vweight = 2; compute(p); const before = p.rects; toggleCollapse(p, 2); try std.testing.expectEqual(@as(u16, 2), p.rects[2].h); try std.testing.expectEqual(@as(u16, BOX_H), p.rects[1].h); try std.testing.expectEqualDeep(before[0], p.rects[0]); try std.testing.expectEqualDeep(before[3], p.rects[3]); } test "collapse single and all panes leave unused column space" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); toggleCollapse(p, 0); try std.testing.expectEqual(@as(u16, 1), p.rects[0].h); _ = try p.newShell(1, ""); insert(p, 0, 1, 1); toggleCollapse(p, 1); try std.testing.expectEqual(@as(u16, 1), p.rects[0].h); try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); try std.testing.expectEqual(p.rects[0].y + 1, p.rects[1].y); try std.testing.expect(p.rects[1].y + 1 < p.screen_h); toggleCollapse(p, 0); try std.testing.expectEqual(p.screen_h - 1, p.rects[1].y); } test "collapse survives tiny resizes column tags and TagBottom" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); _ = try p.newShell(1, ""); _ = try p.newShell(2, ""); insert(p, 0, 1, 1); insert(p, 0, 2, 2); p.panes[1].?.collapsed = true; for ([_]bool{ false, true }) |bottom| { p.settings.tag_bottom = bottom; for ([_]bool{ false, true }) |column_tags| { p.settings.column_tags = column_tags; for (0..50) |height| { p.screen_h = @intCast(height); compute(p); var used: u16 = 0; for (p.col_panes[0][0..p.col_n[0]]) |id| { const rect = p.rects[id]; try std.testing.expect(rect.h <= p.screen_h -| rect.y); used += rect.h; } try std.testing.expect(used <= p.screen_h); if (height >= 5) try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); } } } p.screen_h = 32; compute(p); try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); } test "collapse can be expanded by dragging its boundary" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); _ = try p.newShell(1, ""); insert(p, 0, 1, 1); toggleCollapse(p, 0); applyRowSplit(p, 0, 0, p.rects[0].y + 5); compute(p); try std.testing.expect(!p.panes[0].?.collapsed); try std.testing.expectEqual(@as(u16, 6), p.rects[0].h); toggleCollapse(p, 1); p.settings.tag_bottom = true; applyRowSplit(p, 0, 0, p.rects[1].y - 5); compute(p); try std.testing.expect(!p.panes[1].?.collapsed); try std.testing.expectEqual(@as(u16, 6), p.rects[1].h); } test "collapse dump restores folded state and retained expansion weight" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); p.panes[0].?.vweight = 7; compute(p); const original_height = p.rects[0].h; toggleCollapse(p, 0); try p.dumpState(); const restored = try Pardes.initFromDump(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }, p.dump_out.?); defer restored.deinit(); try std.testing.expect(restored.panes[0].?.collapsed); try std.testing.expectEqual(@as(f32, @floatFromInt(original_height)), restored.panes[0].?.vweight); try std.testing.expectEqual(@as(u16, 1), restored.rects[0].h); toggleCollapse(restored, 0); try std.testing.expectEqual(restored.screen_h - restored.rects[0].y, restored.rects[0].h); } pub const Easing = enum(u8) { linear, smooth, /// Quintic ease-in-out. It creeps at both ends and crosses the middle of /// the distance fast, inside the same frame count a linear walk would use. smoother, in_cubic, out_cubic, out_back, }; pub const Transition = enum(u8) { // Numeric values are shared with the GUI shader ABI. off = 0, slide = 1, zoom = 2, dissolve = 3, ascii = 4, vertical = 5, edges = 6, fall = 7, wave = 8, curtain = 9, scramble = 10, typewriter = 11, pub fn easing(effect: Transition) Easing { return switch (effect) { .off, .dissolve, .wave => .smooth, .slide, .vertical, .edges => .out_cubic, .zoom => .out_back, // Character walks and per-cell locks read best with a slow start, // a fast middle, and a slow settle over their fixed frame count. .ascii, .fall, .scramble => .smoother, // A sweep and a typewriter are constant-rate by definition: easing // their head would make the pass visibly hesitate mid-pane. .curtain, .typewriter => .linear, }; } pub fn frames(effect: Transition) u16 { return switch (effect) { .off => 0, .slide => 12, .zoom => 14, .dissolve => 10, .ascii => ascii_max_movement_frames + 1, .vertical => 12, .edges, .curtain, .scramble => 12, // Travelling motion needs a couple more samples than a lock or a // rigid slide before it stops reading as a jump. .fall, .wave, .typewriter => 14, }; } pub fn composedByCore(effect: Transition) bool { return switch (effect) { .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true, .off, .slide, .zoom, .dissolve, .vertical => false, }; } pub fn needsPreviousGrid(effect: Transition) bool { return effect == .dissolve or effect == .vertical or effect.composedByCore(); } pub fn lifecycleOnly(effect: Transition) bool { return effect == .vertical; } }; pub const SceneEffect = struct { crt: bool = false, ripple: bool = false, glitch: bool = false, }; pub const Phase = enum(u8) { opening = 0, moving = 1, /// Presentation-only content whose pane lifetime has already ended. /// It is never a valid input target. closing = 2, }; pub const Box = extern struct { x: f32 = 0, y: f32 = 0, w: f32 = 0, h: f32 = 0, pub fn eql(a: Box, b: Box) bool { return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h; } // A cell belongs to a fractional box when its center lies inside. pub fn contains(box: Box, col: u16, row: u16) bool { const x: f32 = @floatFromInt(col); const y: f32 = @floatFromInt(row); return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; } }; // Drawing and hit testing share this moving/opening/closing order. pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize { var len: usize = 0; for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| { const track = maybe orelse continue; if (!track.active() or track.phase != phase) continue; if (len == out.len) return len; out[len] = track; len += 1; }; for (closing) |track| { if (!track.active()) continue; if (len == out.len) return len; out[len] = track; len += 1; } return len; } /// One POD record is enough for every backend. `from` and `to` are logical /// cell boxes; frontends convert them to pixels only at their render edge. pub const Track = extern struct { serial: u32 = 0, pane: u8 = 0, phase: Phase = .moving, effect: Transition = .off, _padding: u8 = 0, frame: u16 = 0, frame_count: u16 = 0, from: Box = .{}, to: Box = .{}, pub fn active(track: Track) bool { return track.effect != .off and track.frame < track.frames(); } pub fn frames(track: Track) u16 { return if (track.frame_count != 0) track.frame_count else track.effect.frames(); } pub fn amount(track: Track) f32 { // Opening rises quickly and settles; closing reverses that motion and // accelerates down out of the fixed clip. if (track.phase == .closing and track.effect == .vertical) return progressEased(.in_cubic, track.frames(), track.frame); return progressEased(track.effect.easing(), track.frames(), track.frame); } pub fn presented(track: Track) Box { return lerpBox(track.from, track.to, track.amount()); } pub fn visualBox(track: Track) Box { return switch (track.effect) { .slide, .zoom, .vertical => track.presented(), .off, .dissolve => track.to, // Every character effect stays inside the pane's final rectangle. .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to, }; } pub fn contentBox(track: Track) Box { return if (track.phase == .closing) track.from else track.to; } }; pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { return switch (effect) { .slide => blk: { var from = target; const middle = target.x + target.w * 0.5; from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5) -target.w else @floatFromInt(screen_width); break :blk from; }, .zoom => .{ .x = target.x + target.w * 0.5, .y = target.y + target.h * 0.5, .w = 0, .h = 0, }, .vertical => blk: { var from = target; from.y += target.h; break :blk from; }, .off, .dissolve => target, // Character effects own the glyphs inside a fixed rectangle, so their // panel opens at exactly its final geometry. .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target, }; } pub fn closingBox(effect: Transition, source: Box) Box { return switch (effect) { .vertical => blk: { var to = source; to.y += source.h; break :blk to; }, else => source, }; } pub fn sample(easing: Easing, raw: f32) f32 { const t = std.math.clamp(raw, 0.0, 1.0); return switch (easing) { .linear => t, .smooth => t * t * (3.0 - 2.0 * t), .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0), .in_cubic => t * t * t, .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), // Robert Penner's ease-out-back polynomial. It intentionally travels // a little past one before settling exactly on the endpoint. .out_back => blk: { const c1: f32 = 1.70158; const c3 = c1 + 1.0; const u = t - 1.0; break :blk 1.0 + c3 * u * u * u + c1 * u * u; }, }; } pub fn progress(effect: Transition, frame: u16) f32 { return progressEased(effect.easing(), effect.frames(), frame); } fn progressEased(easing: Easing, frames: u16, frame: u16) f32 { if (frames <= 1 or frame >= frames - 1) return 1.0; return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); } pub fn lerpBox(from: Box, to: Box, t: f32) Box { const u = @max(0.0, t); return .{ .x = from.x + (to.x - from.x) * u, .y = from.y + (to.y - from.y) * u, .w = @max(0.0, from.w + (to.w - from.w) * u), .h = @max(0.0, from.h + (to.h - from.h) * u), }; } /// Stable cell noise shared by the TTY reveal and shader ports. Integer-only /// hashing means resizing or repainting a frame does not make cells flicker. pub fn cellNoise(serial: u32, col: u16, row: u16) f32 { var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b); x ^= x >> 16; x *%= 0x7feb_352d; x ^= x >> 15; x *%= 0x846c_a68b; x ^= x >> 16; return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0; } /// Whether a changed dissolve cell has crossed from the frozen old grid to /// the new one. Exact endpoints are part of the presentation contract. pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool { const t = std.math.clamp(raw_progress, 0.0, 1.0); if (t <= 0) return false; if (t >= 1) return true; return cellNoise(serial, col, row) < t; } pub const CellArea = struct { x0: u16 = 0, y0: u16 = 0, cols: u16 = 1, rows: u16 = 1, pub fn of(box: Box) CellArea { return .{ .x0 = floorCell(box.x), .y0 = floorCell(box.y), .cols = ceilCell(box.w), .rows = ceilCell(box.h), }; } }; fn floorCell(value: f32) u16 { return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16)))); } fn ceilCell(value: f32) u16 { return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16)))); } pub const CharSource = union(enum) { /// Nothing has arrived here yet: keep the frozen old cell. old, at: Offset, /// Paint this printable byte in the destination cell's own style, whatever /// that cell holds — a caret marching over empty space is still a caret. byte: u8, churn: u8, pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 }; pub const settled: CharSource = .{ .at = .{} }; }; pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource { const t = track.amount(); if (t >= 1.0) return .settled; const w: f32 = @floatFromInt(area.cols); const h: f32 = @floatFromInt(area.rows); const c: f32 = @floatFromInt(col); const r: f32 = @floatFromInt(row); const remaining = 1.0 - t; return switch (track.effect) { .edges => blk: { const travel = cellsOf(remaining * (w + 1.0)); break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } }; }, // Columns rain down, each with its own stable head start, so the pane // fills from the top and the last glyphs land at the bottom. .fall => blk: { const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4); if (local <= 0.0) break :blk .old; break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } }; }, // A vertical ripple travels left to right and its amplitude decays, so // the pane settles out of a wave instead of a fade. .wave => .{ .at = .{ .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)), } }, // A curtain of glyphs marches in from the right, column by column, left // to right; each column still has a short slide of its own. .curtain => blk: { const lead = t * (w + 1.0) - c; if (lead <= 0.0) break :blk .old; break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } }; }, // Every cell churns through printable ASCII and locks onto its final // glyph at its own stable threshold: the pane resolves out of noise. .scramble => blk: { if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled; const churn = cellNoise( track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f), col, row, ); break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) }; }, // Reading-order reveal with a caret sitting on the write head. .typewriter => blk: { const head = t * w * h; const index = r * w + c; if (index + 1.0 <= head) break :blk .settled; if (index <= head) break :blk .{ .byte = '_' }; break :blk .old; }, // PanelAscii walks its own byte distance per cell, and the geometry // effects never reach this path at all. .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled, }; } fn cellsOf(distance: f32) i32 { return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0))); } /// Remap track progress into one cell's own window. A stagger delays a glyph /// without making the effect as a whole end after its last frame. fn staggered(t: f32, delay: f32) f32 { if (delay >= 1.0) return t; return (t - delay) / (1.0 - delay); } test "easing presets have exact endpoints and intended shapes" { inline for (std.enums.values(Easing)) |easing| { try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); try std.testing.expectEqual(@as(f32, 1), sample(easing, 1)); } try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5)); try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); try std.testing.expect(sample(.out_back, 0.8) > 1.0); // Slow at both ends, fast through the middle, and symmetric about the // halfway point: the same curve the integer byte walk reproduces. try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5)); try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15)); try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85)); try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) > sample(.linear, 0.6) - sample(.linear, 0.4)); } test "transition progress completes exactly" { inline for (std.enums.values(Transition)) |effect| { try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames())); if (effect.frames() > 0) try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1)); try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16))); } try std.testing.expectEqual(@as(f32, 1), progress(.off, 0)); const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8)); try std.testing.expectEqual(@as(f32, 0), shrinking.w); try std.testing.expectEqual(@as(f32, 0), shrinking.h); const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8)); try std.testing.expect(opening.w > 100); try std.testing.expect(opening.h > 40); } test "character effects are core-composed and settle on the canonical glyph" { const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 }; const area: CellArea = .of(box); try std.testing.expectEqual(@as(u16, 4), area.x0); try std.testing.expectEqual(@as(u16, 2), area.y0); try std.testing.expectEqual(@as(u16, 20), area.cols); try std.testing.expectEqual(@as(u16, 6), area.rows); inline for (std.enums.values(Transition)) |effect| { if (comptime !effect.composedByCore()) continue; // Core composition needs the frozen old grid for every glyph which has // not arrived, so no character effect may animate without it. try std.testing.expect(effect.needsPreviousGrid()); if (comptime effect == .ascii) continue; // owns its own per-cell byte walk const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box }; const first: Track = .{ .effect = effect, .frame = 0, .to = box }; var moving = false; var row: u16 = 0; while (row < area.rows) : (row += 1) { var col: u16 = 0; while (col < area.cols) : (col += 1) { // The last active sample is the exact canonical grid: no cell // is displaced, churning, or still frozen. try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area)); if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area))) moving = true; } } try std.testing.expect(moving); } } test "each character effect moves glyphs along its own axis" { const box: Box = .{ .w = 30, .h = 8 }; const area: CellArea = .of(box); // Rows alternate which screen edge they come from, and every glyph in a row // travels as one rigid slide: one offset, no vertical component. var edges: Track = .{ .effect = .edges, .frame = 2, .to = box }; const even = charSource(edges, 5, 0, area).at; const odd = charSource(edges, 5, 1, area).at; try std.testing.expect(even.cols > 0); try std.testing.expectEqual(-even.cols, odd.cols); try std.testing.expectEqual(@as(i32, 0), even.rows); try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at); edges.frame = 5; try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols); // Falling columns are vertical only, staggered, and sample from below the // destination because the new text is still above the pane. const fall: Track = .{ .effect = .fall, .frame = 4, .to = box }; var falling = false; var col: u16 = 0; while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) { .old => {}, .byte, .churn => return error.FallShouldNotChurn, .at => |offset| { try std.testing.expectEqual(@as(i32, 0), offset.cols); try std.testing.expect(offset.rows >= 0); if (offset.rows > 0) falling = true; }, }; try std.testing.expect(falling); // The wave displaces rows both ways as it travels, and only rows. const wave: Track = .{ .effect = .wave, .frame = 1, .to = box }; var above = false; var below = false; col = 0; while (col < area.cols) : (col += 1) { const offset = charSource(wave, col, 3, area).at; try std.testing.expectEqual(@as(i32, 0), offset.cols); if (offset.rows < 0) above = true; if (offset.rows > 0) below = true; } try std.testing.expect(above and below); // The curtain has a head: columns behind it hold the old grid, columns the // head has passed are settled, and the head itself is still sliding. const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box }; try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area)); try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area)); var sliding = false; col = 0; while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) { .at => |offset| if (offset.cols < 0) { sliding = true; }, .old, .byte, .churn => {}, }; try std.testing.expect(sliding); var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box }; var churning: usize = 0; var locked: usize = 0; var changed = false; col = 0; while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) { .churn => |byte| { try std.testing.expect(byte >= ' ' and byte <= '~'); churning += 1; scramble.frame = 4; switch (charSource(scramble, col, 0, area)) { .churn => |next| changed = changed or next != byte, .old, .at, .byte => {}, } scramble.frame = 3; }, .at => locked += 1, .old, .byte => return error.ScrambleShouldNotFreeze, }; try std.testing.expect(churning > 0 and locked > 0 and changed); // The typewriter writes in reading order with a caret on its head. const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box }; try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area)); try std.testing.expectEqual( CharSource{ .old = {} }, charSource(typewriter, area.cols - 1, area.rows - 1, area), ); var carets: usize = 0; var row: u16 = 0; while (row < area.rows) : (row += 1) { col = 0; while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) { .byte => |byte| { try std.testing.expectEqual(@as(u8, '_'), byte); carets += 1; }, .old, .at, .churn => {}, }; } try std.testing.expectEqual(@as(usize, 1), carets); } test "opening presets separate geometry and content transitions" { const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 }; try std.testing.expectEqual(target, openingBox(.ascii, target, 80)); try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w); try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x); try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y); try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y); var track: Track = .{ .effect = .slide, .from = target, .to = target }; try std.testing.expect(track.active()); track.frame = track.effect.frames(); try std.testing.expect(!track.active()); track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; try std.testing.expectEqual(target, track.visualBox()); var closing: Track = .{ .phase = .closing, .effect = .vertical, .from = target, .to = closingBox(.vertical, target), }; try std.testing.expectEqual(target, closing.contentBox()); closing.frame = 2; try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); } test "dissolve has exact stable endpoints" { for (0..64) |col| { const x: u16 = @intCast(col); try std.testing.expect(!dissolveRevealed(42, x, 7, 0)); try std.testing.expect(dissolveRevealed(42, x, 7, 1)); if (dissolveRevealed(42, x, 7, 0.25)) try std.testing.expect(dissolveRevealed(42, x, 7, 0.75)); } } pub const Animation = struct { pub const frame_ms: u32 = 16; pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; pub const transition_steps: u16 = 10; pub fn Transition(comptime Value: type) type { return struct { const Self = @This(); from: Value, to: Value, displayed: Value, step: u16 = transition_steps, pub fn init(value: Value) Self { return .{ .from = value, .to = value, .displayed = value }; } pub fn isActive(a: *const Self) bool { return a.step < transition_steps; } pub fn retarget(a: *Self, target: Value) void { a.from = a.displayed; a.to = target; a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; if (a.step == transition_steps) a.displayed = target; } pub fn advance(a: *Self) void { if (!a.isActive()) return; a.step += 1; a.displayed = if (a.step == transition_steps) a.to else Value.interpolate(a.from, a.to, a.step, transition_steps); } pub fn snap(a: *Self, value: Value) void { a.* = init(value); } }; } pub fn Immediate(comptime Value: type) type { return struct { const Self = @This(); displayed: Value, pub fn init(value: Value) Self { return .{ .displayed = value }; } pub fn isActive(_: *const Self) bool { return false; } pub fn retarget(a: *Self, target: Value) void { a.displayed = target; } pub fn advance(_: *Self) void {} pub fn snap(a: *Self, value: Value) void { a.displayed = value; } }; } pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { if (step == 0) return from; if (step >= steps) return to; var out: [3]u8 = undefined; for (&out, from, to) |*dst, a, b| { const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; dst.* = @intCast((numerator + steps / 2) / steps); } return out; } const TestColor = struct { rgb: [3]u8, pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; } }; test "Immediate lands where a completed Transition lands" { const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; var faded = Animation.Transition(TestColor).init(from); faded.retarget(to); for (0..transition_steps) |_| faded.advance(); var instant = Immediate(TestColor).init(from); try std.testing.expect(!instant.isActive()); instant.retarget(to); try std.testing.expectEqual(faded.displayed, instant.displayed); // Never active, so a frontend that renders only while something is animating stops immediately // rather than spending ten frames discovering there is nothing to draw. try std.testing.expect(!instant.isActive()); instant.advance(); try std.testing.expectEqual(to, instant.displayed); instant.snap(from); try std.testing.expectEqual(from, instant.displayed); } test "fixed-step interpolation has exact monotonic endpoints" { const Tween = Animation.Transition(TestColor); const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; var tween = Tween.init(from); tween.retarget(to); try std.testing.expectEqual(from, tween.displayed); var previous = tween.displayed; for (0..transition_steps) |_| { tween.advance(); try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); previous = tween.displayed; } try std.testing.expect(!tween.isActive()); try std.testing.expectEqual(to, tween.displayed); tween.advance(); try std.testing.expectEqual(to, tween.displayed); } test "retarget starts at the currently displayed value" { const Tween = Animation.Transition(TestColor); const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; var tween = Tween.init(first); tween.retarget(second); tween.advance(); tween.advance(); tween.advance(); const on_screen = tween.displayed; tween.retarget(third); try std.testing.expectEqual(on_screen, tween.from); try std.testing.expectEqual(on_screen, tween.displayed); try std.testing.expect(tween.isActive()); for (0..transition_steps) |_| tween.advance(); try std.testing.expectEqual(third, tween.displayed); } }; test "fractional panel inverse preserves compact header rows through slide and zoom" { const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; for ([_]Transition{ .slide, .zoom, .vertical }) |effect| { var presentation: Presentation = .{}; const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } }; presentation.shown_tracks[0] = track; const shown = track.presented(); for (0..4) |index| { const row: u16 = @intCast(index); const source_x: f32 = 5.3; const source_y = (layer.rowTop(row, 20, 10) + 5) / 20; const x = shown.x + (source_x - track.to.x) / track.to.w * shown.w; const y = shown.y + (source_y - track.to.y) / track.to.h * shown.h; const point = presentation.pointerFractional(80, 30, x, y).?; try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001); try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001); const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?; try std.testing.expectEqual(row, body_hit.row); } } } test "presentation snapshots own compact body and tag rows across later edits" { var state: Presentation = .{}; defer state.deinit(std.testing.allocator); var cells: [1]pardes.Cell = .{.{}}; var body: [2]pardes.Cell = .{ .{}, .{} }; body[0].text[0] = 'A'; var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells }; surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body }; var tag: [1]pardes.Cell = .{.{}}; tag[0].text[0] = 'T'; surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag }; state.capturePrevious(std.testing.allocator, &surface); try std.testing.expect(state.previous_valid); tag[0].text[0] = 'U'; try std.testing.expectEqual(@as(u8, 'T'), state.previous_tag_layers[0].cells[0].text[0]); body[0].text[0] = 'B'; surface.body_layers[0].context_rows = 0; try std.testing.expectEqual(@as(u8, 'A'), state.previous_body_layers[0].cells[0].text[0]); try std.testing.expectEqual(@as(u16, 1), state.previous_body_layers[0].context_rows); state.capturePrevious(std.testing.allocator, &surface); try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]); try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]); } test "column reorder moves only crossed state and preserves weights" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 173, .rows = 20 }); defer p.deinit(); p.ncol = 4; p.col_n = @splat(0); for (0..4) |c| { p.col_weight[c] = c * 13 + 7; p.col_serial[c] = @intCast(c + 10); p.col_panes[c][0] = c; p.col_tags[c].saved_col = @intCast(c + 20); } compute(p); const original_weights = p.col_weight; const outer_x = p.col_x[0]; const outer_w = p.col_w[0]; p.header_column = 1; p.topbar_col = 23; p.header_scroll = 9; reorderColumn(p, 1, 3); try std.testing.expectEqual(original_weights[1], p.col_weight[3]); try std.testing.expectEqual(original_weights[2], p.col_weight[1]); try std.testing.expectEqual(@as(u32, 11), p.col_serial[3]); try std.testing.expectEqual(@as(?usize, 3), p.header_column); try std.testing.expectEqual(@as(?u16, 23), p.topbar_col); try std.testing.expectEqual(@as(usize, 9), p.header_scroll); try std.testing.expectEqual(outer_x, p.col_x[0]); try std.testing.expectEqual(outer_w, p.col_w[0]); reorderColumn(p, 3, 1); try std.testing.expectEqualSlices(u64, original_weights[0..4], p.col_weight[0..4]); try std.testing.expectEqual(@as(?u16, 21), p.col_tags[1].saved_col); }