From 31fece62f56aa2311e2325de83659edbc9e641db Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 17 Aug 2026 16:12:56 -0300 Subject: animation: core publishes transition records; gui evaluates via shaders, tty over grid cells --- src/pardes.zig | 411 ++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 365 insertions(+), 46 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index c5d06a43..38e317c4 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -482,6 +482,34 @@ test "render uses target content colors while chrome is in flight" { try std.testing.expect(p.animationActive()); } +test "topbar hover highlights exactly the executable word" { + const p = try Pardes.init(std.testing.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.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 1, .row = 0 } }); + _ = frame.reset(.retain_capacity); + const hovered = try p.render(frame.allocator()); + for (0.."New".len) |col| { + const cell = hovered.at(@intCast(col), 0); + try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, cell.style.bg); + try std.testing.expectEqual(Color{ .rgb = p.theme().sel_fg }, cell.style.fg); + } + try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, hovered.at(3, 0).style.bg); + + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 3, .row = 0 } }); + _ = frame.reset(.retain_capacity); + const whitespace = try p.render(frame.allocator()); + try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, whitespace.at(1, 0).style.bg); + + p.update(.pointer_leave); + _ = frame.reset(.retain_capacity); + const left = try p.render(frame.allocator()); + try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, left.at(1, 0).style.bg); +} + test "pane-tag Exec prefers New and argument builtins before shell fallback" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); @@ -1521,14 +1549,24 @@ comptime { } } -/// the whitespace-delimited word covering `col` in `str` (topbar dispatch) -fn wordAtCol(str: []const u8, col: u16) []const u8 { - if (col >= str.len or str[col] == ' ') return ""; +const WordBounds = struct { lo: usize, hi: usize }; + +/// The whitespace-delimited word covering `col` in `str`. The topbar uses the +/// same bounds for pointer feedback and dispatch, so the word that lights up +/// is necessarily the word a middle click will execute. +fn wordBoundsAtCol(str: []const u8, col: u16) ?WordBounds { + if (col >= str.len or str[col] == ' ') return null; var lo: usize = col; while (lo > 0 and str[lo - 1] != ' ') lo -= 1; var hi: usize = col; while (hi < str.len and str[hi] != ' ') hi += 1; - return str[lo..hi]; + return .{ .lo = lo, .hi = hi }; +} + +/// the whitespace-delimited word covering `col` in `str` (topbar dispatch) +fn wordAtCol(str: []const u8, col: u16) []const u8 { + const bounds = wordBoundsAtCol(str, col) orelse return ""; + return str[bounds.lo..bounds.hi]; } /// `a` IS `b` or lives under it — how Grep drops a pane's directory when @@ -1716,6 +1754,7 @@ pub const CellStyle = struct { /// One surface cell. `default = true` means "never painted this frame": the /// shell renders it as the terminal's default cell (vaxis clear semantics). +// EFFECT_CODE_ASCII_DIFF_BEGIN pub const Cell = struct { text: [7]u8 = @splat(' '), len: u8 = 1, @@ -1735,8 +1774,98 @@ pub const Cell = struct { std.mem.eql(u8, a.grapheme(), b.grapheme()) and std.meta.eql(a.style, b.style); } + + /// The byte an ASCII transition may walk. Default cells are visibly + /// spaces; painted cells opt in only when their complete grapheme is one + /// printable byte. This keeps an intermediate frame valid UTF-8 and + /// prevents a style-only or multi-byte change from churning its glyph. + pub fn printableAscii(c: *const Cell) ?u8 { + if (c.default) return ' '; + if (c.len != 1) return null; + const byte = c.text[0]; + return if (byte >= ' ' and byte <= '~') byte else null; + } }; +/// The semantic character part of one old/new panel-cell diff. It lives in +/// the core because every renderer must present the same byte at a given +/// frame. Backends receive the already-composed Cell; none implements this +/// walk or chooses its own punctuation/noise threshold. +pub const AsciiDiff = struct { + from: u8, + to: u8, + + /// 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 = panel_animation.ascii_max_movement_frames; + + pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { + const from = old.printableAscii() orelse return null; + const to = new.printableAscii() orelse return null; + if (from == to) return null; + return .{ .from = from, .to = to }; + } + + pub fn distance(diff: AsciiDiff) u8 { + return if (diff.from < diff.to) diff.to - diff.from else diff.from - diff.to; + } + + pub fn movementFrames(diff: AsciiDiff) u16 { + return @min(@as(u16, diff.distance()), max_movement_frames); + } + + pub fn frameCount(diff: AsciiDiff) u16 { + return diff.movementFrames() + 1; + } + + /// Move through the u8 range with integer ease-out. Every frame advances + /// at least one byte; distances beyond max_movement_frames are distributed + /// as larger skips near the start and taper to an exact final step. Keeping + /// this integer-only makes every backend receive the same character. + pub fn byteAt(diff: AsciiDiff, frame: u16) u8 { + const byte_distance: u32 = diff.distance(); + const movements: u32 = diff.movementFrames(); + const at: u32 = @min(@as(u32, frame), movements); + const extra = byte_distance - movements; + const remaining = movements - at; + const denominator = movements * movements; + const eased_numerator = denominator - remaining * remaining; + // Ceiling division consumes all skipped distance before the last + // movement, so a long walk always settles onto its target by one. + const skipped = if (eased_numerator == 0) + 0 + else + (extra * eased_numerator + denominator - 1) / denominator; + const delta: u8 = @intCast(at + skipped); + return if (diff.from < diff.to) diff.from + delta else diff.from - delta; + } + + pub fn complete(diff: AsciiDiff, frame: u16) bool { + return diff.byteAt(frame) == diff.to; + } +}; + +/// One core-owned classification per cell in the frozen old/new grid. A +/// visual-only diff still matters to dissolve, but PanelAscii only walks the +/// `.ascii` case. That distinction fixes the old effect's habit of replacing +/// unchanged glyphs merely because their colour or other style changed. +pub const PanelCellDiff = union(enum) { + unchanged, + visual, + ascii: AsciiDiff, + + pub fn between(old: *const Cell, new: *const Cell) PanelCellDiff { + if (old.visuallyEqual(new)) return .unchanged; + if (AsciiDiff.between(old, new)) |diff| return .{ .ascii = diff }; + return .visual; + } + + pub fn changed(diff: PanelCellDiff) bool { + return diff != .unchanged; + } +}; +// EFFECT_CODE_ASCII_DIFF_END + test "cell visual equality ignores dead grapheme tail bytes" { var a: Cell = .{ .default = false }; var b = a; @@ -1752,6 +1881,54 @@ test "cell visual equality ignores dead grapheme tail bytes" { try std.testing.expect(a.visuallyEqual(&b)); } +test "ASCII cell diffs ease long byte walks in both directions" { + var low: Cell = .{ .default = false }; + low.text[0] = 'A'; + var high: Cell = .{ .default = false }; + high.text[0] = 'F'; + + const rising = AsciiDiff.between(&low, &high).?; + try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(0)); + try std.testing.expectEqual(@as(u8, 'B'), rising.byteAt(1)); + try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(5)); + try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(500)); + + const falling = AsciiDiff.between(&high, &low).?; + try std.testing.expectEqual(@as(u8, 'E'), falling.byteAt(1)); + try std.testing.expectEqual(@as(u8, 'A'), falling.byteAt(5)); + + low.text[0] = ' '; + high.text[0] = '~'; + const long_rising = AsciiDiff.between(&low, &high).?; + try std.testing.expectEqual(@as(u16, 13), long_rising.frameCount()); + try std.testing.expectEqual(@as(u8, ' '), long_rising.byteAt(0)); + try std.testing.expectEqual(@as(u8, '/'), long_rising.byteAt(1)); + try std.testing.expectEqual(@as(u8, '}'), long_rising.byteAt(11)); + try std.testing.expectEqual(@as(u8, '~'), long_rising.byteAt(12)); + + const long_falling = AsciiDiff.between(&high, &low).?; + try std.testing.expectEqual(@as(u8, 'o'), long_falling.byteAt(1)); + try std.testing.expectEqual(@as(u8, '!'), long_falling.byteAt(11)); + try std.testing.expectEqual(@as(u8, ' '), long_falling.byteAt(12)); +} + +test "ASCII diff classification skips stable and non-ASCII glyphs" { + var old: Cell = .{ .default = false }; + old.text[0] = 'x'; + var style_only = old; + style_only.style.bold = true; + try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &style_only)); + + var changed = old; + changed.text[0] = 'z'; + try std.testing.expectEqual(AsciiDiff{ .from = 'x', .to = 'z' }, PanelCellDiff.between(&old, &changed).ascii); + + var unicode = old; + unicode.text[0..2].* = .{ 0xc3, 0xa9 }; + unicode.len = 2; + try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &unicode)); +} + /// One generation of a pixel attachment. `serial` identifies the pane for its /// whole lifetime; `revision` identifies pixels rendered later by that same /// pane (for example, a different PDF page or zoom level). Backends must use @@ -1882,10 +2059,10 @@ pub const Surface = struct { panel_tracks: [MAX_PANES * 2]panel_animation.Track = undefined, npanel_tracks: usize = 0, /// Frozen canonical cells from before the current content/lifecycle - /// transition, plus its semantic old/new mask. Both are core-owned and - /// remain stable until every associated track has finished. + /// transition, plus the core's semantic old/new classifications. Both are + /// core-owned and remain stable until every associated track has finished. previous_cells: []const Cell = &.{}, - changed_cells: []const u8 = &.{}, + cell_diffs: []const PanelCellDiff = &.{}, pub fn panelTracks(s: *const Surface) []const panel_animation.Track { return s.panel_tracks[0..s.npanel_tracks]; @@ -1893,12 +2070,17 @@ pub const Surface = struct { pub fn hasPanelDiff(s: *const Surface) bool { return s.previous_cells.len == s.cells.len and - s.changed_cells.len == s.cells.len; + s.cell_diffs.len == s.cells.len; } pub fn panelCellChanged(s: *const Surface, x: u16, y: u16) bool { if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return false; - return s.changed_cells[@as(usize, y) * s.cols + x] != 0; + return s.cell_diffs[@as(usize, y) * s.cols + x].changed(); + } + + pub fn panelCellDiff(s: *const Surface, x: u16, y: u16) PanelCellDiff { + if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return .unchanged; + return s.cell_diffs[@as(usize, y) * s.cols + x]; } pub fn at(s: *Surface, x: u16, y: u16) *Cell { @@ -3547,16 +3729,17 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); - const changed_index = std.mem.indexOfScalar(u8, opening.changed_cells, 1) orelse - return error.MissingPanelDiff; + const changed_index = for (opening.cell_diffs, 0..) |diff, index| { + if (diff.changed()) break index; + } else return error.MissingPanelDiff; p.acknowledgePanelPresentation(opening.panelTracks()); - // Corrupt one cached bit to prove a later live frame derives it again + // Corrupt one cached classification to prove a later live frame derives it again // from the frozen old cells and freshly rendered new cells. - p.panel_changed_cells[changed_index] = 0; + p.panel_cell_diffs[changed_index] = .unchanged; _ = frame.reset(.retain_capacity); const refreshed = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(u8, 1), refreshed.changed_cells[changed_index]); + try std.testing.expect(refreshed.cell_diffs[changed_index].changed()); p.acknowledgePanelPresentation(refreshed.panelTracks()); // A second opener before the first canonical endpoint has no truthful @@ -3788,16 +3971,64 @@ test "an unpresented opening pane remains in the opening paint phase" { try std.testing.expect(retargeted.from.eql(first.from)); } +test "core composes ASCII bytes while visual-only cells pass through" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 4, .tty_only = true }); + defer p.deinit(); + var current: [4]Cell = @splat(.{ .default = false }); + var previous = current; + for (¤t, "Dwxq") |*cell, byte| cell.text[0] = byte; + for (&previous, "Azxq") |*cell, byte| cell.text[0] = byte; + current[2].style.bold = true; // visual-only: same glyph, new style + current[3].text[0..2].* = .{ 0xc3, 0xa9 }; + current[3].len = 2; // non-ASCII: canonical immediately + const diffs = [_]PanelCellDiff{ + .{ .ascii = .{ .from = 'A', .to = 'D' } }, + .{ .ascii = .{ .from = 'z', .to = 'w' } }, + .visual, + .visual, + }; + var surface: Surface = .{ + .cols = 4, + .rows = 1, + .cells = ¤t, + .previous_cells = &previous, + .cell_diffs = &diffs, + }; + surface.panel_tracks[0] = .{ + .effect = .ascii, + .frame = 2, + .frame_count = 4, + .from = .{ .w = 4, .h = 1 }, + .to = .{ .w = 4, .h = 1 }, + }; + surface.npanel_tracks = 1; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const presented = try p.composeAsciiTransitions(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()); + try std.testing.expectEqual(current[2], presented.at(2, 0).*); + try std.testing.expectEqual(current[3], presented.at(3, 0).*); + try std.testing.expectEqualStrings("D", surface.at(0, 0).grapheme()); +} + 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: panel_animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; - p.panel_changed_cells = try p.gpa.alloc(u8, @as(usize, p.screen_w) * p.screen_h); - @memset(p.panel_changed_cells, 0); + p.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.panel_cell_diffs, .unchanged); for (2..6) |row| { for (4..36) |col| { - p.panel_changed_cells[row * p.screen_w + col] = 1; + // Distances on both sides of frame five prove that finished ASCII + // bytes are clickable while bytes still walking are not. + p.panel_cell_diffs[row * p.screen_w + col] = .{ .ascii = .{ + .from = ' ', + .to = @intCast(' ' + (col - 4) % 12 + 1), + } }; } } p.panel_diff_ready = true; @@ -3820,6 +4051,8 @@ test "pointer rejects panel content cells which have not materialized" { for (0..4) |relative_row| for (0..32) |relative_col| { const rcol: u16 = @intCast(relative_col); const rrow: u16 = @intCast(relative_row); + const col: u16 = @intCast(4 + relative_col); + const row: u16 = @intCast(2 + relative_row); const visible = switch (effect) { .dissolve => panel_animation.dissolveRevealed( pane.serial, @@ -3827,16 +4060,12 @@ test "pointer rejects panel content cells which have not materialized" { rrow, track.amount(), ), - .ascii => panel_animation.asciiCell( - pane.serial, - rcol, - rrow, - track.amount(), - ).reveal, + .ascii => switch (p.panelCellDiff(col, row)) { + .ascii => |diff| diff.complete(track.frame), + .unchanged, .visual => true, + }, else => unreachable, }; - const col: u16 = @intCast(4 + relative_col); - const row: u16 = @intCast(2 + relative_row); const mapped = p.presentedPointer(col, row); try std.testing.expectEqual(visible, mapped != null); if (mapped) |point| @@ -3853,8 +4082,8 @@ test "unchanged content cells remain clickable through data effects" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - p.panel_changed_cells = try p.gpa.alloc(u8, @as(usize, p.screen_w) * p.screen_h); - @memset(p.panel_changed_cells, 0); + p.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.panel_cell_diffs, .unchanged); p.panel_diff_ready = true; const box: panel_animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; @@ -4191,7 +4420,7 @@ pub const Pardes = struct { /// partially animated pixels. A closing tombstone may sample the frozen /// grid only when that grid actually contains the exact pane box. presented_cells_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), - panel_changed_cells: []u8 = &.{}, + panel_cell_diffs: []PanelCellDiff = &.{}, panel_diff_pending: bool = false, panel_diff_ready: bool = false, /// Canonical pane lifetimes/boxes captured by render, and the visual boxes @@ -4470,7 +4699,7 @@ pub const Pardes = struct { p.scratch.deinit(); gpa.free(p.surface.cells); if (p.presented_cells.len > 0) gpa.free(p.presented_cells); - if (p.panel_changed_cells.len > 0) gpa.free(p.panel_changed_cells); + if (p.panel_cell_diffs.len > 0) gpa.free(p.panel_cell_diffs); if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); } @@ -9875,12 +10104,10 @@ pub const Pardes = struct { relative_row, track.amount(), ), - .ascii => panel_animation.asciiCell( - track.serial, - relative_col, - relative_row, - track.amount(), - ).reveal, + .ascii => switch (p.panelCellDiff(col, row)) { + .ascii => |diff| diff.complete(track.frame), + .unchanged, .visual => true, + }, else => unreachable, }; return if (visible) .{ .col = col, .row = row } else null; @@ -10001,8 +10228,14 @@ pub const Pardes = struct { fn panelCellChanged(p: *const Pardes, col: u16, row: u16) bool { if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or - p.panel_changed_cells.len != @as(usize, p.screen_w) * p.screen_h) return false; - return p.panel_changed_cells[@as(usize, row) * p.screen_w + col] != 0; + p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return false; + return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col].changed(); + } + + fn panelCellDiff(p: *const Pardes, col: u16, row: u16) PanelCellDiff { + if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or + p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return .unchanged; + return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col]; } /// A backend is about to replace an unpresentable animated frame with the @@ -12254,7 +12487,7 @@ pub const Pardes = struct { s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; - s.changed_cells = &.{}; + s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); // the message row paints in the editor's colours // gaps between panes read as chrome (scrollbar track), not raw default @@ -12378,6 +12611,22 @@ pub const Pardes = struct { .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); + // The topbar is executable chrome, just like a button row. Pane text + // already previews the exact operand a Look would use; row zero has + // no Pane and used to fall through that machinery without any pointer + // feedback at all. Paint the same word the click dispatcher resolves, + // immediately, while leaving whitespace inert. + if (p.pointer_inside and p.hover_row < TOPBAR_H) { + if (wordBoundsAtCol(p.topbar(&tb_buf), p.hover_col)) |bounds| { + var col: usize = bounds.lo; + while (col < bounds.hi and col < s.cols) : (col += 1) { + const cell = s.at(@intCast(col), 0); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } // the topbar's cursor, if it has the keyboard. AFTER the pane loop on // purpose: there is exactly one Surface cursor and the bar's must beat // the active pane's. Always a block — the bar has no insert mode. @@ -12543,7 +12792,7 @@ pub const Pardes = struct { } if (p.panel_diff_ready) { s.previous_cells = p.presented_cells; - s.changed_cells = p.panel_changed_cells; + s.cell_diffs = p.panel_cell_diffs; } // Moving panes first, then new panes, then inert closing tombstones on // top. Native GUI paint planners may regroup by phase, but every host @@ -12559,7 +12808,54 @@ pub const Pardes = struct { s.panel_tracks[s.npanel_tracks] = track; s.npanel_tracks += 1; } - return s; + return p.composeAsciiTransitions(arena, s); + } + + // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN + /// Lazily copy the canonical grid only when an active PanelAscii track has + /// a character which has not reached its target yet. The returned Surface + /// is the sole backend boundary, so every shell rasterizes the exact same + /// intermediate bytes and style-only/non-ASCII changes pass through once. + 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 != .ascii or track.phase == .closing) continue; + var row: u16 = 0; + while (row < canonical.rows) : (row += 1) { + var col: u16 = 0; + while (col < canonical.cols) : (col += 1) { + if (!boxContainsCell(track.to, col, row)) continue; + const index = @as(usize, row) * canonical.cols + col; + const diff = switch (canonical.cell_diffs[index]) { + .ascii => |diff| diff, + .unchanged, .visual => continue, + }; + const byte = diff.byteAt(track.frame); + if (byte == diff.to) continue; + if (presented == null) { + const next = try arena.create(Surface); + next.* = canonical.*; + next.cells = try arena.dupe(Cell, canonical.cells); + presented = next; + } + const cell = &presented.?.cells[index]; + if (track.frame == 0) { + cell.* = canonical.previous_cells[index]; + } else { + // The semantic destination owns presentation style as + // soon as its byte begins walking. At the endpoint the + // untouched canonical cell wins bit-for-bit instead. + cell.* = canonical.cells[index]; + cell.text[0] = byte; + cell.len = 1; + cell.default = false; + } + } + } + } + return presented orelse canonical; } fn preparePanelDiff(p: *Pardes) !bool { @@ -12568,17 +12864,40 @@ pub const Pardes = struct { p.presented_cells_cols != p.surface.cols or p.presented_cells_rows != p.surface.rows or p.presented_cells.len != count) return false; - if (p.panel_changed_cells.len != count) { - const next = try p.gpa.alloc(u8, count); - if (p.panel_changed_cells.len > 0) p.gpa.free(p.panel_changed_cells); - p.panel_changed_cells = next; + if (p.panel_cell_diffs.len != count) { + const next = try p.gpa.alloc(PanelCellDiff, count); + if (p.panel_cell_diffs.len > 0) p.gpa.free(p.panel_cell_diffs); + p.panel_cell_diffs = next; + } + for (p.panel_cell_diffs, p.presented_cells, p.surface.cells) |*diff, *old, *new| + diff.* = PanelCellDiff.between(old, new); + // The fixed Track ABI already carried two padding bytes after frame. + // They now hold the core-computed ASCII duration: exactly one sample + // beyond the longest eased walk in this pane, so there is neither a + // forced endpoint jump nor a long invisible tail for nearby glyphs. + for (&p.panel_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 < p.surface.rows) : (row += 1) { + var col: u16 = 0; + while (col < p.surface.cols) : (col += 1) { + if (!boxContainsCell(track.to, col, row)) continue; + const index = @as(usize, row) * p.surface.cols + col; + switch (p.panel_cell_diffs[index]) { + .ascii => |diff| longest = @max(longest, diff.frameCount()), + .unchanged, .visual => {}, + } + } + } + track.frame_count = @max(track.frame_count, longest); } - for (p.panel_changed_cells, p.presented_cells, p.surface.cells) |*changed, *old, *new| - changed.* = @intFromBool(!old.visuallyEqual(new)); p.panel_diff_pending = false; p.panel_diff_ready = true; return true; } + // EFFECT_CODE_ASCII_COMPOSITOR_END /// Paint a selection expressed in the coordinate space used by pointer /// gestures. Both the persistent mouse selections and the delayed Look -- cgit v1.3