From 30c2c8a154e770b3920bcfd7d553825fe9182bf4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 12:57:47 -0300 Subject: Move the frame into draw.zig and the transitions out of layout.zig Pure moves, no behaviour change. draw.zig holds the whole core frame in order: render, the pane, tag, header and notice painters it calls, and the character-effect composition (Pardes.render stays a declaration alias). Presentation.zig is the panel presentation state as a file struct, and animation.zig the easing curves, transition kinds, tracks and boxes, the character effects' sources and the generic displayed-value transition, all of which lived in layout.zig. layout.zig keeps only layout. --- src/layout.zig | 1439 +------------------------------------------------------- 1 file changed, 2 insertions(+), 1437 deletions(-) (limited to 'src/layout.zig') 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(); -- cgit v1.3 From 68e30f560e46bb9a30433eb23b4edb1b93d69f46 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:57:01 -0300 Subject: A selection's other ranges live out of line, made at the second range and freed at one The ranges beside the primary were an inline array in every Text, which at 1024 grew each pane by about 60 KiB. They are now allocated when a second range appears (Text.reserveSels) and given back when the selection is one range again (Text.dropSels); a column's or the workspace's tag allocates from Text.gpa, any other text from its pane's. A refused tag edit restores them by copy. The Pane size budget is back to 128 KiB, and a test holds that a single cursor allocates nothing for them. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- docs/selections.md | 5 ++++- src/Terminal.zig | 5 +---- src/Text.zig | 42 +++++++++++++++++++++++++++++++++++++++--- src/edit.zig | 10 ++++++++-- src/layout.zig | 6 +++--- src/normal.zig | 28 ++++++++++++++++++++++++---- src/pardes.zig | 10 ++++++++-- test/panes.zig | 1 + 9 files changed, 89 insertions(+), 20 deletions(-) (limited to 'src/layout.zig') diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 88aca887..1fb1c67a 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -282,7 +282,7 @@ keeps the matches it found so far. | an all-lowercase pattern | matches case-blind | helix's smart-case. mvzr has no such flag, so the surface is lowercased instead (ASCII folding is byte-for-byte, so the offsets are identical) | helix-verified | | `^` and `$` | hold at every line's start and end | helix compiles with `multi_line(true)`; pardes gets the same by searching each line as its own haystack (`src/regexp.zig`) | helix-verified | | `.` | never matches a newline | as the Rust regex crate: a pattern without `\n` searches line by line, and one that names `\n` has its `.`s made `[^\n]` (`src/regexp.zig`) | helix-verified | -| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The ranges are held inline in every `Text` (about 20 KiB each on the desktop) | helix-verified up to the limit | +| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The room for them is allocated when a second range appears and given back at one range, so a single cursor costs nothing | helix-verified up to the limit | | `K` / `Alt-K` Enter | keep only the ranges a match starts inside / only those none does; primary 0, and keeping none leaves the selection alone | helix `keep_selections` / `remove_selections`, on the same prompt as `s`/`S` (markers `Keep /`, `Remove /`). The range is searched as `s` searches it, line by line with its lines' context, where helix matches the range's text alone: a `^` right at a range that starts mid-line matches in helix and not here | helix-verified | ### `Ctrl-c` — toggle comments diff --git a/docs/selections.md b/docs/selections.md index 203cf1de..5cc1df1a 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -17,7 +17,10 @@ desktop, 64 on the board). head, `vsel.row`/`vsel.col` the anchor while `vsel.active`, and a bare cursor (inactive `vsel`) is the one-character range under it. `msel` is the older whole-line form (`r0`..`r1`), kept for search-result highlights. -- The others are `sels[0..nsel]`, each a `SelRange` of head and anchor plus +- The others are `sels[0..nsel]`, in room allocated when a second range + appears and given back when the selection is one range again (a column's + or the workspace's tag allocates from `Text.gpa`, any other text from its + pane's), each a `SelRange` of head and anchor plus its own `j`/`k` goal column, in document order. The primary is not in the list; `Text.ranges` slots it in and returns its index. - Ends are CELLS, the characters a block cursor sits on, where helix's are diff --git a/src/Terminal.zig b/src/Terminal.zig index 4c29e151..ba156ada 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -545,10 +545,7 @@ test "replay ownership is terminal-only and construction rolls back on allocatio try std.testing.expectEqual(terminal and enabled, pane.terminal != null); } }; - // Most of it is the three texts' selections, limits.selections ranges - // each held inline (about 20 KiB a text on the desktop), and the regex - // prompt's snapshot of them. - try std.testing.expect(@sizeOf(Pane) < 160 * 1024); + try std.testing.expect(@sizeOf(Pane) < 128 * 1024); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); } diff --git a/src/Text.zig b/src/Text.zig index 70ec5369..a44241ca 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -60,8 +60,14 @@ cur_row: i32 = 0, cur_col: i32 = 0, msel: LineSel = .{}, vsel: CharSel = .{}, -sels: [max_selections - 1]SelRange = undefined, +/// The ranges beside the primary, `nsel` of them: room for max_selections - 1 +/// made when a second range appears and given back when the selection is one +/// range again, so a single cursor holds nothing here. +sels: []SelRange = &.{}, nsel: u16 = 0, +/// What a column's or the workspace's tag allocates its ranges from; a text +/// with a pane uses the pane's. +gpa: ?std.mem.Allocator = null, select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. @@ -101,10 +107,39 @@ pub fn pane(t: *Text) ?*Pane { /// Free what a text owns: its characters and the buffers its undo keeps. pub fn deinit(t: *Text, gpa: std.mem.Allocator) void { + if (t.sels.len > 0) gpa.free(t.sels); if (t.own) |own| gpa.free(own); for (t.ed_undo[0..t.ed_undo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); - t.* = .{ .what = t.what }; + t.* = .{ .what = t.what, .gpa = t.gpa }; +} + +fn allocator(t: *Text) ?std.mem.Allocator { + return if (t.pane()) |w| w.gpa else t.gpa; +} + +/// Room for the ranges beside the primary; false when there is none to be had. +pub fn reserveSels(t: *Text) bool { + if (t.sels.len > 0) return true; + const gpa = t.allocator() orelse return false; + t.sels = gpa.alloc(SelRange, max_selections - 1) catch return false; + return true; +} + +/// The ranges beside the primary as they were (a refused edit's undoing). +pub fn restoreSels(t: *Text, kept: []const SelRange) void { + if (kept.len == 0) return t.dropSels(); + if (!t.reserveSels()) return; + @memcpy(t.sels[0..kept.len], kept); + t.nsel = @intCast(kept.len); +} + +/// Back to one range: the room for the others goes. +pub fn dropSels(t: *Text) void { + t.nsel = 0; + if (t.sels.len == 0) return; + if (t.allocator()) |gpa| gpa.free(t.sels); + t.sels = &.{}; } /// Bring the cursor into view (acme's textshow). Only a body scrolls to it; @@ -185,7 +220,7 @@ pub fn setRange(t: *Text, text: []const u8, row0: i32, r0: modal.Selection, expl .explicit = explicit or t.select, }; t.msel.active = false; - t.nsel = 0; // writing ONE range means the selection IS that range + t.dropSels(); // writing ONE range means the selection IS that range t.cur_pinned = true; t.sticky_col = -1; t.normal.clear(); @@ -262,6 +297,7 @@ pub fn setRanges(t: *Text, text: []const u8, in: []const modal.Selection, sticky } n = k + 1; setRange(t, text, 0, r[pri], explicit); + if (n > 1 and !t.reserveSels()) return; // no room: the primary alone t.sticky_col = st[pri]; var w: usize = 0; for (r[0..n], 0..) |x, idx| { diff --git a/src/edit.zig b/src/edit.zig index 01b34237..5437a67d 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -167,10 +167,16 @@ fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void { if (bytes.len == 0) return; const typed: pardes.Key = .{ .cp = bytes[0], .text = bytes }; if (t.what == .tag and t.mode == .insert) if (t.pane()) |pane| if (tagline.typedIntoName(p, pane, typed)) return; - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; if (t.mode == .insert) insertKey(p, t, typed) else pasteText(p, t, bytes, false, 1); - if (t.refused) t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + if (t.refused) { + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); + } } pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { diff --git a/src/layout.zig b/src/layout.zig index 8e44f1d9..be8bad67 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -947,7 +947,7 @@ pub fn removePane(p: *Pardes, id: usize) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } } @@ -971,7 +971,7 @@ pub fn joinCol(p: *Pardes) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } @@ -1021,7 +1021,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { p.col_serial[j] = p.col_serial[j - 1]; } p.col_weight[c] = new_weight; - p.col_tags[c] = .{ .what = .column }; + p.col_tags[c] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[c] = 0; p.col_panes[c][0] = id; p.col_n[c] = 1; diff --git a/src/normal.zig b/src/normal.zig index 4459ab53..2aea2e54 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -686,10 +686,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .all => return t.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { - if (multi.kind == .keep_primary) { - t.nsel = 0; - return; - } + if (multi.kind == .keep_primary) return t.dropSels(); return t.multiSelAction(text, multi.kind, multi.count); }, // s, S and | answer for the text they are pressed in; `/` searches @@ -922,6 +919,7 @@ test "flat text movement and selection replay need no scratch rows" { pane.body.cur_row = 1024; pane.body.cur_col = 0; pane.body.nsel = 1; + _ = pane.body.reserveSels(); pane.body.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; handleNormal(p, &pane.body, .{ .cp = 'l' }); handleNormal(p, &pane.body, .{ .cp = 'j' }); @@ -1021,3 +1019,25 @@ test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { }; } } + +test "one cursor holds no room for other ranges; a second range makes it and one again gives it back" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("alpha beta\nalpha beta\n"); + for ("wxe%;gg") |cp| handleNormal(p, &pane.body, .{ .cp = cp }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + try std.testing.expect(pane.body.sels.len > 0); + handleNormal(p, &pane.body, .{ .cp = 'l' }); // replayed at both, still two + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + handleNormal(p, &pane.body, .{ .cp = ',' }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + handleNormal(p, &pane.body, .{ .cp = ';' }); + handleNormal(p, &pane.body, .{ .cp = 'x' }); // x at both: still two lines + handleNormal(p, &pane.body, .{ .cp = '_', .alt = true }); // merged to one + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); +} diff --git a/src/pardes.zig b/src/pardes.zig index 6b397017..187f3ed3 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4146,6 +4146,8 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .col_tags = @splat(.{ .what = .column, .gpa = gpa }), + .global_tag = .{ .what = .workspace, .gpa = gpa }, .fs = .{}, .screen_w = opts.cols, .screen_h = opts.rows, @@ -5335,10 +5337,14 @@ pub const Pardes = struct { if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return; // A tag's prefix is read-only: a key whose edit reached into it // changes nothing, not even where the cursor is. - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; defer if (t.refused) { - t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); }; const was = t.mode; switch (t.mode) { diff --git a/test/panes.zig b/test/panes.zig index a56c07bf..181317ca 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -4560,6 +4560,7 @@ const LookResetTests = struct { pane.body.cur_col = 3; pane.body.vsel = .{ .active = true, .explicit = true, .row = 0, .col = 0 }; pane.body.msel = .{ .active = true, .r0 = 0, .r1 = 1 }; + _ = pane.body.reserveSels(); pane.body.sels[0] = .{ .row = 1, .col = 2, .arow = 0, .acol = 1 }; pane.body.nsel = 1; pane.body.select = true; -- cgit v1.3