summaryrefslogtreecommitdiff
path: root/src/tty
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 09:04:44 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit99149638c2b89fd134131d081bfd5fc8f967c53e (patch)
treebc1de1e1b2f96cd1da79f7d0b6bff40b619d5ae3 /src/tty
parenta544c0b1af65427f2186baaa5dff176bcd310915 (diff)
downloadpardes-99149638c2b89fd134131d081bfd5fc8f967c53e.tar.gz
pardes-99149638c2b89fd134131d081bfd5fc8f967c53e.zip
T7: the scroll thumb flashes as a terminal pane scrolls
ThumbFlash (off by default) brightens a body's scroll thumb toward the ink when its view moves and fades it back over 250 ms, truecolor only. The tty present path now clears shell_continuous once before its effects and each effect only raises it, so the trail and the flash no longer mask each other.
Diffstat (limited to 'src/tty')
-rw-r--r--src/tty/tty.zig96
1 files changed, 94 insertions, 2 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 69934dd3..c4096faf 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -916,6 +916,8 @@ const Shell = struct {
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,
@@ -1164,8 +1166,11 @@ const Shell = struct {
const tz_cells = tracy.zone(@src(), "surface->vaxis");
const win = vx.window();
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 trailed = s.trail.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed;
+ 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| {
@@ -1873,6 +1878,60 @@ fn chipShadows(arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes
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 {
@@ -1896,7 +1955,7 @@ const JumpTrail = struct {
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;
+ 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;
@@ -2259,6 +2318,7 @@ test "a jump leaves a trail of a few cells behind the cursor that fades in 120 m
// 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 };
@@ -2302,3 +2362,35 @@ test "a notice chip casts a cell's shadow right and down, inside its body, off t
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);
+}