summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 08:45:01 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commitbdb4da01c555337fe08167e420b06a3e3f5548ff (patch)
tree5775c21ce551a65cd347bea7de78519efb27d3ba
parent52f9c1423bc4e58079b008e763b5485ae49911f7 (diff)
downloadpardes-bdb4da01c555337fe08167e420b06a3e3f5548ff.tar.gz
pardes-bdb4da01c555337fe08167e420b06a3e3f5548ff.zip
T2: JumpTrail, a terminal cursor's jump leaves a short fading trail
JumpTrail on|off (off; the tty shell's, §9.2 T2): a jump of three cells or more leaves three to five cells of trail behind the cursor along its path, their ground mixed toward the text's colour in OKLab (most beside the cursor), fading out over 120 ms. Truecolor terminals only (vaxis's answer, or COLORTERM truecolor/24bit); never the cursor's own cell, a selection or anything outside the focused pane's body; none under Motion off. It rides the continuous path (Pardes.shell_continuous) while it lasts; a few cells of output a frame. docs/effects.md opens the terminal track with the audit under decision 2 (keep and polish, remove nothing). Tests: a ten-cell jump lights 3 to 5 cells behind the cursor on its row, gone by 120 ms; a one-cell step and a terminal without truecolor light none. Feel review: .scratch/render/tty/trail/ (a real tty session replayed through pyte at 60 fps).
-rw-r--r--docs/config.md3
-rw-r--r--docs/effects.md17
-rw-r--r--src/config.zig8
-rw-r--r--src/tty/tty.zig138
4 files changed, 164 insertions, 2 deletions
diff --git a/docs/config.md b/docs/config.md
index c66b124e..1d83bd8e 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -582,6 +582,9 @@ Parallax on a theme's page pattern (lapis's dots) moves with the text
at a quarter of its speed
Afterglow on text that changes glows for a moment in the theme's accent
and fades (250 ms), never on a tag, the cursor or a selection
+JumpTrail on in a terminal, a jump of the cursor of three cells or more
+ leaves a trail of a few cells that fades in 120 ms
+ (truecolor terminals; a pixel shell glides instead)
CursorBlink on the cursor blinks, solid while typing and half a second
after, eased at each edge, solid after 10 idle seconds
GripWidth 150 the grip's button and the scrollbar under it, percent of the
diff --git a/docs/effects.md b/docs/effects.md
index 6a283e45..02602a23 100644
--- a/docs/effects.md
+++ b/docs/effects.md
@@ -247,3 +247,20 @@ Never on a tag or a grip (bodies only), the cursor's cell or a selection;
a body whose first line moved (a scroll) starts over instead of lighting
up whole. It runs on the continuous path while anything glows, and costs
a compare of the grid a frame while on, nothing while off.
+
+## Terminal track (§9.2)
+
+Audit, after decision 2 (keep every whimsical effect and polish it, never
+remove it): the six core-composed character transitions (ascii, edges,
+fall, wave, scramble, typewriter) and curtain stay as they are, each
+bounded to its pane's final rectangle and each cell's last sample its
+canonical one; slide and vertical are the tty compositor's and follow the
+Motion flavour (sxrrwplk); zoom stays (nearest-cell scaling, its look is
+the effect); dissolve swaps each changed cell once at its threshold;
+message fades and the theme's chrome fade are the core's (the latter in
+OKLab since G10), and InactiveDim (T5's unfocused dim) works in a grid as
+it does in a pixel shell.
+
+| # | effect | switch | state | review material | verdict |
+|---|---|---|---|---|---|
+| T2 | cursor jump trail | `JumpTrail on\|off` (off) | a jump of three cells or more leaves 3 to 5 cells of trail behind the cursor, their ground drawn toward the text's colour in OKLab, most beside it, fading in 120 ms; truecolor terminals (vaxis's answer or COLORTERM); never the cursor's cell, a selection or outside the focused body; off under Motion off; a few cells a frame | .scratch/render/tty/trail/ (pyte replay of a real tty session at 60 fps: trail-forge.mp4, zooms) | pending |
diff --git a/src/config.zig b/src/config.zig
index 2a764118..c0266e40 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -127,6 +127,7 @@ pub const leader_path = paths: {
if (builtins.capabilities.lift) table.set(.Occlusion, null);
if (builtins.capabilities.lift) table.set(.Parallax, null);
if (builtins.capabilities.lift) table.set(.Afterglow, null);
+ table.set(.JumpTrail, null);
if (builtins.capabilities.lift) table.set(.Lift, null);
table.set(.Motion, null);
table.set(.InactiveDim, null);
@@ -686,6 +687,9 @@ pub const Runtime = struct {
/// G14: text that changes glows for a moment in the theme's accent and
/// fades, a pixel shell's.
afterglow: bool = false,
+ /// T2: in a terminal, a jump of the cursor leaves a short trail that
+ /// fades in 120 ms (a truecolor terminal's; a pixel shell glides).
+ jump_trail: bool = false,
/// How long a message stays after the key or click that dismisses it.
message_linger_ms: u16 = 800,
/// How long its fall into the row takes, easing out (§8.1: arriving,
@@ -909,7 +913,7 @@ pub const Runtime = struct {
/// The message timings a setting word sets, each in milliseconds.
pub const MessageTime = enum { linger, fall, dissolve };
- pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink, selection_glow, hover_glow, occlusion, parallax, afterglow };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink, selection_glow, hover_glow, occlusion, parallax, afterglow, jump_trail };
/// A bundled post pass, a Shadertoy file in shaders/post/: the CRT, and
/// G4's bloom, vignette and grain (docs/effects.md).
pub const Scene = enum { crt, bloom, vignette, grain };
@@ -973,6 +977,7 @@ pub const Runtime = struct {
.{ .word = "Occlusion", .action = .{ .toggle = .occlusion }, .availability = .lift, .doc = "Pane bodies darken faintly toward their edges, on or off." },
.{ .word = "Parallax", .action = .{ .toggle = .parallax }, .availability = .lift, .doc = "A theme's page pattern moves with the text at a quarter of its speed, on or off." },
.{ .word = "Afterglow", .action = .{ .toggle = .afterglow }, .availability = .lift, .doc = "Text that changes glows for a moment and fades, on or off." },
+ .{ .word = "JumpTrail", .action = .{ .toggle = .jump_trail }, .doc = "In a terminal, a jump of the cursor leaves a short trail that fades, on or off." },
.{ .word = "Lift", .action = .lift, .availability = .lift, .doc = "How the focused pane stands off the page: off, shadow, rim or auto." },
.{ .word = "Motion", .action = .motion, .doc = "How animations move: off, crisp, smooth, bouncy or playful." },
.{ .word = "InactiveDim", .action = .inactive_dim, .doc = "Fade the unfocused panes' text by a percentage, 0 through 100, 0 off." },
@@ -1711,6 +1716,7 @@ pub const Runtime = struct {
.occlusion => "Occlusion on\n",
.parallax => "Parallax on\n",
.afterglow => "Afterglow on\n",
+ .jump_trail => "JumpTrail on\n",
},
.shell => "Shell requested (new panes) fish\n",
.tagline_size => "TaglineSize 73\n",
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index e7eddec4..8f07d7fc 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -914,6 +914,8 @@ const Shell = struct {
check_files: bool = false,
in_paste: bool = false,
tracks: []const pardes.animation.Track = &.{},
+ /// T2: the cursor's jump trail (JumpTrail).
+ trail: JumpTrail = .{},
/// PARDES_TEST_CLOCK's virtual time (host_io.testClock), and where the
/// running-out wait moves it: the core's next wake.
test_clock: ?u64 = null,
@@ -1161,7 +1163,8 @@ const Shell = struct {
) catch return;
const tz_cells = tracy.zone(@src(), "surface->vaxis");
const win = vx.window();
- paintCells(win, surface.cells, surface.cols, surface.rows);
+ const trailed = s.trail.apply(s.frame.allocator(), surface, s.core, vx.caps.rgb or colortermTruecolor()) catch surface;
+ paintCells(win, trailed.cells, trailed.cols, trailed.rows);
tz_cells.end();
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
@@ -1804,6 +1807,95 @@ fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u1
}
}
+/// Truecolor by COLORTERM (truecolor or 24bit), for a terminal that does
+/// not answer vaxis's query (§9.2: vaxis caps or COLORTERM).
+fn colortermTruecolor() bool {
+ const value = std.mem.span(std.c.getenv("COLORTERM") orelse return false);
+ return std.mem.eql(u8, value, "truecolor") or std.mem.eql(u8, value, "24bit");
+}
+
+/// T2 (docs/render-pipeline.md §9.2): the cursor jumping three cells or
+/// more leaves a trail of three to five cells along its path, just behind
+/// it, their ground drawn toward the text's colour, most beside the
+/// cursor, all fading out over 120 ms. Truecolor terminals only; a cell a
+/// frame changes, a few bytes. Never the cursor's own cell, a selection or
+/// anything outside the focused pane's body; off with Motion off.
+const JumpTrail = struct {
+ last: ?[2]u16 = null,
+ from: [2]u16 = .{ 0, 0 },
+ to: [2]u16 = .{ 0, 0 },
+ start_ns: u64 = 0,
+ active: bool = false,
+
+ const life_ns: u64 = 120 * std.time.ns_per_ms;
+
+ fn apply(trail: *JumpTrail, arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface {
+ defer core.shell_continuous = trail.active;
+ const on = core.settings.jump_trail and truecolor and core.settings.motion != .off;
+ const cursor = surface.cursor orelse {
+ trail.active = false;
+ return surface;
+ };
+ const here: [2]u16 = .{ cursor.x, cursor.y };
+ if (trail.last) |was| {
+ const dx = @as(i32, here[0]) - was[0];
+ const dy = @as(i32, here[1]) - was[1];
+ if (on and @max(@abs(dx), @abs(dy)) >= 3) {
+ trail.* = .{ .last = here, .from = was, .to = here, .start_ns = core.now_ns, .active = true };
+ }
+ }
+ trail.last = here;
+ if (!on or !trail.active) {
+ trail.active = false;
+ return surface;
+ }
+ const age = core.now_ns -| trail.start_ns;
+ if (age >= life_ns or !std.meta.eql(trail.to, here)) {
+ trail.active = false;
+ return surface;
+ }
+ const out = try arena.create(pardes.Surface);
+ out.* = surface.*;
+ out.cells = try arena.dupe(pardes.Cell, surface.cells);
+ const theme = core.theme();
+ const ink = theme.fg orelse .{ 0xcc, 0xcc, 0xcc };
+ const page = theme.bg orelse pardes.colors.null_page;
+ const body = for (surface.regionList()) |region| {
+ if (region.kind == .body and region.owner == core.active) break region.rect;
+ } else return out;
+ const t = @as(f32, @floatFromInt(age)) / @as(f32, @floatFromInt(life_ns));
+ const fade = (1 - t) * (1 - t);
+ const dx = @as(f32, @floatFromInt(@as(i32, trail.from[0]) - trail.to[0]));
+ const dy = @as(f32, @floatFromInt(@as(i32, trail.from[1]) - trail.to[1]));
+ const span = @max(@abs(dx), @abs(dy));
+ const n: usize = @intFromFloat(std.math.clamp(@round(span / 4), 3, 5));
+ var k: usize = 1;
+ var last_cell: ?[2]i32 = null;
+ var step: f32 = 1;
+ while (k <= n and step <= span) : (step += 1) {
+ const x: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(trail.to[0])) + dx * step / span));
+ const y: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(trail.to[1])) + dy * step / span));
+ if (last_cell) |seen| if (seen[0] == x and seen[1] == y) continue;
+ last_cell = .{ x, y };
+ defer k += 1;
+ if (x < body.x or y < body.y or x >= @as(i32, body.x) + body.w or y >= @as(i32, body.y) + body.h) continue;
+ const cell = &out.cells[@as(usize, @intCast(y)) * out.cols + @as(usize, @intCast(x))];
+ const ground: [3]u8 = if (cell.default) page else switch (cell.style.bg) {
+ .rgb => |rgb| rgb,
+ else => page,
+ };
+ if (std.mem.eql(u8, &ground, &theme.sel_bg)) continue;
+ const strength = 0.55 * fade * (1 - @as(f32, @floatFromInt(k - 1)) / @as(f32, @floatFromInt(n)));
+ if (strength < 0.02) continue;
+ const was_default = cell.default;
+ cell.default = false;
+ if (was_default) cell.style = .{};
+ cell.style.bg = .{ .rgb = pardes.colors.mixOklab(ground, ink, strength) };
+ }
+ return out;
+ }
+};
+
fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void {
win.showCursor(x, y);
win.setCursorShape(if (bar) .beam else .default);
@@ -2064,3 +2156,47 @@ fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx:
return attachLoop(&a);
}
+
+test "a jump leaves a trail of a few cells behind the cursor that fades in 120 ms" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.setTestFile("some text on a line\n" ** 20);
+ try std.testing.expect(core.executeBuiltinLine(0, "Theme forge"));
+ core.settings.jump_trail = true;
+ for (0..40) |_| core.update(.tick);
+ core.sync();
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ const s = try core.render(arena.allocator());
+ const body = for (s.regionList()) |region| {
+ if (region.kind == .body) break region.rect;
+ } else return error.NoBody;
+ var trail: JumpTrail = .{};
+ core.now_ns = std.time.ns_per_s;
+ s.cursor = .{ .x = body.x + 2, .y = body.y + 1 };
+ _ = try trail.apply(arena.allocator(), s, core, true);
+ // Ten cells right: a trail back toward where it was.
+ s.cursor = .{ .x = body.x + 12, .y = body.y + 1 };
+ core.now_ns += 16 * std.time.ns_per_ms;
+ const lit = try trail.apply(arena.allocator(), s, core, true);
+ try std.testing.expect(trail.active and core.shell_continuous);
+ var changed: usize = 0;
+ for (lit.cells, s.cells, 0..) |a, b, i| {
+ if (std.meta.eql(a.style.bg, b.style.bg)) continue;
+ changed += 1;
+ // On the cursor's row, behind it, never its own cell.
+ try std.testing.expectEqual(@as(usize, body.y + 1), i / s.cols);
+ try std.testing.expect(i % s.cols < body.x + 12 and i % s.cols > body.x + 2);
+ }
+ try std.testing.expect(changed >= 3 and changed <= 5);
+ // Gone by 120 ms; a step of a cell leaves none; nor a terminal without
+ // truecolor.
+ core.now_ns += 130 * std.time.ns_per_ms;
+ const faded = try trail.apply(arena.allocator(), s, core, true);
+ try std.testing.expect(faded == s and !trail.active and !core.shell_continuous);
+ s.cursor = .{ .x = body.x + 13, .y = body.y + 1 };
+ try std.testing.expect(try trail.apply(arena.allocator(), s, core, true) == s);
+ s.cursor = .{ .x = body.x + 2, .y = body.y + 1 };
+ try std.testing.expect(try trail.apply(arena.allocator(), s, core, false) == s);
+}