summaryrefslogtreecommitdiff
path: root/src/tty
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty')
-rw-r--r--src/tty/tty.zig138
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);
+}