summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Messages.zig3
-rw-r--r--src/Presentation.zig721
-rw-r--r--src/animation.zig744
-rw-r--r--src/body_layer.zig449
-rw-r--r--src/colors.zig9
-rw-r--r--src/config.zig29
-rw-r--r--src/draw.zig913
-rw-r--r--src/gui/gui.zig66
-rw-r--r--src/layout.zig1439
-rw-r--r--src/look.zig5
-rw-r--r--src/macos.zig45
-rw-r--r--src/mouse.zig34
-rw-r--r--src/ninep/screen.zig2
-rw-r--r--src/pardes.zig473
-rw-r--r--src/surface.zig7
-rw-r--r--src/tty/panel_compositor.zig27
-rw-r--r--src/tty/tty.zig4
17 files changed, 2528 insertions, 2442 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index 72b3b63c..97a2f939 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -5,6 +5,7 @@ const pardes = @import("pardes.zig");
const memory = @import("memory.zig");
const std = @import("std");
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const ctlfs = @import("ninep/tree.zig");
const panes = @import("panes.zig");
const CellStyle = @import("surface.zig").CellStyle;
@@ -285,7 +286,7 @@ pub const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 };
/// Animation frames a message timing lasts; at least one.
pub fn messageFrames(ms: u16) u16 {
- return @intCast(@max(1, (@as(u32, ms) + layout.Animation.frame_ms - 1) / layout.Animation.frame_ms));
+ return @intCast(@max(1, (@as(u32, ms) + animation.Animation.frame_ms - 1) / animation.Animation.frame_ms));
}
pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion {
diff --git a/src/Presentation.zig b/src/Presentation.zig
new file mode 100644
index 00000000..fe8317dd
--- /dev/null
+++ b/src/Presentation.zig
@@ -0,0 +1,721 @@
+//! Panel presentation: which pane geometry the shells have been shown, the
+//! transition tracks that move it there, and the frozen previous grid the
+//! character and dissolve effects read. Only acknowledged draws advance the
+//! shown geometry; committed and submitted may be ahead of it.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const layout = @import("layout.zig");
+const animation = @import("animation.zig");
+const Pardes = pardes.Pardes;
+const MAX_PANES = pardes.MAX_PANES;
+const Box = animation.Box;
+const Track = animation.Track;
+const Phase = animation.Phase;
+const Transition = animation.Transition;
+const CellArea = animation.CellArea;
+const CharSource = animation.CharSource;
+const charSource = animation.charSource;
+const dissolveRevealed = animation.dissolveRevealed;
+const openingBox = animation.openingBox;
+const closingBox = animation.closingBox;
+const paintOrder = animation.paintOrder;
+const panelBox = layout.panelBox;
+
+const Presentation = @This();
+
+pub const Snapshot = struct { serial: u32, box: Box };
+
+// 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).?);
+}
+
+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]);
+}
diff --git a/src/animation.zig b/src/animation.zig
new file mode 100644
index 00000000..b190c1a1
--- /dev/null
+++ b/src/animation.zig
@@ -0,0 +1,744 @@
+//! Animation curves and the panel transition records every shell reads:
+//! easing presets, transition kinds and their tracks, the fractional boxes
+//! they move through, the character effects' per-cell sources, and the
+//! generic displayed-value Transition the chrome colours use.
+const std = @import("std");
+
+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);
+ }
+};
diff --git a/src/body_layer.zig b/src/body_layer.zig
index f9e26b10..aa52957c 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -555,452 +555,3 @@ pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap);
return panes.Terminal.bodyText(arena, pane);
}
-
-/// The workspace and column tags as the grid shows them: their text, the
-/// word under the pointer and, in the one being edited, its selection and
-/// caret. Their tag layers are drawn by renderTagLayers.
-pub fn paintHeaders(p: *Pardes) !void {
- const s = &p.surface;
- const chrome = p.chromeTheme();
- const th = p.theme();
- // global tagbar: full width, top rows — unless a native shell has
- // taken its commands over, in which case the rows are not ours to draw
- tagline.revealHeader(p);
- const global = tagline.topbar(p);
- const global_rows = p.topBarHeight();
- const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0;
- const global_top = if (p.header_focus and p.header_column == null) p.header_top else 0;
- if (p.settings.workspace_tag) {
- s.fill(0, 0, s.cols, global_rows, .{
- .bg = .{ .rgb = chrome.tag_bg },
- .font_role = .tagline,
- });
- for (0..global_rows) |row| {
- const line = modal.lineSlice(global, row + global_top);
- _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{
- .fg = .{ .rgb = chrome.tag_fg },
- .bg = .{ .rgb = chrome.tag_bg },
- .font_role = .tagline,
- });
- }
- }
- if (p.pointer_inside and p.hover_row < global_rows) {
- const bar = modal.lineSlice(global, p.hover_row + global_top);
- if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| {
- var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll;
- const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll;
- while (col < hi and col < s.cols) : (col += 1) {
- const cell = s.at(@intCast(col), p.hover_row);
- cell.default = false;
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
- }
- }
- }
- const column_rows = p.columnBarHeight();
- if (column_rows > 0) for (0..p.ncol) |c| {
- const grip = @min(config.GUTTER, p.col_w[c]);
- const inset = @min(TAG_TEXT_INSET, p.col_w[c]);
- const x = p.col_x[c] + inset;
- const w = p.col_w[c] - inset;
- const y0 = global_rows;
- if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue;
- const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false;
- const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
- const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg;
- // A grip is muted until it is grabbed; then it lights, as a pane's
- // box does for the active pane, so the drag shows what it holds.
- const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c;
- const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim;
- const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10;
- const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 };
- const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline };
- s.fill(p.col_x[c], y0, grip, column_rows, grip_style);
- s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
-
- s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
- const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0;
- const bar_top = if (p.header_focus and p.header_column == c) p.header_top else 0;
- const tag = tagline.columnTag(p, c);
- for (0..column_rows) |row| {
- const line = modal.lineSlice(tag, row + bar_top);
- _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{
- .fg = .{ .rgb = fg },
- .bg = .{ .rgb = bg },
- .font_role = .tagline,
- });
- }
- // A carried column's pointer is aiming at a place, not a word.
- if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) {
- const bar = modal.lineSlice(tag, p.hover_row - y0 + bar_top);
- if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| {
- var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll;
- const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll;
- while (col < hi and col < w) : (col += 1) {
- const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row);
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
- }
- }
- }
- };
- if (tagline.focusedHeader(p)) |t| {
- const text = try edit.flatSurface(p, t);
- const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0;
- const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols;
- const y: u16 = if (p.header_column != null) global_rows else 0;
- const rows = if (p.header_column != null) column_rows else global_rows;
- for (0..rows) |row| {
- const bar = modal.lineSlice(text, row + p.header_top);
- const span = tagline.headerSelection(t, text, row + p.header_top) orelse continue;
- var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll;
- const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll;
- while (col < hi and col < w) : (col += 1) {
- const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row)));
- cell.style.fg = .{ .rgb = th.sel_fg };
- cell.style.bg = .{ .rgb = th.sel_bg };
- }
- }
- // A prompt answering for the header has the caret on its band.
- const asking = if (p.panes[p.active]) |active| active.prompt != .none else false;
- const cur: usize = @intCast(@max(0, t.cur_row));
- if (!asking and cur >= p.header_top and cur < p.header_top + rows) {
- const bar = modal.lineSlice(text, cur);
- const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll;
- if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert };
- }
- }
-}
-
-fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } {
- if (pane.isTerminal()) {
- var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n");
- while (words.next()) |word| {
- if (!std.mem.eql(u8, word, "Tty")) continue;
- const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr);
- return .{ .lo = lo, .hi = lo + word.len };
- }
- return null;
- }
- var start: usize = 0;
- const name = if (pane.file) |file|
- if (pane.prompt == .name) pane.input.own orelse file.path else file.path
- else if (pane.image) |state| blk: {
- // Image tags put renderer controls before the path.
- start = std.mem.lastIndexOf(u8, tag[0..prefix_len], state.path) orelse return null;
- break :blk state.path;
- } else if (comptime pdf_enabled) blk: {
- break :blk if (pane.pdf) |state| state.path else return null;
- } else return null;
- const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0;
- if (basename == name.len) return null;
- return .{ .lo = start + basename, .hi = start + name.len };
-}
-
-pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void {
- const chrome = p.chromeTheme();
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- const r = p.rects[id];
- if (r.w <= TAG_TEXT_INSET or r.h == 0) continue;
- const tag = try tagline.tagText(p, arena, pane);
- const rows = pane.tag_rows;
- // As wide as the widest line it shows, and never narrower than
- // its viewport.
- var widest: usize = 0;
- var split = std.mem.splitScalar(u8, tag, '\n');
- for (0..pane.tag_top) |_| _ = split.next();
- for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse ""));
- const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1)));
- const pitch = @as(usize, columns) + TAG_TEXT_INSET;
- const storage = try arena.alloc(Cell, pitch * rows);
- @memset(storage, .{});
- var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage };
- std.mem.swap(Surface, &p.surface, &temporary);
- paintPaneTag(p, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| {
- std.mem.swap(Surface, &p.surface, &temporary);
- return err;
- };
- std.mem.swap(Surface, &p.surface, &temporary);
- // A layer to a line: the first in the pane's own slot, the rest
- // in the slots kept for the lines of taller tags.
- for (0..rows) |line| {
- const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1];
- if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns);
- @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]);
- layer.kind = .pane;
- layer.id = @intCast(id);
- layer.serial = pane.serial;
- layer.line = @intCast(line);
- layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H };
- layer.cols = columns;
- layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
- layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor|
- (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null)
- else
- null;
- }
- }
- // With the workspace tag row handed to native chrome there is no
- // workspace layer to render; the menu bar carries its commands.
- if (p.settings.workspace_tag) {
- for (0..p.topBarHeight()) |line| {
- const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1;
- const top = if (p.header_focus and p.header_column == null) p.header_top else 0;
- try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg);
- }
- }
- // Each notice is its own tagline-shaped chip over the top of the body.
- // Going through the same layer machinery the pane and column tags use
- // is what gives it the tagline's height, its small-font metrics, the
- // band offset and the border -- none of which a body-grid row can have
- // just by wearing a tagline font. The geometry is the one the grid
- // pass already chose and recorded, so the two cannot disagree and the
- // GUI's "skip the cells a tag layer covers" leaves no stub behind.
- // A prompt is one of them too: left on the grid, a pixel shell drew
- // its tagline glyphs one to a BODY cell, spaced out like a banner.
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- const r = p.rects[id];
- if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue;
- const first = p.bodyTop(pane, r);
- var leader_buf: [16]u8 = undefined;
- for (pane.notices.slice(), 0..) |kind, i| {
- const text = Messages.noticeText(p, pane, i, &leader_buf);
- if (text.len == 0) continue;
- const cx = pane.notices.left[i];
- if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue;
- const chip = r.x + r.w - cx;
- // Right aligned inside the chip, a blank cell short of its
- // edge: the same one the grid pass leaves for a prompt caret.
- // Wider than the chip, the text loses its head, as on the grid.
- const room = p.tagCapacity(chip) -| 1;
- const shown = panes.File.displayWidth(text);
- var kept = text;
- if (shown > room) {
- var start = panes.File.rawAtDisplay(text, shown - room);
- if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1);
- kept = text[start..];
- }
- const pad = room -| panes.File.displayWidth(kept);
- const line = try arena.alloc(u8, pad + kept.len);
- @memset(line[0..pad], ' ');
- @memcpy(line[pad..], kept);
- // A pixel shell slides the message in as it fades up, and
- // fades it on the way out.
- const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{};
- const fade = motion.alpha;
- const page = p.theme().bg orelse chrome.tag_bg;
- const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i;
- try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{
- .x = cx,
- .y = first + @as(u16, pane.notices.row[i]),
- .w = chip,
- .h = BOX_H,
- }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade));
- p.surface.tag_layers[layer_index].slide = motion.slide;
- p.surface.tag_layers[layer_index].fade = fade;
- // The caret rides the band, where the prompt's glyphs are,
- // at the same place in the text the grid pass puts it.
- if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue;
- const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
- const dropped = shown - panes.File.displayWidth(kept);
- if (typed < dropped) continue;
- p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert };
- }
- }
- if (p.columnBarHeight() > 0) for (0..p.ncol) |column| {
- if (p.col_w[column] == 0) continue;
- const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false;
- const top = if (p.header_focus and p.header_column == column) p.header_top else 0;
- for (0..p.columnBarHeight()) |line| {
- const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1;
- try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg);
- }
- };
-}
-
-/// One row of a header or a notice band as a tag layer: `text` is line
-/// `line` of what it shows.
-fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void {
- _ = arena;
- const layer = &p.surface.tag_layers[index];
- if (viewport.w == 0) {
- layer.cols = 0;
- return;
- }
- const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1)));
- if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns);
- layer.kind = kind;
- layer.id = id;
- layer.line = line;
- layer.serial = if (kind == .column) p.col_serial[id] else 0;
- layer.viewport = viewport;
- layer.cols = columns;
- layer.bg = bg;
- layer.cursor = null;
- layer.slide = 0;
- layer.fade = 1;
- var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells };
- // A notice band carries a PANE id, not a column index, so it must not
- // answer to the column being edited -- that would scroll and select
- // its text with the column tag's offsets and give it a second caret.
- const editing: ?*Text = if (p.header_focus and switch (kind) {
- .workspace => p.header_column == null,
- .column => p.header_column == id,
- .pane, .notice => false,
- }) tagline.focusedHeader(p) else null;
- const scroll = if (editing != null) p.header_scroll else 0;
- surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
- _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline });
- if (editing) |t| {
- if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| {
- var col = panes.File.rawDisplayCol(text, span.lo) -| scroll;
- const end = panes.File.rawDisplayCol(text, span.hi) -| scroll;
- while (col < end and col < columns) : (col += 1) {
- surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg };
- surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg };
- }
- }
- const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll;
- const asking = if (p.panes[p.active]) |active| active.prompt != .none else false;
- if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert };
- } else if (p.pointer_inside) {
- const tag_hit = p.pointer_tag_hit;
- const col: ?usize = if (tag_hit) |pointer|
- (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null)
- else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w)
- p.hover_col - viewport.x
- else
- null;
- if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| {
- var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll;
- const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll;
- while (cell < end and cell < columns) : (cell += 1) {
- surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg };
- surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg };
- }
- };
- }
-}
-
-pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void {
- const s = &p.surface;
- const th = p.theme();
- const chrome = p.chromeTheme();
- const tx = r.x + @min(TAG_TEXT_INSET, r.w);
- const tw = r.w -| TAG_TEXT_INSET;
- const tag_y = p.tagTop(pane, r);
- const body_y = p.bodyTop(pane, r);
- const rows = pane.tag_rows;
- const page_rgb = th.bg orelse th.tag_bg;
- // Focus is visible along the tag without changing its live text grid.
- const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
- const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg;
- const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline };
- // Padding belongs to the same font row but keeps its default ink: a
- // role must not manufacture a foreground color for blank cells.
- s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline });
- s.fill(tx, tag_y, tw, rows, .{
- .bg = .{ .rgb = tag_bg },
- .font_role = .tagline,
- });
- const whole = try tagline.tagText(p, arena, pane);
- const tail = tagline.curTail(pane);
- const prefix_len = whole.len - tail.len;
- // A row per line shown, from `tag_top`: the tag's first line carries
- // the prefix, the rest are the tag's own text alone.
- const top = pane.tag_top;
- var lines: [MAX_TAG_ROWS][]const u8 = @splat("");
- var split = std.mem.splitScalar(u8, whole, '\n');
- for (0..top) |_| _ = split.next();
- for (lines[0..rows]) |*line| line.* = split.next() orelse "";
- // The keyboard's caret, as a row and a byte offset in that row's
- // line: in the tag's own text when the tag has the keyboard, in the
- // path while a name is drafted.
- const Caret = struct { row: usize, at: usize };
- const caret: ?Caret = if (!active)
- null
- else if (pane.prompt == .name)
- .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) }
- else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: {
- const row: usize = @intCast(pane.tag.cur_row - top);
- break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) };
- } else null;
- if (caret) |at| {
- const line = lines[at.row];
- const col = panes.File.rawDisplayCol(line, at.at);
- var scroll: usize = pane.tag_scroll;
- if (col < scroll) scroll = col;
- if (col >= scroll + capacity) scroll = col - capacity + 1;
- var raw = panes.File.rawAtDisplay(line, scroll);
- scroll = panes.File.rawDisplayCol(line, raw);
- if (col >= scroll + capacity) {
- raw = modal.nextGrapheme(line, raw);
- scroll = panes.File.rawDisplayCol(line, raw);
- }
- pane.tag_scroll = @intCast(scroll);
- } else if (pane.focus != .tag) pane.tag_scroll = 0;
- for (lines[0..rows], 0..) |line, row| {
- const start = panes.File.rawAtDisplay(line, pane.tag_scroll);
- _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style);
- }
- const tag = lines[0];
- if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| {
- const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll));
- const hi = @min(name.hi, tag.len);
- if (lo < hi) {
- var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll;
- const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll;
- const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg;
- while (col < end and col < tw) : (col += 1)
- s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg };
- }
- };
- if (p.look_hover_preview) |preview| {
- if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) {
- const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg));
- paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
- };
- }
- // The tag's own selections, while it has the keyboard, a line at a
- // time. A range of one grapheme is the block cursor, not a selection.
- if (pane.focus == .tag) {
- var ranges: [Text.max_selections]modal.Selection = undefined;
- const got = pane.tag.ranges(whole, 0, &ranges);
- for (ranges[0..got.n]) |range| {
- const lo = @min(range.anchor, range.head);
- const hi = @max(range.anchor, range.head);
- if (modal.nextGrapheme(whole, lo) >= hi) continue;
- const first = modal.positionAt(whole, lo);
- const last = modal.positionAt(whole, hi);
- var row = @max(first.row, top);
- while (row <= last.row and row < top + rows) : (row += 1) {
- const line = lines[row - top];
- const from = if (row == first.row) first.col else 0;
- const to = @min(line.len, if (row == last.row) last.col else line.len);
- var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll;
- const end = panes.File.rawDisplayCol(line, to);
- const y = tag_y + @as(u16, @intCast(row - top));
- while (col + pane.tag_scroll < end and col < tw) : (col += 1) {
- const cell = s.at(tx + @as(u16, @intCast(col)), y);
- cell.default = false;
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
- }
- }
- }
- }
- // the caret: bar while typing, block in normal mode (same rule as a body)
- if (caret) |at| {
- const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll;
- const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode;
- if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert };
- }
-
- for (pane.sel, 0..) |selection, button| {
- if (selection.state == .none or (selection.state == .done and button != 0)) continue;
- if (@min(selection.r0, selection.r1) >= rows) continue;
- paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg);
- }
-}
diff --git a/src/colors.zig b/src/colors.zig
index bb366fc0..f814d55d 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -5,6 +5,7 @@ const tagline = @import("tagline.zig");
const panes = @import("panes.zig");
const std = @import("std");
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const Color = @import("surface.zig").Color;
const FontRole = @import("surface.zig").FontRole;
const config = @import("config.zig");
@@ -185,15 +186,15 @@ pub const ChromeTheme = struct {
pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme {
var out: ChromeTheme = undefined;
inline for (@typeInfo(ChromeTheme).@"struct".fields) |field|
- @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps);
+ @field(out, field.name) = animation.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps);
return out;
}
};
pub const ChromeAnimation = if (theme_animation)
- layout.Animation.Transition(ChromeTheme)
+ animation.Animation.Transition(ChromeTheme)
else
- layout.Animation.Immediate(ChromeTheme);
+ animation.Animation.Immediate(ChromeTheme);
pub const initial_chrome = ChromeTheme.fromTheme(&themes[0]);
@@ -413,7 +414,7 @@ test "runtime theme changes animate chrome and retarget without a jump" {
try std.testing.expectEqualStrings("acme", p.theme().name);
try std.testing.expectEqual(midflight, p.chromeTheme().*);
try std.testing.expect(p.animationActive());
- for (0..layout.Animation.transition_steps) |_| p.update(.tick);
+ for (0..animation.Animation.transition_steps) |_| p.update(.tick);
try std.testing.expect(!p.animationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
// Extra ticks are inert at the exact endpoint.
diff --git a/src/config.zig b/src/config.zig
index 6d0cf0a6..122c0e75 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const builtin = @import("builtin");
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const limits = @import("memory.zig").limits;
const pardes = @import("pardes.zig");
const builtins = @import("builtins.zig");
@@ -609,10 +610,10 @@ pub const Runtime = struct {
tagline_percent: u8 = 100,
} = .{},
- panel_transition: layout.Transition = .off,
- scene_effects: layout.SceneEffect = .{},
+ panel_transition: animation.Transition = .off,
+ scene_effects: animation.SceneEffect = .{},
- pub fn toggleTransition(state: *Runtime, effect: layout.Transition) void {
+ pub fn toggleTransition(state: *Runtime, effect: animation.Transition) void {
std.debug.assert(effect != .off);
state.panel_transition = if (state.panel_transition == effect) .off else effect;
}
@@ -689,7 +690,7 @@ pub const Runtime = struct {
pub const MessageTime = enum { linger, fall, dissolve };
pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures };
- pub const Scene = std.meta.FieldEnum(layout.SceneEffect);
+ pub const Scene = std.meta.FieldEnum(animation.SceneEffect);
pub const Action = union(enum) {
toggle: Toggle,
@@ -701,7 +702,7 @@ pub const Runtime = struct {
window_blur,
message_ms: MessageTime,
dump_dir,
- transition: layout.Transition,
+ transition: animation.Transition,
scene: Scene,
};
@@ -806,7 +807,7 @@ pub const Runtime = struct {
if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or
actionCount(.tagline_size) != 1 or actionCount(.window_opacity) != 1 or actionCount(.window_blur) != 1)
@compileError("Shell, Theme, Font, TaglineSize, WindowOpacity, and WindowBlur actions must each occur exactly once");
- for (std.enums.values(layout.Transition)) |effect| {
+ for (std.enums.values(animation.Transition)) |effect| {
const expected: comptime_int = @intFromBool(effect != .off);
if (actionCount(.{ .transition = effect }) != expected)
@compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect));
@@ -814,7 +815,7 @@ pub const Runtime = struct {
for (std.enums.values(Scene)) |effect| {
if (actionCount(.{ .scene = effect }) != 1)
@compileError("scene effect must occur exactly once: " ++ @tagName(effect));
- if (@FieldType(layout.SceneEffect, @tagName(effect)) != bool)
+ if (@FieldType(animation.SceneEffect, @tagName(effect)) != bool)
@compileError("scene effect fields must be booleans: " ++ @tagName(effect));
}
}
@@ -929,7 +930,7 @@ pub const Runtime = struct {
return if (text.len == 0) "(none)" else text;
}
- fn transitionSettingName(transition: layout.Transition) []const u8 {
+ fn transitionSettingName(transition: animation.Transition) []const u8 {
if (transition == .off) return "off";
return findAction(.{ .transition = transition }).?.word;
}
@@ -1080,13 +1081,13 @@ pub const Runtime = struct {
const slide = findAction(.{ .transition = .slide }).?;
const zoom = findAction(.{ .transition = .zoom }).?;
try std.testing.expect(apply(&state, slide, "on"));
- try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.slide, state.panel_transition);
try std.testing.expect(apply(&state, zoom, "off"));
- try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.slide, state.panel_transition);
try std.testing.expect(apply(&state, slide, "on"));
- try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.slide, state.panel_transition);
try std.testing.expect(apply(&state, slide, null));
- try std.testing.expectEqual(layout.Transition.off, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.off, state.panel_transition);
try std.testing.expect(!apply(&state, slide, "yes"));
const scene = findAction(.{ .scene = std.enums.values(Scene)[0] }).?;
try std.testing.expect(apply(&state, scene, "on"));
@@ -1126,9 +1127,9 @@ pub const Runtime = struct {
try std.testing.expectEqualStrings("fish", state.shell.requested.get());
try std.testing.expect(state.shell.pending);
try std.testing.expect(apply(&state, find("PanelAscii").?, null));
- try std.testing.expectEqual(layout.Transition.ascii, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.ascii, state.panel_transition);
try std.testing.expect(apply(&state, find("PanelAscii").?, null));
- try std.testing.expectEqual(layout.Transition.off, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.off, state.panel_transition);
try std.testing.expect(apply(&state, find("Crt").?, null));
try std.testing.expect(state.scene_effects.crt);
}
diff --git a/src/draw.zig b/src/draw.zig
new file mode 100644
index 00000000..5db6da30
--- /dev/null
+++ b/src/draw.zig
@@ -0,0 +1,913 @@
+//! The frame: how the core builds the canonical Surface every shell draws.
+//! `render` is the whole order, top to bottom: the page, each pane (its
+//! grip, tag and body, and the body layer of its compact rows), the notice
+//! chips, the workspace and column tags, the tag layers, drag overlays, the
+//! debug box, then the presentation's tracks and the character effects.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const layout = @import("layout.zig");
+const animation = @import("animation.zig");
+const config = @import("config.zig");
+const panes = @import("panes.zig");
+const modal = @import("modal.zig");
+const tracy = @import("tracy.zig");
+const colors = @import("colors.zig");
+const edit = @import("edit.zig");
+const mouse = @import("mouse.zig");
+const tagline = @import("tagline.zig");
+const body_layer = @import("body_layer.zig");
+const Messages = @import("Messages.zig");
+const Color = @import("surface.zig").Color;
+const CellStyle = @import("surface.zig").CellStyle;
+const Cell = @import("surface.zig").Cell;
+const Surface = @import("surface.zig").Surface;
+const Pardes = pardes.Pardes;
+const Pane = panes.Pane;
+const Text = panes.Text;
+const Rect = layout.Rect;
+const TagKind = @import("tag_layer.zig").Kind;
+const pdf_enabled = panes.Pdf.enabled;
+const wordBoundsAtCol = pardes.wordBoundsAtCol;
+const BOX_H = pardes.BOX_H;
+const TAG_GAP = pardes.TAG_GAP;
+const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
+const TOPBAR_H = pardes.TOPBAR_H;
+const COLBAR_H = pardes.COLBAR_H;
+const MAX_PANES = pardes.MAX_PANES;
+const MAX_COLS = pardes.MAX_COLS;
+const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS;
+const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE;
+const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE;
+const HEADER_LINE_LAYER_BASE = pardes.HEADER_LINE_LAYER_BASE;
+
+pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
+ panes.File.refreshHighlights(p);
+ const s = &p.surface;
+ const ncells = @as(usize, p.screen_w) * p.screen_h;
+ if (s.cells.len != ncells) {
+ const cells = try p.gpa.alloc(Cell, ncells);
+ p.gpa.free(s.cells);
+ s.cells = cells;
+ }
+ s.cols = p.screen_w;
+ s.rows = p.screen_h;
+ s.cursor = null;
+ s.pointer_shape = .arrow;
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| {
+ if (preview.probe.link != null and preview.pane < p.panes.len) {
+ if (p.panes[preview.pane]) |pane| {
+ if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane))
+ s.pointer_shape = .link;
+ }
+ }
+ };
+ // A look-hover preview means the pointed word is a real Look or Exec
+ // operand: the same plumbing a click would act on. Hosts that model
+ // the pointer per-text (the macOS I-beam) read this as "target" and
+ // afford it the arrow instead.
+ if (p.look_hover_preview != null) s.pointer_shape = .target;
+ // Stale hover bits must not survive into a frame whose pointer has
+ // moved on: the ordinary content paint does not clear fields it
+ // never set.
+ for (s.cells) |*c| c.hover = false;
+ for (&s.body_layers) |*layer| layer.rows = 0;
+ for (&s.tag_layers) |*layer| layer.cols = 0;
+ s.nimages = 0;
+ s.npanel_tracks = 0;
+ s.previous_cells = &.{};
+ s.previous_body_layers = &.{};
+ s.previous_tag_layers = &.{};
+ s.cell_diffs = &.{};
+ const chrome = p.chromeTheme();
+ const th = p.theme();
+ // Separators have their own role; a scrollbar need not frame the page.
+ s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } });
+
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ pane.body_rows = 0;
+ pane.body_visible_rows = 0;
+ pane.context_row_limit = null;
+ s.body_layers[id].rows = 0;
+ Messages.collectNotices(p, pane, p.rects[id], id);
+ try renderPane(p, arena, pane, p.rects[id], id, id == p.active);
+ try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id);
+ }
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ if (pane.notices.len == 0) continue;
+ const r = p.rects[id];
+ const tx = r.x + config.GUTTER;
+ const tw = r.w - config.GUTTER;
+ // Painted the way the sticky context headers above them are: the
+ // tagline font and the tagline's own colours, so a notice reads as
+ // a band belonging to the pane rather than as a line of body text
+ // that happens to sit at the bottom of it.
+ const msg_style: CellStyle = .{
+ .fg = .{ .rgb = chrome.tag_fg },
+ .bg = .{ .rgb = chrome.tag_bg },
+ .font_role = .tagline,
+ };
+ // An OVERLAY over the body's top rows, at the right edge, one
+ // chip to a row in the order collectNotices chose -- not a row
+ // taken out of the body the way a tree-sitter context header is.
+ // The chip is as wide as its own message and no wider, so the text
+ // beneath the rest of the row keeps reading as the file it is.
+ const first = p.bodyTop(pane, r);
+ var leader_buf: [16]u8 = undefined;
+ for (pane.notices.slice(), 0..) |kind, i| {
+ const row = first + @as(u16, pane.notices.row[i]);
+ // Every notice is painted on the canonical grid as well as
+ // emitted as a tag layer: the grid is what a terminal client
+ // draws, and a band it cannot see is a message it never gets.
+ // The GUI skips grid cells a tag layer covers, so nothing is
+ // drawn twice there.
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
+ const chip = Messages.noticeCols(p, text, tw);
+ if (chip == 0) continue;
+ const cx = tx + tw - chip;
+ pane.notices.left[i] = cx;
+ // A message coming or going blends toward the page it sits
+ // on; a grid cannot move it by less than a row.
+ var style = msg_style;
+ if (Messages.noticeLife(pane, i)) |life| {
+ // No page colour to fade to (a terminal's own default):
+ // the text dissolves into its chip instead.
+ const alpha = Messages.messageMotion(p, life).alpha;
+ const page = th.bg orelse chrome.tag_bg;
+ style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) };
+ style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) };
+ }
+ s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline });
+ // One column short of the chip's right edge: a prompt's caret
+ // sits AFTER its last character the moment it opens, and flush
+ // right would put it past the pane, off the grid, and past what
+ // the detached wire will encode -- which drops every frame for
+ // as long as the prompt is up.
+ const printed = Messages.printRight(s, cx, row, chip -| 1, text, style);
+ if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue;
+ // The cursor follows the text to wherever it landed; a caret
+ // in the part a narrow band dropped has nowhere to be.
+ const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
+ if (prompt_col < printed.dropped) continue;
+ const caret = printed.left + (prompt_col - printed.dropped);
+ if (caret < tx + tw)
+ s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert };
+ }
+ }
+
+ try paintHeaders(p);
+ try renderTagLayers(p, arena);
+
+ switch (p.drag) {
+ .border_v => |d| {
+ if (d.cur_x < s.cols) {
+ var row: u16 = p.topBarHeight();
+ while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
+ }
+ if (d.corner) |k| if (d.cur_y < s.rows) {
+ var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1;
+ const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1;
+ while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ };
+ },
+ .border_h => |d| if (d.cur_y < s.rows) {
+ var col = p.col_x[d.col];
+ while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ },
+ .column_move => |d| rail: {
+ const source = layout.columnBySerial(p, d.serial) orelse break :rail;
+ // Where the column's left edge would land, reordered or not.
+ const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) {
+ .stay => break :rail,
+ .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target],
+ .edge => |x| x,
+ };
+ // Leave the outside insertion rail visible above compact
+ // text layers. This clips presentation only, never reflows.
+ if (edge == s.cols) {
+ for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) {
+ layer.viewport.w -|= 1;
+ };
+ for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) {
+ layer.viewport.w -|= 1;
+ };
+ }
+ // The rail runs in the seam cell left of the edge, where a
+ // border drag's does: the edge cell itself is a gutter, which
+ // the GUI paints as a scroll rail that swallowed the dashes.
+ // At the window's left edge there is no seam, so it runs just
+ // past that scroll rail instead. It takes the lit grip's
+ // color: this is where the grabbed column lands.
+ const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1);
+ var row: u16 = p.topBarHeight();
+ while (row < s.rows) : (row += 1) {
+ // Past the left edge's scroll rail the rail would cut
+ // through column 0's grip and pane boxes: leave them whole.
+ if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue;
+ s.overlayDash(x, row, "╎");
+ s.at(x, row).style.fg = .{ .rgb = chrome.column_box };
+ }
+ },
+ .move => |d| if (d.cur_x < s.cols) {
+ if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| {
+ var col: u16 = p.col_x[placement.preview_col];
+ while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
+ s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } });
+ }
+ s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
+ }
+ },
+ .none, .select => if (p.pointer_inside) {
+ // hover hints on a pane's own trailing edge
+ var c: usize = 0;
+ while (c + 1 < p.ncol) : (c += 1) {
+ if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) {
+ var row: u16 = p.topBarHeight();
+ while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎");
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
+ // the same seam the h-handle hit test picks (see there)
+ if (mouse.seamIdxAt(p, cc, p.hover_row) != null) {
+ var col = p.col_x[cc];
+ while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
+ }
+ }
+ for (0..p.ncol -| 1) |cn| {
+ if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue;
+ if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue;
+ if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue;
+ var col = p.col_x[cn + 1];
+ while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
+ }
+ },
+ }
+
+ // debug overlay: a small stats box drawn last, top-right ("Debug")
+ if (p.settings.debug) if (p.panes[p.active]) |at| {
+ var nlive: usize = 0;
+ for (p.panes) |slot| {
+ if (slot != null) nlive += 1;
+ }
+ var sb_off: usize = undefined;
+ var sb_total: usize = undefined;
+ if (at.file) |*f| {
+ sb_off = f.scroll;
+ sb_total = panes.File.nlines(p.gpa, f);
+ } else if (at.pdfPage()) |page| {
+ sb_off = page;
+ sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0;
+ } else {
+ const sb = panes.Terminal.scrollbar(at);
+ sb_off = sb.offset;
+ sb_total = sb.total;
+ }
+ const effective_font = p.settings.font.effective_name.get();
+ const font_name = if (effective_font.len == 0) "default" else effective_font;
+ var ov_buf: [256]u8 = undefined;
+ const text = std.fmt.bufPrint(
+ &ov_buf,
+ "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
+ .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
+ ) catch "";
+ // wide enough for the longest theme name compiled in, which is the
+ // only row here whose width is not ours to choose
+ const ow: u16 = 38;
+ const oh: u16 = 10;
+ const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
+ if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) {
+ // vaxis single_rounded border ring, default style
+ s.set(ox, p.topBarHeight(), "╭", .{});
+ s.set(ox + ow - 1, p.topBarHeight(), "╮", .{});
+ s.set(ox, p.topBarHeight() + oh - 1, "╰", .{});
+ s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{});
+ var bx = ox + 1;
+ while (bx < ox + ow - 1) : (bx += 1) {
+ s.set(bx, p.topBarHeight(), "─", .{});
+ s.set(bx, p.topBarHeight() + oh - 1, "─", .{});
+ }
+ var by = p.topBarHeight() + 1;
+ while (by < p.topBarHeight() + oh - 1) : (by += 1) {
+ s.set(ox, by, "│", .{});
+ s.set(ox + ow - 1, by, "│", .{});
+ }
+ s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ var lines = std.mem.splitScalar(u8, text, '\n');
+ var ly: u16 = p.topBarHeight() + 1;
+ while (lines.next()) |line| : (ly += 1) {
+ if (ly >= p.topBarHeight() + oh - 1) break;
+ _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } });
+ }
+ }
+ };
+ try p.presentation.submit(p, s);
+ return composeAsciiTransitions(p, arena, s);
+}
+
+pub fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface {
+ _ = p;
+ if (!canonical.hasPanelDiff()) return canonical;
+ var presented: ?*Surface = null;
+ for (canonical.panelTracks()) |track| {
+ if (!track.effect.composedByCore() or track.phase == .closing) continue;
+ const area = animation.CellArea.of(track.to);
+ const col_end = @min(canonical.cols, area.x0 +| area.cols);
+ const row_end = @min(canonical.rows, area.y0 +| area.rows);
+ var row: u16 = area.y0;
+ while (row < row_end) : (row += 1) {
+ var col: u16 = area.x0;
+ while (col < col_end) : (col += 1) {
+ const index = @as(usize, row) * canonical.cols + col;
+ const composed = composedCell(canonical, track, area, col, row, index) orelse
+ continue;
+ if (composed.visuallyEqual(&canonical.cells[index])) continue;
+ if (presented == null) {
+ const next = try arena.create(Surface);
+ next.* = canonical.*;
+ next.cells = try arena.dupe(Cell, canonical.cells);
+ presented = next;
+ }
+ presented.?.cells[index] = composed;
+ }
+ }
+ }
+ return presented orelse canonical;
+}
+
+fn composedCell(
+ canonical: *const Surface,
+ track: animation.Track,
+ area: animation.CellArea,
+ col: u16,
+ row: u16,
+ index: usize,
+) ?Cell {
+ if (track.effect == .ascii) {
+ const diff = switch (canonical.cell_diffs[index]) {
+ .ascii => |diff| diff,
+ .unchanged, .visual => return null,
+ };
+ const byte = diff.byteAt(track.frame);
+ if (byte == diff.to) return null;
+ if (track.frame == 0) return canonical.previous_cells[index];
+ return withByte(canonical.cells[index], byte);
+ }
+ return switch (animation.charSource(track, col - area.x0, row - area.y0, area)) {
+ .old => canonical.previous_cells[index],
+ .byte => |byte| withByte(canonical.cells[index], byte),
+ // Churn belongs on the glyph, not on the pane's empty space, and it
+ // borrows whichever side of the diff actually has one.
+ .churn => |byte| blk: {
+ const new_cell = &canonical.cells[index];
+ if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte);
+ const old_cell = &canonical.previous_cells[index];
+ if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte);
+ break :blk old_cell.*;
+ },
+ .at => |offset| blk: {
+ const source_col = @as(i32, col) + offset.cols;
+ const source_row = @as(i32, row) + offset.rows;
+ // A glyph whose source lies outside the pane has not arrived
+ // yet: the frozen old cell is the only truthful thing under it.
+ if (source_col < area.x0 or source_row < area.y0 or
+ source_col >= @as(i32, area.x0) + area.cols or
+ source_row >= @as(i32, area.y0) + area.rows or
+ source_col >= canonical.cols or source_row >= canonical.rows)
+ break :blk canonical.previous_cells[index];
+ const source = @as(usize, @intCast(source_row)) * canonical.cols +
+ @as(usize, @intCast(source_col));
+ break :blk canonical.cells[source];
+ },
+ };
+}
+
+fn withByte(cell: Cell, byte: u8) Cell {
+ var out = cell;
+ out.text[0] = byte;
+ out.len = 1;
+ out.default = false;
+ return out;
+}
+
+/// Whether a cell shows a glyph a character effect can work on. An unpainted
+/// cell and a space are both blank space, whatever style they carry.
+fn hasGlyph(cell: *const Cell) bool {
+ return !cell.default and !(cell.len == 1 and cell.text[0] == ' ');
+}
+
+fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
+ const tz = tracy.zone(@src(), "renderPane");
+ defer tz.end();
+ if (r.w <= config.GUTTER or r.h == 0) return;
+ const s = &p.surface;
+ const th = p.theme();
+ const chrome = p.chromeTheme();
+ const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter
+ const tw = r.w - config.GUTTER;
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
+ const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default;
+
+ // text area resets to terminal-default cells (vaxis clear semantics);
+ // light themes paint the page over it.
+ {
+ // Two full passes over every cell in the pane, every frame.
+ const tz_clear = tracy.zone(@src(), "paneClear");
+ defer tz_clear.end();
+ s.clearRect(tx, r.y, tw, r.h);
+ if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
+ }
+
+ const box_bg = if (active) chrome.box else chrome.box_dim;
+ const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
+ const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
+ const box_style: CellStyle = .{
+ .fg = .{ .rgb = box_ink },
+ .bg = .{ .rgb = box_bg },
+ // Grips keep physical grid dimensions, independently of tag text.
+ .font_role = .tagline,
+ };
+ s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style);
+ s.set(r.x, tag_y, switch (pane.body.mode) {
+ .normal => config.tag_normal,
+ .insert => config.tag_insert,
+ .tty => config.tag_tty,
+ }, box_style);
+
+ try paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET));
+
+ // Folded panes retain their body state, but neither body pixels nor
+ // its cursor belong outside the one-row tag rectangle.
+ if (pane.collapsed) return;
+
+ if (comptime pdf_enabled)
+ if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return;
+
+ if (pane.image) |*iv| {
+ // Below the notice chips: a picture is drawn after the cells (the
+ // GUI's image pass, kitty's z-order), so a chip over it would be
+ // painted out. Text gets the overlay; a picture gives up the rows.
+ const shown = pane.notices.span();
+ const image_h = (r.h -| pane.tag_rows) -| shown;
+ panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h);
+ // thumbless, but the same one column as the real scrollbar below —
+ // that is the whole point of drawing it, and like that one it runs
+ // past the notice bands so the gutter has no notch in it
+ s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg });
+ return;
+ }
+
+ try body_layer.renderBody(p, arena, pane, r, id, active, true);
+}
+
+/// The workspace and column tags as the grid shows them: their text, the
+/// word under the pointer and, in the one being edited, its selection and
+/// caret. Their tag layers are drawn by renderTagLayers.
+pub fn paintHeaders(p: *Pardes) !void {
+ const s = &p.surface;
+ const chrome = p.chromeTheme();
+ const th = p.theme();
+ // global tagbar: full width, top rows — unless a native shell has
+ // taken its commands over, in which case the rows are not ours to draw
+ tagline.revealHeader(p);
+ const global = tagline.topbar(p);
+ const global_rows = p.topBarHeight();
+ const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0;
+ const global_top = if (p.header_focus and p.header_column == null) p.header_top else 0;
+ if (p.settings.workspace_tag) {
+ s.fill(0, 0, s.cols, global_rows, .{
+ .bg = .{ .rgb = chrome.tag_bg },
+ .font_role = .tagline,
+ });
+ for (0..global_rows) |row| {
+ const line = modal.lineSlice(global, row + global_top);
+ _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{
+ .fg = .{ .rgb = chrome.tag_fg },
+ .bg = .{ .rgb = chrome.tag_bg },
+ .font_role = .tagline,
+ });
+ }
+ }
+ if (p.pointer_inside and p.hover_row < global_rows) {
+ const bar = modal.lineSlice(global, p.hover_row + global_top);
+ if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| {
+ var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll;
+ const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll;
+ while (col < hi and col < s.cols) : (col += 1) {
+ const cell = s.at(@intCast(col), p.hover_row);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = th.sel_bg };
+ cell.style.fg = .{ .rgb = th.sel_fg };
+ }
+ }
+ }
+ const column_rows = p.columnBarHeight();
+ if (column_rows > 0) for (0..p.ncol) |c| {
+ const grip = @min(config.GUTTER, p.col_w[c]);
+ const inset = @min(TAG_TEXT_INSET, p.col_w[c]);
+ const x = p.col_x[c] + inset;
+ const w = p.col_w[c] - inset;
+ const y0 = global_rows;
+ if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue;
+ const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false;
+ const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg;
+ // A grip is muted until it is grabbed; then it lights, as a pane's
+ // box does for the active pane, so the drag shows what it holds.
+ const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c;
+ const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim;
+ const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10;
+ const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 };
+ const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline };
+ s.fill(p.col_x[c], y0, grip, column_rows, grip_style);
+ s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+
+ s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+ const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0;
+ const bar_top = if (p.header_focus and p.header_column == c) p.header_top else 0;
+ const tag = tagline.columnTag(p, c);
+ for (0..column_rows) |row| {
+ const line = modal.lineSlice(tag, row + bar_top);
+ _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{
+ .fg = .{ .rgb = fg },
+ .bg = .{ .rgb = bg },
+ .font_role = .tagline,
+ });
+ }
+ // A carried column's pointer is aiming at a place, not a word.
+ if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) {
+ const bar = modal.lineSlice(tag, p.hover_row - y0 + bar_top);
+ if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| {
+ var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll;
+ const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll;
+ while (col < hi and col < w) : (col += 1) {
+ const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row);
+ cell.style.bg = .{ .rgb = th.sel_bg };
+ cell.style.fg = .{ .rgb = th.sel_fg };
+ }
+ }
+ }
+ };
+ if (tagline.focusedHeader(p)) |t| {
+ const text = try edit.flatSurface(p, t);
+ const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0;
+ const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols;
+ const y: u16 = if (p.header_column != null) global_rows else 0;
+ const rows = if (p.header_column != null) column_rows else global_rows;
+ for (0..rows) |row| {
+ const bar = modal.lineSlice(text, row + p.header_top);
+ const span = tagline.headerSelection(t, text, row + p.header_top) orelse continue;
+ var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll;
+ const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll;
+ while (col < hi and col < w) : (col += 1) {
+ const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row)));
+ cell.style.fg = .{ .rgb = th.sel_fg };
+ cell.style.bg = .{ .rgb = th.sel_bg };
+ }
+ }
+ // A prompt answering for the header has the caret on its band.
+ const asking = if (p.panes[p.active]) |active| active.prompt != .none else false;
+ const cur: usize = @intCast(@max(0, t.cur_row));
+ if (!asking and cur >= p.header_top and cur < p.header_top + rows) {
+ const bar = modal.lineSlice(text, cur);
+ const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll;
+ if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert };
+ }
+ }
+}
+
+fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } {
+ if (pane.isTerminal()) {
+ var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n");
+ while (words.next()) |word| {
+ if (!std.mem.eql(u8, word, "Tty")) continue;
+ const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr);
+ return .{ .lo = lo, .hi = lo + word.len };
+ }
+ return null;
+ }
+ var start: usize = 0;
+ const name = if (pane.file) |file|
+ if (pane.prompt == .name) pane.input.own orelse file.path else file.path
+ else if (pane.image) |state| blk: {
+ // Image tags put renderer controls before the path.
+ start = std.mem.lastIndexOf(u8, tag[0..prefix_len], state.path) orelse return null;
+ break :blk state.path;
+ } else if (comptime pdf_enabled) blk: {
+ break :blk if (pane.pdf) |state| state.path else return null;
+ } else return null;
+ const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0;
+ if (basename == name.len) return null;
+ return .{ .lo = start + basename, .hi = start + name.len };
+}
+
+pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void {
+ const chrome = p.chromeTheme();
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const r = p.rects[id];
+ if (r.w <= TAG_TEXT_INSET or r.h == 0) continue;
+ const tag = try tagline.tagText(p, arena, pane);
+ const rows = pane.tag_rows;
+ // As wide as the widest line it shows, and never narrower than
+ // its viewport.
+ var widest: usize = 0;
+ var split = std.mem.splitScalar(u8, tag, '\n');
+ for (0..pane.tag_top) |_| _ = split.next();
+ for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse ""));
+ const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1)));
+ const pitch = @as(usize, columns) + TAG_TEXT_INSET;
+ const storage = try arena.alloc(Cell, pitch * rows);
+ @memset(storage, .{});
+ var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage };
+ std.mem.swap(Surface, &p.surface, &temporary);
+ paintPaneTag(p, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| {
+ std.mem.swap(Surface, &p.surface, &temporary);
+ return err;
+ };
+ std.mem.swap(Surface, &p.surface, &temporary);
+ // A layer to a line: the first in the pane's own slot, the rest
+ // in the slots kept for the lines of taller tags.
+ for (0..rows) |line| {
+ const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1];
+ if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns);
+ @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]);
+ layer.kind = .pane;
+ layer.id = @intCast(id);
+ layer.serial = pane.serial;
+ layer.line = @intCast(line);
+ layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H };
+ layer.cols = columns;
+ layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor|
+ (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null)
+ else
+ null;
+ }
+ }
+ // With the workspace tag row handed to native chrome there is no
+ // workspace layer to render; the menu bar carries its commands.
+ if (p.settings.workspace_tag) {
+ for (0..p.topBarHeight()) |line| {
+ const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1;
+ const top = if (p.header_focus and p.header_column == null) p.header_top else 0;
+ try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg);
+ }
+ }
+ // Each notice is its own tagline-shaped chip over the top of the body.
+ // Going through the same layer machinery the pane and column tags use
+ // is what gives it the tagline's height, its small-font metrics, the
+ // band offset and the border -- none of which a body-grid row can have
+ // just by wearing a tagline font. The geometry is the one the grid
+ // pass already chose and recorded, so the two cannot disagree and the
+ // GUI's "skip the cells a tag layer covers" leaves no stub behind.
+ // A prompt is one of them too: left on the grid, a pixel shell drew
+ // its tagline glyphs one to a BODY cell, spaced out like a banner.
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const r = p.rects[id];
+ if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue;
+ const first = p.bodyTop(pane, r);
+ var leader_buf: [16]u8 = undefined;
+ for (pane.notices.slice(), 0..) |kind, i| {
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
+ if (text.len == 0) continue;
+ const cx = pane.notices.left[i];
+ if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue;
+ const chip = r.x + r.w - cx;
+ // Right aligned inside the chip, a blank cell short of its
+ // edge: the same one the grid pass leaves for a prompt caret.
+ // Wider than the chip, the text loses its head, as on the grid.
+ const room = p.tagCapacity(chip) -| 1;
+ const shown = panes.File.displayWidth(text);
+ var kept = text;
+ if (shown > room) {
+ var start = panes.File.rawAtDisplay(text, shown - room);
+ if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1);
+ kept = text[start..];
+ }
+ const pad = room -| panes.File.displayWidth(kept);
+ const line = try arena.alloc(u8, pad + kept.len);
+ @memset(line[0..pad], ' ');
+ @memcpy(line[pad..], kept);
+ // A pixel shell slides the message in as it fades up, and
+ // fades it on the way out.
+ const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{};
+ const fade = motion.alpha;
+ const page = p.theme().bg orelse chrome.tag_bg;
+ const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i;
+ try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{
+ .x = cx,
+ .y = first + @as(u16, pane.notices.row[i]),
+ .w = chip,
+ .h = BOX_H,
+ }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade));
+ p.surface.tag_layers[layer_index].slide = motion.slide;
+ p.surface.tag_layers[layer_index].fade = fade;
+ // The caret rides the band, where the prompt's glyphs are,
+ // at the same place in the text the grid pass puts it.
+ if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue;
+ const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
+ const dropped = shown - panes.File.displayWidth(kept);
+ if (typed < dropped) continue;
+ p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert };
+ }
+ }
+ if (p.columnBarHeight() > 0) for (0..p.ncol) |column| {
+ if (p.col_w[column] == 0) continue;
+ const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false;
+ const top = if (p.header_focus and p.header_column == column) p.header_top else 0;
+ for (0..p.columnBarHeight()) |line| {
+ const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1;
+ try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg);
+ }
+ };
+}
+
+/// One row of a header or a notice band as a tag layer: `text` is line
+/// `line` of what it shows.
+fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void {
+ _ = arena;
+ const layer = &p.surface.tag_layers[index];
+ if (viewport.w == 0) {
+ layer.cols = 0;
+ return;
+ }
+ const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1)));
+ if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns);
+ layer.kind = kind;
+ layer.id = id;
+ layer.line = line;
+ layer.serial = if (kind == .column) p.col_serial[id] else 0;
+ layer.viewport = viewport;
+ layer.cols = columns;
+ layer.bg = bg;
+ layer.cursor = null;
+ layer.slide = 0;
+ layer.fade = 1;
+ var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells };
+ // A notice band carries a PANE id, not a column index, so it must not
+ // answer to the column being edited -- that would scroll and select
+ // its text with the column tag's offsets and give it a second caret.
+ const editing: ?*Text = if (p.header_focus and switch (kind) {
+ .workspace => p.header_column == null,
+ .column => p.header_column == id,
+ .pane, .notice => false,
+ }) tagline.focusedHeader(p) else null;
+ const scroll = if (editing != null) p.header_scroll else 0;
+ surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+ _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline });
+ if (editing) |t| {
+ if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| {
+ var col = panes.File.rawDisplayCol(text, span.lo) -| scroll;
+ const end = panes.File.rawDisplayCol(text, span.hi) -| scroll;
+ while (col < end and col < columns) : (col += 1) {
+ surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg };
+ surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg };
+ }
+ }
+ const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll;
+ const asking = if (p.panes[p.active]) |active| active.prompt != .none else false;
+ if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert };
+ } else if (p.pointer_inside) {
+ const tag_hit = p.pointer_tag_hit;
+ const col: ?usize = if (tag_hit) |pointer|
+ (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null)
+ else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w)
+ p.hover_col - viewport.x
+ else
+ null;
+ if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| {
+ var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll;
+ const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll;
+ while (cell < end and cell < columns) : (cell += 1) {
+ surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg };
+ surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg };
+ }
+ };
+ }
+}
+
+pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void {
+ const s = &p.surface;
+ const th = p.theme();
+ const chrome = p.chromeTheme();
+ const tx = r.x + @min(TAG_TEXT_INSET, r.w);
+ const tw = r.w -| TAG_TEXT_INSET;
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
+ const rows = pane.tag_rows;
+ const page_rgb = th.bg orelse th.tag_bg;
+ // Focus is visible along the tag without changing its live text grid.
+ const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg;
+ const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline };
+ // Padding belongs to the same font row but keeps its default ink: a
+ // role must not manufacture a foreground color for blank cells.
+ s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline });
+ s.fill(tx, tag_y, tw, rows, .{
+ .bg = .{ .rgb = tag_bg },
+ .font_role = .tagline,
+ });
+ const whole = try tagline.tagText(p, arena, pane);
+ const tail = tagline.curTail(pane);
+ const prefix_len = whole.len - tail.len;
+ // A row per line shown, from `tag_top`: the tag's first line carries
+ // the prefix, the rest are the tag's own text alone.
+ const top = pane.tag_top;
+ var lines: [MAX_TAG_ROWS][]const u8 = @splat("");
+ var split = std.mem.splitScalar(u8, whole, '\n');
+ for (0..top) |_| _ = split.next();
+ for (lines[0..rows]) |*line| line.* = split.next() orelse "";
+ // The keyboard's caret, as a row and a byte offset in that row's
+ // line: in the tag's own text when the tag has the keyboard, in the
+ // path while a name is drafted.
+ const Caret = struct { row: usize, at: usize };
+ const caret: ?Caret = if (!active)
+ null
+ else if (pane.prompt == .name)
+ .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) }
+ else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: {
+ const row: usize = @intCast(pane.tag.cur_row - top);
+ break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) };
+ } else null;
+ if (caret) |at| {
+ const line = lines[at.row];
+ const col = panes.File.rawDisplayCol(line, at.at);
+ var scroll: usize = pane.tag_scroll;
+ if (col < scroll) scroll = col;
+ if (col >= scroll + capacity) scroll = col - capacity + 1;
+ var raw = panes.File.rawAtDisplay(line, scroll);
+ scroll = panes.File.rawDisplayCol(line, raw);
+ if (col >= scroll + capacity) {
+ raw = modal.nextGrapheme(line, raw);
+ scroll = panes.File.rawDisplayCol(line, raw);
+ }
+ pane.tag_scroll = @intCast(scroll);
+ } else if (pane.focus != .tag) pane.tag_scroll = 0;
+ for (lines[0..rows], 0..) |line, row| {
+ const start = panes.File.rawAtDisplay(line, pane.tag_scroll);
+ _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style);
+ }
+ const tag = lines[0];
+ if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| {
+ const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll));
+ const hi = @min(name.hi, tag.len);
+ if (lo < hi) {
+ var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll;
+ const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll;
+ const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg;
+ while (col < end and col < tw) : (col += 1)
+ s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg };
+ }
+ };
+ if (p.look_hover_preview) |preview| {
+ if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) {
+ const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg));
+ body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
+ };
+ }
+ // The tag's own selections, while it has the keyboard, a line at a
+ // time. A range of one grapheme is the block cursor, not a selection.
+ if (pane.focus == .tag) {
+ var ranges: [Text.max_selections]modal.Selection = undefined;
+ const got = pane.tag.ranges(whole, 0, &ranges);
+ for (ranges[0..got.n]) |range| {
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ if (modal.nextGrapheme(whole, lo) >= hi) continue;
+ const first = modal.positionAt(whole, lo);
+ const last = modal.positionAt(whole, hi);
+ var row = @max(first.row, top);
+ while (row <= last.row and row < top + rows) : (row += 1) {
+ const line = lines[row - top];
+ const from = if (row == first.row) first.col else 0;
+ const to = @min(line.len, if (row == last.row) last.col else line.len);
+ var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll;
+ const end = panes.File.rawDisplayCol(line, to);
+ const y = tag_y + @as(u16, @intCast(row - top));
+ while (col + pane.tag_scroll < end and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), y);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = th.sel_bg };
+ cell.style.fg = .{ .rgb = th.sel_fg };
+ }
+ }
+ }
+ }
+ // the caret: bar while typing, block in normal mode (same rule as a body)
+ if (caret) |at| {
+ const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll;
+ const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode;
+ if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert };
+ }
+
+ for (pane.sel, 0..) |selection, button| {
+ if (selection.state == .none or (selection.state == .done and button != 0)) continue;
+ if (@min(selection.r0, selection.r1) >= rows) continue;
+ body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg);
+ }
+}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 6978284b..0737bb63 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -209,11 +209,11 @@ const AnimationClock = struct {
return false;
}
if (clock.next_ns == 0) {
- clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns;
+ clock.next_ns = now_ns +| pardes.animation.Animation.frame_ns;
return false;
}
if (now_ns < clock.next_ns) return false;
- clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns;
+ clock.next_ns = now_ns +| pardes.animation.Animation.frame_ns;
return true;
}
};
@@ -221,7 +221,7 @@ const AnimationClock = struct {
fn finishPresentedAnimationFrame(
clock: *AnimationClock,
core: *pardes.Pardes,
- tracks: []const pardes.layout.Track,
+ tracks: []const pardes.animation.Track,
now_ns: u64,
) void {
core.acknowledgePanelPresentation(tracks);
@@ -232,9 +232,9 @@ test "GUI animation clock is active-only and cadence gated" {
var clock: AnimationClock = .{};
try std.testing.expect(!clock.due(false, 100));
try std.testing.expect(!clock.due(true, 100));
- try std.testing.expect(!clock.due(true, 100 + pardes.layout.Animation.frame_ns - 1));
- try std.testing.expect(clock.due(true, 100 + pardes.layout.Animation.frame_ns));
- try std.testing.expect(!clock.due(false, 100 + 2 * pardes.layout.Animation.frame_ns));
+ try std.testing.expect(!clock.due(true, 100 + pardes.animation.Animation.frame_ns - 1));
+ try std.testing.expect(clock.due(true, 100 + pardes.animation.Animation.frame_ns));
+ try std.testing.expect(!clock.due(false, 100 + 2 * pardes.animation.Animation.frame_ns));
try std.testing.expectEqual(@as(u64, 0), clock.next_ns);
}
@@ -248,7 +248,7 @@ test "a persistent scene presents a new panel's frame zero before advancing it"
core.settings.scene_effects.crt = true;
const scene_frame = try core.render(arena.allocator());
finishPresentedAnimationFrame(&clock, core, scene_frame.panelTracks(), 100);
- try std.testing.expectEqual(@as(u64, 100 + pardes.layout.Animation.frame_ns), clock.next_ns);
+ try std.testing.expectEqual(@as(u64, 100 + pardes.animation.Animation.frame_ns), clock.next_ns);
core.settings.panel_transition = .slide;
core.update(.{ .command = "Newcol" });
@@ -262,7 +262,7 @@ test "a persistent scene presents a new panel's frame zero before advancing it"
&clock,
core,
first.panelTracks(),
- 100 + pardes.layout.Animation.frame_ns,
+ 100 + pardes.animation.Animation.frame_ns,
);
_ = arena.reset(.retain_capacity);
const second = try core.render(arena.allocator());
@@ -557,8 +557,8 @@ const SavedImagePlace = struct {
const PreparedImage = struct {
place: SavedImagePlace,
texture: *c.SDL_GPUTexture,
- track: ?pardes.layout.Track = null,
- clip: ?pardes.layout.Box = null,
+ track: ?pardes.animation.Track = null,
+ clip: ?pardes.animation.Box = null,
old_layer: bool = false,
};
@@ -592,7 +592,7 @@ test "shader instance ABI carries aligned transition vectors" {
try std.testing.expectEqual(@as(usize, 64), @offsetOf(ImageInstance, "effect"));
try std.testing.expectEqual(@as(usize, 32), @sizeOf([8]f32));
- const tracks = [_]pardes.layout.Track{
+ const tracks = [_]pardes.animation.Track{
.{ .pane = 0, .phase = .opening, .effect = .slide },
.{ .pane = 1, .phase = .moving, .effect = .slide },
.{ .pane = 2, .phase = .moving, .effect = .slide },
@@ -606,19 +606,19 @@ test "shader instance ABI carries aligned transition vectors" {
}
test "GUI paint plan snaps history effects without a frozen grid" {
- const tracks = [_]pardes.layout.Track{
+ const tracks = [_]pardes.animation.Track{
.{ .pane = 0, .phase = .opening, .effect = .ascii },
.{ .pane = 1, .phase = .closing, .effect = .vertical },
};
try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len);
const ready = makePaintPlan(&tracks, true);
try std.testing.expectEqual(@as(usize, 3), ready.len);
- try std.testing.expectEqual(pardes.layout.Phase.opening, ready.batches[1].track.?.phase);
- try std.testing.expectEqual(pardes.layout.Phase.closing, ready.batches[2].track.?.phase);
+ try std.testing.expectEqual(pardes.animation.Phase.opening, ready.batches[1].track.?.phase);
+ try std.testing.expectEqual(pardes.animation.Phase.closing, ready.batches[2].track.?.phase);
}
test "closing tombstone overlays but never owns canonical cells" {
- const closing: pardes.layout.Track = .{
+ const closing: pardes.animation.Track = .{
.pane = 0,
.phase = .closing,
.effect = .vertical,
@@ -628,7 +628,7 @@ test "closing tombstone overlays but never owns canonical cells" {
const plan = makePaintPlan(&.{closing}, true);
try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4));
try std.testing.expectEqual(@as(usize, 2), plan.len);
- try std.testing.expectEqual(pardes.layout.Phase.closing, plan.batches[1].track.?.phase);
+ try std.testing.expectEqual(pardes.animation.Phase.closing, plan.batches[1].track.?.phase);
}
fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32, tag_w: u32, tag_h: u32) bool {
@@ -2031,10 +2031,10 @@ fn taglineLayoutForCell(g: *const Gui, col: u16) CellLayout {
return compactTaglineLayout(g, @floatFromInt(col));
}
-const boxContains = pardes.layout.Box.contains;
+const boxContains = pardes.animation.Box.contains;
const PaintBatch = struct {
- track: ?pardes.layout.Track = null,
+ track: ?pardes.animation.Track = null,
cell_start: u32 = 0,
cell_count: u32 = 0,
image_start: u32 = 0,
@@ -2046,9 +2046,9 @@ const PaintPlan = struct {
len: usize = 1,
};
-fn makePaintPlan(tracks: []const pardes.layout.Track, has_diff: bool) PaintPlan {
+fn makePaintPlan(tracks: []const pardes.animation.Track, has_diff: bool) PaintPlan {
var plan: PaintPlan = .{};
- for ([_]pardes.layout.Phase{ .moving, .opening, .closing }) |phase| {
+ for ([_]pardes.animation.Phase{ .moving, .opening, .closing }) |phase| {
for (tracks) |track| {
if (!track.active() or track.phase != phase) continue;
if (track.effect.needsPreviousGrid() and !has_diff) continue;
@@ -2074,21 +2074,21 @@ fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize {
return 0;
}
-fn panelCellCoord(track: pardes.layout.Track, col: u16, row: u16) u32 {
+fn panelCellCoord(track: pardes.animation.Track, col: u16, row: u16) u32 {
const source = track.contentBox();
const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x)));
const y0: u16 = @intFromFloat(@max(0.0, @floor(source.y)));
return @as(u32, row -| y0) << 16 | @as(u32, col -| x0);
}
-fn panelGridSize(track: pardes.layout.Track) u32 {
+fn panelGridSize(track: pardes.animation.Track) u32 {
const source = track.contentBox();
const cols: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.w))));
const rows: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.h))));
return @as(u32, rows) << 16 | @as(u32, cols);
}
-fn ndcBox(box: pardes.layout.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 {
+fn ndcBox(box: pardes.animation.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 {
const px0 = layout.x_off + box.x * layout.w;
const py0 = layout.y_off + box.y * layout.h;
const px1 = px0 + box.w * layout.w;
@@ -2103,7 +2103,7 @@ fn ndcBox(box: pardes.layout.Box, layout: CellLayout, win_w: f32, win_h: f32) [4
fn setTransitionFields(
instance: anytype,
- track: ?pardes.layout.Track,
+ track: ?pardes.animation.Track,
layout: CellLayout,
win_w: f32,
win_h: f32,
@@ -4636,7 +4636,7 @@ fn snapshotContainsPlacement(g: *const Gui, current: pardes.ImagePlace) bool {
return false;
}
-fn placeIntersectsBox(place: SavedImagePlace, box: pardes.layout.Box) bool {
+fn placeIntersectsBox(place: SavedImagePlace, box: pardes.animation.Box) bool {
const x0: f32 = @floatFromInt(place.x);
const y0: f32 = @floatFromInt(place.y);
const x1: f32 = @floatFromInt(@as(u32, place.x) + place.w);
@@ -4808,8 +4808,8 @@ fn appendPreparedImage(
g: *Gui,
gpa: std.mem.Allocator,
place: SavedImagePlace,
- track: ?pardes.layout.Track,
- clip: ?pardes.layout.Box,
+ track: ?pardes.animation.Track,
+ clip: ?pardes.animation.Box,
old_layer: bool,
) void {
const texture = g.native_images.get(place.key) orelse return;
@@ -4998,7 +4998,7 @@ fn imageScissor(
};
}
-fn panelBoxScissor(g: *const Gui, box: pardes.layout.Box, max_w: u32, max_h: u32) c.SDL_Rect {
+fn panelBoxScissor(g: *const Gui, box: pardes.animation.Box, max_w: u32, max_h: u32) c.SDL_Rect {
const x0: i32 = @intFromFloat(@floor(box.x * @as(f32, @floatFromInt(g.cell_w))));
const y0: i32 = @intFromFloat(@floor(box.y * @as(f32, @floatFromInt(g.cell_h))));
const x1: i32 = @intFromFloat(@ceil((box.x + box.w) * @as(f32, @floatFromInt(g.cell_w))));
@@ -5059,7 +5059,7 @@ fn renderFrame(
theme_bg: ?[3]u8,
topbar_pane_border_rgb: [3]u8,
tagline_rgb: [3]u8,
- scene_effects: pardes.layout.SceneEffect,
+ scene_effects: pardes.animation.SceneEffect,
debug_on: bool,
) !bool {
if (surface.pointer_shape != g.pointer_shape) {
@@ -5763,7 +5763,7 @@ fn tagLayerCellCount(g: *const Gui, layer: *const pardes.TagLayer) u32 {
return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width);
}
-fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.TagLayer, win_w: f32, win_h: f32, track: ?pardes.layout.Track, page: Ground, show_cursor: bool, old_layer: bool) void {
+fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.TagLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool) void {
const bw: f32 = @floatFromInt(g.cell_w);
const bh: f32 = @floatFromInt(g.cell_h);
const tw: f32 = @floatFromInt(g.tagline_width);
@@ -5826,7 +5826,7 @@ fn clipBodyInstance(instance: *CellInstance, bottom: f32, win_h: f32) void {
instance.y1 = limit;
}
-fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.BodyLayer, win_w: f32, win_h: f32, track: ?pardes.layout.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void {
+fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.BodyLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void {
const bw: f32 = @floatFromInt(g.cell_w);
const bh: f32 = @floatFromInt(g.cell_h);
const tw: f32 = @floatFromInt(g.tagline_width);
@@ -5919,7 +5919,7 @@ fn emitSurfaceCell(
body_layout: CellLayout,
win_w: f32,
win_h: f32,
- track: ?pardes.layout.Track,
+ track: ?pardes.animation.Track,
line: []const pardes.Cell,
at: usize,
cursor: ?usize,
@@ -6018,7 +6018,7 @@ fn emitInstance(
layout: CellLayout,
win_w: f32,
win_h: f32,
- track: ?pardes.layout.Track,
+ track: ?pardes.animation.Track,
role: pardes.FontRole,
line: []const pardes.Cell,
at: usize,
diff --git a/src/layout.zig b/src/layout.zig
index 8e44f1d9..b642c52c 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -4,666 +4,16 @@ const tagline = @import("tagline.zig");
const dump = @import("dump.zig");
const config = @import("config.zig");
const panes = @import("panes.zig");
+const animation = @import("animation.zig");
+const Box = animation.Box;
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
pub const Rect = struct { x: u16 = 0, y: u16 = 0, w: u16 = 0, h: u16 = 0 };
-pub const Snapshot = struct { serial: u32, box: Box };
const MAX_COLS = pardes.MAX_COLS;
const MAX_PANES = pardes.MAX_PANES;
const BOX_H = pardes.BOX_H;
-pub const Presentation = struct {
- // Only acknowledged draws advance shown geometry; committed and submitted may be ahead.
- committed: [MAX_PANES]?Snapshot = @splat(null),
- initialized: bool = false,
- enabled: bool = false,
- snap_once: bool = false,
- tracks: [MAX_PANES]?Track = @splat(null),
- closing: [MAX_PANES]Track = undefined,
- closing_len: usize = 0,
- submitted: [MAX_PANES]?Snapshot = @splat(null),
- submitted_ready: bool = false,
- shown: [MAX_PANES]?Snapshot = @splat(null),
- shown_tracks: [MAX_PANES]?Track = @splat(null),
- shown_closing: [MAX_PANES]Track = undefined,
- shown_closing_len: usize = 0,
- acknowledged: bool = false,
- pending: bool = false,
- previous_cells: []pardes.Cell = &.{},
- previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}),
- previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}),
- previous_cols: u16 = 0,
- previous_rows: u16 = 0,
- previous_valid: bool = false,
- previous_layout: [MAX_PANES]?Snapshot = @splat(null),
- diffs: []pardes.PanelCellDiff = &.{},
- diff_state: enum { none, requested, ready } = .none,
-
- pub const CellPosition = struct { col: u16, row: u16 };
-
- pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void {
- if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
- for (&self.previous_body_layers) |*layer| gpa.free(layer.cells);
- for (&self.previous_tag_layers) |*layer| gpa.free(layer.cells);
- if (self.diffs.len > 0) gpa.free(self.diffs);
- }
-
- pub fn sync(self: *Presentation, p: *Pardes) void {
- const initializing = !self.initialized or !self.enabled;
- if (initializing or self.snap_once) {
- const had_layout = self.initialized;
- self.committed = @splat(null);
- self.tracks = @splat(null);
- self.closing_len = 0;
- self.diff_state = .none;
- if (initializing) {
- self.shown_tracks = @splat(null);
- self.shown_closing_len = 0;
- }
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
- }
- self.initialized = true;
- self.snap_once = false;
- if (had_layout and self.acknowledged)
- self.pending = true;
- return;
- }
-
- const effect = p.settings.panel_transition;
- if (effect.needsPreviousGrid() and self.diff_state != .none) {
- var second_change = false;
- for (p.panes, self.committed, 0..) |slot, snapshot, id| {
- const pane = slot orelse {
- second_change = second_change or snapshot != null;
- continue;
- };
- const target = panelBox(p.rects[id]);
- second_change = second_change or snapshot == null or
- snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target);
- }
- if (second_change) {
- p.abandonPanelAnimations();
- self.committed = @splat(null);
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
- }
- return;
- }
- }
- var changed = false;
- var animated_change = false;
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse {
- if (self.committed[id]) |old| {
- changed = true;
- if (effect.lifecycleOnly()) {
- if (self.appendClosing(.{
- .serial = old.serial,
- .pane = @intCast(id),
- .phase = .closing,
- .effect = effect,
- .from = old.box,
- .to = closingBox(effect, old.box),
- })) animated_change = true;
- }
- }
- self.committed[id] = null;
- self.tracks[id] = null;
- // Never let pixels from a dead pane address a reused slot.
- self.shown_tracks[id] = null;
- continue;
- };
- const target = panelBox(p.rects[id]);
- const previous = self.committed[id];
- self.committed[id] = .{ .serial = pane.serial, .box = target };
-
- if (effect == .off) {
- changed = changed or previous == null or previous.?.serial != pane.serial or
- !previous.?.box.eql(target);
- self.tracks[id] = null;
- continue;
- }
- if (previous) |old| {
- if (old.serial == pane.serial and old.box.eql(target)) continue;
- const same_lifetime = old.serial == pane.serial;
- const prior = if (self.tracks[id]) |track|
- if (track.serial == pane.serial and track.active()) track else null
- else
- null;
- if (effect.lifecycleOnly() and same_lifetime) {
- if (prior) |active| if (active.phase == .opening) {
- var next = active;
- next.from = openingBox(effect, target, p.screen_w);
- next.to = target;
- self.tracks[id] = next;
- changed = true;
- animated_change = true;
- continue;
- };
- self.tracks[id] = null;
- changed = true;
- continue;
- }
- if (effect.lifecycleOnly() and !same_lifetime) {
- _ = self.appendClosing(.{
- .serial = old.serial,
- .pane = @intCast(id),
- .phase = .closing,
- .effect = effect,
- .from = old.box,
- .to = closingBox(effect, old.box),
- });
- }
- const shown = if (self.shown[id]) |snapshot|
- if (snapshot.serial == pane.serial) snapshot.box else null
- else
- null;
- const from = if (!same_lifetime)
- openingBox(effect, target, p.screen_w)
- else if (shown) |box|
- box
- else if (self.acknowledged and prior != null)
- prior.?.from
- else
- old.box;
- const next: Track = .{
- .serial = pane.serial,
- .pane = @intCast(id),
- .phase = if (!same_lifetime or
- (prior != null and prior.?.phase == .opening)) .opening else .moving,
- .effect = effect,
- .from = from,
- .to = target,
- };
- self.tracks[id] = next;
- changed = true;
- animated_change = true;
- } else {
- const next: Track = .{
- .serial = pane.serial,
- .pane = @intCast(id),
- .phase = .opening,
- .effect = effect,
- .from = openingBox(effect, target, p.screen_w),
- .to = target,
- };
- self.tracks[id] = next;
- changed = true;
- animated_change = true;
- }
- }
- if (animated_change and effect.needsPreviousGrid()) {
- self.diff_state = .requested;
- }
- if (changed and self.acknowledged) self.pending = true;
- }
-
- fn appendClosing(self: *Presentation, track: Track) bool {
- std.debug.assert(track.phase == .closing);
- const baseline = self.previous_layout[track.pane] orelse return false;
- if (!self.previous_valid or baseline.serial != track.serial or
- !baseline.box.eql(track.from)) return false;
- if (self.closing_len == self.closing.len) {
- std.mem.copyForwards(
- Track,
- self.closing[0 .. self.closing.len - 1],
- self.closing[1..],
- );
- self.closing_len -= 1;
- }
- self.closing[self.closing_len] = track;
- self.closing_len += 1;
- return true;
- }
-
- pub fn advance(self: *Presentation) void {
- for (&self.tracks) |*slot| {
- const track = if (slot.*) |*track| track else continue;
- track.frame +|= 1;
- if (!track.active()) slot.* = null;
- }
- var out: usize = 0;
- for (self.closing[0..self.closing_len]) |value| {
- var track = value;
- track.frame +|= 1;
- if (!track.active()) continue;
- self.closing[out] = track;
- out += 1;
- }
- self.closing_len = out;
- }
-
- pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void {
- var presented: [MAX_PANES]?Track = @splat(null);
- var presented_closing: [MAX_PANES]Track = undefined;
- var nclosing: usize = 0;
- var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready)
- self.submitted
- else
- @splat(null);
- if (!self.submitted_ready) for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
- };
- for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| {
- const pane = p.panes[id] orelse {
- snapshot.* = null;
- continue;
- };
- if (pane.serial != saved.serial) snapshot.* = null;
- };
- for (tracks) |track| {
- if (track.phase == .closing) {
- const current = for (self.closing[0..self.closing_len]) |candidate| {
- if (candidate.serial == track.serial and candidate.pane == track.pane and
- candidate.effect == track.effect) break true;
- } else false;
- if (!current or !track.active() or nclosing == presented_closing.len) continue;
- presented_closing[nclosing] = track;
- nclosing += 1;
- continue;
- }
- const id: usize = track.pane;
- if (id >= p.panes.len or !track.active()) continue;
- const pane = p.panes[id] orelse continue;
- if (pane.serial != track.serial) continue;
- presented[id] = track;
- presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() };
- }
- self.shown_tracks = presented;
- self.shown_closing = presented_closing;
- self.shown_closing_len = nclosing;
- self.shown = presented_layout;
- self.acknowledged = true;
- self.pending = false;
- if (tracks.len == 0) {
- self.tracks = @splat(null);
- self.closing_len = 0;
- self.diff_state = .none;
- self.capturePrevious(p.gpa, &p.surface);
- }
- }
-
- fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
- self.diff_state = .none;
- const cells = surface.cells;
- if (cells.len == 0) {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- }
- if (self.previous_cells.len != cells.len) {
- const next = gpa.alloc(pardes.Cell, cells.len) catch {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- };
- if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
- self.previous_cells = next;
- }
- for (&self.previous_body_layers, surface.bodyLayers()) |*old, *current| {
- const count = if (current.rows > 0) current.cells.len else 0;
- const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- };
- if (count > 0) @memcpy(storage, current.cells);
- old.* = current.*;
- old.cells = storage;
- }
- for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| {
- const count = if (current.cols > 0) current.cells.len else 0;
- const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- };
- if (count > 0) @memcpy(storage, current.cells);
- old.* = current.*;
- old.cells = storage;
- }
- @memcpy(self.previous_cells, cells);
- self.previous_cols = surface.cols;
- self.previous_rows = surface.rows;
- self.previous_valid = true;
- self.previous_layout = if (self.submitted_ready)
- self.submitted
- else
- @splat(null);
- }
-
- pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff {
- if (self.diff_state != .ready or col >= cols or row >= rows or
- self.diffs.len != @as(usize, cols) * rows) return .unchanged;
- return self.diffs[@as(usize, row) * cols + col];
- }
-
- pub const Point = struct { x: f32, y: f32 };
-
- /// Coordinates stay fractional until the host resolves its body font grid.
- pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32, y: f32) ?Point {
- if (self.pending or !std.math.isFinite(x) or !std.math.isFinite(y) or
- x < 0 or y < 0 or x >= @as(f32, @floatFromInt(cols)) or y >= @as(f32, @floatFromInt(rows))) return null;
- var closing = self.shown_closing_len;
- while (closing > 0) {
- closing -= 1;
- const track = self.shown_closing[closing];
- if (track.active() and boxContainsPoint(track.contentBox(), x, y) and
- boxContainsPoint(track.presented(), x, y)) return null;
- }
- for ([_]Phase{ .opening, .moving }) |phase| {
- var index = self.shown_tracks.len;
- while (index > 0) {
- index -= 1;
- const track = self.shown_tracks[index] orelse continue;
- if (!track.active() or track.phase != phase) continue;
- switch (track.effect) {
- .slide, .zoom, .vertical => {
- const target = track.contentBox();
- if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue;
- const shown = track.presented();
- if (!boxContainsPoint(shown, x, y)) {
- if (track.effect == .vertical) return null;
- continue;
- }
- return .{
- .x = target.x + (x - shown.x) / shown.w * target.w,
- .y = target.y + (y - shown.y) / shown.h * target.h,
- };
- },
- else => {
- if (!boxContainsPoint(track.to, x, y)) continue;
- _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
- return .{ .x = x, .y = y };
- },
- }
- }
- }
- _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
- return .{ .x = x, .y = y };
- }
-
- fn boxContainsPoint(box: Box, x: f32, y: f32) bool {
- return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h;
- }
-
- pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition {
- if (self.pending) return null;
- var closing = self.shown_closing_len;
- while (closing > 0) {
- closing -= 1;
- const track = self.shown_closing[closing];
- if (!track.active()) continue;
- if (boxContainsCell(track.contentBox(), col, row) and
- boxContainsCell(track.presented(), col, row)) return null;
- }
- const phases = [_]Phase{ .opening, .moving };
- for (phases) |phase| {
- // Backends paint pane slots forward within a phase. Probe them in
- // reverse so overlapping transition quads address the top pixel.
- var index = self.shown_tracks.len;
- while (index > 0) {
- index -= 1;
- const track = self.shown_tracks[index] orelse continue;
- if (!track.active() or track.phase != phase) continue;
- switch (track.effect) {
- .slide, .zoom => {
- const shown = track.presented();
- if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue;
- const x = @as(f32, @floatFromInt(col)) + 0.5;
- const y = @as(f32, @floatFromInt(row)) + 0.5;
- const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
- const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
- const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w));
- const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h));
- return .{
- .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))),
- .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))),
- };
- },
- .vertical => {
- const clip = track.contentBox();
- if (!boxContainsCell(clip, col, row)) continue;
- const shown = track.presented();
- if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row))
- return null;
- const x = @as(f32, @floatFromInt(col)) + 0.5;
- const y = @as(f32, @floatFromInt(row)) + 0.5;
- const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
- const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
- return .{
- .col = @intFromFloat(@floor(clip.x + u * clip.w)),
- .row = @intFromFloat(@floor(clip.y + v * clip.h)),
- };
- },
- .dissolve, .ascii => {
- if (!boxContainsCell(track.to, col, row)) continue;
- const cell_diff = self.cellDiff(cols, rows, col, row);
- if (!cell_diff.changed())
- return .{ .col = col, .row = row };
- const relative_col: u16 = @intFromFloat(@floor(
- @as(f32, @floatFromInt(col)) + 0.5 - track.to.x,
- ));
- const relative_row: u16 = @intFromFloat(@floor(
- @as(f32, @floatFromInt(row)) + 0.5 - track.to.y,
- ));
- const visible = switch (track.effect) {
- .dissolve => dissolveRevealed(
- track.serial,
- relative_col,
- relative_row,
- track.amount(),
- ),
- .ascii => switch (cell_diff) {
- .ascii => |diff| diff.complete(track.frame),
- .unchanged, .visual => true,
- },
- else => unreachable,
- };
- return if (visible) .{ .col = col, .row = row } else null;
- },
- .edges, .fall, .wave, .curtain, .scramble, .typewriter => {
- if (!boxContainsCell(track.to, col, row)) continue;
- const area = CellArea.of(track.to);
- const settled = std.meta.eql(
- charSource(
- track,
- col -| area.x0,
- row -| area.y0,
- area,
- ),
- CharSource.settled,
- );
- return if (settled) .{ .col = col, .row = row } else null;
- },
- .off => {},
- }
- }
- }
- for (self.shown_tracks) |maybe| {
- const track = maybe orelse continue;
- if (!track.active() or (track.effect != .slide and track.effect != .zoom and
- track.effect != .vertical)) continue;
- if (boxContainsCell(track.to, col, row)) return null;
- }
- return .{ .col = col, .row = row };
- }
-
- fn boxContainsCell(box: Box, col: u16, row: u16) bool {
- const x = @as(f32, @floatFromInt(col)) + 0.5;
- const y = @as(f32, @floatFromInt(row)) + 0.5;
- return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
- }
-
- pub fn cancel(self: *Presentation) void {
- self.tracks = @splat(null);
- self.closing_len = 0;
- self.shown_tracks = @splat(null);
- self.shown_closing_len = 0;
- self.diff_state = .none;
- self.pending = self.acknowledged;
- }
-
- fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool {
- const count = surface.cells.len;
- if (!self.previous_valid or
- self.previous_cols != surface.cols or
- self.previous_rows != surface.rows or
- self.previous_cells.len != count) return false;
- if (self.diffs.len != count) {
- const next = try gpa.alloc(pardes.PanelCellDiff, count);
- if (self.diffs.len > 0) gpa.free(self.diffs);
- self.diffs = next;
- }
- for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new|
- diff.* = pardes.PanelCellDiff.between(old, new);
- for (&self.tracks) |*slot| {
- const track = if (slot.*) |*track| track else continue;
- if (track.effect != .ascii) continue;
- var longest: u16 = 1;
- var row: u16 = 0;
- while (row < surface.rows) : (row += 1) {
- var col: u16 = 0;
- while (col < surface.cols) : (col += 1) {
- if (!boxContainsCell(track.to, col, row)) continue;
- const index = @as(usize, row) * surface.cols + col;
- switch (self.diffs[index]) {
- .ascii => |diff| longest = @max(longest, diff.frameCount()),
- .unchanged, .visual => {},
- }
- }
- }
- track.frame_count = @max(track.frame_count, longest);
- }
- self.diff_state = .ready;
- return true;
- }
-
- pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void {
- self.submitted = @splat(null);
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- self.submitted[id] = .{
- .serial = pane.serial,
- .box = panelBox(p.rects[id]),
- };
- }
- self.submitted_ready = true;
- if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) {
- for (&self.tracks) |*slot| {
- const track = slot.* orelse continue;
- if (track.effect.needsPreviousGrid()) slot.* = null;
- }
- self.closing_len = 0;
- self.diff_state = .none;
- }
- if (self.diff_state == .ready) {
- s.previous_cells = self.previous_cells;
- s.previous_body_layers = &self.previous_body_layers;
- s.previous_tag_layers = &self.previous_tag_layers;
- s.cell_diffs = self.diffs;
- }
- s.npanel_tracks = paintOrder(
- &self.tracks,
- self.closing[0..self.closing_len],
- &s.panel_tracks,
- );
- }
-
- pub fn animating(self: *const Presentation) bool {
- for (self.tracks) |track| if (track != null and track.?.active()) return true;
- for (self.closing[0..self.closing_len]) |track|
- if (track.active()) return true;
- return false;
- }
-
- test "presentation cache allocation failures retain ownership and allow retry" {
- var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
- const gpa = allocator.allocator();
- var state: Presentation = .{};
- defer state.deinit(gpa);
- var cells: [3]pardes.Cell = @splat(.{});
- cells[0].default = false;
- cells[0].text[0] = 'a';
- var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] };
- state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } };
- state.submitted_ready = true;
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(state.previous_valid);
- try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial);
- const previous = state.previous_cells.ptr;
- const allocated = allocator.allocations;
- allocator.fail_index = allocator.alloc_index;
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(state.previous_valid);
- try std.testing.expectEqual(allocated, allocator.allocations);
- try std.testing.expect(!allocator.has_induced_failure);
-
- surface.cols = 3;
- surface.cells = &cells;
- state.submitted[0].?.box.w = 3;
- state.diff_state = .ready;
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(allocator.has_induced_failure);
- try std.testing.expect(!state.previous_valid);
- try std.testing.expectEqual(previous, state.previous_cells.ptr);
- try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len);
- try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]);
- try std.testing.expect(state.previous_layout[0] == null);
- try std.testing.expectEqual(.none, state.diff_state);
-
- allocator.fail_index = std.math.maxInt(usize);
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(state.previous_valid);
- try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len);
- try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w);
- state.diff_state = .requested;
- allocator.fail_index = allocator.alloc_index;
- try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface));
- try std.testing.expectEqual(.requested, state.diff_state);
- try std.testing.expectEqual(@as(usize, 0), state.diffs.len);
- allocator.fail_index = std.math.maxInt(usize);
- try std.testing.expect(try state.prepareDiff(gpa, &surface));
- try std.testing.expectEqual(.ready, state.diff_state);
- try std.testing.expectEqual(@as(usize, 3), state.diffs.len);
- for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff);
- }
-
- test "presentation submission failure preserves shown geometry until retry is acknowledged" {
- var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
- const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
- p.acknowledgePanelPresentation(&.{});
- const shown = p.presentation.shown;
- const previous = p.presentation.previous_cells.ptr;
- p.presentation.diff_state = .requested;
- p.presentation.pending = true;
- allocator.fail_index = allocator.alloc_index;
- try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface));
- try std.testing.expectEqualDeep(shown, p.presentation.shown);
- try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr);
- try std.testing.expectEqual(.requested, p.presentation.diff_state);
- try std.testing.expect(p.presentation.pending);
- try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null);
-
- allocator.fail_index = std.math.maxInt(usize);
- try p.presentation.submit(p, &p.surface);
- try std.testing.expectEqualDeep(shown, p.presentation.shown);
- try std.testing.expect(p.presentation.pending);
- p.acknowledgePanelPresentation(&.{});
- try std.testing.expect(!p.presentation.pending);
- try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?);
- }
-};
-
pub const column_weight_unit: u64 = 1 << 32;
pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS;
@@ -1250,8 +600,6 @@ pub fn compute(p: *Pardes) void {
}
}
-pub const ascii_max_movement_frames: u16 = 12;
-
test "collapse transfers height upward and restores the original layout" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 });
defer p.deinit();
@@ -1457,789 +805,6 @@ test "collapse dump restores folded state and retained expansion weight" {
try std.testing.expectEqual(restored.screen_h - restored.rects[0].y, restored.rects[0].h);
}
-pub const Easing = enum(u8) {
- linear,
- smooth,
- /// Quintic ease-in-out. It creeps at both ends and crosses the middle of
- /// the distance fast, inside the same frame count a linear walk would use.
- smoother,
- in_cubic,
- out_cubic,
- out_back,
-};
-
-pub const Transition = enum(u8) {
- // Numeric values are shared with the GUI shader ABI.
- off = 0,
- slide = 1,
- zoom = 2,
- dissolve = 3,
- ascii = 4,
- vertical = 5,
- edges = 6,
- fall = 7,
- wave = 8,
- curtain = 9,
- scramble = 10,
- typewriter = 11,
-
- pub fn easing(effect: Transition) Easing {
- return switch (effect) {
- .off, .dissolve, .wave => .smooth,
- .slide, .vertical, .edges => .out_cubic,
- .zoom => .out_back,
- // Character walks and per-cell locks read best with a slow start,
- // a fast middle, and a slow settle over their fixed frame count.
- .ascii, .fall, .scramble => .smoother,
- // A sweep and a typewriter are constant-rate by definition: easing
- // their head would make the pass visibly hesitate mid-pane.
- .curtain, .typewriter => .linear,
- };
- }
-
- pub fn frames(effect: Transition) u16 {
- return switch (effect) {
- .off => 0,
- .slide => 12,
- .zoom => 14,
- .dissolve => 10,
- .ascii => ascii_max_movement_frames + 1,
- .vertical => 12,
- .edges, .curtain, .scramble => 12,
- // Travelling motion needs a couple more samples than a lock or a
- // rigid slide before it stops reading as a jump.
- .fall, .wave, .typewriter => 14,
- };
- }
-
- pub fn composedByCore(effect: Transition) bool {
- return switch (effect) {
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true,
- .off, .slide, .zoom, .dissolve, .vertical => false,
- };
- }
-
- pub fn needsPreviousGrid(effect: Transition) bool {
- return effect == .dissolve or effect == .vertical or effect.composedByCore();
- }
-
- pub fn lifecycleOnly(effect: Transition) bool {
- return effect == .vertical;
- }
-};
-
-pub const SceneEffect = struct {
- crt: bool = false,
- ripple: bool = false,
- glitch: bool = false,
-};
-
-pub const Phase = enum(u8) {
- opening = 0,
- moving = 1,
- /// Presentation-only content whose pane lifetime has already ended.
- /// It is never a valid input target.
- closing = 2,
-};
-
-pub const Box = extern struct {
- x: f32 = 0,
- y: f32 = 0,
- w: f32 = 0,
- h: f32 = 0,
-
- pub fn eql(a: Box, b: Box) bool {
- return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h;
- }
-
- // A cell belongs to a fractional box when its center lies inside.
- pub fn contains(box: Box, col: u16, row: u16) bool {
- const x: f32 = @floatFromInt(col);
- const y: f32 = @floatFromInt(row);
- return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
- }
-};
-
-// Drawing and hit testing share this moving/opening/closing order.
-pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize {
- var len: usize = 0;
- for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| {
- const track = maybe orelse continue;
- if (!track.active() or track.phase != phase) continue;
- if (len == out.len) return len;
- out[len] = track;
- len += 1;
- };
- for (closing) |track| {
- if (!track.active()) continue;
- if (len == out.len) return len;
- out[len] = track;
- len += 1;
- }
- return len;
-}
-
-/// One POD record is enough for every backend. `from` and `to` are logical
-/// cell boxes; frontends convert them to pixels only at their render edge.
-pub const Track = extern struct {
- serial: u32 = 0,
- pane: u8 = 0,
- phase: Phase = .moving,
- effect: Transition = .off,
- _padding: u8 = 0,
- frame: u16 = 0,
- frame_count: u16 = 0,
- from: Box = .{},
- to: Box = .{},
-
- pub fn active(track: Track) bool {
- return track.effect != .off and track.frame < track.frames();
- }
-
- pub fn frames(track: Track) u16 {
- return if (track.frame_count != 0) track.frame_count else track.effect.frames();
- }
-
- pub fn amount(track: Track) f32 {
- // Opening rises quickly and settles; closing reverses that motion and
- // accelerates down out of the fixed clip.
- if (track.phase == .closing and track.effect == .vertical)
- return progressEased(.in_cubic, track.frames(), track.frame);
- return progressEased(track.effect.easing(), track.frames(), track.frame);
- }
-
- pub fn presented(track: Track) Box {
- return lerpBox(track.from, track.to, track.amount());
- }
-
- pub fn visualBox(track: Track) Box {
- return switch (track.effect) {
- .slide, .zoom, .vertical => track.presented(),
- .off, .dissolve => track.to,
- // Every character effect stays inside the pane's final rectangle.
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to,
- };
- }
-
- pub fn contentBox(track: Track) Box {
- return if (track.phase == .closing) track.from else track.to;
- }
-};
-
-pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box {
- return switch (effect) {
- .slide => blk: {
- var from = target;
- const middle = target.x + target.w * 0.5;
- from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5)
- -target.w
- else
- @floatFromInt(screen_width);
- break :blk from;
- },
- .zoom => .{
- .x = target.x + target.w * 0.5,
- .y = target.y + target.h * 0.5,
- .w = 0,
- .h = 0,
- },
- .vertical => blk: {
- var from = target;
- from.y += target.h;
- break :blk from;
- },
- .off, .dissolve => target,
- // Character effects own the glyphs inside a fixed rectangle, so their
- // panel opens at exactly its final geometry.
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target,
- };
-}
-
-pub fn closingBox(effect: Transition, source: Box) Box {
- return switch (effect) {
- .vertical => blk: {
- var to = source;
- to.y += source.h;
- break :blk to;
- },
- else => source,
- };
-}
-
-pub fn sample(easing: Easing, raw: f32) f32 {
- const t = std.math.clamp(raw, 0.0, 1.0);
- return switch (easing) {
- .linear => t,
- .smooth => t * t * (3.0 - 2.0 * t),
- .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0),
- .in_cubic => t * t * t,
- .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t),
- // Robert Penner's ease-out-back polynomial. It intentionally travels
- // a little past one before settling exactly on the endpoint.
- .out_back => blk: {
- const c1: f32 = 1.70158;
- const c3 = c1 + 1.0;
- const u = t - 1.0;
- break :blk 1.0 + c3 * u * u * u + c1 * u * u;
- },
- };
-}
-
-pub fn progress(effect: Transition, frame: u16) f32 {
- return progressEased(effect.easing(), effect.frames(), frame);
-}
-
-fn progressEased(easing: Easing, frames: u16, frame: u16) f32 {
- if (frames <= 1 or frame >= frames - 1) return 1.0;
- return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1)));
-}
-
-pub fn lerpBox(from: Box, to: Box, t: f32) Box {
- const u = @max(0.0, t);
- return .{
- .x = from.x + (to.x - from.x) * u,
- .y = from.y + (to.y - from.y) * u,
- .w = @max(0.0, from.w + (to.w - from.w) * u),
- .h = @max(0.0, from.h + (to.h - from.h) * u),
- };
-}
-
-/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only
-/// hashing means resizing or repainting a frame does not make cells flicker.
-pub fn cellNoise(serial: u32, col: u16, row: u16) f32 {
- var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b);
- x ^= x >> 16;
- x *%= 0x7feb_352d;
- x ^= x >> 15;
- x *%= 0x846c_a68b;
- x ^= x >> 16;
- return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0;
-}
-
-/// Whether a changed dissolve cell has crossed from the frozen old grid to
-/// the new one. Exact endpoints are part of the presentation contract.
-pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool {
- const t = std.math.clamp(raw_progress, 0.0, 1.0);
- if (t <= 0) return false;
- if (t >= 1) return true;
- return cellNoise(serial, col, row) < t;
-}
-
-pub const CellArea = struct {
- x0: u16 = 0,
- y0: u16 = 0,
- cols: u16 = 1,
- rows: u16 = 1,
-
- pub fn of(box: Box) CellArea {
- return .{
- .x0 = floorCell(box.x),
- .y0 = floorCell(box.y),
- .cols = ceilCell(box.w),
- .rows = ceilCell(box.h),
- };
- }
-};
-
-fn floorCell(value: f32) u16 {
- return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16))));
-}
-
-fn ceilCell(value: f32) u16 {
- return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16))));
-}
-
-pub const CharSource = union(enum) {
- /// Nothing has arrived here yet: keep the frozen old cell.
- old,
- at: Offset,
- /// Paint this printable byte in the destination cell's own style, whatever
- /// that cell holds — a caret marching over empty space is still a caret.
- byte: u8,
- churn: u8,
-
- pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 };
-
- pub const settled: CharSource = .{ .at = .{} };
-};
-
-pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource {
- const t = track.amount();
- if (t >= 1.0) return .settled;
- const w: f32 = @floatFromInt(area.cols);
- const h: f32 = @floatFromInt(area.rows);
- const c: f32 = @floatFromInt(col);
- const r: f32 = @floatFromInt(row);
- const remaining = 1.0 - t;
- return switch (track.effect) {
- .edges => blk: {
- const travel = cellsOf(remaining * (w + 1.0));
- break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } };
- },
- // Columns rain down, each with its own stable head start, so the pane
- // fills from the top and the last glyphs land at the bottom.
- .fall => blk: {
- const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4);
- if (local <= 0.0) break :blk .old;
- break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } };
- },
- // A vertical ripple travels left to right and its amplitude decays, so
- // the pane settles out of a wave instead of a fade.
- .wave => .{ .at = .{
- .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)),
- } },
- // A curtain of glyphs marches in from the right, column by column, left
- // to right; each column still has a short slide of its own.
- .curtain => blk: {
- const lead = t * (w + 1.0) - c;
- if (lead <= 0.0) break :blk .old;
- break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } };
- },
- // Every cell churns through printable ASCII and locks onto its final
- // glyph at its own stable threshold: the pane resolves out of noise.
- .scramble => blk: {
- if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled;
- const churn = cellNoise(
- track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f),
- col,
- row,
- );
- break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) };
- },
- // Reading-order reveal with a caret sitting on the write head.
- .typewriter => blk: {
- const head = t * w * h;
- const index = r * w + c;
- if (index + 1.0 <= head) break :blk .settled;
- if (index <= head) break :blk .{ .byte = '_' };
- break :blk .old;
- },
- // PanelAscii walks its own byte distance per cell, and the geometry
- // effects never reach this path at all.
- .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled,
- };
-}
-
-fn cellsOf(distance: f32) i32 {
- return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0)));
-}
-
-/// Remap track progress into one cell's own window. A stagger delays a glyph
-/// without making the effect as a whole end after its last frame.
-fn staggered(t: f32, delay: f32) f32 {
- if (delay >= 1.0) return t;
- return (t - delay) / (1.0 - delay);
-}
-
-test "easing presets have exact endpoints and intended shapes" {
- inline for (std.enums.values(Easing)) |easing| {
- try std.testing.expectEqual(@as(f32, 0), sample(easing, 0));
- try std.testing.expectEqual(@as(f32, 1), sample(easing, 1));
- }
- try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5));
- try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5));
- try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5));
- try std.testing.expect(sample(.out_back, 0.8) > 1.0);
- // Slow at both ends, fast through the middle, and symmetric about the
- // halfway point: the same curve the integer byte walk reproduces.
- try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5));
- try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15));
- try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85));
- try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) >
- sample(.linear, 0.6) - sample(.linear, 0.4));
-}
-
-test "transition progress completes exactly" {
- inline for (std.enums.values(Transition)) |effect| {
- try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames()));
- if (effect.frames() > 0)
- try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1));
- try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16)));
- }
- try std.testing.expectEqual(@as(f32, 1), progress(.off, 0));
-
- const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8));
- try std.testing.expectEqual(@as(f32, 0), shrinking.w);
- try std.testing.expectEqual(@as(f32, 0), shrinking.h);
- const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8));
- try std.testing.expect(opening.w > 100);
- try std.testing.expect(opening.h > 40);
-}
-
-test "character effects are core-composed and settle on the canonical glyph" {
- const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 };
- const area: CellArea = .of(box);
- try std.testing.expectEqual(@as(u16, 4), area.x0);
- try std.testing.expectEqual(@as(u16, 2), area.y0);
- try std.testing.expectEqual(@as(u16, 20), area.cols);
- try std.testing.expectEqual(@as(u16, 6), area.rows);
-
- inline for (std.enums.values(Transition)) |effect| {
- if (comptime !effect.composedByCore()) continue;
- // Core composition needs the frozen old grid for every glyph which has
- // not arrived, so no character effect may animate without it.
- try std.testing.expect(effect.needsPreviousGrid());
- if (comptime effect == .ascii) continue; // owns its own per-cell byte walk
-
- const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box };
- const first: Track = .{ .effect = effect, .frame = 0, .to = box };
- var moving = false;
- var row: u16 = 0;
- while (row < area.rows) : (row += 1) {
- var col: u16 = 0;
- while (col < area.cols) : (col += 1) {
- // The last active sample is the exact canonical grid: no cell
- // is displaced, churning, or still frozen.
- try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area));
- if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area)))
- moving = true;
- }
- }
- try std.testing.expect(moving);
- }
-}
-
-test "each character effect moves glyphs along its own axis" {
- const box: Box = .{ .w = 30, .h = 8 };
- const area: CellArea = .of(box);
-
- // Rows alternate which screen edge they come from, and every glyph in a row
- // travels as one rigid slide: one offset, no vertical component.
- var edges: Track = .{ .effect = .edges, .frame = 2, .to = box };
- const even = charSource(edges, 5, 0, area).at;
- const odd = charSource(edges, 5, 1, area).at;
- try std.testing.expect(even.cols > 0);
- try std.testing.expectEqual(-even.cols, odd.cols);
- try std.testing.expectEqual(@as(i32, 0), even.rows);
- try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at);
- edges.frame = 5;
- try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols);
-
- // Falling columns are vertical only, staggered, and sample from below the
- // destination because the new text is still above the pane.
- const fall: Track = .{ .effect = .fall, .frame = 4, .to = box };
- var falling = false;
- var col: u16 = 0;
- while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) {
- .old => {},
- .byte, .churn => return error.FallShouldNotChurn,
- .at => |offset| {
- try std.testing.expectEqual(@as(i32, 0), offset.cols);
- try std.testing.expect(offset.rows >= 0);
- if (offset.rows > 0) falling = true;
- },
- };
- try std.testing.expect(falling);
-
- // The wave displaces rows both ways as it travels, and only rows.
- const wave: Track = .{ .effect = .wave, .frame = 1, .to = box };
- var above = false;
- var below = false;
- col = 0;
- while (col < area.cols) : (col += 1) {
- const offset = charSource(wave, col, 3, area).at;
- try std.testing.expectEqual(@as(i32, 0), offset.cols);
- if (offset.rows < 0) above = true;
- if (offset.rows > 0) below = true;
- }
- try std.testing.expect(above and below);
-
- // The curtain has a head: columns behind it hold the old grid, columns the
- // head has passed are settled, and the head itself is still sliding.
- const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box };
- try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area));
- try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area));
- var sliding = false;
- col = 0;
- while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) {
- .at => |offset| if (offset.cols < 0) {
- sliding = true;
- },
- .old, .byte, .churn => {},
- };
- try std.testing.expect(sliding);
-
- var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box };
- var churning: usize = 0;
- var locked: usize = 0;
- var changed = false;
- col = 0;
- while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) {
- .churn => |byte| {
- try std.testing.expect(byte >= ' ' and byte <= '~');
- churning += 1;
- scramble.frame = 4;
- switch (charSource(scramble, col, 0, area)) {
- .churn => |next| changed = changed or next != byte,
- .old, .at, .byte => {},
- }
- scramble.frame = 3;
- },
- .at => locked += 1,
- .old, .byte => return error.ScrambleShouldNotFreeze,
- };
- try std.testing.expect(churning > 0 and locked > 0 and changed);
-
- // The typewriter writes in reading order with a caret on its head.
- const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box };
- try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area));
- try std.testing.expectEqual(
- CharSource{ .old = {} },
- charSource(typewriter, area.cols - 1, area.rows - 1, area),
- );
- var carets: usize = 0;
- var row: u16 = 0;
- while (row < area.rows) : (row += 1) {
- col = 0;
- while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) {
- .byte => |byte| {
- try std.testing.expectEqual(@as(u8, '_'), byte);
- carets += 1;
- },
- .old, .at, .churn => {},
- };
- }
- try std.testing.expectEqual(@as(usize, 1), carets);
-}
-
-test "opening presets separate geometry and content transitions" {
- const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 };
- try std.testing.expectEqual(target, openingBox(.ascii, target, 80));
- try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w);
- try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x);
- try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y);
- try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y);
-
- var track: Track = .{ .effect = .slide, .from = target, .to = target };
- try std.testing.expect(track.active());
- track.frame = track.effect.frames();
- try std.testing.expect(!track.active());
-
- track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target };
- try std.testing.expectEqual(target, track.visualBox());
-
- var closing: Track = .{
- .phase = .closing,
- .effect = .vertical,
- .from = target,
- .to = closingBox(.vertical, target),
- };
- try std.testing.expectEqual(target, closing.contentBox());
- closing.frame = 2;
- try std.testing.expect(closing.amount() < progress(.vertical, closing.frame));
-}
-
-test "dissolve has exact stable endpoints" {
- for (0..64) |col| {
- const x: u16 = @intCast(col);
- try std.testing.expect(!dissolveRevealed(42, x, 7, 0));
- try std.testing.expect(dissolveRevealed(42, x, 7, 1));
- if (dissolveRevealed(42, x, 7, 0.25))
- try std.testing.expect(dissolveRevealed(42, x, 7, 0.75));
- }
-}
-
-pub const Animation = struct {
- pub const frame_ms: u32 = 16;
- pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms;
- pub const transition_steps: u16 = 10;
-
- pub fn Transition(comptime Value: type) type {
- return struct {
- const Self = @This();
-
- from: Value,
- to: Value,
- displayed: Value,
- step: u16 = transition_steps,
-
- pub fn init(value: Value) Self {
- return .{ .from = value, .to = value, .displayed = value };
- }
-
- pub fn isActive(a: *const Self) bool {
- return a.step < transition_steps;
- }
-
- pub fn retarget(a: *Self, target: Value) void {
- a.from = a.displayed;
- a.to = target;
- a.step = if (std.meta.eql(a.from, target)) transition_steps else 0;
- if (a.step == transition_steps) a.displayed = target;
- }
-
- pub fn advance(a: *Self) void {
- if (!a.isActive()) return;
- a.step += 1;
- a.displayed = if (a.step == transition_steps)
- a.to
- else
- Value.interpolate(a.from, a.to, a.step, transition_steps);
- }
-
- pub fn snap(a: *Self, value: Value) void {
- a.* = init(value);
- }
- };
- }
-
- pub fn Immediate(comptime Value: type) type {
- return struct {
- const Self = @This();
-
- displayed: Value,
-
- pub fn init(value: Value) Self {
- return .{ .displayed = value };
- }
-
- pub fn isActive(_: *const Self) bool {
- return false;
- }
-
- pub fn retarget(a: *Self, target: Value) void {
- a.displayed = target;
- }
-
- pub fn advance(_: *Self) void {}
-
- pub fn snap(a: *Self, value: Value) void {
- a.displayed = value;
- }
- };
- }
-
- pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 {
- if (step == 0) return from;
- if (step >= steps) return to;
- var out: [3]u8 = undefined;
- for (&out, from, to) |*dst, a, b| {
- const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step;
- dst.* = @intCast((numerator + steps / 2) / steps);
- }
- return out;
- }
-
- const TestColor = struct {
- rgb: [3]u8,
-
- pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor {
- return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) };
- }
- };
-
- test "Immediate lands where a completed Transition lands" {
- const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
- const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
-
- var faded = Animation.Transition(TestColor).init(from);
- faded.retarget(to);
- for (0..transition_steps) |_| faded.advance();
-
- var instant = Immediate(TestColor).init(from);
- try std.testing.expect(!instant.isActive());
- instant.retarget(to);
- try std.testing.expectEqual(faded.displayed, instant.displayed);
-
- // Never active, so a frontend that renders only while something is animating stops immediately
- // rather than spending ten frames discovering there is nothing to draw.
- try std.testing.expect(!instant.isActive());
- instant.advance();
- try std.testing.expectEqual(to, instant.displayed);
-
- instant.snap(from);
- try std.testing.expectEqual(from, instant.displayed);
- }
-
- test "fixed-step interpolation has exact monotonic endpoints" {
- const Tween = Animation.Transition(TestColor);
- const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
- const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
- var tween = Tween.init(from);
- tween.retarget(to);
- try std.testing.expectEqual(from, tween.displayed);
-
- var previous = tween.displayed;
- for (0..transition_steps) |_| {
- tween.advance();
- try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]);
- try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]);
- try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]);
- previous = tween.displayed;
- }
- try std.testing.expect(!tween.isActive());
- try std.testing.expectEqual(to, tween.displayed);
- tween.advance();
- try std.testing.expectEqual(to, tween.displayed);
- }
-
- test "retarget starts at the currently displayed value" {
- const Tween = Animation.Transition(TestColor);
- const first: TestColor = .{ .rgb = .{ 0, 40, 200 } };
- const second: TestColor = .{ .rgb = .{ 200, 140, 0 } };
- const third: TestColor = .{ .rgb = .{ 20, 10, 250 } };
- var tween = Tween.init(first);
- tween.retarget(second);
- tween.advance();
- tween.advance();
- tween.advance();
- const on_screen = tween.displayed;
-
- tween.retarget(third);
- try std.testing.expectEqual(on_screen, tween.from);
- try std.testing.expectEqual(on_screen, tween.displayed);
- try std.testing.expect(tween.isActive());
- for (0..transition_steps) |_| tween.advance();
- try std.testing.expectEqual(third, tween.displayed);
- }
-};
-
-test "fractional panel inverse preserves compact header rows through slide and zoom" {
- const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 };
- for ([_]Transition{ .slide, .zoom, .vertical }) |effect| {
- var presentation: Presentation = .{};
- const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } };
- presentation.shown_tracks[0] = track;
- const shown = track.presented();
- for (0..4) |index| {
- const row: u16 = @intCast(index);
- const source_x: f32 = 5.3;
- const source_y = (layer.rowTop(row, 20, 10) + 5) / 20;
- const x = shown.x + (source_x - track.to.x) / track.to.w * shown.w;
- const y = shown.y + (source_y - track.to.y) / track.to.h * shown.h;
- const point = presentation.pointerFractional(80, 30, x, y).?;
- try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001);
- try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001);
- const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?;
- try std.testing.expectEqual(row, body_hit.row);
- }
- }
-}
-
-test "presentation snapshots own compact body and tag rows across later edits" {
- var state: Presentation = .{};
- defer state.deinit(std.testing.allocator);
- var cells: [1]pardes.Cell = .{.{}};
- var body: [2]pardes.Cell = .{ .{}, .{} };
- body[0].text[0] = 'A';
- var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells };
- surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body };
- var tag: [1]pardes.Cell = .{.{}};
- tag[0].text[0] = 'T';
- surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag };
- state.capturePrevious(std.testing.allocator, &surface);
- try std.testing.expect(state.previous_valid);
- tag[0].text[0] = 'U';
- try std.testing.expectEqual(@as(u8, 'T'), state.previous_tag_layers[0].cells[0].text[0]);
- body[0].text[0] = 'B';
- surface.body_layers[0].context_rows = 0;
- try std.testing.expectEqual(@as(u8, 'A'), state.previous_body_layers[0].cells[0].text[0]);
- try std.testing.expectEqual(@as(u16, 1), state.previous_body_layers[0].context_rows);
- state.capturePrevious(std.testing.allocator, &surface);
- try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]);
- try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]);
-}
-
test "column reorder moves only crossed state and preserves weights" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 173, .rows = 20 });
defer p.deinit();
diff --git a/src/look.zig b/src/look.zig
index d7e763d3..8f9e15f2 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -8,6 +8,7 @@ const pdf_enabled = @import("pardes_config").mupdf;
const fs = @import("fs.zig");
const platform_has_fs = fs.platform_has_fs;
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const modal = @import("modal.zig");
const filesystem = @import("fs.zig");
const panes = @import("panes.zig");
@@ -1367,13 +1368,13 @@ test "stationary Look hover follows only acknowledged panel samples" {
defer p.deinit();
const pane = p.panes[0].?;
const rect = p.rects[0];
- const target: layout.Box = .{
+ const target: animation.Box = .{
.x = @floatFromInt(rect.x),
.y = @floatFromInt(rect.y),
.w = @floatFromInt(rect.w),
.h = @floatFromInt(rect.h),
};
- var track: layout.Track = .{
+ var track: animation.Track = .{
.serial = pane.serial,
.pane = 0,
.phase = .moving,
diff --git a/src/macos.zig b/src/macos.zig
index 62858887..854b24f8 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -12,6 +12,7 @@ const colors = @import("colors.zig");
const look = @import("look.zig");
const message = pardes.Messages.Message;
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const file_watch = @import("file_watch.zig");
const image = if (pardes.pdf_enabled) @import("image.zig") else struct {};
const host_io = @import("host_io.zig");
@@ -72,8 +73,8 @@ pub const Scene = extern struct {
frame: u32 = 0,
};
-pub const PanelBox = layout.Box;
-pub const PanelTrack = layout.Track;
+pub const PanelBox = animation.Box;
+pub const PanelTrack = animation.Track;
pub const Image = extern struct {
serial: u32,
@@ -117,7 +118,7 @@ const scene_frame_hz: u32 = 60;
const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s;
-const max_tick_catch_up_ns: u64 = 4 * pardes.layout.Animation.frame_ns;
+const max_tick_catch_up_ns: u64 = 4 * pardes.animation.Animation.frame_ns;
const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES);
const watch_slot_count = pardes.MAX_PANES + 1;
@@ -849,7 +850,7 @@ export fn pardes_should_quit() bool {
return st.core.quit;
}
-fn encodeSceneEffects(effects: layout.SceneEffect) u32 {
+fn encodeSceneEffects(effects: animation.SceneEffect) u32 {
var flags: u32 = 0;
if (effects.crt) flags |= scene_flag_crt;
if (effects.ripple) flags |= scene_flag_ripple;
@@ -869,18 +870,18 @@ const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 };
fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend {
const measured = if (previous_ns == 0 or now_ns <= previous_ns)
- pardes.layout.Animation.frame_ns
+ pardes.animation.Animation.frame_ns
else
now_ns - previous_ns;
const elapsed = @min(measured, max_tick_catch_up_ns);
var bank = bank_ns +| elapsed;
var steps: u32 = 0;
- while (bank >= pardes.layout.Animation.frame_ns) : (steps += 1) bank -= pardes.layout.Animation.frame_ns;
+ while (bank >= pardes.animation.Animation.frame_ns) : (steps += 1) bank -= pardes.animation.Animation.frame_ns;
return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank };
}
test "the animation clock spends real time, not callbacks" {
- const frame = pardes.layout.Animation.frame_ns;
+ const frame = pardes.animation.Animation.frame_ns;
const expectEqual = std.testing.expectEqual;
const first = spendTickTime(0, 999 * std.time.ns_per_s, 0);
@@ -2844,21 +2845,21 @@ test "pardes.h matches the Zig boundary" {
try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt);
try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple);
try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch);
- try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(layout.Phase.opening));
- try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(layout.Phase.moving));
- try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(layout.Phase.closing));
- try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(layout.Transition.off));
- try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(layout.Transition.slide));
- try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(layout.Transition.zoom));
- try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(layout.Transition.dissolve));
- try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(layout.Transition.ascii));
- try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(layout.Transition.vertical));
- try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(layout.Transition.edges));
- try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(layout.Transition.fall));
- try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(layout.Transition.wave));
- try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(layout.Transition.curtain));
- try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(layout.Transition.scramble));
- try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(layout.Transition.typewriter));
+ try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(animation.Phase.opening));
+ try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(animation.Phase.moving));
+ try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(animation.Phase.closing));
+ try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(animation.Transition.off));
+ try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(animation.Transition.slide));
+ try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(animation.Transition.zoom));
+ try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(animation.Transition.dissolve));
+ try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(animation.Transition.ascii));
+ try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(animation.Transition.vertical));
+ try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(animation.Transition.edges));
+ try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(animation.Transition.fall));
+ try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(animation.Transition.wave));
+ try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(animation.Transition.curtain));
+ try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(animation.Transition.scramble));
+ try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(animation.Transition.typewriter));
try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter);
try expectEqual(@as(u21, c.PARDES_KEY_ESCAPE), pardes.Key.escape);
diff --git a/src/mouse.zig b/src/mouse.zig
index beb2e624..5ab6fe2b 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -7,6 +7,8 @@ const exec = @import("exec.zig");
const look = @import("look.zig");
const std = @import("std");
const layout = @import("layout.zig");
+const Presentation = @import("Presentation.zig");
+const animation = @import("animation.zig");
const modal = @import("modal.zig");
const ctlfs = @import("ninep/tree.zig");
const panes = @import("panes.zig");
@@ -1233,7 +1235,7 @@ test "pointer coordinates follow presented panel geometry" {
defer p.deinit();
const pane = p.panes[0].?;
- const moving: layout.Track = .{
+ const moving: animation.Track = .{
.serial = pane.serial,
.pane = 0,
.phase = .moving,
@@ -1244,13 +1246,13 @@ test "pointer coordinates follow presented panel geometry" {
p.acknowledgePanelPresentation(&.{moving});
// One shared physical-to-logical map feeds all pointer gestures. The
// selected sample is 26.25% across and 35% down the presented rectangle.
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?);
// Canonical cells covered only by the not-yet-arrived target are inert.
try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null);
// Unrelated screen space remains in the ordinary grid coordinate system.
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?);
- p.acknowledgePanelPresentation(&.{layout.Track{
+ p.acknowledgePanelPresentation(&.{animation.Track{
.serial = pane.serial,
.pane = 0,
.phase = .opening,
@@ -1262,7 +1264,7 @@ test "pointer coordinates follow presented panel geometry" {
// Tracks paint in pane-slot order inside a phase. The later slot is the
// top quad and therefore owns an overlap, even though both are moving.
- const overlap = [_]layout.Track{ .{
+ const overlap = [_]animation.Track{ .{
.serial = pane.serial,
.pane = 0,
.phase = .moving,
@@ -1280,7 +1282,7 @@ test "pointer coordinates follow presented panel geometry" {
p.presentation.shown_tracks = @splat(null);
p.presentation.shown_tracks[0] = overlap[0];
p.presentation.shown_tracks[1] = overlap[1];
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?);
}
test "queued pointer input is inert until a changed layout is presented" {
@@ -1296,7 +1298,7 @@ test "queued pointer input is inert until a changed layout is presented" {
try std.testing.expect(p.presentation.pending);
try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null);
- var tracks: [MAX_PANES]layout.Track = undefined;
+ var tracks: [MAX_PANES]animation.Track = undefined;
var len: usize = 0;
for (p.presentation.tracks) |maybe| if (maybe) |track| {
tracks[len] = track;
@@ -1313,7 +1315,7 @@ test "pointer rejects panel content cells which have not materialized" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true });
defer p.deinit();
const pane = p.panes[0].?;
- const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 };
+ const target: animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 };
p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h);
@memset(p.presentation.diffs, .unchanged);
for (2..6) |row| {
@@ -1328,9 +1330,9 @@ test "pointer rejects panel content cells which have not materialized" {
}
p.presentation.diff_state = .ready;
- for ([_]layout.Transition{ .dissolve, .ascii }) |effect| {
+ for ([_]animation.Transition{ .dissolve, .ascii }) |effect| {
const frame: u16 = if (effect == .dissolve) 3 else 5;
- const track: layout.Track = .{
+ const track: animation.Track = .{
.serial = pane.serial,
.pane = 0,
.phase = .opening,
@@ -1349,7 +1351,7 @@ test "pointer rejects panel content cells which have not materialized" {
const col: u16 = @intCast(4 + relative_col);
const row: u16 = @intCast(2 + relative_row);
const visible = switch (effect) {
- .dissolve => layout.dissolveRevealed(
+ .dissolve => animation.dissolveRevealed(
pane.serial,
rcol,
rrow,
@@ -1364,7 +1366,7 @@ test "pointer rejects panel content cells which have not materialized" {
const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row);
try std.testing.expectEqual(visible, mapped != null);
if (mapped) |point|
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = col, .row = row }, point);
saw_visible = saw_visible or visible;
saw_hidden = saw_hidden or !visible;
};
@@ -1380,9 +1382,9 @@ test "unchanged content cells remain clickable through data effects" {
p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h);
@memset(p.presentation.diffs, .unchanged);
p.presentation.diff_state = .ready;
- const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 };
+ const box: animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 };
- for ([_]layout.Transition{ .dissolve, .ascii }) |effect| {
+ for ([_]animation.Transition{ .dissolve, .ascii }) |effect| {
p.acknowledgePanelPresentation(&.{.{
.serial = pane.serial,
.pane = 0,
@@ -1391,7 +1393,7 @@ test "unchanged content cells remain clickable through data effects" {
.from = box,
.to = box,
}});
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?);
}
}
@@ -1400,7 +1402,7 @@ test "held drag follows acknowledged panels and balances an invisible release" {
defer p.deinit();
const pane = p.panes[0].?;
const target = layout.panelBox(p.rects[0]);
- var track: layout.Track = .{
+ var track: animation.Track = .{
.serial = pane.serial,
.pane = 0,
.phase = .moving,
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
index bc2788ec..88947d0d 100644
--- a/src/ninep/screen.zig
+++ b/src/ninep/screen.zig
@@ -245,7 +245,7 @@ test "screen snapshots preserve rendered cells and styles until their handle is
test "screen inspection preserves acknowledged presentation and the next real frame" {
const gpa = testing.allocator;
- for (std.enums.values(pardes.layout.Transition)) |transition| {
+ for (std.enums.values(pardes.animation.Transition)) |transition| {
errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)});
const control = try withFile(gpa, "const value = 1;\n");
defer control.deinit();
diff --git a/src/pardes.zig b/src/pardes.zig
index 6e78380a..bfd8a4f6 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1,5 +1,7 @@
const std = @import("std");
pub const layout = @import("layout.zig");
+pub const animation = @import("animation.zig");
+pub const Presentation = @import("Presentation.zig");
const uucode = @import("uucode");
const vaxis = @import("vaxis");
pub const modal = @import("modal.zig");
@@ -16,6 +18,7 @@ pub const mouse = @import("mouse.zig");
pub const normal = @import("normal.zig");
pub const edit = @import("edit.zig");
pub const body_layer = @import("body_layer.zig");
+pub const draw = @import("draw.zig");
pub const Messages = @import("Messages.zig");
pub const Pipe = @import("Pipe.zig");
pub const colors = @import("colors.zig");
@@ -284,7 +287,7 @@ pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 {
return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2);
}
-pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 {
+pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?animation.Track) f32 {
if (row < p.topBarHeight()) return 0;
if (row < p.topBarHeight() + p.columnBarHeight()) {
for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |x, w|
@@ -315,7 +318,7 @@ pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 {
}
test "paint order is moving, then opening, then closing tombstones on top" {
- const Track = layout.Track;
+ const Track = animation.Track;
// Deliberately interleaved on the way in: the phases are what order the
// output, not the slot they happened to occupy.
const live = [_]?Track{
@@ -329,7 +332,7 @@ test "paint order is moving, then opening, then closing tombstones on top" {
.{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical },
};
var out: [8]Track = undefined;
- const len = layout.paintOrder(&live, &closing, &out);
+ const len = animation.paintOrder(&live, &closing, &out);
var serials: [8]u32 = undefined;
for (out[0..len], 0..) |track, i| serials[i] = track.serial;
@@ -338,7 +341,7 @@ test "paint order is moving, then opening, then closing tombstones on top" {
// A host's array is fixed-size and the core's is not its business: writing
// past it would be a buffer overrun in whichever shell had the smaller one.
var tight: [2]Track = undefined;
- try std.testing.expectEqual(@as(usize, 2), layout.paintOrder(&live, &closing, &tight));
+ try std.testing.expectEqual(@as(usize, 2), animation.paintOrder(&live, &closing, &tight));
try std.testing.expectEqual(@as(u32, 12), tight[0].serial);
try std.testing.expectEqual(@as(u32, 15), tight[1].serial);
}
@@ -458,6 +461,7 @@ test {
_ = @import("colors.zig");
_ = @import("surface.zig");
_ = @import("body_layer.zig");
+ _ = @import("draw.zig");
_ = @import("tag_layer.zig");
}
@@ -2223,7 +2227,7 @@ test "Font keeps requested and acknowledged faces as separate plain state" {
try std.testing.expectEqualStrings(installed[0].path, request);
try std.testing.expect(p.settings.font.pending);
try std.testing.expect(p.takeFontRequest() == null);
- const fake_track: layout.Track = .{
+ const fake_track: animation.Track = .{
.serial = p.panes[p.active].?.serial,
.pane = @intCast(p.active),
.effect = .slide,
@@ -2329,7 +2333,7 @@ test "restored sessions animate layout changes after bootstrap" {
source.dump_out.?,
);
defer restored.deinit();
- try std.testing.expectEqual(layout.Transition.slide, restored.settings.panel_transition);
+ try std.testing.expectEqual(animation.Transition.slide, restored.settings.panel_transition);
try std.testing.expect(!restored.animationActive());
var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
@@ -3284,8 +3288,8 @@ test "layout commits publish finite tracks only for changed panes" {
try std.testing.expect(p.presentation.tracks[1] == null);
try std.testing.expect(p.presentation.tracks[0] != null);
try std.testing.expect(p.presentation.tracks[2] != null);
- try std.testing.expectEqual(layout.Phase.moving, p.presentation.tracks[0].?.phase);
- try std.testing.expectEqual(layout.Phase.opening, p.presentation.tracks[2].?.phase);
+ try std.testing.expectEqual(animation.Phase.moving, p.presentation.tracks[0].?.phase);
+ try std.testing.expectEqual(animation.Phase.opening, p.presentation.tracks[2].?.phase);
try std.testing.expectEqual(untouched, p.rects[1]);
try std.testing.expect(p.animationActive());
@@ -3294,7 +3298,7 @@ test "layout commits publish finite tracks only for changed panes" {
const surface = try p.render(frame.allocator());
try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len);
- for (0..layout.Transition.slide.frames()) |_| p.update(.tick);
+ for (0..animation.Transition.slide.frames()) |_| p.update(.tick);
try std.testing.expect(!p.animationActive());
_ = frame.reset(.retain_capacity);
try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len);
@@ -3363,7 +3367,7 @@ test "vertical close samples only a canonical baseline containing that pane" {
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
p.sync();
- for (0..layout.Transition.vertical.frames()) |_| p.update(.tick);
+ for (0..animation.Transition.vertical.frames()) |_| p.update(.tick);
_ = frame.reset(.retain_capacity);
const canonical = try p.render(frame.allocator());
try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len);
@@ -3452,7 +3456,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" {
p.acknowledgePanelPresentation(moving.panelTracks());
try std.testing.expect(moving.panelTracks().len > 0);
exec.applySettingBuiltin(p, config.Runtime.find("PanelZoom").?, null);
- try std.testing.expectEqual(layout.Transition.zoom, p.settings.panel_transition);
+ try std.testing.expectEqual(animation.Transition.zoom, p.settings.panel_transition);
try std.testing.expect(p.presentation.pending);
try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null);
for (p.presentation.tracks) |track| try std.testing.expect(track == null);
@@ -3506,11 +3510,11 @@ test "back-to-back layout commits retarget from the last presented boxes" {
const target_c = p.presentation.tracks[0] orelse return error.MissingSecondRetarget;
try std.testing.expect(target_c.from.eql(shown_a));
try std.testing.expect(target_c.to.eql(layout.panelBox(p.rects[0])));
- try std.testing.expectEqual(layout.Phase.moving, target_c.phase);
+ try std.testing.expectEqual(animation.Phase.moving, target_c.phase);
_ = frame.reset(.retain_capacity);
const latest = try p.render(frame.allocator());
- var submitted: ?layout.Track = null;
+ var submitted: ?animation.Track = null;
for (latest.panelTracks()) |track| if (track.pane == 0) {
submitted = track;
break;
@@ -3533,13 +3537,13 @@ test "an unpresented opening pane remains in the opening paint phase" {
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
p.sync();
const first = p.presentation.tracks[1] orelse return error.MissingOpeningTrack;
- try std.testing.expectEqual(layout.Phase.opening, first.phase);
+ try std.testing.expectEqual(animation.Phase.opening, first.phase);
_ = try p.newShell(2, "");
try std.testing.expect(layout.splitColumn(p, 1, 2, true));
p.sync();
const retargeted = p.presentation.tracks[1] orelse return error.MissingOpeningRetarget;
- try std.testing.expectEqual(layout.Phase.opening, retargeted.phase);
+ try std.testing.expectEqual(animation.Phase.opening, retargeted.phase);
try std.testing.expect(retargeted.from.eql(first.from));
}
@@ -3577,7 +3581,7 @@ test "core composes ASCII bytes while visual-only cells pass through" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- const presented = try p.composeAsciiTransitions(arena.allocator(), &surface);
+ const presented = try draw.composeAsciiTransitions(p, arena.allocator(), &surface);
try std.testing.expect(presented != &surface);
try std.testing.expectEqualStrings("C", presented.at(0, 0).grapheme());
try std.testing.expectEqualStrings("x", presented.at(1, 0).grapheme());
@@ -3612,7 +3616,7 @@ test "core composes character motion out of the new grid, not a fade" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- const presented = try p.composeAsciiTransitions(arena.allocator(), &surface);
+ const presented = try draw.composeAsciiTransitions(p, arena.allocator(), &surface);
try std.testing.expect(presented != &surface);
var seen: [8]u8 = undefined;
for (&seen, 0..) |*byte, col| byte.* = presented.at(@intCast(col), 0).text[0];
@@ -3620,8 +3624,8 @@ test "core composes character motion out of the new grid, not a fade" {
try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme());
// The last active sample is the untouched canonical grid, with no copy.
- surface.panel_tracks[0].frame = layout.Transition.edges.frames() - 1;
- try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface);
+ surface.panel_tracks[0].frame = animation.Transition.edges.frames() - 1;
+ try std.testing.expect(try draw.composeAsciiTransitions(p, arena.allocator(), &surface) == &surface);
}
test "repeated non-dyadic column splits preserve every unrelated boundary" {
@@ -3775,7 +3779,7 @@ pub const Pardes = struct {
rects: [MAX_PANES]Rect = @splat(.{}),
col_x: [MAX_COLS]u16 = undefined,
col_w: [MAX_COLS]u16 = undefined,
- presentation: layout.Presentation = .{},
+ presentation: Presentation = .{},
active: usize = 0,
jumps: [MAX_JUMPS]Loc = undefined,
@@ -4643,7 +4647,7 @@ pub const Pardes = struct {
pub fn pump(p: *Pardes, h: Host) !void {
p.host = h;
const v = h.vtable;
- if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) layout.Animation.frame_ms else 0);
+ if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) animation.Animation.frame_ms else 0);
while (p.nextQueued()) |ev| p.update(ev);
while (p.nextEffect()) |e| p.perform(e);
turn.settle();
@@ -5404,7 +5408,7 @@ pub const Pardes = struct {
// ---- dumb undo/redo: whole-state snapshots, one per edit op ----
- pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void {
+ pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const animation.Track) void {
p.presentation.acknowledge(p, tracks);
if (p.pointer_inside and p.drag != .none) {
if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| {
@@ -5779,430 +5783,9 @@ pub const Pardes = struct {
return null;
}
- // ---- render: build the canonical surface ----
+ // ---- render: build the canonical surface (draw.zig) ----
- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
- panes.File.refreshHighlights(p);
- const s = &p.surface;
- const ncells = @as(usize, p.screen_w) * p.screen_h;
- if (s.cells.len != ncells) {
- const cells = try p.gpa.alloc(Cell, ncells);
- p.gpa.free(s.cells);
- s.cells = cells;
- }
- s.cols = p.screen_w;
- s.rows = p.screen_h;
- s.cursor = null;
- s.pointer_shape = .arrow;
- if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| {
- if (preview.probe.link != null and preview.pane < p.panes.len) {
- if (p.panes[preview.pane]) |pane| {
- if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane))
- s.pointer_shape = .link;
- }
- }
- };
- // A look-hover preview means the pointed word is a real Look or Exec
- // operand: the same plumbing a click would act on. Hosts that model
- // the pointer per-text (the macOS I-beam) read this as "target" and
- // afford it the arrow instead.
- if (p.look_hover_preview != null) s.pointer_shape = .target;
- // Stale hover bits must not survive into a frame whose pointer has
- // moved on: the ordinary content paint does not clear fields it
- // never set.
- for (s.cells) |*c| c.hover = false;
- for (&s.body_layers) |*layer| layer.rows = 0;
- for (&s.tag_layers) |*layer| layer.cols = 0;
- s.nimages = 0;
- s.npanel_tracks = 0;
- s.previous_cells = &.{};
- s.previous_body_layers = &.{};
- s.previous_tag_layers = &.{};
- s.cell_diffs = &.{};
- const chrome = p.chromeTheme();
- const th = p.theme();
- // Separators have their own role; a scrollbar need not frame the page.
- s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } });
-
- for (&p.panes, 0..) |*slot, id| {
- const pane = slot.* orelse continue;
- pane.body_rows = 0;
- pane.body_visible_rows = 0;
- pane.context_row_limit = null;
- s.body_layers[id].rows = 0;
- Messages.collectNotices(p, pane, p.rects[id], id);
- try p.renderPane(arena, pane, p.rects[id], id, id == p.active);
- try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id);
- }
- for (&p.panes, 0..) |*slot, id| {
- const pane = slot.* orelse continue;
- if (pane.notices.len == 0) continue;
- const r = p.rects[id];
- const tx = r.x + config.GUTTER;
- const tw = r.w - config.GUTTER;
- // Painted the way the sticky context headers above them are: the
- // tagline font and the tagline's own colours, so a notice reads as
- // a band belonging to the pane rather than as a line of body text
- // that happens to sit at the bottom of it.
- const msg_style: CellStyle = .{
- .fg = .{ .rgb = chrome.tag_fg },
- .bg = .{ .rgb = chrome.tag_bg },
- .font_role = .tagline,
- };
- // An OVERLAY over the body's top rows, at the right edge, one
- // chip to a row in the order collectNotices chose -- not a row
- // taken out of the body the way a tree-sitter context header is.
- // The chip is as wide as its own message and no wider, so the text
- // beneath the rest of the row keeps reading as the file it is.
- const first = p.bodyTop(pane, r);
- var leader_buf: [16]u8 = undefined;
- for (pane.notices.slice(), 0..) |kind, i| {
- const row = first + @as(u16, pane.notices.row[i]);
- // Every notice is painted on the canonical grid as well as
- // emitted as a tag layer: the grid is what a terminal client
- // draws, and a band it cannot see is a message it never gets.
- // The GUI skips grid cells a tag layer covers, so nothing is
- // drawn twice there.
- const text = Messages.noticeText(p, pane, i, &leader_buf);
- const chip = Messages.noticeCols(p, text, tw);
- if (chip == 0) continue;
- const cx = tx + tw - chip;
- pane.notices.left[i] = cx;
- // A message coming or going blends toward the page it sits
- // on; a grid cannot move it by less than a row.
- var style = msg_style;
- if (Messages.noticeLife(pane, i)) |life| {
- // No page colour to fade to (a terminal's own default):
- // the text dissolves into its chip instead.
- const alpha = Messages.messageMotion(p, life).alpha;
- const page = th.bg orelse chrome.tag_bg;
- style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) };
- style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) };
- }
- s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline });
- // One column short of the chip's right edge: a prompt's caret
- // sits AFTER its last character the moment it opens, and flush
- // right would put it past the pane, off the grid, and past what
- // the detached wire will encode -- which drops every frame for
- // as long as the prompt is up.
- const printed = Messages.printRight(s, cx, row, chip -| 1, text, style);
- if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue;
- // The cursor follows the text to wherever it landed; a caret
- // in the part a narrow band dropped has nowhere to be.
- const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
- if (prompt_col < printed.dropped) continue;
- const caret = printed.left + (prompt_col - printed.dropped);
- if (caret < tx + tw)
- s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert };
- }
- }
-
- try body_layer.paintHeaders(p);
- try body_layer.renderTagLayers(p, arena);
-
- switch (p.drag) {
- .border_v => |d| {
- if (d.cur_x < s.cols) {
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
- }
- if (d.corner) |k| if (d.cur_y < s.rows) {
- var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1;
- const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1;
- while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
- };
- },
- .border_h => |d| if (d.cur_y < s.rows) {
- var col = p.col_x[d.col];
- while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
- },
- .column_move => |d| rail: {
- const source = layout.columnBySerial(p, d.serial) orelse break :rail;
- // Where the column's left edge would land, reordered or not.
- const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) {
- .stay => break :rail,
- .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target],
- .edge => |x| x,
- };
- // Leave the outside insertion rail visible above compact
- // text layers. This clips presentation only, never reflows.
- if (edge == s.cols) {
- for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) {
- layer.viewport.w -|= 1;
- };
- for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) {
- layer.viewport.w -|= 1;
- };
- }
- // The rail runs in the seam cell left of the edge, where a
- // border drag's does: the edge cell itself is a gutter, which
- // the GUI paints as a scroll rail that swallowed the dashes.
- // At the window's left edge there is no seam, so it runs just
- // past that scroll rail instead. It takes the lit grip's
- // color: this is where the grabbed column lands.
- const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1);
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) {
- // Past the left edge's scroll rail the rail would cut
- // through column 0's grip and pane boxes: leave them whole.
- if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue;
- s.overlayDash(x, row, "╎");
- s.at(x, row).style.fg = .{ .rgb = chrome.column_box };
- }
- },
- .move => |d| if (d.cur_x < s.cols) {
- if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| {
- var col: u16 = p.col_x[placement.preview_col];
- while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
- s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } });
- }
- s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
- }
- },
- .none, .select => if (p.pointer_inside) {
- // hover hints on a pane's own trailing edge
- var c: usize = 0;
- while (c + 1 < p.ncol) : (c += 1) {
- if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) {
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎");
- }
- }
- for (0..p.ncol) |cc| {
- if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
- // the same seam the h-handle hit test picks (see there)
- if (mouse.seamIdxAt(p, cc, p.hover_row) != null) {
- var col = p.col_x[cc];
- while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
- }
- }
- for (0..p.ncol -| 1) |cn| {
- if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue;
- if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue;
- if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue;
- var col = p.col_x[cn + 1];
- while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
- }
- },
- }
-
- // debug overlay: a small stats box drawn last, top-right ("Debug")
- if (p.settings.debug) if (p.panes[p.active]) |at| {
- var nlive: usize = 0;
- for (p.panes) |slot| {
- if (slot != null) nlive += 1;
- }
- var sb_off: usize = undefined;
- var sb_total: usize = undefined;
- if (at.file) |*f| {
- sb_off = f.scroll;
- sb_total = panes.File.nlines(p.gpa, f);
- } else if (at.pdfPage()) |page| {
- sb_off = page;
- sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0;
- } else {
- const sb = panes.Terminal.scrollbar(at);
- sb_off = sb.offset;
- sb_total = sb.total;
- }
- const effective_font = p.settings.font.effective_name.get();
- const font_name = if (effective_font.len == 0) "default" else effective_font;
- var ov_buf: [256]u8 = undefined;
- const text = std.fmt.bufPrint(
- &ov_buf,
- "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
- .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
- ) catch "";
- // wide enough for the longest theme name compiled in, which is the
- // only row here whose width is not ours to choose
- const ow: u16 = 38;
- const oh: u16 = 10;
- const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
- if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) {
- // vaxis single_rounded border ring, default style
- s.set(ox, p.topBarHeight(), "╭", .{});
- s.set(ox + ow - 1, p.topBarHeight(), "╮", .{});
- s.set(ox, p.topBarHeight() + oh - 1, "╰", .{});
- s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{});
- var bx = ox + 1;
- while (bx < ox + ow - 1) : (bx += 1) {
- s.set(bx, p.topBarHeight(), "─", .{});
- s.set(bx, p.topBarHeight() + oh - 1, "─", .{});
- }
- var by = p.topBarHeight() + 1;
- while (by < p.topBarHeight() + oh - 1) : (by += 1) {
- s.set(ox, by, "│", .{});
- s.set(ox + ow - 1, by, "│", .{});
- }
- s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } });
- var lines = std.mem.splitScalar(u8, text, '\n');
- var ly: u16 = p.topBarHeight() + 1;
- while (lines.next()) |line| : (ly += 1) {
- if (ly >= p.topBarHeight() + oh - 1) break;
- _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } });
- }
- }
- };
- try p.presentation.submit(p, s);
- return p.composeAsciiTransitions(arena, s);
- }
-
- fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface {
- _ = p;
- if (!canonical.hasPanelDiff()) return canonical;
- var presented: ?*Surface = null;
- for (canonical.panelTracks()) |track| {
- if (!track.effect.composedByCore() or track.phase == .closing) continue;
- const area = layout.CellArea.of(track.to);
- const col_end = @min(canonical.cols, area.x0 +| area.cols);
- const row_end = @min(canonical.rows, area.y0 +| area.rows);
- var row: u16 = area.y0;
- while (row < row_end) : (row += 1) {
- var col: u16 = area.x0;
- while (col < col_end) : (col += 1) {
- const index = @as(usize, row) * canonical.cols + col;
- const composed = composedCell(canonical, track, area, col, row, index) orelse
- continue;
- if (composed.visuallyEqual(&canonical.cells[index])) continue;
- if (presented == null) {
- const next = try arena.create(Surface);
- next.* = canonical.*;
- next.cells = try arena.dupe(Cell, canonical.cells);
- presented = next;
- }
- presented.?.cells[index] = composed;
- }
- }
- }
- return presented orelse canonical;
- }
-
- fn composedCell(
- canonical: *const Surface,
- track: layout.Track,
- area: layout.CellArea,
- col: u16,
- row: u16,
- index: usize,
- ) ?Cell {
- if (track.effect == .ascii) {
- const diff = switch (canonical.cell_diffs[index]) {
- .ascii => |diff| diff,
- .unchanged, .visual => return null,
- };
- const byte = diff.byteAt(track.frame);
- if (byte == diff.to) return null;
- if (track.frame == 0) return canonical.previous_cells[index];
- return withByte(canonical.cells[index], byte);
- }
- return switch (layout.charSource(track, col - area.x0, row - area.y0, area)) {
- .old => canonical.previous_cells[index],
- .byte => |byte| withByte(canonical.cells[index], byte),
- // Churn belongs on the glyph, not on the pane's empty space, and it
- // borrows whichever side of the diff actually has one.
- .churn => |byte| blk: {
- const new_cell = &canonical.cells[index];
- if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte);
- const old_cell = &canonical.previous_cells[index];
- if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte);
- break :blk old_cell.*;
- },
- .at => |offset| blk: {
- const source_col = @as(i32, col) + offset.cols;
- const source_row = @as(i32, row) + offset.rows;
- // A glyph whose source lies outside the pane has not arrived
- // yet: the frozen old cell is the only truthful thing under it.
- if (source_col < area.x0 or source_row < area.y0 or
- source_col >= @as(i32, area.x0) + area.cols or
- source_row >= @as(i32, area.y0) + area.rows or
- source_col >= canonical.cols or source_row >= canonical.rows)
- break :blk canonical.previous_cells[index];
- const source = @as(usize, @intCast(source_row)) * canonical.cols +
- @as(usize, @intCast(source_col));
- break :blk canonical.cells[source];
- },
- };
- }
-
- fn withByte(cell: Cell, byte: u8) Cell {
- var out = cell;
- out.text[0] = byte;
- out.len = 1;
- out.default = false;
- return out;
- }
-
- /// Whether a cell shows a glyph a character effect can work on. An unpainted
- /// cell and a space are both blank space, whatever style they carry.
- fn hasGlyph(cell: *const Cell) bool {
- return !cell.default and !(cell.len == 1 and cell.text[0] == ' ');
- }
-
- fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
- const tz = tracy.zone(@src(), "renderPane");
- defer tz.end();
- if (r.w <= config.GUTTER or r.h == 0) return;
- const s = &p.surface;
- const th = p.theme();
- const chrome = p.chromeTheme();
- const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter
- const tw = r.w - config.GUTTER;
- const tag_y = p.tagTop(pane, r);
- const body_y = p.bodyTop(pane, r);
- const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default;
-
- // text area resets to terminal-default cells (vaxis clear semantics);
- // light themes paint the page over it.
- {
- // Two full passes over every cell in the pane, every frame.
- const tz_clear = tracy.zone(@src(), "paneClear");
- defer tz_clear.end();
- s.clearRect(tx, r.y, tw, r.h);
- if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
- }
-
- const box_bg = if (active) chrome.box else chrome.box_dim;
- const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
- const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
- const box_style: CellStyle = .{
- .fg = .{ .rgb = box_ink },
- .bg = .{ .rgb = box_bg },
- // Grips keep physical grid dimensions, independently of tag text.
- .font_role = .tagline,
- };
- s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style);
- s.set(r.x, tag_y, switch (pane.body.mode) {
- .normal => config.tag_normal,
- .insert => config.tag_insert,
- .tty => config.tag_tty,
- }, box_style);
-
- try body_layer.paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET));
-
- // Folded panes retain their body state, but neither body pixels nor
- // its cursor belong outside the one-row tag rectangle.
- if (pane.collapsed) return;
-
- if (comptime pdf_enabled)
- if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return;
-
- if (pane.image) |*iv| {
- // Below the notice chips: a picture is drawn after the cells (the
- // GUI's image pass, kitty's z-order), so a chip over it would be
- // painted out. Text gets the overlay; a picture gives up the rows.
- const shown = pane.notices.span();
- const image_h = (r.h -| pane.tag_rows) -| shown;
- panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h);
- // thumbless, but the same one column as the real scrollbar below —
- // that is the whole point of drawing it, and like that one it runs
- // past the notice bands so the gutter has no notch in it
- s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg });
- return;
- }
-
- try body_layer.renderBody(p, arena, pane, r, id, active, true);
- }
+ pub const render = draw.render;
pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool {
const s = &p.surface;
diff --git a/src/surface.zig b/src/surface.zig
index a9e69cca..eef787b4 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -3,6 +3,7 @@
const pardes = @import("pardes.zig");
const std = @import("std");
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const uucode = @import("uucode");
const vaxis = @import("vaxis");
const panes = @import("panes.zig");
@@ -70,7 +71,7 @@ pub const AsciiDiff = struct {
/// Long printable-byte walks complete in about the same time as the other
/// panel effects. Extra distance is crossed by eased character skips.
- pub const max_movement_frames = layout.ascii_max_movement_frames;
+ pub const max_movement_frames = animation.ascii_max_movement_frames;
pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff {
const from = old.printableAscii() orelse return null;
@@ -341,7 +342,7 @@ pub const Surface = struct {
cursor: ?struct { x: u16, y: u16, bar: bool = false } = null,
images: []?ImagePlace = &.{},
nimages: usize = 0,
- panel_tracks: [MAX_PANES * 2]layout.Track = undefined,
+ panel_tracks: [MAX_PANES * 2]animation.Track = undefined,
npanel_tracks: usize = 0,
previous_cells: []const Cell = &.{},
previous_body_layers: []const BodyLayer = &.{},
@@ -356,7 +357,7 @@ pub const Surface = struct {
return &s.body_layers;
}
- pub fn panelTracks(s: *const Surface) []const layout.Track {
+ pub fn panelTracks(s: *const Surface) []const animation.Track {
return s.panel_tracks[0..s.npanel_tracks];
}
diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig
index 9363841a..67d3a6e6 100644
--- a/src/tty/panel_compositor.zig
+++ b/src/tty/panel_compositor.zig
@@ -8,9 +8,10 @@
const std = @import("std");
const pardes = @import("../pardes.zig");
const layout = @import("../layout.zig");
+const animation = @import("../animation.zig");
-const Box = layout.Box;
-const Track = layout.Track;
+const Box = animation.Box;
+const Track = animation.Track;
/// Kitty placements cannot be resampled through the character-grid transform.
/// Keep their transmitted pixels cached, but omit the placement while its pane
@@ -77,7 +78,7 @@ pub fn compose(
// Stable layout motion first, newly opening panels above it, and closing
// tombstones last. A closing pane no longer owns input or canonical cells,
// but its frozen old content remains the top visual until it slides out.
- for ([_]layout.Phase{ .moving, .opening, .closing }) |phase| {
+ for ([_]animation.Phase{ .moving, .opening, .closing }) |phase| {
for (tracks) |track| {
if (!drawable(source, track) or track.phase != phase) continue;
switch (track.effect) {
@@ -139,7 +140,7 @@ fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) v
var x = area.x0;
while (x < area.x1) : (x += 1) {
if (!source.panelCellChanged(x, y)) continue;
- if (layout.dissolveRevealed(
+ if (animation.dissolveRevealed(
track.serial,
x - area.x0,
y - area.y0,
@@ -339,7 +340,7 @@ test "TTY content effects never touch cells outside the published diff" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- for ([_]layout.Transition{ .ascii, .dissolve }) |effect| {
+ for ([_]animation.Transition{ .ascii, .dissolve }) |effect| {
const track: Track = .{
.serial = 17,
.effect = effect,
@@ -366,7 +367,7 @@ test "content transition without a diff snaps to canonical surface" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- for ([_]layout.Transition{ .dissolve, .ascii, .vertical }) |effect| {
+ for ([_]animation.Transition{ .dissolve, .ascii, .vertical }) |effect| {
const track: Track = .{
.effect = effect,
.phase = .opening,
@@ -391,7 +392,7 @@ test "exact transition endpoint preserves the canonical cursor" {
};
const track: Track = .{
.effect = .ascii,
- .frame = layout.Transition.ascii.frames() - 1,
+ .frame = animation.Transition.ascii.frames() - 1,
.from = .{ .w = 1, .h = 1 },
.to = .{ .w = 1, .h = 1 },
};
@@ -431,7 +432,7 @@ test "vertical opening rises through a fixed old-grid clip" {
.serial = 5,
.phase = .opening,
.effect = .vertical,
- .from = layout.openingBox(.vertical, target, 3),
+ .from = animation.openingBox(.vertical, target, 3),
.to = target,
};
@@ -485,7 +486,7 @@ test "vertical closing drops frozen content over canonical cells" {
.phase = .closing,
.effect = .vertical,
.from = old_box,
- .to = layout.closingBox(.vertical, old_box),
+ .to = animation.closingBox(.vertical, old_box),
};
const first = try compose(arena.allocator(), &surface, &.{track}, null);
@@ -530,13 +531,13 @@ test "closing content paints after opening content regardless of track order" {
.phase = .closing,
.effect = .vertical,
.from = box,
- .to = layout.closingBox(.vertical, box),
+ .to = animation.closingBox(.vertical, box),
};
const opening: Track = .{
.serial = 2,
.phase = .opening,
.effect = .ascii,
- .frame = layout.Transition.ascii.frames() - 1,
+ .frame = animation.Transition.ascii.frames() - 1,
.from = box,
.to = box,
};
@@ -691,8 +692,8 @@ test "active panel transition hides only its own native attachment" {
const tracks = [_]Track{
.{ .serial = 41, .effect = .slide, .frame = 0 },
.{ .serial = 42, .effect = .ascii, .frame = 0 },
- .{ .serial = 43, .effect = .zoom, .frame = layout.Transition.zoom.frames() - 1 },
- .{ .serial = 44, .effect = .zoom, .frame = layout.Transition.zoom.frames() },
+ .{ .serial = 43, .effect = .zoom, .frame = animation.Transition.zoom.frames() - 1 },
+ .{ .serial = 44, .effect = .zoom, .frame = animation.Transition.zoom.frames() },
.{ .serial = 45, .phase = .opening, .effect = .vertical, .frame = 0 },
};
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 32f9138f..66e551b1 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -899,7 +899,7 @@ const Shell = struct {
caps_pending: bool = true,
check_files: bool = false,
in_paste: bool = false,
- tracks: []const pardes.layout.Track = &.{},
+ tracks: []const pardes.animation.Track = &.{},
fn of(ctx: ?*anyopaque) *Shell {
return @ptrCast(@alignCast(ctx.?));
@@ -1532,7 +1532,7 @@ fn tickWatch(io: std.Io, loop: *Loop) void {
while (true) {
tick_request.waitUncancelable(io);
tick_request.reset();
- var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.layout.Animation.frame_ms) * std.time.ns_per_ms };
+ var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.animation.Animation.frame_ms) * std.time.ns_per_ms };
_ = libc.nanosleep(&ts, null);
loop.postEvent(.tick) catch {};
}