summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig19
-rw-r--r--docs/config.md24
-rw-r--r--src/config.zig66
-rw-r--r--src/detached/server.zig14
-rw-r--r--src/dump.zig19
-rw-r--r--src/fs.zig4
-rw-r--r--src/gui/gui.zig110
-rw-r--r--src/macos.zig4
-rw-r--r--src/panes.zig484
-rw-r--r--src/pardes.zig433
-rw-r--r--src/pdf.zig67
-rw-r--r--src/pdf_bridge.c228
-rw-r--r--src/pdf_bridge.h46
-rw-r--r--src/tag_layer.zig4
-rw-r--r--src/tty/tty.zig148
-rw-r--r--test/output.zig3
-rw-r--r--test/panes.zig37
-rw-r--r--test/pdf_pointer_bench.zig224
-rw-r--r--test/snapshot.zig4
-rw-r--r--test/snapshots/builtins.snap4
-rw-r--r--test/snapshots/dump.golden3
-rw-r--r--test/snapshots/load.golden18
-rw-r--r--test/snapshots/restore.golden6
23 files changed, 1815 insertions, 154 deletions
diff --git a/build.zig b/build.zig
index 562ed89f..7010c863 100644
--- a/build.zig
+++ b/build.zig
@@ -142,6 +142,7 @@ pub fn build(b: *std.Build) void {
const mupdf_check = b.step("mupdf-check", "compile, link, render, and search docs/design.pdf with MuPDF");
const pdf_bench_step = b.step("pdf-bench", "benchmark real MuPDF page rendering (-- [--json] [--reps N] [--warmup N] [--path FILE] [--page N] [--pages N])");
const pdf_sections_bench_step = b.step("pdf-sections-bench", "benchmark PDF outline/sections paths in ReleaseFast (-Doptimize=ReleaseFast -- [--json] [--reps N] [--warmup N])");
+ const pdf_pointer_bench_step = b.step("pdf-pointer-bench", "time PDF hover and selection gestures and prove their pixels (-Doptimize=ReleaseFast -- [--label L] [--reps N] [--path FILE] [--cell WxH])");
const pdf_scroll_bench_step = b.step("pdf-scroll-bench", "benchmark fast continuous-strip PDF scrolling in ReleaseFast (-Doptimize=ReleaseFast -- [--json] [--reps N] [--warmup N] [--path FILE])");
if (!enable_mupdf)
mupdf_check.dependOn(&b.addFail("mupdf-check is unavailable with -Dmupdf=false").step);
@@ -151,6 +152,8 @@ pub fn build(b: *std.Build) void {
pdf_sections_bench_step.dependOn(&b.addFail("pdf-sections-bench is unavailable with -Dmupdf=false").step);
if (!enable_mupdf)
pdf_scroll_bench_step.dependOn(&b.addFail("pdf-scroll-bench is unavailable with -Dmupdf=false").step);
+ if (!enable_mupdf)
+ pdf_pointer_bench_step.dependOn(&b.addFail("pdf-pointer-bench is unavailable with -Dmupdf=false").step);
const shaders_step = b.step("shaders", "refresh paired shaders/prebuilt/*.spv + *.glsl snapshots (glslc)");
const update_shaders = b.addUpdateSourceFiles();
@@ -482,6 +485,22 @@ pub fn build(b: *std.Build) void {
run_pdf_sections_bench.setCwd(b.path("."));
pdf_sections_bench_step.dependOn(&run_pdf_sections_bench.step);
+ const pdf_pointer_bench = b.addExecutable(.{
+ .name = "pardes-pdf-pointer-bench",
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = .ReleaseFast,
+ .root_source_file = b.path("test/pdf_pointer_bench.zig"),
+ .link_libc = true,
+ }),
+ });
+ pdf_pointer_bench.root_module.addImport("pardes", core_module);
+ pdf_pointer_bench.root_module.addImport("mupdf", mupdf_mod);
+ const run_pdf_pointer_bench = b.addRunArtifact(pdf_pointer_bench);
+ if (b.args) |args| run_pdf_pointer_bench.addArgs(args);
+ run_pdf_pointer_bench.setCwd(b.path("."));
+ pdf_pointer_bench_step.dependOn(&run_pdf_pointer_bench.step);
+
const pdf_scroll_bench = b.addExecutable(.{
.name = "pardes-pdf-scroll-bench",
.root_module = b.createModule(.{
diff --git a/docs/config.md b/docs/config.md
index 8eff79a8..27818b5b 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -115,6 +115,16 @@ compact screen. Hiding it preserves your custom column commands.
name before it runs; it is on by default, and the builtins that own the message
row themselves (`Msg`) never announce. Turning it off leaves the row to the
messages a builtin chooses to write.
+`MessageAnimation` toggles how a message comes and goes; it is on by default.
+A message eases down into its row (graphical frontends slide it out from under
+the tagline; a terminal fades it in), stays until the next key or click as it
+always has, then lingers for `MessageLinger` milliseconds (default 800) before
+it dissolves into the page. `MessageFall` (default 180) and `MessageDissolve`
+(default 300) set how long the fall and the dissolve take, also in
+milliseconds; each timing is at most 60000, and `Config` reports all three. `MessageLinger 0` with
+`MessageAnimation off` restores the old behaviour, a message cleared by the
+very input that follows it. An updated line on a row already showing one swaps
+its text in place rather than falling again.
`SyntaxBold` toggles bold syntax keywords; it is off by default. These
settings are shared by GUI and TTY, and `Config` reports their current
states. Like `Colors` and `Wrap`, these commands take no argument and invert
@@ -573,8 +583,18 @@ control all yield null, and the flag has to work anyway. Neither is a question
asked at runtime — a binary that shelled out to `git` would describe whatever
tree it was standing in rather than the one it came from.
-`Restore a.dump` first looks for the relative path in the default dump directory
-(`$XDG_DATA_HOME/pardes`, or `~/.local/share/pardes`). If it is absent, Restore
+`Restore a.dump` first looks for the relative path in the dump directory:
+`DumpDir <path>` when set (a leading `~/` is your home; bare `DumpDir` returns
+to the default), else `$XDG_DATA_HOME/pardes` or `~/.local/share/pardes`.
+`Config` reports the directory in effect as `DumpDir <path>`, so the line can
+be fed back as configuration. If it is absent, Restore
uses the argument as a path as before. Absolute paths and argument-free Restore
retain their existing behavior. `Dump` still honors `$PARDES_DUMP` and otherwise
writes a timestamped file in the default directory.
+
+A restored terminal comes back live: its recorded output (the last MiB of it)
+is replayed as history, a dim `── restored history ──` line marks where it
+ends, and a new shell starts below it in the directory the old one was in. A
+view left scrolled back stays where it was. Only the shell is new; nothing the
+old one was running is restarted. The web shell, which has no ptys, still
+shows the history alone.
diff --git a/src/config.zig b/src/config.zig
index 98bbf3f4..527c3752 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -102,6 +102,11 @@ pub const leader_path = paths: {
table.set(.TaglineSize, null);
}
if (pardes.platform == .gui) table.set(.Pet, null);
+ table.set(.MessageAnimation, null);
+ table.set(.MessageLinger, null);
+ table.set(.MessageFall, null);
+ table.set(.MessageDissolve, null);
+ table.set(.DumpDir, null);
if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null);
if (builtins.capabilities.window_blur) table.set(.WindowBlur, null);
if (builtins.capabilities.panel_transitions) {
@@ -269,6 +274,10 @@ pub const wheel_cols: i32 = 4;
pub const look_preview_delay_frames: ?u16 = 2;
+/// The longest any message timing (MessageLinger, MessageFall,
+/// MessageDissolve) may be set to, in milliseconds.
+pub const message_ms_max: u16 = 60_000;
+
// Tag fonts keep body-cell geometry; valid sizes are 1...100 percent.
pub const gui_tagline_font_percent: u8 = 82;
comptime {
@@ -558,6 +567,17 @@ pub const Runtime = struct {
syntax_bold: bool = false,
/// Builtins announce themselves on the message row.
verbose: bool = true,
+ /// A message falls into its row and dissolves out of it.
+ message_animation: bool = true,
+ /// How long a message stays after the key or click that dismisses it.
+ message_linger_ms: u16 = 800,
+ /// How long its ease-in fall into the row takes.
+ message_fall_ms: u16 = 180,
+ /// How long its ease-out dissolve takes.
+ message_dissolve_ms: u16 = 300,
+ /// Where Dump writes and a relative Restore looks; empty is the default
+ /// ($XDG_DATA_HOME/pardes, else ~/.local/share/pardes).
+ dump_dir: Text(limits.host_path_cap) = .{},
window_opacity: u8 = 100,
window_blur: u8 = 0,
window_opacity_pending: bool = true,
@@ -655,7 +675,10 @@ pub const Runtime = struct {
pub const Capability = std.meta.FieldEnum(Capabilities);
- pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose };
+ /// The message timings a setting word sets, each in milliseconds.
+ pub const MessageTime = enum { linger, fall, dissolve };
+
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation };
pub const Scene = std.meta.FieldEnum(layout.SceneEffect);
pub const Action = union(enum) {
@@ -666,6 +689,8 @@ pub const Runtime = struct {
tagline_size,
window_opacity,
window_blur,
+ message_ms: MessageTime,
+ dump_dir,
transition: layout.Transition,
scene: Scene,
};
@@ -679,7 +704,7 @@ pub const Runtime = struct {
return switch (setting.action) {
// A toggle flips when it is a bare tagline word and SETS when
// it is given `on` or `off`, so the report is configuration.
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .toggle => true,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .dump_dir, .toggle => true,
else => false,
};
}
@@ -703,6 +728,11 @@ pub const Runtime = struct {
.{ .word = "ColumnTags", .action = .{ .toggle = .column_tags } },
.{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } },
.{ .word = "Verbose", .action = .{ .toggle = .verbose } },
+ .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } },
+ .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } },
+ .{ .word = "MessageFall", .action = .{ .message_ms = .fall } },
+ .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } },
+ .{ .word = "DumpDir", .action = .dump_dir },
.{ .word = "Theme", .action = .theme },
.{ .word = "Shell", .action = .shell },
.{ .word = "Font", .action = .font, .availability = .font_picker },
@@ -827,6 +857,17 @@ pub const Runtime = struct {
if (percent > 100) return false;
state.window_blur = percent;
},
+ // Bare, it returns to the default directory.
+ .dump_dir => if (!state.dump_dir.set(std.mem.trim(u8, argument orelse "", " \t\r\n"))) return false,
+ .message_ms => |which| {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ for (text) |byte| if (!std.ascii.isDigit(byte)) return false;
+ const ms = std.fmt.parseInt(u16, text, 10) catch return false;
+ if (ms > message_ms_max) return false;
+ switch (which) {
+ inline else => |w| @field(state, "message_" ++ @tagName(w) ++ "_ms") = ms,
+ }
+ },
.transition => |effect| state.toggleTransition(effect),
.scene => |effect| switch (effect) {
inline else => |field| {
@@ -847,6 +888,9 @@ pub const Runtime = struct {
compiled_default_shell: []const u8,
gui_shader_source_mode: ?[]const u8 = null,
hover_delay_frames: ?u16,
+ /// Where dumps go when `DumpDir` is unset; empty without a host
+ /// filesystem.
+ default_dump_dir: []const u8 = "",
native_images: bool,
capabilities: Capabilities,
state: *const Runtime,
@@ -895,6 +939,13 @@ pub const Runtime = struct {
else
try out.print("{s} unsupported\n", .{setting.word});
},
+ .dump_dir => {
+ const dir = if (state.dump_dir.len > 0) state.dump_dir.get() else context.default_dump_dir;
+ try out.print("{s} {s}\n", .{ setting.word, if (dir.len > 0) dir else "(none)" });
+ },
+ .message_ms => |which| try out.print("{s} {d}\n", .{ setting.word, switch (which) {
+ inline else => |w| @field(state, "message_" ++ @tagName(w) ++ "_ms"),
+ } }),
.shell => {
const chosen = state.shell.requested.get();
try out.print(
@@ -993,7 +1044,7 @@ pub const Runtime = struct {
for (settings[i + 1 ..]) |later|
try std.testing.expect(!std.mem.eql(u8, setting.word, later.word));
try std.testing.expectEqual(switch (setting.action) {
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .toggle => true,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .dump_dir, .toggle => true,
else => false,
}, setting.takesArg());
}
@@ -1152,6 +1203,8 @@ pub const Runtime = struct {
.tagline_size => "73",
.window_opacity => "85",
.window_blur => "40",
+ .message_ms => "1500",
+ .dump_dir => "~/dumps",
else => null,
};
try std.testing.expect(apply(&state, setting, argument));
@@ -1170,11 +1223,18 @@ pub const Runtime = struct {
.column_tags => "ColumnTags off\n",
.syntax_bold => "SyntaxBold on\n",
.verbose => "Verbose off\n",
+ .message_animation => "MessageAnimation off\n",
},
.shell => "Shell requested (new panes) fish\n",
.tagline_size => "TaglineSize 73\n",
.window_opacity => "WindowOpacity 85 (pending)\n",
.window_blur => "WindowBlur 40\n",
+ .dump_dir => "DumpDir ~/dumps\n",
+ .message_ms => |which| switch (which) {
+ .linger => "MessageLinger 1500\n",
+ .fall => "MessageFall 1500\n",
+ .dissolve => "MessageDissolve 1500\n",
+ },
.transition => |transition| switch (transition) {
.off => unreachable,
.slide => "Panel transition PanelSlide\n",
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 3e98fc9d..6959e577 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -536,7 +536,7 @@ pub const Session = struct {
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const s = of(ctx);
var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf) orelse return;
+ const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return;
filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err);
s.core.setLastDump(path);
}
@@ -809,8 +809,11 @@ pub const Session = struct {
// The wait is the 9P connections' turn with the core.
if (n == 0) {
pardes.turn.rest();
- defer pardes.turn.wake();
- return nap(if (timeout_ms == 0) 16 else timeout_ms);
+ nap(if (timeout_ms == 0) 16 else timeout_ms);
+ pardes.turn.wake();
+ // An animating frame's wait is the frame: tick it.
+ if (timeout_ms != 0) s.core.update(.tick);
+ return;
}
var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int)));
if (s.nextWake(now)) |due| timeout = if (timeout < 0) due else @min(timeout, due);
@@ -819,6 +822,9 @@ pub const Session = struct {
const ready = libc.poll(&fds, @intCast(n), timeout);
pardes.turn.wake();
if (ready > 0) s.dispatch(fds[0..n], src[0..n]);
+ // Animating, the frame's time is up: advance it. There is no other
+ // tick in a detached session, and without it an animation never ends.
+ if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) s.core.update(.tick);
_ = s.drainCompletions(true);
s.expire(monotonicMs());
s.harvest();
@@ -1156,7 +1162,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void
try session.core.pump(h);
if (session.core.quit) break;
if (session.core.takeRestore()) |path| restore: {
- const bytes = filesystem.readRestore(gpa, path) catch |err| {
+ const bytes = filesystem.readRestore(gpa, path, session.core.settings.dump_dir.get()) catch |err| {
session.core.reportError(session.core.active, "Restore", err);
break :restore;
};
diff --git a/src/dump.zig b/src/dump.zig
index 89abd995..ff5d2166 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -11,11 +11,11 @@ const log = std.log.scoped(.dump);
/// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share
/// /pardes/pardes-<utc>.zon — timestamped so dumps never overwrite each other.
/// Creates the pardes dir (parents assumed; a failure surfaces at open).
-pub fn outPath(buf: *[1024:0]u8) ?[:0]const u8 {
+pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 {
if (std.c.getenv("PARDES_DUMP")) |p|
return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null;
var dir_buf: [900]u8 = undefined;
- const dir = defaultDirectory(&dir_buf) orelse return null;
+ const dir = directory(&dir_buf, dir_setting) orelse return null;
var dz: [901:0]u8 = undefined;
@memcpy(dz[0..dir.len], dir);
dz[dir.len] = 0;
@@ -32,6 +32,15 @@ pub fn outPath(buf: *[1024:0]u8) ?[:0]const u8 {
ds.getSecondsIntoMinute(),
}, 0) catch null;
}
+/// Where dumps go and relative Restore names are looked up: the `DumpDir`
+/// setting when set (a leading `~/` is the home directory), else the default.
+pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 {
+ if (dir_setting.len == 0) return defaultDirectory(buf);
+ if (std.mem.startsWith(u8, dir_setting, "~/")) if (std.c.getenv("HOME")) |h|
+ return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(h), dir_setting[2..] }) catch null;
+ return std.fmt.bufPrint(buf, "{s}", .{dir_setting}) catch null;
+}
+
/// The standard dump directory, without creating it or generating a filename.
pub fn defaultDirectory(buf: []u8) ?[]const u8 {
if (std.c.getenv("XDG_DATA_HOME")) |x|
@@ -69,8 +78,14 @@ pub const PaneKind = enum {
pub const Terminal = struct {
cwd: []const u8 = "",
+ /// The rendered scrollback and screen as plain text, one row per line.
stream: []const u8 = "",
stream_b64: []const u8 = "",
+ /// `stream_b64` is the emulator's primary screen and scrollback as clean
+ /// VT (ghostty's formatter), which replays to what the screen showed.
+ /// Absent (older dumps), it is the raw tail of the pty's output, which
+ /// replays faithfully only if it holds the whole session at one size.
+ stream_is_screen: bool = false,
cursor: Cursor = .{},
};
diff --git a/src/fs.zig b/src/fs.zig
index 85eae825..84ab3b95 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1104,9 +1104,9 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
/// Restore first tries a relative argument under the default dump directory,
/// then preserves the ordinary file lookup. Absolute paths remain verbatim.
-pub fn readRestore(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
+pub fn readRestore(gpa: std.mem.Allocator, path: []const u8, dir_setting: []const u8) ![]u8 {
var dir_buf: [4096]u8 = undefined;
- const dir = @import("dump.zig").defaultDirectory(&dir_buf);
+ const dir = @import("dump.zig").directory(&dir_buf, dir_setting);
return readRestoreIn(gpa, path, dir);
}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 17bdb46f..7f28124c 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -2111,8 +2111,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
}
var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE;
if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY;
- // A latency run renders to the capture texture; keep it off the desktop.
- if (latency_fd >= 0) win_flags |= c.SDL_WINDOW_HIDDEN;
+ // A test run renders to the capture texture; keep it off the desktop.
+ if (test_mode) win_flags |= c.SDL_WINDOW_HIDDEN;
const native_wayland = !test_mode and std.mem.eql(u8, std.mem.span(c.SDL_GetCurrentVideoDriver()), "wayland");
// Background-only opacity needs per-pixel alpha; whole-window compositor
// opacity would dim the text as well. SDL requires this flag at creation.
@@ -2353,6 +2353,7 @@ fn localSession(
} else try pardes.Pardes.init(allocs.pardes, opts);
defer core.deinit();
core.native_images = true;
+ core.native_image_patches = true;
observeGuiFont(g, core);
syncTaglineFont(g, core);
@@ -2448,7 +2449,7 @@ fn localSession(
}
}
if (core.takeRestore()) |rp| blk: {
- const bytes = filesystem.readRestore(gpa, rp) catch |err| {
+ const bytes = filesystem.readRestore(gpa, rp, core.settings.dump_dir.get()) catch |err| {
core.reportError(core.active, "Restore", err);
break :blk;
};
@@ -2473,6 +2474,7 @@ fn localSession(
g.presented_images.clearRetainingCapacity();
g.prepared_images.clearRetainingCapacity();
nc.native_images = true;
+ nc.native_image_patches = true;
nc.host = host;
if (fs) |f| f.reset(nc);
core.deinit();
@@ -3591,6 +3593,7 @@ const Shell = struct {
test_mode: bool = false,
feed: StdinFeed = .{},
presented: bool = false,
+ skipped_presents: u8 = 0,
surface: ?*pardes.Surface = null,
animation_clock: AnimationClock = .{},
saw_event: bool = false,
@@ -3774,14 +3777,29 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
var in: Input = .{ .core = core };
if (s.gui) |g| {
var sev = std.mem.zeroes(c.SDL_Event);
- const ms: c_int = if (timeout_ms != 0) @intCast(timeout_ms) else 16;
+ // A latency trace is fed on stdin, whose poll below paces the loop:
+ // waiting here too would make every tick cost two frames.
+ const ms: c_int = if (latency_fd >= 0) 0 else if (timeout_ms != 0) @intCast(timeout_ms) else 16;
// The wait is the 9P connections' turn with the core.
pardes.turn.rest();
const got = c.SDL_WaitEventTimeout(&sev, ms);
pardes.turn.wake();
if (got) {
- dispatch(g, &in, &sev);
- while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev);
+ // A run of queued motions is one move to its last position (the
+ // handler reads only absolute x/y and held buttons): acting on
+ // each would repeat hover and selection work the next undoes.
+ var motion: ?c.SDL_Event = null;
+ while (true) {
+ if (sev.type == c.SDL_EVENT_MOUSE_MOTION) {
+ motion = sev;
+ } else {
+ if (motion) |*m| dispatch(g, &in, m);
+ motion = null;
+ dispatch(g, &in, &sev);
+ }
+ if (!c.SDL_PollEvent(&sev)) break;
+ }
+ if (motion) |*m| dispatch(g, &in, m);
}
}
if (s.test_mode) {
@@ -3913,6 +3931,10 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
log.err("render: {t}", .{err});
break :blk false;
};
+ // A skipped swapchain image would otherwise hide this input's frame until
+ // the next event. A few retries only: a minimized window never gets one.
+ s.skipped_presents = if (s.presented) 0 else s.skipped_presents +| 1;
+ if (!s.presented and s.skipped_presents <= 3) core.present_skipped = true;
if (g.scene_target_failed) {
g.scene_failures +|= 1;
log.err("scene target unavailable (attempt {d}/3)", .{g.scene_failures});
@@ -3926,7 +3948,12 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
fn postPresent(ctx: ?*anyopaque) void {
const s = shellOf(ctx);
const g = s.gui orelse return;
- if (!s.presented) return;
+ if (!s.presented) {
+ // No image (a minimized window): time still passes, so a lingering
+ // message or a hover wait runs out instead of redrawing forever.
+ if (s.animation_clock.due(s.core.animationActive(), c.SDL_GetTicksNS())) s.core.update(.tick);
+ return;
+ }
const frame = s.surface orelse return;
refreshPresentedPointer(g, s.core);
finishPresentedAnimationFrame(
@@ -4030,7 +4057,7 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const s = shellOf(ctx);
var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf) orelse return;
+ const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return;
filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err);
s.core.setLastDump(path);
}
@@ -4533,6 +4560,23 @@ fn uploadNativeTexture(
const byte_len = validImageBytes(place) orelse return error.BadImage;
const key = place.cacheKey();
if (g.native_images.contains(key)) return;
+ if (comptime pardes.pdf_enabled) if (place.patch) |patch| patch: {
+ // The core changed only these rows since a revision we still hold:
+ // write them into that texture rather than sending the whole page.
+ var base = key;
+ base.revision = patch.from;
+ if (patch.y + patch.h > place.ih) break :patch;
+ const texture = g.native_images.get(base) orelse break :patch;
+ const row = place.iw * 4;
+ try uploadTextureRows(g, cmd, texture, place.rgba[patch.y * row ..][0 .. patch.h * row], place.iw, patch.y, patch.h);
+ try g.native_images.put(gpa, key, texture);
+ _ = g.native_images.remove(base);
+ // A transition snapshot of the last frame follows its texture.
+ for (g.presented_images.items) |*saved| if (saved.key.eql(base)) {
+ saved.key = key;
+ };
+ return;
+ };
var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
tex_info.type = c.SDL_GPU_TEXTURETYPE_2D;
@@ -4545,39 +4589,51 @@ fn uploadNativeTexture(
tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
const texture = c.SDL_CreateGPUTexture(g.device, &tex_info) orelse return error.GpuCreate;
errdefer c.SDL_ReleaseGPUTexture(g.device, texture);
+ try uploadTextureRows(g, cmd, texture, place.rgba[0..byte_len], place.iw, 0, place.ih);
+ try g.native_images.put(gpa, key, texture);
+}
+/// Copy `rows` rows of RGBA, `width` pixels each, into `texture` at row `y`.
+fn uploadTextureRows(
+ g: *Gui,
+ cmd: *c.SDL_GPUCommandBuffer,
+ texture: *c.SDL_GPUTexture,
+ rgba: []const u8,
+ width: usize,
+ y: usize,
+ rows: usize,
+) !void {
var xf_info = c.SDL_GPUTransferBufferCreateInfo{
.usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD,
- .size = byte_len,
+ .size = @intCast(rgba.len),
.props = 0,
};
const transfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate;
defer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
const mapped: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, transfer, false) orelse return error.GpuMap);
- @memcpy(mapped[0..byte_len], place.rgba);
+ @memcpy(mapped[0..rgba.len], rgba);
c.SDL_UnmapGPUTransferBuffer(g.device, transfer);
const copy = c.SDL_BeginGPUCopyPass(cmd);
const src = c.SDL_GPUTextureTransferInfo{
.transfer_buffer = transfer,
.offset = 0,
- .pixels_per_row = @intCast(place.iw),
- .rows_per_layer = @intCast(place.ih),
+ .pixels_per_row = @intCast(width),
+ .rows_per_layer = @intCast(rows),
};
const dst = c.SDL_GPUTextureRegion{
.texture = texture,
.mip_level = 0,
.layer = 0,
.x = 0,
- .y = 0,
+ .y = @intCast(y),
.z = 0,
- .w = @intCast(place.iw),
- .h = @intCast(place.ih),
+ .w = @intCast(width),
+ .h = @intCast(rows),
.d = 1,
};
c.SDL_UploadToGPUTexture(copy, &src, &dst, false);
c.SDL_EndGPUCopyPass(copy);
- try g.native_images.put(gpa, key, texture);
}
fn appendPreparedImage(
@@ -5442,16 +5498,30 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p
const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * bw;
const background: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = layer.bg } } };
- emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = right - left, .h = bh, .x_off = left, .y_off = 0 }, win_w, win_h, track, .body, &background, old_layer, false, page);
+ // A band sliding into place is drawn offset and cut at its row's top, so
+ // it comes out from under whatever is above it.
+ const slide = layer.slide * bh;
+ const top = @as(f32, @floatFromInt(layer.viewport.y)) * bh;
+ emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, &background, old_layer, false, page);
+ if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h);
next.* += 1;
for (0..tagLayerCellCount(g, layer)) |col| {
const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false;
- emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = 0 }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page);
+ emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page);
clipInstanceRight(&instances[next.*], right, win_w);
+ if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h);
next.* += 1;
}
}
+fn clipInstanceTop(instance: *CellInstance, top: f32, win_h: f32) void {
+ const limit = 1 - top / win_h * 2;
+ if (instance.y0 <= limit) return;
+ const fraction = std.math.clamp((limit - instance.y1) / (instance.y0 - instance.y1), 0, 1);
+ instance.v0 = instance.v1 + (instance.v0 - instance.v1) * fraction;
+ instance.y0 = @max(instance.y1, limit);
+}
+
fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.BodyLayer {
return bodyLayerIn(surface.bodyLayers(), col, row);
}
@@ -6436,7 +6506,9 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t
}
fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void {
- if (g.capture_dir.len == 0) return;
+ // A latency trace times frames, and an 8 MB file write per frame would
+ // stall the loop it is timing.
+ if (g.capture_dir.len == 0 or latency_fd >= 0) return;
const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format);
if (bpp != 4) return error.UnsupportedCaptureFormat;
const bgr = switch (g.swapchain_format) {
diff --git a/src/macos.zig b/src/macos.zig
index a7ee3164..c359eb3f 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -1325,7 +1325,7 @@ export fn pardes_tick() bool {
fn restoreCore(st: *State) bool {
if (st.core.quit) return false;
const path = st.core.takeRestore() orelse return false;
- const bytes = filesystem.readRestore(st.gpa, path) catch |err| {
+ const bytes = filesystem.readRestore(st.gpa, path, st.core.settings.dump_dir.get()) catch |err| {
st.core.reportError(st.core.active, "Restore", err);
return false;
};
@@ -2431,7 +2431,7 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const st = hostState(ctx);
var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf) orelse return;
+ const path = pardes.dump.outPath(&pbuf, st.core.settings.dump_dir.get()) orelse return;
filesystem.write(st.core, path, bytes) catch |err| return st.core.reportError(0, "dump", err);
st.core.setLastDump(path);
}
diff --git a/src/panes.zig b/src/panes.zig
index b3424385..8d4330e9 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -183,6 +183,20 @@ pub const Pane = struct {
tty_filter: bool = false,
msg: [256]u8 = undefined,
msg_len: u16 = 0,
+ /// Where the newest message line is in its life.
+ msg_life: MessageLife = .{},
+ /// The row (among the notice rows) the newest line holds for its life.
+ msg_slot: u8 = 0,
+ /// Earlier message lines still on screen, oldest first, each running its
+ /// own life: a new message stacks under them instead of replacing them.
+ msg_older: [message_stack]OlderMessage = undefined,
+ msg_older_len: u8 = 0,
+ /// The newest line is a builtin announcing itself in this step: what
+ /// the step says next replaces it rather than stacking under it.
+ msg_announcement: bool = false,
+ /// The newest line is a status (progress, a server's state): the next
+ /// line replaces it rather than stacking under it.
+ msg_status: bool = false,
/// The transient lines this pane shows between its body and its tagline:
/// a builtin's message, the pending leader chord, a prompt waiting for
/// input. Rebuilt every frame from the state that owns each one, so a
@@ -192,30 +206,102 @@ pub const Pane = struct {
/// reserves rows for sticky context headers.
notices: Notices = .{},
+ pub const MessagePhase = enum { entering, shown, lingering, leaving };
+ /// One message line's animation: falling in, shown, lingering after the
+ /// key or click that dismissed it, dissolving. `frame` counts animation
+ /// frames within the phase.
+ pub const MessageLife = struct {
+ phase: MessagePhase = .shown,
+ frame: u16 = 0,
+ /// Dismissed while still falling: it lingers once it has landed.
+ dismissed: bool = false,
+ };
+ /// Older message lines kept on screen under a new one.
+ pub const message_stack = 3;
+ pub const OlderMessage = struct {
+ text: [256]u8 = undefined,
+ len: u16 = 0,
+ life: MessageLife = .{},
+ slot: u8 = 0,
+
+ pub fn slice(line: *const OlderMessage) []const u8 {
+ return line.text[0..line.len];
+ }
+ };
+
+ /// Move the newest line onto the stack of older ones, dropping the oldest
+ /// when it is full; the newest slot is left empty for what arrives.
+ pub fn pushOlderMessage(pane: *Pane) void {
+ if (pane.msg_older_len == message_stack) {
+ std.mem.copyForwards(OlderMessage, pane.msg_older[0 .. message_stack - 1], pane.msg_older[1..]);
+ pane.msg_older_len -= 1;
+ }
+ pane.msg_older[pane.msg_older_len] = .{ .text = pane.msg, .len = pane.msg_len, .life = pane.msg_life, .slot = pane.msg_slot };
+ pane.msg_older_len += 1;
+ pane.msg_len = 0;
+ pane.msg_life = .{};
+ }
+
+ /// The first row no older line holds. A line keeps its row until it
+ /// leaves, and a new one fills the first gap: nothing on screen moves.
+ pub fn freeMessageSlot(pane: *const Pane) u8 {
+ var used: u8 = 0;
+ for (pane.msg_older[0..pane.msg_older_len]) |line| used |= @as(u8, 1) << @intCast(line.slot);
+ var slot: u8 = 0;
+ while (used & (@as(u8, 1) << @intCast(slot)) != 0) slot += 1;
+ return slot;
+ }
+
+ pub fn removeOlderMessage(pane: *Pane, i: usize) void {
+ std.mem.copyForwards(OlderMessage, pane.msg_older[i .. pane.msg_older_len - 1], pane.msg_older[i + 1 .. pane.msg_older_len]);
+ pane.msg_older_len -= 1;
+ }
+
pub const Notices = struct {
/// Painted bottom-up in this order, so a prompt keeps the row nearest
/// the tagline that it has always had, and its cursor with it.
- pub const Kind = enum { message, leader, prompt };
- pub const max = @typeInfo(Kind).@"enum".fields.len;
+ /// `older` is an earlier message line still on screen; `index` says
+ /// which (`Pane.msg_older`).
+ pub const Kind = enum { older, message, leader, prompt };
+ pub const max = @typeInfo(Kind).@"enum".fields.len - 1 + message_stack;
kinds: [max]Kind = undefined,
+ index: [max]u8 = @splat(0),
+ /// Each band's row below the body's top: message lines keep theirs,
+ /// so a band that leaves leaves a gap rather than moving the rest.
+ row: [max]u8 = @splat(0),
/// Where each band's chip starts, as an absolute grid column. A chip
/// is only as wide as its own text, so the rest of the row underneath
/// is ordinary body text and a click there has to reach it.
left: [max]u16 = @splat(std.math.maxInt(u16)),
len: u8 = 0,
+ /// A band on the next row after every band so far.
pub fn push(n: *Notices, kind: Kind) void {
+ n.pushAt(kind, 0, n.span());
+ }
+
+ pub fn pushAt(n: *Notices, kind: Kind, index: u8, row: u8) void {
if (n.len >= max) return;
n.kinds[n.len] = kind;
+ n.index[n.len] = index;
+ n.row[n.len] = row;
n.left[n.len] = std.math.maxInt(u16);
n.len += 1;
}
/// Is this cell inside the chip drawn for band `i`? Nothing else on
/// that row belongs to the notice.
- pub fn covers(n: *const Notices, i: usize, col: u16) bool {
- return i < n.len and col >= n.left[i];
+ pub fn covers(n: *const Notices, row: usize, col: u16) bool {
+ for (0..n.len) |i| if (n.row[i] == row) return col >= n.left[i];
+ return false;
+ }
+
+ /// Rows from the body's top down to the last band's.
+ pub fn span(n: *const Notices) u8 {
+ var rows: u8 = 0;
+ for (n.row[0..n.len]) |row| rows = @max(rows, row + 1);
+ return rows;
}
pub fn slice(n: *const Notices) []const Kind {
@@ -1327,7 +1413,15 @@ pub const File = struct {
const file = &pane.file.?;
for ([_][]const u8{ "", "one", "one\n", "a\r\nλ界\nlast", "\n\n", "\tλ e\u{301}\n\r\n" }) |content| {
setContent(p, file, try p.gpa.dupe(u8, content));
- try std.testing.expectEqual(@as(usize, 0), file.line_starts.len);
+ // An edit carries a built index forward; it must equal a fresh one.
+ if (file.line_starts.len > 0) {
+ const carried = file.line_starts;
+ defer p.gpa.free(carried);
+ file.line_starts = &.{};
+ try std.testing.expectEqualSlices(usize, try lineIndex(p.gpa, file), carried);
+ p.gpa.free(file.line_starts);
+ file.line_starts = &.{};
+ }
const rows = lineCount(content) + 2;
for ([_]bool{ false, true }) |indexed| {
if (indexed) _ = try lineIndex(p.gpa, file);
@@ -1546,6 +1640,80 @@ pub const File = struct {
return starts;
}
+ /// The line index of `new` from `old`'s: an edit replaces one span, so
+ /// line starts before it stand, starts after it shift, and only the span
+ /// is scanned. Rebuilding scanned the whole file on every keystroke.
+ fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8) ![]usize {
+ const n = @min(old.len, new.len);
+ var head: usize = 0;
+ while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64;
+ while (head < n and old[head] == new[head]) head += 1;
+ var tail: usize = 0;
+ while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64;
+ while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1;
+ // A start s follows the newline at s-1: kept while that newline is in
+ // the common head, shifted while it is in the common tail.
+ const kept = std.sort.upperBound(usize, starts, head, struct {
+ fn order(key: usize, item: usize) std.math.Order {
+ return std.math.order(key, item);
+ }
+ }.order);
+ const old_tail_at = old.len - tail;
+ const moved = starts.len - std.sort.upperBound(usize, starts, old_tail_at, struct {
+ fn order(key: usize, item: usize) std.math.Order {
+ return std.math.order(key, item);
+ }
+ }.order);
+ const middle = new[head .. new.len - tail];
+ const out = try gpa.alloc(usize, kept + std.mem.count(u8, middle, "\n") + moved);
+ @memcpy(out[0..kept], starts[0..kept]);
+ var i = kept;
+ var off: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, middle, off, '\n')) |nl| {
+ off = nl + 1;
+ out[i] = head + off;
+ i += 1;
+ }
+ for (starts[starts.len - moved ..]) |start| {
+ out[i] = start + new.len - old.len;
+ i += 1;
+ }
+ return out;
+ }
+
+ test "edited line index equals a rebuilt one" {
+ const gpa = std.testing.allocator;
+ var prng = std.Random.DefaultPrng.init(7);
+ const r = prng.random();
+ var text: std.ArrayList(u8) = .empty;
+ defer text.deinit(gpa);
+ for (0..300) |_| try text.append(gpa, "ab\n"[r.uintLessThan(usize, 3)]);
+ for (0..2000) |_| {
+ var old_state: State = undefined;
+ old_state.content = text.items;
+ old_state.line_starts = &.{};
+ const starts = try lineIndex(gpa, &old_state);
+ defer gpa.free(starts);
+ // one random replace, like any edit through setContent
+ const at = r.uintAtMost(usize, text.items.len);
+ const del = r.uintAtMost(usize, @min(8, text.items.len - at));
+ var ins: [8]u8 = undefined;
+ const ins_len = r.uintAtMost(usize, 8);
+ for (ins[0..ins_len]) |*c| c.* = "ab\n"[r.uintLessThan(usize, 3)];
+ const old = try gpa.dupe(u8, text.items);
+ defer gpa.free(old);
+ try text.replaceRange(gpa, at, del, ins[0..ins_len]);
+ const edited = try editedLineStarts(gpa, starts, old, text.items);
+ defer gpa.free(edited);
+ var fresh_state: State = undefined;
+ fresh_state.content = text.items;
+ fresh_state.line_starts = &.{};
+ const fresh = try lineIndex(gpa, &fresh_state);
+ defer gpa.free(fresh);
+ try std.testing.expectEqualSlices(usize, fresh, edited);
+ }
+ }
+
/// line count, O(1) once the index is warm
pub fn nlines(gpa: std.mem.Allocator, f: *State) usize {
const idx = lineIndex(gpa, f) catch return lineCount(f.content);
@@ -1760,12 +1928,13 @@ pub const File = struct {
reportEdit(p, f, new);
if (f.mini) |*mini| mini.deinit(p.gpa);
f.mini = null;
+ const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{};
p.gpa.free(f.content);
f.content = new;
f.revision +%= 1;
f.mtime = pardes.ctlfs.events.now();
if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
- f.line_starts = &.{};
+ f.line_starts = starts;
if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
f.highlights = &.{};
f.highlight_start = 0;
@@ -2864,7 +3033,10 @@ pub const Output = struct {
pub fn openConfig(p: *Pardes, id: usize) !void {
var out: std.Io.Writer.Allocating = .init(p.gpa);
errdefer out.deinit();
+ var dir_buf: [1024]u8 = undefined;
+ const host_fs = comptime (pardes.platform != .web and pardes.platform != .esp32p4);
try config.Runtime.writeReport(&out.writer, .{
+ .default_dump_dir = if (host_fs) (pardes.dump.defaultDirectory(&dir_buf) orelse "") else "",
.startup_config_path = p.opts.startup_config_path,
.platform = @tagName(pardes.platform),
.theme_name = p.theme().name,
@@ -3599,6 +3771,9 @@ pub const Pdf = struct {
const Page = struct { page: usize, items: []const Highlight };
pages: [3]Page = undefined,
len: usize = 0,
+ /// Repaint whenever the set changes, not only when a page's raster was
+ /// invalidated (`HighlightInput.live`).
+ live: bool = false,
pub fn forPage(highlights: @This(), page: usize) []const Highlight {
for (highlights.pages[0..highlights.len]) |group|
@@ -3612,6 +3787,7 @@ pub const Pdf = struct {
hover_page: ?usize = null,
hover_color: [3]u8,
selection_color: [3]u8,
+ live: bool = false,
} else void;
pub fn buildHighlights(
@@ -3623,7 +3799,7 @@ pub const Pdf = struct {
const search_len = if (state.search_results) |results| results.quads.len else 0;
const selection_len = if (state.selection) |selection| selection.quads.len else 0;
const items = try arena.alloc(Highlight, input.hover_quads.len + search_len + selection_len);
- var result: Highlights = .{};
+ var result: Highlights = .{ .live = input.live };
var n: usize = 0;
// Hover, search and selection can belong to three different pages.
// Group them once so raster rendering can borrow a contiguous slice.
@@ -3721,6 +3897,19 @@ pub const Pdf = struct {
decorated: bool = false,
tint_key: ?TintKey = null,
revision: u32 = 0,
+ /// `rgba` before its highlights and tint, kept once the page has been
+ /// highlighted: a highlight change repaints rows from it instead of
+ /// rendering the page again.
+ clean: []u8 = &.{},
+ /// The highlights baked into `rgba` (gpa-owned), so a change repaints
+ /// only the rows of the quads that differ.
+ baked: []Highlight = &.{},
+ /// `baked` is a true record (false after a render could not copy it).
+ baked_valid: bool = false,
+ /// Page rows changed since revision `patch_from`, the one last handed
+ /// to a shell; 0 when the pixels were replaced wholesale since.
+ patch_from: u32 = 0,
+ patch_rows: pdf.Document.Rows = .{ .start = 0, .end = 0 },
} else void;
pub const SectionsOutput = if (enabled) struct {
@@ -3894,6 +4083,8 @@ pub const Pdf = struct {
}
fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void {
+ if (raster.clean.len > 0) gpa.free(raster.clean);
+ gpa.free(raster.baked);
state.retireRgba(gpa, raster.rgba);
raster.* = .{};
}
@@ -4138,8 +4329,11 @@ pub const Pdf = struct {
pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void {
gpa.free(state.path);
- for (state.rasters[0..state.rasters_len]) |raster|
+ for (state.rasters[0..state.rasters_len]) |raster| {
if (raster.rgba.len > 0) gpa.free(raster.rgba);
+ if (raster.clean.len > 0) gpa.free(raster.clean);
+ gpa.free(raster.baked);
+ }
for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba);
gpa.free(state.page_sizes);
gpa.free(state.page_starts);
@@ -4614,16 +4808,27 @@ pub const Pdf = struct {
const slot = raster.?;
const page_highlights = highlights.forPage(page);
const decorated = page_highlights.len > 0;
- const stale = !slot.tried or !slot.request_valid or
- !slot.request.eql(request) or slot.decorated != decorated or
+ const base_stale = !slot.request_valid or !slot.request.eql(request) or
slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or
slot.rgba.len == 0 or slot.band_h == 0;
- const uncovered = !stale and uncovered: {
+ const uncovered = !base_stale and uncovered: {
const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging);
break :uncovered slot.band_y > want.y or
slot.band_y + slot.band_h < want.y + want.height;
};
- if (!stale and !uncovered) continue;
+ if (!base_stale and !uncovered) {
+ if (slot.tried and slot.decorated == decorated and
+ !(highlights.live and !sameHighlights(slot.baked, page_highlights))) continue;
+ // Invalidated, but it already shows exactly these highlights
+ // (a preview withdrawn that was never drawn, on every pointer
+ // move over a page): nothing to render.
+ if (slot.baked_valid and sameHighlights(slot.baked, page_highlights)) {
+ slot.tried = true;
+ continue;
+ }
+ // Only the highlights moved: repaint their rows in place.
+ if (repaintHighlights(state, gpa, slot, request, tint_key, page_highlights)) continue;
+ }
slot.tried = true;
slot.request = request;
@@ -4640,15 +4845,27 @@ pub const Pdf = struct {
{
const tz_render = tracy.zone(@src(), "pdf.render_into");
defer tz_render.end();
+ // A highlighted page keeps its clean rows for repaints.
+ const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0);
state.document.renderIntoAtWithPaper(
page,
request,
shape,
want,
- page_highlights,
+ if (keep_clean) &.{} else page_highlights,
fresh,
pardes.platform == .macos,
) catch break :filled false;
+ if (slot.clean.len != 0 and (!keep_clean or slot.clean.len != fresh.len)) {
+ gpa.free(slot.clean);
+ slot.clean = &.{};
+ }
+ if (keep_clean) {
+ if (slot.clean.len == 0) slot.clean = gpa.alloc(u8, fresh.len) catch &.{};
+ if (slot.clean.len == fresh.len) @memcpy(slot.clean, fresh);
+ state.document.paintHighlightsAt(page, request, shape, want, page_highlights, fresh) catch
+ break :filled false;
+ }
}
const tz_tint = tracy.zone(@src(), "pdf.tint");
defer tz_tint.end();
@@ -4658,6 +4875,9 @@ pub const Pdf = struct {
};
if (!filled) {
state.retireRgba(gpa, fresh);
+ // `clean` may already hold the band that failed.
+ if (slot.clean.len > 0) gpa.free(slot.clean);
+ slot.clean = &.{};
continue;
}
@@ -4669,6 +4889,18 @@ pub const Pdf = struct {
slot.band_h = want.height;
slot.decorated = decorated;
slot.tint_key = tint_key;
+ if (gpa.dupe(Highlight, page_highlights)) |copy| {
+ gpa.free(slot.baked);
+ slot.baked = copy;
+ slot.baked_valid = true;
+ } else |_| {
+ // Unrecorded highlights cannot be compared or diffed.
+ slot.baked_valid = false;
+ if (slot.clean.len > 0) gpa.free(slot.clean);
+ slot.clean = &.{};
+ }
+ slot.patch_from = 0;
+ slot.patch_rows = .{ .start = 0, .end = 0 };
state.next_raster_revision +%= 1;
if (state.next_raster_revision == 0) state.next_raster_revision = 1;
slot.revision = state.next_raster_revision;
@@ -4676,6 +4908,114 @@ pub const Pdf = struct {
state.trimSpares(gpa);
}
+ /// Repaint only the rows where the old and new highlights differ: the
+ /// clean rows kept from the page's last render, MuPDF's highlight pass
+ /// (the whole new set) over them, the per-pixel tint — the operations a
+ /// full render performs, in its order, on fewer rows and without running
+ /// the page again. False leaves the slot for the full path.
+ fn repaintHighlights(
+ state: *State,
+ gpa: std.mem.Allocator,
+ slot: *Raster,
+ request: RenderRequest,
+ tint_key: TintKey,
+ page_highlights: []const Highlight,
+ ) bool {
+ const tz = tracy.zone(@src(), "pdf.repaint_highlights");
+ defer tz.end();
+ if (slot.clean.len != slot.rgba.len or !slot.baked_valid) return false;
+ if (sameHighlights(slot.baked, page_highlights)) {
+ slot.tried = true;
+ return true;
+ }
+ const baked = gpa.dupe(Highlight, page_highlights) catch return false;
+ const changed = changedHighlights(gpa, slot.baked, page_highlights) catch {
+ gpa.free(baked);
+ return false;
+ };
+ defer gpa.free(changed);
+ var dirty = state.document.highlightRows(slot.page, request, changed) catch {
+ gpa.free(baked);
+ return false;
+ };
+ slot.tried = true;
+ dirty.start = @max(dirty.start, slot.band_y);
+ dirty.end = @min(dirty.end, slot.band_y + slot.band_h);
+ if (dirty.start < dirty.end) {
+ const shape = slotShape(slot);
+ const band = shape.band(dirty.start, dirty.end - dirty.start);
+ const offset = (dirty.start - slot.band_y) * shape.stride;
+ const out = slot.rgba[offset..][0..band.len];
+ @memcpy(out, slot.clean[offset..][0..band.len]);
+ // On failure the rows are half-made: let the full path redo them.
+ state.document.paintHighlightsAt(slot.page, request, shape, band, page_highlights, out) catch {
+ gpa.free(baked);
+ slot.tried = false;
+ return false;
+ };
+ pdf.tintRgba(out, tint_key.mode, tint_key.colors) catch {
+ gpa.free(baked);
+ slot.tried = false;
+ return false;
+ };
+ slot.patch_rows = unionRows(slot.patch_rows, dirty);
+ state.next_raster_revision +%= 1;
+ if (state.next_raster_revision == 0) state.next_raster_revision = 1;
+ slot.revision = state.next_raster_revision;
+ }
+ gpa.free(slot.baked);
+ slot.baked = baked;
+ slot.decorated = page_highlights.len > 0;
+ return true;
+ }
+
+ fn sameHighlights(a: []const Highlight, b: []const Highlight) bool {
+ return std.mem.eql(u8, std.mem.sliceAsBytes(a), std.mem.sliceAsBytes(b));
+ }
+
+ /// The highlights in one set and not the other, as multisets (a quad
+ /// painted twice blends twice). When only the order changed, blending
+ /// order did, so everything counts.
+ // ponytail: quadratic match; past 64k comparisons every quad counts.
+ fn changedHighlights(gpa: std.mem.Allocator, old: []const Highlight, new: []const Highlight) ![]Highlight {
+ const out = try gpa.alloc(Highlight, old.len + new.len);
+ errdefer gpa.free(out);
+ var n: usize = 0;
+ if (old.len * new.len <= 1 << 16) {
+ const matched = try gpa.alloc(bool, new.len);
+ defer gpa.free(matched);
+ @memset(matched, false);
+ for (old) |h| {
+ for (new, matched) |candidate, *used| {
+ if (!used.* and std.mem.eql(u8, std.mem.asBytes(&h), std.mem.asBytes(&candidate))) {
+ used.* = true;
+ break;
+ }
+ } else {
+ out[n] = h;
+ n += 1;
+ }
+ }
+ for (new, matched) |h, used| if (!used) {
+ out[n] = h;
+ n += 1;
+ };
+ }
+ if (n == 0) {
+ @memcpy(out[0..old.len], old);
+ @memcpy(out[old.len..], new);
+ return out;
+ }
+ // Freed by length: hand back exactly what the caller will free.
+ return gpa.realloc(out, n);
+ }
+
+ fn unionRows(a: pdf.Document.Rows, b: pdf.Document.Rows) pdf.Document.Rows {
+ if (a.start >= a.end) return b;
+ if (b.start >= b.end) return a;
+ return .{ .start = @min(a.start, b.start), .end = @max(a.end, b.end) };
+ }
+
pub fn renderFrame(
state: *State,
gpa: std.mem.Allocator,
@@ -5837,6 +6177,7 @@ pub const Pdf = struct {
.hover_page = hover_page,
.hover_color = if (link_hover) core.theme().box else selection_color,
.selection_color = selection_color,
+ .live = core.native_image_patches,
};
}
@@ -5907,6 +6248,16 @@ pub const Pdf = struct {
const visible_end = visible.first + visible.len;
while (page < visible_end) : (page += 1) {
const placed = placedRaster(state, view, page) orelse continue;
+ const slot = rasterForPage(state, page).?;
+ const patch: ?pardes.ImagePatch = if (slot.patch_from != 0 and
+ slot.patch_rows.start < slot.patch_rows.end)
+ .{
+ .from = slot.patch_from,
+ .y = @intCast(slot.patch_rows.start - slot.band_y),
+ .h = @intCast(slot.patch_rows.end - slot.patch_rows.start),
+ }
+ else
+ null;
if (!core.appendImagePlace(.{
.pane = @intCast(pane_id),
.serial = pane.serial,
@@ -5928,14 +6279,18 @@ pub const Pdf = struct {
.x = text_x,
// Below the notice chips, like an image: a placed page is
// drawn after the cells and would paint a chip out.
- .y = core.bodyTop(rect) + pane.notices.len,
+ .y = core.bodyTop(rect) + pane.notices.span(),
.w = text_width,
- .h = (rect.h -| pardes.BOX_H) -| pane.notices.len,
+ .h = (rect.h -| pardes.BOX_H) -| pane.notices.span(),
.rgba = placed.rgba,
.iw = placed.width,
// The texture contains the retained band, not the full page.
.ih = placed.band_height,
+ .patch = patch,
})) break;
+ // A shell now holds (or will fetch whole) this revision.
+ slot.patch_from = slot.revision;
+ slot.patch_rows = .{ .start = 0, .end = 0 };
placed_any = true;
}
if (!placed_any) return false;
@@ -6439,9 +6794,15 @@ pub const Terminal = struct {
return pane;
}
- /// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy
- /// stay with the core; raw VT replay and viewport restoration belong here.
- pub fn restore(p: *Pardes, src: dump.Pane) !*Pane {
+ /// Where a restored terminal's history ends and its new shell begins.
+ pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n";
+
+ /// Rebuild a dump's terminal emulator from its recorded output. `live`: a
+ /// shell will be spawned into it, so the history is marked off from what
+ /// that shell prints and the pane waits for it like any new terminal.
+ /// Registration and tag/cwd policy stay with the core; raw VT replay and
+ /// viewport restoration belong here.
+ pub fn restore(p: *Pardes, src: dump.Pane, live: bool) !*Pane {
const terminal = src.terminal.?;
if (comptime !enabled) {
const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
@@ -6450,12 +6811,28 @@ pub const Terminal = struct {
pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) };
return pane;
}
- const bytes = if (terminal.stream_b64.len > 0)
+ // An older dump's raw output tail cannot come back live: cut off mid
+ // redraw, or written at sizes the pane no longer has, it replays as
+ // garbage. Its rendered text can.
+ const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0;
+ const bytes = if (legacy_text)
+ try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n")
+ else if (terminal.stream_b64.len > 0)
try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64)
else
&.{};
const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
- if (bytes.len > 0) {
+ if (live) {
+ if (bytes.len > 0) {
+ ingest(pane, bytes);
+ ingest(pane, restored_banner);
+ }
+ followOutput(pane);
+ // A view left scrolled back stays on what it was showing: the
+ // banner's line breaks scrolled a full screen by that many more.
+ if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n"))));
+ armShellSpawn(pane);
+ } else if (bytes.len > 0) {
ingest(pane, bytes);
followOutput(pane);
if (src.scroll > 0)
@@ -6613,7 +6990,7 @@ pub const Terminal = struct {
.cols = pane.cols,
.rows = pane.rows,
.terminal = .{ .stream_b64 = encoded },
- });
+ }, false);
defer gpa.destroy(restored);
defer deinitEmulator(restored, gpa);
const before = try screenTextAlloc(pane, gpa);
@@ -6624,6 +7001,58 @@ pub const Terminal = struct {
try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa));
}
+ test "a restored terminal keeps its history above a marker and gets a shell where it was" {
+ if (comptime !enabled or pardes.isolated) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ feedOutput(p, pane, "old output\r\n$ ls\r\n");
+ try pane.setOwnedCwd("/tmp");
+ try p.dumpState();
+ const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?);
+ defer restored.deinit();
+ var spawned = false;
+ while (restored.nextEffect()) |effect| switch (effect) {
+ .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"),
+ else => {},
+ };
+ try std.testing.expect(spawned);
+ const again = restored.panes[0].?;
+ try std.testing.expectEqual(.spawn, again.pending_command.wait);
+ const text = try screenTextAlloc(again, gpa);
+ defer gpa.free(text);
+ const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost;
+ const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker;
+ try std.testing.expect(old_at < mark_at);
+ }
+
+ test "an older dump comes back live from its rendered text, not its raw output tail" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ // A tail cut mid-redraw: relative moves from a cursor it never set.
+ const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore");
+ defer gpa.free(tail);
+ const restored = try restore(p, .{
+ .kind = .terminal,
+ .tag = "",
+ .body = "",
+ .cols = 40,
+ .rows = 12,
+ .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail },
+ }, true);
+ defer gpa.destroy(restored);
+ defer deinitEmulator(restored, gpa);
+ defer deinitPendingCommand(restored);
+ const text = try screenTextAlloc(restored, gpa);
+ defer gpa.free(text);
+ try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null);
+ try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null);
+ }
+
/// Record and parse one live pty read, invalidate its motion surface, and
/// follow it only when the body (possibly parked under a tag edit) is raw.
pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
@@ -7247,6 +7676,16 @@ pub const Terminal = struct {
overlay.text = new;
}
+ /// The shell's screen and scrollback (the primary screen, even under a
+ /// full-screen program) as VT that replays to what was shown: styled
+ /// text, soft-wrapped lines rejoined so they reflow at another width.
+ fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 {
+ const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{};
+ var out: std.Io.Writer.Allocating = .init(arena);
+ try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer);
+ return out.written();
+ }
+
/// Serialize terminal-only state; the core supplies shared pane metadata.
pub fn dumpPane(
pane: *Pane,
@@ -7318,7 +7757,8 @@ pub const Terminal = struct {
.terminal = .{
.cwd = try arena.dupe(u8, pane.cwdSlice()),
.stream = stream[0..len],
- .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)),
+ .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)),
+ .stream_is_screen = true,
.cursor = .{
.col = pane.terminal.?.vt.screens.active.cursor.x,
.row = pane.terminal.?.vt.screens.active.cursor.y,
diff --git a/src/pardes.zig b/src/pardes.zig
index cc57127f..fae14f47 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -2722,8 +2722,16 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it
try std.testing.expect(p.executeBuiltinLine(2, "Msg"));
try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2));
- // A message is exactly as old as your last input.
+ // Input starts its exit: it lingers, dissolves, and is gone.
p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2));
+ for (0..200) |_| p.update(.tick);
+ try std.testing.expectEqualStrings("", row(p, 2));
+ // ...and with neither linger nor animation it is as old as your last input.
+ try std.testing.expect(p.executeBuiltinLine(2, "MessageAnimation off"));
+ try std.testing.expect(p.executeBuiltinLine(2, "MessageLinger 0"));
+ try std.testing.expect(p.executeBuiltinLine(2, "Msg gone"));
+ p.update(.{ .key = .{ .cp = 'h' } });
try std.testing.expectEqualStrings("", row(p, 2));
// Longer than the row's buffer is CUT, never refused and never overrun:
@@ -2735,6 +2743,151 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it
try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len);
}
+test "a message falls in, lingers past the input that dismisses it, and dissolves" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[p.active].?;
+ try std.testing.expect(p.settings.message_animation);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ try std.testing.expect(p.animationActive());
+ // An ease-in fall: starts a whole row up, lands exactly on it.
+ const start = p.messageMotion(pane.msg_life);
+ try std.testing.expectEqual(@as(f32, -1), start.slide);
+ var last = start.slide;
+ for (0..Pardes.messageFrames(p.settings.message_fall_ms)) |_| {
+ p.needs_frame = false;
+ p.update(.tick);
+ // Every tick that moves it owes a frame, the landing one included.
+ try std.testing.expect(p.needs_frame);
+ const now = p.messageMotion(pane.msg_life);
+ try std.testing.expect(now.slide >= last);
+ last = now.slide;
+ }
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ try std.testing.expectEqual(@as(f32, 0), p.messageMotion(pane.msg_life).slide);
+ try std.testing.expect(!p.animationActive());
+ // Shown until input, like always; the same line again does not re-enter.
+ for (0..100) |_| p.update(.tick);
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ // Input (a key that announces nothing): 160 ms of lingering (ten frames), unaffected by more input...
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ for (0..9) |_| {
+ p.update(.tick);
+ p.update(.{ .key = .{ .cp = 'h' } });
+ }
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ p.update(.tick);
+ // ...then an ease-out dissolve toward nothing, and gone.
+ try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
+ var alpha: f32 = 1;
+ for (0..Pardes.messageFrames(p.settings.message_dissolve_ms)) |_| {
+ const now = p.messageMotion(pane.msg_life).alpha;
+ try std.testing.expect(now <= alpha);
+ alpha = now;
+ p.needs_frame = false;
+ p.update(.tick);
+ }
+ try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
+ try std.testing.expect(!p.animationActive());
+ // The tick that clears it still owes the frame that shows it gone.
+ try std.testing.expect(p.needs_frame);
+
+ // Dismissed mid-fall: it lands, then lingers.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ p.update(.tick);
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ for (0..Pardes.messageFrames(p.settings.message_fall_ms)) |_| p.update(.tick);
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ // Shown again while lingering: fresh, so it stays; a status update to a
+ // line on screen swaps its text in place instead of falling again.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ p.setStatus(p.active, "progress 50%");
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ try std.testing.expectEqualStrings("progress 50%", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
+ // The fall and the dissolve are timings of their own, in milliseconds.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageFall 48"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageDissolve 0"));
+ try std.testing.expectEqual(@as(u16, 3), Pardes.messageFrames(p.settings.message_fall_ms));
+ try std.testing.expectEqual(@as(u16, 1), Pardes.messageFrames(p.settings.message_dissolve_ms));
+ // The Config report reads back as configuration.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageAnimation"));
+ try std.testing.expect(!p.settings.message_animation);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg plain"));
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+}
+
+test "messages stack, oldest on top, each on its own timing" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[p.active].?;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
+ // `Verbose off` announced itself before it took effect.
+ pane.msg_len = 0;
+ pane.msg_life = .{};
+ const fall = Pardes.messageFrames(p.settings.message_fall_ms);
+ p.setMessage(p.active, "first");
+ for (0..fall) |_| p.update(.tick);
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ // A second message stacks under the first instead of replacing it.
+ p.setMessage(p.active, "second");
+ try std.testing.expectEqual(@as(u8, 1), pane.msg_older_len);
+ try std.testing.expectEqualStrings("first", pane.msg_older[0].slice());
+ try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ p.collectNotices(pane, p.rects[p.active], p.active);
+ try std.testing.expectEqual(Pane.Notices.Kind.older, pane.notices.kinds[0]);
+ try std.testing.expectEqual(Pane.Notices.Kind.message, pane.notices.kinds[1]);
+ // One key dismisses both, but each runs its own clock: the first starts
+ // lingering now, the second only once it has landed.
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_older[0].life.phase);
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ const linger = Pardes.messageFrames(p.settings.message_linger_ms);
+ const dissolve = Pardes.messageFrames(p.settings.message_dissolve_ms);
+ for (0..linger + dissolve) |_| p.update(.tick);
+ // The first is gone; the second, a fall later, is still dissolving.
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
+ try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
+ for (0..fall) |_| p.update(.tick);
+ try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
+ try std.testing.expect(!p.animationActive());
+ // A status ticking in place does not pile up.
+ p.setStatus(p.active, "lsp 10%");
+ p.setStatus(p.active, "lsp 20%");
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
+ // Lines keep their rows: when the top one leaves, the one under it does
+ // not move up, and the next message fills the gap instead.
+ p.setMessage(p.active, "top");
+ p.setMessage(p.active, "under");
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_older[0].slot);
+ try std.testing.expectEqual(@as(u8, 1), pane.msg_slot);
+ pane.removeOlderMessage(0);
+ p.collectNotices(pane, p.rects[p.active], p.active);
+ try std.testing.expectEqual(@as(u8, 1), pane.notices.row[0]);
+ try std.testing.expectEqual(@as(u8, 2), pane.notices.span());
+ p.setMessage(p.active, "fills");
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_slot);
+ try std.testing.expectEqual(@as(u8, 1), pane.msg_older[0].slot);
+ // The stack keeps the newest few.
+ for ([_][]const u8{ "a", "b", "c", "d", "e" }) |text| p.setMessage(p.active, text);
+ try std.testing.expectEqual(@as(u8, Pane.message_stack), pane.msg_older_len);
+ try std.testing.expectEqualStrings("b", pane.msg_older[0].slice());
+ try std.testing.expectEqualStrings("e", pane.msg[0..pane.msg_len]);
+}
+
test "Font keeps requested and acknowledged faces as separate plain state" {
if (comptime !font_picker) return; // there is no Font builtin on tty or web
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
@@ -3774,6 +3927,8 @@ pub const NativePlacement = if (pdf_enabled) struct {
paper_bg: ?[3]u8 = null,
} else struct {};
+pub const ImagePatch = struct { from: u32, y: u32, h: u32 };
+
pub const ImagePlace = struct {
pane: u8,
/// Pane slots are reused. This identity makes a cached GPU texture or
@@ -3787,6 +3942,10 @@ pub const ImagePlace = struct {
rgba: []const u8,
iw: usize,
ih: usize,
+ /// Rows [y, y+h) of `rgba` are the only ones that differ from the
+ /// same pane page at revision `from`: a shell still holding that one may
+ /// update just these rows instead of taking the whole image again.
+ patch: ?ImagePatch = null,
pub fn cacheKey(place: ImagePlace) ImageCacheKey {
if (comptime pdf_enabled)
@@ -6194,6 +6353,10 @@ pub const Pardes = struct {
chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome),
animate_theme_changes: bool = false,
native_images: bool = false,
+ /// The shell can take a changed image a few rows at a time (a texture row
+ /// upload, a shared-memory hand-off), so a PDF selection is repainted as
+ /// it is dragged instead of once on release.
+ native_image_patches: bool = false,
quit: bool = false,
haptic: HapticSlot = if (platform == .macos) .none else {},
drag: Drag = .none,
@@ -6250,6 +6413,9 @@ pub const Pardes = struct {
/// Is there a frame worth drawing? Cleared once one is presented, set by
/// anything that can change what is on screen. The first frame always is.
needs_frame: bool = true,
+ /// Set by a shell whose present was skipped (no swapchain image): the
+ /// frame is still owed, and the next pump draws it again.
+ present_skipped: bool = false,
/// Builtins announce themselves on the message row. Off while startup
/// configuration is applied: those lines are nobody clicking, and should
/// not leave the row holding the name of the last one.
@@ -7040,16 +7206,18 @@ pub const Pardes = struct {
}
};
+ /// A status updates the message line in place (a progress count does not
+ /// pile up); a message is an event, and stacks.
pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void {
- p.showMessage(id, text);
+ p.showMessage(id, text, false);
}
pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void {
p.logMessage(id, text);
- p.showMessage(id, text);
+ p.showMessage(id, text, true);
}
- fn showMessage(p: *Pardes, id: usize, text: []const u8) void {
+ fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void {
if (id >= MAX_PANES) return;
const pane = p.panes[id] orelse return;
// One row of printable text: a language server's multi-line report
@@ -7058,8 +7226,158 @@ pub const Pardes = struct {
if (n < text.len) {
while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1;
}
+ const same = pane.msg_len == n and for (text[0..n], pane.msg[0..n]) |c, have| {
+ if ((if (c < 0x20 or c == 0x7f) ' ' else c) != have) break false;
+ } else true;
+ // A new event moves the line on screen up the stack, where it keeps
+ // its own timing, and falls in below it.
+ // Only an event stacks on an event: an announcement or a status
+ // stands in for whatever comes next.
+ if (stack and pane.msg_len > 0 and !same and !pane.msg_announcement and !pane.msg_status)
+ pane.pushOlderMessage();
+ if (pane.msg_len == 0) pane.msg_slot = pane.freeMessageSlot();
+ pane.msg_announcement = false;
+ pane.msg_status = !stack;
+ const arrives = pane.msg_len == 0 or pane.msg_life.phase == .leaving;
for (text[0..n], pane.msg[0..n]) |c, *cell| cell.* = if (c < 0x20 or c == 0x7f) ' ' else c;
pane.msg_len = @intCast(n);
+ p.needs_frame = true;
+ if (arrives) {
+ pane.msg_life = .{ .phase = if (p.settings.message_animation and n > 0) .entering else .shown };
+ } else if (pane.msg_life.phase == .lingering or
+ (pane.msg_life.phase == .entering and !p.settings.message_animation))
+ {
+ // The same line again, or a status update: fresh, so it stays.
+ pane.msg_life = .{};
+ } else {
+ pane.msg_life.dismissed = false;
+ }
+ }
+
+ /// The key or click that used to clear the message row now starts each
+ /// line's exit: it stays `message_linger_ms`, then dissolves. A line
+ /// already on its way out keeps its own timing.
+ fn dismissMessage(p: *Pardes, pane: *Pane) void {
+ if (pane.msg_len > 0 and p.dismissLine(&pane.msg_life)) {
+ pane.msg_len = 0;
+ pane.msg_life = .{};
+ }
+ var i: usize = 0;
+ while (i < pane.msg_older_len) {
+ if (p.dismissLine(&pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1;
+ }
+ }
+
+ /// Start one line's exit; true when it is gone at once (no linger, no
+ /// animation).
+ fn dismissLine(p: *const Pardes, life: *Pane.MessageLife) bool {
+ switch (life.phase) {
+ .lingering, .leaving => return false,
+ // A falling line lands first, then lingers -- unless falling was
+ // just turned off, and nothing will land it.
+ .entering => if (p.settings.message_animation) {
+ life.dismissed = true;
+ return false;
+ },
+ .shown => {},
+ }
+ life.frame = 0;
+ if (p.settings.message_linger_ms > 0) {
+ life.phase = .lingering;
+ } else if (p.settings.message_animation) {
+ life.phase = .leaving;
+ } else return true;
+ return false;
+ }
+
+ /// One animation frame of every message line. Each one that moves owes
+ /// a frame, the one that finishes included: a tick asks for none itself.
+ fn advanceMessages(p: *Pardes) void {
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.msg_len > 0 and pane.msg_life.phase != .shown) {
+ p.needs_frame = true;
+ if (p.advanceLine(&pane.msg_life)) {
+ pane.msg_len = 0;
+ pane.msg_life = .{};
+ }
+ }
+ var i: usize = 0;
+ while (i < pane.msg_older_len) {
+ if (pane.msg_older[i].life.phase != .shown) p.needs_frame = true;
+ if (p.advanceLine(&pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1;
+ }
+ }
+ }
+
+ /// One frame of one line's life; true once it has left.
+ fn advanceLine(p: *const Pardes, life: *Pane.MessageLife) bool {
+ if (life.phase == .shown) return false;
+ life.frame +|= 1;
+ switch (life.phase) {
+ .shown => {},
+ .entering => if (life.frame >= messageFrames(p.settings.message_fall_ms)) {
+ life.* = .{ .phase = if (life.dismissed) .lingering else .shown };
+ },
+ .lingering => if (life.frame >= messageFrames(p.settings.message_linger_ms)) {
+ if (!p.settings.message_animation) return true;
+ life.* = .{ .phase = .leaving };
+ },
+ .leaving => if (life.frame >= messageFrames(p.settings.message_dissolve_ms)) return true,
+ }
+ return false;
+ }
+
+ /// Is any message line moving (and so owed ticks)?
+ fn messagesAnimating(pane: *const Pane) bool {
+ if (pane.msg_len > 0 and pane.msg_life.phase != .shown) return true;
+ for (pane.msg_older[0..pane.msg_older_len]) |line| if (line.life.phase != .shown) return true;
+ return false;
+ }
+
+ /// How present a message is (1 = fully) and how far above its row it
+ /// still is (rows), eased: an ease-in fall, an ease-out dissolve.
+ const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 };
+
+ /// Animation frames a message timing lasts; at least one.
+ pub fn messageFrames(ms: u16) u16 {
+ return @intCast(@max(1, (@as(u32, ms) + layout.Animation.frame_ms - 1) / layout.Animation.frame_ms));
+ }
+
+ fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion {
+ const fall_frames = messageFrames(p.settings.message_fall_ms);
+ const dissolve_frames = messageFrames(p.settings.message_dissolve_ms);
+ return switch (life.phase) {
+ .shown, .lingering => .{ .alpha = 1, .slide = 0 },
+ .entering => blk: {
+ const t = @as(f32, @floatFromInt(@min(life.frame, fall_frames))) / @as(f32, @floatFromInt(fall_frames));
+ const eased = t * t;
+ break :blk .{ .alpha = eased, .slide = eased - 1 };
+ },
+ .leaving => blk: {
+ const t = @as(f32, @floatFromInt(@min(life.frame, dissolve_frames))) / @as(f32, @floatFromInt(dissolve_frames));
+ break :blk .{ .alpha = (1 - t) * (1 - t), .slide = 0 };
+ },
+ };
+ }
+
+ /// The life of the message line notice `i` shows, if it shows one.
+ fn noticeLife(pane: *const Pane, i: usize) ?Pane.MessageLife {
+ return switch (pane.notices.kinds[i]) {
+ .message => pane.msg_life,
+ .older => pane.msg_older[pane.notices.index[i]].life,
+ .leader, .prompt => null,
+ };
+ }
+
+ fn blendRgb(from: [3]u8, to: [3]u8, amount: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, from, to) |*o, a, b| {
+ const af: f32 = @floatFromInt(a);
+ const bf: f32 = @floatFromInt(b);
+ o.* = @intFromFloat(@round(bf + (af - bf) * std.math.clamp(amount, 0, 1)));
+ }
+ return out;
}
fn logMessage(p: *Pardes, id: usize, text: []const u8) void {
@@ -7358,8 +7676,11 @@ pub const Pardes = struct {
_ = p.frame_arena.reset(.retain_capacity);
const surface = try p.render(p.frame_arena.allocator());
if (v.present) |f| f(h.ctx, surface);
+ // Settled before post_present: a shell ticks there, and what that
+ // tick moves owes the next frame.
+ p.needs_frame = p.present_skipped;
+ p.present_skipped = false;
if (v.post_present) |f| f(h.ctx);
- p.needs_frame = false;
}
/// One request of the control filesystem, from whichever thread has the
@@ -7389,6 +7710,10 @@ pub const Pardes = struct {
// the screen.
p.needs_frame = p.needs_frame or ev != .tick;
p.shell_rows.sweep(p.gpa);
+ // An announcement stands in only until its own step says more.
+ for (p.panes) |slot| if (slot) |pane| {
+ pane.msg_announcement = false;
+ };
switch (ev) {
.tick => {},
.mouse => |m| if (!(m.button == .none and m.kind == .motion)) {
@@ -7415,10 +7740,11 @@ pub const Pardes = struct {
switch (ev) {
.key, .mouse => {
for (p.panes) |slot| {
- if (slot) |pane| pane.msg_len = 0;
+ if (slot) |pane| p.dismissMessage(pane);
}
p.clip_pending = null;
},
+ .tick => p.advanceMessages(),
else => {},
}
if (p.fs.listeners != 0) p.fs.origin = switch (ev) {
@@ -11462,7 +11788,7 @@ pub const Pardes = struct {
// A notice chip is chrome: it sits OVER the body's top rows, so a
// click inside one belongs to nobody. The rest of those rows is
// ordinary body text and still answers normally.
- if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.len and
+ if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.span() and
p.panes[id].?.notices.covers(row - body_y, col)) return null;
if (!on_tag) if (body_hit) |supplied| {
const mapped_hit = p.bodyHitForPane(id, supplied, false) orelse return null;
@@ -12252,6 +12578,21 @@ pub const Pardes = struct {
}
} else {
const clk = pane.sel[b];
+ // A right-click on a line NUMBER looks at that line of
+ // this file -- a sticky context header's number included,
+ // since the row map holds each header's own source line.
+ if (s.button == config.look_button and clk.r0 >= BOX_H and clk.r0 == clk.r1 and
+ clk.c0 == clk.c1 and clk.c0 >= 0 and pane.mode != .tty)
+ if (pane.file) |f| if (f.output == null and clk.c0 < panes.File.gutterWidth(pane)) {
+ const line = pane.wrapAt(clk.r0 - BOX_H).line;
+ if (line >= 0) {
+ pane.sel[b].state = .none;
+ var buf: [48]u8 = undefined;
+ const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ s.id, @as(usize, @intCast(line)) + 1 }) catch return;
+ p.runBuiltin(config.look_cmd, s.id, "", target);
+ return;
+ }
+ };
var operand = p.pointerOperand(pane, clk);
if (operand.expanded) |expanded|
pane.sel[b] = expanded
@@ -12876,8 +13217,10 @@ pub const Pardes = struct {
// A builtin says its own name before it runs, so a tagline click is
// legible even when what it did is invisible. Whatever the builtin
// then says for itself replaces the announcement.
- if (p.announce and p.settings.verbose and builtins.registry.announces(b))
+ if (p.announce and p.settings.verbose and builtins.registry.announces(b)) {
p.setMessage(id, @tagName(b));
+ pane.msg_announcement = true;
+ }
const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg };
builtins.registry.dispatch(b, c);
}
@@ -13121,10 +13464,16 @@ pub const Pardes = struct {
const pane: *Pane = switch (src.kind) {
.terminal => terminal: {
const t = src.terminal.?;
- const restored = try panes.Terminal.restore(p, src);
+ // A host that runs shells gets them back: the old history
+ // above a new shell in the directory it was in. The web
+ // shell and an isolated build only ever replay it.
+ const live = comptime (terminal_panes and platform != .web and !isolated);
+ const revive = live and t.cwd.len <= effect_path_cap;
+ const restored = try panes.Terminal.restore(p, src, revive);
p.installPane(i, restored);
try restored.setOwnedCwd(t.cwd);
if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty;
+ if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .cwd = .from(t.cwd) } });
break :terminal restored;
},
.file => try panes.File.restore(p, i, src),
@@ -13487,6 +13836,9 @@ pub const Pardes = struct {
/// core work belongs in this one predicate so TTY and GUI mature it alike.
pub fn animationActive(p: *const Pardes) bool {
if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true;
+ for (p.panes) |slot| if (slot) |pane| {
+ if (messagesAnimating(pane)) return true;
+ };
const scene = p.settings.scene_effects;
if (scene.crt or scene.ripple or scene.glitch) return true;
return p.presentation.animating();
@@ -13653,7 +14005,7 @@ pub const Pardes = struct {
const first = p.bodyTop(r);
var leader_buf: [16]u8 = undefined;
for (pane.notices.slice(), 0..) |kind, i| {
- const row = first + @as(u16, @intCast(i));
+ const row = first + @as(u16, pane.notices.row[i]);
// Every notice is painted on the canonical grid as well as
// emitted as a tag layer: the grid is what a terminal client
// draws, and a band it cannot see is a message it never gets.
@@ -13661,6 +14013,7 @@ pub const Pardes = struct {
// drawn twice there.
const text = switch (kind) {
.message => pane.msg[0..pane.msg_len],
+ .older => pane.msg_older[pane.notices.index[i]].slice(),
.leader => p.leaderText(&leader_buf),
.prompt => blk: {
const at = pane.promptAt() orelse continue;
@@ -13671,13 +14024,24 @@ pub const Pardes = struct {
if (chip == 0) continue;
const cx = tx + tw - chip;
pane.notices.left[i] = cx;
- s.fill(cx, row, chip, 1, .{ .bg = msg_style.bg, .font_role = .tagline });
+ // A message coming or going blends toward the page it sits
+ // on; a grid cannot move it by less than a row.
+ var style = msg_style;
+ if (noticeLife(pane, i)) |life| {
+ // No page colour to fade to (a terminal's own default):
+ // the text dissolves into its chip instead.
+ const alpha = p.messageMotion(life).alpha;
+ const page = th.bg orelse chrome.tag_bg;
+ style.fg = .{ .rgb = blendRgb(chrome.tag_fg, page, alpha) };
+ style.bg = .{ .rgb = blendRgb(chrome.tag_bg, page, alpha) };
+ }
+ s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline });
// One column short of the chip's right edge: a prompt's caret
// sits AFTER its last character the moment it opens, and flush
// right would put it past the pane, off the grid, and past what
// the detached wire will encode -- which drops every frame for
// as long as the prompt is up.
- const printed = printRight(s, cx, row, chip -| 1, text, msg_style);
+ const printed = printRight(s, cx, row, chip -| 1, text, style);
if (kind != .prompt or id != p.active) continue;
const at = pane.promptAt() orelse continue;
const prompt0 = (p.tagPrefix(pane) catch continue).len + at;
@@ -14166,6 +14530,7 @@ pub const Pardes = struct {
for (pane.notices.slice(), 0..) |kind, i| {
const text = switch (kind) {
.message => pane.msg[0..pane.msg_len],
+ .older => pane.msg_older[pane.notices.index[i]].slice(),
.leader => p.leaderText(&leader_buf),
// A prompt is an editing line with a cursor in it, so it
// stays on the grid where the cursor can sit on a cell.
@@ -14190,12 +14555,19 @@ pub const Pardes = struct {
const line = try arena.alloc(u8, pad + kept.len);
@memset(line[0..pad], ' ');
@memcpy(line[pad..], kept);
- try p.renderHeaderLayer(arena, NOTICE_LAYER_BASE + id * Pane.Notices.max + i, .notice, @intCast(id), .{
+ // A pixel shell slides the message in whole and only fades
+ // it on the way out.
+ const motion: MessageMotion = if (noticeLife(pane, i)) |life| p.messageMotion(life) else .{};
+ const fade = if (motion.slide == 0) motion.alpha else 1;
+ const page = p.theme().bg orelse chrome.tag_bg;
+ const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i;
+ try p.renderHeaderLayer(arena, layer_index, .notice, @intCast(id), .{
.x = cx,
- .y = first + @as(u16, @intCast(i)),
+ .y = first + @as(u16, pane.notices.row[i]),
.w = chip,
.h = BOX_H,
- }, line, chrome.tag_bg, chrome.tag_fg);
+ }, line, blendRgb(chrome.tag_bg, page, fade), blendRgb(chrome.tag_fg, page, fade));
+ p.surface.tag_layers[layer_index].slide = motion.slide;
}
}
if (p.columnBarHeight() > 0) for (0..p.ncol) |column| {
@@ -14221,6 +14593,7 @@ pub const Pardes = struct {
layer.cols = columns;
layer.bg = bg;
layer.cursor = null;
+ layer.slide = 0;
var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells };
// A notice band carries a PANE id, not a column index, so it must not
// answer to the column being edited -- that would scroll and select
@@ -14270,7 +14643,10 @@ pub const Pardes = struct {
fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void {
pane.notices.len = 0;
if (r.w <= config.GUTTER or r.h <= BOX_H) return;
- if (pane.msg_len > 0) pane.notices.push(.message);
+ // Oldest message line on top, so lines already up do not move when a
+ // new one lands under them; when rows run out the oldest go first.
+ for (pane.msg_older[0..pane.msg_older_len], 0..) |line, i| pane.notices.pushAt(.older, @intCast(i), line.slot);
+ if (pane.msg_len > 0) pane.notices.pushAt(.message, 0, pane.msg_slot);
if (id == p.active and p.leader_on) pane.notices.push(.leader);
if (pane.promptAt()) |at| {
if (pane.tagSlice()[@min(at, pane.tag_tail_len)..].len > 0) pane.notices.push(.prompt);
@@ -14281,11 +14657,26 @@ pub const Pardes = struct {
// into blind. A two-row pane spends its only body row on one notice,
// which is what the single shared row used to do anyway.
const room: u8 = @intCast(@min(@as(u16, Pane.Notices.max), r.h - BOX_H));
- if (pane.notices.len > room) {
- const drop = pane.notices.len - room;
- for (0..room) |i| pane.notices.kinds[i] = pane.notices.kinds[i + drop];
- pane.notices.len = room;
+ const n = &pane.notices;
+ if (n.span() <= room) return;
+ // Too short for the gaps: close them up in screen order, and if even
+ // that does not fit, the top bands go.
+ var i: usize = 1;
+ while (i < n.len) : (i += 1) {
+ var j = i;
+ while (j > 0 and n.row[j - 1] > n.row[j]) : (j -= 1) {
+ std.mem.swap(Pane.Notices.Kind, &n.kinds[j - 1], &n.kinds[j]);
+ std.mem.swap(u8, &n.index[j - 1], &n.index[j]);
+ std.mem.swap(u8, &n.row[j - 1], &n.row[j]);
+ }
+ }
+ const drop = n.len -| room;
+ for (0..n.len - drop) |k| {
+ n.kinds[k] = n.kinds[k + drop];
+ n.index[k] = n.index[k + drop];
+ n.row[k] = @intCast(k);
}
+ n.len -= @intCast(drop);
}
fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void {
@@ -14390,7 +14781,7 @@ pub const Pardes = struct {
// Below the notice chips: a picture is drawn after the cells (the
// GUI's image pass, kitty's z-order), so a chip over it would be
// painted out. Text gets the overlay; a picture gives up the rows.
- const shown = pane.notices.len;
+ const shown = pane.notices.span();
const image_h = (r.h -| BOX_H) -| shown;
panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h);
// thumbless, but the same one column as the real scrollbar below —
diff --git a/src/pdf.zig b/src/pdf.zig
index 712750dc..de5e6d86 100644
--- a/src/pdf.zig
+++ b/src/pdf.zig
@@ -1017,6 +1017,73 @@ pub const Document = struct {
) != c.PARDES_PDF_OK) return error.RenderFailed;
}
+ /// Only the highlight pass of `renderIntoAtWithPaper`, over `rgba`, which
+ /// holds `band` of this page as that render left it before highlighting
+ /// (opaque paper). Rendering with highlights equals rendering without and
+ /// then this, row for row: the render draws highlights over the finished
+ /// page, and so does this.
+ pub fn paintHighlightsAt(
+ document: *Document,
+ page: usize,
+ request: RenderRequest,
+ raster: Raster,
+ band: Raster.Band,
+ highlights: []const Highlight,
+ rgba: []u8,
+ ) !void {
+ if (rgba.len != band.len) return error.BadPixmap;
+ if (band.height == 0 or band.y + band.height > raster.height)
+ return error.BadPixmap;
+ const bounded = try boundedRequest(document, page, request);
+ if (highlights.len > c.PARDES_PDF_MAX_RESULT_QUADS) return error.RenderFailed;
+ if (c.pardes_pdf_paint_highlights(
+ document.handle,
+ @intCast(page),
+ bounded.dpi,
+ bounded.minimum_width,
+ bounded.minimum_height,
+ bounded.max_dimension,
+ if (highlights.len == 0) null else @ptrCast(highlights.ptr),
+ highlights.len,
+ rgba.ptr,
+ rgba.len,
+ @intCast(raster.width),
+ @intCast(raster.height),
+ @intCast(raster.stride),
+ @intCast(band.y),
+ @intCast(band.height),
+ ) != c.PARDES_PDF_OK) return error.RenderFailed;
+ }
+
+ pub const Rows = struct { start: usize, end: usize };
+
+ /// The rows of `request`'s raster that painting `highlights` can touch,
+ /// by the transform the render itself uses (rotation included). Empty
+ /// when nothing would paint.
+ pub fn highlightRows(
+ document: *Document,
+ page: usize,
+ request: RenderRequest,
+ highlights: []const Highlight,
+ ) !Rows {
+ const bounded = try boundedRequest(document, page, request);
+ var row_start: c_int = 0;
+ var row_end: c_int = 0;
+ if (c.pardes_pdf_highlight_rows(
+ document.handle,
+ @intCast(page),
+ bounded.dpi,
+ bounded.minimum_width,
+ bounded.minimum_height,
+ bounded.max_dimension,
+ if (highlights.len == 0) null else @ptrCast(highlights.ptr),
+ highlights.len,
+ &row_start,
+ &row_end,
+ ) != c.PARDES_PDF_OK) return error.RenderFailed;
+ return .{ .start = @intCast(row_start), .end = @intCast(row_end) };
+ }
+
const BoundedRequest = struct {
dpi: c_int,
minimum_width: c_int,
diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c
index 976ed838..2c723dbb 100644
--- a/src/pdf_bridge.c
+++ b/src/pdf_bridge.c
@@ -572,6 +572,55 @@ pardes_pdf_measure_render(
return PARDES_PDF_OK;
}
+/* The highlight pass of a render: fills over whatever the pixmap holds. */
+static void
+pardes_pdf_fill_highlights(fz_context *ctx, pardes_pdf_document *document,
+ fz_device *device, const pardes_pdf_highlight *highlights, size_t highlight_count)
+{
+ fz_path *path = NULL;
+ size_t i;
+
+ fz_var(path);
+ fz_try(ctx)
+ {
+ for (i = 0; i < highlight_count; ++i) {
+ fz_quad quad;
+ float color[3];
+ float alpha;
+
+ if (!pardes_pdf_valid_quad(highlights[i].quad))
+ fz_throw(ctx, FZ_ERROR_ARGUMENT,
+ "PDF highlight contains a non-finite point");
+ if (highlights[i].rgba[3] == 0)
+ continue;
+
+ /* Kind is semantic metadata; caller-supplied RGBA is final. */
+ (void)highlights[i].kind;
+ quad = pardes_pdf_quad_to_page(document, highlights[i].quad);
+ color[0] = highlights[i].rgba[0] / 255.0f;
+ color[1] = highlights[i].rgba[1] / 255.0f;
+ color[2] = highlights[i].rgba[2] / 255.0f;
+ alpha = highlights[i].rgba[3] / 255.0f;
+
+ path = fz_new_path(ctx);
+ fz_moveto(ctx, path, quad.ul.x, quad.ul.y);
+ fz_lineto(ctx, path, quad.ur.x, quad.ur.y);
+ fz_lineto(ctx, path, quad.lr.x, quad.lr.y);
+ fz_lineto(ctx, path, quad.ll.x, quad.ll.y);
+ fz_closepath(ctx, path);
+ fz_fill_path(ctx, device, path, 0, fz_identity,
+ fz_device_rgb(ctx), color, alpha,
+ fz_default_color_params);
+ fz_drop_path(ctx, path);
+ path = NULL;
+ }
+ }
+ fz_always(ctx)
+ fz_drop_path(ctx, path);
+ fz_catch(ctx)
+ fz_rethrow(ctx);
+}
+
int
pardes_pdf_render_into(
pardes_pdf_document *document,
@@ -594,10 +643,8 @@ pardes_pdf_render_into(
fz_context *ctx;
fz_pixmap *pixmap = NULL;
fz_device *device = NULL;
- fz_path *path = NULL;
fz_matrix ctm;
fz_irect bbox;
- size_t i;
if (document == NULL || page_number < 0 ||
page_number >= document->page_count || dpi < 1 ||
@@ -614,7 +661,6 @@ pardes_pdf_render_into(
ctx = document->ctx;
fz_var(pixmap);
fz_var(device);
- fz_var(path);
fz_try(ctx)
{
pardes_pdf_render_geometry(document, page_number, dpi,
@@ -627,10 +673,12 @@ pardes_pdf_render_into(
* The BAND: rows [band_y, band_y + band_height) of the page raster,
* and nothing else. A pixmap's bbox IS the draw device's clip, so the
* page runs under the same CTM it would for a full raster and MuPDF
- * discards everything outside these rows. Every row inside them is
- * therefore bit-identical to the same row of the full-page raster —
- * "band rows equal full-page rows" in pdf.zig proves it, because the
- * whole point of a band is that the reader cannot tell.
+ * discards everything outside these rows. The rows are NOT always
+ * bit-identical to the full page's: rows at the band's edges, and a
+ * resampled image anywhere in it, can differ (measured on
+ * docs/registry.pdf and docs/design.pdf). Good enough for a band that
+ * is only shown while flinging; never use a band to patch rows into a
+ * whole-page raster -- `pardes_pdf_paint_highlights` exists for that.
*/
bbox.y0 += band_y;
bbox.y1 = bbox.y0 + band_height;
@@ -674,43 +722,12 @@ pardes_pdf_render_into(
if (highlight_count != 0) {
device = fz_new_draw_device(ctx, ctm, pixmap);
- for (i = 0; i < highlight_count; ++i) {
- fz_quad quad;
- float color[3];
- float alpha;
-
- if (!pardes_pdf_valid_quad(highlights[i].quad))
- fz_throw(ctx, FZ_ERROR_ARGUMENT,
- "PDF highlight contains a non-finite point");
- if (highlights[i].rgba[3] == 0)
- continue;
-
- /* Kind is semantic metadata; caller-supplied RGBA is final. */
- (void)highlights[i].kind;
- quad = pardes_pdf_quad_to_page(document, highlights[i].quad);
- color[0] = highlights[i].rgba[0] / 255.0f;
- color[1] = highlights[i].rgba[1] / 255.0f;
- color[2] = highlights[i].rgba[2] / 255.0f;
- alpha = highlights[i].rgba[3] / 255.0f;
-
- path = fz_new_path(ctx);
- fz_moveto(ctx, path, quad.ul.x, quad.ul.y);
- fz_lineto(ctx, path, quad.ur.x, quad.ur.y);
- fz_lineto(ctx, path, quad.lr.x, quad.lr.y);
- fz_lineto(ctx, path, quad.ll.x, quad.ll.y);
- fz_closepath(ctx, path);
- fz_fill_path(ctx, device, path, 0, fz_identity,
- fz_device_rgb(ctx), color, alpha,
- fz_default_color_params);
- fz_drop_path(ctx, path);
- path = NULL;
- }
+ pardes_pdf_fill_highlights(ctx, document, device, highlights, highlight_count);
fz_close_device(ctx, device);
}
}
fz_always(ctx)
{
- fz_drop_path(ctx, path);
fz_drop_device(ctx, device);
/* Drops only the wrapper: caller-owned samples remain untouched. */
fz_drop_pixmap(ctx, pixmap);
@@ -743,6 +760,139 @@ pardes_pdf_render_into(
}
int
+pardes_pdf_paint_highlights(
+ pardes_pdf_document *document,
+ int page_number,
+ int dpi,
+ int minimum_width,
+ int minimum_height,
+ int max_dimension,
+ const pardes_pdf_highlight *highlights,
+ size_t highlight_count,
+ unsigned char *samples,
+ size_t samples_len,
+ int width,
+ int height,
+ int stride,
+ int band_y,
+ int band_height)
+{
+ fz_context *ctx;
+ fz_pixmap *pixmap = NULL;
+ fz_device *device = NULL;
+ fz_matrix ctm;
+ fz_irect bbox;
+
+ if (document == NULL || page_number < 0 ||
+ page_number >= document->page_count || dpi < 1 ||
+ minimum_width < 0 || minimum_height < 0 || max_dimension < 1 ||
+ (highlight_count != 0 && highlights == NULL) ||
+ highlight_count > PARDES_PDF_MAX_RESULT_QUADS || samples == NULL ||
+ width < 1 || height < 1 || width > INT_MAX / 4 || stride != width * 4 ||
+ band_y < 0 || band_height < 1 || band_y > height - band_height ||
+ (size_t)band_height > SIZE_MAX / (size_t)stride ||
+ samples_len != (size_t)stride * (size_t)band_height)
+ return PARDES_PDF_ERROR;
+ if (highlight_count == 0)
+ return PARDES_PDF_OK;
+
+ ctx = document->ctx;
+ fz_var(pixmap);
+ fz_var(device);
+ fz_try(ctx)
+ {
+ pardes_pdf_render_geometry(document, page_number, dpi,
+ minimum_width, minimum_height, max_dimension, &ctm, &bbox);
+ if (fz_irect_width(bbox) != width || fz_irect_height(bbox) != height)
+ fz_throw(ctx, FZ_ERROR_ARGUMENT,
+ "PDF raster layout changed between measure and paint");
+ bbox.y0 += band_y;
+ bbox.y1 = bbox.y0 + band_height;
+ pixmap = fz_new_pixmap_with_bbox_and_data(
+ ctx, fz_device_rgb(ctx), bbox, NULL, 1, samples);
+ device = fz_new_draw_device(ctx, ctm, pixmap);
+ pardes_pdf_fill_highlights(ctx, document, device, highlights, highlight_count);
+ fz_close_device(ctx, device);
+ }
+ fz_always(ctx)
+ {
+ fz_drop_device(ctx, device);
+ fz_drop_pixmap(ctx, pixmap);
+ }
+ fz_catch(ctx)
+ {
+ fz_report_error(ctx);
+ return PARDES_PDF_ERROR;
+ }
+ return PARDES_PDF_OK;
+}
+
+int
+pardes_pdf_highlight_rows(
+ pardes_pdf_document *document,
+ int page_number,
+ int dpi,
+ int minimum_width,
+ int minimum_height,
+ int max_dimension,
+ const pardes_pdf_highlight *highlights,
+ size_t highlight_count,
+ int *row_start,
+ int *row_end)
+{
+ fz_context *ctx;
+ fz_matrix ctm;
+ fz_irect bbox;
+ float y0 = 0, y1 = 0;
+ int any = 0;
+ size_t i;
+
+ if (row_start == NULL || row_end == NULL)
+ return PARDES_PDF_ERROR;
+ *row_start = *row_end = 0;
+ if (document == NULL || page_number < 0 ||
+ page_number >= document->page_count || dpi < 1 ||
+ minimum_width < 0 || minimum_height < 0 || max_dimension < 1 ||
+ (highlight_count != 0 && highlights == NULL))
+ return PARDES_PDF_ERROR;
+
+ ctx = document->ctx;
+ fz_try(ctx)
+ {
+ pardes_pdf_render_geometry(document, page_number, dpi,
+ minimum_width, minimum_height, max_dimension, &ctm, &bbox);
+ for (i = 0; i < highlight_count; ++i) {
+ fz_rect r;
+ if (highlights[i].rgba[3] == 0 ||
+ !pardes_pdf_valid_quad(highlights[i].quad))
+ continue;
+ r = fz_rect_from_quad(fz_transform_quad(
+ pardes_pdf_quad_to_page(document, highlights[i].quad), ctm));
+ if (!any || r.y0 < y0) y0 = r.y0;
+ if (!any || r.y1 > y1) y1 = r.y1;
+ any = 1;
+ }
+ }
+ fz_catch(ctx)
+ {
+ fz_report_error(ctx);
+ return PARDES_PDF_ERROR;
+ }
+ if (any) {
+ /*
+ * Antialiasing covers the pixel a fractional edge falls in, and the
+ * rasterizer paints row floor(y1) even for a zero-height quad.
+ */
+ int lo = (int)floorf(y0) - bbox.y0;
+ int hi = (int)floorf(y1) + 1 - bbox.y0;
+ int h = fz_irect_height(bbox);
+ *row_start = lo < 0 ? 0 : (lo > h ? h : lo);
+ *row_end = hi < *row_start ? *row_start : (hi > h ? h : hi);
+ }
+ return PARDES_PDF_OK;
+}
+
+int
pardes_pdf_page_text(
pardes_pdf_document *document,
int page_number,
diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h
index cda426aa..e3514437 100644
--- a/src/pdf_bridge.h
+++ b/src/pdf_bridge.h
@@ -222,8 +222,9 @@ int pardes_pdf_measure_render(
* MuPDF wraps but never owns or frees samples. Every successful pixel is
* opaque unless transparent_paper is nonzero. That mode preserves content
* coverage and returns straight-alpha RGBA over an unpainted page.
- * The CTM does not depend on the band, so a band's rows are bit-identical to
- * the same rows of the whole page: it is a clip, not a different rendering.
+ * The CTM does not depend on the band, so a band is a clip of the same
+ * rendering -- but not a bit-exact one: its edge rows and resampled images can
+ * differ from the whole page's (see pardes_pdf_render_into).
*/
int pardes_pdf_render_into(
pardes_pdf_document *document,
@@ -244,6 +245,47 @@ int pardes_pdf_render_into(
int transparent_paper
);
+/*
+ * The highlight pass of `pardes_pdf_render_into` alone, over caller-supplied
+ * rows of that page's opaque raster: render_into(h) == paint(render_into({}), h)
+ * row for row, because a render draws its highlights over the finished page.
+ */
+int pardes_pdf_paint_highlights(
+ pardes_pdf_document *document,
+ int page,
+ int dpi,
+ int minimum_width,
+ int minimum_height,
+ int max_dimension,
+ const pardes_pdf_highlight *highlights,
+ size_t highlight_count,
+ unsigned char *samples,
+ size_t samples_len,
+ int width,
+ int height,
+ int stride,
+ int band_y,
+ int band_height
+);
+
+/*
+ * The raster rows [*row_start, *row_end) that painting `highlights` can touch
+ * under the same request, in the coordinates `pardes_pdf_render_into` bands
+ * use. An empty set (or only transparent ones) yields start == end == 0.
+ */
+int pardes_pdf_highlight_rows(
+ pardes_pdf_document *document,
+ int page,
+ int dpi,
+ int minimum_width,
+ int minimum_height,
+ int max_dimension,
+ const pardes_pdf_highlight *highlights,
+ size_t highlight_count,
+ int *row_start,
+ int *row_end
+);
+
int pardes_pdf_page_text(
pardes_pdf_document *document,
int page,
diff --git a/src/tag_layer.zig b/src/tag_layer.zig
index 0fb2297d..f8cc6eca 100644
--- a/src/tag_layer.zig
+++ b/src/tag_layer.zig
@@ -26,6 +26,10 @@ pub const Layer = struct {
bg: [3]u8 = .{ 0, 0, 0 },
cells: []pardes.Cell = &.{},
cursor: ?struct { col: u16, bar: bool = false } = null,
+ /// Vertical offset in rows (negative: above its viewport), for a band
+ /// that is moving into place. A pixel shell draws it offset and clipped
+ /// to the rows below the viewport's top; the grid has no such thing.
+ slide: f32 = 0,
pub fn capacity(layer: *const Layer, metrics: pardes.RowMetrics) u16 {
return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w))));
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 03ccd3e2..5976852f 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -21,6 +21,56 @@ extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int
const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
+/// The terminal answered the shared-memory probe (`kittyShmProbe`): it can
+/// read a raster from a POSIX shm object, so a page costs a name on the wire
+/// instead of its whole base64 body. Set by the input reader, which sees the
+/// reply before vaxis' parser swallows every graphics APC alike.
+var kitty_shm = std.atomic.Value(bool).init(false);
+/// Only a reply that arrives while the probe is out counts. The id is one
+/// vaxis' own image numbering (1, 2, ...) never reaches, because a terminal
+/// answers every transmission `i=<id>;OK` and one of those must not pass.
+var kitty_shm_probing = std.atomic.Value(bool).init(false);
+const kitty_shm_probe_id = 0x7fff_fff1;
+var kitty_shm_serial: u32 = 0;
+
+fn kittyShmName(buf: []u8, n: u32) ![:0]const u8 {
+ return std.fmt.bufPrintSentinel(buf, "/pardes-{d}-{d}", .{ libc.getpid(), n }, 0);
+}
+
+/// Write `bytes` to a fresh shm object; the terminal unlinks it after reading.
+fn kittyShmWrite(name: [:0]const u8, bytes: []const u8) bool {
+ const fd = libc.shm_open(name, @bitCast(libc.O{ .ACCMODE = .RDWR, .CREAT = true, .EXCL = true }), @as(c_uint, 0o600));
+ if (fd < 0) return false;
+ defer _ = libc.close(fd);
+ if (libc.ftruncate(fd, @intCast(bytes.len)) != 0) {
+ _ = libc.shm_unlink(name);
+ return false;
+ }
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = libc.write(fd, bytes[off..].ptr, bytes.len - off);
+ if (n <= 0) {
+ _ = libc.shm_unlink(name);
+ return false;
+ }
+ off += @intCast(n);
+ }
+ return true;
+}
+
+/// Ask with a 1x1 RGB image in shm whether the terminal reads that medium. A
+/// remote terminal cannot see our shm and answers an error (or nothing).
+fn kittyShmProbe(w: *std.Io.Writer) void {
+ var buf: [64]u8 = undefined;
+ const name = kittyShmName(&buf, 0) catch return;
+ if (!kittyShmWrite(name, &.{ 0, 0, 0 })) return;
+ var b64: [64]u8 = undefined;
+ const enc = std.base64.standard.Encoder.encode(&b64, name);
+ kitty_shm_probing.store(true, .release);
+ w.print("\x1b_Gi={d},s=1,v=1,a=q,t=s,f=24;{s}\x1b\\", .{ kitty_shm_probe_id, enc }) catch return;
+ w.flush() catch {};
+}
+
pub const Command = struct {
pub var value: union(enum) {
nop,
@@ -81,6 +131,13 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
const received = try tty.read(buf[carried..]);
if (received == 0) return;
const end = carried + received;
+ if (kitty_shm_probing.load(.acquire)) if (std.mem.indexOf(u8, buf[0..end], std.fmt.comptimePrint("\x1b_Gi={d};", .{kitty_shm_probe_id}))) |at| {
+ kitty_shm_probing.store(false, .release);
+ kitty_shm.store(std.mem.startsWith(u8, buf[at..end], std.fmt.comptimePrint("\x1b_Gi={d};OK", .{kitty_shm_probe_id})), .release);
+ // Answered either way: a terminal that could not read it left it.
+ var name_buf: [64]u8 = undefined;
+ if (kittyShmName(&name_buf, 0)) |name| _ = libc.shm_unlink(name) else |_| {}
+ };
var parse_end = end;
var lead = end;
while (lead > 0 and buf[lead - 1] & 0xc0 == 0x80) lead -= 1;
@@ -717,6 +774,7 @@ fn localSession(
try startInput(loop, input_cache);
defer if (attached.* == null) stopInput(loop);
(try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach();
+ (try std.Thread.spawn(.{}, tickWatch, .{ io, loop })).detach();
if (fs) |f| try f.watch(loop, wakeFs);
try vx.queryTerminalSend(tty.writer());
@@ -726,11 +784,20 @@ fn localSession(
};
if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, loop }) catch null;
+ // A terminal unlinks what it reads; whatever it never read (the probe
+ // unanswered, a transmission lost with the terminal) is ours to remove.
+ defer {
+ var name_buf: [64]u8 = undefined;
+ var n: u32 = 0;
+ while (n <= kitty_shm_serial) : (n += 1) {
+ if (kittyShmName(&name_buf, n)) |name| _ = libc.shm_unlink(name) else |_| {}
+ }
+ }
frames: while (!core.quit) {
pardes.turn.restoreSettled();
try core.pump(host);
if (core.takeRestore()) |rp| blk: {
- const bytes = filesystem.readRestore(gpa, rp) catch |err| {
+ const bytes = filesystem.readRestore(gpa, rp, core.settings.dump_dir.get()) catch |err| {
core.reportError(core.active, "Restore", err);
break :blk;
};
@@ -872,29 +939,39 @@ const Shell = struct {
fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
const s = of(ctx);
var batch: usize = 0;
- // The wait is the 9P connections' turn with the core.
- if (timeout_ms == 0) {
- pardes.turn.rest();
- const first = s.loop.nextEvent() catch {
- pardes.turn.wake();
- s.core.quit = true;
- return s.reloadWatched();
- };
+ // The wait is the 9P connections' turn with the core. Animating, the
+ // frame's tick arrives through the same queue (`tickWatch`).
+ if (timeout_ms != 0) tick_request.set(s.io);
+ pardes.turn.rest();
+ const first = s.loop.nextEvent() catch {
pardes.turn.wake();
- batch = 1;
- if (s.apply(first)) return s.reloadWatched();
- } else {
- pardes.turn.rest();
- std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) catch {};
- pardes.turn.wake();
- _ = s.apply(.tick);
- batch = 1;
- }
- while (s.in_paste or batch < 64) {
- const ev = (s.loop.tryEvent() catch null) orelse break;
+ s.core.quit = true;
+ return s.reloadWatched();
+ };
+ pardes.turn.wake();
+ batch = 1;
+ // A run of queued motions is one move to its last cell: each would
+ // otherwise redo hover and selection work the next one undoes.
+ var motion: ?@TypeOf(Command.value) = null;
+ var ev = first;
+ while (true) {
+ if (ev == .mouse and (ev.mouse.type == .motion or ev.mouse.type == .drag)) {
+ if (motion) |m| if (m.mouse.button != ev.mouse.button or m.mouse.type != ev.mouse.type or
+ @as(u3, @bitCast(m.mouse.mods)) != @as(u3, @bitCast(ev.mouse.mods)))
+ {
+ _ = s.apply(m);
+ };
+ motion = ev;
+ } else {
+ if (motion) |m| _ = s.apply(m);
+ motion = null;
+ if (s.apply(ev)) break;
+ }
+ if (!s.in_paste and batch >= 64) break;
+ ev = (s.loop.tryEvent() catch null) orelse break;
batch += 1;
- if (s.apply(ev)) break;
}
+ if (motion) |m| _ = s.apply(m);
s.reloadWatched();
}
@@ -1001,6 +1078,7 @@ const Shell = struct {
fn pollFrame(ctx: ?*anyopaque) void {
const s = of(ctx);
+ s.core.native_image_patches = kitty_shm.load(.acquire);
if (s.fs) |f| if (f.tick().pending) {
_ = s.loop.tryPostEvent(.fs_ready) catch {};
};
@@ -1013,6 +1091,7 @@ const Shell = struct {
s.caps_pending = false;
s.vx.enableDetectedFeatures(s.tty.writer()) catch {};
s.core.native_images = s.vx.caps.kitty_graphics;
+ if (s.vx.caps.kitty_graphics) kittyShmProbe(s.tty.writer());
s.vx.queueRefresh();
}
}
@@ -1039,6 +1118,16 @@ const Shell = struct {
if (!kittyImageRepresentable(place)) continue;
}
const key = place.cacheKey();
+ if (!s.kitty.contains(key) and vx.caps.kitty_graphics and kitty_shm.load(.acquire)) shm: {
+ var buf: [64]u8 = undefined;
+ kitty_shm_serial +%= 1;
+ const name = kittyShmName(&buf, kitty_shm_serial) catch break :shm;
+ if (!kittyShmWrite(name, place.rgba)) break :shm;
+ if (vx.transmitLocalImagePath(s.gpa, s.tty.writer(), name, @intCast(place.iw), @intCast(place.ih), .shared_mem, .rgba) catch null) |handle|
+ s.kitty.put(key, handle) catch vx.freeImage(s.tty.writer(), handle.id)
+ else
+ _ = libc.shm_unlink(name);
+ }
if (!s.kitty.contains(key) and vx.caps.kitty_graphics) {
const enc = std.base64.standard.Encoder;
if (s.gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| {
@@ -1160,7 +1249,7 @@ const Shell = struct {
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const s = of(ctx);
var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf) orelse return;
+ const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return;
filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err);
s.core.setLastDump(path);
}
@@ -1415,6 +1504,21 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void {
}
}
+/// Set by an animating frame's wait: post one `.tick` a frame from now. The
+/// tick joins the input queue, so a key or a click that lands inside the
+/// frame is handled at once instead of after a blind sleep.
+var tick_request: std.Io.Event = .unset;
+
+fn tickWatch(io: std.Io, loop: *Loop) void {
+ while (true) {
+ tick_request.waitUncancelable(io);
+ tick_request.reset();
+ var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.layout.Animation.frame_ms) * std.time.ns_per_ms };
+ _ = libc.nanosleep(&ts, null);
+ loop.postEvent(.tick) catch {};
+ }
+}
+
fn wakeFs(ctx: ?*anyopaque) void {
const loop: *Loop = @ptrCast(@alignCast(ctx.?));
_ = loop.tryPostEvent(.fs_ready) catch {};
diff --git a/test/output.zig b/test/output.zig
index ad33954f..7c80e105 100644
--- a/test/output.zig
+++ b/test/output.zig
@@ -1089,7 +1089,8 @@ test "identical result refresh keeps warm content caches without allocation" {
try std.testing.expectEqual(revision + 1, file.revision);
try std.testing.expectEqualStrings("short\n", file.content);
try std.testing.expectEqualStrings("changed", file.output.?.arg());
- try std.testing.expectEqual(@as(usize, 0), file.line_starts.len);
+ // The warm index is carried through the edit, not dropped.
+ try std.testing.expectEqualSlices(usize, &.{ 0, 6 }, file.line_starts);
try std.testing.expectEqual(@as(usize, 0), file.highlights.len);
try std.testing.expect(file.syntax_dirty);
try std.testing.expect(!result.vsel.active and !result.select);
diff --git a/test/panes.zig b/test/panes.zig
index 8e71e69d..2d3aed43 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -394,6 +394,42 @@ const BodyHitTests = struct {
}
};
+const LineNumberLookTests = struct {
+ test "right-click on a line number looks at that line, sticky context headers included" {
+ if (!syntax.enabled) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 64, .rows = 18 });
+ defer p.deinit();
+ p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 8, .tagline_h = 10 };
+ const pane = try TreeContextTests.setZigFile(p, "const Box = struct {\n pub fn work(\n value: usize,\n ) void {\n" ++ " // body\n" ** 60 ++ " }\n};\n");
+ try std.testing.expect(p.executeBuiltinLine(0, "TreeContext"));
+ pane.scrollBy(30);
+ var frame = std.heap.ArenaAllocator.init(p.gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const layer = &p.surface.body_layers[0];
+ try std.testing.expect(layer.context_rows >= 2);
+ // A header row's number is its declaration's own source line, not the
+ // body line it sits over (`pub fn work(` is line 1); a body row's is
+ // its own.
+ try std.testing.expectEqual(@as(i32, 1), pane.wrapAt(1).line);
+ const rows = [_]u16{ 1, pane.context_rows + 3 };
+ for (rows) |row| {
+ // Each Look recentres the view; come back under the headers.
+ pane.scrollBy(30 - pane.scroll());
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const expected = pane.wrapAt(row).line;
+ var press = BodyHitTests.mouse(&p.surface.body_layers[0], row, 1, .press);
+ press.button = .right;
+ var release = BodyHitTests.mouse(&p.surface.body_layers[0], row, 1, .release);
+ release.button = .right;
+ p.update(.{ .mouse = press });
+ p.update(.{ .mouse = release });
+ try std.testing.expectEqual(expected, pane.cur_row);
+ }
+ }
+};
+
const TreeContextTests = struct {
const source = "const Box = struct {\n" ++
" pub fn work(\n" ++
@@ -4069,6 +4105,7 @@ test {
_ = TagNameTintTests;
_ = TreeContextTests;
_ = BodyHitTests;
+ _ = LineNumberLookTests;
_ = TtySelectionTests;
_ = ClickCursorTests;
_ = JumpSelectionTests;
diff --git a/test/pdf_pointer_bench.zig b/test/pdf_pointer_bench.zig
new file mode 100644
index 00000000..f1d61229
--- /dev/null
+++ b/test/pdf_pointer_bench.zig
@@ -0,0 +1,224 @@
+//! Pointer gestures over a native PDF pane: hovering words (the Look preview
+//! highlight), and dragging a selection to its release.
+//!
+//! zig build pdf-pointer-bench -Doptimize=ReleaseFast -- [--label L] [--reps N] [--path FILE]
+//! [--cell WxH] [--scroll NOTCHES] [--verify 1] [--live 1]
+//!
+//! Every frame is timed the way a shell pump spends it: the event through
+//! `Pardes.update`, one `render()` into a reset arena, the effect drain. A
+//! hover step is its motion frame plus every tick frame the preview animates
+//! before settling, which is when the highlight lands. Stdout is CSV,
+//! `label,scenario:kind,round,ns`, one row per frame and one per step
+//! (`kind=step`), for an external statistics pass.
+//!
+//! `visual` hashes every presented raster in full, with its placement, on the
+//! frame each step settles on. It is the no-regression proof: a change to how highlights
+//! are painted may change what it costs, never one presented pixel.
+const std = @import("std");
+const pardes = @import("pardes");
+const pdf = @import("mupdf");
+
+pub const std_options: std.Options = .{ .log_level = .err };
+
+const gpa = std.heap.smp_allocator;
+const cols: u16 = 120;
+const rows: u16 = 40;
+
+const Config = struct {
+ path: []const u8 = "docs/design.pdf",
+ label: []const u8 = "run",
+ reps: usize = 5,
+ /// Wheel notches before the gestures, off the sparse title page.
+ scroll: usize = 40,
+ /// 8x16 is the Kitty policy's cell; the SDL shell's is closer to 16x35.
+ cell_w: u16 = 8,
+ cell_h: u16 = 16,
+ /// Re-render every raster that claims to hold the current highlights and
+ /// compare it row by row: exactness without a baseline build.
+ verify: bool = false,
+ /// A shell that takes row patches: selections repaint while dragged.
+ live: bool = false,
+};
+
+pub fn main(init: std.process.Init) !void {
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ var config: Config = .{};
+ var i: usize = 1;
+ while (i + 1 < args.len) : (i += 2) {
+ const value = args[i + 1];
+ if (std.mem.eql(u8, args[i], "--path")) config.path = value else if (std.mem.eql(u8, args[i], "--label")) config.label = value else if (std.mem.eql(u8, args[i], "--reps")) config.reps = try std.fmt.parseUnsigned(usize, value, 10) else if (std.mem.eql(u8, args[i], "--scroll")) config.scroll = try std.fmt.parseUnsigned(usize, value, 10) else if (std.mem.eql(u8, args[i], "--verify")) config.verify = std.mem.eql(u8, value, "1") else if (std.mem.eql(u8, args[i], "--live")) config.live = std.mem.eql(u8, value, "1") else if (std.mem.eql(u8, args[i], "--cell")) {
+ var it = std.mem.splitScalar(u8, value, 'x');
+ config.cell_w = try std.fmt.parseUnsigned(u16, it.next() orelse "", 10);
+ config.cell_h = try std.fmt.parseUnsigned(u16, it.next() orelse "", 10);
+ } else return error.UnknownOption;
+ }
+ if (i != args.len) return error.MissingValue;
+
+ var visual: ?u64 = null;
+ for (0..config.reps) |round| {
+ const got = try measure(config, round);
+ if (visual) |want| if (want != got) {
+ std.debug.print("pdf-pointer-bench: visual changed between rounds: {x} vs {x}\n", .{ want, got });
+ return error.Unstable;
+ };
+ visual = got;
+ }
+ std.debug.print("pdf-pointer-bench {s}: visual={x:0>16}\n", .{ config.label, visual.? });
+}
+
+const Run = struct {
+ core: *pardes.Pardes,
+ arena: std.heap.ArenaAllocator,
+ config: Config,
+ round: usize,
+ visual: u64 = 0x7061_7264_6573_5050,
+ step_ns: u64 = 0,
+ frames: usize = 0,
+ steps: usize = 0,
+ last: ?*const pardes.Surface = null,
+
+ fn frame(run: *Run, scenario: []const u8, kind: []const u8, event: pardes.Event) !void {
+ const started = nowNs();
+ run.core.update(event);
+ while (run.core.nextEffect()) |_| {}
+ _ = run.arena.reset(.retain_capacity);
+ const surface = try run.core.render(run.arena.allocator());
+ while (run.core.nextEffect()) |_| {}
+ const elapsed = nowNs() - started;
+ run.step_ns += elapsed;
+ run.frames += 1;
+ print("{s},{s}:{s},{d},{d}\n", .{ run.config.label, scenario, kind, run.round, elapsed });
+ run.last = surface;
+ if (run.config.verify) try verify(run.core, run.arena.allocator());
+ }
+
+ /// The step, then the tick frames its animation asks for (a hover's
+ /// preview arrives on one of them), as a pump with nothing else queued.
+ fn step(run: *Run, scenario: []const u8, kind: []const u8, event: pardes.Event) !void {
+ run.step_ns = 0;
+ try run.frame(scenario, kind, event);
+ var ticks: usize = 0;
+ while (run.core.animationActive() and ticks < 32) : (ticks += 1)
+ try run.frame(scenario, "tick", .tick);
+ print("{s},{s}:step,{d},{d}\n", .{ run.config.label, scenario, run.round, run.step_ns });
+ // Only the settled frame is the invariant: how many frames a preview
+ // takes to arrive is a setting (look_preview_delay_frames).
+ run.observe(run.last.?);
+ run.steps += 1;
+ }
+
+ fn observe(run: *Run, surface: *const pardes.Surface) void {
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ run.visual = mix(run.visual, [_]u64{ place.pane, place.x, place.y, place.w, place.h, place.native.page, place.iw, place.ih });
+ if (place.native.geometry) |g| run.visual = mix(run.visual, [_]u64{ g.src.x, g.src.y, g.src.w, g.src.h, g.dst.x, g.dst.y, g.dst.w, g.dst.h });
+ run.visual = std.hash.Wyhash.hash(run.visual, place.rgba);
+ }
+ }
+};
+
+var verify_failures: usize = 0;
+
+fn verify(core: *pardes.Pardes, arena: std.mem.Allocator) !void {
+ const Pdf = pardes.panes.Pdf;
+ const pane = core.panes[0] orelse return;
+ const state = &(pane.pdf orelse return);
+ const highlights = try Pdf.buildHighlights(state, arena, Pdf.highlightInput(core, 0, pane));
+ for (state.rasters[0..state.rasters_len]) |*r| {
+ if (!r.valid or r.rgba.len == 0 or r.tint_key == null) continue;
+ const want = highlights.forPage(r.page);
+ if (comptime !@hasField(@TypeOf(r.*), "baked")) return else if (!std.mem.eql(u8, std.mem.sliceAsBytes(want), std.mem.sliceAsBytes(r.baked))) continue;
+ const shape = try state.document.measureRenderAt(r.page, r.request);
+ // The oracle is the WHOLE page: a band's own edge rows can differ.
+ const whole = try arena.alloc(u8, shape.len);
+ try state.document.renderIntoAtWithPaper(r.page, r.request, shape, shape.wholePage(), want, whole, false);
+ try pdf.tintRgba(whole, r.tint_key.?.mode, r.tint_key.?.colors);
+ const fresh = whole[r.band_y * shape.stride ..][0 .. r.band_h * shape.stride];
+ var first: ?usize = null;
+ var last: usize = 0;
+ var count: usize = 0;
+ for (0..r.band_h) |y| {
+ const row = fresh[y * shape.stride ..][0..shape.stride];
+ if (std.mem.eql(u8, row, r.rgba[y * shape.stride ..][0..shape.stride])) continue;
+ if (first == null) first = y;
+ last = y;
+ count += 1;
+ }
+ if (first) |f| {
+ verify_failures += 1;
+ if (verify_failures <= 20) std.debug.print("verify: page {d} rows {d}..{d} ({d} differ), highlights {d}, band {d}+{d}\n", .{ r.page, f + r.band_y, last + r.band_y, count, want.len, r.band_y, r.band_h });
+ }
+ }
+}
+
+fn measure(config: Config, round: usize) !u64 {
+ const core = try pardes.Pardes.init(gpa, .{ .file = config.path, .cols = cols, .rows = rows });
+ defer core.deinit();
+ core.native_images = true;
+ if (comptime @hasField(pardes.Pardes, "native_image_patches")) core.native_image_patches = config.live;
+ var run: Run = .{ .core = core, .arena = .init(gpa), .config = config, .round = round };
+ defer run.arena.deinit();
+ const cell: pardes.CellPixels = .{ .w = config.cell_w, .h = config.cell_h };
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = cell } });
+ _ = try core.render(run.arena.allocator());
+ const pane = core.panes[0] orelse return error.NoPane;
+ if (pane.pdf == null) return error.NotPdf;
+ const rect = core.rects[0];
+ const mid_col = rect.x + rect.w / 2;
+ const mid_row = rect.y + rect.h / 2;
+ for (0..config.scroll) |_| {
+ core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mid_col, .row = mid_row } });
+ _ = run.arena.reset(.retain_capacity);
+ _ = try core.render(run.arena.allocator());
+ while (core.nextEffect()) |_| {}
+ }
+
+ const top = rect.y + 4;
+ const bottom = rect.y + rect.h -| 3;
+ const left = rect.x + 4;
+ const right = rect.x + rect.w -| 4;
+
+ // Hover: walk four rows of the body, a word-ish stride at a time.
+ var row: u16 = top;
+ while (row < bottom) : (row += (bottom - top) / 4) {
+ var col: u16 = left;
+ while (col < right) : (col += 3)
+ try run.step("hover", "motion", .{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } });
+ }
+ // Leave the page so the last preview is withdrawn like a real exit.
+ try run.step("hover", "leave", .pointer_leave);
+
+ // Select: press, drag across three lines, release; five times down the page.
+ row = top;
+ while (row + 3 < bottom) : (row += (bottom - top) / 5) {
+ try run.step("select", "press", .{ .mouse = .{ .button = .left, .kind = .press, .col = left, .row = row } });
+ var n: u16 = 1;
+ while (n <= 24) : (n += 1) {
+ const col = left + (right - left) * (n % 8) / 8;
+ try run.step("select", "drag", .{ .mouse = .{ .button = .left, .kind = .drag, .col = col, .row = row + n / 8 } });
+ }
+ try run.step("select", "release", .{ .mouse = .{ .button = .left, .kind = .release, .col = right, .row = row + 3 } });
+ }
+ return mix(run.visual, @as(u64, run.steps));
+}
+
+fn print(comptime format: []const u8, args: anytype) void {
+ var buf: [256]u8 = undefined;
+ const line = std.fmt.bufPrint(&buf, format, args) catch return;
+ _ = std.c.write(1, line.ptr, line.len);
+}
+
+fn mix(seed: u64, value: anytype) u64 {
+ var copy = value;
+ return std.hash.Wyhash.hash(seed, std.mem.asBytes(&copy));
+}
+
+fn nowNs() u64 {
+ var ts: std.c.timespec = undefined;
+ _ = std.c.clock_gettime(.MONOTONIC, &ts);
+ return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
+}
+
+comptime {
+ _ = pdf.Document;
+}
diff --git a/test/snapshot.zig b/test/snapshot.zig
index 7cc5eb91..bdabb56e 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -569,7 +569,9 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
try mkdir(arena, config_home, false);
const pardes_config = try std.fmt.allocPrint(arena, "{s}/pardes", .{config_home});
try mkdir(arena, pardes_config, false);
- try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/init", .{pardes_config}, 0), "Shell bash\n");
+ // Messages leave the moment they are dismissed, as goldens have always
+ // recorded: a linger or a dissolve is time, and time cannot be a golden.
+ try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/init", .{pardes_config}, 0), "Shell bash\nMessageAnimation off\nMessageLinger 0\n");
_ = setenv("TERM", "xterm-256color", 1);
_ = setenv("LC_ALL", "C", 1);
// a wall clock cannot live in a golden; everything that would print one
diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap
index cd47209f..02547b91 100644
--- a/test/snapshots/builtins.snap
+++ b/test/snapshots/builtins.snap
@@ -19,6 +19,10 @@ wheel up 110 50
wheel up 110 50
wheel up 110 50
wheel up 110 50
+wheel up 110 50
+wheel up 110 50
+wheel up 110 50
+wheel up 110 50
wait 10000 Look enter, right-click
wait 10000 Exec tab, middle-click
wait 10000 Filter
diff --git a/test/snapshots/dump.golden b/test/snapshots/dump.golden
index 8f920136..e466dbf1 100644
--- a/test/snapshots/dump.golden
+++ b/test/snapshots/dump.golden
@@ -38,6 +38,7 @@
.cwd = "/tmp/pardes-snap/dump/cwd",
.stream = "ls\nd.txt\n",
.stream_b64 = <elided>
+ .stream_is_screen = true,
.cursor = .{ .col = 2, .row = 2 },
},
.file = null,
@@ -57,6 +58,7 @@
.cwd = "/tmp/pardes-snap/dump/cwd",
.stream = "ls\nd.txt\n",
.stream_b64 = <elided>
+ .stream_is_screen = true,
.cursor = .{ .col = 2, .row = 2 },
},
.file = null,
@@ -76,6 +78,7 @@
.cwd = "/tmp/pardes-snap/dump/cwd",
.stream = "ls\nd.txt\n",
.stream_b64 = <elided>
+ .stream_is_screen = true,
.cursor = .{ .col = 2, .row = 2 },
},
.file = null,
diff --git a/test/snapshots/load.golden b/test/snapshots/load.golden
index 994ab83e..32024cec 100644
--- a/test/snapshots/load.golden
+++ b/test/snapshots/load.golden
@@ -2,10 +2,10 @@
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/load/cwd Tty Save Mode Filter /tmp/pardes-snap/load/cwd Tty Save Mode Filter
-| echo LOAD''MARK; seq 1 40; echo E''ND ls
+| $ echo LOAD''MARK; seq 1 40; echo E''ND $ ls
| LOADMARK d.txt
-| 1
-| 2
+| 1 $
+| 2 ── restored history ──
| 3
| 4
| 5
@@ -17,8 +17,10 @@
| 11
| 12
| /tmp/pardes-snap/load/cwd Tty Save Mode Filter
-| ls
+| $ ls
| d.txt
+| $
+| ── restored history ──
== style loaded grid=100x31
|0: 0-70 #b2c3b8,#1d2824, 71-99 d,#1d2824,
|1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-49 d,#26362d, 50-51 #ffffff,#887455, 52-52 d,#1d2824, 53-84 #b2c3b8,#1d2824, 85-99 d,#1d2824,
@@ -26,8 +28,8 @@
|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
|4: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
|5: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
-|6: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
-|7: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|6: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-73 #e5eee5,#0d1410,f 74-99 #e5eee5,#0d1410,
+|7: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-99 d,#0d1410,
|8: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
|9: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
|10: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
@@ -41,8 +43,8 @@
|18: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
|19: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
|20: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
-|21: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
-|22: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|21: 0-0 d,#608269, 1-1 d,#0d1410, 2-23 #e5eee5,#0d1410,f 24-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|22: 0-0 d,#608269, 1-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
|23: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
|24: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
|25: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
diff --git a/test/snapshots/restore.golden b/test/snapshots/restore.golden
index 11d92288..01b52984 100644
--- a/test/snapshots/restore.golden
+++ b/test/snapshots/restore.golden
@@ -6,9 +6,7 @@
| $ echo KEEP''ME
| KEEPME
| $
-== snap restored grid=100x31 cursor=4,6
+== snap restored grid=100x31 cursor=4,8
|0: Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
|2: /tmp/pardes-snap/restore/cwd Tty Save Mode Filter Collapse Del
-|3: ls
-|4: echo KEEP''ME
-|6:
+|7: ── restored history ──