diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 08:45:01 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | bdb4da01c555337fe08167e420b06a3e3f5548ff (patch) | |
| tree | 5775c21ce551a65cd347bea7de78519efb27d3ba /src/tty/tty.zig | |
| parent | 52f9c1423bc4e58079b008e763b5485ae49911f7 (diff) | |
| download | pardes-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).
Diffstat (limited to 'src/tty/tty.zig')
| -rw-r--r-- | src/tty/tty.zig | 138 |
1 files changed, 137 insertions, 1 deletions
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); +} |
