diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Messages.zig | 3 | ||||
| -rw-r--r-- | src/Presentation.zig | 721 | ||||
| -rw-r--r-- | src/animation.zig | 744 | ||||
| -rw-r--r-- | src/body_layer.zig | 449 | ||||
| -rw-r--r-- | src/colors.zig | 9 | ||||
| -rw-r--r-- | src/config.zig | 29 | ||||
| -rw-r--r-- | src/draw.zig | 913 | ||||
| -rw-r--r-- | src/gui/gui.zig | 66 | ||||
| -rw-r--r-- | src/layout.zig | 1439 | ||||
| -rw-r--r-- | src/look.zig | 5 | ||||
| -rw-r--r-- | src/macos.zig | 45 | ||||
| -rw-r--r-- | src/mouse.zig | 34 | ||||
| -rw-r--r-- | src/ninep/screen.zig | 2 | ||||
| -rw-r--r-- | src/pardes.zig | 473 | ||||
| -rw-r--r-- | src/surface.zig | 7 | ||||
| -rw-r--r-- | src/tty/panel_compositor.zig | 27 | ||||
| -rw-r--r-- | src/tty/tty.zig | 4 |
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 {}; } |
