summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/config.zig12
-rw-r--r--src/effect_sources.zig24
-rw-r--r--src/gui/gui.zig151
-rw-r--r--src/macos.zig20
-rw-r--r--src/macos/Sources/PardesView.swift1
-rw-r--r--src/macos/pardes.h2
-rw-r--r--src/panel_animation.zig101
-rw-r--r--src/pardes.zig411
-rw-r--r--src/tty/panel_compositor.zig140
-rw-r--r--src/tutor.txt10
10 files changed, 638 insertions, 234 deletions
diff --git a/src/config.zig b/src/config.zig
index c8a8f8cf..9f18f75e 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -465,6 +465,18 @@ comptime {
@compileError("config.gui_tagline_font_percent must be in 1...100");
}
+/// Physical-pixel rule between the global topbar and pane taglines in the
+/// SDL GUI. Their smaller font bands retain body-sized grid rows; without
+/// an explicit join, centering both bands leaves the two unused half-bands
+/// touching and makes a wide strip of the window background show through.
+/// Zero disables the rule and joins the two bands directly.
+pub const gui_topbar_pane_border_px: u8 = 1;
+
+/// Fixed RGB for that rule, or null to follow the active theme's scrollbar
+/// track. The themed default stays quiet across light and dark themes while a
+/// build that wants a deliberate accent can pin one here.
+pub const gui_topbar_pane_border_rgb: ?[3]u8 = null;
+
/// Touchpad drift guard, in ticks. A two-finger swipe that is MEANT to be
/// vertical carries a little sideways drift, and the pad faithfully turns that
/// drift into wheel_left/wheel_right — so a plain scroll slides the view
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
index 87026278..6f1c6bb1 100644
--- a/src/effect_sources.zig
+++ b/src/effect_sources.zig
@@ -9,6 +9,7 @@ const build_config = @import("pardes_config");
const runtime_config = @import("runtime_config.zig");
const panel_math = @embedFile("panel_animation.zig");
+const pardes_core = @embedFile("pardes.zig");
const tty_compositor = @embedFile("tty/panel_compositor.zig");
const panel_vertex = @embedFile("effect-source-ui.vert.glsl");
const panel_fragment = @embedFile("effect-source-ui.frag.glsl");
@@ -59,6 +60,16 @@ const gui_cell_instance = sourceSection(
"// EFFECT_CODE_CELL_INSTANCE_BEGIN\n",
"// EFFECT_CODE_CELL_INSTANCE_END",
);
+const ascii_diff = sourceSection(
+ pardes_core,
+ "// EFFECT_CODE_ASCII_DIFF_BEGIN\n",
+ "// EFFECT_CODE_ASCII_DIFF_END",
+);
+const ascii_compositor = sourceSection(
+ pardes_core,
+ " // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN\n",
+ " // EFFECT_CODE_ASCII_COMPOSITOR_END",
+);
const ui_vertex_path = if (build_config.gui_shader_sources_prebuilt)
"shaders/prebuilt/ui.vert.glsl"
@@ -117,10 +128,14 @@ pub const Segment = struct {
const tty_panel = [_]Segment{
.{ .path = "src/panel_animation.zig", .source = panel_math },
+ .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff },
+ .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor },
.{ .path = "src/tty/panel_compositor.zig", .source = tty_compositor },
};
const gui_panel = [_]Segment{
.{ .path = "src/panel_animation.zig", .source = panel_math },
+ .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff },
+ .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor },
.{ .path = "src/gui/gui.zig#panel-host", .source = gui_panel_host },
.{ .path = "src/gui/gui.zig#native-panel", .source = gui_native_panel },
.{ .path = "src/gui/gui.zig#frame-submission", .source = gui_frame_submission },
@@ -138,6 +153,8 @@ const gui_scene = [_]Segment{
};
const mac_panel = [_]Segment{
.{ .path = "src/panel_animation.zig", .source = panel_math },
+ .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff },
+ .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor },
.{ .path = "src/macos/Sources/PardesView.swift", .source = mac_view },
.{ .path = "src/macos/Sources/ScenePostprocessor.swift", .source = mac_postprocessor },
.{ .path = "shaders/crt.ci.metal", .source = mac_scene },
@@ -205,6 +222,8 @@ test "GUI scene EffectCode includes both runtime shader stages" {
test "GUI panel EffectCode includes the actual host paint and submission path" {
if (comptime backend == .gui) {
+ try std.testing.expect(std.mem.indexOf(u8, ascii_diff, "pub const PanelCellDiff") != null);
+ try std.testing.expect(std.mem.indexOf(u8, ascii_compositor, "fn composeAsciiTransitions") != null);
try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn makePaintPlan") != null);
try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn setTransitionFields") != null);
try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "fn prepareNativeImages") != null);
@@ -213,7 +232,8 @@ test "GUI panel EffectCode includes the actual host paint and submission path" {
try std.testing.expect(std.mem.indexOf(u8, gui_frame_submission, "for (paint_plan.batches") != null);
try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "fn emitInstance") != null);
try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "setTransitionFields") != null);
- try std.testing.expect(std.mem.indexOf(u8, image_fragment, "vec3 contrastInk") != null);
+ try std.testing.expect(std.mem.indexOf(u8, panel_math, "frame_count") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_frame_submission, "active.effect == .dissolve") != null);
}
}
@@ -233,7 +253,7 @@ test "mac EffectCode includes the runtime panel and scene wiring" {
try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesPanelClear") != null);
try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesPanel") != null);
try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesScene") != null);
- try std.testing.expect(std.mem.indexOf(u8, mac_scene, "float3 contrastInk") != null);
+ try std.testing.expect(std.mem.indexOf(u8, mac_scene, "already composed in Pardes core") != null);
try std.testing.expect(std.mem.indexOf(u8, mac_view, "hideCursor: !tracks.isEmpty") != null);
try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelClearKernel.apply") != null);
try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelKernel.apply") != null);
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 2c556895..d0757d7f 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -239,7 +239,7 @@ test "a persistent scene presents a new panel's frame zero before advancing it"
for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame);
}
-const max_overlay_vertices: usize = 6 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
+const max_overlay_vertices: usize = 18 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
// Ctrl+ / Ctrl-: how far one press moves g.px, and the two sizes it stops at.
// ONE size for the whole window, not one per pane: the core lays every pane
@@ -2014,6 +2014,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
gpa,
surface,
core.theme().bg,
+ config.gui_topbar_pane_border_rgb orelse core.chromeTheme().scroll_track,
+ core.chromeTheme().tag_bg,
core.settings.scene_effects,
core.settings.debug,
) catch |err| blk: {
@@ -3710,7 +3712,7 @@ fn prepareNativeImages(
if (saved.serial != active.serial) continue;
appendPreparedImage(g, gpa, saved, active, null, false);
}
- } else if (active.effect == .dissolve or active.effect == .ascii) {
+ } else if (active.effect == .dissolve) {
// Old layer first. Its shader half disappears at the same
// per-cell threshold at which the current half appears.
for (g.presented_images.items) |saved| {
@@ -3729,8 +3731,8 @@ fn prepareNativeImages(
const current_track = if (track) |active| switch (active.effect) {
// An identical cached placement is semantically unchanged
// and must bypass a data effect exactly like an unchanged
- // cell in Surface.changed_cells.
- .dissolve, .ascii => if (snapshotContainsPlacement(g, current)) null else active,
+ // cell in Surface.cell_diffs.
+ .dissolve => if (snapshotContainsPlacement(g, current)) null else active,
else => active,
} else null;
appendPreparedImage(g, gpa, saved, current_track, null, false);
@@ -3860,6 +3862,8 @@ fn renderFrame(
gpa: std.mem.Allocator,
surface: *pardes.Surface,
theme_bg: ?[3]u8,
+ topbar_pane_border_rgb: [3]u8,
+ tagline_rgb: [3]u8,
scene_effects: pardes.panel_animation.SceneEffect,
debug_on: bool,
) !bool {
@@ -3920,14 +3924,22 @@ fn renderFrame(
const layout = fixedCellLayout(g);
var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff());
- // Cursors/debug overlays do not carry pane ownership. Hide them for the
+ // Cursors/debug overlays do not carry pane ownership. Hide those for the
// short interval in which panel geometry differs from logical geometry;
// otherwise a cursor could remain pinned at the final cell, or paint over
- // a later opening pane, while its own pane moves underneath it.
- var overlay_count = if (paint_plan.len == 1)
- buildOverlay(g, surface, layout, sw, sh, debug_on)
- else
- 0;
+ // a later opening pane, while its own pane moves underneath it. The
+ // topbar/pane rule is anchored window chrome and remains present.
+ var overlay_count = buildOverlay(
+ g,
+ surface,
+ layout,
+ sw,
+ sh,
+ topbar_pane_border_rgb,
+ tagline_rgb,
+ paint_plan.len == 1,
+ debug_on,
+ );
if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count))
overlay_count = 0;
if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) {
@@ -3962,7 +3974,7 @@ fn renderFrame(
const batch_index = paintBatchAt(&paint_plan, col, row);
const track = paint_plan.batches[batch_index].track;
const data_effect = if (track) |active|
- active.effect == .dissolve or active.effect == .ascii
+ active.effect == .dissolve
else
false;
const data_diff = data_effect and surface.panelCellChanged(col, row);
@@ -4022,7 +4034,7 @@ fn renderFrame(
const track = paint_plan.batches[batch_index].track;
const logical_idx: u32 = @as(u32, row) * surface.cols + col;
const data_effect = if (track) |active|
- active.effect == .dissolve or active.effect == .ascii
+ active.effect == .dissolve
else
false;
const data_diff = data_effect and surface.panelCellChanged(col, row);
@@ -4219,6 +4231,55 @@ const ResolvedCell = struct {
role: pardes.FontRole,
};
+fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 {
+ const spare = cell_h -| tagline_h;
+ return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2);
+}
+
+/// Vertical offset of a tagline band inside its body-sized logical row. Row
+/// zero and the first pane-tag row face a shared rule instead of centering two
+/// independent bands. A Tagbottom band on the final grid row faces the window
+/// edge, eliminating the matching unused half-band at the bottom.
+fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 {
+ const spare = cell_h -| tagline_h;
+ const border = topbarPaneBorderPixels(cell_h, tagline_h);
+ if (row == 0) return spare - border / 2;
+ if (row == pardes.TOPBAR_H) return border - border / 2;
+ const rows: u32 = @max(1, @as(u32, @intFromFloat(@floor(canvas_h / @as(f32, @floatFromInt(cell_h))))));
+ if (@as(u32, row) + 1 >= rows) return spare;
+ return spare / 2;
+}
+
+fn topbarPaneBorderHeight(surface: *const pardes.Surface, cell_h: u32, tagline_h: u32) ?u32 {
+ if (surface.rows <= pardes.TOPBAR_H) return null;
+ const height = topbarPaneBorderPixels(cell_h, tagline_h);
+ if (height == 0) return null;
+ const base = @as(usize, pardes.TOPBAR_H) * surface.cols;
+ for (surface.cells[base..][0..surface.cols]) |cell|
+ if (!cell.default and cell.style.font_role == .tagline) return height;
+ return null;
+}
+
+fn bottomTaglinePresent(surface: *const pardes.Surface) bool {
+ if (surface.rows == 0) return false;
+ const base = @as(usize, surface.rows - 1) * surface.cols;
+ for (surface.cells[base..][0..surface.cols]) |cell|
+ if (!cell.default and cell.style.font_role == .tagline) return true;
+ return false;
+}
+
+test "tagline bands face the topbar rule and Tagbottom faces the window edge" {
+ const cell_h: u32 = 20;
+ const tagline_h: u32 = 16;
+ const canvas_h: f32 = 200;
+ const border = topbarPaneBorderPixels(cell_h, tagline_h);
+ try std.testing.expectEqual(@as(u32, 1), border);
+ try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(0, canvas_h, cell_h, tagline_h));
+ try std.testing.expectEqual(@as(u32, 1), taglineBandOffset(1, canvas_h, cell_h, tagline_h));
+ try std.testing.expectEqual(@as(u32, 2), taglineBandOffset(5, canvas_h, cell_h, tagline_h));
+ try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h));
+}
+
fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8) ResolvedCell {
var fg = fg_default;
var bg = page;
@@ -4283,8 +4344,11 @@ fn emitInstance(
@floatFromInt(g.tagline_height)
else
layout.h;
- const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h +
- @max(0.0, (layout.h - visual_h) * 0.5);
+ const band_offset: f32 = if (resolved.role == .tagline)
+ @floatFromInt(taglineBandOffset(row, win_h, g.cell_h, g.tagline_height))
+ else
+ 0;
+ const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h + band_offset;
const x0 = (px0 / win_w) * 2.0 - 1.0;
const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0;
const y0 = 1.0 - (py0 / win_h) * 2.0;
@@ -4812,7 +4876,7 @@ fn addCursorBar(
const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w;
const height = std.math.clamp(visual_height, 1.0, layout.h);
const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h +
- (layout.h - height) * 0.5;
+ @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height))));
builder.addRect(
x0,
y0,
@@ -4827,23 +4891,58 @@ fn addCursorBar(
);
}
-fn buildOverlay(g: *Gui, surface: *const pardes.Surface, layout: CellLayout, sw: u32, sh: u32, debug_on: bool) u32 {
+fn buildOverlay(
+ g: *Gui,
+ surface: *const pardes.Surface,
+ layout: CellLayout,
+ sw: u32,
+ sh: u32,
+ topbar_pane_border_rgb: [3]u8,
+ tagline_rgb: [3]u8,
+ transient_on: bool,
+ debug_on: bool,
+) u32 {
if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h };
- if (surface.cursor) |cursor| {
- if (cursor.x < surface.cols and cursor.y < surface.rows) {
- const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
- const role = cell.style.font_role;
- const visual_height: f32 = if (role == .tagline)
- @floatFromInt(g.tagline_height)
- else
- layout.h;
- addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, layout);
+ if (topbarPaneBorderHeight(surface, g.cell_h, g.tagline_height)) |height| {
+ const y0: f32 = @floatFromInt(g.cell_h - height / 2);
+ const rgb = topbar_pane_border_rgb;
+ builder.addRect(0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), .{
+ .r = @as(f32, @floatFromInt(rgb[0])) / 255.0,
+ .g = @as(f32, @floatFromInt(rgb[1])) / 255.0,
+ .b = @as(f32, @floatFromInt(rgb[2])) / 255.0,
+ .a = 1.0,
+ });
+ }
+ // The core grid contains only complete cells. Extend a bottommost
+ // Tagbottom band through the swapchain remainder so an arbitrary window
+ // height cannot reintroduce a page-colored strip below the final row.
+ const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h;
+ if (grid_bottom < win_h and bottomTaglinePresent(surface)) {
+ const rgb = tagline_rgb;
+ builder.addRect(0, grid_bottom, win_w, win_h, .{
+ .r = @as(f32, @floatFromInt(rgb[0])) / 255.0,
+ .g = @as(f32, @floatFromInt(rgb[1])) / 255.0,
+ .b = @as(f32, @floatFromInt(rgb[2])) / 255.0,
+ .a = 1.0,
+ });
+ }
+ if (transient_on) {
+ if (surface.cursor) |cursor| {
+ if (cursor.x < surface.cols and cursor.y < surface.rows) {
+ const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
+ const role = cell.style.font_role;
+ const visual_height: f32 = if (role == .tagline)
+ @floatFromInt(g.tagline_height)
+ else
+ layout.h;
+ addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, layout);
+ }
}
}
- appendTouchOverlay(g, &builder, debug_on);
+ if (transient_on) appendTouchOverlay(g, &builder, debug_on);
return @intCast(builder.len);
}
diff --git a/src/macos.zig b/src/macos.zig
index 9f971e5f..3c1458a3 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -498,7 +498,7 @@ const State = struct {
arena: std.heap.ArenaAllocator,
runtime: Runtime,
cells: []Cell = &.{},
- /// Frozen canonical grid paired with `changed_cells` while a content or
+ /// Frozen canonical grid paired with an encoded change mask while a content or
/// lifecycle transition is active. Both are encoded at frame time so the
/// native renderer never borrows core-owned Cell layout across the ABI.
previous_cells: []Cell = &.{},
@@ -1397,13 +1397,13 @@ fn collectPanelDiff(st: *State, surface: *const pardes.Surface, count: usize) vo
}
for (surface.previous_cells, st.previous_cells[0..count]) |cell, *out|
out.* = encodeCell(cell);
- for (surface.changed_cells, st.changed_cells[0..count]) |changed, *out|
- out.* = encodeChanged(changed);
+ for (surface.cell_diffs, st.changed_cells[0..count]) |diff, *out|
+ out.* = encodeChanged(diff);
st.panel_diff_len = count;
}
-fn encodeChanged(changed: u8) u8 {
- return if (changed == 0) 0 else 255;
+fn encodeChanged(diff: pardes.PanelCellDiff) u8 {
+ return if (diff.changed()) 255 else 0;
}
/// Flatten Surface.images into the flat C array the host walks.
@@ -2089,8 +2089,6 @@ test "pardes.h matches the Zig boundary" {
inline for (@typeInfo(PanelTrack).@"struct".fields) |field| {
const c_name = comptime if (std.mem.eql(u8, field.name, "_padding"))
"reserved0"
- else if (std.mem.eql(u8, field.name, "_frame_padding"))
- "reserved1"
else
field.name;
try expectEqual(@offsetOf(c.pardes_panel_track_s, c_name), @offsetOf(PanelTrack, field.name));
@@ -2217,9 +2215,9 @@ test "mac panel ABI paint order includes closing tombstones after live panes" {
}
test "mac panel mask is a literal normalized grayscale texture" {
- try std.testing.expectEqual(@as(u8, 0), encodeChanged(0));
- try std.testing.expectEqual(@as(u8, 255), encodeChanged(1));
- try std.testing.expectEqual(@as(u8, 255), encodeChanged(255));
+ try std.testing.expectEqual(@as(u8, 0), encodeChanged(.unchanged));
+ try std.testing.expectEqual(@as(u8, 255), encodeChanged(.visual));
+ try std.testing.expectEqual(@as(u8, 255), encodeChanged(.{ .ascii = .{ .from = 'a', .to = 'z' } }));
}
test "mac panel ABI has the documented compact byte layout" {
@@ -2231,7 +2229,7 @@ test "mac panel ABI has the documented compact byte layout" {
try std.testing.expectEqual(@as(usize, 6), @offsetOf(PanelTrack, "effect"));
try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "_padding"));
try std.testing.expectEqual(@as(usize, 8), @offsetOf(PanelTrack, "frame"));
- try std.testing.expectEqual(@as(usize, 10), @offsetOf(PanelTrack, "_frame_padding"));
+ try std.testing.expectEqual(@as(usize, 10), @offsetOf(PanelTrack, "frame_count"));
try std.testing.expectEqual(@as(usize, 12), @offsetOf(PanelTrack, "from"));
try std.testing.expectEqual(@as(usize, 28), @offsetOf(PanelTrack, "to"));
}
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index a22d4d55..aa8ac425 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -648,7 +648,6 @@ final class PardesView: NSView {
drawFrame(sceneContext, count: count, hideCursor: !tracks.isEmpty)
let needsPrevious = tracks.contains {
$0.effect == UInt8(PARDES_PANEL_DISSOLVE)
- || $0.effect == UInt8(PARDES_PANEL_ASCII)
|| $0.effect == UInt8(PARDES_PANEL_VERTICAL)
}
var oldReady = !needsPrevious
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index cfe83ffa..a506423d 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -108,7 +108,7 @@ typedef struct {
uint8_t effect;
uint8_t reserved0;
uint16_t frame;
- uint16_t reserved1;
+ uint16_t frame_count;
pardes_panel_box_s from;
pardes_panel_box_s to;
} pardes_panel_track_s;
diff --git a/src/panel_animation.zig b/src/panel_animation.zig
index 045d2a87..0451213d 100644
--- a/src/panel_animation.zig
+++ b/src/panel_animation.zig
@@ -1,13 +1,17 @@
//! Backend-neutral vocabulary and math for pane transitions.
//!
-//! The core publishes plain transition data. GUI shells evaluate that data in
-//! shaders; the TTY shell evaluates the same progress over cells in its grid.
-//! There are deliberately no callbacks or backend objects here.
+//! The core publishes plain transition data and composes semantic PanelAscii
+//! bytes itself. GUI shells evaluate geometry/dissolve data in shaders; the
+//! TTY shell evaluates the same records over cells in its grid. There are
+//! deliberately no callbacks or backend objects here.
const std = @import("std");
+pub const ascii_max_movement_frames: u16 = 12;
+
pub const Easing = enum(u8) {
linear,
smooth,
+ in_cubic,
out_cubic,
out_back,
};
@@ -38,7 +42,10 @@ pub const Transition = enum(u8) {
.slide => 12,
.zoom => 14,
.dissolve => 10,
- .ascii => 16,
+ // Frame zero is the exact old byte. Core's AsciiDiff caps a long
+ // byte walk at twelve eased movement samples, including the exact
+ // destination; nearby bytes still move one value at a time.
+ .ascii => ascii_max_movement_frames + 1,
.vertical => 12,
};
}
@@ -89,16 +96,27 @@ pub const Track = extern struct {
effect: Transition = .off,
_padding: u8 = 0,
frame: u16 = 0,
- _frame_padding: u16 = 0,
+ /// Core-computed duration for data-dependent effects. Zero selects the
+ /// effect preset; PanelAscii fills this from the longest eased byte walk
+ /// in the pane's semantic cell diff.
+ frame_count: u16 = 0,
from: Box = .{},
to: Box = .{},
pub fn active(track: Track) bool {
- return track.effect != .off and track.frame < track.effect.frames();
+ 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 {
- return progress(track.effect, track.frame);
+ // 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 {
@@ -153,14 +171,15 @@ pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box {
};
}
-/// Destination for a lifecycle-only closing track. The old panel rises out
-/// through its own fixed clip; surviving panes are already at canonical
-/// geometry underneath it and never receive tracks for this effect.
+/// Destination for a lifecycle-only closing track. The old panel drops out
+/// through its own fixed clip, reversing the opening path; surviving panes
+/// are already at canonical geometry underneath it and never receive tracks
+/// for this effect.
pub fn closingBox(effect: Transition, source: Box) Box {
return switch (effect) {
.vertical => blk: {
var to = source;
- to.y -= source.h;
+ to.y += source.h;
break :blk to;
},
else => source,
@@ -172,6 +191,7 @@ pub fn sample(easing: Easing, raw: f32) f32 {
return switch (easing) {
.linear => t,
.smooth => t * t * (3.0 - 2.0 * t),
+ .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.
@@ -185,12 +205,15 @@ pub fn sample(easing: Easing, raw: f32) f32 {
}
pub fn progress(effect: Transition, frame: u16) f32 {
- const frames = effect.frames();
+ 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;
// `frames` is the number of presented samples, including both exact
// endpoints. This makes the last active frame the real final image rather
// than 15/16 followed by an unrendered snap to canonical content.
- return sample(effect.easing(), @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1)));
+ return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1)));
}
pub fn lerpBox(from: Box, to: Box, t: f32) Box {
@@ -227,39 +250,13 @@ pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool
return cellNoise(serial, col, row) < t;
}
-pub const AsciiCell = struct {
- /// When true the pane's real cell has arrived.
- reveal: bool,
- /// A transient one-byte glyph while `reveal` is false. Space means the
- /// cell has not entered the animation yet.
- glyph: u8,
-};
-
-/// ASCII-materialization preset: a diagonal wave perturbed by stable noise.
-/// It is content animation rather than geometry; TTY replaces the cell glyph,
-/// while GUI shaders use the same threshold to dissolve glyph coverage.
-pub fn asciiCell(serial: u32, col: u16, row: u16, raw_progress: f32) AsciiCell {
- const t = std.math.clamp(raw_progress, 0.0, 1.0);
- // These endpoints are real presented frames: even a cell whose stable
- // threshold is zero starts as old data, and every cell ends as new data.
- if (t <= 0) return .{ .reveal = false, .glyph = ' ' };
- if (t >= 1) return .{ .reveal = true, .glyph = ' ' };
- const noise = cellNoise(serial, col, row);
- const diagonal = @as(f32, @floatFromInt((@as(u32, col) + @as(u32, row) * 2) % 17)) / 17.0;
- const threshold = noise * 0.72 + diagonal * 0.28;
- if (t >= threshold) return .{ .reveal = true, .glyph = ' ' };
- if (t + 0.22 < threshold) return .{ .reveal = false, .glyph = ' ' };
- const glyphs = ".:+*#%@";
- const index: usize = @intFromFloat(@floor(noise * @as(f32, @floatFromInt(glyphs.len))));
- return .{ .reveal = false, .glyph = glyphs[@min(index, glyphs.len - 1)] };
-}
-
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);
}
@@ -287,7 +284,7 @@ test "opening presets separate geometry and content transitions" {
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);
+ 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());
@@ -297,31 +294,15 @@ test "opening presets separate geometry and content transitions" {
track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target };
try std.testing.expectEqual(target, track.visualBox());
- const closing: Track = .{
+ var closing: Track = .{
.phase = .closing,
.effect = .vertical,
.from = target,
.to = closingBox(.vertical, target),
};
try std.testing.expectEqual(target, closing.contentBox());
-}
-
-test "ASCII materialization is stable and finishes as real content" {
- const a = asciiCell(42, 11, 7, 0.4);
- const b = asciiCell(42, 11, 7, 0.4);
- try std.testing.expectEqual(a, b);
- try std.testing.expect(!asciiCell(42, 11, 7, 0).reveal);
- try std.testing.expectEqual(@as(u8, ' '), asciiCell(42, 11, 7, 0).glyph);
- try std.testing.expect(asciiCell(42, 11, 7, 1).reveal);
-
- var changed = false;
- for (0..32) |col| {
- if (!std.meta.eql(asciiCell(42, @intCast(col), 3, 0.35), asciiCell(43, @intCast(col), 3, 0.35))) {
- changed = true;
- break;
- }
- }
- try std.testing.expect(changed);
+ closing.frame = 2;
+ try std.testing.expect(closing.amount() < progress(.vertical, closing.frame));
}
test "dissolve has exact stable endpoints" {
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
diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig
index 647c36ff..1891c623 100644
--- a/src/tty/panel_compositor.zig
+++ b/src/tty/panel_compositor.zig
@@ -1,8 +1,9 @@
//! TTY implementation of panel transitions.
//!
-//! The core publishes final cells plus POD tracks. This backend copies that
-//! grid and performs the transition in cell space; no terminal escape trick
-//! or pixel assumption leaks into the core.
+//! The core publishes presentation cells plus POD tracks. It has already
+//! composed PanelAscii because byte stepping is semantic, while this backend
+//! copies the grid for geometry and dissolve operations. No terminal escape
+//! trick or pixel assumption leaks into the core.
const std = @import("std");
const pardes = @import("../pardes.zig");
@@ -22,7 +23,7 @@ pub fn hidesAttachment(tracks: []const Track, serial: u32) bool {
if (track.effect != .slide and track.effect != .zoom and track.effect != .vertical) continue;
// The last active sample is already the exact canonical frame. Place
// the image there too, rather than making it pop in one tick later.
- if (track.frame + 1 < track.effect.frames()) return true;
+ if (track.frame + 1 < track.frames()) return true;
}
return false;
}
@@ -83,7 +84,9 @@ pub fn compose(
.off => {},
.slide, .zoom => transform(out, source, track),
.dissolve => dissolve(out, source, track),
- .ascii => ascii(out, source, track),
+ // Pardes core has already composed the byte-wise ASCII diff
+ // into source.cells. TTY only owns cursor suppression here.
+ .ascii => {},
.vertical => vertical(out, source, track),
}
}
@@ -146,33 +149,6 @@ fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) v
}
}
-fn ascii(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void {
- const area = clipped(track.to, out.cols, out.rows) orelse return;
- const amount = track.amount();
- var y = area.y0;
- while (y < area.y1) : (y += 1) {
- var x = area.x0;
- while (x < area.x1) : (x += 1) {
- if (!source.panelCellChanged(x, y)) continue;
- const material = panel_animation.asciiCell(track.serial, x - area.x0, y - area.y0, amount);
- if (material.reveal) continue;
- const cell = out.at(x, y);
- if (material.glyph == ' ') {
- cell.* = previousCell(source, x, y).*;
- continue;
- }
- // The wave itself wears the new cell's colors, then yields to its
- // real glyph. Before the wave arrives the complete old cell—not a
- // synthetic blank—remains visible above.
- cell.* = source.cells[@as(usize, y) * source.cols + x];
- cell.text[0] = material.glyph;
- cell.len = 1;
- cell.default = false;
- cell.style.dim = true;
- }
- }
-}
-
fn previousCell(source: *const pardes.Surface, x: u16, y: u16) *const pardes.Cell {
std.debug.assert(source.hasPanelDiff());
return &source.previous_cells[@as(usize, y) * source.cols + x];
@@ -258,10 +234,10 @@ fn clipped(box: Box, cols: u16, rows: u16) ?GridBox {
return if (area.x0 < area.x1 and area.y0 < area.y1) area else null;
}
-test "ASCII materialization happens in copied TTY cells" {
+test "TTY consumes core-composed ASCII cells without reinterpreting glyphs" {
var cells: [8]pardes.Cell = @splat(.{});
var previous: [8]pardes.Cell = @splat(.{});
- const changed: [8]u8 = @splat(1);
+ const diffs: [8]pardes.PanelCellDiff = @splat(.visual);
for (&cells) |*cell| {
cell.text[0] = 'X';
cell.len = 1;
@@ -277,7 +253,7 @@ test "ASCII materialization happens in copied TTY cells" {
.rows = 2,
.cells = &cells,
.previous_cells = &previous,
- .changed_cells = &changed,
+ .cell_diffs = &diffs,
};
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
@@ -291,9 +267,7 @@ test "ASCII materialization happens in copied TTY cells" {
const animated = try compose(arena.allocator(), &surface, &.{track}, null);
try std.testing.expect(animated != &surface);
try std.testing.expectEqualStrings("X", surface.at(0, 0).grapheme());
- var differs = false;
- for (animated.cells) |cell| differs = differs or !std.mem.eql(u8, cell.grapheme(), "X");
- try std.testing.expect(differs);
+ for (animated.cells) |cell| try std.testing.expectEqualStrings("X", cell.grapheme());
}
test "dissolve changes only diff cells from old to new" {
@@ -309,13 +283,13 @@ test "dissolve changes only diff cells from old to new" {
cell.len = 1;
cell.default = false;
}
- const changed = [_]u8{ 1, 0 };
+ const diffs = [_]pardes.PanelCellDiff{ .{ .ascii = .{ .from = 'O', .to = 'N' } }, .unchanged };
var surface: pardes.Surface = .{
.cols = 2,
.rows = 1,
.cells = &cells,
.previous_cells = &previous,
- .changed_cells = &changed,
+ .cell_diffs = &diffs,
};
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
@@ -337,45 +311,45 @@ test "dissolve changes only diff cells from old to new" {
try std.testing.expectEqual(cells[1], final.at(1, 0).*);
}
-test "ASCII diff travels old through punctuation to new" {
- var cells = [_]pardes.Cell{.{}};
- var previous = [_]pardes.Cell{.{}};
- cells[0].text[0] = 'N';
- cells[0].default = false;
- previous[0].text[0] = 'O';
- previous[0].default = false;
- const changed = [_]u8{1};
+test "TTY content effects never touch cells outside the published diff" {
+ var cells: [4]pardes.Cell = @splat(.{});
+ var previous: [4]pardes.Cell = @splat(.{});
+ for (&cells, "NUQV") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ // Deliberately disagree even where the mask says unchanged. This makes
+ // the test prove that the compositor obeys the core cell diff rather than
+ // merely appearing correct because old and new happened to match there.
+ for (&previous, "OXRY") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ const diffs = [_]pardes.PanelCellDiff{ .visual, .unchanged, .visual, .unchanged };
var surface: pardes.Surface = .{
- .cols = 1,
+ .cols = 4,
.rows = 1,
.cells = &cells,
.previous_cells = &previous,
- .changed_cells = &changed,
+ .cell_diffs = &diffs,
};
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- var track: Track = .{
- .serial = 9,
- .effect = .ascii,
- .from = .{ .w = 1, .h = 1 },
- .to = .{ .w = 1, .h = 1 },
- };
- const old_frame = try compose(arena.allocator(), &surface, &.{track}, null);
- try std.testing.expectEqualStrings("O", old_frame.at(0, 0).grapheme());
-
- _ = arena.reset(.retain_capacity);
- track.frame = 7;
- const wave = panel_animation.asciiCell(track.serial, 0, 0, track.amount());
- try std.testing.expect(!wave.reveal and wave.glyph != ' ');
- const punctuation = try compose(arena.allocator(), &surface, &.{track}, null);
- try std.testing.expectEqualSlices(u8, &.{wave.glyph}, punctuation.at(0, 0).grapheme());
- try std.testing.expect(punctuation.at(0, 0).style.dim);
-
- _ = arena.reset(.retain_capacity);
- track.frame = track.effect.frames() - 1;
- const new_frame = try compose(arena.allocator(), &surface, &.{track}, null);
- try std.testing.expectEqual(cells[0], new_frame.at(0, 0).*);
+ for ([_]panel_animation.Transition{ .ascii, .dissolve }) |effect| {
+ const track: Track = .{
+ .serial = 17,
+ .effect = effect,
+ .from = .{ .w = 4, .h = 1 },
+ .to = .{ .w = 4, .h = 1 },
+ };
+ const animated = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(cells[1], animated.at(1, 0).*);
+ try std.testing.expectEqual(cells[3], animated.at(3, 0).*);
+ _ = arena.reset(.retain_capacity);
+ }
}
test "content transition without a diff snaps to canonical surface" {
@@ -405,14 +379,14 @@ test "content transition without a diff snaps to canonical surface" {
test "exact transition endpoint preserves the canonical cursor" {
var cells = [_]pardes.Cell{.{}};
var previous = cells;
- const changed = [_]u8{1};
+ const diffs = [_]pardes.PanelCellDiff{.visual};
var surface: pardes.Surface = .{
.cols = 1,
.rows = 1,
.cells = &cells,
.cursor = .{ .x = 0, .y = 0 },
.previous_cells = &previous,
- .changed_cells = &changed,
+ .cell_diffs = &diffs,
};
const track: Track = .{
.effect = .ascii,
@@ -441,13 +415,13 @@ test "vertical opening rises through a fixed old-grid clip" {
cell.len = 1;
cell.default = false;
}
- const changed: [12]u8 = @splat(1);
+ const diffs: [12]pardes.PanelCellDiff = @splat(.visual);
var surface: pardes.Surface = .{
.cols = 3,
.rows = 4,
.cells = &cells,
.previous_cells = &previous,
- .changed_cells = &changed,
+ .cell_diffs = &diffs,
};
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
@@ -481,7 +455,7 @@ test "vertical opening rises through a fixed old-grid clip" {
try std.testing.expectEqualStrings("c", final.at(0, 2).grapheme());
}
-test "vertical closing slides frozen content upward over canonical cells" {
+test "vertical closing drops frozen content over canonical cells" {
var cells: [12]pardes.Cell = @splat(.{});
var previous: [12]pardes.Cell = @splat(.{});
for (&cells, "aaabbbcccddd") |*cell, glyph| {
@@ -494,13 +468,13 @@ test "vertical closing slides frozen content upward over canonical cells" {
cell.len = 1;
cell.default = false;
}
- const changed: [12]u8 = @splat(1);
+ const diffs: [12]pardes.PanelCellDiff = @splat(.visual);
var surface: pardes.Surface = .{
.cols = 3,
.rows = 4,
.cells = &cells,
.previous_cells = &previous,
- .changed_cells = &changed,
+ .cell_diffs = &diffs,
};
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
@@ -518,10 +492,10 @@ test "vertical closing slides frozen content upward over canonical cells" {
try std.testing.expectEqualStrings("2", first.at(0, 2).grapheme());
_ = arena.reset(.retain_capacity);
- track.frame = 2;
+ track.frame = 8;
const leaving = try compose(arena.allocator(), &surface, &.{track}, null);
- try std.testing.expectEqualStrings("2", leaving.at(0, 1).grapheme());
- try std.testing.expectEqualStrings("c", leaving.at(0, 2).grapheme());
+ try std.testing.expectEqualStrings("b", leaving.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("1", leaving.at(0, 2).grapheme());
try std.testing.expectEqualStrings("a", leaving.at(0, 0).grapheme());
try std.testing.expectEqualStrings("d", leaving.at(0, 3).grapheme());
@@ -539,13 +513,13 @@ test "closing content paints after opening content regardless of track order" {
cells[0].default = false;
previous[0].text[0] = 'O';
previous[0].default = false;
- const changed = [_]u8{1};
+ const diffs = [_]pardes.PanelCellDiff{.visual};
var surface: pardes.Surface = .{
.cols = 1,
.rows = 1,
.cells = &cells,
.previous_cells = &previous,
- .changed_cells = &changed,
+ .cell_diffs = &diffs,
};
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
diff --git a/src/tutor.txt b/src/tutor.txt
index 7c4c2733..f9ee1932 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -1079,10 +1079,12 @@ abc
Native panel transitions are the five SPC a choices listed in 3.13. They
start off. Only one is selected; running it again turns it off. Slide and
- zoom animate geometry. Dissolve and ASCII animate only the old/new cell
- diff, leaving unchanged content alone. Vertical raises only the pane being
- added or deleted inside its own box, leaving every survivor still. The
- native GUI scene switches Crt, Ripple
+ zoom animate geometry. Dissolve switches changed cells at stable thresholds.
+ ASCII increments or decrements each changed printable byte toward its new
+ value, using fast ease-out skips for long distances and exact steps nearby;
+ unchanged glyphs and non-ASCII graphemes pass through. Vertical raises a pane being added
+ and drops a deleted pane back down inside its own box; every survivor stays
+ still. The native GUI scene switches Crt, Ripple
and Glitch may be combined. `EffectCode PanelAscii` (or any other
effect builtin as its argument) opens the exact backend sources that
were embedded when this binary was built.