summaryrefslogtreecommitdiff
path: root/src/tty
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty')
-rw-r--r--src/tty/tty.zig334
1 files changed, 333 insertions, 1 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 762616f5..3c8c90bc 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -914,6 +914,10 @@ 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 = .{},
+ /// T7: when each pane's view last moved (ThumbFlash).
+ flash: ThumbFlash = .{},
/// 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 +1165,13 @@ 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 truecolor = vx.caps.rgb or colortermTruecolor();
+ // Each of the shell's own motions below asks for frames while it runs.
+ s.core.shell_continuous = false;
+ const shadowed = chipShadows(s.frame.allocator(), surface, s.core, truecolor) catch surface;
+ const flashed = s.flash.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed;
+ const trailed = s.trail.apply(s.frame.allocator(), flashed, s.core, truecolor) catch flashed;
+ paintCells(win, trailed.cells, trailed.cols, trailed.rows);
tz_cells.end();
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
@@ -1807,6 +1817,213 @@ fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u1
}
}
+/// T1 (§9.2): each notice chip casts a shadow one cell right and one down,
+/// inside its own pane's body: its ground 35% toward black in OKLab on a
+/// cell with text; on a blank one a half block (▌ on the right, ▀ below,
+/// ▘ at the corner) in the darker colour, so the shadow is half a cell
+/// thick and reads as one. Never the cursor's cell, a selection, a tag or
+/// another chip. Truecolor terminals only.
+fn chipShadows(arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface {
+ if (!core.settings.chip_shadow or !truecolor) return surface;
+ const regions = surface.regionList();
+ const any = for (regions) |region| {
+ if (region.kind == .notice) break true;
+ } else false;
+ if (!any) 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 page = theme.bg orelse pardes.colors.null_page;
+ for (regions) |chip| {
+ if (chip.kind != .notice or chip.rect.w == 0) continue;
+ const body = for (regions) |region| {
+ if (region.kind == .body and region.owner == chip.owner) break region.rect;
+ } else continue;
+ const right = chip.rect.x + chip.rect.w;
+ const below = chip.rect.y + chip.rect.h;
+ // The column right of it (from its second row), the row below (from
+ // its second column), and the corner.
+ var y = chip.rect.y;
+ while (y <= below) : (y += 1) {
+ var x = chip.rect.x;
+ while (x <= right) : (x += 1) {
+ const side = x == right and y > chip.rect.y and y < below;
+ const foot = y == below and x > chip.rect.x and x < right;
+ const corner = x == right and y == below;
+ if (!side and !foot and !corner) continue;
+ if (x < body.x or y < body.y or x >= body.x + body.w or y >= body.y + body.h or x >= out.cols or y >= out.rows) continue;
+ if (surface.cursor) |cu| if (cu.x == x and cu.y == y) continue;
+ const covered = for (regions) |region| {
+ if ((region.kind == .notice or region.kind == .tag or region.kind == .grip) and x >= region.rect.x and x < region.rect.x + region.rect.w and y >= region.rect.y and y < region.rect.y + region.rect.h) break true;
+ } else false;
+ if (covered) continue;
+ const cell = &out.cells[@as(usize, y) * out.cols + 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 dark = pardes.colors.mixOklab(ground, .{ 0, 0, 0 }, 0.35);
+ const blank = cell.default or cell.len == 0 or (cell.len == 1 and cell.text[0] == ' ');
+ if (blank) {
+ const glyph: []const u8 = if (corner) "▘" else if (side) "▌" else "▀";
+ cell.* = .{ .default = false, .style = .{ .fg = .{ .rgb = dark }, .bg = .{ .rgb = ground } } };
+ @memcpy(cell.text[0..glyph.len], glyph);
+ cell.len = @intCast(glyph.len);
+ } else {
+ cell.default = false;
+ cell.style.bg = .{ .rgb = dark };
+ }
+ }
+ }
+ }
+ return out;
+}
+
+/// T7 (§9.2): a pane whose view moved (Region.line) has its scroll thumb
+/// brighten, drawn most of the way to the text's colour, and fade back
+/// over 250 ms with an ease-out: where you are in the file, as you move.
+/// The rail is chrome, not a focus indicator. Truecolor terminals only.
+const ThumbFlash = struct {
+ lines: [pardes.MAX_PANES]i32 = @splat(std.math.minInt(i32)),
+ since: [pardes.MAX_PANES]u64 = @splat(0),
+
+ const life_ns: u64 = 250 * std.time.ns_per_ms;
+
+ fn apply(flash: *ThumbFlash, arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface {
+ const on = core.settings.thumb_flash and truecolor and core.settings.motion != .off;
+ var out: ?*pardes.Surface = null;
+ const theme = core.theme();
+ const ink = theme.fg orelse .{ 0xcc, 0xcc, 0xcc };
+ for (surface.regionList()) |body| {
+ if (body.kind != .body or body.owner >= flash.lines.len) continue;
+ const id = body.owner;
+ if (flash.lines[id] != std.math.minInt(i32) and flash.lines[id] != body.line) flash.since[id] = core.now_ns;
+ flash.lines[id] = body.line;
+ const age = core.now_ns -| flash.since[id];
+ if (!on or flash.since[id] == 0 or age >= life_ns) continue;
+ const rail = for (surface.regionList()) |region| {
+ if (region.kind == .rail and region.owner == id) break region;
+ } else continue;
+ if (rail.thumb_h == 0) continue;
+ const t = @as(f32, @floatFromInt(age)) / @as(f32, @floatFromInt(life_ns));
+ const strength = 0.6 * (1 - t) * (1 - t);
+ if (out == null) {
+ const copy = try arena.create(pardes.Surface);
+ copy.* = surface.*;
+ copy.cells = try arena.dupe(pardes.Cell, surface.cells);
+ out = copy;
+ }
+ const o = out.?;
+ var y = rail.rect.y + rail.thumb_y;
+ while (y < rail.rect.y + rail.thumb_y + rail.thumb_h and y < o.rows) : (y += 1) {
+ var x = rail.rect.x;
+ while (x < rail.rect.x + rail.rect.w and x < o.cols) : (x += 1) {
+ const cell = &o.cells[@as(usize, y) * o.cols + x];
+ const ground: [3]u8 = switch (cell.style.bg) {
+ .rgb => |rgb| rgb,
+ else => continue,
+ };
+ if (!std.mem.eql(u8, &ground, &core.chromeTheme().scroll_thumb)) continue;
+ cell.style.bg = .{ .rgb = pardes.colors.mixOklab(ground, ink, strength) };
+ }
+ }
+ core.shell_continuous = true;
+ }
+ return out orelse surface;
+ }
+};
+
+/// 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 or core.shell_continuous;
+ 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);
@@ -1943,6 +2160,8 @@ const Attach = struct {
.set_clipboard => |text| copyToClipboard(a.vx, a.tty, a.gpa, text),
.read_clipboard => requestClipboard(a.vx, a.tty),
.open_link => |url| look.openLink(url),
+ // A terminal has no post chain to draw through.
+ .post => {},
}
return null;
}
@@ -2067,3 +2286,116 @@ 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;
+ core.shell_continuous = false;
+ 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);
+}
+
+test "a notice chip casts a cell's shadow right and down, inside its body, off the cursor" {
+ 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.chip_shadow = true;
+ try std.testing.expect(core.executeBuiltinLine(0, "Msg a notice"));
+ for (0..60) |_| core.update(.tick);
+ core.sync();
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ const s = try core.render(arena.allocator());
+ const chip = for (s.regionList()) |region| {
+ if (region.kind == .notice) break region.rect;
+ } else return error.NoNotice;
+ s.cursor = null;
+ const out = try chipShadows(arena.allocator(), s, core, true);
+ try std.testing.expect(out != s);
+ var changed: usize = 0;
+ for (out.cells, s.cells, 0..) |a, b, i| {
+ if (std.meta.eql(a, b)) continue;
+ changed += 1;
+ const x = i % s.cols;
+ const y = i / s.cols;
+ // Right of the chip or below it, never inside it.
+ try std.testing.expect(x == chip.x + chip.w or y == chip.y + chip.h);
+ try std.testing.expect(!(x >= chip.x and x < chip.x + chip.w and y >= chip.y and y < chip.y + chip.h));
+ }
+ try std.testing.expect(changed >= chip.w - 1);
+ // Off, or without truecolor: the frame as it was.
+ try std.testing.expect(try chipShadows(arena.allocator(), s, core, false) == s);
+ core.settings.chip_shadow = false;
+ try std.testing.expect(try chipShadows(arena.allocator(), s, core, true) == s);
+}
+
+test "a scrolled pane's thumb brightens and fades back in 250 ms" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer core.deinit();
+ const pane = try core.setTestFile("some text on a line\n" ** 200);
+ try std.testing.expect(core.executeBuiltinLine(0, "Theme forge"));
+ core.settings.thumb_flash = true;
+ for (0..40) |_| core.update(.tick);
+ core.sync();
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ var flash: ThumbFlash = .{};
+ core.now_ns = std.time.ns_per_s;
+ const first = try core.render(arena.allocator());
+ try std.testing.expect(try flash.apply(arena.allocator(), first, core, true) == first);
+ pane.scrollBy(20);
+ core.now_ns += 16 * std.time.ns_per_ms;
+ const moved = try core.render(arena.allocator());
+ const lit = try flash.apply(arena.allocator(), moved, core, true);
+ try std.testing.expect(lit != moved and core.shell_continuous);
+ var brighter: usize = 0;
+ for (lit.cells, moved.cells) |a, b| {
+ if (!std.meta.eql(a.style.bg, b.style.bg)) brighter += 1;
+ }
+ try std.testing.expect(brighter >= 1);
+ core.now_ns += 300 * std.time.ns_per_ms;
+ core.shell_continuous = false;
+ const later = try core.render(arena.allocator());
+ try std.testing.expect(try flash.apply(arena.allocator(), later, core, true) == later);
+ try std.testing.expect(!core.shell_continuous);
+}