summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-17 16:12:56 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:45:04 -0300
commit31fece62f56aa2311e2325de83659edbc9e641db (patch)
tree13eee1e542d9670ad3fb7badad353265121d668f /src/pardes.zig
parent1551e409c31992437cb2fa864f576d45c8433801 (diff)
downloadpardes-31fece62f56aa2311e2325de83659edbc9e641db.tar.gz
pardes-31fece62f56aa2311e2325de83659edbc9e641db.zip
animation: core publishes transition records; gui evaluates via shaders, tty over grid cells
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig411
1 files changed, 365 insertions, 46 deletions
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 (&current, "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 = &current,
+ .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