diff options
Diffstat (limited to 'src/tty/tty.zig')
| -rw-r--r-- | src/tty/tty.zig | 334 |
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); +} |
