summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-14 13:40:29 -0300
committerGabriel Schneider <[email protected]>2026-08-15 11:57:13 -0300
commitf67fec978a9296c651ec06bd2f43686d34ff86ee (patch)
treecbf5568883e5888398e0887093fc5afc524fd54d /test
parent9280c597b000eed661fd98793e182fdcb640f6cd (diff)
downloadpardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.tar.gz
pardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.zip
look: richer path/range parsing, pdf rendering, corner-drag and stepgrain snapshots
Diffstat (limited to 'test')
-rw-r--r--test/pdf_scroll_bench.zig567
-rw-r--r--test/snapshots/cornerdrag-left.golden155
-rw-r--r--test/snapshots/cornerdrag-left.snap41
-rw-r--r--test/snapshots/grep.golden2
-rw-r--r--test/snapshots/grep.snap13
-rw-r--r--test/snapshots/jumps.snap35
-rw-r--r--test/snapshots/stepgrain.golden310
-rw-r--r--test/snapshots/stepgrain.snap77
-rw-r--r--test/snapshots/ttytaken.golden124
-rw-r--r--test/snapshots/ttytaken.snap43
10 files changed, 1341 insertions, 26 deletions
diff --git a/test/pdf_scroll_bench.zig b/test/pdf_scroll_bench.zig
new file mode 100644
index 00000000..7bd644e4
--- /dev/null
+++ b/test/pdf_scroll_bench.zig
@@ -0,0 +1,567 @@
+//! ReleaseFast scoreboard for FAST scrolling of the continuous PDF strip.
+//!
+//! zig build pdf-scroll-bench -Doptimize=ReleaseFast
+//! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --json
+//! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --warmup 1 --reps 15
+//! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --path other.pdf
+//!
+//! What a fling IS, and why one-tick-per-frame benchmarks miss it: a shell
+//! pump drains every input the OS queued since the last present and then calls
+//! render() once. A trackpad fling therefore arrives as a BATCH of wheel
+//! notches inside one frame, and the frame budget is spent on the batch plus
+//! the single render — not on one notch. Every timed region below is exactly
+//! that: `ticks_per_frame` real `Mouse.wheel_down`/`wheel_up` presses through
+//! `Pardes.update`, one `render()` into a reset frame arena, and the effect
+//! drain, which is the loop src/tty/tty.zig and src/gui/gui.zig both run.
+//!
+//! Rows are per-FRAME latency, not per-notch: a dropped frame is what a reader
+//! feels, and the batch is indivisible from the reader's point of view.
+//!
+//! Two checksums, because they answer two different questions:
+//!
+//! `visual` is the no-regression proof. Frame count, emitted image count,
+//! every placement rectangle, the presented RGBA (strided every
+//! frame, in full every `full_rgba_stride`-th frame), and the
+//! final scroll offset and page. Any optimization that changes
+//! ONE presented pixel or ONE placement changes this. It must be
+//! bit-identical across builds.
+//! `transport` is the texture-upload trace: the (page, raster generation)
+//! pair per emitted image, with generations rebased on the
+//! counter's value at run start. Caching work legitimately
+//! changes this while `visual` stays fixed — reusing a retained
+//! raster keeps its old generation, which is precisely the
+//! retransmit the revision mechanism exists to suppress.
+//!
+//! Setup, teardown, checksumming, and reporting are outside every timer.
+const std = @import("std");
+const libc = std.c;
+const pardes = @import("pardes");
+const pdf = @import("mupdf");
+const bench_config = @import("pdf_scroll_bench_config");
+
+pub const std_options: std.Options = .{ .log_level = .err };
+
+const gpa = std.heap.smp_allocator;
+
+// A reading-sized window. Wide enough that fit-width pages are taller than the
+// viewport (so a fling genuinely crosses page boundaries) and small enough
+// that the Kitty raster policy's 1200px cap is the binding quality term, which
+// is what the tty shell actually ships.
+const bench_cols: u16 = 120;
+const bench_rows: u16 = 40;
+const bench_cell_pixels: pardes.CellPixels = .{ .w = 8, .h = 16 };
+
+/// config.wheel_rows is one cell per notch, so a notch is `cell_pixels.h`
+/// display pixels. 192 notches is ~3072px — between two and three fit-width
+/// pages of travel inside a single frame, i.e. a hard flick whose momentum the
+/// OS delivers as one burst. This is the number that makes the benchmark about
+/// flinging rather than about nudging.
+const fling_ticks_per_frame: usize = 192;
+const slow_ticks_per_frame: usize = 1;
+/// The control row is a fixed wall of frames rather than a whole-document
+/// traversal: one notch per frame over a real document is tens of thousands of
+/// frames, and 240 already crosses several page boundaries at reading speed.
+const slow_frames: usize = 240;
+/// Guard against a pathological document turning one row into a multi-minute
+/// run. Deterministic either way: the frame count is derived from layout.
+const max_frames: usize = 2048;
+/// Full-RGBA hashing is O(document) per frame, so it samples the run instead of
+/// every frame; the strided hash below runs on every frame and every image.
+const full_rgba_stride: usize = 8;
+const checksum_seed: u64 = 0x7061_7264_6573_5343;
+
+const Config = struct {
+ path: []const u8 = "docs/design.pdf",
+ json: bool = false,
+ warmup: usize = 2,
+ reps: usize = 25,
+};
+
+/// The bit-exact description of one scrolling run. `visual` is the invariant;
+/// see the file header for why `transport` is reported beside it rather than
+/// folded into it.
+const Identity = struct {
+ frames: u64,
+ images: u64,
+ visual: u64,
+ transport: u64,
+
+ fn eql(a: Identity, b: Identity) bool {
+ return a.frames == b.frames and a.images == b.images and
+ a.visual == b.visual and a.transport == b.transport;
+ }
+};
+
+const Result = struct {
+ name: []const u8,
+ ticks_per_frame: usize,
+ frames: usize,
+ min_ns: u64,
+ median_ns: u64,
+ p90_ns: u64,
+ p99_ns: u64,
+ max_ns: u64,
+ total_ns: u64,
+ rasterizations: u64,
+ rasters_retained: u64,
+ identity: Identity,
+};
+
+const Scenario = enum {
+ /// Sustained high-velocity travel from page zero to the last page.
+ fling_down,
+ /// Down to the end and straight back up. Worst case for any cache that
+ /// only retains what the current frame shows: the way back finds nothing.
+ fling_reverse,
+ /// One notch per frame. The control: it must not regress.
+ slow_scroll,
+
+ fn label(scenario: Scenario) []const u8 {
+ return switch (scenario) {
+ .fling_down => "fling_down",
+ .fling_reverse => "fling_reverse",
+ .slow_scroll => "slow_scroll",
+ };
+ }
+
+ fn ticks(scenario: Scenario) usize {
+ return switch (scenario) {
+ .fling_down, .fling_reverse => fling_ticks_per_frame,
+ .slow_scroll => slow_ticks_per_frame,
+ };
+ }
+};
+
+const Plan = struct {
+ scenario: Scenario,
+ frames: usize,
+ /// Frames of downward travel before `fling_reverse` turns around.
+ down_frames: usize,
+
+ fn button(plan: Plan, frame: usize) pardes.Mouse.Button {
+ return switch (plan.scenario) {
+ .fling_reverse => if (frame < plan.down_frames) .wheel_down else .wheel_up,
+ .fling_down, .slow_scroll => .wheel_down,
+ };
+ }
+};
+
+pub fn main(init: std.process.Init) !void {
+ if (!bench_config.release_fast_core)
+ fatal("the production core must be ReleaseFast; add -Doptimize=ReleaseFast", .{});
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ const config = parseArgs(args);
+
+ // One untimed probe boot supplies the strip height every plan is sized
+ // from, so the frame counts below are a property of the document and the
+ // viewport rather than of the machine.
+ const travel = probeTravel(config.path);
+
+ const results = [_]Result{
+ try measure(config, planFor(.fling_down, travel)),
+ try measure(config, planFor(.fling_reverse, travel)),
+ try measure(config, planFor(.slow_scroll, travel)),
+ };
+
+ if (config.json)
+ reportJson(init.io, config, &results)
+ else
+ reportText(config, &results);
+}
+
+fn parseArgs(args: []const []const u8) Config {
+ var config: Config = .{};
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ if (std.mem.eql(u8, args[i], "--json")) {
+ config.json = true;
+ } else if (std.mem.eql(u8, args[i], "--warmup") and i + 1 < args.len) {
+ i += 1;
+ config.warmup = parseCount("--warmup", args[i]);
+ } else if (std.mem.eql(u8, args[i], "--reps") and i + 1 < args.len) {
+ i += 1;
+ config.reps = parseCount("--reps", args[i]);
+ } else if (std.mem.eql(u8, args[i], "--path") and i + 1 < args.len) {
+ i += 1;
+ config.path = args[i];
+ } else fatal(
+ "usage: pardes-pdf-scroll-bench [--json] [--warmup N] [--reps N] [--path FILE]",
+ .{},
+ );
+ }
+ return config;
+}
+
+fn parseCount(flag: []const u8, text: []const u8) usize {
+ const value = std.fmt.parseUnsigned(usize, text, 10) catch
+ fatal("{s} expects a positive integer", .{flag});
+ if (value == 0 or value > 10_000)
+ fatal("{s} must be in 1..10000", .{flag});
+ return value;
+}
+
+const Travel = struct {
+ /// Scrollable display pixels: the whole strip minus one screenful.
+ max_scroll: f64,
+ /// Display pixels one fling frame's notch batch covers.
+ per_frame: f64,
+};
+
+fn probeTravel(path: []const u8) Travel {
+ const core = bootCore(path) catch |err|
+ fatal("cannot boot a PDF pane on {s}: {t}", .{ path, err });
+ defer core.deinit();
+ var frame_arena = std.heap.ArenaAllocator.init(gpa);
+ defer frame_arena.deinit();
+ _ = core.render(frame_arena.allocator()) catch |err|
+ fatal("first render failed: {t}", .{err});
+ drainEffects(core);
+ const pane = core.panes[0] orelse fatal("PDF pane disappeared", .{});
+ if (pane.pdf == null) fatal("{s} did not open as a PDF", .{path});
+ const pv = &pane.pdf.?;
+ if (pv.page_count < 4)
+ fatal("{s} has {d} pages; a fling benchmark needs at least 4", .{ path, pv.page_count });
+ if (!pv.layout_valid or pv.document_height == 0)
+ fatal("the pixel strip never laid out; is native_images/cell_pixels wired?", .{});
+ // Exactly the clamp scrollPdfDocument enforces, so the last fling frame
+ // lands on the document end instead of adding a no-op frame to the row.
+ const viewport_h: u64 = @as(u64, bench_rows -| pardes.BOX_H) * bench_cell_pixels.h;
+ const notch: f64 = @floatFromInt(bench_cell_pixels.h);
+ return .{
+ .max_scroll = @max(notch, @as(f64, @floatFromInt(pv.document_height -| viewport_h))),
+ .per_frame = notch * @as(f64, @floatFromInt(fling_ticks_per_frame)),
+ };
+}
+
+fn planFor(scenario: Scenario, travel: Travel) Plan {
+ const down = @min(
+ max_frames,
+ @as(usize, @intFromFloat(@ceil(travel.max_scroll / travel.per_frame))),
+ );
+ return switch (scenario) {
+ .fling_down => .{ .scenario = scenario, .frames = down, .down_frames = down },
+ .fling_reverse => .{ .scenario = scenario, .frames = down * 2, .down_frames = down },
+ .slow_scroll => .{ .scenario = scenario, .frames = slow_frames, .down_frames = slow_frames },
+ };
+}
+
+/// A pane on a real multi-page document with the native pixel-strip path live
+/// and a genuine pixel viewport delivered the way a shell delivers one.
+fn bootCore(path: []const u8) !*pardes.Pardes {
+ const core = try pardes.Pardes.init(gpa, .{
+ .file = path,
+ .cols = bench_cols,
+ .rows = bench_rows,
+ });
+ errdefer core.deinit();
+ core.native_images = true;
+ core.update(.{ .resize = .{
+ .cols = bench_cols,
+ .rows = bench_rows,
+ .cell_pixels = bench_cell_pixels,
+ } });
+ drainEffects(core);
+ return core;
+}
+
+fn measure(config: Config, plan: Plan) !Result {
+ const name = plan.scenario.label();
+ const ticks = plan.scenario.ticks();
+ const totals = try gpa.alloc(u64, config.reps);
+ defer gpa.free(totals);
+ const pool = try gpa.alloc(u64, config.reps * plan.frames);
+ defer gpa.free(pool);
+ var pool_len: usize = 0;
+ const recorder = try gpa.create(Recorder);
+ defer gpa.destroy(recorder);
+ var expected: ?Identity = null;
+ var rasterizations: u64 = 0;
+ var rasters_retained: u64 = 0;
+
+ for (0..config.warmup + config.reps) |round| {
+ const core = try bootCore(config.path);
+ defer core.deinit();
+ var frame_arena = std.heap.ArenaAllocator.init(gpa);
+ defer frame_arena.deinit();
+ // Untimed: the steady state a reader flings FROM already has page zero
+ // resident and the strip laid out.
+ _ = try core.render(frame_arena.allocator());
+ drainEffects(core);
+ const pane = core.panes[0] orelse fatal("PDF pane disappeared", .{});
+ const pv = &pane.pdf.?;
+ const rect = core.rects[0];
+ // Anywhere inside the pane body resolves to this pane's hover, which
+ // is what routes the notch; the exact cell is irrelevant to a wheel.
+ const wheel: pardes.Mouse = .{
+ .button = .wheel_down,
+ .kind = .press,
+ .col = rect.x + rect.w / 2,
+ .row = rect.y + rect.h / 2,
+ };
+ const base_revision = pv.next_raster_revision;
+ recorder.reset(base_revision);
+
+ var total: u64 = 0;
+ for (0..plan.frames) |frame| {
+ var event = wheel;
+ event.button = plan.button(frame);
+ const started = nowNs();
+ for (0..ticks) |_| core.update(.{ .mouse = event });
+ _ = frame_arena.reset(.retain_capacity);
+ const surface = core.render(frame_arena.allocator()) catch |err|
+ fatal("render failed in {s} frame {d}: {t}", .{ name, frame, err });
+ drainEffects(core);
+ const elapsed = nowNs() -| started;
+ total += elapsed;
+ recorder.observe(frame, surface);
+ if (round >= config.warmup) {
+ pool[pool_len] = @max(1, elapsed);
+ pool_len += 1;
+ }
+ }
+
+ const identity = recorder.finish(pv);
+ try verifyIdentity(name, &expected, identity, round);
+ if (round >= config.warmup) {
+ totals[round - config.warmup] = total;
+ rasterizations = pv.next_raster_revision -% base_revision;
+ rasters_retained = pv.rasters_len;
+ }
+ }
+
+ std.debug.assert(pool_len == pool.len);
+ std.mem.sort(u64, pool, {}, std.sort.asc(u64));
+ std.mem.sort(u64, totals, {}, std.sort.asc(u64));
+ return .{
+ .name = name,
+ .ticks_per_frame = ticks,
+ .frames = plan.frames,
+ .min_ns = pool[0],
+ .median_ns = pool[pool.len / 2],
+ .p90_ns = pool[(pool.len * 9) / 10 -| 1],
+ .p99_ns = pool[(pool.len * 99) / 100 -| 1],
+ .max_ns = pool[pool.len - 1],
+ .total_ns = totals[totals.len / 2],
+ .rasterizations = rasterizations,
+ .rasters_retained = rasters_retained,
+ .identity = expected.?,
+ };
+}
+
+/// Accumulates the two checksums across one run. Raster generations are
+/// rebased on the counter's value at run start so the trace describes the
+/// ORDER textures were produced in rather than absolute counter values, which
+/// depend on how much the untimed boot happened to rasterize.
+const Recorder = struct {
+ frames: u64,
+ images: u64,
+ visual: u64,
+ transport: u64,
+ base_revision: u32,
+
+ fn reset(self: *Recorder, base_revision: u32) void {
+ self.* = .{
+ .frames = 0,
+ .images = 0,
+ .visual = checksum_seed,
+ .transport = checksum_seed,
+ .base_revision = base_revision,
+ };
+ }
+
+ fn observe(self: *Recorder, frame: usize, surface: *const pardes.Surface) void {
+ self.frames += 1;
+ self.visual = mix(self.visual, surface.nimages);
+ self.transport = mix(self.transport, surface.nimages);
+ const full = frame % full_rgba_stride == 0;
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse {
+ self.visual = mix(self.visual, std.math.maxInt(u64));
+ continue;
+ };
+ self.images += 1;
+ self.visual = mix(self.visual, place.pane);
+ self.visual = mix(self.visual, place.x);
+ self.visual = mix(self.visual, place.y);
+ self.visual = mix(self.visual, place.w);
+ self.visual = mix(self.visual, place.h);
+ self.visual = mix(self.visual, place.iw);
+ self.visual = mix(self.visual, place.ih);
+ self.visual = mix(self.visual, place.rgba.len);
+ self.visual = mix(self.visual, place.native.page);
+ self.visual = mix(self.visual, @intFromEnum(place.native.fit));
+ self.visual = mix(self.visual, place.native.pan_x);
+ self.visual = mix(self.visual, place.native.pan_y);
+ self.visual = mix(self.visual, @as(u32, @bitCast(place.native.pixel_offset_y)));
+ if (place.native.geometry) |geometry| {
+ inline for (.{ geometry.src, geometry.dst }) |rect| {
+ self.visual = mix(self.visual, rect.x);
+ self.visual = mix(self.visual, rect.y);
+ self.visual = mix(self.visual, rect.w);
+ self.visual = mix(self.visual, rect.h);
+ }
+ } else self.visual = mix(self.visual, std.math.maxInt(u64));
+ self.visual = if (full)
+ std.hash.Wyhash.hash(self.visual, place.rgba)
+ else
+ stridedHash(self.visual, place.rgba);
+ self.transport = mix(self.transport, place.native.page);
+ self.transport = mix(self.transport, place.native.revision -% self.base_revision);
+ }
+ }
+
+ fn finish(self: *Recorder, pv: anytype) Identity {
+ var visual = mix(self.visual, @as(u64, @bitCast(pv.document_scroll_y)));
+ visual = mix(visual, pv.page);
+ visual = mix(visual, pv.page_count);
+ visual = mix(visual, pv.document_height);
+ return .{
+ .frames = self.frames,
+ .images = self.images,
+ .visual = visual,
+ .transport = self.transport,
+ };
+ }
+};
+
+/// 256 fixed-stride RGBA words. Cheap enough for every image of every frame
+/// while still failing on any change that touches a band of the page, and the
+/// full hash above closes the gap on sampled frames.
+fn stridedHash(seed: u64, rgba: []const u8) u64 {
+ if (rgba.len < 4) return mix(seed, rgba.len);
+ const stride = @max(@as(usize, 4), (rgba.len / 256) & ~@as(usize, 3));
+ var hash = seed;
+ var at: usize = 0;
+ while (at + 4 <= rgba.len) : (at += stride)
+ hash = std.hash.Wyhash.hash(hash, rgba[at..][0..4]);
+ return hash;
+}
+
+fn mix(seed: u64, value: anytype) u64 {
+ const Value = @TypeOf(value);
+ const Stable = switch (@typeInfo(Value)) {
+ .comptime_int => u64,
+ else => Value,
+ };
+ var stable: Stable = value;
+ return std.hash.Wyhash.hash(seed, std.mem.asBytes(&stable));
+}
+
+fn drainEffects(core: *pardes.Pardes) void {
+ while (core.nextEffect()) |_| {}
+}
+
+fn verifyIdentity(
+ name: []const u8,
+ expected: *?Identity,
+ got: Identity,
+ round: usize,
+) !void {
+ if (expected.*) |want| {
+ if (!want.eql(got)) {
+ std.debug.print(
+ "pdf-scroll-bench: unstable identity in {s} round {d}:\n" ++
+ " expected frames={d} images={d} visual={x:0>16} transport={x:0>16}\n" ++
+ " got frames={d} images={d} visual={x:0>16} transport={x:0>16}\n",
+ .{
+ name, round, want.frames, want.images,
+ want.visual, want.transport, got.frames, got.images,
+ got.visual, got.transport,
+ },
+ );
+ return error.UnstableIdentity;
+ }
+ } else expected.* = got;
+}
+
+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));
+}
+
+fn reportText(config: Config, results: []const Result) void {
+ std.debug.print("pardes PDF fast-scroll benchmark (ReleaseFast)\n", .{});
+ std.debug.print(
+ "warmup: {d}, sampled reps: {d}; every number is ONE FRAME (notch batch + render + drain)\n\n",
+ .{ config.warmup, config.reps },
+ );
+ std.debug.print("{s:<16} {s:>6} {s:>7} {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>11} {s:>7} {s:>5} {s:<16} {s}\n", .{
+ "scenario", "frames", "notches", "min",
+ "median", "p90", "p99", "max",
+ "total_us", "rasters", "kept", "visual",
+ "transport",
+ });
+ std.debug.print("{s}\n", .{"-" ** 160});
+ for (results) |result| std.debug.print(
+ "{s:<16} {d:>6} {d:>7} {d:>10} {d:>10} {d:>10} {d:>10} {d:>10} {d:>11} {d:>7} {d:>5} {x:0>16} {x:0>16}\n",
+ .{
+ result.name,
+ result.frames,
+ result.ticks_per_frame,
+ result.min_ns,
+ result.median_ns,
+ result.p90_ns,
+ result.p99_ns,
+ result.max_ns,
+ result.total_ns / 1000,
+ result.rasterizations,
+ result.rasters_retained,
+ result.identity.visual,
+ result.identity.transport,
+ },
+ );
+ std.debug.print(
+ "\n`rasters` counts MuPDF page rasterizations over one run; `kept` is the retained raster count at the end.\n" ++
+ "`visual` pins presented pixels and placement and must never change; `transport` traces texture uploads.\n",
+ .{},
+ );
+}
+
+const json_report_max_bytes = 16 * 1024;
+
+fn reportJson(io: std.Io, config: Config, results: []const Result) void {
+ var storage: [json_report_max_bytes]u8 = undefined;
+ var out: std.Io.Writer = .fixed(&storage);
+ out.print(
+ "{{\"benchmark\":\"pardes-pdf-scroll\",\"build\":\"ReleaseFast\",\"warmup\":{d},\"reps\":{d},\"results\":[",
+ .{ config.warmup, config.reps },
+ ) catch return;
+ for (results, 0..) |result, i| out.print(
+ "{s}{{\"scenario\":\"{s}\",\"frames\":{d},\"notches_per_frame\":{d}," ++
+ "\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d},\"p99_ns\":{d},\"max_ns\":{d},\"total_ns\":{d}," ++
+ "\"rasterizations\":{d},\"rasters_retained\":{d},\"images\":{d}," ++
+ "\"visual\":\"{x:0>16}\",\"transport\":\"{x:0>16}\"}}",
+ .{
+ if (i == 0) "" else ",",
+ result.name,
+ result.frames,
+ result.ticks_per_frame,
+ result.min_ns,
+ result.median_ns,
+ result.p90_ns,
+ result.p99_ns,
+ result.max_ns,
+ result.total_ns,
+ result.rasterizations,
+ result.rasters_retained,
+ result.identity.images,
+ result.identity.visual,
+ result.identity.transport,
+ },
+ ) catch return;
+ out.writeAll("]}\n") catch return;
+ std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {};
+}
+
+fn fatal(comptime format: []const u8, args: anytype) noreturn {
+ std.debug.print("pdf-scroll-bench: " ++ format ++ "\n", args);
+ std.process.exit(1);
+}
+
+comptime {
+ // The bench links the real MuPDF wrapper the core uses; keep the import
+ // load-bearing so a build that silently dropped it fails here.
+ _ = pdf.Document;
+}
diff --git a/test/snapshots/cornerdrag-left.golden b/test/snapshots/cornerdrag-left.golden
new file mode 100644
index 00000000..b87651b1
--- /dev/null
+++ b/test/snapshots/cornerdrag-left.golden
@@ -0,0 +1,155 @@
+== snap right-split grid=100x30 cursor=54,8
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/cornerdrag-left/cwd New Del /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ls ls
+| w.txt w.txt
+|
+| /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ls
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ls
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap hover-corner grid=100x30 cursor=54,8
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/cornerdrag-left/cwd New Del ╎ /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ls ╎ ls
+| w.txt ╎ w.txt
+| ╎╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| ╎ /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ╎ ls
+| ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| /tmp/pardes-snap/cornerdrag-left/cwd New Del ╎
+| ls ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap hover-left-corner grid=100x30 cursor=54,8
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/cornerdrag-left/cwd New Del ╎ /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ls ╎ ls
+| w.txt ╎ w.txt
+| ╎
+| ╎ /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ╎ ls
+| ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| /tmp/pardes-snap/cornerdrag-left/cwd New Del ╎
+| ls ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap corner-drag grid=100x30 cursor=54,8
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/cornerdrag╎left/cwd New Del /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ls ╎ ls
+| w.txt ╎ w.txt
+| ╎
+| ╎ /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ╎ ls
+| ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| ╎
+| ╎
+| ╎
+| ╎
+| /tmp/pardes-snap/cornerdrag╎left/cwd New Del
+| ls ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap corner-done grid=100x30 cursor=34,15
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/cornerdrag- /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ls ls
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/cornerdrag-left/cwd New Del
+| ls
+| w.txt
+|
+| /tmp/pardes-snap/cornerdrag-
+| ls
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/cornerdrag-left.snap b/test/snapshots/cornerdrag-left.snap
new file mode 100644
index 00000000..c90dbc76
--- /dev/null
+++ b/test/snapshots/cornerdrag-left.snap
@@ -0,0 +1,41 @@
+# corner grabs on the RIGHT column's side of the v border. The v handle is the
+# LEFT column's last cell, so that column's own h hint runs THROUGH it while the
+# right column's hints start one cell further right — the crossing there reads as
+# a T butting into the junction rather than a full cross, and it is grabbable all
+# the same: one drag moves the column edge and the RIGHT column's pane edge.
+# Layout: windowops' -n 3 (col 0 x 0 w 50, panes on rows 1-15 and 16-29; col 1
+# x 50 w 50), then focus right and Alt-n so col 1 holds two panes as well, split
+# at ITS own row. The two corners on col 49 are then row 16 (col 0's seam, the
+# one that already worked) and row 5 (col 1's, the one this script is about).
+file w.txt x
+start 30 100 -n 3
+wait 8000 w.txt
+stable 700 20000
+# a second pane in the RIGHT column
+key c-w l
+stable 400 5000
+key a-n
+stable 700 15000
+snap right-split
+# hover col 1's corner: the v hint runs full height AND col 1's h hint lights,
+# from col 51 rightward. Before this that row was dark on the handle — only the
+# hovered column's seam lit, and the hovered cell belongs to col 0
+motion 50 5
+stable 400 5000
+snap hover-corner
+# CONTROL: col 0's own seam is still a left-column corner, its h hint drawn
+# through the handle from col 1 to the handle itself, unchanged
+motion 50 16
+stable 400 5000
+snap hover-left-corner
+# grab col 1's corner and drag down-left: the column preview and col 1's row
+# preview both follow the one mouse, the row dashes lying to the RIGHT of the v
+# line because they are the right column's
+press left 50 5
+drag left 30 12
+stable 400 5000
+snap corner-drag
+release left 30 12
+motion 45 25
+stable 500 8000
+snap corner-done
diff --git a/test/snapshots/grep.golden b/test/snapshots/grep.golden
index 846ab703..af4c4708 100644
--- a/test/snapshots/grep.golden
+++ b/test/snapshots/grep.golden
@@ -153,7 +153,7 @@
| 2 a.txt:3:6-11 last TARGET line
| 3 sub/b.txt:2:1-6 TARGET inside sub
| 4
-== snap nomatch grid=180x30 cursor=37,28
+== snap nomatch grid=180x30 cursor=62,3
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/grep/cwd/a.txt Save New Del /tmp/pardes-snap/grep/cwd New Del /tmp/pardes-snap/grep/cwd New Del
| 1 hello TARGET one ls ls
diff --git a/test/snapshots/grep.snap b/test/snapshots/grep.snap
index 1d3efc9e..ab708a25 100644
--- a/test/snapshots/grep.snap
+++ b/test/snapshots/grep.snap
@@ -6,7 +6,8 @@
# looking it SELECTS the hit — with the path RELATIVE to the directory
# of the pane that asked — the same directory its results buffer is named in,
# so the short name resolves; only a hit from outside that tree stays absolute.
-# n/N SELECT the next/prev row like any other result list and open nothing;
+# n/N SELECT the next/prev row like any other result list and open nothing —
+# one press per row, the location at its head, never the matched text after it;
# Enter looks what they leave selected, and a row pointing into a file nothing
# has open yet opens it.
dirmk sub
@@ -62,10 +63,12 @@ wait 10000 TARGET inside sub
stable 700 15000
snap opened-hit
# a pattern nothing holds: an empty list of its own, with nothing in it for n
-# to stop on — so the ring carries that step back into the TARGET list. The
-# look above is why both of those read the way they do: it left the cursor in
-# sub/b.txt, so the empty list is named in sub/ rather than cwd/, and it left
-# the hit SELECTED there (a ranged result row is one a look selects).
+# to stop on — so the ring carries that step out of it and on round. It lands
+# in the SHELL, whose `ls` row is free text and steps word by word: the TARGET
+# list is standing on its last row, and a row-grained walk has nothing left in
+# it to offer. The look above is why these read the way they do: it left the
+# cursor in sub/b.txt, so the empty list is named in sub/ rather than cwd/, and
+# it left the hit SELECTED there (a ranged result row is one a look selects).
# Armed from the LEADER, not the topbar word, because of that selection: a
# topbar execute chords whatever is selected into its argument, so a
# middle-click here would run `Grep TARGET` and never ask for a pattern at
diff --git a/test/snapshots/jumps.snap b/test/snapshots/jumps.snap
index db131bd4..d7d44967 100644
--- a/test/snapshots/jumps.snap
+++ b/test/snapshots/jumps.snap
@@ -64,19 +64,20 @@ snap last-back
key space j l
stable 700 15000
snap jumplist
-# n/N select the next/prev look-able span and Enter looks it, exactly as they
-# do a +Search: the stepped span is selected in the buffer (snapstyle) and the
-# Enter after it moves the pane the row names. Row 1 is the shell (no path, so
+# n/N select the next/prev row and Enter looks it, exactly as they do a
+# +Search: the stepped span is selected in the buffer (snapstyle) and the Enter
+# after it moves the pane the row names. Row 1 is the shell (no path, so
# `@pN`), row 2 the file at line 1, row 4 the file at line 100 — so the body
# visibly rides the list.
#
-# SPANS, not rows, and this buffer is the one place in the suite where the two
-# differ: an `@pN` row's trailing text is the pane's DIRECTORY, which is a
-# look-able word of its own, so row 1 and row 3 each hold TWO stops and
-# reaching the next row takes two steps. A file row's text is its content line
-# (`line 1`), which resolves to nothing, so those hold one. Stepping words
-# rather than rows is what makes the walk the same motion in a results buffer
-# and in the middle of a paragraph, and this is the price of that.
+# ONE PRESS PER ROW, and this buffer is where that rule earns itself: an `@pN`
+# row's trailing text is the pane's DIRECTORY, which is a look-able word of its
+# own, so rows 1 and 3 used to hold TWO stops each and reaching the next row
+# took two steps. A results buffer is a LIST — its rows are locations, and the
+# text after one says what is THERE rather than naming another place to go — so
+# the walk takes the location at the head of a row and moves on to the next row
+# (output_pane.Grain.line). Free text still steps every word: that is the
+# terminal walk in ttylook, and it is why the two goldens differ.
#
# An `@pN` row moves focus like any other, which it did not use to: the STEP
# only selects now, and the look Enter runs goes to the pane by the same route
@@ -86,26 +87,20 @@ key enter
stable 700 15000
snap step1-shell
snapstyle step1-shell
-# past the directory on row 1, then onto row 2
-key n
-stable 400 5000
+# on to row 2 — one press, the directory on row 1 is no longer a stop of its own
key n
key enter
stable 700 15000
snap step2-file
-# row 3 is the shell again, both its spans, and then row 4
-key n
-stable 400 5000
+# row 3 is the shell again, then row 4
key n
stable 400 5000
key n
key enter
stable 700 15000
snap step4-line100
-# N is the exact inverse: the same two spans of row 3 in the other order, so
-# two presses land back on its location and Enter follows it to the shell
-key N
-stable 400 5000
+# N is the exact inverse: one press back onto row 3's location, and Enter
+# follows it to the shell
key N
key enter
stable 700 15000
diff --git a/test/snapshots/stepgrain.golden b/test/snapshots/stepgrain.golden
new file mode 100644
index 00000000..f269c865
--- /dev/null
+++ b/test/snapshots/stepgrain.golden
@@ -0,0 +1,310 @@
+== snap results grid=150x30 cursor=7,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/stepgrain/cwd/a.txt Save New Del
+| 1 hello ZQMARK one
+| 2 see also sub/b.txt for ZQMARK two
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/stepgrain/cwd/+Search New Del
+| 1 a.txt:1:7-12 hello ZQMARK one
+| 2 a.txt:2:24-29 see also sub/b.txt for ZQMARK two
+| 3 sub/b.txt:1:1-6 ZQMARK inside sub
+== snap step-row1 grid=150x30 cursor=7,27
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/stepgrain/cwd/a.txt Save New Del
+| 1 hello ZQMARK one
+| 2 see also sub/b.txt for ZQMARK two
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/stepgrain/cwd/+Search New Del
+| 1 a.txt:1:7-12 hello ZQMARK one
+| 2 a.txt:2:24-29 see also sub/b.txt for ZQMARK two
+| 3 sub/b.txt:1:1-6 ZQMARK inside sub
+== style step-row1
+|0: 0-56 #949494,#121212, 57-149 d,#121212,
+|1: 0-1 #ffffff,#262626, 2-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-22 #bdbdbd,#080808, 23-149 d,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-39 #bdbdbd,#080808, 40-149 d,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|26: 0-1 #ffffff,#626262, 2-129 #949494,#121212, 130-149 d,#121212,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-18 #080808,#80a0ff, 19-35 #bdbdbd,#080808, 36-149 d,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-53 #bdbdbd,#080808, 54-149 d,#080808,
+|29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-39 #bdbdbd,#080808, 40-149 d,#080808,
+== snap step-row2 grid=150x30 cursor=7,28
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/stepgrain/cwd/a.txt Save New Del
+| 1 hello ZQMARK one
+| 2 see also sub/b.txt for ZQMARK two
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/stepgrain/cwd/+Search New Del
+| 1 a.txt:1:7-12 hello ZQMARK one
+| 2 a.txt:2:24-29 see also sub/b.txt for ZQMARK two
+| 3 sub/b.txt:1:1-6 ZQMARK inside sub
+== style step-row2
+|0: 0-56 #949494,#121212, 57-149 d,#121212,
+|1: 0-1 #ffffff,#262626, 2-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-22 #bdbdbd,#080808, 23-149 d,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-39 #bdbdbd,#080808, 40-149 d,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|26: 0-1 #ffffff,#626262, 2-129 #949494,#121212, 130-149 d,#121212,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-35 #bdbdbd,#080808, 36-149 d,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-19 #080808,#80a0ff, 20-53 #bdbdbd,#080808, 54-149 d,#080808,
+|29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-39 #bdbdbd,#080808, 40-149 d,#080808,
+== snap step-row3 grid=150x30 cursor=7,28
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/stepgrain/cwd/a.txt Save New Del
+| 1 hello ZQMARK one
+| 2 see also sub/b.txt for ZQMARK two
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/stepgrain/cwd/+Search New Del
+| 2 a.txt:2:24-29 see also sub/b.txt for ZQMARK two
+| 3 sub/b.txt:1:1-6 ZQMARK inside sub
+| 4
+== snap step-wrap grid=150x30 cursor=7,27
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/stepgrain/cwd/a.txt Save New Del
+| 1 hello ZQMARK one
+| 2 see also sub/b.txt for ZQMARK two
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/stepgrain/cwd/+Search New Del
+| 1 a.txt:1:7-12 hello ZQMARK one
+| 2 a.txt:2:24-29 see also sub/b.txt for ZQMARK two
+| 3 sub/b.txt:1:1-6 ZQMARK inside sub
+== snap blank-in-path grid=150x30 cursor=7,25
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/stepgrain/cwd/a.txt Save New Del
+| 1 hello ZQMARK one
+| 2 see also sub/b.txt for ZQMARK two
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/stepgrain/cwd/+Search New Del
+| 1 two words.txt
+| /tmp/pardes-snap/stepgrain/cwd/+Search New Del
+| 1 a.txt:1:7-12 hello ZQMARK one
+| 2 a.txt:2:24-29 see also sub/b.txt for ZQMARK two
+| 3 sub/b.txt:1:1-6 ZQMARK inside sub
+== style blank-in-path
+|0: 0-56 #949494,#121212, 57-149 d,#121212,
+|1: 0-1 #ffffff,#262626, 2-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-22 #bdbdbd,#080808, 23-149 d,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-39 #bdbdbd,#080808, 40-149 d,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-149 d,#080808,
+|24: 0-1 #ffffff,#626262, 2-129 #949494,#121212, 130-149 d,#121212,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-19 #080808,#80a0ff, 20-149 d,#080808,
+|26: 0-1 #ffffff,#262626, 2-129 #949494,#121212, 130-149 d,#121212,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-18 #080808,#80a0ff, 19-35 #bdbdbd,#080808, 36-149 d,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-53 #bdbdbd,#080808, 54-149 d,#080808,
+|29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-39 #bdbdbd,#080808, 40-149 d,#080808,
+== snap blank-opened grid=150x30 cursor=7,14
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/stepgrain/cwd/a.txt Save New Del
+| 1 hello ZQMARK one
+| 2 see also sub/b.txt for ZQMARK two
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/stepgrain/cwd/two words.txt Save New Del
+| 1
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/stepgrain/cwd/+Search New Del
+| 1 two words.txt
+| /tmp/pardes-snap/stepgrain/cwd/+Search New Del
+| 1 a.txt:1:7-12 hello ZQMARK one
+| 2 a.txt:2:24-29 see also sub/b.txt for ZQMARK two
+| 3 sub/b.txt:1:1-6 ZQMARK inside sub
diff --git a/test/snapshots/stepgrain.snap b/test/snapshots/stepgrain.snap
new file mode 100644
index 00000000..a12d7bbd
--- /dev/null
+++ b/test/snapshots/stepgrain.snap
@@ -0,0 +1,77 @@
+# WHAT ONE PRESS OF `n` TAKES, and why that is not one answer. A pane's rows
+# decide it (output_pane.Grain, read in Pardes.lookSpanIn):
+#
+# free text — a terminal, a file, a PDF — is stepped WORD by word, several
+# stops to a line, because a line of it may name several places at once (the
+# `ls` row in ttylook.snap hops big.txt -> plain.txt -> sub);
+#
+# a RESULTS BUFFER is stepped ROW by row, on the location at the head of the
+# row, because a row there IS one location and the words after it are the
+# MATCHED TEXT — the same hit, spelled out. That text is full of look-able
+# words, and stopping on them was stopping on the same result twice.
+#
+# This script is the second rule. Row 2 below matched on a line that mentions
+# `sub/b.txt`, so the old word-grained walk stopped there before it ever
+# reached row 3; now `n` presses and rows correspond one to one, and the fourth
+# press wraps the ring back to row 1.
+#
+# LARGEST from the head, so a path with a BLANK in it is one span rather than
+# the word in front of it: `two words.txt` selects whole and opens.
+dirmk sub
+file a.txt hello ZQMARK one\nsee also sub/b.txt for ZQMARK two
+file sub/b.txt ZQMARK inside sub
+run touch 'two words.txt'
+start 30 150 a.txt
+wait 8000 hello ZQMARK
+stable 700 20000
+# Grep from the leader: three hits, sorted by path, the empty file contributing
+# none. Row 2 is the one whose TEXT holds a path.
+key space
+key f
+key g
+stable 400 5000
+text ZQMARK
+key enter
+wait 10000 sub/b.txt:1:1
+stable 700 15000
+snap results
+# one press, one row: the head of row 1...
+key n
+stable 400 5000
+snap step-row1
+snapstyle step-row1
+# ...the head of row 2, and NOT the `sub/b.txt` sitting in its matched text —
+# the cursor column is the assertion
+key n
+stable 400 5000
+snap step-row2
+snapstyle step-row2
+# ...row 3, and then the ring comes round to row 1 again
+key n
+stable 400 5000
+snap step-row3
+key n
+stable 400 5000
+snap step-wrap
+# Back into the file, so the Find below is asked from a pane that is not a
+# results buffer (fillResults never refills the buffer it was asked from).
+press left 8 3
+release left 8 3
+stable 400 5000
+# A path with a blank in it: the row is one span from its first cell, because
+# the walk asks about the LONGEST run of the row first and the filesystem is
+# what vouches for it. Enter then opens exactly what is selected.
+key space
+key f
+key f
+text words
+key enter
+wait 10000 two words.txt
+stable 700 15000
+key n
+stable 400 5000
+snap blank-in-path
+snapstyle blank-in-path
+key enter
+stable 700 15000
+snap blank-opened
diff --git a/test/snapshots/ttytaken.golden b/test/snapshots/ttytaken.golden
new file mode 100644
index 00000000..960081d1
--- /dev/null
+++ b/test/snapshots/ttytaken.golden
@@ -0,0 +1,124 @@
+== snap opened grid=100x30 cursor=7,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/ttytaken/cwd/cmd.txt Save New D /tmp/pardes-snap/ttytaken/cwd New Del
+| 1 echo TAKEN''ONE ls
+| 2 echo TAKEN''TWO big.txt cmd.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap at-prompt grid=100x30 cursor=7,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/ttytaken/cwd/cmd.txt Save New D /tmp/pardes-snap/ttytaken/cwd New Del
+| 1 echo TAKEN''ONE ls
+| 2 echo TAKEN''TWO big.txt cmd.txt
+| echo TAKEN''ONE
+| TAKENONE
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap paging grid=100x30 cursor=59,29
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/ttytaken/cwd/cmd.txt Save New D$ /tmp/pardes-snap/ttytaken/cwd New Del
+| 1 echo TAKEN''ONE line 1
+| 2 echo TAKEN''TWO line 2
+| line 3
+| line 4
+| line 5
+| line 6
+| line 7
+| line 8
+| line 9
+| line 10
+| line 11
+| line 12
+| line 13
+| line 14
+| line 15
+| line 16
+| line 17
+| line 18
+| line 19
+| line 20
+| line 21
+| line 22
+| line 23
+| line 24
+| line 25
+| line 26
+| line 27
+| big.txt
+== snap spawned grid=100x30 cursor=53,25
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/ttytaken/cwd/cmd.txt Save New D$ /tmp/pardes-snap/ttytaken/cwd New Del
+| 1 echo TAKEN''ONE line 1
+| 2 echo TAKEN''TWO line 2
+| line 3
+| line 4
+| line 5
+| line 6
+| line 7
+| line 8
+| line 9
+| line 10
+| line 11
+| line 12
+| line 13
+| line 14
+| line 15
+| line 16
+| line 17
+| line 18
+| line 19
+| line 20
+| line 21
+| line 22
+| line 23
+| :
+| /tmp/pardes-snap/ttytaken/cwd New Del
+| echo TAKEN''TWO
+| TAKENTWO
+|
diff --git a/test/snapshots/ttytaken.snap b/test/snapshots/ttytaken.snap
new file mode 100644
index 00000000..6505fc63
--- /dev/null
+++ b/test/snapshots/ttytaken.snap
@@ -0,0 +1,43 @@
+# Process monitoring, end to end. A pane whose tty is held by a full-screen
+# program is not somewhere a command line may be typed: the SAME middle-drag
+# Exec goes into the shell while it sits at its prompt, and into a NEW shell
+# beside it once `less` owns the terminal — where it runs, instead of becoming
+# keystrokes the pager would eat. Nothing about the pager's screen changes.
+file cmd.txt echo TAKEN''ONE\necho TAKEN''TWO
+lines big.txt 40
+start 30 100 -n 1
+wait 8000 cmd.txt
+stable 700 20000
+# open the command file by looking its name in the greeting `ls`
+press right 14 4
+release right 14 4
+wait 10000 echo TAKEN
+stable 700 20000
+snap opened
+# at the prompt: the drag runs in the shell that is already on this directory
+press middle 8 3
+drag middle 22 3
+release middle 22 3
+wait 10000 TAKENONE
+stable 700 20000
+snap at-prompt
+# ...and now a pager owns that terminal. Ctrl-b is raw tty (config.tty_toggle),
+# which is how a script types AT the shell instead of into the pane's own edit
+# buffer — the same way exec.snap runs a command line.
+press left 60 8
+release left 60 8
+stable 400 5000
+key c-b
+stable 400 5000
+text env LESS= less big.txt
+key enter
+wait 10000 line 1
+stable 700 20000
+snap paging
+# the same gesture on the second line: a new shell, not `less` keystrokes
+press middle 8 4
+drag middle 22 4
+release middle 22 4
+wait 10000 TAKENTWO
+stable 700 20000
+snap spawned